Skip to content

Commit 6be9f7e

Browse files
Fenrurclaude
andcommitted
feat: Telegram message debounce (PR moazbuilds#31)
- Add debounceMs config for Telegram message batching - Messages from same chat within window are merged into single prompt - Preserves attachments (photos, voice, audio, documents) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 185ae43 commit 6be9f7e

File tree

2 files changed

+84
-4
lines changed

2 files changed

+84
-4
lines changed

src/commands/telegram.ts

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -920,6 +920,77 @@ async function registerBotCommands(token: string): Promise<void> {
920920
}
921921
}
922922

923+
// --- Debounce buffer ---
924+
925+
interface DebouncedEntry {
926+
messages: TelegramMessage[];
927+
timer: ReturnType<typeof setTimeout>;
928+
}
929+
930+
const debounceBuffer = new Map<number, DebouncedEntry>();
931+
932+
function flushDebounced(chatId: number): void {
933+
const entry = debounceBuffer.get(chatId);
934+
if (!entry || entry.messages.length === 0) {
935+
debounceBuffer.delete(chatId);
936+
return;
937+
}
938+
debounceBuffer.delete(chatId);
939+
940+
if (entry.messages.length === 1) {
941+
handleMessage(entry.messages[0]).catch((err) => {
942+
console.error(`[Telegram] Unhandled: ${err}`);
943+
});
944+
return;
945+
}
946+
947+
const first = entry.messages[0];
948+
const merged: TelegramMessage = {
949+
message_id: first.message_id,
950+
from: first.from,
951+
reply_to_message: first.reply_to_message,
952+
chat: first.chat,
953+
message_thread_id: first.message_thread_id,
954+
text: entry.messages
955+
.map((m) => {
956+
const { text } = getMessageTextAndEntities(m);
957+
return text;
958+
})
959+
.filter(Boolean)
960+
.join("\n"),
961+
entities: first.entities,
962+
};
963+
964+
for (const m of entry.messages) {
965+
if (m.photo && m.photo.length > 0 && !merged.photo) merged.photo = m.photo;
966+
if (m.voice && !merged.voice) merged.voice = m.voice;
967+
if (m.audio && !merged.audio) merged.audio = m.audio;
968+
if (m.document && !merged.document) merged.document = m.document;
969+
}
970+
971+
const count = entry.messages.length;
972+
debugLog(`Debounce flush: chat=${chatId} merged=${count} messages`);
973+
console.log(`[Telegram] Debounced ${count} messages from chat ${chatId}`);
974+
975+
handleMessage(merged).catch((err) => {
976+
console.error(`[Telegram] Unhandled: ${err}`);
977+
});
978+
}
979+
980+
function enqueueDebounced(message: TelegramMessage, debounceMs: number): void {
981+
const chatId = message.chat.id;
982+
const existing = debounceBuffer.get(chatId);
983+
984+
if (existing) {
985+
clearTimeout(existing.timer);
986+
existing.messages.push(message);
987+
existing.timer = setTimeout(() => flushDebounced(chatId), debounceMs);
988+
} else {
989+
const timer = setTimeout(() => flushDebounced(chatId), debounceMs);
990+
debounceBuffer.set(chatId, { messages: [message], timer });
991+
}
992+
}
993+
923994
// --- Polling loop ---
924995

925996
let running = true;
@@ -941,6 +1012,7 @@ async function poll(): Promise<void> {
9411012

9421013
console.log("Telegram bot started (long polling)");
9431014
console.log(` Allowed users: ${config.allowedUserIds.length === 0 ? "all" : config.allowedUserIds.join(", ")}`);
1015+
if (config.debounceMs > 0) console.log(` Debounce: ${config.debounceMs}ms`);
9441016
if (telegramDebug) console.log(" Debug: enabled");
9451017

9461018
// Register available skills as bot command menu (non-blocking)
@@ -968,9 +1040,13 @@ async function poll(): Promise<void> {
9681040
update.edited_channel_post,
9691041
].filter((m): m is TelegramMessage => Boolean(m));
9701042
for (const incoming of incomingMessages) {
971-
handleMessage(incoming).catch((err) => {
972-
console.error(`[Telegram] Unhandled: ${err}`);
973-
});
1043+
if (config.debounceMs > 0) {
1044+
enqueueDebounced(incoming, config.debounceMs);
1045+
} else {
1046+
handleMessage(incoming).catch((err) => {
1047+
console.error(`[Telegram] Unhandled: ${err}`);
1048+
});
1049+
}
9741050
}
9751051
if (update.my_chat_member) {
9761052
handleMyChatMember(update.my_chat_member).catch((err) => {

src/config.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ const DEFAULT_SETTINGS: Settings = {
2929
excludeWindows: [],
3030
forwardToTelegram: true,
3131
},
32-
telegram: { token: "", allowedUserIds: [], receiveEnabled: true },
32+
telegram: { token: "", allowedUserIds: [], receiveEnabled: true, debounceMs: 0 },
3333
discord: { token: "", allowedUserIds: [], listenChannels: [] },
3434
security: { level: "moderate", allowedTools: [], disallowedTools: [] },
3535
web: { enabled: false, host: "127.0.0.1", port: 4632 },
@@ -56,6 +56,9 @@ export interface TelegramConfig {
5656
allowedUserIds: number[];
5757
/** When false, skip Telegram polling (incoming messages). Useful for send-only instances. Default: true */
5858
receiveEnabled: boolean;
59+
/** Debounce window in ms. Messages from the same chat arriving within this
60+
* window are merged into a single prompt. 0 = disabled (default). */
61+
debounceMs: number;
5962
}
6063

6164
export interface DiscordConfig {
@@ -172,6 +175,7 @@ function parseSettings(raw: Record<string, any>, discordUserIds?: string[]): Set
172175
token: raw.telegram?.token ?? "",
173176
allowedUserIds: raw.telegram?.allowedUserIds ?? [],
174177
receiveEnabled: raw.telegram?.receiveEnabled !== false,
178+
debounceMs: Number.isFinite(raw.telegram?.debounceMs) ? Number(raw.telegram.debounceMs) : 0,
175179
},
176180
discord: {
177181
token: typeof raw.discord?.token === "string" ? raw.discord.token.trim() : "",

0 commit comments

Comments
 (0)