feat: add Telegram Topics (forum mode) support
Some checks failed
Bump version / bump-version (push) Has been cancelled
Merge-forward skill branches / merge-forward (push) Has been cancelled
Update token count / update-tokens (push) Has been cancelled

- 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:
Andy
2026-03-14 12:04:06 +00:00
parent c0902877fa
commit 8e24a31bd4
26 changed files with 5190 additions and 717 deletions

View File

@@ -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;