@sayknow-cli/coding-agent 0.3.0 → 0.3.2
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/bin/skc.js +4 -0
- package/dist/types/cli/mcp-cli.d.ts +25 -0
- package/dist/types/cli/notify-cli.d.ts +2 -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/config/settings-schema.d.ts +39 -2
- package/dist/types/deep-interview/plaintext-gate-guard.d.ts +11 -0
- package/dist/types/defaults/skc-defaults.d.ts +2 -13
- package/dist/types/extensibility/shared-events.d.ts +1 -0
- package/dist/types/extensibility/skc-plugins/compiler.d.ts +19 -0
- package/dist/types/extensibility/skc-plugins/constrained-hooks.d.ts +29 -0
- package/dist/types/extensibility/skc-plugins/index.d.ts +9 -0
- package/dist/types/extensibility/skc-plugins/injection.d.ts +9 -0
- package/dist/types/extensibility/skc-plugins/installer.d.ts +13 -0
- package/dist/types/extensibility/skc-plugins/mcp-policy.d.ts +26 -0
- package/dist/types/extensibility/skc-plugins/observability.d.ts +27 -0
- package/dist/types/extensibility/skc-plugins/prompt-appendix.d.ts +16 -0
- package/dist/types/extensibility/skc-plugins/registry.d.ts +32 -0
- package/dist/types/extensibility/skc-plugins/runtime-adapters.d.ts +64 -0
- package/dist/types/extensibility/skc-plugins/session-validation.d.ts +42 -0
- package/dist/types/extensibility/skc-plugins/types.d.ts +158 -2
- package/dist/types/extensibility/skc-plugins/validation.d.ts +8 -1
- package/dist/types/lsp/types.d.ts +2 -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/attachment-registry.d.ts +17 -0
- package/dist/types/notifications/chat-adapters.d.ts +9 -0
- package/dist/types/notifications/config.d.ts +9 -1
- package/dist/types/notifications/engine.d.ts +59 -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 +60 -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/managed-daemon.d.ts +48 -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 +133 -16
- package/dist/types/notifications/telegram-reference.d.ts +3 -1
- package/dist/types/notifications/threaded-inbound.d.ts +19 -0
- package/dist/types/notifications/threaded-render.d.ts +6 -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 +16 -4
- package/dist/types/session/blob-store.d.ts +25 -0
- package/dist/types/session/session-manager.d.ts +57 -0
- package/dist/types/skc-runtime/launch-tmux.d.ts +1 -0
- package/dist/types/skc-runtime/psmux-detect.d.ts +78 -0
- package/dist/types/skc-runtime/ralplan-runtime.d.ts +1 -1
- package/dist/types/skc-runtime/team-runtime.d.ts +2 -0
- package/dist/types/skc-runtime/tmux-common.d.ts +30 -2
- package/dist/types/skc-runtime/tmux-sessions.d.ts +18 -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/fetch.d.ts +23 -0
- package/dist/types/tools/index.d.ts +4 -1
- package/dist/types/tools/telegram-send.d.ts +32 -0
- package/dist/types/utils/changelog.d.ts +1 -0
- package/dist/types/web/insane/bridge.d.ts +103 -0
- package/dist/types/web/insane/url-guard.d.ts +25 -0
- package/dist/types/web/scrapers/types.d.ts +5 -0
- package/dist/types/web/scrapers/utils.d.ts +7 -1
- package/dist/types/web/search/provider.d.ts +18 -1
- package/dist/types/web/search/providers/insane.d.ts +53 -0
- package/dist/types/web/search/providers/text-citations.d.ts +23 -0
- package/dist/types/web/search/types.d.ts +12 -4
- package/package.json +14 -10
- package/scripts/g004-tmux-smoke.ts +100 -0
- package/scripts/g005-daemon-smoke.ts +180 -0
- package/scripts/g011-daemon-path-smoke.ts +153 -0
- package/scripts/verify-insane-vendor.ts +132 -0
- package/src/cli/args.ts +1 -1
- package/src/cli/fast-help.ts +30 -2
- package/src/cli/mcp-cli.ts +272 -0
- package/src/cli/notify-cli.ts +152 -5
- package/src/cli/plugin-cli.ts +66 -3
- package/src/cli.ts +30 -12
- package/src/commands/mcp.ts +117 -0
- package/src/commands/plugin.ts +4 -0
- package/src/commands/session.ts +18 -0
- package/src/commands/team.ts +1 -1
- package/src/config/keybindings.ts +2 -2
- package/src/config/model-profile-activation.ts +55 -7
- package/src/config/settings-schema.ts +30 -1
- package/src/deep-interview/plaintext-gate-guard.ts +94 -0
- package/src/defaults/skc/extensions/grok-cli-vendor/biome.json +1 -1
- package/src/defaults/skc/skills/deep-interview/SKILL.md +7 -6
- package/src/defaults/skc/skills/ralplan/SKILL.md +11 -4
- package/src/defaults/skc/skills/team/SKILL.md +5 -3
- package/src/defaults/skc/skills/ultragoal/SKILL.md +41 -13
- package/src/defaults/skc-defaults.ts +2 -27
- package/src/export/html/index.ts +2 -2
- package/src/extensibility/extensions/runner.ts +1 -0
- package/src/extensibility/shared-events.ts +1 -0
- package/src/extensibility/skc-plugins/compiler.ts +351 -0
- package/src/extensibility/skc-plugins/constrained-hooks.ts +170 -0
- package/src/extensibility/skc-plugins/index.ts +9 -0
- package/src/extensibility/skc-plugins/injection.ts +109 -0
- package/src/extensibility/skc-plugins/installer.ts +434 -0
- package/src/extensibility/skc-plugins/loader.ts +3 -1
- package/src/extensibility/skc-plugins/mcp-policy.ts +239 -0
- package/src/extensibility/skc-plugins/observability.ts +84 -0
- package/src/extensibility/skc-plugins/paths.ts +1 -1
- package/src/extensibility/skc-plugins/prompt-appendix.ts +109 -0
- package/src/extensibility/skc-plugins/registry.ts +180 -0
- package/src/extensibility/skc-plugins/runtime-adapters.ts +234 -0
- package/src/extensibility/skc-plugins/schema.ts +250 -20
- package/src/extensibility/skc-plugins/session-validation.ts +147 -0
- package/src/extensibility/skc-plugins/types.ts +199 -3
- package/src/extensibility/skc-plugins/validation.ts +80 -0
- package/src/extensibility/skills.ts +15 -0
- package/src/hooks/skill-state.ts +57 -0
- package/src/internal-urls/docs-index.generated.ts +17 -13
- package/src/lsp/config.ts +16 -3
- package/src/lsp/defaults.json +7 -0
- package/src/lsp/types.ts +2 -0
- package/src/main.ts +14 -3
- package/src/modes/bridge/bridge-mode.ts +11 -0
- package/src/modes/components/assistant-message.ts +49 -1
- 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/event-controller.ts +15 -0
- 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 +50 -3
- 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/context-usage.ts +2 -2
- package/src/modes/utils/hotkeys-markdown.ts +1 -1
- package/src/notifications/attachment-registry.ts +23 -0
- package/src/notifications/chat-adapters.ts +147 -0
- package/src/notifications/config.ts +23 -2
- package/src/notifications/engine.ts +100 -0
- package/src/notifications/html-format.ts +38 -0
- package/src/notifications/index.ts +417 -45
- package/src/notifications/lifecycle-commands.ts +238 -0
- package/src/notifications/lifecycle-control-runtime.ts +405 -0
- package/src/notifications/lifecycle-orchestrator.ts +358 -0
- package/src/notifications/managed-daemon.ts +163 -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 +984 -242
- package/src/notifications/telegram-reference.ts +25 -7
- package/src/notifications/threaded-inbound.ts +60 -4
- package/src/notifications/threaded-render.ts +20 -2
- 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 +248 -125
- package/src/session/blob-store.ts +196 -8
- package/src/session/session-manager.ts +762 -12
- package/src/skc-runtime/launch-tmux.ts +85 -22
- package/src/skc-runtime/ledger-event-renderer.ts +1 -0
- package/src/skc-runtime/psmux-detect.ts +239 -0
- package/src/skc-runtime/ralplan-runtime.ts +2 -2
- package/src/skc-runtime/team-runtime.ts +56 -23
- package/src/skc-runtime/tmux-common.ts +88 -4
- package/src/skc-runtime/tmux-sessions.ts +111 -9
- package/src/skc-runtime/ultragoal-guard.ts +25 -8
- package/src/skc-runtime/ultragoal-runtime.ts +75 -15
- package/src/skc-runtime/workflow-manifest.generated.json +29 -0
- package/src/skc-runtime/workflow-manifest.ts +7 -2
- 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 +94 -1
- package/src/tools/index.ts +6 -1
- package/src/tools/telegram-send.ts +137 -0
- package/src/utils/changelog.ts +8 -0
- package/src/web/insane/bridge.ts +350 -0
- package/src/web/insane/url-guard.ts +159 -0
- package/src/web/scrapers/types.ts +143 -45
- package/src/web/scrapers/utils.ts +70 -19
- package/src/web/search/provider.ts +77 -18
- package/src/web/search/providers/anthropic.ts +70 -3
- package/src/web/search/providers/codex.ts +1 -119
- package/src/web/search/providers/gemini.ts +99 -0
- package/src/web/search/providers/insane.ts +551 -0
- package/src/web/search/providers/openai-compatible.ts +66 -32
- package/src/web/search/providers/text-citations.ts +111 -0
- package/src/web/search/types.ts +13 -2
- package/vendor/insane-search/LICENSE +21 -0
- package/vendor/insane-search/MANIFEST.json +24 -0
- package/vendor/insane-search/engine/__init__.py +23 -0
- package/vendor/insane-search/engine/__main__.py +128 -0
- package/vendor/insane-search/engine/bias_check.py +183 -0
- package/vendor/insane-search/engine/executor.py +254 -0
- package/vendor/insane-search/engine/fetch_chain.py +725 -0
- package/vendor/insane-search/engine/learning.py +175 -0
- package/vendor/insane-search/engine/phase0.py +214 -0
- package/vendor/insane-search/engine/safety.py +91 -0
- package/vendor/insane-search/engine/templates/package.json +11 -0
- package/vendor/insane-search/engine/templates/playwright_mobile_chrome.js +188 -0
- package/vendor/insane-search/engine/templates/playwright_real_chrome.js +243 -0
- package/vendor/insane-search/engine/tests/test_hardening.py +57 -0
- package/vendor/insane-search/engine/tests/test_smoke.py +152 -0
- package/vendor/insane-search/engine/tests/test_u1.py +200 -0
- package/vendor/insane-search/engine/tests/test_u4.py +131 -0
- package/vendor/insane-search/engine/tests/test_u5.py +163 -0
- package/vendor/insane-search/engine/tests/test_u7.py +124 -0
- package/vendor/insane-search/engine/transport.py +211 -0
- package/vendor/insane-search/engine/url_transforms.py +98 -0
- package/vendor/insane-search/engine/validators.py +331 -0
- package/vendor/insane-search/engine/waf_detector.py +214 -0
- package/vendor/insane-search/engine/waf_profiles.yaml +162 -0
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { spawn as childProcessSpawn } from "node:child_process";
|
|
2
|
+
import * as crypto from "node:crypto";
|
|
2
3
|
import * as fs from "node:fs";
|
|
4
|
+
import * as os from "node:os";
|
|
3
5
|
import * as path from "node:path";
|
|
4
6
|
import { logger } from "@sayknow-cli/utils";
|
|
5
7
|
import { withFileLock } from "../config/file-lock";
|
|
@@ -8,8 +10,32 @@ import type { DaemonRuntimeInfo } from "../daemon/control-types";
|
|
|
8
10
|
import { resolveSkcRuntimeSpawnInfo } from "../daemon/runtime";
|
|
9
11
|
import { getNotificationConfig, isGloballyConfigured, tokenFingerprint } from "./config";
|
|
10
12
|
import { parseInThreadConfigCommand } from "./config-commands";
|
|
11
|
-
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";
|
|
12
37
|
import { RateLimitPool } from "./rate-limit-pool";
|
|
38
|
+
import { listRecentSessions } from "./recent-activity";
|
|
13
39
|
import {
|
|
14
40
|
type AliasTable,
|
|
15
41
|
buildActionMessage,
|
|
@@ -19,7 +45,7 @@ import {
|
|
|
19
45
|
readEndpoint,
|
|
20
46
|
routeInboundUpdate,
|
|
21
47
|
} from "./telegram-reference";
|
|
22
|
-
import { decideThreadedInbound } from "./threaded-inbound";
|
|
48
|
+
import { decideThreadedInbound, type InboundAttachment } from "./threaded-inbound";
|
|
23
49
|
import { renderThreadedFrame, type ThreadedSend } from "./threaded-render";
|
|
24
50
|
import { TopicRegistry, type TopicRegistryState } from "./topic-registry";
|
|
25
51
|
|
|
@@ -102,6 +128,7 @@ const TYPING_REFRESH_INTERVAL_MS = 4_000;
|
|
|
102
128
|
// Native reactions used as a two-stage delivery double-check on inbound thread
|
|
103
129
|
// messages: queued on receipt, consumed once a turn picks the message up.
|
|
104
130
|
const QUEUED_REACTION = "👀";
|
|
131
|
+
const PENDING_TOPIC_FRAME_LIMIT = 20;
|
|
105
132
|
const CONSUMED_REACTION = "✅";
|
|
106
133
|
|
|
107
134
|
/**
|
|
@@ -169,6 +196,31 @@ export function daemonPaths(agentDir: string): DaemonPaths {
|
|
|
169
196
|
};
|
|
170
197
|
}
|
|
171
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
|
+
|
|
172
224
|
async function ensureDir(fsImpl: TelegramDaemonFs, dir: string): Promise<void> {
|
|
173
225
|
await fsImpl.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
174
226
|
await fsImpl.chmod(dir, 0o700).catch(() => undefined);
|
|
@@ -525,6 +577,154 @@ export interface BotApi {
|
|
|
525
577
|
call(method: string, body: unknown, opts?: { signal?: AbortSignal }): Promise<unknown>;
|
|
526
578
|
}
|
|
527
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
|
+
|
|
528
728
|
/**
|
|
529
729
|
* Cooperative control seam for the daemon run loop. Implemented by the
|
|
530
730
|
* daemon-internal CLI / controller against the owner-scoped control-request
|
|
@@ -554,8 +754,17 @@ export interface TelegramDaemonOptions {
|
|
|
554
754
|
idleTimeoutMs?: number;
|
|
555
755
|
scanIntervalMs?: number;
|
|
556
756
|
pid?: number;
|
|
757
|
+
/** Liveness probe for skipping dead-PID endpoint records in {@link TelegramNotificationDaemon.scanRoots}. */
|
|
758
|
+
pidAlive?: (pid: number) => boolean;
|
|
557
759
|
botApi?: BotApi;
|
|
558
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;
|
|
559
768
|
}
|
|
560
769
|
|
|
561
770
|
interface SessionSocket {
|
|
@@ -573,31 +782,63 @@ interface SessionSocket {
|
|
|
573
782
|
pingTimer: ReturnType<typeof setInterval> | undefined;
|
|
574
783
|
}
|
|
575
784
|
|
|
785
|
+
interface PendingThreadedFrame {
|
|
786
|
+
send: ThreadedSend;
|
|
787
|
+
msg: Record<string, unknown>;
|
|
788
|
+
}
|
|
789
|
+
|
|
576
790
|
export class TelegramNotificationDaemon {
|
|
577
791
|
readonly aliasTable: AliasTable;
|
|
578
792
|
readonly messageRoutes = new Map<string | number, CallbackRoute | Omit<CallbackRoute, "answer">>();
|
|
579
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 });
|
|
580
798
|
private running = false;
|
|
581
|
-
private offset = 0;
|
|
582
799
|
private readonly fsImpl: TelegramDaemonFs;
|
|
583
800
|
private readonly botApi: BotApi;
|
|
584
801
|
private readonly topics = new TopicRegistry();
|
|
585
|
-
private readonly pool: RateLimitPool<{ send: ThreadedSend; topicId
|
|
586
|
-
private readonly
|
|
587
|
-
private
|
|
588
|
-
|
|
589
|
-
private
|
|
590
|
-
|
|
802
|
+
private readonly pool: RateLimitPool<{ send: ThreadedSend; topicId?: string }>;
|
|
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[]>();
|
|
809
|
+
/** True once the daemon has nudged the user to enable Threaded Mode. */
|
|
810
|
+
private threadedFallbackNoticeSent = false;
|
|
811
|
+
/** Sessions whose identity header was already sent flat (Threaded Mode off). */
|
|
812
|
+
private readonly flatIdentitySent = new Set<string>();
|
|
813
|
+
/** Cached result of whether the paired chat is a private chat (flat-fallback gate). */
|
|
814
|
+
private pairedChatPrivate: boolean | undefined;
|
|
591
815
|
/** Sessions whose agent loop is currently busy (drives the typing indicator). */
|
|
592
|
-
private
|
|
816
|
+
private get busy(): Set<string> {
|
|
817
|
+
return this.dispatchState.busy;
|
|
818
|
+
}
|
|
593
819
|
/** Inbound update id → originating Telegram message, for delivery reactions. */
|
|
594
|
-
private
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
/**
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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;
|
|
601
842
|
|
|
602
843
|
/**
|
|
603
844
|
* Cooperatively stop the daemon: set the stop flag and abort the in-flight
|
|
@@ -605,62 +846,368 @@ export class TelegramNotificationDaemon {
|
|
|
605
846
|
* ~25s getUpdates timeout. Safe to call from a signal handler.
|
|
606
847
|
*/
|
|
607
848
|
requestStop(_reason?: "reload" | "stop" | "signal"): void {
|
|
608
|
-
this.
|
|
849
|
+
this.runtime.requestStop();
|
|
609
850
|
this.running = false;
|
|
610
|
-
|
|
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);
|
|
611
1127
|
}
|
|
612
1128
|
|
|
613
1129
|
constructor(private readonly opts: TelegramDaemonOptions) {
|
|
614
1130
|
this.fsImpl = opts.fs ?? nodeFs;
|
|
615
1131
|
this.aliasTable = createAliasTable();
|
|
616
|
-
this.botApi =
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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();
|
|
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
|
+
});
|
|
664
1211
|
}
|
|
665
1212
|
|
|
666
1213
|
async loadAliases(): Promise<void> {
|
|
@@ -690,6 +1237,11 @@ export class TelegramNotificationDaemon {
|
|
|
690
1237
|
if (this.sessions.has(sessionId)) continue;
|
|
691
1238
|
try {
|
|
692
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;
|
|
693
1245
|
this.connectSession(sessionId, endpoint.url, endpoint.token);
|
|
694
1246
|
} catch {}
|
|
695
1247
|
}
|
|
@@ -725,6 +1277,12 @@ export class TelegramNotificationDaemon {
|
|
|
725
1277
|
);
|
|
726
1278
|
} catch {}
|
|
727
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 "SKC <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);
|
|
728
1286
|
});
|
|
729
1287
|
ws.addEventListener("message", ev => {
|
|
730
1288
|
// Identity guard: a delayed frame from a superseded socket must not act
|
|
@@ -733,7 +1291,7 @@ export class TelegramNotificationDaemon {
|
|
|
733
1291
|
void this.handleSessionMessage(session, JSON.parse(String(ev.data))).catch(err => {
|
|
734
1292
|
// Surface frame-handling failures (e.g. a rejected ask sendMessage) to
|
|
735
1293
|
// the daemon log instead of an invisible unhandled rejection.
|
|
736
|
-
|
|
1294
|
+
logger.error("notifications daemon: handleSessionMessage failed", { error: String(err) });
|
|
737
1295
|
});
|
|
738
1296
|
});
|
|
739
1297
|
ws.addEventListener("close", () => {
|
|
@@ -751,7 +1309,7 @@ export class TelegramNotificationDaemon {
|
|
|
751
1309
|
private startLiveness(session: SessionSocket): void {
|
|
752
1310
|
if (session.pingTimer) return;
|
|
753
1311
|
const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
|
|
754
|
-
const now = () =>
|
|
1312
|
+
const now = () => this.runtime.now();
|
|
755
1313
|
session.lastPongAt = now();
|
|
756
1314
|
session.pingTimer = setIntervalImpl(() => {
|
|
757
1315
|
if (this.sessions.get(session.sessionId) !== session) return;
|
|
@@ -797,6 +1355,7 @@ export class TelegramNotificationDaemon {
|
|
|
797
1355
|
"context_update",
|
|
798
1356
|
"turn_stream",
|
|
799
1357
|
"image_attachment",
|
|
1358
|
+
"file_attachment",
|
|
800
1359
|
"config_update",
|
|
801
1360
|
]);
|
|
802
1361
|
|
|
@@ -813,10 +1372,65 @@ export class TelegramNotificationDaemon {
|
|
|
813
1372
|
return `SKC ${sessionId.slice(-6)}`;
|
|
814
1373
|
}
|
|
815
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
|
+
|
|
816
1429
|
/**
|
|
817
1430
|
* Resolve (creating once via `createForumTopic`) the forum topic for a
|
|
818
|
-
* session.
|
|
819
|
-
* `undefined
|
|
1431
|
+
* session. On capability failure (e.g. Threaded Mode off) this returns
|
|
1432
|
+
* `undefined`; callers then flat-deliver to a private paired chat (with a
|
|
1433
|
+
* one-time nudge) or drop fail-closed for a non-private chat.
|
|
820
1434
|
*/
|
|
821
1435
|
private async ensureTopic(sessionId: string, name: string): Promise<string | undefined> {
|
|
822
1436
|
const existing = this.topics.get(sessionId);
|
|
@@ -834,8 +1448,12 @@ export class TelegramNotificationDaemon {
|
|
|
834
1448
|
return String(tid);
|
|
835
1449
|
},
|
|
836
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,
|
|
837
1456
|
);
|
|
838
|
-
this.topics.applyName(sessionId, name);
|
|
839
1457
|
await this.persistTopics();
|
|
840
1458
|
return rec.topicId;
|
|
841
1459
|
} catch {
|
|
@@ -843,6 +1461,31 @@ export class TelegramNotificationDaemon {
|
|
|
843
1461
|
}
|
|
844
1462
|
}
|
|
845
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
|
+
|
|
846
1489
|
private async persistTopics(): Promise<void> {
|
|
847
1490
|
const paths = daemonPaths(this.opts.settings.getAgentDir());
|
|
848
1491
|
await ensureDir(this.fsImpl, paths.dir);
|
|
@@ -857,26 +1500,140 @@ export class TelegramNotificationDaemon {
|
|
|
857
1500
|
if (raw && typeof raw === "object") this.topics.load(raw);
|
|
858
1501
|
}
|
|
859
1502
|
|
|
1503
|
+
/** Download a Telegram file by its file_path (from getFile) into memory. */
|
|
1504
|
+
private async downloadTelegramFile(filePath: string): Promise<Buffer | undefined> {
|
|
1505
|
+
const apiBase = this.opts.apiBase ?? "https://api.telegram.org";
|
|
1506
|
+
const fetchImpl = this.opts.fetchImpl ?? fetch;
|
|
1507
|
+
// `filePath` is remote metadata from getFile; reject suspicious segments
|
|
1508
|
+
// (traversal/absolute/backslash) and percent-encode each component before
|
|
1509
|
+
// composing the download URL.
|
|
1510
|
+
if (filePath.includes("..") || filePath.startsWith("/") || filePath.includes("\\")) {
|
|
1511
|
+
logger.warn("notifications: rejecting suspicious Telegram file_path");
|
|
1512
|
+
return undefined;
|
|
1513
|
+
}
|
|
1514
|
+
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
|
|
1515
|
+
const url = `${apiBase}/file/bot${this.opts.botToken}/${encodedPath}`;
|
|
1516
|
+
try {
|
|
1517
|
+
const res = await fetchImpl(url);
|
|
1518
|
+
if (!res.ok) return undefined;
|
|
1519
|
+
return Buffer.from(await res.arrayBuffer());
|
|
1520
|
+
} catch (e) {
|
|
1521
|
+
logger.warn(`notifications: file download failed: ${String(e)}`);
|
|
1522
|
+
return undefined;
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
/**
|
|
1527
|
+
* Per-session private temp directories (mode 0700) holding inbound non-image
|
|
1528
|
+
* attachments. Keyed by session id and reused across transient reconnects;
|
|
1529
|
+
* removed when the daemon stops (see {@link cleanupAllAttachmentDirs}).
|
|
1530
|
+
*/
|
|
1531
|
+
private readonly attachmentDirs = new Map<string, string>();
|
|
1532
|
+
|
|
1533
|
+
/** Lazily create a private, unguessable 0700 temp dir for `sessionId`. */
|
|
1534
|
+
private async ensureAttachmentDir(sessionId: string): Promise<string> {
|
|
1535
|
+
const existing = this.attachmentDirs.get(sessionId);
|
|
1536
|
+
if (existing) return existing;
|
|
1537
|
+
// mkdtemp creates a directory with an unguessable suffix and 0700 perms;
|
|
1538
|
+
// chmod defensively in case of an unusual platform/umask.
|
|
1539
|
+
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "skc-telegram-"));
|
|
1540
|
+
await fs.promises.chmod(dir, 0o700).catch(() => undefined);
|
|
1541
|
+
this.attachmentDirs.set(sessionId, dir);
|
|
1542
|
+
return dir;
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
/** Remove all per-session attachment directories. Called on daemon shutdown. */
|
|
1546
|
+
private async cleanupAllAttachmentDirs(): Promise<void> {
|
|
1547
|
+
const dirs = [...this.attachmentDirs.values()];
|
|
1548
|
+
this.attachmentDirs.clear();
|
|
1549
|
+
await Promise.all(dirs.map(dir => fs.promises.rm(dir, { recursive: true, force: true }).catch(() => undefined)));
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
/**
|
|
1553
|
+
* Resolve an inbound attachment to inline image bytes (forwarded as images) or
|
|
1554
|
+
* a securely-saved file path note (non-images). Non-image bytes are written
|
|
1555
|
+
* into a private per-session temp dir (0700) under an unguessable name via an
|
|
1556
|
+
* exclusive 0600 create (`wx`), so the files are not world-readable and the
|
|
1557
|
+
* write never follows a pre-existing symlink. The directory is removed when the
|
|
1558
|
+
* daemon stops. Returns base64 images to inline plus human-readable file notes
|
|
1559
|
+
* to append to the injected text.
|
|
1560
|
+
*/
|
|
1561
|
+
private async resolveInboundAttachment(
|
|
1562
|
+
att: InboundAttachment,
|
|
1563
|
+
sessionId: string,
|
|
1564
|
+
): Promise<{ images: { data: string; mime?: string }[]; fileNotes: string[] }> {
|
|
1565
|
+
const images: { data: string; mime?: string }[] = [];
|
|
1566
|
+
const fileNotes: string[] = [];
|
|
1567
|
+
const label = att.fileName ?? att.kind;
|
|
1568
|
+
try {
|
|
1569
|
+
const got = (await this.botApi.call("getFile", { file_id: att.fileId })) as {
|
|
1570
|
+
result?: { file_path?: unknown };
|
|
1571
|
+
};
|
|
1572
|
+
const filePath = typeof got?.result?.file_path === "string" ? got.result.file_path : undefined;
|
|
1573
|
+
if (!filePath) {
|
|
1574
|
+
fileNotes.push(`[attachment unavailable: ${label}]`);
|
|
1575
|
+
return { images, fileNotes };
|
|
1576
|
+
}
|
|
1577
|
+
const bytes = await this.downloadTelegramFile(filePath);
|
|
1578
|
+
if (!bytes) {
|
|
1579
|
+
fileNotes.push(`[attachment download failed: ${label}]`);
|
|
1580
|
+
return { images, fileNotes };
|
|
1581
|
+
}
|
|
1582
|
+
const isImage = att.kind === "photo" || (typeof att.mime === "string" && att.mime.startsWith("image/"));
|
|
1583
|
+
if (isImage) {
|
|
1584
|
+
images.push({ data: bytes.toString("base64"), mime: att.mime ?? "image/jpeg" });
|
|
1585
|
+
} else {
|
|
1586
|
+
const safeBase =
|
|
1587
|
+
(att.fileName?.trim() || path.basename(filePath) || `${att.kind}-${att.fileId}`)
|
|
1588
|
+
.replace(/[^\w.-]+/g, "_") // drop path separators and unusual chars
|
|
1589
|
+
.replace(/\.\.+/g, "_") // neutralize any ".." traversal-looking runs
|
|
1590
|
+
.replace(/^[.-]+/, "_") // no leading dot/hyphen
|
|
1591
|
+
.slice(-128) || "file";
|
|
1592
|
+
const dir = await this.ensureAttachmentDir(sessionId);
|
|
1593
|
+
// Unguessable, non-colliding name inside the private 0700 dir; the
|
|
1594
|
+
// exclusive 0600 create (`wx`) refuses to follow a pre-existing file/symlink.
|
|
1595
|
+
const dest = path.join(dir, `${crypto.randomBytes(8).toString("hex")}-${safeBase}`);
|
|
1596
|
+
await fs.promises.writeFile(dest, bytes, { flag: "wx", mode: 0o600 });
|
|
1597
|
+
fileNotes.push(`[user attached a file, saved to ${dest}${att.mime ? ` (${att.mime})` : ""}]`);
|
|
1598
|
+
}
|
|
1599
|
+
} catch (e) {
|
|
1600
|
+
logger.warn(`notifications: inbound attachment failed: ${String(e)}`);
|
|
1601
|
+
fileNotes.push(`[attachment error: ${label}]`);
|
|
1602
|
+
}
|
|
1603
|
+
return { images, fileNotes };
|
|
1604
|
+
}
|
|
1605
|
+
|
|
860
1606
|
/** Drain the shared rate-limit pool and deliver each granted send to its topic. */
|
|
861
1607
|
private async flushPool(): Promise<void> {
|
|
862
1608
|
for (const item of this.pool.drain()) {
|
|
863
1609
|
const { send, topicId } = item.payload;
|
|
864
|
-
|
|
1610
|
+
// Threaded topic when available; otherwise deliver flat to the paired chat.
|
|
1611
|
+
const threadField = topicId ? { message_thread_id: Number(topicId) } : {};
|
|
865
1612
|
try {
|
|
866
1613
|
if (send.method === "sendPhoto" && send.photoBase64) {
|
|
867
1614
|
// Real photo upload (the default botApi multiparts base64 -> file).
|
|
868
1615
|
await this.botApi.call("sendPhoto", {
|
|
869
1616
|
chat_id: this.opts.chatId,
|
|
870
|
-
|
|
1617
|
+
...threadField,
|
|
871
1618
|
photo: send.photoBase64,
|
|
872
1619
|
mime: send.mime,
|
|
873
1620
|
caption: send.text,
|
|
874
1621
|
parse_mode: TELEGRAM_PARSE_MODE,
|
|
875
1622
|
});
|
|
1623
|
+
} else if (send.method === "sendDocument" && send.documentBase64) {
|
|
1624
|
+
await this.botApi.call("sendDocument", {
|
|
1625
|
+
chat_id: this.opts.chatId,
|
|
1626
|
+
...threadField,
|
|
1627
|
+
document: send.documentBase64,
|
|
1628
|
+
mime: send.mime,
|
|
1629
|
+
fileName: send.fileName,
|
|
1630
|
+
caption: send.text,
|
|
1631
|
+
parse_mode: TELEGRAM_PARSE_MODE,
|
|
1632
|
+
});
|
|
876
1633
|
} else if (send.text) {
|
|
877
1634
|
await this.botApi.call("sendMessage", {
|
|
878
1635
|
chat_id: this.opts.chatId,
|
|
879
|
-
|
|
1636
|
+
...threadField,
|
|
880
1637
|
text: send.text,
|
|
881
1638
|
parse_mode: TELEGRAM_PARSE_MODE,
|
|
882
1639
|
});
|
|
@@ -887,47 +1644,84 @@ export class TelegramNotificationDaemon {
|
|
|
887
1644
|
}
|
|
888
1645
|
}
|
|
889
1646
|
|
|
1647
|
+
/**
|
|
1648
|
+
* Threaded Mode is unavailable (the bot owner has not enabled forum topics in
|
|
1649
|
+
* @BotFather, so `createForumTopic` fails). Deliver the rendered frame flat to
|
|
1650
|
+
* the paired chat instead of dropping it, and nudge the user once. Flat delivery
|
|
1651
|
+
* is gated on the paired chat being a private chat: for a group/supergroup/channel
|
|
1652
|
+
* (e.g. a legacy or hand-edited `chatId`) we keep dropping fail-closed so session
|
|
1653
|
+
* content never lands in a shared chat. Identity headers are sent at most once per
|
|
1654
|
+
* session in flat mode.
|
|
1655
|
+
*/
|
|
1656
|
+
private async deliverFlatFallback(sessionId: string, send: ThreadedSend): Promise<void> {
|
|
1657
|
+
if (!(await this.pairedChatIsPrivate())) return;
|
|
1658
|
+
await this.notifyThreadedFallback();
|
|
1659
|
+
if (send.identity && this.flatIdentitySent.has(sessionId)) return;
|
|
1660
|
+
this.pool.submit({ sessionId, lane: send.lane, coalesceKey: send.coalesceKey, payload: { send } });
|
|
1661
|
+
await this.flushPool();
|
|
1662
|
+
if (send.identity) this.flatIdentitySent.add(sessionId);
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
/**
|
|
1666
|
+
* Resolve once (cached) whether the paired `chatId` is a private chat. Flat
|
|
1667
|
+
* fallback is only safe in a private DM; any non-private chat or an unresolvable
|
|
1668
|
+
* `getChat` is treated as not-private so delivery fails closed.
|
|
1669
|
+
*/
|
|
1670
|
+
private async pairedChatIsPrivate(): Promise<boolean> {
|
|
1671
|
+
if (this.pairedChatPrivate !== undefined) return this.pairedChatPrivate;
|
|
1672
|
+
try {
|
|
1673
|
+
const res = (await this.botApi.call("getChat", { chat_id: this.opts.chatId })) as {
|
|
1674
|
+
result?: { type?: string };
|
|
1675
|
+
};
|
|
1676
|
+
this.pairedChatPrivate = res.result?.type === "private";
|
|
1677
|
+
} catch {
|
|
1678
|
+
this.pairedChatPrivate = false;
|
|
1679
|
+
}
|
|
1680
|
+
return this.pairedChatPrivate;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
/** Tell the user once (per daemon run) how to enable Threaded Mode. */
|
|
1684
|
+
private async notifyThreadedFallback(): Promise<void> {
|
|
1685
|
+
if (this.threadedFallbackNoticeSent) return;
|
|
1686
|
+
this.threadedFallbackNoticeSent = true;
|
|
1687
|
+
try {
|
|
1688
|
+
await this.botApi.call("sendMessage", {
|
|
1689
|
+
chat_id: this.opts.chatId,
|
|
1690
|
+
text: "turn on threaded mode from botfather miniapp to receive skc notification!",
|
|
1691
|
+
parse_mode: TELEGRAM_PARSE_MODE,
|
|
1692
|
+
});
|
|
1693
|
+
} catch {
|
|
1694
|
+
// Best-effort nudge; never block delivery.
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
|
|
890
1698
|
private startFlushTimer(): void {
|
|
891
|
-
|
|
892
|
-
const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
|
|
893
|
-
this.flushTimer = setIntervalImpl(() => {
|
|
1699
|
+
this.runtime.startInterval("telegram-flush", RATE_LIMIT_FLUSH_INTERVAL_MS, () => {
|
|
894
1700
|
if (!this.running || this.pool.pending === 0) return;
|
|
895
1701
|
void this.flushPool();
|
|
896
|
-
}
|
|
1702
|
+
});
|
|
897
1703
|
}
|
|
898
1704
|
|
|
899
1705
|
private stopFlushTimer(): void {
|
|
900
|
-
|
|
901
|
-
const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
|
|
902
|
-
clearIntervalImpl(this.flushTimer);
|
|
903
|
-
this.flushTimer = undefined;
|
|
1706
|
+
this.runtime.stopInterval("telegram-flush");
|
|
904
1707
|
}
|
|
905
1708
|
|
|
906
1709
|
/** Run a root scan, guarding against overlapping scans from the timer + loop. */
|
|
907
1710
|
private async runScan(): Promise<void> {
|
|
908
|
-
|
|
909
|
-
this.scanning = true;
|
|
910
|
-
try {
|
|
1711
|
+
await this.runtime.runExclusive("telegram-scan", async () => {
|
|
911
1712
|
await this.scanRoots();
|
|
912
|
-
}
|
|
913
|
-
this.scanning = false;
|
|
914
|
-
}
|
|
1713
|
+
});
|
|
915
1714
|
}
|
|
916
1715
|
|
|
917
1716
|
private startScanTimer(): void {
|
|
918
|
-
|
|
919
|
-
const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
|
|
920
|
-
this.scanTimer = setIntervalImpl(() => {
|
|
1717
|
+
this.runtime.startInterval("telegram-scan", this.opts.scanIntervalMs ?? SESSION_SCAN_INTERVAL_MS, () => {
|
|
921
1718
|
if (!this.running) return;
|
|
922
1719
|
void this.runScan();
|
|
923
|
-
}
|
|
1720
|
+
});
|
|
924
1721
|
}
|
|
925
1722
|
|
|
926
1723
|
private stopScanTimer(): void {
|
|
927
|
-
|
|
928
|
-
const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
|
|
929
|
-
clearIntervalImpl(this.scanTimer);
|
|
930
|
-
this.scanTimer = undefined;
|
|
1724
|
+
this.runtime.stopInterval("telegram-scan");
|
|
931
1725
|
}
|
|
932
1726
|
|
|
933
1727
|
/** Send a single `typing` chat action into a busy session's topic (best-effort). */
|
|
@@ -959,64 +1753,43 @@ export class TelegramNotificationDaemon {
|
|
|
959
1753
|
}
|
|
960
1754
|
|
|
961
1755
|
private startTypingTimer(): void {
|
|
962
|
-
|
|
963
|
-
const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
|
|
964
|
-
this.typingTimer = setIntervalImpl(() => {
|
|
1756
|
+
this.runtime.startInterval("telegram-typing", TYPING_REFRESH_INTERVAL_MS, () => {
|
|
965
1757
|
if (!this.running || this.busy.size === 0) return;
|
|
966
1758
|
for (const sessionId of this.busy) void this.sendTyping(sessionId);
|
|
967
|
-
}
|
|
1759
|
+
});
|
|
968
1760
|
}
|
|
969
1761
|
|
|
970
1762
|
private stopTypingTimer(): void {
|
|
971
|
-
|
|
972
|
-
const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
|
|
973
|
-
clearIntervalImpl(this.typingTimer);
|
|
974
|
-
this.typingTimer = undefined;
|
|
1763
|
+
this.runtime.stopInterval("telegram-typing");
|
|
975
1764
|
}
|
|
976
1765
|
|
|
977
1766
|
async handleSessionMessage(session: SessionSocket, msg: any): Promise<void> {
|
|
978
|
-
if (msg
|
|
979
|
-
const caps = Array.isArray(msg.capabilities) ? msg.capabilities : [];
|
|
980
|
-
if (caps.includes(CLIENT_PING_PONG_CAPABILITY)) {
|
|
981
|
-
session.capable = true;
|
|
982
|
-
this.startLiveness(session);
|
|
983
|
-
}
|
|
984
|
-
return;
|
|
985
|
-
}
|
|
986
|
-
if (msg?.type === "pong") {
|
|
987
|
-
if (typeof msg.nonce === "string" && msg.nonce === session.awaitingNonce) {
|
|
988
|
-
session.awaitingNonce = undefined;
|
|
989
|
-
session.lastPongAt = (this.opts.now ?? Date.now)();
|
|
990
|
-
}
|
|
991
|
-
return;
|
|
992
|
-
}
|
|
993
|
-
// Live typing indicator: track busy/idle per session and push an immediate
|
|
994
|
-
// chat action so "typing…" appears without waiting for the refresh tick.
|
|
995
|
-
if (msg?.type === "activity") {
|
|
996
|
-
if (msg.state === "busy") {
|
|
997
|
-
this.busy.add(session.sessionId);
|
|
998
|
-
await this.sendTyping(session.sessionId);
|
|
999
|
-
} else {
|
|
1000
|
-
this.busy.delete(session.sessionId);
|
|
1001
|
-
}
|
|
1002
|
-
return;
|
|
1003
|
-
}
|
|
1004
|
-
// Inbound delivery double-check: flip the queued reaction to the consumed
|
|
1005
|
-
// reaction once the session reports a turn picked the message up.
|
|
1006
|
-
if (msg?.type === "inbound_ack" && typeof msg.updateId === "number") {
|
|
1007
|
-
const target = this.inboundReactions.get(msg.updateId);
|
|
1008
|
-
if (target && msg.state === "consumed") {
|
|
1009
|
-
this.inboundReactions.delete(msg.updateId);
|
|
1010
|
-
await this.setReaction(target.messageId, CONSUMED_REACTION);
|
|
1011
|
-
}
|
|
1012
|
-
return;
|
|
1013
|
-
}
|
|
1767
|
+
if (await this.sessionRouter.dispatch(session, msg as Record<string, unknown>)) return;
|
|
1014
1768
|
if (typeof msg?.type === "string" && TelegramNotificationDaemon.THREADED_FRAMES.has(msg.type)) {
|
|
1015
1769
|
const send = renderThreadedFrame(msg);
|
|
1016
1770
|
if (!send) return;
|
|
1017
|
-
const
|
|
1018
|
-
if (!
|
|
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
|
+
}
|
|
1019
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)));
|
|
1786
|
+
if (!topicId) {
|
|
1787
|
+
await this.deliverFlatFallback(session.sessionId, send);
|
|
1788
|
+
return;
|
|
1789
|
+
}
|
|
1790
|
+
if (send.identity) {
|
|
1791
|
+
const identityKey = this.topicIdentityKey(msg);
|
|
1792
|
+
if (identityKey) this.topicOwnerByIdentity.set(identityKey, session.sessionId);
|
|
1020
1793
|
// Rename the topic if the title changed (e.g. the session title was
|
|
1021
1794
|
// auto-generated after the topic was first created). This runs on
|
|
1022
1795
|
// every identity frame, but does NOT re-send the bulleted message.
|
|
@@ -1034,31 +1807,25 @@ export class TelegramNotificationDaemon {
|
|
|
1034
1807
|
}
|
|
1035
1808
|
// Send the full bulleted identity header EXACTLY ONCE per topic.
|
|
1036
1809
|
if (this.topics.needsIdentity(session.sessionId)) {
|
|
1037
|
-
this.
|
|
1038
|
-
sessionId: session.sessionId,
|
|
1039
|
-
lane: send.lane,
|
|
1040
|
-
coalesceKey: send.coalesceKey,
|
|
1041
|
-
payload: { send, topicId },
|
|
1042
|
-
});
|
|
1043
|
-
await this.flushPool();
|
|
1810
|
+
await this.submitThreadedFrame(session.sessionId, send, topicId);
|
|
1044
1811
|
this.topics.markIdentitySent(session.sessionId);
|
|
1045
1812
|
}
|
|
1813
|
+
await this.flushPendingThreadedFrames(session.sessionId, topicId);
|
|
1046
1814
|
await this.persistTopics();
|
|
1047
1815
|
return;
|
|
1048
1816
|
}
|
|
1049
|
-
this.
|
|
1050
|
-
sessionId: session.sessionId,
|
|
1051
|
-
lane: send.lane,
|
|
1052
|
-
coalesceKey: send.coalesceKey,
|
|
1053
|
-
payload: { send, topicId },
|
|
1054
|
-
});
|
|
1055
|
-
await this.flushPool();
|
|
1817
|
+
await this.submitThreadedFrame(session.sessionId, send, topicId);
|
|
1056
1818
|
return;
|
|
1057
1819
|
}
|
|
1058
1820
|
if (msg.type === "action_needed" && msg.id) {
|
|
1059
1821
|
if (msg.kind === "ask") session.pending.set(msg.id, { sessionId: session.sessionId, actionId: msg.id });
|
|
1060
1822
|
const topicId = await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg));
|
|
1061
|
-
if (!topicId)
|
|
1823
|
+
if (!topicId) {
|
|
1824
|
+
// Fail closed for non-private chats; only nudge + flat-deliver in a private DM.
|
|
1825
|
+
if (!(await this.pairedChatIsPrivate())) return;
|
|
1826
|
+
await this.notifyThreadedFallback();
|
|
1827
|
+
}
|
|
1828
|
+
const threadField = topicId ? { message_thread_id: Number(topicId) } : {};
|
|
1062
1829
|
const rendered = buildActionMessage({
|
|
1063
1830
|
kind: msg.kind ?? "ask",
|
|
1064
1831
|
id: msg.id,
|
|
@@ -1067,14 +1834,14 @@ export class TelegramNotificationDaemon {
|
|
|
1067
1834
|
summary: msg.summary,
|
|
1068
1835
|
});
|
|
1069
1836
|
const options = Array.isArray(msg.options) ? msg.options : [];
|
|
1070
|
-
// Daemon keyboards
|
|
1071
|
-
//
|
|
1072
|
-
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) =>
|
|
1073
1840
|
this.aliasTable.put({ sessionId: session.sessionId, actionId: msg.id, answer: i }),
|
|
1074
1841
|
);
|
|
1075
1842
|
const result = (await this.botApi.call("sendMessage", {
|
|
1076
1843
|
chat_id: this.opts.chatId,
|
|
1077
|
-
|
|
1844
|
+
...threadField,
|
|
1078
1845
|
text: rendered.text,
|
|
1079
1846
|
parse_mode: TELEGRAM_PARSE_MODE,
|
|
1080
1847
|
...(inline_keyboard.length ? { reply_markup: { inline_keyboard } } : {}),
|
|
@@ -1113,6 +1880,20 @@ export class TelegramNotificationDaemon {
|
|
|
1113
1880
|
}
|
|
1114
1881
|
|
|
1115
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
|
+
}
|
|
1116
1897
|
// Threaded injection: a free-text message in a known topic (not a button
|
|
1117
1898
|
// tap and not a reply to a specific ask message) injects a user turn or an
|
|
1118
1899
|
// in-thread config command. Fail-closed: paired chat + known topic +
|
|
@@ -1131,19 +1912,26 @@ export class TelegramNotificationDaemon {
|
|
|
1131
1912
|
const inbound = decideThreadedInbound(update as never, {
|
|
1132
1913
|
pairedChatId: this.opts.chatId,
|
|
1133
1914
|
topicToSession: t => this.topics.sessionForTopic(t),
|
|
1134
|
-
isDuplicate: id => this.seenUpdateIds.has(id),
|
|
1915
|
+
isDuplicate: id => this.dispatchState.seenUpdateIds.has(id),
|
|
1135
1916
|
});
|
|
1136
1917
|
if (inbound.kind === "duplicate") return;
|
|
1137
1918
|
if (inbound.kind === "inject") {
|
|
1138
|
-
this.seenUpdateIds.add(inbound.updateId);
|
|
1919
|
+
this.dispatchState.seenUpdateIds.add(inbound.updateId);
|
|
1139
1920
|
const session = this.sessions.get(inbound.sessionId);
|
|
1140
1921
|
if (session?.ws.readyState === WebSocket.OPEN) {
|
|
1141
|
-
const
|
|
1922
|
+
const attachmentResult = inbound.attachment
|
|
1923
|
+
? await this.resolveInboundAttachment(inbound.attachment, inbound.sessionId)
|
|
1924
|
+
: undefined;
|
|
1925
|
+
const images = attachmentResult?.images ?? [];
|
|
1926
|
+
const fileNotes = attachmentResult?.fileNotes ?? [];
|
|
1927
|
+
const hasMedia = images.length > 0 || fileNotes.length > 0;
|
|
1928
|
+
const injectedText = [inbound.text, ...fileNotes].filter(Boolean).join("\n");
|
|
1929
|
+
const cfg = hasMedia ? undefined : parseInThreadConfigCommand(inbound.text);
|
|
1142
1930
|
// A plain (non-config) message while an ask is pending for this session
|
|
1143
1931
|
// answers that ask as free-input — instead of starting a new user turn.
|
|
1144
1932
|
// Telegram asks always accept custom text (the SDK maps a string answer
|
|
1145
1933
|
// to the ask's custom-input slot), so route the latest pending ask here.
|
|
1146
|
-
const pendingAsk = cfg ? undefined : [...session.pending.values()].at(-1);
|
|
1934
|
+
const pendingAsk = cfg || hasMedia ? undefined : [...session.pending.values()].at(-1);
|
|
1147
1935
|
if (pendingAsk) {
|
|
1148
1936
|
session.ws.send(
|
|
1149
1937
|
JSON.stringify({
|
|
@@ -1163,10 +1951,11 @@ export class TelegramNotificationDaemon {
|
|
|
1163
1951
|
: {
|
|
1164
1952
|
type: "user_message",
|
|
1165
1953
|
sessionId: inbound.sessionId,
|
|
1166
|
-
text:
|
|
1954
|
+
text: injectedText,
|
|
1167
1955
|
token: session.token,
|
|
1168
1956
|
updateId: inbound.updateId,
|
|
1169
1957
|
threadId: inbound.threadId,
|
|
1958
|
+
images,
|
|
1170
1959
|
},
|
|
1171
1960
|
),
|
|
1172
1961
|
);
|
|
@@ -1205,67 +1994,7 @@ export class TelegramNotificationDaemon {
|
|
|
1205
1994
|
}
|
|
1206
1995
|
|
|
1207
1996
|
async pollOnce(signal?: AbortSignal): Promise<number> {
|
|
1208
|
-
|
|
1209
|
-
ok?: boolean;
|
|
1210
|
-
error_code?: number;
|
|
1211
|
-
description?: string;
|
|
1212
|
-
result?: Array<{ update_id: number } & Record<string, unknown>>;
|
|
1213
|
-
};
|
|
1214
|
-
try {
|
|
1215
|
-
body = (await this.botApi.call(
|
|
1216
|
-
"getUpdates",
|
|
1217
|
-
{ offset: this.offset, timeout: 25, allowed_updates: ["message", "callback_query"] },
|
|
1218
|
-
{ signal },
|
|
1219
|
-
)) as typeof body;
|
|
1220
|
-
} catch (err) {
|
|
1221
|
-
// A cooperative stop aborts the in-flight long poll; treat as a clean wake.
|
|
1222
|
-
if (isAbortError(err)) return 0;
|
|
1223
|
-
// A transient Telegram API failure (e.g. ECONNRESET on the long-poll) must
|
|
1224
|
-
// never crash the daemon — that silently stops all delivery, including ask
|
|
1225
|
-
// notifications. Log, back off, and let the run loop retry.
|
|
1226
|
-
console.error("notifications daemon: getUpdates failed:", err);
|
|
1227
|
-
await this.sleep(POLL_BACKOFF_MS, signal);
|
|
1228
|
-
return 0;
|
|
1229
|
-
}
|
|
1230
|
-
// Telegram allows only one active getUpdates poller per bot. A 409 means
|
|
1231
|
-
// another poller is live; back off boundedly instead of hot-looping.
|
|
1232
|
-
if (body && body.ok === false && (body.error_code === 409 || /409|conflict/i.test(body.description ?? ""))) {
|
|
1233
|
-
this.pollConflictBackoffMs = Math.min(
|
|
1234
|
-
this.pollConflictBackoffMs ? this.pollConflictBackoffMs * 2 : 500,
|
|
1235
|
-
5_000,
|
|
1236
|
-
);
|
|
1237
|
-
console.error(
|
|
1238
|
-
`notifications daemon: Telegram getUpdates 409 conflict (${body.description ?? "no description"}); backing off ${this.pollConflictBackoffMs}ms`,
|
|
1239
|
-
);
|
|
1240
|
-
await this.sleep(this.pollConflictBackoffMs, signal);
|
|
1241
|
-
return 0;
|
|
1242
|
-
}
|
|
1243
|
-
this.pollConflictBackoffMs = 0;
|
|
1244
|
-
for (const update of body.result ?? []) {
|
|
1245
|
-
this.offset = update.update_id + 1;
|
|
1246
|
-
try {
|
|
1247
|
-
await this.handleTelegramUpdate(update);
|
|
1248
|
-
} catch (err) {
|
|
1249
|
-
console.error("notifications daemon: handleTelegramUpdate failed:", err);
|
|
1250
|
-
}
|
|
1251
|
-
}
|
|
1252
|
-
return body.result?.length ?? 0;
|
|
1253
|
-
}
|
|
1254
|
-
|
|
1255
|
-
/** Abortable sleep honoring the injected timer; resolves early on abort. */
|
|
1256
|
-
private sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
1257
|
-
return new Promise<void>(resolve => {
|
|
1258
|
-
if (signal?.aborted) return resolve();
|
|
1259
|
-
const timer = (this.opts.setTimeoutImpl ?? setTimeout)(() => resolve(), ms);
|
|
1260
|
-
signal?.addEventListener(
|
|
1261
|
-
"abort",
|
|
1262
|
-
() => {
|
|
1263
|
-
(this.opts.clearTimeoutImpl ?? clearTimeout)(timer);
|
|
1264
|
-
resolve();
|
|
1265
|
-
},
|
|
1266
|
-
{ once: true },
|
|
1267
|
-
);
|
|
1268
|
-
});
|
|
1997
|
+
return this.poller.pollOnce(signal);
|
|
1269
1998
|
}
|
|
1270
1999
|
|
|
1271
2000
|
/** Sync the bot's Telegram command menu to what the daemon actually handles. */
|
|
@@ -1292,6 +2021,7 @@ export class TelegramNotificationDaemon {
|
|
|
1292
2021
|
pid: this.opts.pid ?? process.pid,
|
|
1293
2022
|
});
|
|
1294
2023
|
if (!this.running) return;
|
|
2024
|
+
this.runtime.start();
|
|
1295
2025
|
this.startFlushTimer();
|
|
1296
2026
|
this.startScanTimer();
|
|
1297
2027
|
this.startTypingTimer();
|
|
@@ -1300,8 +2030,10 @@ export class TelegramNotificationDaemon {
|
|
|
1300
2030
|
await this.loadAliases();
|
|
1301
2031
|
await this.loadTopics();
|
|
1302
2032
|
await this.runScan();
|
|
1303
|
-
|
|
1304
|
-
|
|
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();
|
|
1305
2037
|
while (this.running) {
|
|
1306
2038
|
if (await this.controlStopRequested()) break;
|
|
1307
2039
|
if (
|
|
@@ -1316,33 +2048,43 @@ export class TelegramNotificationDaemon {
|
|
|
1316
2048
|
break;
|
|
1317
2049
|
await this.runScan();
|
|
1318
2050
|
if (await this.controlStopRequested()) break;
|
|
1319
|
-
|
|
1320
|
-
|
|
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;
|
|
1321
2055
|
} else {
|
|
1322
|
-
|
|
1323
|
-
|
|
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();
|
|
1324
2063
|
try {
|
|
1325
|
-
await this.pollOnce(
|
|
1326
|
-
|
|
2064
|
+
await this.pollOnce(activePoll.signal);
|
|
2065
|
+
this.loopBackoff.reset();
|
|
1327
2066
|
} catch (e) {
|
|
1328
2067
|
// A transient getUpdates/network failure must not kill the
|
|
1329
2068
|
// daemon. Back off (bounded, below the heartbeat TTL) and keep
|
|
1330
2069
|
// renewing ownership at the loop top.
|
|
1331
|
-
|
|
1332
|
-
logger.warn(`notifications: getUpdates failed, backing off ${
|
|
1333
|
-
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);
|
|
1334
2073
|
continue;
|
|
1335
2074
|
} finally {
|
|
1336
|
-
this.activePoll
|
|
2075
|
+
this.runtime.clearAbortController(activePoll);
|
|
1337
2076
|
}
|
|
1338
2077
|
}
|
|
1339
2078
|
if (await this.controlStopRequested()) break;
|
|
1340
|
-
await
|
|
2079
|
+
await this.runtime.sleep(10);
|
|
1341
2080
|
}
|
|
1342
2081
|
} finally {
|
|
2082
|
+
this.runtime.stop();
|
|
1343
2083
|
this.stopFlushTimer();
|
|
1344
2084
|
this.stopScanTimer();
|
|
1345
2085
|
this.stopTypingTimer();
|
|
2086
|
+
this.stopLifecycleControl();
|
|
2087
|
+
await this.cleanupAllAttachmentDirs();
|
|
1346
2088
|
// Persist durable state before releasing ownership so a fresh daemon
|
|
1347
2089
|
// (e.g. after reload) reloads aliases/topics seamlessly.
|
|
1348
2090
|
await this.persistAliases().catch(() => undefined);
|
|
@@ -1359,7 +2101,7 @@ export class TelegramNotificationDaemon {
|
|
|
1359
2101
|
|
|
1360
2102
|
/** True when a signal-driven stop or an owner-scoped control request asks the loop to exit. */
|
|
1361
2103
|
private async controlStopRequested(): Promise<boolean> {
|
|
1362
|
-
if (this.stopRequested) return true;
|
|
2104
|
+
if (this.runtime.stopRequested) return true;
|
|
1363
2105
|
if (!this.opts.control) return false;
|
|
1364
2106
|
try {
|
|
1365
2107
|
return await this.opts.control.shouldStop(this.opts.ownerId);
|