@gajae-code/coding-agent 0.12.0 → 0.12.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/CHANGELOG.md +111 -0
- package/dist/types/async/job-manager.d.ts +17 -3
- package/dist/types/config/settings-schema.d.ts +56 -2
- package/dist/types/exa/mcp-client.d.ts +10 -1
- package/dist/types/exec/bash-executor.d.ts +45 -12
- package/dist/types/extensibility/extensions/types.d.ts +16 -2
- package/dist/types/extensibility/gjc-plugins/index.d.ts +12 -2
- package/dist/types/extensibility/gjc-plugins/installer.d.ts +63 -7
- package/dist/types/extensibility/gjc-plugins/lifecycle-reconciliation.d.ts +55 -0
- package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +64 -0
- package/dist/types/extensibility/gjc-plugins/runtime-quarantine.d.ts +67 -0
- package/dist/types/extensibility/gjc-plugins/session-validation.d.ts +3 -1
- package/dist/types/extensibility/gjc-plugins/types.d.ts +124 -0
- package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
- package/dist/types/gjc-runtime/ralplan-review-conflicts.d.ts +114 -0
- package/dist/types/gjc-runtime/ralplan-runtime.d.ts +23 -3
- package/dist/types/gjc-runtime/team-launch.d.ts +1 -1
- package/dist/types/gjc-runtime/team-runtime.d.ts +25 -0
- package/dist/types/gjc-runtime/team-worker-heartbeat.d.ts +43 -0
- package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +10 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +37 -80
- package/dist/types/modes/acp/acp-agent.d.ts +2 -19
- package/dist/types/modes/components/gjc-bundle-settings.d.ts +34 -0
- package/dist/types/modes/components/irc-sidebar.d.ts +16 -0
- package/dist/types/modes/components/notifications-settings-editor.d.ts +1 -0
- package/dist/types/modes/components/session-observer-overlay.d.ts +18 -0
- package/dist/types/modes/components/settings-selector.d.ts +9 -0
- package/dist/types/modes/components/status-line/gh.d.ts +5 -0
- package/dist/types/modes/components/transcript-viewer-overlay.d.ts +18 -1
- package/dist/types/modes/controllers/command-controller.d.ts +1 -1
- package/dist/types/modes/controllers/event-controller.d.ts +1 -1
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -1
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -1
- package/dist/types/modes/interactive-mode.d.ts +9 -0
- package/dist/types/modes/theme/theme.d.ts +3 -1
- package/dist/types/modes/types.d.ts +32 -0
- package/dist/types/reminders/star-reminder.d.ts +3 -13
- package/dist/types/runtime-mcp/manager.d.ts +1 -0
- package/dist/types/sdk/acp/adapter.d.ts +3 -1
- package/dist/types/sdk/broker/transport.d.ts +2 -0
- package/dist/types/sdk/bus/attachment-registry.d.ts +1 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +5 -1
- package/dist/types/sdk/bus/config.d.ts +6 -2
- package/dist/types/sdk/bus/index.d.ts +29 -0
- package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +8 -1
- package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +4 -0
- package/dist/types/sdk/bus/reconciliation-store.d.ts +7 -3
- package/dist/types/sdk/bus/rich-draft.d.ts +1 -0
- package/dist/types/sdk/bus/rich-render.d.ts +2 -0
- package/dist/types/sdk/bus/telegram-adoption-intent.d.ts +165 -0
- package/dist/types/sdk/bus/telegram-cli.d.ts +12 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +12 -4
- package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -1
- package/dist/types/sdk/bus/telegram-reference.d.ts +11 -1
- package/dist/types/sdk/bus/threaded-render.d.ts +3 -2
- package/dist/types/sdk/bus/topic-registry.d.ts +11 -1
- package/dist/types/sdk/host/query/revision-store.d.ts +2 -0
- package/dist/types/sdk/host/reverse-leases.d.ts +1 -1
- package/dist/types/sdk/prompt-status.d.ts +23 -3
- package/dist/types/sdk/session.d.ts +6 -0
- package/dist/types/session/agent-session.d.ts +40 -2
- package/dist/types/session/artifacts.d.ts +8 -0
- package/dist/types/session/blob-store.d.ts +57 -4
- package/dist/types/session/internal/managed-session-storage.d.ts +30 -4
- package/dist/types/session/session-manager.d.ts +23 -6
- package/dist/types/session/session-storage.d.ts +4 -0
- package/dist/types/session/streaming-output.d.ts +36 -7
- package/dist/types/setup/credential-import.d.ts +12 -0
- package/dist/types/skill-state/active-state.d.ts +12 -0
- package/dist/types/skill-state/workflow-hud.d.ts +6 -0
- package/dist/types/task/executor.d.ts +3 -2
- package/dist/types/task/index.d.ts +7 -1
- package/dist/types/task/output-manager.d.ts +1 -0
- package/dist/types/task/provider-retry-status.d.ts +8 -0
- package/dist/types/task/receipt.d.ts +9 -1
- package/dist/types/task/types.d.ts +23 -0
- package/dist/types/tools/ask-answer-registry.d.ts +2 -2
- package/dist/types/tools/bash-interactive.d.ts +4 -1
- package/dist/types/tools/bash.d.ts +3 -1
- package/dist/types/tools/image-gen.d.ts +4 -0
- package/dist/types/tools/index.d.ts +6 -0
- package/dist/types/tools/output-meta.d.ts +23 -2
- package/dist/types/tools/path-utils.d.ts +1 -0
- package/dist/types/tools/read.d.ts +0 -6
- package/dist/types/tools/review.d.ts +5 -7
- package/dist/types/tools/subagent.d.ts +2 -0
- package/dist/types/utils/gh.d.ts +13 -0
- package/dist/types/utils/image-resize.d.ts +1 -0
- package/dist/types/web/search/providers/kimi.d.ts +2 -0
- package/dist/types/web/search/providers/searxng.d.ts +7 -0
- package/dist/types/web/search/providers/xai.d.ts +2 -0
- package/package.json +10 -8
- package/scripts/acp-conformance-agent.ts +218 -0
- package/scripts/capture-gjc-bundle-settings.ts +306 -0
- package/scripts/dogfood-ralplan-review-conflicts.ts +272 -0
- package/scripts/generate-sdk-operation-inventory.ts +5 -0
- package/scripts/resident-memory-bench.ts +1341 -0
- package/scripts/run-acp-conformance.ts +290 -0
- package/src/async/job-manager.ts +99 -39
- package/src/cli/fast-help.ts +1 -1
- package/src/cli/plugin-cli.ts +176 -18
- package/src/commands/team.ts +19 -1
- package/src/config/file-lock.ts +11 -2
- package/src/config/settings-schema.ts +53 -2
- package/src/coordinator-mcp/question-gate-codec.ts +1 -1
- package/src/coordinator-mcp/server.ts +5 -12
- package/src/debug/index.ts +42 -17
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -0
- package/src/defaults/gjc/skills/ralplan/SKILL.md +25 -20
- package/src/defaults/gjc/skills/team/SKILL.md +2 -0
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +39 -26
- package/src/defaults/gjc/skills/ultragoal/validation-batch-contracts.md +12 -0
- package/src/defaults/gjc-defaults.ts +4 -4
- package/src/exa/mcp-client.ts +12 -3
- package/src/exec/bash-executor.ts +144 -27
- package/src/extensibility/extensions/runner.ts +15 -0
- package/src/extensibility/extensions/types.ts +18 -1
- package/src/extensibility/gjc-plugins/constrained-hooks.ts +8 -2
- package/src/extensibility/gjc-plugins/index.ts +19 -2
- package/src/extensibility/gjc-plugins/installer.ts +189 -50
- package/src/extensibility/gjc-plugins/lifecycle-reconciliation.ts +199 -0
- package/src/extensibility/gjc-plugins/lifecycle.ts +626 -0
- package/src/extensibility/gjc-plugins/observability.ts +3 -3
- package/src/extensibility/gjc-plugins/prompt-appendix.ts +5 -1
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +12 -2
- package/src/extensibility/gjc-plugins/runtime-quarantine.ts +121 -0
- package/src/extensibility/gjc-plugins/schema.ts +84 -12
- package/src/extensibility/gjc-plugins/session-validation.ts +9 -3
- package/src/extensibility/gjc-plugins/types.ts +141 -0
- package/src/extensibility/gjc-plugins/validation.ts +9 -0
- package/src/gjc-runtime/launch-tmux.ts +25 -6
- package/src/gjc-runtime/launch-worktree.ts +103 -1
- package/src/gjc-runtime/ledger-event-renderer.ts +1 -0
- package/src/gjc-runtime/managed-owner-admission.ts +1 -1
- package/src/gjc-runtime/ralplan-review-conflicts.ts +472 -0
- package/src/gjc-runtime/ralplan-runtime.ts +491 -27
- package/src/gjc-runtime/team-launch.ts +8 -0
- package/src/gjc-runtime/team-runtime.ts +140 -32
- package/src/gjc-runtime/team-worker-heartbeat.ts +149 -0
- package/src/gjc-runtime/tmux-owner-isolation.ts +10 -0
- package/src/gjc-runtime/tmux-sessions.ts +58 -13
- package/src/gjc-runtime/ultragoal-evidence.ts +6 -74
- package/src/gjc-runtime/ultragoal-runtime.ts +635 -612
- package/src/gjc-runtime/workflow-manifest.generated.json +37 -99
- package/src/gjc-runtime/workflow-manifest.ts +21 -70
- package/src/internal-urls/docs-index.generated.ts +16 -16
- package/src/modes/DESIGN.md +104 -0
- package/src/modes/acp/acp-agent.ts +298 -186
- package/src/modes/components/gjc-bundle-settings.ts +508 -0
- package/src/modes/components/irc-sidebar.ts +30 -1
- package/src/modes/components/notifications-settings-editor.ts +12 -1
- package/src/modes/components/oauth-selector.ts +68 -30
- package/src/modes/components/session-observer-overlay.ts +305 -86
- package/src/modes/components/settings-selector.ts +56 -5
- package/src/modes/components/status-line/gh.ts +16 -0
- package/src/modes/components/tool-status-header.ts +3 -15
- package/src/modes/components/transcript-viewer-overlay.ts +99 -24
- package/src/modes/controllers/command-controller.ts +42 -34
- package/src/modes/controllers/event-controller.ts +27 -16
- package/src/modes/controllers/extension-ui-controller.ts +193 -34
- package/src/modes/controllers/selector-controller.ts +160 -22
- package/src/modes/interactive-mode.ts +189 -62
- package/src/modes/jobs-observer.ts +1 -1
- package/src/modes/runtime-init.ts +3 -0
- package/src/modes/theme/theme.ts +11 -9
- package/src/modes/types.ts +88 -0
- package/src/prompts/agent-fragments/ralplan-persistence.md +4 -3
- package/src/prompts/system/eager-todo.md +8 -4
- package/src/prompts/system/system-prompt.md +11 -4
- package/src/prompts/tools/bash.md +5 -5
- package/src/prompts/tools/search-tool-bm25.md +5 -0
- package/src/prompts/tools/task.md +1 -1
- package/src/reminders/star-reminder.ts +4 -42
- package/src/runtime-mcp/manager.ts +19 -4
- package/src/sdk/acp/adapter.ts +22 -6
- package/src/sdk/broker/lifecycle.ts +18 -13
- package/src/sdk/broker/transport.ts +35 -3
- package/src/sdk/bus/attachment-registry.ts +5 -1
- package/src/sdk/bus/chat-daemon-cli.ts +4 -39
- package/src/sdk/bus/chat-daemon-control.ts +7 -3
- package/src/sdk/bus/config.ts +18 -2
- package/src/sdk/bus/index.ts +661 -126
- package/src/sdk/bus/kind-aware-reconciliation.ts +148 -73
- package/src/sdk/bus/lifecycle-commands.ts +4 -1
- package/src/sdk/bus/lifecycle-control-runtime.ts +403 -88
- package/src/sdk/bus/reconciliation-store.ts +95 -16
- package/src/sdk/bus/rich-draft.ts +1 -1
- package/src/sdk/bus/rich-render.ts +2 -2
- package/src/sdk/bus/telegram-adoption-intent.ts +574 -0
- package/src/sdk/bus/telegram-cli.ts +33 -8
- package/src/sdk/bus/telegram-daemon-cli.ts +1 -0
- package/src/sdk/bus/telegram-daemon-contract.ts +12 -4
- package/src/sdk/bus/telegram-daemon.ts +916 -23
- package/src/sdk/bus/telegram-reference.ts +40 -3
- package/src/sdk/bus/threaded-render.ts +48 -3
- package/src/sdk/bus/topic-registry.ts +20 -0
- package/src/sdk/host/query/revision-store.ts +36 -2
- package/src/sdk/host/reverse-leases.ts +44 -7
- package/src/sdk/prompt-status.ts +16 -3
- package/src/sdk/protocol/operation-inventory.generated.json +33 -0
- package/src/sdk/session.ts +89 -9
- package/src/session/agent-session.ts +844 -173
- package/src/session/artifacts.ts +22 -0
- package/src/session/auth-broker-config.ts +10 -3
- package/src/session/blob-store.ts +915 -24
- package/src/session/internal/managed-session-scope.ts +200 -111
- package/src/session/internal/managed-session-storage.ts +492 -62
- package/src/session/session-manager.ts +1614 -435
- package/src/session/session-storage.ts +38 -45
- package/src/session/streaming-output.ts +238 -68
- package/src/setup/credential-import.ts +75 -13
- package/src/skill-state/active-state.ts +32 -0
- package/src/skill-state/workflow-hud.ts +17 -0
- package/src/skill-state/workflow-mutation-guard.ts +496 -2
- package/src/task/executor.ts +122 -39
- package/src/task/index.ts +280 -45
- package/src/task/output-manager.ts +24 -14
- package/src/task/provider-retry-status.ts +16 -0
- package/src/task/receipt.ts +34 -8
- package/src/task/render.ts +6 -2
- package/src/task/types.ts +108 -0
- package/src/tools/ask-answer-registry.ts +12 -6
- package/src/tools/ask.ts +86 -55
- package/src/tools/ast-edit.ts +1 -0
- package/src/tools/ast-grep.ts +1 -0
- package/src/tools/bash-interactive.ts +8 -3
- package/src/tools/bash.ts +503 -60
- package/src/tools/find.ts +1 -0
- package/src/tools/image-gen.ts +29 -5
- package/src/tools/index.ts +6 -0
- package/src/tools/output-meta.ts +108 -14
- package/src/tools/path-utils.ts +3 -2
- package/src/tools/read.ts +18 -0
- package/src/tools/review.ts +5 -7
- package/src/tools/search.ts +1 -0
- package/src/tools/subagent-render.ts +6 -2
- package/src/tools/subagent.ts +6 -0
- package/src/tools/telegram-send.ts +6 -3
- package/src/utils/gh.ts +39 -0
- package/src/utils/image-resize.ts +3 -0
- package/src/web/search/providers/anthropic.ts +4 -4
- package/src/web/search/providers/kimi.ts +15 -5
- package/src/web/search/providers/searxng.ts +47 -4
- package/src/web/search/providers/xai.ts +7 -2
- package/dist/types/gjc-runtime/ultragoal-pipeline.d.ts +0 -23
- package/src/defaults/gjc/skills/ultragoal/pipeline-validation-contracts.md +0 -30
- package/src/gjc-runtime/ultragoal-pipeline.ts +0 -306
|
@@ -9,6 +9,7 @@ import { withFileLock } from "../../config/file-lock";
|
|
|
9
9
|
import type { Settings } from "../../config/settings";
|
|
10
10
|
import type { DaemonRuntimeInfo } from "../../daemon/control-types";
|
|
11
11
|
import { resolveGjcRuntimeSpawnInfo } from "../../daemon/runtime";
|
|
12
|
+
import { resizeImageBuffer } from "../../utils/image-resize";
|
|
12
13
|
import { isProcessIncarnation, processIncarnation } from "../broker/process-incarnation";
|
|
13
14
|
import { getNotificationConfig, isTelegramConfigured, tokenFingerprint } from "./config";
|
|
14
15
|
import {
|
|
@@ -63,6 +64,7 @@ import {
|
|
|
63
64
|
isLifecycleCommandText,
|
|
64
65
|
type LifecycleCommandVerb,
|
|
65
66
|
lifecycleUsage,
|
|
67
|
+
normalizeLifecyclePath,
|
|
66
68
|
parseLifecycleCommand,
|
|
67
69
|
validateLifecycleTarget,
|
|
68
70
|
} from "./lifecycle-commands";
|
|
@@ -77,7 +79,7 @@ import {
|
|
|
77
79
|
import type { OrchestratorDeps } from "./lifecycle-orchestrator";
|
|
78
80
|
import { NotificationOperatorRuntime, OperatorBackoffPolicy, OperatorEventRouter } from "./operator-runtime";
|
|
79
81
|
import { RateLimitPool } from "./rate-limit-pool";
|
|
80
|
-
import { listRecentSessions } from "./recent-activity";
|
|
82
|
+
import { type ListRecentSessionsResult, listRecentSessions } from "./recent-activity";
|
|
81
83
|
import { ReplySentStore } from "./reply-sent-store";
|
|
82
84
|
import { DraftStreamState, deliverDraft, shouldStreamDraft } from "./rich-draft";
|
|
83
85
|
import {
|
|
@@ -86,6 +88,13 @@ import {
|
|
|
86
88
|
isBtwRichEligible,
|
|
87
89
|
shouldPromoteRich,
|
|
88
90
|
} from "./rich-render";
|
|
91
|
+
import {
|
|
92
|
+
type AdoptionIntentFs,
|
|
93
|
+
buildAdoptionIntent,
|
|
94
|
+
DEFAULT_ADOPTION_INTENT_TTL_MS,
|
|
95
|
+
TelegramAdoptionIntentStore,
|
|
96
|
+
type TelegramAdoptionTarget,
|
|
97
|
+
} from "./telegram-adoption-intent";
|
|
89
98
|
import {
|
|
90
99
|
type AliasTable,
|
|
91
100
|
buildActionMarkdown,
|
|
@@ -94,9 +103,10 @@ import {
|
|
|
94
103
|
createAliasTable,
|
|
95
104
|
readEndpoint,
|
|
96
105
|
routeInboundUpdate,
|
|
106
|
+
telegramDisableNotification,
|
|
97
107
|
} from "./telegram-reference";
|
|
98
108
|
import { decideThreadedInbound, type InboundAttachment } from "./threaded-inbound";
|
|
99
|
-
import { renderThreadedFrame, type ThreadedSend } from "./threaded-render";
|
|
109
|
+
import { renderThreadedFrame, supportsTelegramPhotoUpload, type ThreadedSend } from "./threaded-render";
|
|
100
110
|
import { type TopicEndpointBinding, TopicRegistry, type TopicRegistryState } from "./topic-registry";
|
|
101
111
|
|
|
102
112
|
export type EnsureDaemonResult = "owner_spawned" | "attached" | "disabled" | "blocked";
|
|
@@ -145,7 +155,14 @@ export interface TelegramDaemonFs {
|
|
|
145
155
|
open(path: string, flags: string, mode?: number): Promise<{ close(): Promise<void> }>;
|
|
146
156
|
readdir(path: string): Promise<string[]>;
|
|
147
157
|
chmod(path: string, mode: number): Promise<void>;
|
|
148
|
-
stat?(path: string): Promise<{
|
|
158
|
+
stat?(path: string): Promise<{
|
|
159
|
+
mtimeMs: number;
|
|
160
|
+
size?: number;
|
|
161
|
+
dev?: number;
|
|
162
|
+
ino?: number;
|
|
163
|
+
ctimeMs?: number;
|
|
164
|
+
isDirectory?: () => boolean;
|
|
165
|
+
}>;
|
|
149
166
|
readEndpointFile?(path: string): Promise<NotificationEndpointFile>;
|
|
150
167
|
exactUnlink?(path: string, identity: NotificationEndpointFileIdentity): Promise<NotificationExactUnlinkResult>;
|
|
151
168
|
}
|
|
@@ -301,6 +318,8 @@ const CONSUMED_REACTION = "✅";
|
|
|
301
318
|
const MODEL_CALLBACK_PREFIX = "m:";
|
|
302
319
|
const MODEL_CHOICE_TTL_MS = 10 * 60 * 1_000;
|
|
303
320
|
const BTW_PENDING_TTL_MS = 300_000;
|
|
321
|
+
const PICKER_CALLBACK_PREFIX = "p:";
|
|
322
|
+
const ADOPTION_INTENT_SWEEP_INTERVAL_MS = 60_000;
|
|
304
323
|
const BTW_MAX_PENDING = 256;
|
|
305
324
|
const BTW_SHUTDOWN_JOIN_MS = 1_000;
|
|
306
325
|
const BTW_USAGE_TEXT = "Usage: /btw <question>";
|
|
@@ -311,11 +330,45 @@ const TELEGRAM_ATTACHMENT_MAX_BYTES = 20 * 1024 * 1024;
|
|
|
311
330
|
const TELEGRAM_SESSION_ATTACHMENT_MAX_COUNT = 20;
|
|
312
331
|
const TELEGRAM_SESSION_ATTACHMENT_MAX_BYTES = 50 * 1024 * 1024;
|
|
313
332
|
const TELEGRAM_ATTACHMENT_DOWNLOAD_TIMEOUT_MS = 60_000;
|
|
333
|
+
const TELEGRAM_PHOTO_MAX_BYTES = 10 * 1024 * 1024;
|
|
334
|
+
const TELEGRAM_PHOTO_MAX_DIMENSION = 10_000;
|
|
335
|
+
const TELEGRAM_PHOTO_MAX_ASPECT_RATIO = 20;
|
|
314
336
|
const BTW_QUESTION_LIMIT_TEXT = "Question must be at most 4096 Unicode scalar values and 16384 UTF-8 bytes.";
|
|
315
337
|
type ParsedBtwCommand = { kind: "question"; question: string } | { kind: "ignored" };
|
|
316
338
|
type TelegramFileDownload = { bytes: Buffer } | { failure: "download_failed" | "too_large" };
|
|
317
339
|
class ThreadedModeCapabilityRefusal extends Error {}
|
|
318
340
|
|
|
341
|
+
async function prepareTelegramImageAttachment(frame: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
342
|
+
if (frame.type !== "image_attachment") return frame;
|
|
343
|
+
const mime = typeof frame.mime === "string" ? frame.mime : undefined;
|
|
344
|
+
const data = typeof frame.data === "string" ? frame.data : undefined;
|
|
345
|
+
if (!data || supportsTelegramPhotoUpload(mime) || !mime?.toLowerCase().startsWith("image/")) return frame;
|
|
346
|
+
try {
|
|
347
|
+
const converted = await resizeImageBuffer(Buffer.from(data, "base64"), mime, {
|
|
348
|
+
maxWidth: TELEGRAM_PHOTO_MAX_DIMENSION,
|
|
349
|
+
maxHeight: TELEGRAM_PHOTO_MAX_DIMENSION,
|
|
350
|
+
maxBytes: TELEGRAM_PHOTO_MAX_BYTES,
|
|
351
|
+
jpegQuality: 90,
|
|
352
|
+
excludeWebP: true,
|
|
353
|
+
forceReencode: true,
|
|
354
|
+
});
|
|
355
|
+
const shortestEdge = Math.min(converted.width, converted.height);
|
|
356
|
+
const longestEdge = Math.max(converted.width, converted.height);
|
|
357
|
+
if (
|
|
358
|
+
!supportsTelegramPhotoUpload(converted.mimeType) ||
|
|
359
|
+
converted.buffer.byteLength > TELEGRAM_PHOTO_MAX_BYTES ||
|
|
360
|
+
shortestEdge <= 0 ||
|
|
361
|
+
longestEdge / shortestEdge > TELEGRAM_PHOTO_MAX_ASPECT_RATIO
|
|
362
|
+
) {
|
|
363
|
+
return frame;
|
|
364
|
+
}
|
|
365
|
+
return { ...frame, mime: converted.mimeType, data: converted.data };
|
|
366
|
+
} catch (error) {
|
|
367
|
+
logger.warn(`notifications: image conversion failed; falling back to document: ${String(error)}`);
|
|
368
|
+
return frame;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
319
372
|
/** Only an explicit Bot API capability refusal may enable flat private-chat delivery. */
|
|
320
373
|
function isThreadedModeCapabilityRefusal(response: unknown): boolean {
|
|
321
374
|
if (!response || typeof response !== "object") return false;
|
|
@@ -3529,12 +3582,14 @@ export class TelegramBotTransport implements BotApi {
|
|
|
3529
3582
|
mime?: string;
|
|
3530
3583
|
caption?: string;
|
|
3531
3584
|
parse_mode?: string;
|
|
3585
|
+
disable_notification?: boolean;
|
|
3532
3586
|
};
|
|
3533
3587
|
const form = new FormData();
|
|
3534
3588
|
form.set("chat_id", String(b.chat_id));
|
|
3535
3589
|
if (b.message_thread_id !== undefined) form.set("message_thread_id", String(b.message_thread_id));
|
|
3536
3590
|
if (b.caption) form.set("caption", b.caption);
|
|
3537
3591
|
if (b.parse_mode) form.set("parse_mode", String(b.parse_mode));
|
|
3592
|
+
if (b.disable_notification === true) form.set("disable_notification", "true");
|
|
3538
3593
|
form.set("photo", new Blob([Buffer.from(b.photo, "base64")], { type: b.mime ?? "image/png" }), "image");
|
|
3539
3594
|
const res = await fetchWithRetry(
|
|
3540
3595
|
fetchImpl,
|
|
@@ -3555,12 +3610,14 @@ export class TelegramBotTransport implements BotApi {
|
|
|
3555
3610
|
fileName?: string;
|
|
3556
3611
|
caption?: string;
|
|
3557
3612
|
parse_mode?: string;
|
|
3613
|
+
disable_notification?: boolean;
|
|
3558
3614
|
};
|
|
3559
3615
|
const form = new FormData();
|
|
3560
3616
|
form.set("chat_id", String(b.chat_id));
|
|
3561
3617
|
if (b.message_thread_id !== undefined) form.set("message_thread_id", String(b.message_thread_id));
|
|
3562
3618
|
if (b.caption) form.set("caption", b.caption);
|
|
3563
3619
|
if (b.parse_mode) form.set("parse_mode", String(b.parse_mode));
|
|
3620
|
+
if (b.disable_notification === true) form.set("disable_notification", "true");
|
|
3564
3621
|
form.set(
|
|
3565
3622
|
"document",
|
|
3566
3623
|
new Blob([Buffer.from(b.document, "base64")], { type: b.mime ?? "application/octet-stream" }),
|
|
@@ -3818,6 +3875,8 @@ export interface TelegramDaemonOptions {
|
|
|
3818
3875
|
clearIntervalImpl?: typeof clearInterval;
|
|
3819
3876
|
btw?: { enabled: boolean };
|
|
3820
3877
|
idleTimeoutMs?: number;
|
|
3878
|
+
/** TTL for durable topic-adoption intents (default 10 minutes). Observed, not a fixed contract. */
|
|
3879
|
+
adoptionIntentTtlMs?: number;
|
|
3821
3880
|
scanIntervalMs?: number;
|
|
3822
3881
|
pid?: number;
|
|
3823
3882
|
/** Liveness probe for skipping dead-PID endpoint records in {@link TelegramNotificationDaemon.scanRoots}. */
|
|
@@ -3848,6 +3907,8 @@ export interface TelegramDaemonOptions {
|
|
|
3848
3907
|
richDraft?: { enabled: boolean };
|
|
3849
3908
|
/** Tool start/completion messages (off by default; explicit opt-in only). */
|
|
3850
3909
|
toolActivity?: { enabled: boolean };
|
|
3910
|
+
/** Controls which Telegram sends play an audible notification. Defaults to all. */
|
|
3911
|
+
sound?: "all" | "important" | "none";
|
|
3851
3912
|
/**
|
|
3852
3913
|
* Per-session Telegram forum-topic naming. `nameTemplate` supports the
|
|
3853
3914
|
* `{repo}`, `{branch}`, and `{title}` placeholders; unset preserves the
|
|
@@ -3912,6 +3973,24 @@ interface RenderedModelChoice {
|
|
|
3912
3973
|
selector: string;
|
|
3913
3974
|
label: string;
|
|
3914
3975
|
}
|
|
3976
|
+
type TopicPickerAction = { kind: "select"; target: TelegramAdoptionTarget } | { kind: "recent" } | { kind: "direct" };
|
|
3977
|
+
|
|
3978
|
+
interface TopicPickerRoute {
|
|
3979
|
+
/** Telegram forum topic id the user created and that this picker controls. */
|
|
3980
|
+
topicId: number;
|
|
3981
|
+
/** Preallocated session id that will adopt this topic once created. */
|
|
3982
|
+
intendedSessionId: string;
|
|
3983
|
+
/** Configured chat id this picker was rendered for; replay into another chat is refused. */
|
|
3984
|
+
chatId: string;
|
|
3985
|
+
action: TopicPickerAction;
|
|
3986
|
+
expiresAt: number;
|
|
3987
|
+
}
|
|
3988
|
+
|
|
3989
|
+
interface PendingAdoptionPathInput {
|
|
3990
|
+
chatId: string;
|
|
3991
|
+
intendedSessionId: string;
|
|
3992
|
+
expiresAt: number;
|
|
3993
|
+
}
|
|
3915
3994
|
|
|
3916
3995
|
interface TopicAuthorityLease {
|
|
3917
3996
|
sessionId: string;
|
|
@@ -4127,6 +4206,18 @@ export class TelegramNotificationDaemon {
|
|
|
4127
4206
|
readonly sessions = new Map<string, SessionSocket>();
|
|
4128
4207
|
/** Ephemeral aliases for model choices; deliberately never serialized across daemon restarts. */
|
|
4129
4208
|
#modelChoiceAliases = new Map<string, ModelChoiceRoute>();
|
|
4209
|
+
/** One-shot picker aliases for user-created topic adoption; consumed before submit. */
|
|
4210
|
+
#adoptionPickerAliases = new Map<string, TopicPickerRoute>();
|
|
4211
|
+
/** Topics whose next authenticated text message is treated as an explicitly requested path. */
|
|
4212
|
+
#pendingAdoptionPathInputs = new Map<number, PendingAdoptionPathInput>();
|
|
4213
|
+
/** Durable, non-routable topic-adoption intents; sole claim authority is getOrCreateTopic. */
|
|
4214
|
+
#adoptionIntents: TelegramAdoptionIntentStore;
|
|
4215
|
+
/** Configured adoption-intent TTL (default 10 minutes); observed, not a fixed contract. */
|
|
4216
|
+
#adoptionIntentTtlMs: number;
|
|
4217
|
+
/** Periodic sweep handle for expired adoption intents. */
|
|
4218
|
+
#adoptionSweepTimer: NodeJS.Timeout | undefined;
|
|
4219
|
+
/** In-flight submissions close the pre-sidecar race across picker and direct-path entry points. */
|
|
4220
|
+
readonly #adoptionStartingTopics = new Set<number>();
|
|
4130
4221
|
|
|
4131
4222
|
private readonly runtime: NotificationOperatorRuntime;
|
|
4132
4223
|
private readonly sessionRouter: OperatorEventRouter<SessionSocket>;
|
|
@@ -4437,8 +4528,8 @@ export class TelegramNotificationDaemon {
|
|
|
4437
4528
|
type: "session_lifecycle_error",
|
|
4438
4529
|
requestId: frame.requestId,
|
|
4439
4530
|
status: "error",
|
|
4440
|
-
reason: "
|
|
4441
|
-
message: "lifecycle control unavailable",
|
|
4531
|
+
reason: "spawn_failed",
|
|
4532
|
+
message: "lifecycle control unavailable; no session was started",
|
|
4442
4533
|
});
|
|
4443
4534
|
return;
|
|
4444
4535
|
}
|
|
@@ -4462,8 +4553,8 @@ export class TelegramNotificationDaemon {
|
|
|
4462
4553
|
type: "session_lifecycle_error",
|
|
4463
4554
|
requestId: frame.requestId,
|
|
4464
4555
|
status: "error",
|
|
4465
|
-
reason: "
|
|
4466
|
-
message: `lifecycle send failed: ${String(e)}`,
|
|
4556
|
+
reason: "spawn_failed",
|
|
4557
|
+
message: `lifecycle send failed before session start: ${String(e)}`,
|
|
4467
4558
|
});
|
|
4468
4559
|
}
|
|
4469
4560
|
});
|
|
@@ -4481,6 +4572,7 @@ export class TelegramNotificationDaemon {
|
|
|
4481
4572
|
| { kind: "close"; target: SessionCloseTarget }
|
|
4482
4573
|
| { kind: "resume"; target: SessionResumeTarget },
|
|
4483
4574
|
updateId: number,
|
|
4575
|
+
intendedSessionId?: string,
|
|
4484
4576
|
): SessionLifecycleRequest {
|
|
4485
4577
|
const requestId = this.nextLifecycleRequestId();
|
|
4486
4578
|
const token = this.controlToken ?? "";
|
|
@@ -4490,7 +4582,7 @@ export class TelegramNotificationDaemon {
|
|
|
4490
4582
|
type: "session_create",
|
|
4491
4583
|
requestId,
|
|
4492
4584
|
lifecycleRequestId: requestId,
|
|
4493
|
-
intendedSessionId: `s${crypto.randomBytes(6).toString("hex")}`,
|
|
4585
|
+
intendedSessionId: intendedSessionId ?? `s${crypto.randomBytes(6).toString("hex")}`,
|
|
4494
4586
|
updateId,
|
|
4495
4587
|
chatId,
|
|
4496
4588
|
token,
|
|
@@ -4524,6 +4616,9 @@ export class TelegramNotificationDaemon {
|
|
|
4524
4616
|
chat_id: this.opts.chatId,
|
|
4525
4617
|
...(threadId !== undefined ? { message_thread_id: threadId } : {}),
|
|
4526
4618
|
text,
|
|
4619
|
+
...(telegramDisableNotification(this.opts.sound, "finalized") === true
|
|
4620
|
+
? { disable_notification: true }
|
|
4621
|
+
: {}),
|
|
4527
4622
|
})
|
|
4528
4623
|
.catch(() => undefined);
|
|
4529
4624
|
}
|
|
@@ -4536,6 +4631,9 @@ export class TelegramNotificationDaemon {
|
|
|
4536
4631
|
...(threadId !== undefined ? { message_thread_id: threadId } : {}),
|
|
4537
4632
|
text,
|
|
4538
4633
|
parse_mode: TELEGRAM_PARSE_MODE,
|
|
4634
|
+
...(telegramDisableNotification(this.opts.sound, "finalized") === true
|
|
4635
|
+
? { disable_notification: true }
|
|
4636
|
+
: {}),
|
|
4539
4637
|
})
|
|
4540
4638
|
.catch(() => undefined);
|
|
4541
4639
|
}
|
|
@@ -4650,6 +4748,30 @@ export class TelegramNotificationDaemon {
|
|
|
4650
4748
|
this.fsImpl = opts.fs ?? nodeFs;
|
|
4651
4749
|
this.replyStore = new ReplySentStore({ agentDir: opts.settings.getAgentDir(), fs: opts.fs });
|
|
4652
4750
|
this.aliasTable = createAliasTable();
|
|
4751
|
+
this.#adoptionIntentTtlMs = opts.adoptionIntentTtlMs ?? DEFAULT_ADOPTION_INTENT_TTL_MS;
|
|
4752
|
+
const adoptionFs: AdoptionIntentFs = {
|
|
4753
|
+
mkdir: (directory, options) => this.fsImpl.mkdir(directory, options),
|
|
4754
|
+
chmod: (target, mode) => this.fsImpl.chmod(target, mode),
|
|
4755
|
+
readFile: (file, encoding) => this.fsImpl.readFile(file, encoding),
|
|
4756
|
+
writeFile: (file, data, options) => this.fsImpl.writeFile(file, data, options),
|
|
4757
|
+
rename: (from, to) => this.fsImpl.rename(from, to),
|
|
4758
|
+
unlink: file => this.fsImpl.unlink(file),
|
|
4759
|
+
readdir: directory => this.fsImpl.readdir(directory),
|
|
4760
|
+
open: async (file, flags) => {
|
|
4761
|
+
const handle = await this.fsImpl.open(file, flags);
|
|
4762
|
+
const sync = (handle as { sync?: () => Promise<void> }).sync;
|
|
4763
|
+
if (typeof sync !== "function") {
|
|
4764
|
+
await handle.close();
|
|
4765
|
+
throw new Error("adoption sidecar durability requires filesystem sync support");
|
|
4766
|
+
}
|
|
4767
|
+
return { sync: () => sync.call(handle), close: () => handle.close() };
|
|
4768
|
+
},
|
|
4769
|
+
};
|
|
4770
|
+
this.#adoptionIntents = new TelegramAdoptionIntentStore({
|
|
4771
|
+
agentDir: opts.settings.getAgentDir(),
|
|
4772
|
+
now: opts.now,
|
|
4773
|
+
fs: adoptionFs,
|
|
4774
|
+
});
|
|
4653
4775
|
const rawBotApi =
|
|
4654
4776
|
opts.botApi ??
|
|
4655
4777
|
new TelegramBotTransport({
|
|
@@ -5160,7 +5282,7 @@ export class TelegramNotificationDaemon {
|
|
|
5160
5282
|
}
|
|
5161
5283
|
}
|
|
5162
5284
|
|
|
5163
|
-
connectSession(sessionId: string, url: string, token: string):
|
|
5285
|
+
connectSession(sessionId: string, url: string, token: string): SessionSocket {
|
|
5164
5286
|
const WS = this.opts.WebSocketImpl ?? WebSocket;
|
|
5165
5287
|
const ws = new WS(`${url}/?token=${encodeURIComponent(token)}`);
|
|
5166
5288
|
const endpointKey = endpointGenerationKey(url, token);
|
|
@@ -5264,6 +5386,7 @@ export class TelegramNotificationDaemon {
|
|
|
5264
5386
|
ws.addEventListener("close", () => {
|
|
5265
5387
|
this.dropSession(session, "socket_closed");
|
|
5266
5388
|
});
|
|
5389
|
+
return session;
|
|
5267
5390
|
}
|
|
5268
5391
|
|
|
5269
5392
|
/**
|
|
@@ -5649,6 +5772,9 @@ export class TelegramNotificationDaemon {
|
|
|
5649
5772
|
reply_parameters: { message_id: input.messageId },
|
|
5650
5773
|
text: input.text,
|
|
5651
5774
|
...(input.parseMode ? { parse_mode: input.parseMode } : {}),
|
|
5775
|
+
...(telegramDisableNotification(this.opts.sound, "finalized") === true
|
|
5776
|
+
? { disable_notification: true }
|
|
5777
|
+
: {}),
|
|
5652
5778
|
},
|
|
5653
5779
|
{
|
|
5654
5780
|
noRetry: true,
|
|
@@ -6632,12 +6758,42 @@ export class TelegramNotificationDaemon {
|
|
|
6632
6758
|
let acceptedTopicCompensated = false;
|
|
6633
6759
|
let acceptedTopicDeleteAttempted = false;
|
|
6634
6760
|
let creationSuppressed = false;
|
|
6761
|
+
/** User-created topicId adopted in this callback, if any (for sidecar reconciliation). */
|
|
6762
|
+
let adoptedTopicId: number | undefined;
|
|
6763
|
+
const adoptionIntentCandidate = this.#adoptionIntents.bySession(sessionId);
|
|
6635
6764
|
try {
|
|
6636
6765
|
const rec = await this.topics.getOrCreateTopic(
|
|
6637
6766
|
sessionId,
|
|
6638
6767
|
async () => {
|
|
6639
6768
|
if (capturedCreationLease && !this.#leaseTokenAllows(capturedCreationLease))
|
|
6640
6769
|
throw new Error("topic authority was revoked during creation");
|
|
6770
|
+
// Adoption path: if a durable non-routable intent targets this
|
|
6771
|
+
// session, synchronously claim its user-created topicId, validate
|
|
6772
|
+
// chat/binding/pure availability, and RETURN it so getOrCreateTopic
|
|
6773
|
+
// commits the full record exactly once. createForumTopic is never
|
|
6774
|
+
// called for an adopted topic; acceptedTopicId stays unset so no
|
|
6775
|
+
// compensation path can delete the user topic.
|
|
6776
|
+
const adoptionIntent = adoptionIntentCandidate ? this.#adoptionIntents.bySession(sessionId) : undefined;
|
|
6777
|
+
if (adoptionIntentCandidate && !adoptionIntent) {
|
|
6778
|
+
throw new Error("topic adoption refused: intent expired before consumption");
|
|
6779
|
+
}
|
|
6780
|
+
if (adoptionIntent) {
|
|
6781
|
+
const chatId = String(this.opts.chatId);
|
|
6782
|
+
const topicIdStr = String(adoptionIntent.topicId);
|
|
6783
|
+
const chatMatches = adoptionIntent.chatId === chatId;
|
|
6784
|
+
const bindingChatMatches = !creationBinding || creationBinding.chatId === chatId;
|
|
6785
|
+
const available = this.topics.isTopicIdAvailable(topicIdStr);
|
|
6786
|
+
if (!chatMatches || !bindingChatMatches || !available) {
|
|
6787
|
+
// Fail closed: never fall back to remote-create over a user topic.
|
|
6788
|
+
throw new Error("topic adoption refused: intent chat/binding/topic unavailable");
|
|
6789
|
+
}
|
|
6790
|
+
if (!this.#adoptionIntents.tryClaim(adoptionIntent.topicId, sessionId)) {
|
|
6791
|
+
throw new Error("topic adoption refused: concurrent claim held");
|
|
6792
|
+
}
|
|
6793
|
+
this.#malformedTopicCreateEndpoints.delete(sessionId);
|
|
6794
|
+
adoptedTopicId = adoptionIntent.topicId;
|
|
6795
|
+
return topicIdStr;
|
|
6796
|
+
}
|
|
6641
6797
|
const res = (await this.botApi.call("createForumTopic", { chat_id: this.opts.chatId, name })) as
|
|
6642
6798
|
| { result?: { message_thread_id?: unknown } }
|
|
6643
6799
|
| undefined;
|
|
@@ -6696,7 +6852,25 @@ export class TelegramNotificationDaemon {
|
|
|
6696
6852
|
creationBinding,
|
|
6697
6853
|
() => this.persistTopics(),
|
|
6698
6854
|
session,
|
|
6855
|
+
adoptionIntentCandidate ? "user_created" : undefined,
|
|
6699
6856
|
);
|
|
6857
|
+
// Adoption commit success: remove the durable sidecar. A cleanup failure
|
|
6858
|
+
// cannot roll back the committed record; topic success is retained and the
|
|
6859
|
+
// stale sidecar is left for startup reconciliation to remove safely.
|
|
6860
|
+
if (adoptedTopicId !== undefined) {
|
|
6861
|
+
if (adoptionIntentCandidate)
|
|
6862
|
+
logger.info(
|
|
6863
|
+
`notifications: topic-adoption committed age_ms=${Math.max(0, this.runtime.now() - adoptionIntentCandidate.createdAt)}`,
|
|
6864
|
+
);
|
|
6865
|
+
try {
|
|
6866
|
+
await this.#adoptionIntents.remove(sessionId);
|
|
6867
|
+
await this.#adoptionIntents.removePendingTopic(adoptedTopicId);
|
|
6868
|
+
} catch (error) {
|
|
6869
|
+
logger.warn(
|
|
6870
|
+
`notifications: topic-adoption sidecar cleanup failed; retained for reconciliation: ${sanitizeDiagnostic(String(error))}`,
|
|
6871
|
+
);
|
|
6872
|
+
}
|
|
6873
|
+
}
|
|
6700
6874
|
// getOrCreateTopic deduplicates callers, so an accepted create can be
|
|
6701
6875
|
// observed by a successor after its initiating socket was revoked. Check
|
|
6702
6876
|
// the immutable lease again before exposing that record to frame delivery.
|
|
@@ -6729,6 +6903,11 @@ export class TelegramNotificationDaemon {
|
|
|
6729
6903
|
return rec.topicId;
|
|
6730
6904
|
} catch (err) {
|
|
6731
6905
|
if (creationSuppressed || err instanceof ThreadedModeCapabilityRefusal) return undefined;
|
|
6906
|
+
// Adoption commit failure: release the in-memory claim so a retry can
|
|
6907
|
+
// re-claim, and retain the durable sidecar (never delete the user topic).
|
|
6908
|
+
if (adoptedTopicId !== undefined) {
|
|
6909
|
+
this.#adoptionIntents.releaseClaim(adoptedTopicId, sessionId);
|
|
6910
|
+
}
|
|
6732
6911
|
if (
|
|
6733
6912
|
acceptedTopicId &&
|
|
6734
6913
|
!acceptedTopicCompensated &&
|
|
@@ -6833,6 +7012,26 @@ export class TelegramNotificationDaemon {
|
|
|
6833
7012
|
try {
|
|
6834
7013
|
await this.flushPool();
|
|
6835
7014
|
if (socketLease && !this.#deleteLeaseAllows(socketLease)) return "pre_dispatch_cancelled";
|
|
7015
|
+
if (record.topicOrigin === "user_created") {
|
|
7016
|
+
this.topics.settleDelete(sessionId, record.topicId);
|
|
7017
|
+
for (const k of [...this.liveMessages.keys()])
|
|
7018
|
+
if (k.startsWith(`${sessionId}:`)) {
|
|
7019
|
+
this.liveMessages.delete(k);
|
|
7020
|
+
this.toolActivityOwners.delete(k);
|
|
7021
|
+
}
|
|
7022
|
+
this.topicOwnerByIdentity.forEach((ownerSessionId, identityKey) => {
|
|
7023
|
+
if (ownerSessionId === sessionId) this.topicOwnerByIdentity.delete(identityKey);
|
|
7024
|
+
});
|
|
7025
|
+
this.pendingThreadedFrames.delete(sessionId);
|
|
7026
|
+
try {
|
|
7027
|
+
await this.persistTopics();
|
|
7028
|
+
return "settled";
|
|
7029
|
+
} catch {
|
|
7030
|
+
this.topics.restoreDeleteFence(deleteSnapshot);
|
|
7031
|
+
await this.#persistTopicsWithRetry().catch(() => undefined);
|
|
7032
|
+
return "post_dispatch_pending";
|
|
7033
|
+
}
|
|
7034
|
+
}
|
|
6836
7035
|
const res = (await this.botApi.call("deleteForumTopic", {
|
|
6837
7036
|
chat_id: this.opts.chatId,
|
|
6838
7037
|
message_thread_id: Number(record.topicId),
|
|
@@ -6950,6 +7149,56 @@ export class TelegramNotificationDaemon {
|
|
|
6950
7149
|
}
|
|
6951
7150
|
}
|
|
6952
7151
|
|
|
7152
|
+
/**
|
|
7153
|
+
* Rehydrate durable adoption intents after restart, then reconcile: a
|
|
7154
|
+
* non-expired sidecar whose target session already has a committed topic
|
|
7155
|
+
* record is a stale leftover (commit succeeded but sidecar cleanup failed)
|
|
7156
|
+
* and is removed safely without touching the user topic.
|
|
7157
|
+
*/
|
|
7158
|
+
async loadAdoptionIntents(): Promise<void> {
|
|
7159
|
+
await this.#adoptionIntents.rehydrate();
|
|
7160
|
+
const expired = await this.#adoptionIntents.sweepExpired();
|
|
7161
|
+
if (expired > 0) logger.info(`notifications: topic-adoption expired count=${expired}`);
|
|
7162
|
+
for (const sessionId of this.topics.sessionIds()) {
|
|
7163
|
+
const intent = this.#adoptionIntents.bySession(sessionId);
|
|
7164
|
+
if (!intent) continue;
|
|
7165
|
+
const record = this.topics.get(sessionId);
|
|
7166
|
+
if (record && record.topicId === String(intent.topicId)) {
|
|
7167
|
+
try {
|
|
7168
|
+
await this.#adoptionIntents.remove(sessionId);
|
|
7169
|
+
await this.#adoptionIntents.removePendingTopic(intent.topicId);
|
|
7170
|
+
} catch (error) {
|
|
7171
|
+
logger.warn(
|
|
7172
|
+
`notifications: stale topic-adoption sidecar cleanup failed: ${sanitizeDiagnostic(String(error))}`,
|
|
7173
|
+
);
|
|
7174
|
+
}
|
|
7175
|
+
}
|
|
7176
|
+
}
|
|
7177
|
+
}
|
|
7178
|
+
|
|
7179
|
+
/** Periodic sweep of expired adoption-intent sidecars (files only; no Telegram API). */
|
|
7180
|
+
private startAdoptionSweepTimer(): void {
|
|
7181
|
+
const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
|
|
7182
|
+
this.#adoptionSweepTimer = setIntervalImpl(() => {
|
|
7183
|
+
this.#sweepExpiredAdoptionPickerAliases();
|
|
7184
|
+
void this.#adoptionIntents
|
|
7185
|
+
.sweepExpired()
|
|
7186
|
+
.then(expired => {
|
|
7187
|
+
if (expired > 0) logger.info(`notifications: topic-adoption expired count=${expired}`);
|
|
7188
|
+
})
|
|
7189
|
+
.catch(error => {
|
|
7190
|
+
logger.warn(`notifications: adoption-intent sweep failed: ${sanitizeDiagnostic(String(error))}`);
|
|
7191
|
+
});
|
|
7192
|
+
}, ADOPTION_INTENT_SWEEP_INTERVAL_MS);
|
|
7193
|
+
}
|
|
7194
|
+
|
|
7195
|
+
private stopAdoptionSweepTimer(): void {
|
|
7196
|
+
if (this.#adoptionSweepTimer !== undefined) {
|
|
7197
|
+
(this.opts.clearIntervalImpl ?? clearInterval)(this.#adoptionSweepTimer);
|
|
7198
|
+
this.#adoptionSweepTimer = undefined;
|
|
7199
|
+
}
|
|
7200
|
+
}
|
|
7201
|
+
|
|
6953
7202
|
/** Retry crash-interrupted topic deletes; only a definite Telegram result clears the durable fence. */
|
|
6954
7203
|
private async reconcilePendingTopicDeletes(): Promise<void> {
|
|
6955
7204
|
for (const sessionId of this.topics.deletePendingSessionIds()) await this.deleteTopic(sessionId);
|
|
@@ -7345,6 +7594,9 @@ export class TelegramNotificationDaemon {
|
|
|
7345
7594
|
chat_id: this.opts.chatId,
|
|
7346
7595
|
...(topicLease ? { message_thread_id: Number(topicLease.topicId) } : {}),
|
|
7347
7596
|
text: "Selected!",
|
|
7597
|
+
...(telegramDisableNotification(this.opts.sound, item.lane) === true
|
|
7598
|
+
? { disable_notification: true }
|
|
7599
|
+
: {}),
|
|
7348
7600
|
},
|
|
7349
7601
|
{ signal: controller.signal, noRetry: true },
|
|
7350
7602
|
);
|
|
@@ -7448,7 +7700,13 @@ export class TelegramNotificationDaemon {
|
|
|
7448
7700
|
if (draftId !== undefined) {
|
|
7449
7701
|
await deliverDraft(
|
|
7450
7702
|
this.botApi,
|
|
7451
|
-
{
|
|
7703
|
+
{
|
|
7704
|
+
chat_id: this.opts.chatId,
|
|
7705
|
+
...threadField,
|
|
7706
|
+
...(telegramDisableNotification(this.opts.sound, "live") === true
|
|
7707
|
+
? { disable_notification: true }
|
|
7708
|
+
: {}),
|
|
7709
|
+
},
|
|
7452
7710
|
draftId,
|
|
7453
7711
|
send.richDraftMarkdown!,
|
|
7454
7712
|
logger,
|
|
@@ -7463,6 +7721,7 @@ export class TelegramNotificationDaemon {
|
|
|
7463
7721
|
disposition = "rejected";
|
|
7464
7722
|
continue;
|
|
7465
7723
|
}
|
|
7724
|
+
const disableNotification = telegramDisableNotification(this.opts.sound, send.lane);
|
|
7466
7725
|
if (send.method === "sendPhoto" && send.photoBase64) {
|
|
7467
7726
|
// Real photo upload (the default botApi multiparts base64 -> file).
|
|
7468
7727
|
await itemBotApi.call("sendPhoto", {
|
|
@@ -7472,6 +7731,7 @@ export class TelegramNotificationDaemon {
|
|
|
7472
7731
|
mime: send.mime,
|
|
7473
7732
|
caption: send.text,
|
|
7474
7733
|
parse_mode: TELEGRAM_PARSE_MODE,
|
|
7734
|
+
...(disableNotification === true ? { disable_notification: true } : {}),
|
|
7475
7735
|
});
|
|
7476
7736
|
} else if (send.method === "sendDocument" && send.documentBase64) {
|
|
7477
7737
|
await itemBotApi.call("sendDocument", {
|
|
@@ -7482,11 +7742,11 @@ export class TelegramNotificationDaemon {
|
|
|
7482
7742
|
fileName: send.fileName,
|
|
7483
7743
|
caption: send.text,
|
|
7484
7744
|
parse_mode: TELEGRAM_PARSE_MODE,
|
|
7745
|
+
...(disableNotification === true ? { disable_notification: true } : {}),
|
|
7485
7746
|
});
|
|
7486
7747
|
} else if (send.text) {
|
|
7487
7748
|
// Rich pre-branch: promote stable non-editable finalized text to a fresh
|
|
7488
|
-
// sendRichMessage when enabled. Off/miss falls through to the
|
|
7489
|
-
// upstream edit/send path, so off behavior is byte-identical.
|
|
7749
|
+
// sendRichMessage when enabled. Off/miss falls through to the HTML path.
|
|
7490
7750
|
if (
|
|
7491
7751
|
shouldPromoteRich({
|
|
7492
7752
|
enabled: this.opts.rich?.enabled !== false,
|
|
@@ -7509,6 +7769,7 @@ export class TelegramNotificationDaemon {
|
|
|
7509
7769
|
...threadField,
|
|
7510
7770
|
text: chunks[0]!,
|
|
7511
7771
|
parse_mode: TELEGRAM_PARSE_MODE,
|
|
7772
|
+
...(disableNotification === true ? { disable_notification: true } : {}),
|
|
7512
7773
|
});
|
|
7513
7774
|
for (let i = 1; i < chunks.length; i++) {
|
|
7514
7775
|
this.submitPool({
|
|
@@ -7535,7 +7796,11 @@ export class TelegramNotificationDaemon {
|
|
|
7535
7796
|
};
|
|
7536
7797
|
const richMessageId = await deliverRichWithFallback(
|
|
7537
7798
|
itemBotApi,
|
|
7538
|
-
{
|
|
7799
|
+
{
|
|
7800
|
+
chat_id: this.opts.chatId,
|
|
7801
|
+
...threadField,
|
|
7802
|
+
...(disableNotification === true ? { disable_notification: true } : {}),
|
|
7803
|
+
},
|
|
7539
7804
|
send,
|
|
7540
7805
|
AbortSignal.any([this.#deliveryAbort.signal, AbortSignal.timeout(30_000)]),
|
|
7541
7806
|
sendHtmlFallback,
|
|
@@ -7589,6 +7854,7 @@ export class TelegramNotificationDaemon {
|
|
|
7589
7854
|
...threadField,
|
|
7590
7855
|
text: chunks[0]!,
|
|
7591
7856
|
parse_mode: TELEGRAM_PARSE_MODE,
|
|
7857
|
+
...(disableNotification === true ? { disable_notification: true } : {}),
|
|
7592
7858
|
})) as { result?: { message_id?: number } };
|
|
7593
7859
|
firstMessageId = res?.result?.message_id;
|
|
7594
7860
|
}
|
|
@@ -7605,6 +7871,7 @@ export class TelegramNotificationDaemon {
|
|
|
7605
7871
|
...threadField,
|
|
7606
7872
|
text: chunks[0]!,
|
|
7607
7873
|
parse_mode: TELEGRAM_PARSE_MODE,
|
|
7874
|
+
...(disableNotification === true ? { disable_notification: true } : {}),
|
|
7608
7875
|
})) as { result?: { message_id?: number } };
|
|
7609
7876
|
firstMessageId = res?.result?.message_id;
|
|
7610
7877
|
}
|
|
@@ -7906,6 +8173,9 @@ export class TelegramNotificationDaemon {
|
|
|
7906
8173
|
chat_id: this.opts.chatId,
|
|
7907
8174
|
text: "Flat Telegram private chat supports outbound notifications and inline ask buttons only. Enable Threaded Mode in @BotFather > Bot Settings > Threads Settings for free-text replies and session commands.",
|
|
7908
8175
|
parse_mode: TELEGRAM_PARSE_MODE,
|
|
8176
|
+
...(telegramDisableNotification(this.opts.sound, "finalized") === true
|
|
8177
|
+
? { disable_notification: true }
|
|
8178
|
+
: {}),
|
|
7909
8179
|
});
|
|
7910
8180
|
} catch {
|
|
7911
8181
|
// Best-effort nudge; never block delivery.
|
|
@@ -8083,6 +8353,7 @@ export class TelegramNotificationDaemon {
|
|
|
8083
8353
|
text: rendered.text,
|
|
8084
8354
|
parse_mode: TELEGRAM_PARSE_MODE,
|
|
8085
8355
|
reply_markup: { inline_keyboard },
|
|
8356
|
+
...(telegramDisableNotification(this.opts.sound, "ask") === true ? { disable_notification: true } : {}),
|
|
8086
8357
|
});
|
|
8087
8358
|
if (!response || typeof response !== "object" || (response as { ok?: unknown }).ok !== true) {
|
|
8088
8359
|
for (const alias of aliases) this.#modelChoiceAliases.delete(alias);
|
|
@@ -8393,6 +8664,9 @@ export class TelegramNotificationDaemon {
|
|
|
8393
8664
|
...(index === 0 ? { reply_parameters: { message_id: pending.messageId } } : {}),
|
|
8394
8665
|
text,
|
|
8395
8666
|
parse_mode: TELEGRAM_PARSE_MODE,
|
|
8667
|
+
...(telegramDisableNotification(this.opts.sound, "finalized") === true
|
|
8668
|
+
? { disable_notification: true }
|
|
8669
|
+
: {}),
|
|
8396
8670
|
},
|
|
8397
8671
|
});
|
|
8398
8672
|
if (outcome === "accepted") {
|
|
@@ -8411,6 +8685,9 @@ export class TelegramNotificationDaemon {
|
|
|
8411
8685
|
message_thread_id: Number(pending.threadId),
|
|
8412
8686
|
reply_parameters: { message_id: pending.messageId },
|
|
8413
8687
|
rich_message: { markdown, skip_entity_detection: true },
|
|
8688
|
+
...(telegramDisableNotification(this.opts.sound, "finalized") === true
|
|
8689
|
+
? { disable_notification: true }
|
|
8690
|
+
: {}),
|
|
8414
8691
|
});
|
|
8415
8692
|
if (outcome === "accepted") {
|
|
8416
8693
|
deliveryOutcome = "accepted";
|
|
@@ -8560,7 +8837,8 @@ export class TelegramNotificationDaemon {
|
|
|
8560
8837
|
toolActivity?.phase === "terminal" && !this.toolActivitySummariesAreCurrent(toolActivity)
|
|
8561
8838
|
? summaryFreeToolFrame
|
|
8562
8839
|
: threadedFrame;
|
|
8563
|
-
const
|
|
8840
|
+
const preparedFrame = await prepareTelegramImageAttachment(renderedFrame);
|
|
8841
|
+
const send = this.renderThreadedFrame(preparedFrame);
|
|
8564
8842
|
if (toolActivity?.phase === "terminal") {
|
|
8565
8843
|
const summaryFreeSend = this.renderThreadedFrame(summaryFreeToolFrame);
|
|
8566
8844
|
if (summaryFreeSend) toolActivity.summaryFreeSend = summaryFreeSend;
|
|
@@ -8709,18 +8987,32 @@ export class TelegramNotificationDaemon {
|
|
|
8709
8987
|
(control as { enabled?: unknown }).enabled === true,
|
|
8710
8988
|
)
|
|
8711
8989
|
: [];
|
|
8990
|
+
const options: string[] = Array.isArray(msg.options)
|
|
8991
|
+
? msg.options.filter((option: unknown): option is string => typeof option === "string")
|
|
8992
|
+
: [];
|
|
8993
|
+
const multiSelect = Array.isArray(msg.selectedOptionIndices);
|
|
8994
|
+
const selectedOptionIndices = new Set(
|
|
8995
|
+
multiSelect
|
|
8996
|
+
? msg.selectedOptionIndices.filter(
|
|
8997
|
+
(index: unknown): index is number =>
|
|
8998
|
+
Number.isInteger(index) && Number(index) >= 0 && Number(index) < options.length,
|
|
8999
|
+
)
|
|
9000
|
+
: [],
|
|
9001
|
+
);
|
|
9002
|
+
const displayOptions = options.map((option, index) =>
|
|
9003
|
+
multiSelect ? `${selectedOptionIndices.has(index) ? "☑" : "☐"} ${option}` : option,
|
|
9004
|
+
);
|
|
8712
9005
|
const rendered = buildActionMessage({
|
|
8713
9006
|
kind: msg.kind ?? "ask",
|
|
8714
9007
|
id: msg.id,
|
|
8715
9008
|
question: msg.question,
|
|
8716
|
-
options:
|
|
9009
|
+
options: displayOptions,
|
|
8717
9010
|
recommendedIndex: msg.recommendedIndex,
|
|
8718
9011
|
controls,
|
|
8719
9012
|
summary: msg.summary,
|
|
8720
9013
|
});
|
|
8721
|
-
const options = Array.isArray(msg.options) ? msg.options : [];
|
|
8722
9014
|
const inline_keyboard = [
|
|
8723
|
-
...buildCompactChoiceGrid(
|
|
9015
|
+
...buildCompactChoiceGrid(displayOptions, (i: number) =>
|
|
8724
9016
|
this.aliasTable.put({ sessionId: logicalSessionId, actionId: msg.id, answer: i }),
|
|
8725
9017
|
),
|
|
8726
9018
|
...controls.map(control => [
|
|
@@ -8734,6 +9026,7 @@ export class TelegramNotificationDaemon {
|
|
|
8734
9026
|
},
|
|
8735
9027
|
]),
|
|
8736
9028
|
];
|
|
9029
|
+
const kind = msg.kind === "idle" ? "idle" : "ask";
|
|
8737
9030
|
// HTML delivery: one sendMessage per chunk, keyboard on the last chunk;
|
|
8738
9031
|
// returns the last chunk's message_id (the reply-routable message).
|
|
8739
9032
|
const sendHtmlChunks = async (): Promise<number | undefined> => {
|
|
@@ -8747,13 +9040,15 @@ export class TelegramNotificationDaemon {
|
|
|
8747
9040
|
...threadField,
|
|
8748
9041
|
text: chunks[i]!,
|
|
8749
9042
|
parse_mode: TELEGRAM_PARSE_MODE,
|
|
9043
|
+
...(telegramDisableNotification(this.opts.sound, kind, i === chunks.length - 1) === true
|
|
9044
|
+
? { disable_notification: true }
|
|
9045
|
+
: {}),
|
|
8750
9046
|
...(i === chunks.length - 1 && inline_keyboard.length ? { reply_markup: { inline_keyboard } } : {}),
|
|
8751
9047
|
})) as { result?: { message_id?: number } } | undefined;
|
|
8752
9048
|
if (result === undefined) return undefined;
|
|
8753
9049
|
}
|
|
8754
9050
|
return result?.result?.message_id;
|
|
8755
9051
|
};
|
|
8756
|
-
const kind = msg.kind === "idle" ? "idle" : "ask";
|
|
8757
9052
|
if (this.opts.rich?.enabled !== false) {
|
|
8758
9053
|
// Rich (default on): promote to sendRichMessage with a top-level
|
|
8759
9054
|
// reply_markup (probe-confirmed). Any miss falls back to the HTML loop.
|
|
@@ -8761,12 +9056,18 @@ export class TelegramNotificationDaemon {
|
|
|
8761
9056
|
if (!this.#leaseTokenAllows(socketLease) || (topicLease && !this.topicLeaseIsCurrent(topicLease))) return;
|
|
8762
9057
|
const outcome = await deliverRichActionWithFallback(
|
|
8763
9058
|
this.botApi,
|
|
8764
|
-
{
|
|
9059
|
+
{
|
|
9060
|
+
chat_id: this.opts.chatId,
|
|
9061
|
+
...threadField,
|
|
9062
|
+
...(telegramDisableNotification(this.opts.sound, kind) === true
|
|
9063
|
+
? { disable_notification: true }
|
|
9064
|
+
: {}),
|
|
9065
|
+
},
|
|
8765
9066
|
{
|
|
8766
9067
|
markdown: buildActionMarkdown({
|
|
8767
9068
|
kind,
|
|
8768
9069
|
question: msg.question,
|
|
8769
|
-
options:
|
|
9070
|
+
options: displayOptions,
|
|
8770
9071
|
recommendedIndex: msg.recommendedIndex,
|
|
8771
9072
|
summary: msg.summary,
|
|
8772
9073
|
}),
|
|
@@ -8780,10 +9081,9 @@ export class TelegramNotificationDaemon {
|
|
|
8780
9081
|
if (kind === "ask" && outcome.messageId !== undefined)
|
|
8781
9082
|
this.messageRoutes.set(String(outcome.messageId), { sessionId: logicalSessionId, actionId: msg.id });
|
|
8782
9083
|
} else {
|
|
8783
|
-
//
|
|
9084
|
+
// Rich disabled: deliver through the HTML chunk path.
|
|
8784
9085
|
const messageId = await sendHtmlChunks();
|
|
8785
|
-
// Only asks are reply-routable; idle pings register no route
|
|
8786
|
-
// with the rich branch and correct even in the byte-identical off path).
|
|
9086
|
+
// Only asks are reply-routable; idle pings register no route.
|
|
8787
9087
|
if (kind === "ask" && messageId !== undefined)
|
|
8788
9088
|
this.messageRoutes.set(String(messageId), { sessionId: logicalSessionId, actionId: msg.id });
|
|
8789
9089
|
}
|
|
@@ -8889,6 +9189,9 @@ export class TelegramNotificationDaemon {
|
|
|
8889
9189
|
chat_id: this.opts.chatId,
|
|
8890
9190
|
text,
|
|
8891
9191
|
parse_mode: TELEGRAM_PARSE_MODE,
|
|
9192
|
+
...(telegramDisableNotification(this.opts.sound, "finalized") === true
|
|
9193
|
+
? { disable_notification: true }
|
|
9194
|
+
: {}),
|
|
8892
9195
|
});
|
|
8893
9196
|
} catch {
|
|
8894
9197
|
// Best-effort stale guidance must not reject a callback.
|
|
@@ -8902,7 +9205,570 @@ export class TelegramNotificationDaemon {
|
|
|
8902
9205
|
chat_id: this.opts.chatId,
|
|
8903
9206
|
text: "This button is stale after notification daemon restart. Please answer locally in the GJC session or wait for a fresh notification.",
|
|
8904
9207
|
parse_mode: TELEGRAM_PARSE_MODE,
|
|
9208
|
+
...(telegramDisableNotification(this.opts.sound, "finalized") === true ? { disable_notification: true } : {}),
|
|
9209
|
+
});
|
|
9210
|
+
}
|
|
9211
|
+
/**
|
|
9212
|
+
* Consume a user-created forum topic (`forum_topic_created` service message)
|
|
9213
|
+
* before edited/text routing. Authenticates the chat, user, privacy, and
|
|
9214
|
+
* lifecycle state; offers home, recent folders, or explicit path entry. The
|
|
9215
|
+
* selected path (or an exact `/session_create path <dir>`) creates a session
|
|
9216
|
+
* that adopts this topic — never a duplicate topic. Non-routable durable
|
|
9217
|
+
* intents prevent duplicate creation across concurrent first frames.
|
|
9218
|
+
*/
|
|
9219
|
+
private async handleForumTopicCreatedUpdate(update: unknown): Promise<"not-topic" | TelegramUpdateOutcome> {
|
|
9220
|
+
const parsed = update as {
|
|
9221
|
+
update_id?: unknown;
|
|
9222
|
+
message?: {
|
|
9223
|
+
chat?: { id?: unknown };
|
|
9224
|
+
from?: { id?: unknown; is_bot?: unknown };
|
|
9225
|
+
message_thread_id?: unknown;
|
|
9226
|
+
forum_topic_created?: { name?: unknown };
|
|
9227
|
+
};
|
|
9228
|
+
};
|
|
9229
|
+
const message = parsed.message;
|
|
9230
|
+
if (!message?.forum_topic_created) return "not-topic";
|
|
9231
|
+
const updateId = parsed.update_id;
|
|
9232
|
+
if (typeof updateId !== "number" || !Number.isSafeInteger(updateId) || updateId < 0) return "consumed";
|
|
9233
|
+
if (this.dispatchState.seenUpdateIds.has(updateId)) return "consumed";
|
|
9234
|
+
const configuredUserId = Number(this.opts.chatId);
|
|
9235
|
+
if (
|
|
9236
|
+
!Number.isSafeInteger(configuredUserId) ||
|
|
9237
|
+
typeof message.chat?.id !== "number" ||
|
|
9238
|
+
message.chat.id !== configuredUserId ||
|
|
9239
|
+
message.from?.id !== configuredUserId ||
|
|
9240
|
+
message.from?.is_bot !== false
|
|
9241
|
+
)
|
|
9242
|
+
return "consumed";
|
|
9243
|
+
const privacy = await this.resolvePairedChatPrivacy();
|
|
9244
|
+
if (privacy === "indeterminate") return "retry";
|
|
9245
|
+
if (privacy !== "private") return "consumed";
|
|
9246
|
+
const threadId = message.message_thread_id;
|
|
9247
|
+
if (typeof threadId !== "number" || !Number.isSafeInteger(threadId) || threadId <= 0) return "consumed";
|
|
9248
|
+
if (!this.lifecycleControlActive) return "consumed";
|
|
9249
|
+
// A non-expired intent for this topic means a session is already starting;
|
|
9250
|
+
// do not mint a new intendedSessionId or render a second picker.
|
|
9251
|
+
if (this.#adoptionIntents.hasNonExpiredTopic(threadId)) {
|
|
9252
|
+
try {
|
|
9253
|
+
await this.botApi.call("sendMessage", {
|
|
9254
|
+
chat_id: this.opts.chatId,
|
|
9255
|
+
message_thread_id: threadId,
|
|
9256
|
+
text: "A session is already starting in this topic.",
|
|
9257
|
+
});
|
|
9258
|
+
} catch {
|
|
9259
|
+
// Best-effort notice; never block on the side message.
|
|
9260
|
+
}
|
|
9261
|
+
await this.rememberSeenUpdateId(updateId);
|
|
9262
|
+
return "consumed";
|
|
9263
|
+
}
|
|
9264
|
+
// The topic may already be bound to an active session (late duplicate of a
|
|
9265
|
+
// created update whose adoption already committed). Do not re-offer.
|
|
9266
|
+
if (this.topics.sessionForTopic(String(threadId))) {
|
|
9267
|
+
await this.rememberSeenUpdateId(updateId);
|
|
9268
|
+
return "consumed";
|
|
9269
|
+
}
|
|
9270
|
+
let pendingTopic = this.#adoptionIntents.pendingTopic(threadId);
|
|
9271
|
+
if (!pendingTopic) {
|
|
9272
|
+
const now = this.runtime.now();
|
|
9273
|
+
pendingTopic = {
|
|
9274
|
+
topicId: threadId,
|
|
9275
|
+
chatId: String(this.opts.chatId),
|
|
9276
|
+
createdAt: now,
|
|
9277
|
+
expiresAt: now + this.#adoptionIntentTtlMs,
|
|
9278
|
+
};
|
|
9279
|
+
try {
|
|
9280
|
+
await this.#adoptionIntents.putPendingTopic(pendingTopic);
|
|
9281
|
+
} catch (error) {
|
|
9282
|
+
logger.warn(
|
|
9283
|
+
`notifications: failed to persist pending user topic: ${sanitizeDiagnostic(String(error), this.opts.botToken)}`,
|
|
9284
|
+
);
|
|
9285
|
+
return "retry";
|
|
9286
|
+
}
|
|
9287
|
+
}
|
|
9288
|
+
if (!(await this.#renderAdoptionPicker(threadId))) return "retry";
|
|
9289
|
+
await this.rememberSeenUpdateId(updateId);
|
|
9290
|
+
return "consumed";
|
|
9291
|
+
}
|
|
9292
|
+
|
|
9293
|
+
/** Render the top-level folder source choices for a user-created topic. */
|
|
9294
|
+
async #renderAdoptionPicker(threadId: number): Promise<boolean> {
|
|
9295
|
+
const intendedSessionId = `s${crypto.randomBytes(6).toString("hex")}`;
|
|
9296
|
+
const chatId = String(this.opts.chatId);
|
|
9297
|
+
const choices: Array<{ label: string; action: TopicPickerAction }> = [
|
|
9298
|
+
{ label: "1. Home (~)", action: { kind: "select", target: { kind: "existing_path", path: os.homedir() } } },
|
|
9299
|
+
{ label: "2. Recent work folders", action: { kind: "recent" } },
|
|
9300
|
+
{ label: "3. Enter a path", action: { kind: "direct" } },
|
|
9301
|
+
];
|
|
9302
|
+
const aliases = choices.map(choice => ({
|
|
9303
|
+
...choice,
|
|
9304
|
+
alias: this.#putAdoptionPickerAlias({
|
|
9305
|
+
topicId: threadId,
|
|
9306
|
+
intendedSessionId,
|
|
9307
|
+
chatId,
|
|
9308
|
+
action: choice.action,
|
|
9309
|
+
}),
|
|
9310
|
+
}));
|
|
9311
|
+
try {
|
|
9312
|
+
const response = await this.botApi.call("sendMessage", {
|
|
9313
|
+
chat_id: this.opts.chatId,
|
|
9314
|
+
message_thread_id: threadId,
|
|
9315
|
+
text: "Choose where to start the GJC session:",
|
|
9316
|
+
reply_markup: {
|
|
9317
|
+
inline_keyboard: aliases.map(choice => [{ text: choice.label, callback_data: choice.alias }]),
|
|
9318
|
+
},
|
|
9319
|
+
});
|
|
9320
|
+
if (!response || typeof response !== "object" || (response as { ok?: unknown }).ok !== true) {
|
|
9321
|
+
for (const choice of aliases) this.#adoptionPickerAliases.delete(choice.alias);
|
|
9322
|
+
return false;
|
|
9323
|
+
}
|
|
9324
|
+
return true;
|
|
9325
|
+
} catch {
|
|
9326
|
+
for (const choice of aliases) this.#adoptionPickerAliases.delete(choice.alias);
|
|
9327
|
+
logger.warn("notifications: failed to send topic-adoption picker keyboard");
|
|
9328
|
+
return false;
|
|
9329
|
+
}
|
|
9330
|
+
}
|
|
9331
|
+
|
|
9332
|
+
/** Render verified recent work folders as one-shot picker buttons. */
|
|
9333
|
+
async #renderRecentFolderChoices(threadId: number, intendedSessionId: string): Promise<boolean> {
|
|
9334
|
+
let recent: ListRecentSessionsResult;
|
|
9335
|
+
try {
|
|
9336
|
+
recent = await listRecentSessions({
|
|
9337
|
+
cwd: process.cwd(),
|
|
9338
|
+
agentDir: this.opts.settings.getAgentDir(),
|
|
9339
|
+
limit: 8,
|
|
9340
|
+
includeInternal: false,
|
|
9341
|
+
allWorkspaces: true,
|
|
9342
|
+
});
|
|
9343
|
+
} catch (error) {
|
|
9344
|
+
logger.warn(
|
|
9345
|
+
`notifications: failed to list recent folders for topic adoption: ${sanitizeDiagnostic(String(error))}`,
|
|
9346
|
+
);
|
|
9347
|
+
return false;
|
|
9348
|
+
}
|
|
9349
|
+
const entries = recent.kind === "complete" ? recent.entries.filter(entry => entry.path) : [];
|
|
9350
|
+
const chatId = String(this.opts.chatId);
|
|
9351
|
+
if (entries.length === 0) {
|
|
9352
|
+
const text =
|
|
9353
|
+
recent.kind === "error"
|
|
9354
|
+
? `Recent work folders could not be verified: ${recent.message}. Choose “Enter a path” from a new topic or use /session_create path <dir>.`
|
|
9355
|
+
: "No recent verified work folders were found. Choose “Enter a path” from a new topic or use /session_create path <dir>.";
|
|
9356
|
+
try {
|
|
9357
|
+
const response = await this.botApi.call("sendMessage", {
|
|
9358
|
+
chat_id: this.opts.chatId,
|
|
9359
|
+
message_thread_id: threadId,
|
|
9360
|
+
text,
|
|
9361
|
+
});
|
|
9362
|
+
return Boolean(response && typeof response === "object" && (response as { ok?: unknown }).ok === true);
|
|
9363
|
+
} catch {
|
|
9364
|
+
return false;
|
|
9365
|
+
}
|
|
9366
|
+
}
|
|
9367
|
+
const aliases = entries.map(entry => {
|
|
9368
|
+
const target = { kind: "existing_path" as const, path: entry.path! };
|
|
9369
|
+
const alias = this.#putAdoptionPickerAlias({
|
|
9370
|
+
topicId: threadId,
|
|
9371
|
+
intendedSessionId,
|
|
9372
|
+
chatId,
|
|
9373
|
+
action: { kind: "select", target },
|
|
9374
|
+
});
|
|
9375
|
+
return { alias, label: this.#pickerLabel(entry) };
|
|
8905
9376
|
});
|
|
9377
|
+
const inline_keyboard = buildButtonGrid(
|
|
9378
|
+
aliases.map(a => a.label),
|
|
9379
|
+
index => aliases[index]!.alias,
|
|
9380
|
+
);
|
|
9381
|
+
try {
|
|
9382
|
+
const response = await this.botApi.call("sendMessage", {
|
|
9383
|
+
chat_id: this.opts.chatId,
|
|
9384
|
+
message_thread_id: threadId,
|
|
9385
|
+
text: "Pick a recent work folder to start a session here:",
|
|
9386
|
+
reply_markup: { inline_keyboard },
|
|
9387
|
+
});
|
|
9388
|
+
if (!response || typeof response !== "object" || (response as { ok?: unknown }).ok !== true) {
|
|
9389
|
+
for (const a of aliases) this.#adoptionPickerAliases.delete(a.alias);
|
|
9390
|
+
return false;
|
|
9391
|
+
}
|
|
9392
|
+
return true;
|
|
9393
|
+
} catch {
|
|
9394
|
+
for (const a of aliases) this.#adoptionPickerAliases.delete(a.alias);
|
|
9395
|
+
logger.warn("notifications: failed to send recent-folder picker keyboard");
|
|
9396
|
+
return false;
|
|
9397
|
+
}
|
|
9398
|
+
}
|
|
9399
|
+
|
|
9400
|
+
#pickerLabel(entry: { sessionId: string; path?: string; branch?: string; title?: string }): string {
|
|
9401
|
+
const base = path.basename(entry.path ?? entry.sessionId);
|
|
9402
|
+
const raw = entry.branch ? `${base} (${entry.branch})` : base;
|
|
9403
|
+
return safeModelButtonLabel(raw) ?? "workspace";
|
|
9404
|
+
}
|
|
9405
|
+
|
|
9406
|
+
#sweepExpiredAdoptionPickerAliases(): void {
|
|
9407
|
+
const now = this.runtime.now();
|
|
9408
|
+
for (const [alias, route] of this.#adoptionPickerAliases) {
|
|
9409
|
+
if (route.expiresAt <= now) this.#adoptionPickerAliases.delete(alias);
|
|
9410
|
+
}
|
|
9411
|
+
for (const [topicId, pending] of this.#pendingAdoptionPathInputs) {
|
|
9412
|
+
if (pending.expiresAt <= now) this.#pendingAdoptionPathInputs.delete(topicId);
|
|
9413
|
+
}
|
|
9414
|
+
}
|
|
9415
|
+
|
|
9416
|
+
#putAdoptionPickerAlias(route: Omit<TopicPickerRoute, "expiresAt">): string {
|
|
9417
|
+
this.#sweepExpiredAdoptionPickerAliases();
|
|
9418
|
+
let alias: string;
|
|
9419
|
+
do {
|
|
9420
|
+
alias = `${PICKER_CALLBACK_PREFIX}${crypto.randomBytes(16).toString("base64url")}`;
|
|
9421
|
+
} while (this.#adoptionPickerAliases.has(alias));
|
|
9422
|
+
this.#adoptionPickerAliases.set(alias, { ...route, expiresAt: this.runtime.now() + this.#adoptionIntentTtlMs });
|
|
9423
|
+
return alias;
|
|
9424
|
+
}
|
|
9425
|
+
#deleteAdoptionPickerAliasesForTopic(topicId: number): void {
|
|
9426
|
+
for (const [alias, route] of this.#adoptionPickerAliases) {
|
|
9427
|
+
if (route.topicId === topicId) this.#adoptionPickerAliases.delete(alias);
|
|
9428
|
+
}
|
|
9429
|
+
}
|
|
9430
|
+
|
|
9431
|
+
/** Handle a one-shot picker button tap. Re-validates chat/topic/expiry/target before submit. */
|
|
9432
|
+
async #handleAdoptionPickerCallback(update: unknown, callbackId: unknown): Promise<boolean> {
|
|
9433
|
+
const parsed = update as {
|
|
9434
|
+
update_id?: unknown;
|
|
9435
|
+
callback_query?: {
|
|
9436
|
+
data?: unknown;
|
|
9437
|
+
from?: { id?: unknown; is_bot?: unknown };
|
|
9438
|
+
message?: { chat?: { id?: unknown }; message_id?: unknown; message_thread_id?: unknown };
|
|
9439
|
+
};
|
|
9440
|
+
};
|
|
9441
|
+
const callback = parsed.callback_query;
|
|
9442
|
+
const alias = callback?.data;
|
|
9443
|
+
if (typeof alias !== "string" || !alias.startsWith(PICKER_CALLBACK_PREFIX)) return false;
|
|
9444
|
+
this.#sweepExpiredAdoptionPickerAliases();
|
|
9445
|
+
const route = this.#adoptionPickerAliases.get(alias);
|
|
9446
|
+
if (!route) {
|
|
9447
|
+
await this.answerCallbackQueryBestEffort(callbackId, "Button is stale");
|
|
9448
|
+
return true;
|
|
9449
|
+
}
|
|
9450
|
+
const configuredUserId = Number(this.opts.chatId);
|
|
9451
|
+
const updateId = parsed.update_id;
|
|
9452
|
+
if (
|
|
9453
|
+
!Number.isSafeInteger(configuredUserId) ||
|
|
9454
|
+
String(callback?.message?.chat?.id) !== route.chatId ||
|
|
9455
|
+
route.chatId !== String(this.opts.chatId) ||
|
|
9456
|
+
callback?.from?.id !== configuredUserId ||
|
|
9457
|
+
callback?.from?.is_bot !== false ||
|
|
9458
|
+
callback?.message?.message_thread_id !== route.topicId ||
|
|
9459
|
+
typeof updateId !== "number" ||
|
|
9460
|
+
!Number.isSafeInteger(updateId) ||
|
|
9461
|
+
updateId < 0 ||
|
|
9462
|
+
!this.lifecycleControlActive
|
|
9463
|
+
) {
|
|
9464
|
+
await this.answerCallbackQueryBestEffort(callbackId, "Not authorized");
|
|
9465
|
+
return true;
|
|
9466
|
+
}
|
|
9467
|
+
if (route.expiresAt <= this.runtime.now()) {
|
|
9468
|
+
this.#deleteAdoptionPickerAliasesForTopic(route.topicId);
|
|
9469
|
+
await this.answerCallbackQueryBestEffort(callbackId, "Button is stale");
|
|
9470
|
+
return true;
|
|
9471
|
+
}
|
|
9472
|
+
if (!this.#adoptionIntents.hasPendingTopic(route.topicId, route.chatId)) {
|
|
9473
|
+
this.#deleteAdoptionPickerAliasesForTopic(route.topicId);
|
|
9474
|
+
await this.answerCallbackQueryBestEffort(callbackId, "Button is stale");
|
|
9475
|
+
return true;
|
|
9476
|
+
}
|
|
9477
|
+
if (
|
|
9478
|
+
this.dispatchState.seenUpdateIds.has(updateId) ||
|
|
9479
|
+
this.#adoptionIntents.hasNonExpiredTopic(route.topicId) ||
|
|
9480
|
+
this.#adoptionStartingTopics.has(route.topicId)
|
|
9481
|
+
) {
|
|
9482
|
+
this.#deleteAdoptionPickerAliasesForTopic(route.topicId);
|
|
9483
|
+
await this.answerCallbackQueryBestEffort(callbackId, "A session is already starting in this topic.");
|
|
9484
|
+
return true;
|
|
9485
|
+
}
|
|
9486
|
+
if (route.action.kind === "select") {
|
|
9487
|
+
const valid = validateLifecycleTarget("session_create", route.action.target);
|
|
9488
|
+
if (!valid.ok) {
|
|
9489
|
+
this.#deleteAdoptionPickerAliasesForTopic(route.topicId);
|
|
9490
|
+
await this.answerCallbackQueryBestEffort(callbackId, "That folder is no longer valid.");
|
|
9491
|
+
return true;
|
|
9492
|
+
}
|
|
9493
|
+
}
|
|
9494
|
+
// Consume the whole picker synchronously before the first successful-path await.
|
|
9495
|
+
this.#deleteAdoptionPickerAliasesForTopic(route.topicId);
|
|
9496
|
+
if (!(await this.pairedChatIsPrivate())) {
|
|
9497
|
+
await this.answerCallbackQueryBestEffort(callbackId, "Not authorized");
|
|
9498
|
+
return true;
|
|
9499
|
+
}
|
|
9500
|
+
await this.rememberSeenUpdateId(updateId);
|
|
9501
|
+
await this.answerCallbackQueryBestEffort(callbackId);
|
|
9502
|
+
if (route.action.kind === "recent") {
|
|
9503
|
+
if (!(await this.#renderRecentFolderChoices(route.topicId, route.intendedSessionId))) {
|
|
9504
|
+
await this.botApi
|
|
9505
|
+
.call("sendMessage", {
|
|
9506
|
+
chat_id: this.opts.chatId,
|
|
9507
|
+
message_thread_id: route.topicId,
|
|
9508
|
+
text: "Recent work folders could not be loaded. Open a new topic and try again, or use /session_create path <dir>.",
|
|
9509
|
+
})
|
|
9510
|
+
.catch(() => undefined);
|
|
9511
|
+
}
|
|
9512
|
+
return true;
|
|
9513
|
+
}
|
|
9514
|
+
if (route.action.kind === "direct") {
|
|
9515
|
+
this.#pendingAdoptionPathInputs.set(route.topicId, {
|
|
9516
|
+
chatId: route.chatId,
|
|
9517
|
+
intendedSessionId: route.intendedSessionId,
|
|
9518
|
+
expiresAt: route.expiresAt,
|
|
9519
|
+
});
|
|
9520
|
+
await this.botApi
|
|
9521
|
+
.call("sendMessage", {
|
|
9522
|
+
chat_id: this.opts.chatId,
|
|
9523
|
+
message_thread_id: route.topicId,
|
|
9524
|
+
text: "Send the folder path in your next message. Examples: ~ or ~/projects/my-app",
|
|
9525
|
+
})
|
|
9526
|
+
.catch(() => undefined);
|
|
9527
|
+
return true;
|
|
9528
|
+
}
|
|
9529
|
+
await this.#submitAdoptionFrame(
|
|
9530
|
+
route.topicId,
|
|
9531
|
+
route.action.target,
|
|
9532
|
+
route.chatId,
|
|
9533
|
+
updateId,
|
|
9534
|
+
route.intendedSessionId,
|
|
9535
|
+
);
|
|
9536
|
+
return true;
|
|
9537
|
+
}
|
|
9538
|
+
|
|
9539
|
+
/** Handle the next text message after the user explicitly chooses direct path entry. */
|
|
9540
|
+
async #handleAdoptionPathInput(update: unknown): Promise<boolean> {
|
|
9541
|
+
const parsed = update as { update_id?: unknown; message?: Record<string, unknown> };
|
|
9542
|
+
const message = parsed.message;
|
|
9543
|
+
const threadId = message?.message_thread_id;
|
|
9544
|
+
if (typeof threadId !== "number" || !Number.isSafeInteger(threadId) || threadId <= 0) return false;
|
|
9545
|
+
this.#sweepExpiredAdoptionPickerAliases();
|
|
9546
|
+
const pending = this.#pendingAdoptionPathInputs.get(threadId);
|
|
9547
|
+
if (!pending) return false;
|
|
9548
|
+
|
|
9549
|
+
const text = typeof message?.text === "string" ? message.text.trim() : undefined;
|
|
9550
|
+
const chat = message?.chat as { id?: unknown; type?: unknown } | undefined;
|
|
9551
|
+
const from = message?.from as { id?: unknown; is_bot?: unknown } | undefined;
|
|
9552
|
+
const updateId = parsed.update_id;
|
|
9553
|
+
const commandCtx = {
|
|
9554
|
+
chatType: typeof chat?.type === "string" ? chat.type : undefined,
|
|
9555
|
+
botUsername: this.botUsername,
|
|
9556
|
+
};
|
|
9557
|
+
if (isLifecycleCommandText(text, commandCtx)) return false;
|
|
9558
|
+
const configuredUserId = Number(this.opts.chatId);
|
|
9559
|
+
if (
|
|
9560
|
+
!Number.isSafeInteger(configuredUserId) ||
|
|
9561
|
+
String(chat?.id) !== pending.chatId ||
|
|
9562
|
+
pending.chatId !== String(this.opts.chatId) ||
|
|
9563
|
+
from?.id !== configuredUserId ||
|
|
9564
|
+
from?.is_bot !== false ||
|
|
9565
|
+
typeof updateId !== "number" ||
|
|
9566
|
+
!Number.isSafeInteger(updateId) ||
|
|
9567
|
+
updateId < 0
|
|
9568
|
+
)
|
|
9569
|
+
return true;
|
|
9570
|
+
if (!this.lifecycleControlActive || this.dispatchState.seenUpdateIds.has(updateId)) return true;
|
|
9571
|
+
if (!(await this.pairedChatIsPrivate())) return true;
|
|
9572
|
+
if (
|
|
9573
|
+
!this.#adoptionIntents.hasPendingTopic(threadId, pending.chatId) ||
|
|
9574
|
+
this.#adoptionIntents.hasNonExpiredTopic(threadId) ||
|
|
9575
|
+
this.#adoptionStartingTopics.has(threadId)
|
|
9576
|
+
) {
|
|
9577
|
+
this.#pendingAdoptionPathInputs.delete(threadId);
|
|
9578
|
+
return true;
|
|
9579
|
+
}
|
|
9580
|
+
|
|
9581
|
+
const normalized = text ? normalizeLifecyclePath(text) : undefined;
|
|
9582
|
+
const target = normalized ? ({ kind: "existing_path", path: normalized } as const) : undefined;
|
|
9583
|
+
if (!target || !validateLifecycleTarget("session_create", target).ok) {
|
|
9584
|
+
await this.rememberSeenUpdateId(updateId);
|
|
9585
|
+
await this.botApi
|
|
9586
|
+
.call("sendMessage", {
|
|
9587
|
+
chat_id: this.opts.chatId,
|
|
9588
|
+
message_thread_id: threadId,
|
|
9589
|
+
text: "That path is invalid. Send a folder path such as ~ or ~/projects/my-app.",
|
|
9590
|
+
})
|
|
9591
|
+
.catch(() => undefined);
|
|
9592
|
+
return true;
|
|
9593
|
+
}
|
|
9594
|
+
|
|
9595
|
+
this.#pendingAdoptionPathInputs.delete(threadId);
|
|
9596
|
+
await this.rememberSeenUpdateId(updateId);
|
|
9597
|
+
await this.#submitAdoptionFrame(threadId, target, pending.chatId, updateId, pending.intendedSessionId);
|
|
9598
|
+
return true;
|
|
9599
|
+
}
|
|
9600
|
+
/**
|
|
9601
|
+
* Handle an exact `/session_create path <dir>` typed inside a pending
|
|
9602
|
+
* (user-created, not-yet-adopted) topic without requiring the direct-entry choice.
|
|
9603
|
+
*/
|
|
9604
|
+
async #handleAdoptionDirectPath(update: unknown): Promise<boolean> {
|
|
9605
|
+
const parsedUpdate = update as { update_id?: unknown; message?: Record<string, unknown> };
|
|
9606
|
+
const message = parsedUpdate.message;
|
|
9607
|
+
const text = typeof message?.text === "string" ? message.text : undefined;
|
|
9608
|
+
const chat = message?.chat as { id?: unknown; type?: unknown } | undefined;
|
|
9609
|
+
const from = message?.from as { id?: unknown; is_bot?: unknown } | undefined;
|
|
9610
|
+
const commandCtx = {
|
|
9611
|
+
chatType: typeof chat?.type === "string" ? chat.type : undefined,
|
|
9612
|
+
botUsername: this.botUsername,
|
|
9613
|
+
};
|
|
9614
|
+
if (!isLifecycleCommandText(text, commandCtx)) return false;
|
|
9615
|
+
const parsed = parseLifecycleCommand(text, commandCtx);
|
|
9616
|
+
if (parsed.kind !== "create") return false;
|
|
9617
|
+
const configuredUserId = Number(this.opts.chatId);
|
|
9618
|
+
const threadId = message?.message_thread_id;
|
|
9619
|
+
const updateId = parsedUpdate.update_id;
|
|
9620
|
+
if (typeof threadId !== "number" || !Number.isSafeInteger(threadId) || threadId <= 0) return false;
|
|
9621
|
+
if (
|
|
9622
|
+
!Number.isSafeInteger(configuredUserId) ||
|
|
9623
|
+
String(chat?.id) !== String(this.opts.chatId) ||
|
|
9624
|
+
from?.id !== configuredUserId ||
|
|
9625
|
+
from?.is_bot !== false ||
|
|
9626
|
+
typeof updateId !== "number" ||
|
|
9627
|
+
!Number.isSafeInteger(updateId) ||
|
|
9628
|
+
updateId < 0
|
|
9629
|
+
)
|
|
9630
|
+
return true;
|
|
9631
|
+
if (!this.lifecycleControlActive) return false;
|
|
9632
|
+
if (this.dispatchState.seenUpdateIds.has(updateId)) return true;
|
|
9633
|
+
if (!(await this.pairedChatIsPrivate())) return true;
|
|
9634
|
+
if (this.topics.sessionForTopic(String(threadId))) return false;
|
|
9635
|
+
if (!this.#adoptionIntents.hasPendingTopic(threadId, String(this.opts.chatId))) {
|
|
9636
|
+
await this.rememberSeenUpdateId(updateId);
|
|
9637
|
+
await this.botApi
|
|
9638
|
+
.call("sendMessage", {
|
|
9639
|
+
chat_id: this.opts.chatId,
|
|
9640
|
+
message_thread_id: threadId,
|
|
9641
|
+
text: "This topic was not opened for a new GJC session, or its folder picker expired. Open a new topic and try again.",
|
|
9642
|
+
})
|
|
9643
|
+
.catch(error => {
|
|
9644
|
+
logger.warn(
|
|
9645
|
+
`notifications: failed to report stale topic adoption: ${sanitizeDiagnostic(String(error), this.opts.botToken)}`,
|
|
9646
|
+
);
|
|
9647
|
+
});
|
|
9648
|
+
return true;
|
|
9649
|
+
}
|
|
9650
|
+
if (parsed.target.kind !== "existing_path") {
|
|
9651
|
+
await this.rememberSeenUpdateId(updateId);
|
|
9652
|
+
await this.botApi
|
|
9653
|
+
.call("sendMessage", {
|
|
9654
|
+
chat_id: this.opts.chatId,
|
|
9655
|
+
message_thread_id: threadId,
|
|
9656
|
+
text: "This topic can start a session only with /session_create path <dir>.",
|
|
9657
|
+
})
|
|
9658
|
+
.catch(error => {
|
|
9659
|
+
logger.warn(
|
|
9660
|
+
`notifications: failed to report unsupported topic-adoption target: ${sanitizeDiagnostic(String(error), this.opts.botToken)}`,
|
|
9661
|
+
);
|
|
9662
|
+
});
|
|
9663
|
+
return true;
|
|
9664
|
+
}
|
|
9665
|
+
const valid = validateLifecycleTarget("session_create", parsed.target);
|
|
9666
|
+
if (!valid.ok) return false;
|
|
9667
|
+
await this.rememberSeenUpdateId(updateId);
|
|
9668
|
+
await this.#submitAdoptionFrame(threadId, parsed.target, String(this.opts.chatId), updateId);
|
|
9669
|
+
return true;
|
|
9670
|
+
}
|
|
9671
|
+
|
|
9672
|
+
/**
|
|
9673
|
+
* Build an authenticated lifecycle frame, durably persist the adoption intent
|
|
9674
|
+
* BEFORE submit, then submit. Definite no-spawn failures remove the intent;
|
|
9675
|
+
* readiness_timeout / terminal_uncertain retain it because a late spawn may
|
|
9676
|
+
* still adopt the original topic. Never deletes the user topic.
|
|
9677
|
+
*/
|
|
9678
|
+
async #submitAdoptionFrame(
|
|
9679
|
+
topicId: number,
|
|
9680
|
+
target: TelegramAdoptionTarget,
|
|
9681
|
+
chatId: string,
|
|
9682
|
+
updateId: number,
|
|
9683
|
+
preallocatedSessionId?: string,
|
|
9684
|
+
): Promise<void> {
|
|
9685
|
+
if (this.#adoptionStartingTopics.has(topicId) || this.#adoptionIntents.hasNonExpiredTopic(topicId)) {
|
|
9686
|
+
await this.botApi
|
|
9687
|
+
.call("sendMessage", {
|
|
9688
|
+
chat_id: this.opts.chatId,
|
|
9689
|
+
message_thread_id: topicId,
|
|
9690
|
+
text: "A session is already starting in this topic.",
|
|
9691
|
+
})
|
|
9692
|
+
.catch(() => undefined);
|
|
9693
|
+
return;
|
|
9694
|
+
}
|
|
9695
|
+
this.#adoptionStartingTopics.add(topicId);
|
|
9696
|
+
try {
|
|
9697
|
+
const frame = this.buildLifecycleFrame({ kind: "create", target }, updateId, preallocatedSessionId);
|
|
9698
|
+
if (frame.type !== "session_create") throw new Error("topic adoption requires a create frame");
|
|
9699
|
+
const intendedSessionId = frame.intendedSessionId;
|
|
9700
|
+
const intent = buildAdoptionIntent({
|
|
9701
|
+
intendedSessionId,
|
|
9702
|
+
topicId,
|
|
9703
|
+
chatId,
|
|
9704
|
+
target,
|
|
9705
|
+
now: this.runtime.now(),
|
|
9706
|
+
ttlMs: this.#adoptionIntentTtlMs,
|
|
9707
|
+
});
|
|
9708
|
+
try {
|
|
9709
|
+
await this.#adoptionIntents.put(intent);
|
|
9710
|
+
} catch (error) {
|
|
9711
|
+
logger.warn(`notifications: failed to persist topic-adoption intent: ${sanitizeDiagnostic(String(error))}`);
|
|
9712
|
+
try {
|
|
9713
|
+
await this.botApi.call("sendMessage", {
|
|
9714
|
+
chat_id: this.opts.chatId,
|
|
9715
|
+
message_thread_id: topicId,
|
|
9716
|
+
text: "Could not start a session because its durable topic binding could not be saved.",
|
|
9717
|
+
});
|
|
9718
|
+
} catch (noticeError) {
|
|
9719
|
+
logger.warn(
|
|
9720
|
+
`notifications: failed to report topic-adoption persistence failure: ${sanitizeDiagnostic(String(noticeError))}`,
|
|
9721
|
+
);
|
|
9722
|
+
}
|
|
9723
|
+
return;
|
|
9724
|
+
}
|
|
9725
|
+
const normalizedPath = target.kind === "existing_path" ? normalizeLifecyclePath(target.path) : undefined;
|
|
9726
|
+
let targetExists = false;
|
|
9727
|
+
if (normalizedPath) {
|
|
9728
|
+
try {
|
|
9729
|
+
const stat = await this.fsImpl.stat?.(normalizedPath);
|
|
9730
|
+
targetExists = stat?.isDirectory?.() === true;
|
|
9731
|
+
} catch {
|
|
9732
|
+
targetExists = false;
|
|
9733
|
+
}
|
|
9734
|
+
}
|
|
9735
|
+
if (!targetExists || !validateLifecycleTarget("session_create", target).ok) {
|
|
9736
|
+
await this.#adoptionIntents.remove(intendedSessionId);
|
|
9737
|
+
await this.botApi
|
|
9738
|
+
.call("sendMessage", {
|
|
9739
|
+
chat_id: this.opts.chatId,
|
|
9740
|
+
message_thread_id: topicId,
|
|
9741
|
+
text: "That folder is no longer available.",
|
|
9742
|
+
})
|
|
9743
|
+
.catch(() => undefined);
|
|
9744
|
+
return;
|
|
9745
|
+
}
|
|
9746
|
+
const response = await this.submitLifecycleFrame(frame);
|
|
9747
|
+
await this.botApi
|
|
9748
|
+
.call("sendMessage", {
|
|
9749
|
+
chat_id: this.opts.chatId,
|
|
9750
|
+
message_thread_id: topicId,
|
|
9751
|
+
text: this.formatLifecycleResponse(response, "session_create"),
|
|
9752
|
+
})
|
|
9753
|
+
.catch(error => {
|
|
9754
|
+
logger.warn(
|
|
9755
|
+
`notifications: failed to report topic-adoption lifecycle result: ${sanitizeDiagnostic(String(error), this.opts.botToken)}`,
|
|
9756
|
+
);
|
|
9757
|
+
});
|
|
9758
|
+
if (response.status === "ok") return;
|
|
9759
|
+
if (response.type === "session_lifecycle_error" && response.reason === "spawn_failed") {
|
|
9760
|
+
try {
|
|
9761
|
+
await this.#adoptionIntents.remove(intendedSessionId);
|
|
9762
|
+
} catch (error) {
|
|
9763
|
+
logger.warn(
|
|
9764
|
+
`notifications: failed to remove rejected topic-adoption intent: ${sanitizeDiagnostic(String(error))}`,
|
|
9765
|
+
);
|
|
9766
|
+
}
|
|
9767
|
+
}
|
|
9768
|
+
// readiness_timeout / terminal_uncertain: spawn may still be in flight; retain intent.
|
|
9769
|
+
} finally {
|
|
9770
|
+
this.#adoptionStartingTopics.delete(topicId);
|
|
9771
|
+
}
|
|
8906
9772
|
}
|
|
8907
9773
|
|
|
8908
9774
|
/** Consume Telegram forum-topic rename service messages before text routing. */
|
|
@@ -8967,6 +9833,8 @@ export class TelegramNotificationDaemon {
|
|
|
8967
9833
|
}
|
|
8968
9834
|
|
|
8969
9835
|
private async processTelegramUpdate(update: unknown): Promise<TelegramUpdateOutcome> {
|
|
9836
|
+
const createdOutcome = await this.handleForumTopicCreatedUpdate(update);
|
|
9837
|
+
if (createdOutcome !== "not-topic") return createdOutcome;
|
|
8970
9838
|
const topicOutcome = await this.handleForumTopicEdited(update);
|
|
8971
9839
|
if (topicOutcome !== "not-topic") return topicOutcome;
|
|
8972
9840
|
try {
|
|
@@ -8978,7 +9846,19 @@ export class TelegramNotificationDaemon {
|
|
|
8978
9846
|
}
|
|
8979
9847
|
|
|
8980
9848
|
async handleTelegramUpdate(update: unknown): Promise<void> {
|
|
9849
|
+
// A user-created forum topic (`forum_topic_created`) is consumed first: it
|
|
9850
|
+
// authenticates the chat/user, shows the folder-source picker, and emits the
|
|
9851
|
+
// "already starting" dedup notice for a topic with a live adoption intent.
|
|
9852
|
+
// This mirrors the poll-loop ordering in processTelegramUpdate so direct
|
|
9853
|
+
// (test/orchestration) callers observe identical created-topic handling.
|
|
9854
|
+
if ((await this.handleForumTopicCreatedUpdate(update)) !== "not-topic") return;
|
|
8981
9855
|
if ((await this.handleForumTopicEdited(update)) !== "not-topic") return;
|
|
9856
|
+
// A raw path is accepted only after the explicit direct-entry choice. The exact
|
|
9857
|
+
// `/session_create path <dir>` form remains available in any pending topic.
|
|
9858
|
+
// Both must precede the general lifecycle handler, which would otherwise create
|
|
9859
|
+
// a separate daemon-owned topic.
|
|
9860
|
+
if (await this.#handleAdoptionPathInput(update)) return;
|
|
9861
|
+
if (await this.#handleAdoptionDirectPath(update)) return;
|
|
8982
9862
|
// Session-lifecycle command (/session_*): handled ONLY from the paired chat,
|
|
8983
9863
|
// gated before any arg parsing or side effect, and routed through the control
|
|
8984
9864
|
// endpoint. Must run before threaded-injection so commands are not treated as
|
|
@@ -9038,6 +9918,9 @@ export class TelegramNotificationDaemon {
|
|
|
9038
9918
|
...threadField,
|
|
9039
9919
|
text: body,
|
|
9040
9920
|
parse_mode: TELEGRAM_PARSE_MODE,
|
|
9921
|
+
...(telegramDisableNotification(this.opts.sound, "finalized") === true
|
|
9922
|
+
? { disable_notification: true }
|
|
9923
|
+
: {}),
|
|
9041
9924
|
});
|
|
9042
9925
|
} catch {
|
|
9043
9926
|
// Best-effort confirmation; never block on the notice.
|
|
@@ -9234,6 +10117,9 @@ export class TelegramNotificationDaemon {
|
|
|
9234
10117
|
chat_id: this.opts.chatId,
|
|
9235
10118
|
message_thread_id: Number(inbound.threadId),
|
|
9236
10119
|
text: "Session control unavailable: this local GJC session is disconnected.",
|
|
10120
|
+
...(telegramDisableNotification(this.opts.sound, "finalized") === true
|
|
10121
|
+
? { disable_notification: true }
|
|
10122
|
+
: {}),
|
|
9237
10123
|
});
|
|
9238
10124
|
} catch {
|
|
9239
10125
|
logger.warn("notifications: unavailable-control notice delivery failed");
|
|
@@ -9408,6 +10294,9 @@ export class TelegramNotificationDaemon {
|
|
|
9408
10294
|
message_thread_id: Number(inbound.threadId),
|
|
9409
10295
|
text: body,
|
|
9410
10296
|
parse_mode: TELEGRAM_PARSE_MODE,
|
|
10297
|
+
...(telegramDisableNotification(this.opts.sound, "finalized") === true
|
|
10298
|
+
? { disable_notification: true }
|
|
10299
|
+
: {}),
|
|
9411
10300
|
});
|
|
9412
10301
|
} catch {
|
|
9413
10302
|
// Best-effort control feedback; never convert to user input.
|
|
@@ -9489,6 +10378,7 @@ export class TelegramNotificationDaemon {
|
|
|
9489
10378
|
}
|
|
9490
10379
|
}
|
|
9491
10380
|
const callbackId = (update as { callback_query?: { id?: unknown } }).callback_query?.id;
|
|
10381
|
+
if (await this.#handleAdoptionPickerCallback(update, callbackId)) return;
|
|
9492
10382
|
if (await this.#handleModelChoiceCallback(update, callbackId)) return;
|
|
9493
10383
|
const decision = routeInboundUpdate(update, {
|
|
9494
10384
|
aliasTable: this.aliasTable,
|
|
@@ -9601,6 +10491,8 @@ export class TelegramNotificationDaemon {
|
|
|
9601
10491
|
await this.registerBotCommands();
|
|
9602
10492
|
await this.loadAliases();
|
|
9603
10493
|
await this.loadTopics();
|
|
10494
|
+
await this.loadAdoptionIntents();
|
|
10495
|
+
this.startAdoptionSweepTimer();
|
|
9604
10496
|
await this.loadSeenUpdateIds();
|
|
9605
10497
|
await this.replyStore.load();
|
|
9606
10498
|
await this.runScan();
|
|
@@ -9676,6 +10568,7 @@ export class TelegramNotificationDaemon {
|
|
|
9676
10568
|
this.stopFlushTimer();
|
|
9677
10569
|
this.stopScanTimer();
|
|
9678
10570
|
this.stopTypingTimer();
|
|
10571
|
+
this.stopAdoptionSweepTimer();
|
|
9679
10572
|
this.stopLifecycleControl();
|
|
9680
10573
|
await this.cleanupAllAttachmentDirs();
|
|
9681
10574
|
await this.persistAliases();
|