grok-telegram-bot 2.4.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +38 -2
- package/CHANGELOG.md +190 -1
- package/README.md +60 -15
- package/docs/GROUP.md +260 -0
- package/docs/INSTALL.md +3 -0
- package/package.json +4 -4
- package/src/app/lifetime-flag.ts +20 -0
- package/src/app/settings-store.ts +47 -8
- package/src/app/types.ts +38 -1
- package/src/app/updater.ts +24 -3
- package/src/bot/auth.ts +100 -15
- package/src/bot/bot.ts +193 -17
- package/src/bot/chat-controller.ts +181 -18
- package/src/bot/commands.ts +69 -29
- package/src/bot/deps.ts +3 -0
- package/src/bot/group-memory.ts +339 -0
- package/src/bot/handlers/accounts.ts +7 -0
- package/src/bot/handlers/control.ts +85 -32
- package/src/bot/handlers/document.ts +31 -4
- package/src/bot/handlers/forum.ts +217 -0
- package/src/bot/handlers/menu.ts +86 -24
- package/src/bot/handlers/message.ts +247 -27
- package/src/bot/handlers/photo.ts +126 -16
- package/src/bot/handlers/running.ts +150 -24
- package/src/bot/handlers/session-card.ts +13 -5
- package/src/bot/handlers/sessions.ts +68 -18
- package/src/bot/handlers/voice.ts +52 -7
- package/src/bot/image-return.ts +11 -5
- package/src/bot/manager-context.ts +208 -0
- package/src/bot/manager-jobs.ts +142 -0
- package/src/bot/menu/ephemeral.ts +16 -3
- package/src/bot/menu/keyboard.ts +53 -14
- package/src/bot/menu/refresh.ts +3 -1
- package/src/bot/menu/status-panel.ts +12 -6
- package/src/bot/permission-service.ts +19 -0
- package/src/bot/prompt-anchor.ts +299 -0
- package/src/bot/prompt-content.ts +8 -0
- package/src/bot/registry.ts +94 -1
- package/src/bot/scope.ts +95 -0
- package/src/bot/session-runtime.ts +1280 -183
- package/src/bot/suggestions.ts +91 -31
- package/src/bot/telegram-actions.ts +1130 -0
- package/src/bot/telegram-bots.ts +496 -0
- package/src/bot/telegram-io.ts +97 -10
- package/src/cli.ts +2 -0
- package/src/config.ts +201 -2
- package/src/forum/bind-path.ts +146 -0
- package/src/forum/manager.ts +652 -0
- package/src/forum/project-icon.ts +142 -0
- package/src/forum/thread.ts +49 -0
- package/src/forum/topic-store.ts +114 -0
- package/src/forum/types.ts +29 -0
- package/src/grok/client.ts +130 -28
- package/src/index.ts +205 -75
- package/src/projects/manager.ts +16 -3
- package/src/render/chunk.ts +17 -10
- package/src/render/hashtags.ts +5 -1
- package/src/render/manager-directive.ts +137 -0
- package/src/render/session-comment.ts +74 -7
- package/src/render/telegram-bridge.ts +464 -0
- package/src/render/tool-call.ts +56 -37
- package/src/service/platform.ts +44 -7
- package/src/service/windows.ts +16 -4
- package/src/sessions/history.ts +68 -9
- package/src/sessions/process.ts +7 -0
- package/src/sessions/types.ts +2 -2
- package/src/stream/streamer.ts +62 -15
- package/scripts/analyze-jsonl.ts +0 -33
- package/scripts/delayed-restart.ps1 +0 -29
- package/scripts/probe-exit-response-shape.py +0 -77
- package/scripts/probe-plan-exit.py +0 -60
- package/scripts/probe-plan-exit2.py +0 -48
- package/scripts/probe-plan-fields.py +0 -41
- package/scripts/probe-plan-fields2.py +0 -58
- package/scripts/probe-plan-response-path.py +0 -48
- package/scripts/sample-claude-tooluse.ts +0 -21
- package/scripts/sample-kiro-events.ts +0 -31
- package/scripts/smoke-exit-plan.ts +0 -274
- package/scripts/smoke-exit-shapes.ts +0 -252
- package/scripts/smoke-import.mjs +0 -82
- package/scripts/smoke-import.ts +0 -73
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Allowlisted sibling Telegram bots — optional command catalogs + invoke
|
|
3
|
+
* slash commands like lightweight MCP tools.
|
|
4
|
+
*
|
|
5
|
+
* Reply capture rules:
|
|
6
|
+
* - Only messages/edits from a bot we *just* triggered (pending wait) count.
|
|
7
|
+
* - Prefer replies to our trigger message_id; also accept non-reply messages
|
|
8
|
+
* from that bot in the same chat/thread after the trigger was sent.
|
|
9
|
+
* - Streaming bots often edit one message: we listen for edited_message and
|
|
10
|
+
* wait until activity settles (idle window) before resolving — Bot API does
|
|
11
|
+
* not expose other bots' typing indicators, so settle-after-idle is the
|
|
12
|
+
* practical equivalent of "wait until typing finishes".
|
|
13
|
+
* - Hard timeout: if any content arrived, return it as partial; else error.
|
|
14
|
+
* Session always continues (not a Done for the Grok turn).
|
|
15
|
+
*/
|
|
16
|
+
import type { Api, Bot, Context } from "grammy";
|
|
17
|
+
import type { AppConfig } from "../config.js";
|
|
18
|
+
import { createLogger } from "../logger.js";
|
|
19
|
+
import { normalizeUsername } from "../render/telegram-bridge.js";
|
|
20
|
+
|
|
21
|
+
const log = createLogger("telegram-bots");
|
|
22
|
+
|
|
23
|
+
export interface SiblingBotCommand {
|
|
24
|
+
/** Command without leading slash. */
|
|
25
|
+
command: string;
|
|
26
|
+
/** Optional short description. */
|
|
27
|
+
description?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SiblingBotInfo {
|
|
31
|
+
username: string;
|
|
32
|
+
id?: number;
|
|
33
|
+
inGroup: boolean;
|
|
34
|
+
status?: string;
|
|
35
|
+
note?: string;
|
|
36
|
+
/** From TELEGRAM_BOT_COMMANDS when configured. */
|
|
37
|
+
commands: SiblingBotCommand[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface BotWaitResult {
|
|
41
|
+
text: string;
|
|
42
|
+
/** True when hard timeout fired with incomplete stream content. */
|
|
43
|
+
partial: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface PendingWait {
|
|
47
|
+
chatId: number;
|
|
48
|
+
botId: number;
|
|
49
|
+
messageThreadId?: number;
|
|
50
|
+
/** Our /cmd@bot message id — preferred reply target. */
|
|
51
|
+
triggerMessageId?: number;
|
|
52
|
+
/** Only accept content after this timestamp (ms). */
|
|
53
|
+
startedAt: number;
|
|
54
|
+
settleMs: number;
|
|
55
|
+
chunks: Map<number, string>; // message_id → latest text
|
|
56
|
+
hardTimer: NodeJS.Timeout;
|
|
57
|
+
settleTimer?: NodeJS.Timeout;
|
|
58
|
+
resolve: (result: BotWaitResult) => void;
|
|
59
|
+
reject: (err: Error) => void;
|
|
60
|
+
settled: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Catalog + reply waiter for allowlisted bots.
|
|
65
|
+
* Register once with {@link attachToBot} so message/edit updates fulfill waits.
|
|
66
|
+
*/
|
|
67
|
+
export class TelegramBotService {
|
|
68
|
+
private readonly cache = new Map<string, SiblingBotInfo>();
|
|
69
|
+
private readonly pending: PendingWait[] = [];
|
|
70
|
+
|
|
71
|
+
constructor(
|
|
72
|
+
private readonly api: Api,
|
|
73
|
+
private readonly cfg: AppConfig,
|
|
74
|
+
) {}
|
|
75
|
+
|
|
76
|
+
get allowedUsernames(): string[] {
|
|
77
|
+
return this.cfg.allowedTelegramBots;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Commands configured for a bot (empty if none). */
|
|
81
|
+
commandsFor(username: string): SiblingBotCommand[] {
|
|
82
|
+
const u = normalizeUsername(username);
|
|
83
|
+
return this.cfg.telegramBotCommands[u] ?? [];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Wire message + edit middleware so bot_command waits can resolve. */
|
|
87
|
+
attachToBot(bot: Bot): void {
|
|
88
|
+
bot.on("message", (ctx, next) => {
|
|
89
|
+
this.onBotContent(ctx, "message");
|
|
90
|
+
return next();
|
|
91
|
+
});
|
|
92
|
+
bot.on("edited_message", (ctx, next) => {
|
|
93
|
+
this.onBotContent(ctx, "edited_message");
|
|
94
|
+
return next();
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
isAllowed(username: string): boolean {
|
|
99
|
+
const u = normalizeUsername(username);
|
|
100
|
+
return this.cfg.allowedTelegramBots.includes(u);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Probe each allowlisted bot (cached per process). */
|
|
104
|
+
async listBots(force = false): Promise<SiblingBotInfo[]> {
|
|
105
|
+
const out: SiblingBotInfo[] = [];
|
|
106
|
+
for (const username of this.cfg.allowedTelegramBots) {
|
|
107
|
+
if (!force && this.cache.has(username)) {
|
|
108
|
+
// Refresh commands from live config (catalog can be static; membership cached).
|
|
109
|
+
const cached = this.cache.get(username)!;
|
|
110
|
+
cached.commands = this.commandsFor(username);
|
|
111
|
+
out.push(cached);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const info = await this.probeOne(username);
|
|
115
|
+
this.cache.set(username, info);
|
|
116
|
+
out.push(info);
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Send `/command@bot args` in the target chat and wait until the bot finishes
|
|
123
|
+
* streaming (idle settle) or hard timeout. Only content from that bot after
|
|
124
|
+
* our trigger is collected — unrelated bot chatter is ignored.
|
|
125
|
+
*/
|
|
126
|
+
async invokeCommand(opts: {
|
|
127
|
+
bot: string;
|
|
128
|
+
command: string;
|
|
129
|
+
args?: string;
|
|
130
|
+
chatId: number;
|
|
131
|
+
messageThreadId?: number;
|
|
132
|
+
}): Promise<
|
|
133
|
+
| { ok: true; reply: string; partial: boolean }
|
|
134
|
+
| {
|
|
135
|
+
ok: false;
|
|
136
|
+
error: string;
|
|
137
|
+
kind: "timeout" | "send" | "resolve" | "not_allowed" | "unknown_command";
|
|
138
|
+
partialReply?: string;
|
|
139
|
+
}
|
|
140
|
+
> {
|
|
141
|
+
const username = normalizeUsername(opts.bot);
|
|
142
|
+
if (!this.isAllowed(username)) {
|
|
143
|
+
return {
|
|
144
|
+
ok: false,
|
|
145
|
+
kind: "not_allowed",
|
|
146
|
+
error: `@${username} is not in ALLOWED_TELEGRAM_BOTS`,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const cmd = opts.command.replace(/^\//, "").trim().toLowerCase();
|
|
151
|
+
if (!cmd) {
|
|
152
|
+
return { ok: false, kind: "unknown_command", error: "empty command" };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Optional catalog: unknown commands still attempt (bot may accept them) but
|
|
156
|
+
// we annotate so the agent can adjust. We do NOT hard-fail — a dead command
|
|
157
|
+
// becomes a timeout/error result, not a Done for the Grok session.
|
|
158
|
+
const catalog = this.commandsFor(username);
|
|
159
|
+
const catalogHint =
|
|
160
|
+
catalog.length > 0 && !catalog.some((c) => c.command === cmd)
|
|
161
|
+
? ` (not in TELEGRAM_BOT_COMMANDS catalog for @${username}: ${catalog.map((c) => "/" + c.command).join(", ")})`
|
|
162
|
+
: "";
|
|
163
|
+
|
|
164
|
+
const info = (await this.listBots(true)).find((b) => b.username === username);
|
|
165
|
+
if (!info?.id) {
|
|
166
|
+
return {
|
|
167
|
+
ok: false,
|
|
168
|
+
kind: "resolve",
|
|
169
|
+
error: `Could not resolve @${username} (public username required)`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const args = (opts.args ?? "").trim();
|
|
174
|
+
const text = args
|
|
175
|
+
? `/${cmd}@${username} ${args}`.slice(0, 4000)
|
|
176
|
+
: `/${cmd}@${username}`;
|
|
177
|
+
|
|
178
|
+
const extra: Record<string, unknown> = {};
|
|
179
|
+
// Omit General (1) — Bot API rejects message_thread_id=1.
|
|
180
|
+
if (opts.messageThreadId !== undefined && opts.messageThreadId !== 1) {
|
|
181
|
+
extra.message_thread_id = opts.messageThreadId;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Register waiter BEFORE send so a fast reply cannot race past us.
|
|
185
|
+
const replyPromise = this.waitForReply({
|
|
186
|
+
chatId: opts.chatId,
|
|
187
|
+
botId: info.id,
|
|
188
|
+
messageThreadId: opts.messageThreadId,
|
|
189
|
+
timeoutMs: this.cfg.telegramBotReplyTimeoutMs,
|
|
190
|
+
settleMs: this.cfg.telegramBotSettleMs,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
const sent = await this.api.sendMessage(opts.chatId, text, extra);
|
|
195
|
+
const triggerMessageId = (sent as { message_id?: number }).message_id;
|
|
196
|
+
if (triggerMessageId !== undefined) {
|
|
197
|
+
this.setTriggerMessageId(opts.chatId, info.id, triggerMessageId);
|
|
198
|
+
}
|
|
199
|
+
} catch (e) {
|
|
200
|
+
this.cancelWait(opts.chatId, info.id, "send failed");
|
|
201
|
+
return {
|
|
202
|
+
ok: false,
|
|
203
|
+
kind: "send",
|
|
204
|
+
error: `send failed: ${(e as Error).message}${catalogHint}`,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
const result = await replyPromise;
|
|
210
|
+
return {
|
|
211
|
+
ok: true,
|
|
212
|
+
reply: result.text,
|
|
213
|
+
partial: result.partial,
|
|
214
|
+
};
|
|
215
|
+
} catch (e) {
|
|
216
|
+
const msg = (e as Error).message ?? String(e);
|
|
217
|
+
const kind = /timed out/i.test(msg) ? "timeout" : "resolve";
|
|
218
|
+
return {
|
|
219
|
+
ok: false,
|
|
220
|
+
kind,
|
|
221
|
+
error: `${msg}${catalogHint}`,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private setTriggerMessageId(chatId: number, botId: number, messageId: number): void {
|
|
227
|
+
// Prefer the most recently added wait without a trigger (LIFO for sequential invokes).
|
|
228
|
+
for (let i = this.pending.length - 1; i >= 0; i--) {
|
|
229
|
+
const p = this.pending[i]!;
|
|
230
|
+
if (p.chatId === chatId && p.botId === botId && !p.settled && p.triggerMessageId === undefined) {
|
|
231
|
+
p.triggerMessageId = messageId;
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Drop pending wait(s) for this bot so the promise settles (send failure). */
|
|
238
|
+
private cancelWait(chatId: number, botId: number, reason: string): void {
|
|
239
|
+
for (let i = this.pending.length - 1; i >= 0; i--) {
|
|
240
|
+
const p = this.pending[i]!;
|
|
241
|
+
if (p.chatId === chatId && p.botId === botId && !p.settled) {
|
|
242
|
+
this.finishWait(p, i, "reject", new Error(reason));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private async probeOne(username: string): Promise<SiblingBotInfo> {
|
|
248
|
+
const commands = this.commandsFor(username);
|
|
249
|
+
const base: SiblingBotInfo = { username, inGroup: false, commands };
|
|
250
|
+
let id: number | undefined;
|
|
251
|
+
try {
|
|
252
|
+
const chat = await this.api.getChat(`@${username}`);
|
|
253
|
+
id = (chat as { id?: number }).id;
|
|
254
|
+
base.id = id;
|
|
255
|
+
} catch (e) {
|
|
256
|
+
base.note = `getChat(@${username}) failed: ${(e as Error).message}`;
|
|
257
|
+
return base;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const groupId = this.cfg.topicGroupId;
|
|
261
|
+
if (groupId === undefined || id === undefined) {
|
|
262
|
+
base.note =
|
|
263
|
+
groupId === undefined
|
|
264
|
+
? "TOPIC_GROUP_ID unset — cannot verify group membership"
|
|
265
|
+
: base.note;
|
|
266
|
+
return base;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
const member = await this.api.getChatMember(groupId, id);
|
|
271
|
+
const status = (member as { status?: string }).status ?? "unknown";
|
|
272
|
+
base.status = status;
|
|
273
|
+
base.inGroup = !["left", "kicked"].includes(status);
|
|
274
|
+
if (!base.inGroup) base.note = `not in group (status=${status})`;
|
|
275
|
+
} catch (e) {
|
|
276
|
+
base.note = `getChatMember failed: ${(e as Error).message}`;
|
|
277
|
+
}
|
|
278
|
+
return base;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private waitForReply(opts: {
|
|
282
|
+
chatId: number;
|
|
283
|
+
botId: number;
|
|
284
|
+
messageThreadId?: number;
|
|
285
|
+
timeoutMs: number;
|
|
286
|
+
settleMs: number;
|
|
287
|
+
}): Promise<BotWaitResult> {
|
|
288
|
+
return new Promise((resolve, reject) => {
|
|
289
|
+
const entry: PendingWait = {
|
|
290
|
+
chatId: opts.chatId,
|
|
291
|
+
botId: opts.botId,
|
|
292
|
+
messageThreadId: opts.messageThreadId,
|
|
293
|
+
startedAt: Date.now(),
|
|
294
|
+
settleMs: Math.max(400, opts.settleMs),
|
|
295
|
+
chunks: new Map(),
|
|
296
|
+
hardTimer: setTimeout(() => {
|
|
297
|
+
if (entry.settled) return;
|
|
298
|
+
const text = this.joinChunks(entry);
|
|
299
|
+
// Partial content on timeout → resolve so the agent still gets what arrived.
|
|
300
|
+
if (text) {
|
|
301
|
+
this.finishWait(entry, this.pending.indexOf(entry), "resolve", {
|
|
302
|
+
text,
|
|
303
|
+
partial: true,
|
|
304
|
+
});
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
this.finishWait(
|
|
308
|
+
entry,
|
|
309
|
+
this.pending.indexOf(entry),
|
|
310
|
+
"reject",
|
|
311
|
+
new Error(
|
|
312
|
+
`Timed out after ${opts.timeoutMs}ms waiting for bot reply (offline, privacy mode, or unknown command?)`,
|
|
313
|
+
),
|
|
314
|
+
);
|
|
315
|
+
}, opts.timeoutMs),
|
|
316
|
+
resolve,
|
|
317
|
+
reject,
|
|
318
|
+
settled: false,
|
|
319
|
+
};
|
|
320
|
+
this.pending.push(entry);
|
|
321
|
+
log.debug(
|
|
322
|
+
`waiting for bot ${opts.botId} in chat ${opts.chatId} ` +
|
|
323
|
+
`(timeout ${opts.timeoutMs}ms, settle ${opts.settleMs}ms)`,
|
|
324
|
+
);
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private finishWait(
|
|
329
|
+
entry: PendingWait,
|
|
330
|
+
index: number,
|
|
331
|
+
mode: "resolve" | "reject",
|
|
332
|
+
value: BotWaitResult | Error,
|
|
333
|
+
): void {
|
|
334
|
+
if (entry.settled) return;
|
|
335
|
+
entry.settled = true;
|
|
336
|
+
clearTimeout(entry.hardTimer);
|
|
337
|
+
if (entry.settleTimer) clearTimeout(entry.settleTimer);
|
|
338
|
+
if (index >= 0) this.pending.splice(index, 1);
|
|
339
|
+
else {
|
|
340
|
+
const i = this.pending.indexOf(entry);
|
|
341
|
+
if (i >= 0) this.pending.splice(i, 1);
|
|
342
|
+
}
|
|
343
|
+
if (mode === "resolve") entry.resolve(value as BotWaitResult);
|
|
344
|
+
else entry.reject(value as Error);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private joinChunks(entry: PendingWait): string {
|
|
348
|
+
// Preserve message order by message_id (Telegram ids increase).
|
|
349
|
+
const parts = [...entry.chunks.entries()]
|
|
350
|
+
.sort((a, b) => a[0] - b[0])
|
|
351
|
+
.map(([, t]) => t.trim())
|
|
352
|
+
.filter(Boolean);
|
|
353
|
+
return parts.join("\n\n").slice(0, 8000);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
private scheduleSettle(entry: PendingWait): void {
|
|
357
|
+
if (entry.settled) return;
|
|
358
|
+
if (entry.settleTimer) clearTimeout(entry.settleTimer);
|
|
359
|
+
entry.settleTimer = setTimeout(() => {
|
|
360
|
+
if (entry.settled || entry.chunks.size === 0) return;
|
|
361
|
+
const text = this.joinChunks(entry);
|
|
362
|
+
if (!text) return;
|
|
363
|
+
this.finishWait(entry, this.pending.indexOf(entry), "resolve", {
|
|
364
|
+
text,
|
|
365
|
+
partial: false,
|
|
366
|
+
});
|
|
367
|
+
}, entry.settleMs);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private onBotContent(ctx: Context, kind: "message" | "edited_message"): void {
|
|
371
|
+
if (this.pending.length === 0) return;
|
|
372
|
+
const msg = kind === "edited_message" ? ctx.editedMessage : ctx.message;
|
|
373
|
+
if (!msg) return;
|
|
374
|
+
const from = msg.from;
|
|
375
|
+
if (!from?.is_bot) return;
|
|
376
|
+
|
|
377
|
+
const chatId = msg.chat.id;
|
|
378
|
+
const botId = from.id;
|
|
379
|
+
const threadId = msg.message_thread_id;
|
|
380
|
+
const messageId = msg.message_id;
|
|
381
|
+
const text = extractMessageText(msg);
|
|
382
|
+
if (!text) return;
|
|
383
|
+
|
|
384
|
+
const replyTo = msg.reply_to_message?.message_id;
|
|
385
|
+
const msgDateMs = (msg.date ?? 0) * 1000;
|
|
386
|
+
const editDateMs =
|
|
387
|
+
typeof (msg as { edit_date?: number }).edit_date === "number"
|
|
388
|
+
? (msg as { edit_date: number }).edit_date * 1000
|
|
389
|
+
: 0;
|
|
390
|
+
|
|
391
|
+
for (const p of this.pending) {
|
|
392
|
+
if (p.settled) continue;
|
|
393
|
+
if (p.chatId !== chatId || p.botId !== botId) continue;
|
|
394
|
+
|
|
395
|
+
// Strict thread match when the wait is topic-scoped (forum).
|
|
396
|
+
if (p.messageThreadId !== undefined && threadId !== p.messageThreadId) {
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (
|
|
401
|
+
!shouldAcceptBotContent({
|
|
402
|
+
kind,
|
|
403
|
+
alreadyTracked: p.chunks.has(messageId),
|
|
404
|
+
startedAt: p.startedAt,
|
|
405
|
+
msgDateMs,
|
|
406
|
+
editDateMs,
|
|
407
|
+
triggerMessageId: p.triggerMessageId,
|
|
408
|
+
replyToMessageId: replyTo,
|
|
409
|
+
})
|
|
410
|
+
) {
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
p.chunks.set(messageId, text);
|
|
415
|
+
this.scheduleSettle(p);
|
|
416
|
+
log.debug(
|
|
417
|
+
`bot ${botId} ${kind} #${messageId} (${text.length} chars) — settle in ${p.settleMs}ms`,
|
|
418
|
+
);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Pure filter: should this update count toward a pending sibling-bot wait?
|
|
426
|
+
* Exported for unit tests.
|
|
427
|
+
*/
|
|
428
|
+
export function shouldAcceptBotContent(opts: {
|
|
429
|
+
kind: "message" | "edited_message";
|
|
430
|
+
alreadyTracked: boolean;
|
|
431
|
+
startedAt: number;
|
|
432
|
+
msgDateMs: number;
|
|
433
|
+
editDateMs: number;
|
|
434
|
+
triggerMessageId?: number;
|
|
435
|
+
replyToMessageId?: number;
|
|
436
|
+
}): boolean {
|
|
437
|
+
// Edits of messages we already collected always win (streaming / SSE bots).
|
|
438
|
+
if (opts.kind === "edited_message" && opts.alreadyTracked) return true;
|
|
439
|
+
|
|
440
|
+
// Explicit reply to something other than our trigger → not our call.
|
|
441
|
+
if (
|
|
442
|
+
opts.triggerMessageId !== undefined &&
|
|
443
|
+
opts.replyToMessageId !== undefined &&
|
|
444
|
+
opts.replyToMessageId !== opts.triggerMessageId
|
|
445
|
+
) {
|
|
446
|
+
return false;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Prefer content that replies to our trigger.
|
|
450
|
+
if (
|
|
451
|
+
opts.triggerMessageId !== undefined &&
|
|
452
|
+
opts.replyToMessageId === opts.triggerMessageId
|
|
453
|
+
) {
|
|
454
|
+
return true;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// New message: drop stale chatter from before we started waiting (2s slack).
|
|
458
|
+
if (opts.kind === "message") {
|
|
459
|
+
if (opts.msgDateMs > 0 && opts.msgDateMs + 2000 < opts.startedAt) return false;
|
|
460
|
+
return true;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// First sighting of a message via edit only (no prior chunk): require it looks
|
|
464
|
+
// "new enough" — use edit_date or date so we do not steal ancient bot posts.
|
|
465
|
+
const effective = opts.editDateMs || opts.msgDateMs;
|
|
466
|
+
if (effective > 0 && effective + 2000 < opts.startedAt) return false;
|
|
467
|
+
return true;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function extractMessageText(msg: {
|
|
471
|
+
text?: string;
|
|
472
|
+
caption?: string;
|
|
473
|
+
photo?: unknown;
|
|
474
|
+
document?: unknown;
|
|
475
|
+
video?: unknown;
|
|
476
|
+
audio?: unknown;
|
|
477
|
+
voice?: unknown;
|
|
478
|
+
sticker?: unknown;
|
|
479
|
+
animation?: unknown;
|
|
480
|
+
}): string {
|
|
481
|
+
const t = (msg.text || msg.caption || "").trim();
|
|
482
|
+
if (t) return t;
|
|
483
|
+
// Media-only replies still count so settle can finish (not hang until timeout).
|
|
484
|
+
if (
|
|
485
|
+
msg.photo ||
|
|
486
|
+
msg.document ||
|
|
487
|
+
msg.video ||
|
|
488
|
+
msg.audio ||
|
|
489
|
+
msg.voice ||
|
|
490
|
+
msg.sticker ||
|
|
491
|
+
msg.animation
|
|
492
|
+
) {
|
|
493
|
+
return "[media]";
|
|
494
|
+
}
|
|
495
|
+
return "";
|
|
496
|
+
}
|
package/src/bot/telegram-io.ts
CHANGED
|
@@ -10,26 +10,65 @@ import { toTelegramMarkdown } from "../render/markdown.js";
|
|
|
10
10
|
const log = createLogger("tg:io");
|
|
11
11
|
const MAX_RETRIES = 3;
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
const TRANSIENT_NET =
|
|
14
|
+
/econnreset|econnrefused|etimedout|eai_again|socket hang ?up|fetch failed|network|temporarily unavailable/i;
|
|
15
|
+
|
|
16
|
+
export type TelegramRetryOptions = {
|
|
17
|
+
/** Max attempts after the first try for 429 (default 3). */
|
|
18
|
+
maxRateLimitRetries?: number;
|
|
19
|
+
/** Max attempts after the first try for transient network errors (default 0 = off). */
|
|
20
|
+
maxNetworkRetries?: number;
|
|
21
|
+
/** Logger label for debug lines. */
|
|
22
|
+
label?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Retry a Telegram API call on 429 (respect `retry_after`) and optionally on
|
|
27
|
+
* transient network failures. Used by safe send/edit and by long forum setup.
|
|
28
|
+
*/
|
|
29
|
+
export async function withTelegramRetry<T>(
|
|
30
|
+
fn: () => Promise<T>,
|
|
31
|
+
opts: TelegramRetryOptions = {},
|
|
32
|
+
): Promise<T> {
|
|
33
|
+
const max429 = opts.maxRateLimitRetries ?? MAX_RETRIES;
|
|
34
|
+
const maxNet = opts.maxNetworkRetries ?? 0;
|
|
35
|
+
const label = opts.label ?? "tg";
|
|
36
|
+
let rateAttempts = 0;
|
|
37
|
+
let netAttempts = 0;
|
|
15
38
|
for (;;) {
|
|
16
39
|
try {
|
|
17
40
|
return await fn();
|
|
18
41
|
} catch (err) {
|
|
19
42
|
if (err instanceof GrammyError && err.error_code === 429) {
|
|
20
43
|
const wait = (err.parameters?.retry_after ?? 1) * 1000 + 250;
|
|
21
|
-
if (
|
|
22
|
-
log.debug(
|
|
44
|
+
if (rateAttempts++ < max429) {
|
|
45
|
+
log.debug(`${label}: 429 rate limited, waiting ${wait}ms (try ${rateAttempts}/${max429})`);
|
|
23
46
|
await sleep(wait);
|
|
24
47
|
continue;
|
|
25
48
|
}
|
|
49
|
+
} else if (maxNet > 0 && isTransientNetworkError(err) && netAttempts++ < maxNet) {
|
|
50
|
+
const wait = Math.min(30_000, 1000 * 2 ** (netAttempts - 1));
|
|
51
|
+
log.debug(`${label}: transient network error, retry in ${wait}ms (try ${netAttempts}/${maxNet})`);
|
|
52
|
+
await sleep(wait);
|
|
53
|
+
continue;
|
|
26
54
|
}
|
|
27
55
|
throw err;
|
|
28
56
|
}
|
|
29
57
|
}
|
|
30
58
|
}
|
|
31
59
|
|
|
32
|
-
|
|
60
|
+
function isTransientNetworkError(err: unknown): boolean {
|
|
61
|
+
if (!err || typeof err !== "object") return false;
|
|
62
|
+
const msg = String((err as Error).message ?? err);
|
|
63
|
+
const code = String((err as { code?: string }).code ?? "");
|
|
64
|
+
return TRANSIENT_NET.test(msg) || TRANSIENT_NET.test(code);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function withRetry<T>(fn: () => Promise<T>): Promise<T> {
|
|
68
|
+
return withTelegramRetry(fn, { maxRateLimitRetries: MAX_RETRIES });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Send a message as MarkdownV2, falling back to demoted plain text on parse errors. */
|
|
33
72
|
export async function safeSend(
|
|
34
73
|
api: Api,
|
|
35
74
|
chatId: number,
|
|
@@ -44,7 +83,10 @@ export async function safeSend(
|
|
|
44
83
|
return msg.message_id;
|
|
45
84
|
} catch (err) {
|
|
46
85
|
if (isParseError(err)) {
|
|
47
|
-
|
|
86
|
+
// Do not send raw markdown — Telegram clients soft-render ** and ``` in
|
|
87
|
+
// plain messages and Windows paths look broken (e.g. **Edit C:** wrap).
|
|
88
|
+
const demoted = demoteMarkdownForPlain(plain);
|
|
89
|
+
const msg = await withRetry(() => api.sendMessage(chatId, demoted, extra));
|
|
48
90
|
return msg.message_id;
|
|
49
91
|
}
|
|
50
92
|
log.warn("sendMessage failed:", (err as Error).message);
|
|
@@ -52,7 +94,7 @@ export async function safeSend(
|
|
|
52
94
|
}
|
|
53
95
|
}
|
|
54
96
|
|
|
55
|
-
/** Edit a message as MarkdownV2, falling back to plain text on parse errors. */
|
|
97
|
+
/** Edit a message as MarkdownV2, falling back to demoted plain text on parse errors. */
|
|
56
98
|
export async function safeEdit(
|
|
57
99
|
api: Api,
|
|
58
100
|
chatId: number,
|
|
@@ -68,7 +110,7 @@ export async function safeEdit(
|
|
|
68
110
|
if (isNotModified(err)) return;
|
|
69
111
|
if (isParseError(err)) {
|
|
70
112
|
try {
|
|
71
|
-
await withRetry(() => api.editMessageText(chatId, messageId, plain));
|
|
113
|
+
await withRetry(() => api.editMessageText(chatId, messageId, demoteMarkdownForPlain(plain)));
|
|
72
114
|
} catch (e2) {
|
|
73
115
|
if (!isNotModified(e2)) log.debug("plain edit failed:", (e2 as Error).message);
|
|
74
116
|
}
|
|
@@ -78,6 +120,47 @@ export async function safeEdit(
|
|
|
78
120
|
}
|
|
79
121
|
}
|
|
80
122
|
|
|
123
|
+
/**
|
|
124
|
+
* When MarkdownV2 parse fails, send readable plain text without `**` / fences
|
|
125
|
+
* that clients soft-render into broken bold around Windows paths.
|
|
126
|
+
*/
|
|
127
|
+
export function demoteMarkdownForPlain(src: string): string {
|
|
128
|
+
if (!src) return src;
|
|
129
|
+
let s = src.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
130
|
+
// Fenced blocks → indented plain body (drop language tag). Same tick count open/close.
|
|
131
|
+
s = s.replace(/^[ \t]*(`{3,})([^\n]*)\n([\s\S]*?)^[ \t]*\1[ \t]*$/gm, (_m, _ticks, _lang, body: string) => {
|
|
132
|
+
const lines = String(body).replace(/\n$/, "").split("\n");
|
|
133
|
+
return lines.map((l) => (l ? " " + l : "")).join("\n");
|
|
134
|
+
});
|
|
135
|
+
// Unclosed trailing fence only at end of message (streaming mid-fence).
|
|
136
|
+
s = s.replace(/^[ \t]*`{3,}[^\n]*\n([\s\S]*)$/m, (full, body: string, offset: number) => {
|
|
137
|
+
// Only treat as unclosed if this match reaches the true end of the string.
|
|
138
|
+
if (offset + full.length < s.length) return full;
|
|
139
|
+
return String(body)
|
|
140
|
+
.split("\n")
|
|
141
|
+
.map((l) => (l ? " " + l : ""))
|
|
142
|
+
.join("\n");
|
|
143
|
+
});
|
|
144
|
+
// Inline code → keep content.
|
|
145
|
+
s = s.replace(/`([^`\n]+)`/g, "$1");
|
|
146
|
+
// Bold / italic / strike markers (repeat until stable for adjacent spans).
|
|
147
|
+
for (let i = 0; i < 3; i++) {
|
|
148
|
+
const next = s
|
|
149
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
150
|
+
.replace(/__([^_]+)__/g, "$1")
|
|
151
|
+
.replace(/~~([^~]+)~~/g, "$1");
|
|
152
|
+
if (next === s) break;
|
|
153
|
+
s = next;
|
|
154
|
+
}
|
|
155
|
+
// Leftover emphasis markers (including broken **Edit C:** style).
|
|
156
|
+
s = s.replace(/\*\*/g, "");
|
|
157
|
+
s = s.replace(/(?<!\w)\*([^*\n]+)\*(?!\w)/g, "$1");
|
|
158
|
+
s = s.replace(/(?<!\w)_([^_\n]+)_(?!\w)/g, "$1");
|
|
159
|
+
// Collapse leftover fence ticks.
|
|
160
|
+
s = s.replace(/`{3,}/g, "");
|
|
161
|
+
return s.replace(/\n{4,}/g, "\n\n\n").trimEnd();
|
|
162
|
+
}
|
|
163
|
+
|
|
81
164
|
function isParseError(err: unknown): boolean {
|
|
82
165
|
return err instanceof GrammyError && /can't parse entities|parse entities/i.test(err.description);
|
|
83
166
|
}
|
|
@@ -97,9 +180,13 @@ export async function sendMarkdownDoc(
|
|
|
97
180
|
api: Api,
|
|
98
181
|
chatId: number,
|
|
99
182
|
rawMarkdown: string,
|
|
100
|
-
opts?: { loud?: boolean },
|
|
183
|
+
opts?: { loud?: boolean; messageThreadId?: number },
|
|
101
184
|
): Promise<void> {
|
|
102
|
-
const extra = opts?.loud ? { disable_notification: false } : {};
|
|
185
|
+
const extra: Record<string, unknown> = opts?.loud ? { disable_notification: false } : {};
|
|
186
|
+
// Omit General (1) — Bot API rejects message_thread_id=1.
|
|
187
|
+
if (opts?.messageThreadId !== undefined && opts.messageThreadId !== 1) {
|
|
188
|
+
extra.message_thread_id = opts.messageThreadId;
|
|
189
|
+
}
|
|
103
190
|
const rendered = toTelegramMarkdown(rawMarkdown);
|
|
104
191
|
const mdChunks = chunkMarkdown(rendered);
|
|
105
192
|
const plainChunks = chunkMarkdown(rawMarkdown);
|
package/src/cli.ts
CHANGED
|
@@ -83,12 +83,14 @@ async function main(): Promise<void> {
|
|
|
83
83
|
|
|
84
84
|
case "logs":
|
|
85
85
|
printLogs(arg ? Number(arg) || 100 : 100);
|
|
86
|
+
process.exit(0);
|
|
86
87
|
break;
|
|
87
88
|
|
|
88
89
|
case "help":
|
|
89
90
|
case "--help":
|
|
90
91
|
case "-h":
|
|
91
92
|
console.log(HELP);
|
|
93
|
+
process.exit(0);
|
|
92
94
|
break;
|
|
93
95
|
|
|
94
96
|
default:
|