feat: add Telegram Topics (forum mode) support
- buildJid() constructs tg:{chatId}:{threadId} for topic messages
- parseJid() extracts chatId + threadId from JID for outbound routing
- /chatid command shows thread ID in forum topics
- sendMessage and setTyping pass message_thread_id when present
- All message handlers (text, photo, voice, media) use thread-aware JIDs
Allows each forum topic to be registered as an independent Nanoclaw group.
This commit is contained in:
77
src/db.ts
77
src/db.ts
@@ -106,19 +106,6 @@ function createSchema(database: Database.Database): void {
|
||||
/* column already exists */
|
||||
}
|
||||
|
||||
// Add is_main column if it doesn't exist (migration for existing DBs)
|
||||
try {
|
||||
database.exec(
|
||||
`ALTER TABLE registered_groups ADD COLUMN is_main INTEGER DEFAULT 0`,
|
||||
);
|
||||
// Backfill: existing rows with folder = 'main' are the main group
|
||||
database.exec(
|
||||
`UPDATE registered_groups SET is_main = 1 WHERE folder = 'main'`,
|
||||
);
|
||||
} catch {
|
||||
/* column already exists */
|
||||
}
|
||||
|
||||
// Add channel and is_group columns if they don't exist (migration for existing DBs)
|
||||
try {
|
||||
database.exec(`ALTER TABLE chats ADD COLUMN channel TEXT`);
|
||||
@@ -158,6 +145,10 @@ export function _initTestDatabase(): void {
|
||||
createSchema(db);
|
||||
}
|
||||
|
||||
export function closeDatabase(): void {
|
||||
db?.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Store chat metadata only (no message content).
|
||||
* Used for all chats to enable group discovery without storing sensitive content.
|
||||
@@ -276,7 +267,7 @@ export function storeMessage(msg: NewMessage): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a message directly.
|
||||
* Store a message directly (for non-WhatsApp channels that don't use Baileys proto).
|
||||
*/
|
||||
export function storeMessageDirect(msg: {
|
||||
id: string;
|
||||
@@ -306,29 +297,24 @@ export function getNewMessages(
|
||||
jids: string[],
|
||||
lastTimestamp: string,
|
||||
botPrefix: string,
|
||||
limit: number = 200,
|
||||
): { messages: NewMessage[]; newTimestamp: string } {
|
||||
if (jids.length === 0) return { messages: [], newTimestamp: lastTimestamp };
|
||||
|
||||
const placeholders = jids.map(() => '?').join(',');
|
||||
// Filter bot messages using both the is_bot_message flag AND the content
|
||||
// prefix as a backstop for messages written before the migration ran.
|
||||
// Subquery takes the N most recent, outer query re-sorts chronologically.
|
||||
const sql = `
|
||||
SELECT * FROM (
|
||||
SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me
|
||||
FROM messages
|
||||
WHERE timestamp > ? AND chat_jid IN (${placeholders})
|
||||
AND is_bot_message = 0 AND content NOT LIKE ?
|
||||
AND content != '' AND content IS NOT NULL
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
) ORDER BY timestamp
|
||||
SELECT id, chat_jid, sender, sender_name, content, timestamp
|
||||
FROM messages
|
||||
WHERE timestamp > ? AND chat_jid IN (${placeholders})
|
||||
AND is_bot_message = 0 AND content NOT LIKE ?
|
||||
AND content != '' AND content IS NOT NULL
|
||||
ORDER BY timestamp
|
||||
`;
|
||||
|
||||
const rows = db
|
||||
.prepare(sql)
|
||||
.all(lastTimestamp, ...jids, `${botPrefix}:%`, limit) as NewMessage[];
|
||||
.all(lastTimestamp, ...jids, `${botPrefix}:%`) as NewMessage[];
|
||||
|
||||
let newTimestamp = lastTimestamp;
|
||||
for (const row of rows) {
|
||||
@@ -342,25 +328,20 @@ export function getMessagesSince(
|
||||
chatJid: string,
|
||||
sinceTimestamp: string,
|
||||
botPrefix: string,
|
||||
limit: number = 200,
|
||||
): NewMessage[] {
|
||||
// Filter bot messages using both the is_bot_message flag AND the content
|
||||
// prefix as a backstop for messages written before the migration ran.
|
||||
// Subquery takes the N most recent, outer query re-sorts chronologically.
|
||||
const sql = `
|
||||
SELECT * FROM (
|
||||
SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me
|
||||
FROM messages
|
||||
WHERE chat_jid = ? AND timestamp > ?
|
||||
AND is_bot_message = 0 AND content NOT LIKE ?
|
||||
AND content != '' AND content IS NOT NULL
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
) ORDER BY timestamp
|
||||
SELECT id, chat_jid, sender, sender_name, content, timestamp
|
||||
FROM messages
|
||||
WHERE chat_jid = ? AND timestamp > ?
|
||||
AND is_bot_message = 0 AND content NOT LIKE ?
|
||||
AND content != '' AND content IS NOT NULL
|
||||
ORDER BY timestamp
|
||||
`;
|
||||
return db
|
||||
.prepare(sql)
|
||||
.all(chatJid, sinceTimestamp, `${botPrefix}:%`, limit) as NewMessage[];
|
||||
.all(chatJid, sinceTimestamp, `${botPrefix}:%`) as NewMessage[];
|
||||
}
|
||||
|
||||
export function createTask(
|
||||
@@ -447,9 +428,10 @@ export function updateTask(
|
||||
}
|
||||
|
||||
export function deleteTask(id: string): void {
|
||||
// Delete child records first (FK constraint)
|
||||
db.prepare('DELETE FROM task_run_logs WHERE task_id = ?').run(id);
|
||||
db.prepare('DELETE FROM scheduled_tasks WHERE id = ?').run(id);
|
||||
db.transaction(() => {
|
||||
db.prepare('DELETE FROM task_run_logs WHERE task_id = ?').run(id);
|
||||
db.prepare('DELETE FROM scheduled_tasks WHERE id = ?').run(id);
|
||||
})();
|
||||
}
|
||||
|
||||
export function getDueTasks(): ScheduledTask[] {
|
||||
@@ -526,6 +508,10 @@ export function setSession(groupFolder: string, sessionId: string): void {
|
||||
).run(groupFolder, sessionId);
|
||||
}
|
||||
|
||||
export function deleteSession(groupFolder: string): void {
|
||||
db.prepare('DELETE FROM sessions WHERE group_folder = ?').run(groupFolder);
|
||||
}
|
||||
|
||||
export function getAllSessions(): Record<string, string> {
|
||||
const rows = db
|
||||
.prepare('SELECT group_folder, session_id FROM sessions')
|
||||
@@ -553,7 +539,6 @@ export function getRegisteredGroup(
|
||||
added_at: string;
|
||||
container_config: string | null;
|
||||
requires_trigger: number | null;
|
||||
is_main: number | null;
|
||||
}
|
||||
| undefined;
|
||||
if (!row) return undefined;
|
||||
@@ -575,7 +560,6 @@ export function getRegisteredGroup(
|
||||
: undefined,
|
||||
requiresTrigger:
|
||||
row.requires_trigger === null ? undefined : row.requires_trigger === 1,
|
||||
isMain: row.is_main === 1 ? true : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -584,8 +568,8 @@ export function setRegisteredGroup(jid: string, group: RegisteredGroup): void {
|
||||
throw new Error(`Invalid group folder "${group.folder}" for JID ${jid}`);
|
||||
}
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger, is_main)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
`INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
jid,
|
||||
group.name,
|
||||
@@ -594,7 +578,6 @@ export function setRegisteredGroup(jid: string, group: RegisteredGroup): void {
|
||||
group.added_at,
|
||||
group.containerConfig ? JSON.stringify(group.containerConfig) : null,
|
||||
group.requiresTrigger === undefined ? 1 : group.requiresTrigger ? 1 : 0,
|
||||
group.isMain ? 1 : 0,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -607,7 +590,6 @@ export function getAllRegisteredGroups(): Record<string, RegisteredGroup> {
|
||||
added_at: string;
|
||||
container_config: string | null;
|
||||
requires_trigger: number | null;
|
||||
is_main: number | null;
|
||||
}>;
|
||||
const result: Record<string, RegisteredGroup> = {};
|
||||
for (const row of rows) {
|
||||
@@ -628,7 +610,6 @@ export function getAllRegisteredGroups(): Record<string, RegisteredGroup> {
|
||||
: undefined,
|
||||
requiresTrigger:
|
||||
row.requires_trigger === null ? undefined : row.requires_trigger === 1,
|
||||
isMain: row.is_main === 1 ? true : undefined,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
|
||||
Reference in New Issue
Block a user