@gajae-code/coding-agent 0.7.2 → 0.7.4
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 +86 -0
- package/bin/gjc.js +4 -0
- package/dist/types/cli/mcp-cli.d.ts +25 -0
- package/dist/types/cli/plugin-cli.d.ts +2 -0
- package/dist/types/cli.d.ts +6 -0
- package/dist/types/commands/mcp.d.ts +70 -0
- package/dist/types/commands/plugin.d.ts +6 -0
- package/dist/types/commands/session.d.ts +6 -0
- package/dist/types/config/keybindings.d.ts +2 -2
- package/dist/types/config/model-profile-activation.d.ts +8 -1
- package/dist/types/deep-interview/plaintext-gate-guard.d.ts +11 -0
- package/dist/types/extensibility/gjc-plugins/compiler.d.ts +19 -0
- package/dist/types/extensibility/gjc-plugins/constrained-hooks.d.ts +29 -0
- package/dist/types/extensibility/gjc-plugins/index.d.ts +9 -0
- package/dist/types/extensibility/gjc-plugins/injection.d.ts +9 -0
- package/dist/types/extensibility/gjc-plugins/installer.d.ts +13 -0
- package/dist/types/extensibility/gjc-plugins/mcp-policy.d.ts +26 -0
- package/dist/types/extensibility/gjc-plugins/observability.d.ts +27 -0
- package/dist/types/extensibility/gjc-plugins/prompt-appendix.d.ts +16 -0
- package/dist/types/extensibility/gjc-plugins/registry.d.ts +32 -0
- package/dist/types/extensibility/gjc-plugins/runtime-adapters.d.ts +64 -0
- package/dist/types/extensibility/gjc-plugins/session-validation.d.ts +42 -0
- package/dist/types/extensibility/gjc-plugins/types.d.ts +158 -2
- package/dist/types/extensibility/gjc-plugins/validation.d.ts +8 -1
- package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
- package/dist/types/gjc-runtime/psmux-detect.d.ts +78 -0
- package/dist/types/gjc-runtime/team-runtime.d.ts +2 -0
- package/dist/types/gjc-runtime/tmux-common.d.ts +20 -1
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +18 -0
- package/dist/types/main.d.ts +2 -0
- package/dist/types/modes/components/custom-editor.d.ts +1 -1
- package/dist/types/modes/components/model-selector.d.ts +8 -0
- package/dist/types/modes/components/status-line/git-utils.d.ts +6 -0
- package/dist/types/modes/theme/defaults/index.d.ts +99 -0
- package/dist/types/notifications/html-format.d.ts +11 -0
- package/dist/types/notifications/index.d.ts +149 -1
- package/dist/types/notifications/lifecycle-commands.d.ts +72 -0
- package/dist/types/notifications/lifecycle-control-runtime.d.ts +98 -0
- package/dist/types/notifications/lifecycle-orchestrator.d.ts +144 -0
- package/dist/types/notifications/operator-runtime.d.ts +52 -0
- package/dist/types/notifications/rate-limit-pool.d.ts +2 -0
- package/dist/types/notifications/recent-activity.d.ts +35 -0
- package/dist/types/notifications/telegram-daemon.d.ts +114 -16
- package/dist/types/notifications/telegram-reference.d.ts +3 -1
- package/dist/types/notifications/topic-registry.d.ts +12 -9
- package/dist/types/runtime-mcp/types.d.ts +7 -0
- package/dist/types/sdk.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +14 -4
- package/dist/types/session/blob-store.d.ts +25 -0
- package/dist/types/session/session-manager.d.ts +57 -0
- package/dist/types/slash-commands/helpers/fast-status-report.d.ts +6 -0
- package/dist/types/system-prompt.d.ts +2 -0
- package/dist/types/task/executor.d.ts +9 -1
- package/dist/types/tools/composer-bash-policy.d.ts +14 -0
- package/dist/types/tools/index.d.ts +3 -1
- package/dist/types/utils/changelog.d.ts +1 -0
- package/dist/types/web/insane/url-guard.d.ts +6 -3
- package/dist/types/web/scrapers/types.d.ts +5 -0
- package/dist/types/web/scrapers/utils.d.ts +7 -1
- package/package.json +11 -9
- package/scripts/g004-tmux-smoke.ts +100 -0
- package/scripts/g005-daemon-smoke.ts +181 -0
- package/scripts/g011-daemon-path-smoke.ts +153 -0
- package/src/cli/mcp-cli.ts +272 -0
- package/src/cli/plugin-cli.ts +66 -3
- package/src/cli.ts +27 -6
- package/src/commands/mcp.ts +117 -0
- package/src/commands/plugin.ts +4 -0
- package/src/commands/session.ts +18 -0
- package/src/config/keybindings.ts +2 -2
- package/src/config/model-profile-activation.ts +55 -7
- package/src/deep-interview/plaintext-gate-guard.ts +94 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/biome.json +1 -1
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +7 -6
- package/src/defaults/gjc/skills/team/SKILL.md +5 -3
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +41 -13
- package/src/export/html/index.ts +2 -2
- package/src/extensibility/extensions/runner.ts +1 -0
- package/src/extensibility/gjc-plugins/compiler.ts +351 -0
- package/src/extensibility/gjc-plugins/constrained-hooks.ts +170 -0
- package/src/extensibility/gjc-plugins/index.ts +9 -0
- package/src/extensibility/gjc-plugins/injection.ts +109 -0
- package/src/extensibility/gjc-plugins/installer.ts +434 -0
- package/src/extensibility/gjc-plugins/loader.ts +3 -1
- package/src/extensibility/gjc-plugins/mcp-policy.ts +239 -0
- package/src/extensibility/gjc-plugins/observability.ts +84 -0
- package/src/extensibility/gjc-plugins/paths.ts +1 -1
- package/src/extensibility/gjc-plugins/prompt-appendix.ts +109 -0
- package/src/extensibility/gjc-plugins/registry.ts +180 -0
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +234 -0
- package/src/extensibility/gjc-plugins/schema.ts +250 -20
- package/src/extensibility/gjc-plugins/session-validation.ts +147 -0
- package/src/extensibility/gjc-plugins/types.ts +199 -3
- package/src/extensibility/gjc-plugins/validation.ts +80 -0
- package/src/extensibility/skills.ts +15 -0
- package/src/gjc-runtime/launch-tmux.ts +61 -7
- package/src/gjc-runtime/psmux-detect.ts +239 -0
- package/src/gjc-runtime/team-runtime.ts +56 -23
- package/src/gjc-runtime/tmux-common.ts +30 -3
- package/src/gjc-runtime/tmux-sessions.ts +51 -1
- package/src/gjc-runtime/ultragoal-guard.ts +25 -8
- package/src/gjc-runtime/ultragoal-runtime.ts +75 -15
- package/src/hooks/skill-state.ts +57 -0
- package/src/internal-urls/docs-index.generated.ts +12 -8
- package/src/main.ts +14 -3
- package/src/modes/bridge/bridge-mode.ts +11 -0
- package/src/modes/components/custom-editor.ts +2 -0
- package/src/modes/components/footer.ts +2 -3
- package/src/modes/components/hook-editor.ts +1 -1
- package/src/modes/components/hook-selector.ts +67 -43
- package/src/modes/components/model-selector.ts +56 -11
- package/src/modes/components/status-line/git-utils.ts +25 -0
- package/src/modes/components/status-line.ts +10 -11
- package/src/modes/components/welcome.ts +2 -3
- package/src/modes/controllers/extension-ui-controller.ts +0 -27
- package/src/modes/controllers/selector-controller.ts +53 -11
- package/src/modes/interactive-mode.ts +4 -1
- package/src/modes/shared/agent-wire/scopes.ts +1 -1
- package/src/modes/theme/defaults/gruvbox-dark.json +99 -0
- package/src/modes/theme/defaults/index.ts +2 -0
- package/src/modes/utils/hotkeys-markdown.ts +1 -1
- package/src/notifications/html-format.ts +38 -0
- package/src/notifications/index.ts +242 -12
- package/src/notifications/lifecycle-commands.ts +228 -0
- package/src/notifications/lifecycle-control-runtime.ts +400 -0
- package/src/notifications/lifecycle-orchestrator.ts +358 -0
- package/src/notifications/operator-runtime.ts +171 -0
- package/src/notifications/rate-limit-pool.ts +19 -0
- package/src/notifications/recent-activity.ts +132 -0
- package/src/notifications/telegram-daemon.ts +778 -257
- package/src/notifications/telegram-reference.ts +25 -7
- package/src/notifications/topic-registry.ts +23 -9
- package/src/prompts/agents/executor.md +2 -2
- package/src/runtime-mcp/transports/stdio.ts +38 -4
- package/src/runtime-mcp/types.ts +7 -0
- package/src/sdk.ts +157 -10
- package/src/session/agent-session.ts +166 -74
- package/src/session/blob-store.ts +196 -8
- package/src/session/session-manager.ts +678 -7
- package/src/slash-commands/builtin-registry.ts +23 -3
- package/src/slash-commands/helpers/fast-status-report.ts +13 -3
- package/src/slash-commands/helpers/parse.ts +2 -1
- package/src/system-prompt.ts +9 -0
- package/src/task/executor.ts +31 -7
- package/src/task/index.ts +2 -0
- package/src/tools/ask.ts +5 -1
- package/src/tools/bash.ts +9 -0
- package/src/tools/composer-bash-policy.ts +96 -0
- package/src/tools/fetch.ts +18 -2
- package/src/tools/index.ts +3 -1
- package/src/utils/changelog.ts +8 -0
- package/src/web/insane/url-guard.ts +18 -14
- package/src/web/scrapers/types.ts +143 -45
- package/src/web/scrapers/utils.ts +70 -19
|
@@ -10,8 +10,32 @@ import type { DaemonRuntimeInfo } from "../daemon/control-types";
|
|
|
10
10
|
import { resolveGjcRuntimeSpawnInfo } from "../daemon/runtime";
|
|
11
11
|
import { getNotificationConfig, isGloballyConfigured, tokenFingerprint } from "./config";
|
|
12
12
|
import { parseInThreadConfigCommand } from "./config-commands";
|
|
13
|
-
import {
|
|
13
|
+
import { buildCompactChoiceGrid, TELEGRAM_PARSE_MODE } from "./html-format";
|
|
14
|
+
import type {
|
|
15
|
+
SessionCloseTarget,
|
|
16
|
+
SessionCreateTarget,
|
|
17
|
+
SessionLifecycleRequest,
|
|
18
|
+
SessionLifecycleResponse,
|
|
19
|
+
SessionResumeTarget,
|
|
20
|
+
} from "./index";
|
|
21
|
+
import {
|
|
22
|
+
formatLifecycleOutcome,
|
|
23
|
+
isLifecycleCommandText,
|
|
24
|
+
lifecycleUsage,
|
|
25
|
+
parseLifecycleCommand,
|
|
26
|
+
validateLifecycleTarget,
|
|
27
|
+
} from "./lifecycle-commands";
|
|
28
|
+
import {
|
|
29
|
+
attachLifecycleControl,
|
|
30
|
+
buildOrchestratorDeps,
|
|
31
|
+
type ControlServerLike,
|
|
32
|
+
createNativeControlServer,
|
|
33
|
+
type LifecycleControlServer,
|
|
34
|
+
type LifecycleControlServerFactory,
|
|
35
|
+
} from "./lifecycle-control-runtime";
|
|
36
|
+
import { NotificationOperatorRuntime, OperatorBackoffPolicy, OperatorEventRouter } from "./operator-runtime";
|
|
14
37
|
import { RateLimitPool } from "./rate-limit-pool";
|
|
38
|
+
import { listRecentSessions } from "./recent-activity";
|
|
15
39
|
import {
|
|
16
40
|
type AliasTable,
|
|
17
41
|
buildActionMessage,
|
|
@@ -104,6 +128,7 @@ const TYPING_REFRESH_INTERVAL_MS = 4_000;
|
|
|
104
128
|
// Native reactions used as a two-stage delivery double-check on inbound thread
|
|
105
129
|
// messages: queued on receipt, consumed once a turn picks the message up.
|
|
106
130
|
const QUEUED_REACTION = "👀";
|
|
131
|
+
const PENDING_TOPIC_FRAME_LIMIT = 20;
|
|
107
132
|
const CONSUMED_REACTION = "✅";
|
|
108
133
|
|
|
109
134
|
/**
|
|
@@ -171,6 +196,31 @@ export function daemonPaths(agentDir: string): DaemonPaths {
|
|
|
171
196
|
};
|
|
172
197
|
}
|
|
173
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Attach session-lifecycle control (create/close/resume) to the running daemon.
|
|
201
|
+
*
|
|
202
|
+
* Wires an already-started, authenticated control server to the lifecycle
|
|
203
|
+
* orchestrator with real daemon-side effects (tmux launcher / force-close /
|
|
204
|
+
* resume), a durable fsynced idempotency ledger + audit JSONL under the agent
|
|
205
|
+
* notifications dir, and strict paired-chat gating. The control server itself
|
|
206
|
+
* (NotificationControlServer) is owned/started by the daemon process; this
|
|
207
|
+
* function only connects it to policy. Returns the orchestrator deps for tests.
|
|
208
|
+
*/
|
|
209
|
+
export function startDaemonLifecycleControl(input: {
|
|
210
|
+
controlServer: ControlServerLike;
|
|
211
|
+
pairedChatId: string;
|
|
212
|
+
agentDir: string;
|
|
213
|
+
env?: NodeJS.ProcessEnv;
|
|
214
|
+
}): void {
|
|
215
|
+
const deps = buildOrchestratorDeps({
|
|
216
|
+
pairedChatId: input.pairedChatId,
|
|
217
|
+
agentNotificationsDir: daemonPaths(input.agentDir).dir,
|
|
218
|
+
sessionsRoot: path.join(input.agentDir, "sessions"),
|
|
219
|
+
env: input.env,
|
|
220
|
+
});
|
|
221
|
+
attachLifecycleControl(input.controlServer, deps);
|
|
222
|
+
}
|
|
223
|
+
|
|
174
224
|
async function ensureDir(fsImpl: TelegramDaemonFs, dir: string): Promise<void> {
|
|
175
225
|
await fsImpl.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
176
226
|
await fsImpl.chmod(dir, 0o700).catch(() => undefined);
|
|
@@ -527,6 +577,154 @@ export interface BotApi {
|
|
|
527
577
|
call(method: string, body: unknown, opts?: { signal?: AbortSignal }): Promise<unknown>;
|
|
528
578
|
}
|
|
529
579
|
|
|
580
|
+
export interface TelegramTransportOptions {
|
|
581
|
+
botToken: string;
|
|
582
|
+
apiBase?: string;
|
|
583
|
+
fetchImpl?: typeof fetch;
|
|
584
|
+
setTimeoutImpl?: typeof setTimeout;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** Telegram Bot API transport: HTTP JSON/multipart details stay out of daemon orchestration. */
|
|
588
|
+
export class TelegramBotTransport implements BotApi {
|
|
589
|
+
#opts: TelegramTransportOptions;
|
|
590
|
+
|
|
591
|
+
constructor(opts: TelegramTransportOptions) {
|
|
592
|
+
this.#opts = opts;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
async call(method: string, body: unknown, opts?: { signal?: AbortSignal }): Promise<unknown> {
|
|
596
|
+
const apiBase = this.#opts.apiBase ?? "https://api.telegram.org";
|
|
597
|
+
const url = `${apiBase}/bot${this.#opts.botToken}/${method}`;
|
|
598
|
+
const fetchImpl = this.#opts.fetchImpl ?? fetch;
|
|
599
|
+
const setTimeoutImpl = this.#opts.setTimeoutImpl ?? setTimeout;
|
|
600
|
+
const sleep = (ms: number) => new Promise<void>(resolve => setTimeoutImpl(resolve, ms));
|
|
601
|
+
// sendPhoto with base64 bytes must be a multipart upload (Telegram does
|
|
602
|
+
// not accept base64 in JSON). Other methods stay JSON.
|
|
603
|
+
const photoBody = body as { photo?: unknown; mime?: unknown } | null;
|
|
604
|
+
if (method === "sendPhoto" && photoBody && typeof photoBody.photo === "string") {
|
|
605
|
+
const b = body as {
|
|
606
|
+
chat_id: unknown;
|
|
607
|
+
message_thread_id?: unknown;
|
|
608
|
+
photo: string;
|
|
609
|
+
mime?: string;
|
|
610
|
+
caption?: string;
|
|
611
|
+
parse_mode?: string;
|
|
612
|
+
};
|
|
613
|
+
const form = new FormData();
|
|
614
|
+
form.set("chat_id", String(b.chat_id));
|
|
615
|
+
if (b.message_thread_id !== undefined) form.set("message_thread_id", String(b.message_thread_id));
|
|
616
|
+
if (b.caption) form.set("caption", b.caption);
|
|
617
|
+
if (b.parse_mode) form.set("parse_mode", String(b.parse_mode));
|
|
618
|
+
form.set("photo", new Blob([Buffer.from(b.photo, "base64")], { type: b.mime ?? "image/png" }), "image");
|
|
619
|
+
const res = await fetchWithRetry(fetchImpl, url, { method: "POST", body: form, signal: opts?.signal }, sleep);
|
|
620
|
+
return res.json();
|
|
621
|
+
}
|
|
622
|
+
const docBody = body as { document?: unknown } | null;
|
|
623
|
+
if (method === "sendDocument" && docBody && typeof docBody.document === "string") {
|
|
624
|
+
const b = body as {
|
|
625
|
+
chat_id: unknown;
|
|
626
|
+
message_thread_id?: unknown;
|
|
627
|
+
document: string;
|
|
628
|
+
mime?: string;
|
|
629
|
+
fileName?: string;
|
|
630
|
+
caption?: string;
|
|
631
|
+
parse_mode?: string;
|
|
632
|
+
};
|
|
633
|
+
const form = new FormData();
|
|
634
|
+
form.set("chat_id", String(b.chat_id));
|
|
635
|
+
if (b.message_thread_id !== undefined) form.set("message_thread_id", String(b.message_thread_id));
|
|
636
|
+
if (b.caption) form.set("caption", b.caption);
|
|
637
|
+
if (b.parse_mode) form.set("parse_mode", String(b.parse_mode));
|
|
638
|
+
form.set(
|
|
639
|
+
"document",
|
|
640
|
+
new Blob([Buffer.from(b.document, "base64")], { type: b.mime ?? "application/octet-stream" }),
|
|
641
|
+
b.fileName ?? "file",
|
|
642
|
+
);
|
|
643
|
+
const res = await fetchWithRetry(fetchImpl, url, { method: "POST", body: form, signal: opts?.signal }, sleep);
|
|
644
|
+
return res.json();
|
|
645
|
+
}
|
|
646
|
+
const res = await fetchWithRetry(
|
|
647
|
+
fetchImpl,
|
|
648
|
+
url,
|
|
649
|
+
{
|
|
650
|
+
method: "POST",
|
|
651
|
+
headers: { "content-type": "application/json" },
|
|
652
|
+
body: JSON.stringify(body),
|
|
653
|
+
signal: opts?.signal,
|
|
654
|
+
},
|
|
655
|
+
sleep,
|
|
656
|
+
);
|
|
657
|
+
return res.json();
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
export interface TelegramUpdatePollerOptions {
|
|
662
|
+
botApi: BotApi;
|
|
663
|
+
runtime: NotificationOperatorRuntime;
|
|
664
|
+
backoff: OperatorBackoffPolicy;
|
|
665
|
+
processUpdate: (update: unknown) => Promise<void>;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** Owns getUpdates offset, conflict backoff, and per-update error isolation. */
|
|
669
|
+
export class TelegramUpdatePoller {
|
|
670
|
+
#offset = 0;
|
|
671
|
+
#opts: TelegramUpdatePollerOptions;
|
|
672
|
+
|
|
673
|
+
constructor(opts: TelegramUpdatePollerOptions) {
|
|
674
|
+
this.#opts = opts;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
async pollOnce(signal?: AbortSignal): Promise<number> {
|
|
678
|
+
let body: {
|
|
679
|
+
ok?: boolean;
|
|
680
|
+
error_code?: number;
|
|
681
|
+
description?: string;
|
|
682
|
+
result?: Array<{ update_id: number } & Record<string, unknown>>;
|
|
683
|
+
};
|
|
684
|
+
try {
|
|
685
|
+
body = (await this.#opts.botApi.call(
|
|
686
|
+
"getUpdates",
|
|
687
|
+
{ offset: this.#offset, timeout: 25, allowed_updates: ["message", "callback_query"] },
|
|
688
|
+
{ signal },
|
|
689
|
+
)) as typeof body;
|
|
690
|
+
} catch (err) {
|
|
691
|
+
// A cooperative stop aborts the in-flight long poll; treat as a clean wake.
|
|
692
|
+
if (isAbortError(err)) return 0;
|
|
693
|
+
// A transient Telegram API failure must never crash the daemon.
|
|
694
|
+
logger.error("notifications daemon: getUpdates failed", { error: String(err) });
|
|
695
|
+
await this.#opts.runtime.sleep(POLL_BACKOFF_MS, signal);
|
|
696
|
+
return 0;
|
|
697
|
+
}
|
|
698
|
+
// Telegram allows only one active getUpdates poller per bot. A 409 means
|
|
699
|
+
// another poller is live; back off boundedly instead of hot-looping.
|
|
700
|
+
if (body && body.ok === false && (body.error_code === 409 || /409|conflict/i.test(body.description ?? ""))) {
|
|
701
|
+
const backoffMs = this.#opts.backoff.next();
|
|
702
|
+
logger.error(
|
|
703
|
+
`notifications daemon: Telegram getUpdates 409 conflict (${body.description ?? "no description"}); backing off ${backoffMs}ms`,
|
|
704
|
+
);
|
|
705
|
+
await this.#opts.runtime.sleep(backoffMs, signal);
|
|
706
|
+
return 0;
|
|
707
|
+
}
|
|
708
|
+
this.#opts.backoff.reset();
|
|
709
|
+
for (const update of body.result ?? []) {
|
|
710
|
+
this.#offset = update.update_id + 1;
|
|
711
|
+
try {
|
|
712
|
+
await this.#opts.processUpdate(update);
|
|
713
|
+
} catch (err) {
|
|
714
|
+
logger.error("notifications daemon: handleTelegramUpdate failed", { error: String(err) });
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
return body.result?.length ?? 0;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/** Mutable dispatch state shared by session frames and inbound Telegram updates. */
|
|
722
|
+
export class TelegramEventDispatchState {
|
|
723
|
+
readonly busy = new Set<string>();
|
|
724
|
+
readonly inboundReactions = new Map<number, { messageId: number }>();
|
|
725
|
+
readonly seenUpdateIds = new Set<number>();
|
|
726
|
+
}
|
|
727
|
+
|
|
530
728
|
/**
|
|
531
729
|
* Cooperative control seam for the daemon run loop. Implemented by the
|
|
532
730
|
* daemon-internal CLI / controller against the owner-scoped control-request
|
|
@@ -556,8 +754,17 @@ export interface TelegramDaemonOptions {
|
|
|
556
754
|
idleTimeoutMs?: number;
|
|
557
755
|
scanIntervalMs?: number;
|
|
558
756
|
pid?: number;
|
|
757
|
+
/** Liveness probe for skipping dead-PID endpoint records in {@link TelegramNotificationDaemon.scanRoots}. */
|
|
758
|
+
pidAlive?: (pid: number) => boolean;
|
|
559
759
|
botApi?: BotApi;
|
|
560
760
|
control?: DaemonControlHooks;
|
|
761
|
+
/**
|
|
762
|
+
* Factory for the session-lifecycle control server. Defaults to the real
|
|
763
|
+
* native NotificationControlServer; tests inject a fake to verify the
|
|
764
|
+
* owner-bound start/stop lifecycle without a socket. When `undefined` AND no
|
|
765
|
+
* default applies (e.g. lifecycle control disabled), no control server starts.
|
|
766
|
+
*/
|
|
767
|
+
createLifecycleControlServer?: LifecycleControlServerFactory | null;
|
|
561
768
|
}
|
|
562
769
|
|
|
563
770
|
interface SessionSocket {
|
|
@@ -575,37 +782,63 @@ interface SessionSocket {
|
|
|
575
782
|
pingTimer: ReturnType<typeof setInterval> | undefined;
|
|
576
783
|
}
|
|
577
784
|
|
|
785
|
+
interface PendingThreadedFrame {
|
|
786
|
+
send: ThreadedSend;
|
|
787
|
+
msg: Record<string, unknown>;
|
|
788
|
+
}
|
|
789
|
+
|
|
578
790
|
export class TelegramNotificationDaemon {
|
|
579
791
|
readonly aliasTable: AliasTable;
|
|
580
792
|
readonly messageRoutes = new Map<string | number, CallbackRoute | Omit<CallbackRoute, "answer">>();
|
|
581
793
|
readonly sessions = new Map<string, SessionSocket>();
|
|
794
|
+
private readonly runtime: NotificationOperatorRuntime;
|
|
795
|
+
private readonly sessionRouter: OperatorEventRouter<SessionSocket>;
|
|
796
|
+
private readonly pollConflictBackoff = new OperatorBackoffPolicy({ initialMs: 500, maxMs: 5_000 });
|
|
797
|
+
private readonly loopBackoff = new OperatorBackoffPolicy({ initialMs: 250, maxMs: 4_000 });
|
|
582
798
|
private running = false;
|
|
583
|
-
private offset = 0;
|
|
584
799
|
private readonly fsImpl: TelegramDaemonFs;
|
|
585
800
|
private readonly botApi: BotApi;
|
|
586
801
|
private readonly topics = new TopicRegistry();
|
|
587
802
|
private readonly pool: RateLimitPool<{ send: ThreadedSend; topicId?: string }>;
|
|
588
|
-
private readonly
|
|
803
|
+
private readonly poller: TelegramUpdatePoller;
|
|
804
|
+
private readonly dispatchState = new TelegramEventDispatchState();
|
|
805
|
+
/** Identity-bearing sessions by repo/branch surface, used to avoid transient duplicate topics. */
|
|
806
|
+
private readonly topicOwnerByIdentity = new Map<string, string>();
|
|
807
|
+
/** Non-identity frames held until identity creates the correct thread. */
|
|
808
|
+
private readonly pendingThreadedFrames = new Map<string, PendingThreadedFrame[]>();
|
|
589
809
|
/** True once the daemon has nudged the user to enable Threaded Mode. */
|
|
590
810
|
private threadedFallbackNoticeSent = false;
|
|
591
811
|
/** Sessions whose identity header was already sent flat (Threaded Mode off). */
|
|
592
812
|
private readonly flatIdentitySent = new Set<string>();
|
|
593
813
|
/** Cached result of whether the paired chat is a private chat (flat-fallback gate). */
|
|
594
814
|
private pairedChatPrivate: boolean | undefined;
|
|
595
|
-
private flushTimer: ReturnType<typeof setInterval> | undefined;
|
|
596
|
-
private scanTimer: ReturnType<typeof setInterval> | undefined;
|
|
597
|
-
private scanning = false;
|
|
598
|
-
private typingTimer: ReturnType<typeof setInterval> | undefined;
|
|
599
815
|
/** Sessions whose agent loop is currently busy (drives the typing indicator). */
|
|
600
|
-
private
|
|
816
|
+
private get busy(): Set<string> {
|
|
817
|
+
return this.dispatchState.busy;
|
|
818
|
+
}
|
|
601
819
|
/** Inbound update id → originating Telegram message, for delivery reactions. */
|
|
602
|
-
private
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
/**
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
820
|
+
private get inboundReactions(): Map<number, { messageId: number }> {
|
|
821
|
+
return this.dispatchState.inboundReactions;
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* The owner-bound session-lifecycle control server (create/close/resume).
|
|
825
|
+
* Started in {@link run} after ownership is confirmed (so exactly one owner
|
|
826
|
+
* ever runs one), stopped in run()'s finally on any exit path.
|
|
827
|
+
*/
|
|
828
|
+
private controlServer: LifecycleControlServer | undefined;
|
|
829
|
+
/** True while lifecycle control is active, so the loop keeps polling at idle. */
|
|
830
|
+
private lifecycleControlActive = false;
|
|
831
|
+
/** Control token (in-memory) the loopback client presents; never persisted/logged. */
|
|
832
|
+
private controlToken: string | undefined;
|
|
833
|
+
/** Loopback WS client to the daemon's own control endpoint (Option A real wire path). */
|
|
834
|
+
private controlClient: WebSocket | undefined;
|
|
835
|
+
/** Pending lifecycle responses awaiting a control-endpoint reply, by requestId. */
|
|
836
|
+
private readonly pendingLifecycle = new Map<
|
|
837
|
+
string,
|
|
838
|
+
{ resolve: (r: SessionLifecycleResponse) => void; timer: ReturnType<typeof setTimeout> }
|
|
839
|
+
>();
|
|
840
|
+
/** Monotonic counter for unique lifecycle request ids. */
|
|
841
|
+
private lifecycleSeq = 0;
|
|
609
842
|
|
|
610
843
|
/**
|
|
611
844
|
* Cooperatively stop the daemon: set the stop flag and abort the in-flight
|
|
@@ -613,91 +846,368 @@ export class TelegramNotificationDaemon {
|
|
|
613
846
|
* ~25s getUpdates timeout. Safe to call from a signal handler.
|
|
614
847
|
*/
|
|
615
848
|
requestStop(_reason?: "reload" | "stop" | "signal"): void {
|
|
616
|
-
this.
|
|
849
|
+
this.runtime.requestStop();
|
|
617
850
|
this.running = false;
|
|
618
|
-
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Start the owner-bound lifecycle control server and wire it to the
|
|
855
|
+
* orchestrator. Called from {@link run} ONLY after ownership is confirmed, so
|
|
856
|
+
* exactly one owner ever starts exactly one control server (no second poller
|
|
857
|
+
* / 409). A control-server failure degrades gracefully: the daemon keeps
|
|
858
|
+
* serving notifications without lifecycle control. Returns true when started.
|
|
859
|
+
*/
|
|
860
|
+
private async startLifecycleControl(): Promise<boolean> {
|
|
861
|
+
const factory =
|
|
862
|
+
this.opts.createLifecycleControlServer === null
|
|
863
|
+
? undefined
|
|
864
|
+
: (this.opts.createLifecycleControlServer ?? createNativeControlServer);
|
|
865
|
+
if (!factory) return false;
|
|
866
|
+
let server: LifecycleControlServer | undefined;
|
|
867
|
+
try {
|
|
868
|
+
// High-entropy, in-memory control token (never persisted raw / logged).
|
|
869
|
+
const token = crypto.randomBytes(32).toString("base64url");
|
|
870
|
+
const agentDir = this.opts.settings.getAgentDir();
|
|
871
|
+
server = factory({ token, ownerId: this.opts.ownerId, agentDir });
|
|
872
|
+
const deps = buildOrchestratorDeps({
|
|
873
|
+
pairedChatId: this.opts.chatId,
|
|
874
|
+
agentNotificationsDir: daemonPaths(agentDir).dir,
|
|
875
|
+
sessionsRoot: path.join(agentDir, "sessions"),
|
|
876
|
+
});
|
|
877
|
+
// Register the lifecycle-request handler BEFORE start(): the native
|
|
878
|
+
// control server captures the callback at start time, so wiring must
|
|
879
|
+
// precede start or forwarded requests never reach the orchestrator.
|
|
880
|
+
attachLifecycleControl(server, deps);
|
|
881
|
+
const endpoint = (await server.start()) as { url?: string } | undefined;
|
|
882
|
+
this.controlServer = server;
|
|
883
|
+
this.controlToken = token;
|
|
884
|
+
// Option A: connect a loopback WS client to our own control endpoint so
|
|
885
|
+
// parsed /session_* commands traverse the real authenticated wire path.
|
|
886
|
+
// Mark control active ONLY after the client is open, so a first-poll
|
|
887
|
+
// /session_create never races a still-CONNECTING socket.
|
|
888
|
+
const opened = endpoint?.url ? await this.connectControlClient(endpoint.url, token) : false;
|
|
889
|
+
this.lifecycleControlActive = opened;
|
|
890
|
+
if (!opened) {
|
|
891
|
+
logger.warn("notifications: lifecycle control client did not open; lifecycle commands disabled");
|
|
892
|
+
}
|
|
893
|
+
return opened;
|
|
894
|
+
} catch (e) {
|
|
895
|
+
// Never let lifecycle-control startup kill the notifications daemon.
|
|
896
|
+
// Stop any partially-started server so it cannot leak.
|
|
897
|
+
try {
|
|
898
|
+
server?.stop();
|
|
899
|
+
} catch {
|
|
900
|
+
// best-effort
|
|
901
|
+
}
|
|
902
|
+
logger.warn(`notifications: lifecycle control failed to start: ${String(e)}`);
|
|
903
|
+
this.controlServer = undefined;
|
|
904
|
+
this.lifecycleControlActive = false;
|
|
905
|
+
return false;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/** Stop the lifecycle control server (idempotent); called from run()'s finally. */
|
|
910
|
+
private stopLifecycleControl(): void {
|
|
911
|
+
this.lifecycleControlActive = false;
|
|
912
|
+
this.controlToken = undefined;
|
|
913
|
+
const client = this.controlClient;
|
|
914
|
+
this.controlClient = undefined;
|
|
915
|
+
try {
|
|
916
|
+
client?.close();
|
|
917
|
+
} catch {
|
|
918
|
+
// best-effort
|
|
919
|
+
}
|
|
920
|
+
// Reject any in-flight lifecycle requests so callers do not hang.
|
|
921
|
+
for (const [requestId, pending] of this.pendingLifecycle) {
|
|
922
|
+
clearTimeout(pending.timer);
|
|
923
|
+
pending.resolve({
|
|
924
|
+
type: "session_lifecycle_error",
|
|
925
|
+
requestId,
|
|
926
|
+
status: "error",
|
|
927
|
+
reason: "terminal_uncertain",
|
|
928
|
+
message: "control server stopped",
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
this.pendingLifecycle.clear();
|
|
932
|
+
const server = this.controlServer;
|
|
933
|
+
this.controlServer = undefined;
|
|
934
|
+
try {
|
|
935
|
+
server?.stop();
|
|
936
|
+
} catch (e) {
|
|
937
|
+
logger.warn(`notifications: lifecycle control failed to stop cleanly: ${String(e)}`);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Connect the loopback control client and resolve responses by requestId.
|
|
943
|
+
* Resolves true once the socket is OPEN (bounded), false on error/timeout, so
|
|
944
|
+
* the caller only marks lifecycle control active when commands can be sent.
|
|
945
|
+
*/
|
|
946
|
+
private connectControlClient(url: string, token: string): Promise<boolean> {
|
|
947
|
+
return new Promise<boolean>(resolve => {
|
|
948
|
+
let settled = false;
|
|
949
|
+
const finish = (ok: boolean) => {
|
|
950
|
+
if (settled) return;
|
|
951
|
+
settled = true;
|
|
952
|
+
resolve(ok);
|
|
953
|
+
};
|
|
954
|
+
try {
|
|
955
|
+
const WsCtor = this.opts.WebSocketImpl ?? WebSocket;
|
|
956
|
+
const client = new WsCtor(`${url}/?token=${encodeURIComponent(token)}`);
|
|
957
|
+
this.controlClient = client;
|
|
958
|
+
const openTimer = (this.opts.setTimeoutImpl ?? setTimeout)(() => finish(false), 5_000);
|
|
959
|
+
client.addEventListener("open", () => {
|
|
960
|
+
clearTimeout(openTimer);
|
|
961
|
+
finish(true);
|
|
962
|
+
});
|
|
963
|
+
client.addEventListener("error", () => {
|
|
964
|
+
clearTimeout(openTimer);
|
|
965
|
+
finish(false);
|
|
966
|
+
});
|
|
967
|
+
client.addEventListener("message", (ev: MessageEvent) => {
|
|
968
|
+
let msg: SessionLifecycleResponse;
|
|
969
|
+
try {
|
|
970
|
+
msg = JSON.parse(String((ev as { data: unknown }).data)) as SessionLifecycleResponse;
|
|
971
|
+
} catch {
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
const requestId = (msg as { requestId?: string }).requestId;
|
|
975
|
+
if (!requestId) return;
|
|
976
|
+
const pending = this.pendingLifecycle.get(requestId);
|
|
977
|
+
if (!pending) return;
|
|
978
|
+
clearTimeout(pending.timer);
|
|
979
|
+
this.pendingLifecycle.delete(requestId);
|
|
980
|
+
pending.resolve(msg);
|
|
981
|
+
});
|
|
982
|
+
} catch (e) {
|
|
983
|
+
logger.warn(`notifications: lifecycle control client failed to connect: ${String(e)}`);
|
|
984
|
+
finish(false);
|
|
985
|
+
}
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/** Send a lifecycle frame over the loopback client and await the response. */
|
|
990
|
+
private submitLifecycleFrame(frame: SessionLifecycleRequest): Promise<SessionLifecycleResponse> {
|
|
991
|
+
return new Promise<SessionLifecycleResponse>(resolve => {
|
|
992
|
+
const client = this.controlClient;
|
|
993
|
+
if (!client || client.readyState !== WebSocket.OPEN) {
|
|
994
|
+
resolve({
|
|
995
|
+
type: "session_lifecycle_error",
|
|
996
|
+
requestId: frame.requestId,
|
|
997
|
+
status: "error",
|
|
998
|
+
reason: "terminal_uncertain",
|
|
999
|
+
message: "lifecycle control unavailable",
|
|
1000
|
+
});
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
const timer = (this.opts.setTimeoutImpl ?? setTimeout)(() => {
|
|
1004
|
+
this.pendingLifecycle.delete(frame.requestId);
|
|
1005
|
+
resolve({
|
|
1006
|
+
type: "session_lifecycle_error",
|
|
1007
|
+
requestId: frame.requestId,
|
|
1008
|
+
status: "error",
|
|
1009
|
+
reason: "readiness_timeout",
|
|
1010
|
+
message: "lifecycle request timed out",
|
|
1011
|
+
});
|
|
1012
|
+
}, 120_000);
|
|
1013
|
+
this.pendingLifecycle.set(frame.requestId, { resolve, timer });
|
|
1014
|
+
try {
|
|
1015
|
+
client.send(JSON.stringify(frame));
|
|
1016
|
+
} catch (e) {
|
|
1017
|
+
clearTimeout(timer);
|
|
1018
|
+
this.pendingLifecycle.delete(frame.requestId);
|
|
1019
|
+
resolve({
|
|
1020
|
+
type: "session_lifecycle_error",
|
|
1021
|
+
requestId: frame.requestId,
|
|
1022
|
+
status: "error",
|
|
1023
|
+
reason: "terminal_uncertain",
|
|
1024
|
+
message: `lifecycle send failed: ${String(e)}`,
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
private nextLifecycleRequestId(): string {
|
|
1031
|
+
this.lifecycleSeq += 1;
|
|
1032
|
+
return `tg-${this.opts.ownerId}-${this.lifecycleSeq}-${crypto.randomBytes(4).toString("hex")}`;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/** Build an authenticated lifecycle frame from a parsed command + identity. */
|
|
1036
|
+
private buildLifecycleFrame(
|
|
1037
|
+
parsed:
|
|
1038
|
+
| { kind: "create"; target: SessionCreateTarget }
|
|
1039
|
+
| { kind: "close"; target: SessionCloseTarget }
|
|
1040
|
+
| { kind: "resume"; target: SessionResumeTarget },
|
|
1041
|
+
updateId: number,
|
|
1042
|
+
): SessionLifecycleRequest {
|
|
1043
|
+
const requestId = this.nextLifecycleRequestId();
|
|
1044
|
+
const token = this.controlToken ?? "";
|
|
1045
|
+
const chatId = this.opts.chatId;
|
|
1046
|
+
if (parsed.kind === "create") {
|
|
1047
|
+
return {
|
|
1048
|
+
type: "session_create",
|
|
1049
|
+
requestId,
|
|
1050
|
+
lifecycleRequestId: requestId,
|
|
1051
|
+
intendedSessionId: `s${crypto.randomBytes(6).toString("hex")}`,
|
|
1052
|
+
updateId,
|
|
1053
|
+
chatId,
|
|
1054
|
+
token,
|
|
1055
|
+
target: parsed.target,
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
if (parsed.kind === "close") {
|
|
1059
|
+
return { type: "session_close", requestId, updateId, chatId, token, target: parsed.target, force: true };
|
|
1060
|
+
}
|
|
1061
|
+
return { type: "session_resume", requestId, updateId, chatId, token, target: parsed.target };
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* Handle a paired-chat /session_* command: validate (shared validator),
|
|
1066
|
+
* route to the control endpoint, and reply with the outcome. Returns true
|
|
1067
|
+
* when the message was a lifecycle command (so the caller stops processing).
|
|
1068
|
+
*/
|
|
1069
|
+
private async handleLifecycleCommand(
|
|
1070
|
+
text: string | undefined,
|
|
1071
|
+
updateId: number | undefined,
|
|
1072
|
+
threadId: number | undefined,
|
|
1073
|
+
): Promise<boolean> {
|
|
1074
|
+
if (!isLifecycleCommandText(text)) return false;
|
|
1075
|
+
const reply = (body: string) =>
|
|
1076
|
+
this.botApi
|
|
1077
|
+
.call("sendMessage", {
|
|
1078
|
+
chat_id: this.opts.chatId,
|
|
1079
|
+
...(threadId !== undefined ? { message_thread_id: threadId } : {}),
|
|
1080
|
+
text: body,
|
|
1081
|
+
})
|
|
1082
|
+
.catch(() => undefined);
|
|
1083
|
+
|
|
1084
|
+
if (!this.lifecycleControlActive) {
|
|
1085
|
+
await reply("Session lifecycle control is not available right now.");
|
|
1086
|
+
return true;
|
|
1087
|
+
}
|
|
1088
|
+
if (updateId !== undefined && this.dispatchState.seenUpdateIds.has(updateId)) return true;
|
|
1089
|
+
if (updateId !== undefined) this.dispatchState.seenUpdateIds.add(updateId);
|
|
1090
|
+
|
|
1091
|
+
const parsed = parseLifecycleCommand(text);
|
|
1092
|
+
if (parsed.kind === "none") return false;
|
|
1093
|
+
if (parsed.kind === "usage" || parsed.kind === "reject") {
|
|
1094
|
+
await reply(parsed.message);
|
|
1095
|
+
return true;
|
|
1096
|
+
}
|
|
1097
|
+
if (parsed.kind === "recent") {
|
|
1098
|
+
const recent = listRecentSessions({
|
|
1099
|
+
sessionsRoot: path.join(this.opts.settings.getAgentDir(), "sessions"),
|
|
1100
|
+
limit: 10,
|
|
1101
|
+
});
|
|
1102
|
+
const lines = recent.length
|
|
1103
|
+
? recent.map(e => `\u2022 ${e.sessionId}${e.path ? ` (${e.path})` : ""}`).join("\n")
|
|
1104
|
+
: "No recent sessions.";
|
|
1105
|
+
await reply(lines);
|
|
1106
|
+
return true;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// Defensive shared-validator pre-check before any effect.
|
|
1110
|
+
const verb =
|
|
1111
|
+
parsed.kind === "create" ? "session_create" : parsed.kind === "close" ? "session_close" : "session_resume";
|
|
1112
|
+
const valid = validateLifecycleTarget(verb, parsed.target);
|
|
1113
|
+
if (!valid.ok) {
|
|
1114
|
+
await reply(`${valid.message}\n\n${lifecycleUsage()}`);
|
|
1115
|
+
return true;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
const frame = this.buildLifecycleFrame(parsed, updateId ?? Date.now());
|
|
1119
|
+
const response = await this.submitLifecycleFrame(frame);
|
|
1120
|
+
await reply(this.formatLifecycleResponse(response));
|
|
1121
|
+
return true;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/** Map a lifecycle response/error to a user-facing message (G010 surfacing). */
|
|
1125
|
+
private formatLifecycleResponse(r: SessionLifecycleResponse): string {
|
|
1126
|
+
return formatLifecycleOutcome(r);
|
|
619
1127
|
}
|
|
620
1128
|
|
|
621
1129
|
constructor(private readonly opts: TelegramDaemonOptions) {
|
|
622
1130
|
this.fsImpl = opts.fs ?? nodeFs;
|
|
623
1131
|
this.aliasTable = createAliasTable();
|
|
624
|
-
this.botApi =
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
caption?: string;
|
|
641
|
-
parse_mode?: string;
|
|
642
|
-
};
|
|
643
|
-
const form = new FormData();
|
|
644
|
-
form.set("chat_id", String(b.chat_id));
|
|
645
|
-
if (b.message_thread_id !== undefined) form.set("message_thread_id", String(b.message_thread_id));
|
|
646
|
-
if (b.caption) form.set("caption", b.caption);
|
|
647
|
-
if (b.parse_mode) form.set("parse_mode", String(b.parse_mode));
|
|
648
|
-
form.set("photo", new Blob([Buffer.from(b.photo, "base64")], { type: b.mime ?? "image/png" }), "image");
|
|
649
|
-
const res = await fetchWithRetry(
|
|
650
|
-
fetchImpl,
|
|
651
|
-
url,
|
|
652
|
-
{ method: "POST", body: form, signal: callOpts?.signal },
|
|
653
|
-
sleep,
|
|
654
|
-
);
|
|
655
|
-
return res.json();
|
|
656
|
-
}
|
|
657
|
-
const docBody = body as { document?: unknown } | null;
|
|
658
|
-
if (method === "sendDocument" && docBody && typeof docBody.document === "string") {
|
|
659
|
-
const b = body as {
|
|
660
|
-
chat_id: unknown;
|
|
661
|
-
message_thread_id?: unknown;
|
|
662
|
-
document: string;
|
|
663
|
-
mime?: string;
|
|
664
|
-
fileName?: string;
|
|
665
|
-
caption?: string;
|
|
666
|
-
parse_mode?: string;
|
|
667
|
-
};
|
|
668
|
-
const form = new FormData();
|
|
669
|
-
form.set("chat_id", String(b.chat_id));
|
|
670
|
-
if (b.message_thread_id !== undefined) form.set("message_thread_id", String(b.message_thread_id));
|
|
671
|
-
if (b.caption) form.set("caption", b.caption);
|
|
672
|
-
if (b.parse_mode) form.set("parse_mode", String(b.parse_mode));
|
|
673
|
-
form.set(
|
|
674
|
-
"document",
|
|
675
|
-
new Blob([Buffer.from(b.document, "base64")], { type: b.mime ?? "application/octet-stream" }),
|
|
676
|
-
b.fileName ?? "file",
|
|
677
|
-
);
|
|
678
|
-
const res = await fetchWithRetry(
|
|
679
|
-
fetchImpl,
|
|
680
|
-
url,
|
|
681
|
-
{ method: "POST", body: form, signal: callOpts?.signal },
|
|
682
|
-
sleep,
|
|
683
|
-
);
|
|
684
|
-
return res.json();
|
|
685
|
-
}
|
|
686
|
-
const res = await fetchWithRetry(
|
|
687
|
-
fetchImpl,
|
|
688
|
-
url,
|
|
689
|
-
{
|
|
690
|
-
method: "POST",
|
|
691
|
-
headers: { "content-type": "application/json" },
|
|
692
|
-
body: JSON.stringify(body),
|
|
693
|
-
signal: callOpts?.signal,
|
|
694
|
-
},
|
|
695
|
-
sleep,
|
|
696
|
-
);
|
|
697
|
-
return res.json();
|
|
698
|
-
},
|
|
699
|
-
};
|
|
1132
|
+
this.botApi =
|
|
1133
|
+
opts.botApi ??
|
|
1134
|
+
new TelegramBotTransport({
|
|
1135
|
+
botToken: opts.botToken,
|
|
1136
|
+
apiBase: opts.apiBase,
|
|
1137
|
+
fetchImpl: opts.fetchImpl,
|
|
1138
|
+
setTimeoutImpl: opts.setTimeoutImpl,
|
|
1139
|
+
});
|
|
1140
|
+
this.runtime = new NotificationOperatorRuntime({
|
|
1141
|
+
now: opts.now,
|
|
1142
|
+
setTimeoutImpl: opts.setTimeoutImpl,
|
|
1143
|
+
clearTimeoutImpl: opts.clearTimeoutImpl,
|
|
1144
|
+
setIntervalImpl: opts.setIntervalImpl,
|
|
1145
|
+
clearIntervalImpl: opts.clearIntervalImpl,
|
|
1146
|
+
});
|
|
1147
|
+
this.sessionRouter = this.createSessionRouter();
|
|
700
1148
|
this.pool = new RateLimitPool<{ send: ThreadedSend; topicId?: string }>({ now: opts.now });
|
|
1149
|
+
this.poller = new TelegramUpdatePoller({
|
|
1150
|
+
botApi: this.botApi,
|
|
1151
|
+
runtime: this.runtime,
|
|
1152
|
+
backoff: this.pollConflictBackoff,
|
|
1153
|
+
processUpdate: update => this.handleTelegramUpdate(update),
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
private createSessionRouter(): OperatorEventRouter<SessionSocket> {
|
|
1158
|
+
return new OperatorEventRouter<SessionSocket>()
|
|
1159
|
+
.add({
|
|
1160
|
+
name: "hello",
|
|
1161
|
+
matches: msg => msg.type === "hello",
|
|
1162
|
+
handle: (session, msg) => {
|
|
1163
|
+
const caps = Array.isArray(msg.capabilities) ? msg.capabilities : [];
|
|
1164
|
+
if (caps.includes(CLIENT_PING_PONG_CAPABILITY)) {
|
|
1165
|
+
session.capable = true;
|
|
1166
|
+
this.startLiveness(session);
|
|
1167
|
+
}
|
|
1168
|
+
},
|
|
1169
|
+
})
|
|
1170
|
+
.add({
|
|
1171
|
+
name: "pong",
|
|
1172
|
+
matches: msg => msg.type === "pong",
|
|
1173
|
+
handle: (session, msg) => {
|
|
1174
|
+
if (typeof msg.nonce === "string" && msg.nonce === session.awaitingNonce) {
|
|
1175
|
+
session.awaitingNonce = undefined;
|
|
1176
|
+
session.lastPongAt = this.runtime.now();
|
|
1177
|
+
}
|
|
1178
|
+
},
|
|
1179
|
+
})
|
|
1180
|
+
.add({
|
|
1181
|
+
name: "activity",
|
|
1182
|
+
matches: msg => msg.type === "activity",
|
|
1183
|
+
handle: async (session, msg) => {
|
|
1184
|
+
if (msg.state === "busy") {
|
|
1185
|
+
this.busy.add(session.sessionId);
|
|
1186
|
+
await this.sendTyping(session.sessionId);
|
|
1187
|
+
} else {
|
|
1188
|
+
this.busy.delete(session.sessionId);
|
|
1189
|
+
}
|
|
1190
|
+
},
|
|
1191
|
+
})
|
|
1192
|
+
.add({
|
|
1193
|
+
name: "inbound_ack",
|
|
1194
|
+
matches: msg => msg.type === "inbound_ack" && typeof msg.updateId === "number",
|
|
1195
|
+
handle: async (_session, msg) => {
|
|
1196
|
+
const target = this.inboundReactions.get(msg.updateId as number);
|
|
1197
|
+
if (target && msg.state === "consumed") {
|
|
1198
|
+
this.inboundReactions.delete(msg.updateId as number);
|
|
1199
|
+
await this.setReaction(target.messageId, CONSUMED_REACTION);
|
|
1200
|
+
}
|
|
1201
|
+
},
|
|
1202
|
+
})
|
|
1203
|
+
.add({
|
|
1204
|
+
name: "session_closed",
|
|
1205
|
+
matches: msg => msg.type === "session_closed",
|
|
1206
|
+
handle: async session => {
|
|
1207
|
+
this.busy.delete(session.sessionId);
|
|
1208
|
+
await this.deleteTopic(session.sessionId);
|
|
1209
|
+
},
|
|
1210
|
+
});
|
|
701
1211
|
}
|
|
702
1212
|
|
|
703
1213
|
async loadAliases(): Promise<void> {
|
|
@@ -727,6 +1237,11 @@ export class TelegramNotificationDaemon {
|
|
|
727
1237
|
if (this.sessions.has(sessionId)) continue;
|
|
728
1238
|
try {
|
|
729
1239
|
const endpoint = readEndpoint(path.join(dir, file));
|
|
1240
|
+
// Skip endpoint files whose owning process is gone or that are
|
|
1241
|
+
// explicitly stale (e.g. a hard-closed session): reconnecting
|
|
1242
|
+
// would chase a dead, token-bearing record forever.
|
|
1243
|
+
const pidAlive = this.opts.pidAlive ?? defaultPidAlive;
|
|
1244
|
+
if (endpoint.stale || (endpoint.pid !== undefined && !pidAlive(endpoint.pid))) continue;
|
|
730
1245
|
this.connectSession(sessionId, endpoint.url, endpoint.token);
|
|
731
1246
|
} catch {}
|
|
732
1247
|
}
|
|
@@ -762,6 +1277,12 @@ export class TelegramNotificationDaemon {
|
|
|
762
1277
|
);
|
|
763
1278
|
} catch {}
|
|
764
1279
|
}
|
|
1280
|
+
// Eagerly create the session's Telegram topic as soon as it connects, so
|
|
1281
|
+
// a thread exists the moment a notifications-enabled session is live —
|
|
1282
|
+
// not lazily on the first delivered frame (which only arrives once the
|
|
1283
|
+
// user sends a prompt). A provisional "GJC <id>" name is used; the
|
|
1284
|
+
// identity_header frame renames it to "{repo}/{branch} - {title}" later.
|
|
1285
|
+
void this.ensureTopic(sessionId, this.topicNameFor(sessionId, {})).catch(() => undefined);
|
|
765
1286
|
});
|
|
766
1287
|
ws.addEventListener("message", ev => {
|
|
767
1288
|
// Identity guard: a delayed frame from a superseded socket must not act
|
|
@@ -770,7 +1291,7 @@ export class TelegramNotificationDaemon {
|
|
|
770
1291
|
void this.handleSessionMessage(session, JSON.parse(String(ev.data))).catch(err => {
|
|
771
1292
|
// Surface frame-handling failures (e.g. a rejected ask sendMessage) to
|
|
772
1293
|
// the daemon log instead of an invisible unhandled rejection.
|
|
773
|
-
|
|
1294
|
+
logger.error("notifications daemon: handleSessionMessage failed", { error: String(err) });
|
|
774
1295
|
});
|
|
775
1296
|
});
|
|
776
1297
|
ws.addEventListener("close", () => {
|
|
@@ -788,7 +1309,7 @@ export class TelegramNotificationDaemon {
|
|
|
788
1309
|
private startLiveness(session: SessionSocket): void {
|
|
789
1310
|
if (session.pingTimer) return;
|
|
790
1311
|
const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
|
|
791
|
-
const now = () =>
|
|
1312
|
+
const now = () => this.runtime.now();
|
|
792
1313
|
session.lastPongAt = now();
|
|
793
1314
|
session.pingTimer = setIntervalImpl(() => {
|
|
794
1315
|
if (this.sessions.get(session.sessionId) !== session) return;
|
|
@@ -851,6 +1372,60 @@ export class TelegramNotificationDaemon {
|
|
|
851
1372
|
return `GJC ${sessionId.slice(-6)}`;
|
|
852
1373
|
}
|
|
853
1374
|
|
|
1375
|
+
private topicIdentityKey(msg: { repo?: unknown; branch?: unknown }): string | undefined {
|
|
1376
|
+
const repo = typeof msg?.repo === "string" && msg.repo.trim() ? msg.repo.trim() : undefined;
|
|
1377
|
+
if (!repo) return undefined;
|
|
1378
|
+
const branch = typeof msg?.branch === "string" && msg.branch.trim() ? msg.branch.trim() : "";
|
|
1379
|
+
return `${repo}\0${branch}`;
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
private topicIdentityBase(msg: { repo?: unknown; branch?: unknown }): string | undefined {
|
|
1383
|
+
const repo = typeof msg?.repo === "string" && msg.repo.trim() ? msg.repo.trim() : undefined;
|
|
1384
|
+
if (!repo) return undefined;
|
|
1385
|
+
const branch = typeof msg?.branch === "string" && msg.branch.trim() ? msg.branch.trim() : undefined;
|
|
1386
|
+
return branch ? `${repo}/${branch}` : repo;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
private topicOwnerForIdentity(msg: { repo?: unknown; branch?: unknown }): string | undefined {
|
|
1390
|
+
const identityKey = this.topicIdentityKey(msg);
|
|
1391
|
+
const remembered = identityKey ? this.topicOwnerByIdentity.get(identityKey) : undefined;
|
|
1392
|
+
if (remembered && this.topics.get(remembered)) return remembered;
|
|
1393
|
+
const base = this.topicIdentityBase(msg);
|
|
1394
|
+
if (!identityKey || !base) return undefined;
|
|
1395
|
+
for (const sessionId of this.topics.sessionIds()) {
|
|
1396
|
+
const name = this.topics.get(sessionId)?.name;
|
|
1397
|
+
if (name === base || name?.startsWith(`${base} - `)) {
|
|
1398
|
+
this.topicOwnerByIdentity.set(identityKey, sessionId);
|
|
1399
|
+
return sessionId;
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
return undefined;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
private async submitThreadedFrame(sessionId: string, send: ThreadedSend, topicId: string): Promise<void> {
|
|
1406
|
+
this.pool.submit({
|
|
1407
|
+
sessionId,
|
|
1408
|
+
lane: send.lane,
|
|
1409
|
+
coalesceKey: send.coalesceKey,
|
|
1410
|
+
payload: { send, topicId },
|
|
1411
|
+
});
|
|
1412
|
+
await this.flushPool();
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
private rememberPendingThreadedFrame(sessionId: string, send: ThreadedSend, msg: Record<string, unknown>): void {
|
|
1416
|
+
const frames = this.pendingThreadedFrames.get(sessionId) ?? [];
|
|
1417
|
+
frames.push({ send, msg });
|
|
1418
|
+
if (frames.length > PENDING_TOPIC_FRAME_LIMIT) frames.shift();
|
|
1419
|
+
this.pendingThreadedFrames.set(sessionId, frames);
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
private async flushPendingThreadedFrames(sessionId: string, topicId: string): Promise<void> {
|
|
1423
|
+
const frames = this.pendingThreadedFrames.get(sessionId);
|
|
1424
|
+
if (!frames || frames.length === 0) return;
|
|
1425
|
+
this.pendingThreadedFrames.delete(sessionId);
|
|
1426
|
+
for (const frame of frames) await this.submitThreadedFrame(sessionId, frame.send, topicId);
|
|
1427
|
+
}
|
|
1428
|
+
|
|
854
1429
|
/**
|
|
855
1430
|
* Resolve (creating once via `createForumTopic`) the forum topic for a
|
|
856
1431
|
* session. On capability failure (e.g. Threaded Mode off) this returns
|
|
@@ -873,8 +1448,12 @@ export class TelegramNotificationDaemon {
|
|
|
873
1448
|
return String(tid);
|
|
874
1449
|
},
|
|
875
1450
|
this.opts.now,
|
|
1451
|
+
// The create winner records the name it actually used; callers that
|
|
1452
|
+
// merely JOIN an in-flight create must not overwrite it locally, or a
|
|
1453
|
+
// later identity rename would be wrongly skipped (topic stuck at the
|
|
1454
|
+
// provisional name on Telegram).
|
|
1455
|
+
name,
|
|
876
1456
|
);
|
|
877
|
-
this.topics.applyName(sessionId, name);
|
|
878
1457
|
await this.persistTopics();
|
|
879
1458
|
return rec.topicId;
|
|
880
1459
|
} catch {
|
|
@@ -882,6 +1461,31 @@ export class TelegramNotificationDaemon {
|
|
|
882
1461
|
}
|
|
883
1462
|
}
|
|
884
1463
|
|
|
1464
|
+
/** Best-effort delete of a session topic once its local notification endpoint shuts down. */
|
|
1465
|
+
private async deleteTopic(sessionId: string): Promise<void> {
|
|
1466
|
+
const record = this.topics.get(sessionId);
|
|
1467
|
+
if (!record) return;
|
|
1468
|
+
try {
|
|
1469
|
+
// Drop queued sends for this session before deleting the topic; otherwise
|
|
1470
|
+
// rate-limited frames can flush later into a deleted topic or across resume.
|
|
1471
|
+
this.pool.removeWhere(item => item.sessionId === sessionId);
|
|
1472
|
+
await this.flushPool();
|
|
1473
|
+
const res = (await this.botApi.call("deleteForumTopic", {
|
|
1474
|
+
chat_id: this.opts.chatId,
|
|
1475
|
+
message_thread_id: Number(record.topicId),
|
|
1476
|
+
})) as { ok?: boolean };
|
|
1477
|
+
if (res?.ok === false) return;
|
|
1478
|
+
this.topics.delete(sessionId);
|
|
1479
|
+
this.topicOwnerByIdentity.forEach((ownerSessionId, identityKey) => {
|
|
1480
|
+
if (ownerSessionId === sessionId) this.topicOwnerByIdentity.delete(identityKey);
|
|
1481
|
+
});
|
|
1482
|
+
this.pendingThreadedFrames.delete(sessionId);
|
|
1483
|
+
await this.persistTopics();
|
|
1484
|
+
} catch {
|
|
1485
|
+
// Best-effort: missing Telegram topic permissions must not stop teardown.
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
|
|
885
1489
|
private async persistTopics(): Promise<void> {
|
|
886
1490
|
const paths = daemonPaths(this.opts.settings.getAgentDir());
|
|
887
1491
|
await ensureDir(this.fsImpl, paths.dir);
|
|
@@ -1092,46 +1696,32 @@ export class TelegramNotificationDaemon {
|
|
|
1092
1696
|
}
|
|
1093
1697
|
|
|
1094
1698
|
private startFlushTimer(): void {
|
|
1095
|
-
|
|
1096
|
-
const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
|
|
1097
|
-
this.flushTimer = setIntervalImpl(() => {
|
|
1699
|
+
this.runtime.startInterval("telegram-flush", RATE_LIMIT_FLUSH_INTERVAL_MS, () => {
|
|
1098
1700
|
if (!this.running || this.pool.pending === 0) return;
|
|
1099
1701
|
void this.flushPool();
|
|
1100
|
-
}
|
|
1702
|
+
});
|
|
1101
1703
|
}
|
|
1102
1704
|
|
|
1103
1705
|
private stopFlushTimer(): void {
|
|
1104
|
-
|
|
1105
|
-
const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
|
|
1106
|
-
clearIntervalImpl(this.flushTimer);
|
|
1107
|
-
this.flushTimer = undefined;
|
|
1706
|
+
this.runtime.stopInterval("telegram-flush");
|
|
1108
1707
|
}
|
|
1109
1708
|
|
|
1110
1709
|
/** Run a root scan, guarding against overlapping scans from the timer + loop. */
|
|
1111
1710
|
private async runScan(): Promise<void> {
|
|
1112
|
-
|
|
1113
|
-
this.scanning = true;
|
|
1114
|
-
try {
|
|
1711
|
+
await this.runtime.runExclusive("telegram-scan", async () => {
|
|
1115
1712
|
await this.scanRoots();
|
|
1116
|
-
}
|
|
1117
|
-
this.scanning = false;
|
|
1118
|
-
}
|
|
1713
|
+
});
|
|
1119
1714
|
}
|
|
1120
1715
|
|
|
1121
1716
|
private startScanTimer(): void {
|
|
1122
|
-
|
|
1123
|
-
const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
|
|
1124
|
-
this.scanTimer = setIntervalImpl(() => {
|
|
1717
|
+
this.runtime.startInterval("telegram-scan", this.opts.scanIntervalMs ?? SESSION_SCAN_INTERVAL_MS, () => {
|
|
1125
1718
|
if (!this.running) return;
|
|
1126
1719
|
void this.runScan();
|
|
1127
|
-
}
|
|
1720
|
+
});
|
|
1128
1721
|
}
|
|
1129
1722
|
|
|
1130
1723
|
private stopScanTimer(): void {
|
|
1131
|
-
|
|
1132
|
-
const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
|
|
1133
|
-
clearIntervalImpl(this.scanTimer);
|
|
1134
|
-
this.scanTimer = undefined;
|
|
1724
|
+
this.runtime.stopInterval("telegram-scan");
|
|
1135
1725
|
}
|
|
1136
1726
|
|
|
1137
1727
|
/** Send a single `typing` chat action into a busy session's topic (best-effort). */
|
|
@@ -1163,67 +1753,43 @@ export class TelegramNotificationDaemon {
|
|
|
1163
1753
|
}
|
|
1164
1754
|
|
|
1165
1755
|
private startTypingTimer(): void {
|
|
1166
|
-
|
|
1167
|
-
const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
|
|
1168
|
-
this.typingTimer = setIntervalImpl(() => {
|
|
1756
|
+
this.runtime.startInterval("telegram-typing", TYPING_REFRESH_INTERVAL_MS, () => {
|
|
1169
1757
|
if (!this.running || this.busy.size === 0) return;
|
|
1170
1758
|
for (const sessionId of this.busy) void this.sendTyping(sessionId);
|
|
1171
|
-
}
|
|
1759
|
+
});
|
|
1172
1760
|
}
|
|
1173
1761
|
|
|
1174
1762
|
private stopTypingTimer(): void {
|
|
1175
|
-
|
|
1176
|
-
const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
|
|
1177
|
-
clearIntervalImpl(this.typingTimer);
|
|
1178
|
-
this.typingTimer = undefined;
|
|
1763
|
+
this.runtime.stopInterval("telegram-typing");
|
|
1179
1764
|
}
|
|
1180
1765
|
|
|
1181
1766
|
async handleSessionMessage(session: SessionSocket, msg: any): Promise<void> {
|
|
1182
|
-
if (msg
|
|
1183
|
-
const caps = Array.isArray(msg.capabilities) ? msg.capabilities : [];
|
|
1184
|
-
if (caps.includes(CLIENT_PING_PONG_CAPABILITY)) {
|
|
1185
|
-
session.capable = true;
|
|
1186
|
-
this.startLiveness(session);
|
|
1187
|
-
}
|
|
1188
|
-
return;
|
|
1189
|
-
}
|
|
1190
|
-
if (msg?.type === "pong") {
|
|
1191
|
-
if (typeof msg.nonce === "string" && msg.nonce === session.awaitingNonce) {
|
|
1192
|
-
session.awaitingNonce = undefined;
|
|
1193
|
-
session.lastPongAt = (this.opts.now ?? Date.now)();
|
|
1194
|
-
}
|
|
1195
|
-
return;
|
|
1196
|
-
}
|
|
1197
|
-
// Live typing indicator: track busy/idle per session and push an immediate
|
|
1198
|
-
// chat action so "typing…" appears without waiting for the refresh tick.
|
|
1199
|
-
if (msg?.type === "activity") {
|
|
1200
|
-
if (msg.state === "busy") {
|
|
1201
|
-
this.busy.add(session.sessionId);
|
|
1202
|
-
await this.sendTyping(session.sessionId);
|
|
1203
|
-
} else {
|
|
1204
|
-
this.busy.delete(session.sessionId);
|
|
1205
|
-
}
|
|
1206
|
-
return;
|
|
1207
|
-
}
|
|
1208
|
-
// Inbound delivery double-check: flip the queued reaction to the consumed
|
|
1209
|
-
// reaction once the session reports a turn picked the message up.
|
|
1210
|
-
if (msg?.type === "inbound_ack" && typeof msg.updateId === "number") {
|
|
1211
|
-
const target = this.inboundReactions.get(msg.updateId);
|
|
1212
|
-
if (target && msg.state === "consumed") {
|
|
1213
|
-
this.inboundReactions.delete(msg.updateId);
|
|
1214
|
-
await this.setReaction(target.messageId, CONSUMED_REACTION);
|
|
1215
|
-
}
|
|
1216
|
-
return;
|
|
1217
|
-
}
|
|
1767
|
+
if (await this.sessionRouter.dispatch(session, msg as Record<string, unknown>)) return;
|
|
1218
1768
|
if (typeof msg?.type === "string" && TelegramNotificationDaemon.THREADED_FRAMES.has(msg.type)) {
|
|
1219
1769
|
const send = renderThreadedFrame(msg);
|
|
1220
1770
|
if (!send) return;
|
|
1221
|
-
const
|
|
1771
|
+
const existingTopic = this.topics.get(session.sessionId)?.topicId;
|
|
1772
|
+
if (!send.identity && !existingTopic && !this.flatIdentitySent.has(session.sessionId)) {
|
|
1773
|
+
this.rememberPendingThreadedFrame(session.sessionId, send, msg as Record<string, unknown>);
|
|
1774
|
+
return;
|
|
1775
|
+
}
|
|
1776
|
+
if (send.identity) {
|
|
1777
|
+
const ownerId = this.topicOwnerForIdentity(msg);
|
|
1778
|
+
const ownerTopic = ownerId ? this.topics.get(ownerId) : undefined;
|
|
1779
|
+
if (ownerId && ownerId !== session.sessionId && ownerTopic) {
|
|
1780
|
+
await this.flushPendingThreadedFrames(session.sessionId, ownerTopic.topicId);
|
|
1781
|
+
return;
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
const topicId =
|
|
1785
|
+
existingTopic ?? (await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg)));
|
|
1222
1786
|
if (!topicId) {
|
|
1223
1787
|
await this.deliverFlatFallback(session.sessionId, send);
|
|
1224
1788
|
return;
|
|
1225
1789
|
}
|
|
1226
1790
|
if (send.identity) {
|
|
1791
|
+
const identityKey = this.topicIdentityKey(msg);
|
|
1792
|
+
if (identityKey) this.topicOwnerByIdentity.set(identityKey, session.sessionId);
|
|
1227
1793
|
// Rename the topic if the title changed (e.g. the session title was
|
|
1228
1794
|
// auto-generated after the topic was first created). This runs on
|
|
1229
1795
|
// every identity frame, but does NOT re-send the bulleted message.
|
|
@@ -1241,25 +1807,14 @@ export class TelegramNotificationDaemon {
|
|
|
1241
1807
|
}
|
|
1242
1808
|
// Send the full bulleted identity header EXACTLY ONCE per topic.
|
|
1243
1809
|
if (this.topics.needsIdentity(session.sessionId)) {
|
|
1244
|
-
this.
|
|
1245
|
-
sessionId: session.sessionId,
|
|
1246
|
-
lane: send.lane,
|
|
1247
|
-
coalesceKey: send.coalesceKey,
|
|
1248
|
-
payload: { send, topicId },
|
|
1249
|
-
});
|
|
1250
|
-
await this.flushPool();
|
|
1810
|
+
await this.submitThreadedFrame(session.sessionId, send, topicId);
|
|
1251
1811
|
this.topics.markIdentitySent(session.sessionId);
|
|
1252
1812
|
}
|
|
1813
|
+
await this.flushPendingThreadedFrames(session.sessionId, topicId);
|
|
1253
1814
|
await this.persistTopics();
|
|
1254
1815
|
return;
|
|
1255
1816
|
}
|
|
1256
|
-
this.
|
|
1257
|
-
sessionId: session.sessionId,
|
|
1258
|
-
lane: send.lane,
|
|
1259
|
-
coalesceKey: send.coalesceKey,
|
|
1260
|
-
payload: { send, topicId },
|
|
1261
|
-
});
|
|
1262
|
-
await this.flushPool();
|
|
1817
|
+
await this.submitThreadedFrame(session.sessionId, send, topicId);
|
|
1263
1818
|
return;
|
|
1264
1819
|
}
|
|
1265
1820
|
if (msg.type === "action_needed" && msg.id) {
|
|
@@ -1279,9 +1834,9 @@ export class TelegramNotificationDaemon {
|
|
|
1279
1834
|
summary: msg.summary,
|
|
1280
1835
|
});
|
|
1281
1836
|
const options = Array.isArray(msg.options) ? msg.options : [];
|
|
1282
|
-
// Daemon keyboards
|
|
1283
|
-
//
|
|
1284
|
-
const inline_keyboard =
|
|
1837
|
+
// Daemon keyboards use alias callback data with compact one-based tap targets;
|
|
1838
|
+
// full option text is rendered in the message body by buildActionMessage.
|
|
1839
|
+
const inline_keyboard = buildCompactChoiceGrid(options, (i: number) =>
|
|
1285
1840
|
this.aliasTable.put({ sessionId: session.sessionId, actionId: msg.id, answer: i }),
|
|
1286
1841
|
);
|
|
1287
1842
|
const result = (await this.botApi.call("sendMessage", {
|
|
@@ -1325,6 +1880,20 @@ export class TelegramNotificationDaemon {
|
|
|
1325
1880
|
}
|
|
1326
1881
|
|
|
1327
1882
|
async handleTelegramUpdate(update: unknown): Promise<void> {
|
|
1883
|
+
// Session-lifecycle command (/session_*): handled ONLY from the paired chat,
|
|
1884
|
+
// gated before any arg parsing or side effect, and routed through the control
|
|
1885
|
+
// endpoint. Must run before threaded-injection so commands are not treated as
|
|
1886
|
+
// session input.
|
|
1887
|
+
{
|
|
1888
|
+
const m = (update as { update_id?: number; message?: Record<string, unknown> }).message;
|
|
1889
|
+
const chatId = (m?.chat as { id?: unknown } | undefined)?.id;
|
|
1890
|
+
const cmdText = typeof m?.text === "string" ? m.text : undefined;
|
|
1891
|
+
if (m !== undefined && String(chatId) === String(this.opts.chatId) && isLifecycleCommandText(cmdText)) {
|
|
1892
|
+
const updateId = (update as { update_id?: number }).update_id;
|
|
1893
|
+
const threadId = typeof m.message_thread_id === "number" ? (m.message_thread_id as number) : undefined;
|
|
1894
|
+
if (await this.handleLifecycleCommand(cmdText, updateId, threadId)) return;
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1328
1897
|
// Threaded injection: a free-text message in a known topic (not a button
|
|
1329
1898
|
// tap and not a reply to a specific ask message) injects a user turn or an
|
|
1330
1899
|
// in-thread config command. Fail-closed: paired chat + known topic +
|
|
@@ -1343,11 +1912,11 @@ export class TelegramNotificationDaemon {
|
|
|
1343
1912
|
const inbound = decideThreadedInbound(update as never, {
|
|
1344
1913
|
pairedChatId: this.opts.chatId,
|
|
1345
1914
|
topicToSession: t => this.topics.sessionForTopic(t),
|
|
1346
|
-
isDuplicate: id => this.seenUpdateIds.has(id),
|
|
1915
|
+
isDuplicate: id => this.dispatchState.seenUpdateIds.has(id),
|
|
1347
1916
|
});
|
|
1348
1917
|
if (inbound.kind === "duplicate") return;
|
|
1349
1918
|
if (inbound.kind === "inject") {
|
|
1350
|
-
this.seenUpdateIds.add(inbound.updateId);
|
|
1919
|
+
this.dispatchState.seenUpdateIds.add(inbound.updateId);
|
|
1351
1920
|
const session = this.sessions.get(inbound.sessionId);
|
|
1352
1921
|
if (session?.ws.readyState === WebSocket.OPEN) {
|
|
1353
1922
|
const attachmentResult = inbound.attachment
|
|
@@ -1425,67 +1994,7 @@ export class TelegramNotificationDaemon {
|
|
|
1425
1994
|
}
|
|
1426
1995
|
|
|
1427
1996
|
async pollOnce(signal?: AbortSignal): Promise<number> {
|
|
1428
|
-
|
|
1429
|
-
ok?: boolean;
|
|
1430
|
-
error_code?: number;
|
|
1431
|
-
description?: string;
|
|
1432
|
-
result?: Array<{ update_id: number } & Record<string, unknown>>;
|
|
1433
|
-
};
|
|
1434
|
-
try {
|
|
1435
|
-
body = (await this.botApi.call(
|
|
1436
|
-
"getUpdates",
|
|
1437
|
-
{ offset: this.offset, timeout: 25, allowed_updates: ["message", "callback_query"] },
|
|
1438
|
-
{ signal },
|
|
1439
|
-
)) as typeof body;
|
|
1440
|
-
} catch (err) {
|
|
1441
|
-
// A cooperative stop aborts the in-flight long poll; treat as a clean wake.
|
|
1442
|
-
if (isAbortError(err)) return 0;
|
|
1443
|
-
// A transient Telegram API failure (e.g. ECONNRESET on the long-poll) must
|
|
1444
|
-
// never crash the daemon — that silently stops all delivery, including ask
|
|
1445
|
-
// notifications. Log, back off, and let the run loop retry.
|
|
1446
|
-
console.error("notifications daemon: getUpdates failed:", err);
|
|
1447
|
-
await this.sleep(POLL_BACKOFF_MS, signal);
|
|
1448
|
-
return 0;
|
|
1449
|
-
}
|
|
1450
|
-
// Telegram allows only one active getUpdates poller per bot. A 409 means
|
|
1451
|
-
// another poller is live; back off boundedly instead of hot-looping.
|
|
1452
|
-
if (body && body.ok === false && (body.error_code === 409 || /409|conflict/i.test(body.description ?? ""))) {
|
|
1453
|
-
this.pollConflictBackoffMs = Math.min(
|
|
1454
|
-
this.pollConflictBackoffMs ? this.pollConflictBackoffMs * 2 : 500,
|
|
1455
|
-
5_000,
|
|
1456
|
-
);
|
|
1457
|
-
console.error(
|
|
1458
|
-
`notifications daemon: Telegram getUpdates 409 conflict (${body.description ?? "no description"}); backing off ${this.pollConflictBackoffMs}ms`,
|
|
1459
|
-
);
|
|
1460
|
-
await this.sleep(this.pollConflictBackoffMs, signal);
|
|
1461
|
-
return 0;
|
|
1462
|
-
}
|
|
1463
|
-
this.pollConflictBackoffMs = 0;
|
|
1464
|
-
for (const update of body.result ?? []) {
|
|
1465
|
-
this.offset = update.update_id + 1;
|
|
1466
|
-
try {
|
|
1467
|
-
await this.handleTelegramUpdate(update);
|
|
1468
|
-
} catch (err) {
|
|
1469
|
-
console.error("notifications daemon: handleTelegramUpdate failed:", err);
|
|
1470
|
-
}
|
|
1471
|
-
}
|
|
1472
|
-
return body.result?.length ?? 0;
|
|
1473
|
-
}
|
|
1474
|
-
|
|
1475
|
-
/** Abortable sleep honoring the injected timer; resolves early on abort. */
|
|
1476
|
-
private sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
1477
|
-
return new Promise<void>(resolve => {
|
|
1478
|
-
if (signal?.aborted) return resolve();
|
|
1479
|
-
const timer = (this.opts.setTimeoutImpl ?? setTimeout)(() => resolve(), ms);
|
|
1480
|
-
signal?.addEventListener(
|
|
1481
|
-
"abort",
|
|
1482
|
-
() => {
|
|
1483
|
-
(this.opts.clearTimeoutImpl ?? clearTimeout)(timer);
|
|
1484
|
-
resolve();
|
|
1485
|
-
},
|
|
1486
|
-
{ once: true },
|
|
1487
|
-
);
|
|
1488
|
-
});
|
|
1997
|
+
return this.poller.pollOnce(signal);
|
|
1489
1998
|
}
|
|
1490
1999
|
|
|
1491
2000
|
/** Sync the bot's Telegram command menu to what the daemon actually handles. */
|
|
@@ -1512,6 +2021,7 @@ export class TelegramNotificationDaemon {
|
|
|
1512
2021
|
pid: this.opts.pid ?? process.pid,
|
|
1513
2022
|
});
|
|
1514
2023
|
if (!this.running) return;
|
|
2024
|
+
this.runtime.start();
|
|
1515
2025
|
this.startFlushTimer();
|
|
1516
2026
|
this.startScanTimer();
|
|
1517
2027
|
this.startTypingTimer();
|
|
@@ -1520,8 +2030,10 @@ export class TelegramNotificationDaemon {
|
|
|
1520
2030
|
await this.loadAliases();
|
|
1521
2031
|
await this.loadTopics();
|
|
1522
2032
|
await this.runScan();
|
|
1523
|
-
|
|
1524
|
-
|
|
2033
|
+
// Owner-only: start the session-lifecycle control server now that
|
|
2034
|
+
// ownership is confirmed (singleton-safe). Best-effort; degrades.
|
|
2035
|
+
await this.startLifecycleControl();
|
|
2036
|
+
let idleSince = this.runtime.now();
|
|
1525
2037
|
while (this.running) {
|
|
1526
2038
|
if (await this.controlStopRequested()) break;
|
|
1527
2039
|
if (
|
|
@@ -1536,33 +2048,42 @@ export class TelegramNotificationDaemon {
|
|
|
1536
2048
|
break;
|
|
1537
2049
|
await this.runScan();
|
|
1538
2050
|
if (await this.controlStopRequested()) break;
|
|
1539
|
-
|
|
1540
|
-
|
|
2051
|
+
const idleElapsed = this.runtime.now() - idleSince >= (this.opts.idleTimeoutMs ?? 60_000);
|
|
2052
|
+
if (this.sessions.size === 0 && !this.lifecycleControlActive) {
|
|
2053
|
+
// No sessions and no lifecycle control: idle-exit on timeout.
|
|
2054
|
+
if (idleElapsed) break;
|
|
1541
2055
|
} else {
|
|
1542
|
-
|
|
1543
|
-
|
|
2056
|
+
// Poll getUpdates when sessions exist OR lifecycle control is active
|
|
2057
|
+
// (so phone /session_* commands are received even with zero sessions).
|
|
2058
|
+
// With zero sessions, still idle-exit after the timeout so the owner
|
|
2059
|
+
// does not run forever; an active session resets the idle window.
|
|
2060
|
+
if (this.sessions.size > 0) idleSince = this.runtime.now();
|
|
2061
|
+
else if (idleElapsed) break;
|
|
2062
|
+
const activePoll = this.runtime.createAbortController();
|
|
1544
2063
|
try {
|
|
1545
|
-
await this.pollOnce(
|
|
1546
|
-
|
|
2064
|
+
await this.pollOnce(activePoll.signal);
|
|
2065
|
+
this.loopBackoff.reset();
|
|
1547
2066
|
} catch (e) {
|
|
1548
2067
|
// A transient getUpdates/network failure must not kill the
|
|
1549
2068
|
// daemon. Back off (bounded, below the heartbeat TTL) and keep
|
|
1550
2069
|
// renewing ownership at the loop top.
|
|
1551
|
-
|
|
1552
|
-
logger.warn(`notifications: getUpdates failed, backing off ${
|
|
1553
|
-
await
|
|
2070
|
+
const backoffMs = this.loopBackoff.next();
|
|
2071
|
+
logger.warn(`notifications: getUpdates failed, backing off ${backoffMs}ms: ${String(e)}`);
|
|
2072
|
+
await this.runtime.sleep(backoffMs);
|
|
1554
2073
|
continue;
|
|
1555
2074
|
} finally {
|
|
1556
|
-
this.activePoll
|
|
2075
|
+
this.runtime.clearAbortController(activePoll);
|
|
1557
2076
|
}
|
|
1558
2077
|
}
|
|
1559
2078
|
if (await this.controlStopRequested()) break;
|
|
1560
|
-
await
|
|
2079
|
+
await this.runtime.sleep(10);
|
|
1561
2080
|
}
|
|
1562
2081
|
} finally {
|
|
2082
|
+
this.runtime.stop();
|
|
1563
2083
|
this.stopFlushTimer();
|
|
1564
2084
|
this.stopScanTimer();
|
|
1565
2085
|
this.stopTypingTimer();
|
|
2086
|
+
this.stopLifecycleControl();
|
|
1566
2087
|
await this.cleanupAllAttachmentDirs();
|
|
1567
2088
|
// Persist durable state before releasing ownership so a fresh daemon
|
|
1568
2089
|
// (e.g. after reload) reloads aliases/topics seamlessly.
|
|
@@ -1580,7 +2101,7 @@ export class TelegramNotificationDaemon {
|
|
|
1580
2101
|
|
|
1581
2102
|
/** True when a signal-driven stop or an owner-scoped control request asks the loop to exit. */
|
|
1582
2103
|
private async controlStopRequested(): Promise<boolean> {
|
|
1583
|
-
if (this.stopRequested) return true;
|
|
2104
|
+
if (this.runtime.stopRequested) return true;
|
|
1584
2105
|
if (!this.opts.control) return false;
|
|
1585
2106
|
try {
|
|
1586
2107
|
return await this.opts.control.shouldStop(this.opts.ownerId);
|