jeo-code 0.7.48 → 0.7.49
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/CHANGELOG.md +21 -0
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/package.json +1 -1
- package/src/agent/config-schema.ts +15 -1
- package/src/agent/notify/AGENTS.md +30 -10
- package/src/agent/notify/config-commands.ts +50 -0
- package/src/agent/notify/paths.ts +7 -0
- package/src/agent/notify/rate-limit-pool.ts +217 -0
- package/src/agent/notify/session-endpoint.ts +167 -29
- package/src/agent/notify/telegram-api.ts +71 -0
- package/src/agent/notify/telegram-daemon.ts +371 -38
- package/src/agent/notify/telegram-html.ts +414 -0
- package/src/agent/notify/topic-registry.ts +136 -0
- package/src/agent/state.ts +7 -0
- package/src/agent/task-tool.ts +6 -2
- package/src/commands/launch.ts +141 -4
|
@@ -17,16 +17,30 @@
|
|
|
17
17
|
*
|
|
18
18
|
* gjc-parity surface added on top of the plain-text core: forum topics (all
|
|
19
19
|
* pushes carry the configured `message_thread_id`, inbound is topic-filtered),
|
|
20
|
-
* inline keyboards (cancel buttons + `callback_query` handling),
|
|
20
|
+
* inline keyboards (cancel buttons + `callback_query` handling), image
|
|
21
21
|
* attachments (a session may push a `{type:"photo"}` frame relayed via
|
|
22
|
-
* `sendPhoto`)
|
|
22
|
+
* `sendPhoto`), a shared `RateLimitPool` (burst/steady-rate protection across
|
|
23
|
+
* every session sharing this one bot token), and — when
|
|
24
|
+
* `notifications.telegram.perSessionTopics` is enabled — a dynamic,
|
|
25
|
+
* PER-SESSION forum topic (via `TopicRegistry`) that mirrors that session's
|
|
26
|
+
* OWN activity (`identity_header`/`context_update`/`turn_stream` frames) and
|
|
27
|
+
* accepts free-text replies (routed back as `user_message`) and
|
|
28
|
+
* `/verbose`/`/lean`/`/redact` config commands, ALL scoped to that one
|
|
29
|
+
* session's topic. The flat/global `topicId` behavior below is UNCHANGED when
|
|
30
|
+
* `perSessionTopics` is off (or unset) — zero regression risk for existing
|
|
31
|
+
* setups.
|
|
23
32
|
*/
|
|
24
33
|
import * as fs from "node:fs/promises";
|
|
34
|
+
import * as os from "node:os";
|
|
25
35
|
import * as path from "node:path";
|
|
26
36
|
import { readGlobalConfig } from "../state";
|
|
27
|
-
import { notifySessionsDir } from "./paths";
|
|
37
|
+
import { notifySessionsDir, notifyTopicsPath } from "./paths";
|
|
28
38
|
import { acquireDaemonLock, isPidAlive } from "./daemon-control";
|
|
29
39
|
import { TelegramApi, type TelegramUpdate, type TelegramCallbackQuery, type InlineKeyboardMarkup, type InlineKeyboardButton } from "./telegram-api";
|
|
40
|
+
import { TopicRegistry, type TopicRegistryState } from "./topic-registry";
|
|
41
|
+
import { RateLimitPool } from "./rate-limit-pool";
|
|
42
|
+
import { parseInThreadConfigCommand } from "./config-commands";
|
|
43
|
+
import { markdownToTelegramHtml, splitTelegramHtml } from "./telegram-html";
|
|
30
44
|
import type { SubagentRecord } from "../subagent-registry";
|
|
31
45
|
|
|
32
46
|
|
|
@@ -116,7 +130,10 @@ export const HELP_TEXT =
|
|
|
116
130
|
"/cancel <sessionId> <subagentId> — cancel a running subagent\n" +
|
|
117
131
|
"/cancel <sessionId> <subagentId> — cancel a running subagent\n" +
|
|
118
132
|
"/help — show this message\n" +
|
|
119
|
-
"(running subagents also carry an inline ⏹ Cancel button — tap it instead of typing /cancel)"
|
|
133
|
+
"(running subagents also carry an inline ⏹ Cancel button — tap it instead of typing /cancel)\n\n" +
|
|
134
|
+
"Inside a session's OWN topic (when per-session topics are enabled): reply with any " +
|
|
135
|
+
"text to steer that session directly, or use /verbose, /lean, /verbosity lean|verbose, " +
|
|
136
|
+
"/redact on|off to adjust how that session mirrors its activity here.";
|
|
120
137
|
|
|
121
138
|
// ── Inline keyboards / callback data (gjc parity) ───────────────────────────────
|
|
122
139
|
|
|
@@ -164,6 +181,14 @@ interface SessionConnection {
|
|
|
164
181
|
pid: number;
|
|
165
182
|
ws: WebSocket;
|
|
166
183
|
lastRecords: SubagentRecord[];
|
|
184
|
+
/** Latest identity, once an `identity_header` frame arrives — used to name/
|
|
185
|
+
* rename the session's forum topic. `undefined` until then (topic still
|
|
186
|
+
* gets created eagerly with a provisional short-id name, gjc parity). */
|
|
187
|
+
identity?: { repo: string; branch?: string; cwd: string };
|
|
188
|
+
/** True once this session's per-session topic creation has been attempted
|
|
189
|
+
* and failed (e.g. Threaded Mode off) — stops a retry storm; falls back to
|
|
190
|
+
* no per-session mirroring for the rest of this connection's lifetime. */
|
|
191
|
+
topicFailed?: boolean;
|
|
167
192
|
}
|
|
168
193
|
|
|
169
194
|
export interface TelegramDaemonOptions {
|
|
@@ -177,10 +202,30 @@ export interface TelegramDaemonOptions {
|
|
|
177
202
|
scanIntervalMs?: number;
|
|
178
203
|
ackTimeoutMs?: number;
|
|
179
204
|
/** Forum-topic thread id (message_thread_id). When set, every push is sent
|
|
180
|
-
* into this topic and inbound messages/taps from other topics are ignored.
|
|
205
|
+
* into this topic and inbound messages/taps from other topics are ignored.
|
|
206
|
+
* Ignored for a session with its OWN per-session topic when
|
|
207
|
+
* `perSessionTopics` is true. */
|
|
181
208
|
topicId?: number;
|
|
209
|
+
/** Auto-create/manage one forum topic PER SESSION instead of the single flat
|
|
210
|
+
* `topicId` above (see the file header doc). Off by default. */
|
|
211
|
+
perSessionTopics?: boolean;
|
|
212
|
+
/** Injectable persistence for the per-session topic map — defaults to
|
|
213
|
+
* reading/writing `notifyTopicsPath()` via `fs`. */
|
|
214
|
+
loadTopicState?: () => Promise<TopicRegistryState>;
|
|
215
|
+
saveTopicState?: (state: TopicRegistryState) => Promise<void>;
|
|
216
|
+
/** Injectable clock for `RateLimitPool` determinism in tests. */
|
|
217
|
+
now?: () => number;
|
|
218
|
+
/** Injectable temp-file writer for downloaded inbound attachments — defaults
|
|
219
|
+
* to a real `os.tmpdir()` write. Returns the written file's absolute path. */
|
|
220
|
+
writeTempFile?: (bytes: Uint8Array, suggestedName: string) => Promise<string>;
|
|
182
221
|
}
|
|
183
222
|
|
|
223
|
+
/** Outbound payload the shared `RateLimitPool` schedules; `flushPool` maps each
|
|
224
|
+
* variant onto the matching Bot API call. */
|
|
225
|
+
type OutboundPayload =
|
|
226
|
+
| { kind: "text"; chatId: string; text: string; topicId?: number; replyMarkup?: InlineKeyboardMarkup; html?: boolean }
|
|
227
|
+
| { kind: "photo"; chatId: string; photo: string; topicId?: number; caption?: string; replyMarkup?: InlineKeyboardMarkup };
|
|
228
|
+
|
|
184
229
|
export class TelegramDaemon {
|
|
185
230
|
private readonly WS: typeof WebSocket;
|
|
186
231
|
readonly sessions = new Map<string, SessionConnection>();
|
|
@@ -188,32 +233,43 @@ export class TelegramDaemon {
|
|
|
188
233
|
private updateOffset: number | undefined;
|
|
189
234
|
private stopped = false;
|
|
190
235
|
private scanTimer: ReturnType<typeof setInterval> | undefined;
|
|
236
|
+
private poolTimer: ReturnType<typeof setInterval> | undefined;
|
|
237
|
+
/** `undefined` when `perSessionTopics` is off — the flat/global `topicId`
|
|
238
|
+
* path never touches this. */
|
|
239
|
+
private readonly topics: TopicRegistry | undefined;
|
|
240
|
+
private readonly pool: RateLimitPool<OutboundPayload>;
|
|
191
241
|
|
|
192
242
|
constructor(private readonly opts: TelegramDaemonOptions) {
|
|
193
243
|
this.WS = opts.WebSocketImpl ?? WebSocket;
|
|
244
|
+
this.topics = opts.perSessionTopics ? new TopicRegistry() : undefined;
|
|
245
|
+
this.pool = new RateLimitPool<OutboundPayload>({ now: opts.now });
|
|
194
246
|
}
|
|
195
247
|
|
|
196
|
-
private async notify(
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
248
|
+
private async notify(
|
|
249
|
+
text: string,
|
|
250
|
+
replyMarkup?: InlineKeyboardMarkup,
|
|
251
|
+
opts?: { topicId?: number; sessionId?: string; html?: boolean },
|
|
252
|
+
): Promise<void> {
|
|
253
|
+
this.pool.submit({
|
|
254
|
+
sessionId: opts?.sessionId ?? "__global__",
|
|
255
|
+
lane: "finalized",
|
|
256
|
+
payload: { kind: "text", chatId: this.opts.chatId, text, topicId: opts?.topicId ?? this.opts.topicId, replyMarkup, html: opts?.html },
|
|
257
|
+
});
|
|
258
|
+
await this.flushPool();
|
|
205
259
|
}
|
|
206
260
|
|
|
207
|
-
private async notifyPhoto(
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
261
|
+
private async notifyPhoto(
|
|
262
|
+
photo: string,
|
|
263
|
+
caption?: string,
|
|
264
|
+
replyMarkup?: InlineKeyboardMarkup,
|
|
265
|
+
opts?: { topicId?: number; sessionId?: string },
|
|
266
|
+
): Promise<void> {
|
|
267
|
+
this.pool.submit({
|
|
268
|
+
sessionId: opts?.sessionId ?? "__global__",
|
|
269
|
+
lane: "finalized",
|
|
270
|
+
payload: { kind: "photo", chatId: this.opts.chatId, photo, topicId: opts?.topicId ?? this.opts.topicId, caption, replyMarkup },
|
|
271
|
+
});
|
|
272
|
+
await this.flushPool();
|
|
217
273
|
}
|
|
218
274
|
|
|
219
275
|
private async answerCallback(id: string, text?: string): Promise<void> {
|
|
@@ -224,6 +280,39 @@ export class TelegramDaemon {
|
|
|
224
280
|
}
|
|
225
281
|
}
|
|
226
282
|
|
|
283
|
+
/** Grant queued pool items tokens and actually dispatch them via the Bot API.
|
|
284
|
+
* Called after every `submit()` (drains whatever tokens are free RIGHT NOW —
|
|
285
|
+
* the common case, near-synchronous like the pre-pool behavior) AND by a
|
|
286
|
+
* 250ms timer (catches anything that queued because the burst capacity was
|
|
287
|
+
* briefly exhausted — e.g. a large fan-out's subagents all finishing near-
|
|
288
|
+
* simultaneously). `splitTelegramHtml` is applied to EVERY text send (not
|
|
289
|
+
* just HTML ones — it degrades to plain char-boundary splitting when no
|
|
290
|
+
* tags are present), so nothing this daemon sends can exceed Telegram's
|
|
291
|
+
* 4096-char hard limit regardless of source. */
|
|
292
|
+
private async flushPool(): Promise<void> {
|
|
293
|
+
const granted = this.pool.drain();
|
|
294
|
+
for (const item of granted) {
|
|
295
|
+
const p = item.payload;
|
|
296
|
+
try {
|
|
297
|
+
if (p.kind === "text") {
|
|
298
|
+
const chunks = splitTelegramHtml(p.text);
|
|
299
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
300
|
+
await this.opts.telegram.sendMessage(p.chatId, chunks[i]!, {
|
|
301
|
+
messageThreadId: p.topicId,
|
|
302
|
+
// A reply keyboard belongs on the LAST fragment a split reply ends on.
|
|
303
|
+
replyMarkup: i === chunks.length - 1 ? p.replyMarkup : undefined,
|
|
304
|
+
parseMode: p.html ? "HTML" : undefined,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
} else {
|
|
308
|
+
await this.opts.telegram.sendPhoto(p.chatId, p.photo, { messageThreadId: p.topicId, caption: p.caption, replyMarkup: p.replyMarkup });
|
|
309
|
+
}
|
|
310
|
+
} catch {
|
|
311
|
+
// best-effort — a failed push must never crash the daemon loop.
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
227
316
|
async scanSessions(): Promise<void> {
|
|
228
317
|
const readdir = this.opts.readdir ?? (d => fs.readdir(d));
|
|
229
318
|
let files: string[];
|
|
@@ -265,6 +354,97 @@ export class TelegramDaemon {
|
|
|
265
354
|
});
|
|
266
355
|
}
|
|
267
356
|
|
|
357
|
+
/** Resolve the topic to push INTO for `conn`: its own per-session topic
|
|
358
|
+
* (created on first use, cached thereafter) when `perSessionTopics` is on
|
|
359
|
+
* and creation hasn't already failed for this connection; the flat/global
|
|
360
|
+
* `topicId` otherwise (unchanged pre-Tier-2 behavior). Never throws — a
|
|
361
|
+
* creation failure (e.g. Threaded Mode off in @BotFather) is remembered on
|
|
362
|
+
* `conn.topicFailed` so it is not retried every single frame. */
|
|
363
|
+
private async resolveTopicId(conn: SessionConnection): Promise<number | undefined> {
|
|
364
|
+
if (!this.opts.perSessionTopics || !this.topics || conn.topicFailed) return this.opts.topicId;
|
|
365
|
+
// Fail-closed privacy gate (contract requirement): per-session topics carry
|
|
366
|
+
// that session's OWN activity mirroring + accept free-text steering — both
|
|
367
|
+
// are only safe in a confirmed 1:1 private DM. A group/channel `chatId`
|
|
368
|
+
// (e.g. misconfigured `jeo notify setup`) must NEVER get per-session
|
|
369
|
+
// topics/routing; it silently falls back to the flat/global `topicId`
|
|
370
|
+
// instead (still fully functional for subagent-status pushes, just without
|
|
371
|
+
// the per-session surface). Checked once per daemon lifetime and cached —
|
|
372
|
+
// chat type cannot change without re-running `jeo notify setup` anyway.
|
|
373
|
+
if (!(await this.pairedChatIsPrivate())) return this.opts.topicId;
|
|
374
|
+
const provisional = conn.identity
|
|
375
|
+
? `${conn.identity.repo}${conn.identity.branch ? `@${conn.identity.branch}` : ""}`
|
|
376
|
+
: `session ${shortSessionId(conn.sessionId)}`;
|
|
377
|
+
try {
|
|
378
|
+
const record = await this.topics.getOrCreateTopic(
|
|
379
|
+
conn.sessionId,
|
|
380
|
+
async () => {
|
|
381
|
+
const res = await this.opts.telegram.createForumTopic(this.opts.chatId, provisional);
|
|
382
|
+
const tid = res.result?.message_thread_id;
|
|
383
|
+
if (tid === undefined) throw new Error("createForumTopic: no message_thread_id");
|
|
384
|
+
return tid;
|
|
385
|
+
},
|
|
386
|
+
undefined,
|
|
387
|
+
provisional,
|
|
388
|
+
);
|
|
389
|
+
void this.persistTopics();
|
|
390
|
+
return record.topicId;
|
|
391
|
+
} catch {
|
|
392
|
+
conn.topicFailed = true;
|
|
393
|
+
return this.opts.topicId;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Cached (undefined = not yet checked) result of `getChat`'s `type ===
|
|
398
|
+
* "private"` — a `getChat` failure fails CLOSED (treated as not-private,
|
|
399
|
+
* never retried, matching `resolveTopicId`'s own no-retry-storm posture). */
|
|
400
|
+
private pairedChatPrivate: boolean | undefined;
|
|
401
|
+
private async pairedChatIsPrivate(): Promise<boolean> {
|
|
402
|
+
if (this.pairedChatPrivate !== undefined) return this.pairedChatPrivate;
|
|
403
|
+
try {
|
|
404
|
+
const res = await this.opts.telegram.getChat(this.opts.chatId);
|
|
405
|
+
this.pairedChatPrivate = res.result?.type === "private";
|
|
406
|
+
} catch {
|
|
407
|
+
this.pairedChatPrivate = false;
|
|
408
|
+
}
|
|
409
|
+
return this.pairedChatPrivate;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private async persistTopics(): Promise<void> {
|
|
413
|
+
if (!this.topics) return;
|
|
414
|
+
const save = this.opts.saveTopicState ?? defaultSaveTopicState;
|
|
415
|
+
try {
|
|
416
|
+
await save(this.topics.serialize());
|
|
417
|
+
} catch {
|
|
418
|
+
// best-effort — a failed persist just re-creates topics after a restart.
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Download a Telegram-hosted file into a local temp file for relay into a
|
|
423
|
+
* session's `[image #N]` attachment pipeline (`attachImagePaths`, see
|
|
424
|
+
* `src/util/file-attachment.ts`). Returns `undefined` on any failure — the
|
|
425
|
+
* message still delivers as text-only rather than being dropped entirely. */
|
|
426
|
+
private async downloadAttachment(fileId: string, suggestedName: string): Promise<string | undefined> {
|
|
427
|
+
try {
|
|
428
|
+
const got = await this.opts.telegram.getFile(fileId);
|
|
429
|
+
const filePath = got.result?.file_path;
|
|
430
|
+
if (!filePath) return undefined;
|
|
431
|
+
const bytes = await this.opts.telegram.downloadFile(filePath);
|
|
432
|
+
if (!bytes) return undefined;
|
|
433
|
+
const write = this.opts.writeTempFile ?? defaultWriteTempFile;
|
|
434
|
+
return await write(bytes, suggestedName);
|
|
435
|
+
} catch {
|
|
436
|
+
return undefined;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
private async reactToMessage(messageId: number, emoji: string): Promise<void> {
|
|
441
|
+
try {
|
|
442
|
+
await this.opts.telegram.setMessageReaction(this.opts.chatId, messageId, emoji);
|
|
443
|
+
} catch {
|
|
444
|
+
// best-effort — a visible delivery confirmation, not load-bearing.
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
268
448
|
async handleSessionMessage(conn: SessionConnection, raw: string): Promise<void> {
|
|
269
449
|
let msg: Record<string, unknown>;
|
|
270
450
|
try {
|
|
@@ -277,10 +457,13 @@ export class TelegramDaemon {
|
|
|
277
457
|
const events = diffSubagentTransitions(conn.lastRecords, next);
|
|
278
458
|
conn.lastRecords = next;
|
|
279
459
|
const shortId = shortSessionId(conn.sessionId);
|
|
280
|
-
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
460
|
+
if (events.length > 0) {
|
|
461
|
+
const topicId = await this.resolveTopicId(conn);
|
|
462
|
+
for (const ev of events) {
|
|
463
|
+
const keyboard =
|
|
464
|
+
ev.kind === "started" ? { inline_keyboard: [[cancelButton(shortId, ev.record)]] } : undefined;
|
|
465
|
+
await this.notify(formatNotifyEvent(shortId, conn.cwd, ev), keyboard, { topicId, sessionId: conn.sessionId });
|
|
466
|
+
}
|
|
284
467
|
}
|
|
285
468
|
return;
|
|
286
469
|
}
|
|
@@ -291,10 +474,12 @@ export class TelegramDaemon {
|
|
|
291
474
|
"inline_keyboard" in (msg.replyMarkup as object)
|
|
292
475
|
? (msg.replyMarkup as InlineKeyboardMarkup)
|
|
293
476
|
: undefined;
|
|
477
|
+
const topicId = await this.resolveTopicId(conn);
|
|
294
478
|
await this.notifyPhoto(
|
|
295
479
|
msg.url,
|
|
296
480
|
typeof msg.caption === "string" ? msg.caption : undefined,
|
|
297
481
|
replyMarkup,
|
|
482
|
+
{ topicId, sessionId: conn.sessionId },
|
|
298
483
|
);
|
|
299
484
|
return;
|
|
300
485
|
}
|
|
@@ -304,6 +489,49 @@ export class TelegramDaemon {
|
|
|
304
489
|
resolve(Boolean(msg.ok));
|
|
305
490
|
this.pendingAcks.delete(msg.reqId);
|
|
306
491
|
}
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
// ── Tier 2: main-session mirroring frames (only meaningful/acted-on when
|
|
495
|
+
// `perSessionTopics` is enabled — `resolveTopicId` degrades to the
|
|
496
|
+
// flat/global topicId otherwise, so these still deliver, just without
|
|
497
|
+
// per-session grouping). ──────────────────────────────────────────────
|
|
498
|
+
if (msg.type === "identity_header") {
|
|
499
|
+
conn.identity = {
|
|
500
|
+
repo: typeof msg.repo === "string" && msg.repo ? msg.repo : projectName(conn.cwd),
|
|
501
|
+
branch: typeof msg.branch === "string" ? msg.branch : undefined,
|
|
502
|
+
cwd: typeof msg.cwd === "string" ? msg.cwd : conn.cwd,
|
|
503
|
+
};
|
|
504
|
+
const topicId = await this.resolveTopicId(conn);
|
|
505
|
+
if (topicId !== undefined && this.topics) {
|
|
506
|
+
const name = `${conn.identity.repo}${conn.identity.branch ? `@${conn.identity.branch}` : ""}`;
|
|
507
|
+
// `getOrCreateTopic` only names a topic on FIRST creation — a later,
|
|
508
|
+
// more-informative identity (the provisional short-id name gets
|
|
509
|
+
// superseded once the real repo/branch is known) needs an explicit
|
|
510
|
+
// rename, applied only when the name actually changed (dedup).
|
|
511
|
+
if (this.topics.applyName(conn.sessionId, name)) {
|
|
512
|
+
try {
|
|
513
|
+
await this.opts.telegram.editForumTopic(this.opts.chatId, topicId, name);
|
|
514
|
+
void this.persistTopics();
|
|
515
|
+
} catch {
|
|
516
|
+
// best-effort — a failed rename just leaves the provisional name.
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
if (msg.type === "context_update" && typeof msg.phase === "string" && typeof msg.summary === "string") {
|
|
523
|
+
const topicId = await this.resolveTopicId(conn);
|
|
524
|
+
const icon = msg.phase === "turn_start" ? "▶" : "■";
|
|
525
|
+
const label = msg.phase === "turn_start" ? "Turn started" : "Turn finished";
|
|
526
|
+
const modelSuffix = typeof msg.model === "string" && msg.model ? ` (${msg.model})` : "";
|
|
527
|
+
const usageLine = typeof msg.usage === "string" && msg.usage ? `\n${msg.usage}` : "";
|
|
528
|
+
await this.notify(`${icon} ${label}${modelSuffix}\n${msg.summary}${usageLine}`, undefined, { topicId, sessionId: conn.sessionId });
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
if (msg.type === "turn_stream" && typeof msg.text === "string") {
|
|
532
|
+
const topicId = await this.resolveTopicId(conn);
|
|
533
|
+
await this.notify(markdownToTelegramHtml(msg.text), undefined, { topicId, sessionId: conn.sessionId, html: true });
|
|
534
|
+
return;
|
|
307
535
|
}
|
|
308
536
|
}
|
|
309
537
|
|
|
@@ -370,19 +598,66 @@ export class TelegramDaemon {
|
|
|
370
598
|
if (trimmed.startsWith("/")) await this.notify(`Unrecognized command. ${HELP_TEXT}`);
|
|
371
599
|
}
|
|
372
600
|
|
|
601
|
+
/** Inbound routed to a KNOWN session's OWN forum topic (per-session-topics
|
|
602
|
+
* mode only — `handleUpdate` is the only caller, and only once
|
|
603
|
+
* `this.topics.sessionForTopic(...)` has already confirmed ownership).
|
|
604
|
+
* A config command (`/verbose`, `/lean`, `/redact on|off`) is applied to
|
|
605
|
+
* THAT session only; anything else (including a media-only message with no
|
|
606
|
+
* caption) is forwarded as free-text steering, with any attached photo/
|
|
607
|
+
* image-document downloaded first. A 👀 reaction confirms the daemon
|
|
608
|
+
* actually routed the message (simplified single-stage delivery
|
|
609
|
+
* confirmation — gjc's own two-stage queued→consumed reaction protocol
|
|
610
|
+
* requires a round-trip ack frame this version does not add). */
|
|
611
|
+
private async handleSessionTopicInbound(sessionId: string, msg: NonNullable<TelegramUpdate["message"]>): Promise<void> {
|
|
612
|
+
const conn = this.sessions.get(sessionId);
|
|
613
|
+
if (!conn) return; // the owning session has disconnected; the topic record just lingers
|
|
614
|
+
const text = msg.text ?? msg.caption ?? "";
|
|
615
|
+
const hasMedia = !!(msg.photo?.length || msg.document);
|
|
616
|
+
const cfg = !hasMedia ? parseInThreadConfigCommand(text) : undefined;
|
|
617
|
+
if (cfg) {
|
|
618
|
+
conn.ws.send(JSON.stringify({ type: "config_command", sessionId, ...cfg }));
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
const imagePaths: string[] = [];
|
|
622
|
+
if (msg.photo?.length) {
|
|
623
|
+
const largest = msg.photo[msg.photo.length - 1]!;
|
|
624
|
+
const p = await this.downloadAttachment(largest.file_id, `photo-${largest.file_id}.jpg`);
|
|
625
|
+
if (p) imagePaths.push(p);
|
|
626
|
+
} else if (msg.document && (msg.document.mime_type ?? "").startsWith("image/")) {
|
|
627
|
+
const p = await this.downloadAttachment(msg.document.file_id, msg.document.file_name ?? `document-${msg.document.file_id}`);
|
|
628
|
+
if (p) imagePaths.push(p);
|
|
629
|
+
}
|
|
630
|
+
if (!text && imagePaths.length === 0) return; // a non-image document with no caption — nothing usable to forward
|
|
631
|
+
conn.ws.send(JSON.stringify({ type: "user_message", sessionId, text, imagePaths: imagePaths.length ? imagePaths : undefined }));
|
|
632
|
+
await this.reactToMessage(msg.message_id, "👀");
|
|
633
|
+
}
|
|
634
|
+
|
|
373
635
|
/** Trust boundary: a bot's username is publicly discoverable, so ANY Telegram
|
|
374
636
|
* user can message it. Only the paired chat (the one that ran
|
|
375
|
-
* `jeo notify setup`) may steer/cancel subagents
|
|
376
|
-
* without a reply (replying would leak that the
|
|
377
|
-
*
|
|
637
|
+
* `jeo notify setup`) may steer/cancel subagents or steer a session;
|
|
638
|
+
* everything else is dropped without a reply (replying would leak that the
|
|
639
|
+
* bot is live). A message in a KNOWN session's own topic (per-session-
|
|
640
|
+
* topics mode) routes directly to that session — bypassing the flat
|
|
641
|
+
* `topicId` gate, which still governs everything else (global commands in
|
|
642
|
+
* the General topic or the configured flat topic; other topics ignored). */
|
|
378
643
|
async handleUpdate(update: TelegramUpdate): Promise<void> {
|
|
379
644
|
if (update.callback_query) {
|
|
380
645
|
await this.handleCallbackQuery(update.callback_query);
|
|
381
646
|
return;
|
|
382
647
|
}
|
|
383
648
|
const msg = update.message;
|
|
384
|
-
if (!msg
|
|
649
|
+
if (!msg) return;
|
|
385
650
|
if (String(msg.chat.id) !== this.opts.chatId) return;
|
|
651
|
+
|
|
652
|
+
const ownerSessionId = this.topics && msg.message_thread_id !== undefined
|
|
653
|
+
? this.topics.sessionForTopic(msg.message_thread_id)
|
|
654
|
+
: undefined;
|
|
655
|
+
if (ownerSessionId) {
|
|
656
|
+
await this.handleSessionTopicInbound(ownerSessionId, msg);
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
if (!msg.text) return;
|
|
386
661
|
if (
|
|
387
662
|
this.opts.topicId !== undefined &&
|
|
388
663
|
msg.message_thread_id !== undefined &&
|
|
@@ -393,18 +668,25 @@ export class TelegramDaemon {
|
|
|
393
668
|
await this.handleInboundText(msg.text);
|
|
394
669
|
}
|
|
395
670
|
|
|
396
|
-
/** Handle an inline-button tap. Same chat
|
|
397
|
-
*
|
|
671
|
+
/** Handle an inline-button tap. Same chat trust boundary as text; the topic
|
|
672
|
+
* gate additionally allows a tap from within any KNOWN session's own topic
|
|
673
|
+
* (its cancel buttons target that session's own subagents — legitimate
|
|
674
|
+
* regardless of the flat `topicId`), not just the configured flat topic.
|
|
675
|
+
* The tap is always acknowledged (Telegram shows a spinner until
|
|
676
|
+
* `answerCallbackQuery`). */
|
|
398
677
|
async handleCallbackQuery(cb: TelegramCallbackQuery): Promise<void> {
|
|
399
678
|
const chatId = cb.message?.chat.id;
|
|
400
679
|
if (chatId !== undefined && String(chatId) !== this.opts.chatId) {
|
|
401
680
|
await this.answerCallback(cb.id);
|
|
402
681
|
return;
|
|
403
682
|
}
|
|
683
|
+
const threadId = cb.message?.message_thread_id;
|
|
684
|
+
const inKnownSessionTopic = !!this.topics && threadId !== undefined && this.topics.sessionForTopic(threadId) !== undefined;
|
|
404
685
|
if (
|
|
405
686
|
this.opts.topicId !== undefined &&
|
|
406
|
-
|
|
407
|
-
|
|
687
|
+
threadId !== undefined &&
|
|
688
|
+
threadId !== this.opts.topicId &&
|
|
689
|
+
!inKnownSessionTopic
|
|
408
690
|
) {
|
|
409
691
|
await this.answerCallback(cb.id);
|
|
410
692
|
return;
|
|
@@ -452,14 +734,29 @@ export class TelegramDaemon {
|
|
|
452
734
|
|
|
453
735
|
/** Blocks (long-poll loop) until `stop()` is called. */
|
|
454
736
|
async start(): Promise<void> {
|
|
737
|
+
if (this.topics) {
|
|
738
|
+
const load = this.opts.loadTopicState ?? defaultLoadTopicState;
|
|
739
|
+
try {
|
|
740
|
+
this.topics.load(await load());
|
|
741
|
+
} catch {
|
|
742
|
+
// best-effort — a missing/corrupt topic-state file just starts empty
|
|
743
|
+
// (every session's first frame re-creates its topic from scratch).
|
|
744
|
+
}
|
|
745
|
+
}
|
|
455
746
|
await this.scanSessions();
|
|
456
747
|
this.scanTimer = setInterval(() => void this.scanSessions(), this.opts.scanIntervalMs ?? 3_000);
|
|
748
|
+
// Catches anything left queued after a submit-time drain because the
|
|
749
|
+
// burst capacity was briefly exhausted (rare at jeo's realistic message
|
|
750
|
+
// volume, but a large simultaneous fan-out can hit it) — without this,
|
|
751
|
+
// a queued-but-never-retried item would silently never send.
|
|
752
|
+
this.poolTimer = setInterval(() => void this.flushPool(), 250);
|
|
457
753
|
await this.pollTelegramLoop();
|
|
458
754
|
}
|
|
459
755
|
|
|
460
756
|
stop(): void {
|
|
461
757
|
this.stopped = true;
|
|
462
|
-
|
|
758
|
+
clearInterval(this.scanTimer);
|
|
759
|
+
clearInterval(this.poolTimer);
|
|
463
760
|
for (const conn of this.sessions.values()) {
|
|
464
761
|
try {
|
|
465
762
|
conn.ws.close();
|
|
@@ -469,6 +766,41 @@ export class TelegramDaemon {
|
|
|
469
766
|
}
|
|
470
767
|
}
|
|
471
768
|
|
|
769
|
+
/** Default inbound-attachment temp-file writer: a private (0700-parent,
|
|
770
|
+
* default-mode) file under the OS temp dir, named to survive filesystem
|
|
771
|
+
* weirdness (sanitized, length-capped) while staying traceable to its
|
|
772
|
+
* Telegram origin for debugging. */
|
|
773
|
+
async function defaultWriteTempFile(bytes: Uint8Array, suggestedName: string): Promise<string> {
|
|
774
|
+
const safe = (suggestedName.replace(/[^\w.-]+/g, "_").slice(-128) || "file");
|
|
775
|
+
const target = path.join(os.tmpdir(), `jeo-telegram-${Date.now()}-${crypto.randomUUID().slice(0, 8)}-${safe}`);
|
|
776
|
+
await fs.writeFile(target, bytes);
|
|
777
|
+
return target;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
async function defaultLoadTopicState(): Promise<TopicRegistryState> {
|
|
781
|
+
try {
|
|
782
|
+
const raw = await fs.readFile(notifyTopicsPath(), "utf-8");
|
|
783
|
+
return JSON.parse(raw) as TopicRegistryState;
|
|
784
|
+
} catch {
|
|
785
|
+
return { topics: {} };
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/** Atomic temp+rename write (same convention as `state.ts`'s config/workflow
|
|
790
|
+
* persistence) — a torn write must never corrupt the topic map. */
|
|
791
|
+
async function defaultSaveTopicState(state: TopicRegistryState): Promise<void> {
|
|
792
|
+
const target = notifyTopicsPath();
|
|
793
|
+
await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
794
|
+
const tmp = `${target}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
795
|
+
try {
|
|
796
|
+
await fs.writeFile(tmp, JSON.stringify(state, null, 2), { encoding: "utf-8", mode: 0o600 });
|
|
797
|
+
await fs.rename(tmp, target);
|
|
798
|
+
} catch (err) {
|
|
799
|
+
await fs.unlink(tmp).catch(() => {});
|
|
800
|
+
throw err;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
472
804
|
// ── CLI entrypoint (`jeo notify-daemon-run`, internal) ──────────────────────────
|
|
473
805
|
|
|
474
806
|
export async function runNotifyDaemonForeground(): Promise<void> {
|
|
@@ -482,11 +814,12 @@ export async function runNotifyDaemonForeground(): Promise<void> {
|
|
|
482
814
|
const botToken = config.notifications?.telegram?.botToken;
|
|
483
815
|
const chatId = config.notifications?.telegram?.chatId;
|
|
484
816
|
const topicId = config.notifications?.telegram?.topicId;
|
|
817
|
+
const perSessionTopics = config.notifications?.telegram?.perSessionTopics;
|
|
485
818
|
if (!config.notifications?.enabled || !botToken || !chatId) {
|
|
486
819
|
process.stderr.write("[jeo notify-daemon] notifications not configured — run `jeo notify setup` first.\n");
|
|
487
820
|
return;
|
|
488
821
|
}
|
|
489
|
-
const daemon = new TelegramDaemon({ chatId, topicId, telegram: new TelegramApi(botToken) });
|
|
822
|
+
const daemon = new TelegramDaemon({ chatId, topicId, perSessionTopics, telegram: new TelegramApi(botToken) });
|
|
490
823
|
let shuttingDown = false;
|
|
491
824
|
const shutdown = () => {
|
|
492
825
|
if (shuttingDown) return;
|