@sayknow-cli/coding-agent 0.3.7 → 0.3.9
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/dist/types/append-only-mode.d.ts +31 -0
- package/dist/types/capability/rule.d.ts +8 -0
- package/dist/types/cli/completion-cli.d.ts +41 -0
- package/dist/types/cli/config-cli.d.ts +1 -0
- package/dist/types/cli/fixture-report.d.ts +20 -0
- package/dist/types/cli/fixture-report.test.d.ts +1 -0
- package/dist/types/cli/nofile-limit.d.ts +11 -0
- package/dist/types/cli/notify-cli.d.ts +7 -0
- package/dist/types/cli/update-cli.d.ts +22 -0
- package/dist/types/cli.d.ts +124 -1
- package/dist/types/commands/completion.d.ts +29 -0
- package/dist/types/commands/config.d.ts +3 -0
- package/dist/types/commands/deep-interview.d.ts +3 -0
- package/dist/types/config/file-lock.d.ts +7 -6
- package/dist/types/config/keybindings.d.ts +2 -2
- package/dist/types/config/model-profile-activation.d.ts +6 -0
- package/dist/types/config/model-profiles.d.ts +1 -0
- package/dist/types/config/settings-schema.d.ts +119 -1
- package/dist/types/config/settings.d.ts +8 -0
- package/dist/types/discovery/index.d.ts +1 -0
- package/dist/types/export/html/template.generated.d.ts +1 -1
- package/dist/types/export/ttsr.d.ts +14 -3
- package/dist/types/extensibility/extensions/runner.d.ts +2 -1
- package/dist/types/extensibility/extensions/types.d.ts +8 -0
- package/dist/types/goals/runtime.d.ts +1 -0
- package/dist/types/hooks/native-skill-hook.d.ts +12 -0
- package/dist/types/main.d.ts +4 -1
- package/dist/types/modes/components/assistant-message.d.ts +5 -1
- package/dist/types/modes/components/model-selector.d.ts +4 -0
- package/dist/types/modes/components/queued-message-selector.d.ts +12 -0
- package/dist/types/modes/components/settings-selector.d.ts +2 -0
- package/dist/types/modes/components/status-line.d.ts +9 -0
- package/dist/types/modes/components/thinking-selector.d.ts +2 -2
- package/dist/types/modes/controllers/command-controller.d.ts +1 -0
- package/dist/types/modes/controllers/event-controller.d.ts +5 -0
- package/dist/types/modes/controllers/input-controller.d.ts +1 -3
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +4 -0
- package/dist/types/modes/print-mode.d.ts +13 -1
- package/dist/types/modes/prompt-action-autocomplete.d.ts +1 -1
- package/dist/types/modes/rpc/rpc-mode.d.ts +1 -3
- package/dist/types/modes/rpc/rpc-socket-security.d.ts +3 -0
- package/dist/types/modes/rpc/rpc-types.d.ts +14 -0
- package/dist/types/modes/shared/agent-wire/event-contract.d.ts +19 -0
- package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +23 -1
- package/dist/types/modes/types.d.ts +3 -0
- package/dist/types/modes/utils/injected-user-submission.d.ts +52 -0
- package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
- package/dist/types/notifications/config-commands.d.ts +9 -0
- package/dist/types/notifications/config.d.ts +30 -1
- package/dist/types/notifications/daemon-paths.d.ts +1 -0
- package/dist/types/notifications/index.d.ts +8 -3
- package/dist/types/notifications/lifecycle-commands.d.ts +12 -3
- package/dist/types/notifications/lifecycle-control-runtime.d.ts +2 -1
- package/dist/types/notifications/reply-sent-store.d.ts +53 -0
- package/dist/types/notifications/rich-draft.d.ts +68 -0
- package/dist/types/notifications/rich-render.d.ts +90 -0
- package/dist/types/notifications/telegram-daemon.d.ts +45 -3
- package/dist/types/notifications/telegram-reference.d.ts +7 -5
- package/dist/types/notifications/threaded-render.d.ts +14 -0
- package/dist/types/runtime-api-key.d.ts +7 -0
- package/dist/types/sdk.d.ts +3 -0
- package/dist/types/session/agent-session.d.ts +61 -6
- package/dist/types/session/agent-storage.d.ts +1 -0
- package/dist/types/session/file-mention-pruning.d.ts +8 -0
- package/dist/types/session/messages.d.ts +4 -0
- package/dist/types/session/session-manager.d.ts +17 -1
- package/dist/types/skc-runtime/deep-interview-ambiguity.d.ts +39 -0
- package/dist/types/skc-runtime/deep-interview-recorder.d.ts +10 -1
- package/dist/types/skc-runtime/deep-interview-state.d.ts +17 -2
- package/dist/types/skc-runtime/session-state-sidecar.d.ts +7 -0
- package/dist/types/skc-runtime/state-writer.d.ts +3 -0
- package/dist/types/skc-runtime/tmux-common.d.ts +1 -0
- package/dist/types/skc-runtime/ultragoal-receipt-freshness.d.ts +42 -0
- package/dist/types/skc-runtime/ultragoal-runtime.d.ts +120 -13
- package/dist/types/skill-state/active-state.d.ts +18 -1
- package/dist/types/system-prompt.d.ts +18 -2
- package/dist/types/task/executor.d.ts +3 -0
- package/dist/types/task/fork-context-budget.d.ts +7 -0
- package/dist/types/task/token-log.d.ts +30 -0
- package/dist/types/task/token-log.test.d.ts +1 -0
- package/dist/types/tools/bisect.d.ts +81 -0
- package/dist/types/tools/computer.d.ts +2 -2
- package/dist/types/tools/index.d.ts +1 -0
- package/dist/types/tools/search.d.ts +2 -2
- package/dist/types/tools/ultragoal-ask-guard.d.ts +9 -2
- package/dist/types/utils/file-mentions.d.ts +14 -0
- package/dist/types/utils/git.d.ts +32 -0
- package/dist/types/utils/pasted-image-path.d.ts +30 -0
- package/dist/types/web/scrapers/youtube.d.ts +8 -0
- package/dist/types/workflow/workflow-intent-diff.d.ts +8 -2
- package/dist/types/workflow/workflow-intent-report-consumer.d.ts +50 -0
- package/dist/types/workflow/workflow-intent-report-schema.d.ts +2 -0
- package/dist/types/workflow/workflow-intent-report.d.ts +48 -0
- package/package.json +8 -8
- package/src/append-only-mode.ts +58 -0
- package/src/autoresearch/prompt.md +1 -1
- package/src/autoresearch/tools/log-experiment.ts +1 -1
- package/src/capability/rule.ts +9 -0
- package/src/cli/completion-cli.ts +234 -0
- package/src/cli/config-cli.ts +61 -9
- package/src/cli/fast-help.ts +1 -0
- package/src/cli/fixture-report.test.ts +230 -0
- package/src/cli/fixture-report.ts +171 -0
- package/src/cli/nofile-limit.ts +48 -0
- package/src/cli/notify-cli.ts +103 -17
- package/src/cli/telegram-cli.ts +1 -1
- package/src/cli/update-cli.ts +148 -33
- package/src/cli.ts +29 -1
- package/src/commands/completion.ts +84 -0
- package/src/commands/config.ts +2 -0
- package/src/commands/contribution-prep.ts +1 -1
- package/src/commands/deep-interview.ts +2 -1
- package/src/config/file-lock.ts +73 -34
- package/src/config/keybindings.ts +3 -3
- package/src/config/model-profile-activation.ts +65 -0
- package/src/config/model-profiles.ts +31 -2
- package/src/config/settings-schema.ts +76 -1
- package/src/config/settings.ts +22 -0
- package/src/defaults/skc/skills/team/SKILL.md +17 -14
- package/src/defaults/skc/skills/ultragoal/SKILL.md +59 -4
- package/src/defaults/skc/skills/ultragoal/pipeline-validation-contracts.md +30 -0
- package/src/defaults/skc-defaults.ts +9 -0
- package/src/discovery/helpers.ts +10 -0
- package/src/discovery/index.ts +1 -0
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +60 -2
- package/src/export/ttsr.ts +96 -18
- package/src/extensibility/extensions/runner.ts +183 -219
- package/src/extensibility/extensions/types.ts +9 -0
- package/src/extensibility/skc-plugins/runtime-adapters.ts +156 -44
- package/src/goals/runtime.ts +10 -2
- package/src/hindsight/backend.ts +1 -3
- package/src/hooks/native-skill-hook.ts +124 -4
- package/src/internal-urls/docs-index.generated.ts +17 -8
- package/src/lsp/client.ts +1 -0
- package/src/main.ts +95 -43
- package/src/memories/index.ts +12 -1
- package/src/modes/components/assistant-message.ts +241 -82
- package/src/modes/components/custom-editor.ts +1 -0
- package/src/modes/components/footer.ts +7 -2
- package/src/modes/components/model-selector.ts +54 -11
- package/src/modes/components/queued-message-selector.ts +92 -0
- package/src/modes/components/settings-selector.ts +4 -0
- package/src/modes/components/status-line.ts +167 -54
- package/src/modes/components/thinking-selector.ts +10 -8
- package/src/modes/components/tool-execution.ts +44 -19
- package/src/modes/components/welcome.ts +181 -44
- package/src/modes/controllers/command-controller.ts +40 -4
- package/src/modes/controllers/event-controller.ts +77 -49
- package/src/modes/controllers/extension-ui-controller.ts +12 -11
- package/src/modes/controllers/input-controller.ts +192 -35
- package/src/modes/controllers/selector-controller.ts +168 -24
- package/src/modes/interactive-mode.ts +56 -16
- package/src/modes/print-mode.ts +43 -4
- package/src/modes/prompt-action-autocomplete.ts +21 -20
- package/src/modes/rpc/rpc-mode.ts +32 -12
- package/src/modes/rpc/rpc-socket-security.ts +13 -1
- package/src/modes/rpc/rpc-types.ts +9 -0
- package/src/modes/shared/agent-wire/command-validation.ts +4 -0
- package/src/modes/shared/agent-wire/event-contract.ts +22 -0
- package/src/modes/shared/agent-wire/event-envelope.ts +82 -0
- package/src/modes/shared/agent-wire/scopes.ts +1 -0
- package/src/modes/types.ts +3 -0
- package/src/modes/utils/hotkeys-markdown.ts +1 -0
- package/src/modes/utils/injected-user-submission.ts +94 -0
- package/src/modes/utils/ui-helpers.ts +20 -7
- package/src/notifications/config-commands.ts +20 -0
- package/src/notifications/config.ts +52 -5
- package/src/notifications/daemon-paths.ts +2 -0
- package/src/notifications/index.ts +144 -18
- package/src/notifications/lifecycle-commands.ts +74 -23
- package/src/notifications/lifecycle-control-runtime.ts +8 -3
- package/src/notifications/lifecycle-orchestrator.ts +19 -6
- package/src/notifications/reply-sent-store.ts +134 -0
- package/src/notifications/rich-draft.ts +107 -0
- package/src/notifications/rich-render.ts +142 -0
- package/src/notifications/telegram-daemon-control.ts +5 -3
- package/src/notifications/telegram-daemon.ts +606 -98
- package/src/notifications/telegram-reference.ts +17 -16
- package/src/notifications/threaded-render.ts +45 -2
- package/src/prompts/agents/architect.md +19 -10
- package/src/prompts/agents/critic.md +16 -18
- package/src/prompts/agents/executor.md +14 -11
- package/src/prompts/agents/planner.md +18 -13
- package/src/prompts/ci-green-request.md +3 -8
- package/src/prompts/goals/goal-continuation.md +0 -5
- package/src/prompts/goals/goal-mode-active.md +0 -5
- package/src/prompts/system/agent-creation-architect.md +2 -2
- package/src/prompts/system/plan-mode-active.md +1 -1
- package/src/prompts/system/plan-mode-subagent.md +3 -3
- package/src/prompts/system/project-prompt.md +0 -13
- package/src/prompts/system/subagent-system-prompt.md +3 -2
- package/src/prompts/system/system-prompt.md +29 -17
- package/src/prompts/system/volatile-project-context.md +10 -0
- package/src/prompts/system/web-search.md +4 -7
- package/src/prompts/tools/apply-patch.md +6 -0
- package/src/prompts/tools/ast-edit.md +1 -0
- package/src/prompts/tools/bash.md +1 -1
- package/src/prompts/tools/bisect.md +22 -0
- package/src/prompts/tools/browser.md +1 -1
- package/src/prompts/tools/computer.md +2 -2
- package/src/prompts/tools/cron.md +0 -2
- package/src/prompts/tools/eval.md +2 -2
- package/src/prompts/tools/find.md +1 -1
- package/src/prompts/tools/github.md +6 -6
- package/src/prompts/tools/goal.md +1 -3
- package/src/prompts/tools/image-gen.md +12 -5
- package/src/prompts/tools/irc.md +5 -4
- package/src/prompts/tools/job.md +2 -2
- package/src/prompts/tools/lsp.md +1 -1
- package/src/prompts/tools/monitor.md +2 -2
- package/src/prompts/tools/patch.md +2 -2
- package/src/prompts/tools/replace.md +2 -21
- package/src/prompts/tools/rewind.md +1 -1
- package/src/prompts/tools/search-tool-bm25.md +6 -11
- package/src/prompts/tools/search.md +1 -1
- package/src/prompts/tools/skill.md +3 -2
- package/src/prompts/tools/ssh.md +2 -1
- package/src/prompts/tools/subagent.md +2 -2
- package/src/prompts/tools/task.md +4 -5
- package/src/prompts/tools/todo-write.md +1 -1
- package/src/prompts/tools/vim.md +1 -0
- package/src/prompts/tools/web-search.md +2 -0
- package/src/prompts/tools/write.md +8 -2
- package/src/runtime-api-key.ts +16 -0
- package/src/sdk.ts +88 -36
- package/src/session/agent-session.ts +1013 -141
- package/src/session/agent-storage.ts +4 -0
- package/src/session/file-mention-pruning.ts +64 -0
- package/src/session/messages.ts +4 -0
- package/src/session/session-manager.ts +103 -4
- package/src/skc-runtime/deep-interview-ambiguity.ts +206 -0
- package/src/skc-runtime/deep-interview-recorder.ts +62 -8
- package/src/skc-runtime/deep-interview-state.ts +44 -2
- package/src/skc-runtime/session-state-sidecar.ts +263 -20
- package/src/skc-runtime/state-runtime.ts +12 -2
- package/src/skc-runtime/state-writer.ts +15 -0
- package/src/skc-runtime/tmux-common.ts +1 -0
- package/src/skc-runtime/ultragoal-guard.ts +55 -66
- package/src/skc-runtime/ultragoal-receipt-freshness.ts +315 -0
- package/src/skc-runtime/ultragoal-runtime.ts +1672 -159
- package/src/skc-runtime/workflow-manifest.generated.json +113 -2
- package/src/skc-runtime/workflow-manifest.ts +74 -1
- package/src/skill-state/active-state.ts +141 -5
- package/src/skill-state/deep-interview-mutation-guard.ts +67 -3
- package/src/slash-commands/builtin-registry.ts +296 -72
- package/src/system-prompt.ts +57 -16
- package/src/task/executor.ts +132 -24
- package/src/task/fork-context-advisory.ts +2 -7
- package/src/task/fork-context-budget.ts +9 -0
- package/src/task/index.ts +13 -6
- package/src/task/output-manager.ts +20 -8
- package/src/task/receipt.ts +26 -7
- package/src/task/token-log.test.ts +188 -0
- package/src/task/token-log.ts +179 -0
- package/src/tools/bisect.ts +367 -0
- package/src/tools/computer.ts +246 -43
- package/src/tools/index.ts +3 -0
- package/src/tools/output-meta.ts +116 -4
- package/src/tools/search.ts +8 -2
- package/src/tools/skill.ts +15 -0
- package/src/tools/telegram-send.ts +6 -0
- package/src/tools/ultragoal-ask-guard.ts +39 -5
- package/src/utils/file-mentions.ts +39 -7
- package/src/utils/git.ts +73 -0
- package/src/utils/pasted-image-path.ts +170 -0
- package/src/web/scrapers/youtube.ts +24 -12
- package/src/workflow/workflow-intent-diff.ts +29 -6
- package/src/workflow/workflow-intent-report-consumer.ts +221 -0
- package/src/workflow/workflow-intent-report-schema.ts +129 -0
- package/src/workflow/workflow-intent-report.ts +135 -0
|
@@ -8,8 +8,8 @@ import { withFileLock } from "../config/file-lock";
|
|
|
8
8
|
import type { Settings } from "../config/settings";
|
|
9
9
|
import type { DaemonRuntimeInfo } from "../daemon/control-types";
|
|
10
10
|
import { resolveSkcRuntimeSpawnInfo } from "../daemon/runtime";
|
|
11
|
-
import { getNotificationConfig,
|
|
12
|
-
import { parseInThreadConfigCommand } from "./config-commands";
|
|
11
|
+
import { getNotificationConfig, isTelegramConfigured, tokenFingerprint } from "./config";
|
|
12
|
+
import { parseInThreadConfigCommand, parseRichToggleCommand } from "./config-commands";
|
|
13
13
|
import { daemonPaths } from "./daemon-paths";
|
|
14
14
|
import {
|
|
15
15
|
buildCompactChoiceGrid,
|
|
@@ -27,6 +27,7 @@ import type {
|
|
|
27
27
|
} from "./index";
|
|
28
28
|
import {
|
|
29
29
|
formatLifecycleOutcome,
|
|
30
|
+
isLifecycleCommandLikeText,
|
|
30
31
|
isLifecycleCommandText,
|
|
31
32
|
lifecycleUsage,
|
|
32
33
|
parseLifecycleCommand,
|
|
@@ -43,12 +44,15 @@ import {
|
|
|
43
44
|
import { NotificationOperatorRuntime, OperatorBackoffPolicy, OperatorEventRouter } from "./operator-runtime";
|
|
44
45
|
import { RateLimitPool } from "./rate-limit-pool";
|
|
45
46
|
import { listRecentSessions } from "./recent-activity";
|
|
47
|
+
import { ReplySentStore } from "./reply-sent-store";
|
|
48
|
+
import { DraftStreamState, deliverDraft, shouldStreamDraft } from "./rich-draft";
|
|
49
|
+
import { deliverRichActionWithFallback, deliverRichWithFallback, shouldPromoteRich } from "./rich-render";
|
|
46
50
|
import {
|
|
47
51
|
type AliasTable,
|
|
52
|
+
buildActionMarkdown,
|
|
48
53
|
buildActionMessage,
|
|
49
54
|
type CallbackRoute,
|
|
50
55
|
createAliasTable,
|
|
51
|
-
type PendingAsk,
|
|
52
56
|
readEndpoint,
|
|
53
57
|
routeInboundUpdate,
|
|
54
58
|
} from "./telegram-reference";
|
|
@@ -56,7 +60,7 @@ import { decideThreadedInbound, type InboundAttachment } from "./threaded-inboun
|
|
|
56
60
|
import { renderThreadedFrame, type ThreadedSend } from "./threaded-render";
|
|
57
61
|
import { TopicRegistry, type TopicRegistryState } from "./topic-registry";
|
|
58
62
|
|
|
59
|
-
export type EnsureDaemonResult = "owner_spawned" | "attached" | "disabled";
|
|
63
|
+
export type EnsureDaemonResult = "owner_spawned" | "attached" | "disabled" | "blocked";
|
|
60
64
|
|
|
61
65
|
export interface DaemonState {
|
|
62
66
|
pid: number;
|
|
@@ -126,6 +130,7 @@ const TYPING_REFRESH_INTERVAL_MS = 4_000;
|
|
|
126
130
|
// messages: queued on receipt, consumed once a turn picks the message up.
|
|
127
131
|
const QUEUED_REACTION = "👀";
|
|
128
132
|
const PENDING_TOPIC_FRAME_LIMIT = 20;
|
|
133
|
+
const SEEN_UPDATE_ID_LIMIT = 1_000;
|
|
129
134
|
const CONSUMED_REACTION = "✅";
|
|
130
135
|
|
|
131
136
|
function splitTelegramPlainText(text: string, max = TELEGRAM_MESSAGE_LIMIT): string[] {
|
|
@@ -267,7 +272,7 @@ export async function registerNotificationRoot(input: {
|
|
|
267
272
|
const fsImpl = input.fs ?? nodeFs;
|
|
268
273
|
const paths = daemonPaths(input.settings.getAgentDir());
|
|
269
274
|
await ensureDir(fsImpl, paths.dir);
|
|
270
|
-
const root =
|
|
275
|
+
const root = notificationRootForCwd(input.cwd);
|
|
271
276
|
await withFileLock(
|
|
272
277
|
paths.roots,
|
|
273
278
|
async () => {
|
|
@@ -286,6 +291,29 @@ export async function registerNotificationRoot(input: {
|
|
|
286
291
|
return root;
|
|
287
292
|
}
|
|
288
293
|
|
|
294
|
+
function notificationRootForCwd(cwd: string): string {
|
|
295
|
+
return path.join(cwd, ".skc", "state");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function ownerIdentityMatches(state: DaemonState, tokenFingerprint: string, chatId: string): boolean {
|
|
299
|
+
return state.tokenFingerprint === tokenFingerprint && state.chatId === chatId;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function liveOwnerUsesDifferentIdentity(input: {
|
|
303
|
+
state: DaemonState | undefined;
|
|
304
|
+
tokenFingerprint: string;
|
|
305
|
+
chatId: string;
|
|
306
|
+
pidAlive: (pid: number) => boolean;
|
|
307
|
+
}): boolean {
|
|
308
|
+
const { state } = input;
|
|
309
|
+
return Boolean(
|
|
310
|
+
state &&
|
|
311
|
+
state.version === DAEMON_VERSION &&
|
|
312
|
+
!ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) &&
|
|
313
|
+
input.pidAlive(state.pid),
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
289
317
|
export function isFreshLiveOwner(input: {
|
|
290
318
|
state: DaemonState | undefined;
|
|
291
319
|
now: number;
|
|
@@ -297,8 +325,7 @@ export function isFreshLiveOwner(input: {
|
|
|
297
325
|
return Boolean(
|
|
298
326
|
state &&
|
|
299
327
|
state.version === DAEMON_VERSION &&
|
|
300
|
-
state.tokenFingerprint
|
|
301
|
-
state.chatId === input.chatId &&
|
|
328
|
+
ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) &&
|
|
302
329
|
input.now - state.heartbeatAt <= HEARTBEAT_TTL_MS &&
|
|
303
330
|
input.pidAlive(state.pid),
|
|
304
331
|
);
|
|
@@ -314,7 +341,13 @@ export async function acquireDaemonOwnership(input: {
|
|
|
314
341
|
pid?: number;
|
|
315
342
|
pidAlive?: (pid: number) => boolean;
|
|
316
343
|
randomId?: () => string;
|
|
317
|
-
}): Promise<{
|
|
344
|
+
}): Promise<{
|
|
345
|
+
acquired: boolean;
|
|
346
|
+
ownerId?: string;
|
|
347
|
+
attached?: boolean;
|
|
348
|
+
blocked?: boolean;
|
|
349
|
+
reason?: "identity_mismatch";
|
|
350
|
+
}> {
|
|
318
351
|
const fsImpl = input.fs ?? nodeFs;
|
|
319
352
|
const now = input.now ?? Date.now;
|
|
320
353
|
const pid = input.pid ?? process.pid;
|
|
@@ -324,6 +357,16 @@ export async function acquireDaemonOwnership(input: {
|
|
|
324
357
|
const ownerId = input.randomId?.() ?? `${pid}-${now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
325
358
|
const roots = input.roots ?? (await readJson<{ roots?: string[] }>(fsImpl, paths.roots))?.roots ?? [];
|
|
326
359
|
const existing = await readJson<DaemonState>(fsImpl, paths.state);
|
|
360
|
+
if (
|
|
361
|
+
liveOwnerUsesDifferentIdentity({
|
|
362
|
+
state: existing,
|
|
363
|
+
tokenFingerprint: input.tokenFingerprint,
|
|
364
|
+
chatId: input.chatId,
|
|
365
|
+
pidAlive,
|
|
366
|
+
})
|
|
367
|
+
) {
|
|
368
|
+
return { acquired: false, blocked: true, reason: "identity_mismatch" };
|
|
369
|
+
}
|
|
327
370
|
if (
|
|
328
371
|
isFreshLiveOwner({
|
|
329
372
|
state: existing,
|
|
@@ -349,6 +392,16 @@ export async function acquireDaemonOwnership(input: {
|
|
|
349
392
|
return { acquired: true, ownerId };
|
|
350
393
|
}
|
|
351
394
|
const afterLock = await readJson<DaemonState>(fsImpl, paths.state);
|
|
395
|
+
if (
|
|
396
|
+
liveOwnerUsesDifferentIdentity({
|
|
397
|
+
state: afterLock,
|
|
398
|
+
tokenFingerprint: input.tokenFingerprint,
|
|
399
|
+
chatId: input.chatId,
|
|
400
|
+
pidAlive,
|
|
401
|
+
})
|
|
402
|
+
) {
|
|
403
|
+
return { acquired: false, blocked: true, reason: "identity_mismatch" };
|
|
404
|
+
}
|
|
352
405
|
if (
|
|
353
406
|
isFreshLiveOwner({
|
|
354
407
|
state: afterLock,
|
|
@@ -375,6 +428,16 @@ export async function acquireDaemonOwnership(input: {
|
|
|
375
428
|
) {
|
|
376
429
|
return { acquired: false, attached: true };
|
|
377
430
|
}
|
|
431
|
+
if (
|
|
432
|
+
liveOwnerUsesDifferentIdentity({
|
|
433
|
+
state: rechecked,
|
|
434
|
+
tokenFingerprint: input.tokenFingerprint,
|
|
435
|
+
chatId: input.chatId,
|
|
436
|
+
pidAlive,
|
|
437
|
+
})
|
|
438
|
+
) {
|
|
439
|
+
return { acquired: false, blocked: true, reason: "identity_mismatch" };
|
|
440
|
+
}
|
|
378
441
|
if (rechecked && pidAlive(rechecked.pid)) {
|
|
379
442
|
return { acquired: false, attached: true };
|
|
380
443
|
}
|
|
@@ -552,7 +615,16 @@ export async function spawnTelegramDaemonOwner(
|
|
|
552
615
|
ownerId: ownership.ownerId ?? "",
|
|
553
616
|
agentDir,
|
|
554
617
|
});
|
|
555
|
-
if (!ownership.acquired)
|
|
618
|
+
if (!ownership.acquired) {
|
|
619
|
+
if (ownership.blocked) {
|
|
620
|
+
return {
|
|
621
|
+
result: "blocked",
|
|
622
|
+
runtime,
|
|
623
|
+
warnings: ["live telegram daemon uses a different bot token or chat; refusing to attach"],
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
return { result: "attached", runtime, warnings: [] };
|
|
627
|
+
}
|
|
556
628
|
const spawnImpl = deps.spawn ?? defaultDaemonSpawn;
|
|
557
629
|
const child = spawnImpl(command, args, {
|
|
558
630
|
detached: true,
|
|
@@ -568,13 +640,18 @@ export async function ensureTelegramDaemonRunning(
|
|
|
568
640
|
deps: TelegramDaemonDeps = {},
|
|
569
641
|
): Promise<EnsureDaemonResult> {
|
|
570
642
|
const cfg = getNotificationConfig(input.settings);
|
|
571
|
-
if (!
|
|
572
|
-
const root =
|
|
643
|
+
if (!isTelegramConfigured(cfg)) return "disabled";
|
|
644
|
+
const root = notificationRootForCwd(input.cwd);
|
|
573
645
|
const fp = tokenFingerprint(cfg.botToken);
|
|
574
646
|
const spawned = await spawnTelegramDaemonOwner(
|
|
575
647
|
{ settings: input.settings, roots: [root], tokenFingerprint: fp, chatId: cfg.chatId },
|
|
576
648
|
deps,
|
|
577
649
|
);
|
|
650
|
+
if (spawned.result === "blocked") {
|
|
651
|
+
logger.warn(`notifications: failed to ensure Telegram daemon: ${spawned.warnings.join("; ")}`);
|
|
652
|
+
return spawned.result;
|
|
653
|
+
}
|
|
654
|
+
await registerNotificationRoot({ ...input, fs: deps.fs });
|
|
578
655
|
return spawned.result;
|
|
579
656
|
}
|
|
580
657
|
|
|
@@ -770,6 +847,10 @@ export interface TelegramDaemonOptions {
|
|
|
770
847
|
* default applies (e.g. lifecycle control disabled), no control server starts.
|
|
771
848
|
*/
|
|
772
849
|
createLifecycleControlServer?: LifecycleControlServerFactory | null;
|
|
850
|
+
/** Rich text promotion (enabled by default; see rich-render.ts). */
|
|
851
|
+
rich?: { enabled: boolean };
|
|
852
|
+
/** Opt-in rich-draft streaming of live turn previews (off by default; see rich-draft.ts). */
|
|
853
|
+
richDraft?: { enabled: boolean };
|
|
773
854
|
}
|
|
774
855
|
|
|
775
856
|
interface SessionSocket {
|
|
@@ -796,6 +877,8 @@ interface PendingThreadedFrame {
|
|
|
796
877
|
export class TelegramNotificationDaemon {
|
|
797
878
|
readonly aliasTable: AliasTable;
|
|
798
879
|
readonly messageRoutes = new Map<string | number, CallbackRoute | Omit<CallbackRoute, "answer">>();
|
|
880
|
+
/** Telegram message id backing each streamed `${sessionId}:${coalesceKey}`, for in-place edits. */
|
|
881
|
+
private readonly liveMessages = new Map<string, number>();
|
|
799
882
|
readonly sessions = new Map<string, SessionSocket>();
|
|
800
883
|
private readonly runtime: NotificationOperatorRuntime;
|
|
801
884
|
private readonly sessionRouter: OperatorEventRouter<SessionSocket>;
|
|
@@ -808,6 +891,10 @@ export class TelegramNotificationDaemon {
|
|
|
808
891
|
private readonly pool: RateLimitPool<{ send: ThreadedSend; topicId?: string }>;
|
|
809
892
|
private readonly poller: TelegramUpdatePoller;
|
|
810
893
|
private readonly dispatchState = new TelegramEventDispatchState();
|
|
894
|
+
/** Original markdown of rich messages we sent (chat+message_id), for restoring reply context on inbound replies. */
|
|
895
|
+
private readonly replyStore: ReplySentStore;
|
|
896
|
+
/** Per-session debounce + monotonic draft-id state for opt-in draft streaming. */
|
|
897
|
+
private readonly draftStream = new DraftStreamState();
|
|
811
898
|
/** Identity-bearing sessions by repo/branch surface, used to avoid transient duplicate topics. */
|
|
812
899
|
private readonly topicOwnerByIdentity = new Map<string, string>();
|
|
813
900
|
/** Non-identity frames held until identity creates the correct thread. */
|
|
@@ -820,6 +907,8 @@ export class TelegramNotificationDaemon {
|
|
|
820
907
|
private readonly flatIdentitySent = new Set<string>();
|
|
821
908
|
/** Cached result of whether the paired chat is a private chat (flat-fallback gate). */
|
|
822
909
|
private pairedChatPrivate: boolean | undefined;
|
|
910
|
+
/** Bot username from getMe, cached once at owner startup for group/forum command targeting. */
|
|
911
|
+
private botUsername: string | undefined;
|
|
823
912
|
/** Sessions whose agent loop is currently busy (drives the typing indicator). */
|
|
824
913
|
private get busy(): Set<string> {
|
|
825
914
|
return this.dispatchState.busy;
|
|
@@ -1043,7 +1132,7 @@ export class TelegramNotificationDaemon {
|
|
|
1043
1132
|
/** Build an authenticated lifecycle frame from a parsed command + identity. */
|
|
1044
1133
|
private buildLifecycleFrame(
|
|
1045
1134
|
parsed:
|
|
1046
|
-
| { kind: "create"; target: SessionCreateTarget }
|
|
1135
|
+
| { kind: "create"; target: SessionCreateTarget; modelPreset?: string }
|
|
1047
1136
|
| { kind: "close"; target: SessionCloseTarget }
|
|
1048
1137
|
| { kind: "resume"; target: SessionResumeTarget },
|
|
1049
1138
|
updateId: number,
|
|
@@ -1061,6 +1150,7 @@ export class TelegramNotificationDaemon {
|
|
|
1061
1150
|
chatId,
|
|
1062
1151
|
token,
|
|
1063
1152
|
target: parsed.target,
|
|
1153
|
+
modelPreset: parsed.modelPreset,
|
|
1064
1154
|
};
|
|
1065
1155
|
}
|
|
1066
1156
|
if (parsed.kind === "close") {
|
|
@@ -1078,8 +1168,10 @@ export class TelegramNotificationDaemon {
|
|
|
1078
1168
|
text: string | undefined,
|
|
1079
1169
|
updateId: number | undefined,
|
|
1080
1170
|
threadId: number | undefined,
|
|
1171
|
+
commandCtx: { chatType?: string; botUsername?: string },
|
|
1081
1172
|
): Promise<boolean> {
|
|
1082
|
-
if (!isLifecycleCommandText(text)) return false;
|
|
1173
|
+
if (!isLifecycleCommandText(text, commandCtx)) return false;
|
|
1174
|
+
if (!(await this.pairedChatIsPrivate())) return true;
|
|
1083
1175
|
const reply = async (body: string): Promise<void> => {
|
|
1084
1176
|
for (const text of splitTelegramPlainText(body)) {
|
|
1085
1177
|
await this.botApi
|
|
@@ -1104,15 +1196,15 @@ export class TelegramNotificationDaemon {
|
|
|
1104
1196
|
}
|
|
1105
1197
|
};
|
|
1106
1198
|
|
|
1199
|
+
const parsed = parseLifecycleCommand(text, commandCtx);
|
|
1200
|
+
if (parsed.kind === "none") return false;
|
|
1107
1201
|
if (!this.lifecycleControlActive) {
|
|
1108
1202
|
await reply("Session lifecycle control is not available right now.");
|
|
1109
1203
|
return true;
|
|
1110
1204
|
}
|
|
1111
1205
|
if (updateId !== undefined && this.dispatchState.seenUpdateIds.has(updateId)) return true;
|
|
1112
|
-
if (updateId !== undefined) this.
|
|
1206
|
+
if (updateId !== undefined) await this.rememberSeenUpdateId(updateId);
|
|
1113
1207
|
|
|
1114
|
-
const parsed = parseLifecycleCommand(text);
|
|
1115
|
-
if (parsed.kind === "none") return false;
|
|
1116
1208
|
if (parsed.kind === "usage" || parsed.kind === "reject") {
|
|
1117
1209
|
await reply(parsed.message);
|
|
1118
1210
|
return true;
|
|
@@ -1145,6 +1237,17 @@ export class TelegramNotificationDaemon {
|
|
|
1145
1237
|
return true;
|
|
1146
1238
|
}
|
|
1147
1239
|
|
|
1240
|
+
private async refreshBotIdentity(): Promise<void> {
|
|
1241
|
+
try {
|
|
1242
|
+
const response = (await this.botApi.call("getMe", {})) as { result?: { username?: unknown } };
|
|
1243
|
+
const username = response.result?.username;
|
|
1244
|
+
this.botUsername =
|
|
1245
|
+
typeof username === "string" && username.trim() ? username.trim().replace(/^@/, "") : undefined;
|
|
1246
|
+
} catch {
|
|
1247
|
+
this.botUsername = undefined;
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1148
1251
|
/** Map a lifecycle response/error to a user-facing message (G010 surfacing). */
|
|
1149
1252
|
private formatLifecycleResponse(r: SessionLifecycleResponse): string {
|
|
1150
1253
|
return formatLifecycleOutcome(r);
|
|
@@ -1152,6 +1255,7 @@ export class TelegramNotificationDaemon {
|
|
|
1152
1255
|
|
|
1153
1256
|
constructor(private readonly opts: TelegramDaemonOptions) {
|
|
1154
1257
|
this.fsImpl = opts.fs ?? nodeFs;
|
|
1258
|
+
this.replyStore = new ReplySentStore({ agentDir: opts.settings.getAgentDir(), fs: opts.fs });
|
|
1155
1259
|
this.aliasTable = createAliasTable();
|
|
1156
1260
|
this.botApi =
|
|
1157
1261
|
opts.botApi ??
|
|
@@ -1247,6 +1351,51 @@ export class TelegramNotificationDaemon {
|
|
|
1247
1351
|
await writeJsonAtomic(this.fsImpl, paths.aliases, this.aliasTable.serialize());
|
|
1248
1352
|
}
|
|
1249
1353
|
|
|
1354
|
+
async loadSeenUpdateIds(): Promise<void> {
|
|
1355
|
+
const raw = await readJson<{ updateIds?: unknown }>(
|
|
1356
|
+
this.fsImpl,
|
|
1357
|
+
daemonPaths(this.opts.settings.getAgentDir()).seenUpdates,
|
|
1358
|
+
);
|
|
1359
|
+
this.dispatchState.seenUpdateIds.clear();
|
|
1360
|
+
const updateIds = Array.isArray(raw?.updateIds) ? raw.updateIds : [];
|
|
1361
|
+
for (const updateId of updateIds) {
|
|
1362
|
+
if (Number.isSafeInteger(updateId) && Number(updateId) >= 0) {
|
|
1363
|
+
this.dispatchState.seenUpdateIds.add(Number(updateId));
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
this.pruneSeenUpdateIds();
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
async persistSeenUpdateIds(): Promise<void> {
|
|
1370
|
+
const paths = daemonPaths(this.opts.settings.getAgentDir());
|
|
1371
|
+
await ensureDir(this.fsImpl, paths.dir);
|
|
1372
|
+
await writeJsonAtomic(this.fsImpl, paths.seenUpdates, {
|
|
1373
|
+
version: 1,
|
|
1374
|
+
updateIds: [...this.dispatchState.seenUpdateIds].slice(-SEEN_UPDATE_ID_LIMIT),
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
private pruneSeenUpdateIds(): void {
|
|
1379
|
+
let extra = this.dispatchState.seenUpdateIds.size - SEEN_UPDATE_ID_LIMIT;
|
|
1380
|
+
if (extra <= 0) return;
|
|
1381
|
+
for (const updateId of this.dispatchState.seenUpdateIds) {
|
|
1382
|
+
this.dispatchState.seenUpdateIds.delete(updateId);
|
|
1383
|
+
extra -= 1;
|
|
1384
|
+
if (extra <= 0) break;
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
private async rememberSeenUpdateId(updateId: number): Promise<void> {
|
|
1389
|
+
if (!Number.isSafeInteger(updateId) || updateId < 0) return;
|
|
1390
|
+
this.dispatchState.seenUpdateIds.add(updateId);
|
|
1391
|
+
this.pruneSeenUpdateIds();
|
|
1392
|
+
try {
|
|
1393
|
+
await this.persistSeenUpdateIds();
|
|
1394
|
+
} catch (err) {
|
|
1395
|
+
logger.warn(`notifications: failed to persist Telegram update id ${updateId}: ${String(err)}`);
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1250
1399
|
async scanRoots(): Promise<void> {
|
|
1251
1400
|
const paths = daemonPaths(this.opts.settings.getAgentDir());
|
|
1252
1401
|
const rootState = await readJson<{ roots?: string[] }>(this.fsImpl, paths.roots);
|
|
@@ -1366,13 +1515,17 @@ export class TelegramNotificationDaemon {
|
|
|
1366
1515
|
* (so a delayed old close cannot delete a replacement), and best-effort closes
|
|
1367
1516
|
* the socket. `scanRoots()` then reconnects the session.
|
|
1368
1517
|
*/
|
|
1369
|
-
private dropSession(session: SessionSocket,
|
|
1518
|
+
private dropSession(session: SessionSocket, reason: string): void {
|
|
1370
1519
|
const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
|
|
1371
1520
|
if (session.pingTimer) {
|
|
1372
1521
|
clearIntervalImpl(session.pingTimer);
|
|
1373
1522
|
session.pingTimer = undefined;
|
|
1374
1523
|
}
|
|
1375
|
-
|
|
1524
|
+
const isCurrentSession = this.sessions.get(session.sessionId) === session;
|
|
1525
|
+
if (isCurrentSession || reason === "session_closed") {
|
|
1526
|
+
this.deleteMessageRoutes(session.sessionId);
|
|
1527
|
+
}
|
|
1528
|
+
if (isCurrentSession) {
|
|
1376
1529
|
this.sessions.delete(session.sessionId);
|
|
1377
1530
|
}
|
|
1378
1531
|
if (session.ws.readyState !== WebSocket.CLOSED) {
|
|
@@ -1382,6 +1535,14 @@ export class TelegramNotificationDaemon {
|
|
|
1382
1535
|
}
|
|
1383
1536
|
}
|
|
1384
1537
|
|
|
1538
|
+
private deleteMessageRoutes(sessionId: string, actionId?: string): void {
|
|
1539
|
+
for (const [messageId, route] of this.messageRoutes.entries()) {
|
|
1540
|
+
if (route.sessionId === sessionId && (actionId === undefined || route.actionId === actionId)) {
|
|
1541
|
+
this.messageRoutes.delete(messageId);
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1385
1546
|
private static readonly THREADED_FRAMES = new Set([
|
|
1386
1547
|
"identity_header",
|
|
1387
1548
|
"context_update",
|
|
@@ -1434,6 +1595,13 @@ export class TelegramNotificationDaemon {
|
|
|
1434
1595
|
return undefined;
|
|
1435
1596
|
}
|
|
1436
1597
|
|
|
1598
|
+
private sessionCanClaimIdentity(session: SessionSocket, msg: { repo?: unknown; branch?: unknown }): boolean {
|
|
1599
|
+
const current = this.sessions.get(session.sessionId);
|
|
1600
|
+
if (current) return current === session;
|
|
1601
|
+
const ownerId = this.topicOwnerForIdentity(msg);
|
|
1602
|
+
return !ownerId || ownerId === session.sessionId;
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1437
1605
|
private async submitThreadedFrame(sessionId: string, send: ThreadedSend, topicId: string): Promise<void> {
|
|
1438
1606
|
this.pool.submit({
|
|
1439
1607
|
sessionId,
|
|
@@ -1444,6 +1612,11 @@ export class TelegramNotificationDaemon {
|
|
|
1444
1612
|
await this.flushPool();
|
|
1445
1613
|
}
|
|
1446
1614
|
|
|
1615
|
+
private async existingTopicForPrivateChat(sessionId: string): Promise<string | undefined> {
|
|
1616
|
+
if (!(await this.pairedChatIsPrivate())) return undefined;
|
|
1617
|
+
return this.topics.get(sessionId)?.topicId;
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1447
1620
|
private rememberPendingThreadedFrame(sessionId: string, send: ThreadedSend, msg: Record<string, unknown>): void {
|
|
1448
1621
|
const frames = this.pendingThreadedFrames.get(sessionId) ?? [];
|
|
1449
1622
|
frames.push({ send, msg });
|
|
@@ -1465,6 +1638,7 @@ export class TelegramNotificationDaemon {
|
|
|
1465
1638
|
* one-time nudge) or drop fail-closed for a non-private chat.
|
|
1466
1639
|
*/
|
|
1467
1640
|
private async ensureTopic(sessionId: string, name: string): Promise<string | undefined> {
|
|
1641
|
+
if (!(await this.pairedChatIsPrivate())) return undefined;
|
|
1468
1642
|
const existing = this.topics.get(sessionId);
|
|
1469
1643
|
if (existing) return existing.topicId;
|
|
1470
1644
|
try {
|
|
@@ -1508,6 +1682,9 @@ export class TelegramNotificationDaemon {
|
|
|
1508
1682
|
})) as { ok?: boolean };
|
|
1509
1683
|
if (res?.ok === false) return;
|
|
1510
1684
|
this.topics.delete(sessionId);
|
|
1685
|
+
for (const k of [...this.liveMessages.keys()]) {
|
|
1686
|
+
if (k.startsWith(`${sessionId}:`)) this.liveMessages.delete(k);
|
|
1687
|
+
}
|
|
1511
1688
|
this.topicOwnerByIdentity.forEach((ownerSessionId, identityKey) => {
|
|
1512
1689
|
if (ownerSessionId === sessionId) this.topicOwnerByIdentity.delete(identityKey);
|
|
1513
1690
|
});
|
|
@@ -1635,13 +1812,81 @@ export class TelegramNotificationDaemon {
|
|
|
1635
1812
|
return { images, fileNotes };
|
|
1636
1813
|
}
|
|
1637
1814
|
|
|
1815
|
+
/**
|
|
1816
|
+
* Serialize all pool flushes. Every caller (`submitThreadedFrame`, the flat
|
|
1817
|
+
* fallback, the drain timer's `void this.flushPool()`, topic teardown) goes
|
|
1818
|
+
* through one promise chain, so two flushes never interleave — a live send can
|
|
1819
|
+
* never be in-flight while a finalized flush reads `liveMessages` and decides
|
|
1820
|
+
* to post a fresh (duplicate) final. Errors are swallowed so one failed flush
|
|
1821
|
+
* never poisons the queue (each flush is already best-effort internally).
|
|
1822
|
+
*/
|
|
1823
|
+
private flushChain: Promise<void> = Promise.resolve();
|
|
1824
|
+
private flushPool(): Promise<void> {
|
|
1825
|
+
const next = this.flushChain.then(() => this.flushPoolInner());
|
|
1826
|
+
this.flushChain = next.catch(() => {});
|
|
1827
|
+
return next;
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1638
1830
|
/** Drain the shared rate-limit pool and deliver each granted send to its topic. */
|
|
1639
|
-
private async
|
|
1640
|
-
|
|
1831
|
+
private async flushPoolInner(): Promise<void> {
|
|
1832
|
+
const batch = this.pool.drain();
|
|
1833
|
+
// Within a batch a finalized frame supersedes any still-queued live frame for
|
|
1834
|
+
// the same streamed message (finalized outranks live), so drop the stale live
|
|
1835
|
+
// edit — otherwise the authoritative final text could be overwritten by an
|
|
1836
|
+
// older partial delivered right after it.
|
|
1837
|
+
const finalizedKeys = new Set<string>();
|
|
1838
|
+
for (const item of batch) {
|
|
1839
|
+
if (item.lane === "finalized" && item.coalesceKey !== undefined) {
|
|
1840
|
+
finalizedKeys.add(`${item.sessionId}:${item.coalesceKey}`);
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
// Cross-batch protection: also purge any live frame still QUEUED for a
|
|
1844
|
+
// message whose finalized frame is in this batch, so a stale live edit can
|
|
1845
|
+
// never be delivered on a later drain after the authoritative final.
|
|
1846
|
+
if (finalizedKeys.size > 0) {
|
|
1847
|
+
this.pool.removeWhere(
|
|
1848
|
+
it =>
|
|
1849
|
+
it.lane === "live" &&
|
|
1850
|
+
it.coalesceKey !== undefined &&
|
|
1851
|
+
finalizedKeys.has(`${it.sessionId}:${it.coalesceKey}`),
|
|
1852
|
+
);
|
|
1853
|
+
}
|
|
1854
|
+
for (const item of batch) {
|
|
1641
1855
|
const { send, topicId } = item.payload;
|
|
1856
|
+
if (topicId && !(await this.pairedChatIsPrivate())) continue;
|
|
1642
1857
|
// Threaded topic when available; otherwise deliver flat to the paired chat.
|
|
1643
1858
|
const threadField = topicId ? { message_thread_id: Number(topicId) } : {};
|
|
1859
|
+
const ckey = send.editable ? item.coalesceKey : undefined;
|
|
1860
|
+
const editKey = ckey !== undefined ? `${item.sessionId}:${ckey}` : undefined;
|
|
1861
|
+
if (item.lane === "live" && editKey && finalizedKeys.has(editKey)) continue;
|
|
1644
1862
|
try {
|
|
1863
|
+
// Draft streaming (opt-in, off by default): stream a live turn frame as a
|
|
1864
|
+
// best-effort rich-draft preview, debounced to >=1.5s per session through
|
|
1865
|
+
// this same rate-limited drain; a finalized frame ends the turn's draft
|
|
1866
|
+
// window. Entirely inert when richDraft is off (the enabled gate /
|
|
1867
|
+
// shouldStreamDraft fail closed), so off-state HTML request bodies stay
|
|
1868
|
+
// byte-identical.
|
|
1869
|
+
if (this.opts.richDraft?.enabled === true && this.opts.rich?.enabled !== false) {
|
|
1870
|
+
if (send.lane === "finalized" && send.method === "sendMessage") {
|
|
1871
|
+
this.draftStream.reset(item.sessionId);
|
|
1872
|
+
} else if (
|
|
1873
|
+
shouldStreamDraft({
|
|
1874
|
+
enabled: this.opts.richDraft.enabled,
|
|
1875
|
+
send,
|
|
1876
|
+
})
|
|
1877
|
+
) {
|
|
1878
|
+
const draftId = this.draftStream.tryClaim(item.sessionId, this.opts.now?.() ?? Date.now());
|
|
1879
|
+
if (draftId !== undefined) {
|
|
1880
|
+
await deliverDraft(
|
|
1881
|
+
this.botApi,
|
|
1882
|
+
{ chat_id: this.opts.chatId, ...threadField },
|
|
1883
|
+
draftId,
|
|
1884
|
+
send.richDraftMarkdown!,
|
|
1885
|
+
logger,
|
|
1886
|
+
);
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1645
1890
|
if (send.method === "sendPhoto" && send.photoBase64) {
|
|
1646
1891
|
// Real photo upload (the default botApi multiparts base64 -> file).
|
|
1647
1892
|
await this.botApi.call("sendPhoto", {
|
|
@@ -1663,21 +1908,165 @@ export class TelegramNotificationDaemon {
|
|
|
1663
1908
|
parse_mode: TELEGRAM_PARSE_MODE,
|
|
1664
1909
|
});
|
|
1665
1910
|
} else if (send.text) {
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1911
|
+
// Rich pre-branch: promote stable non-editable finalized text to a fresh
|
|
1912
|
+
// sendRichMessage when enabled. Off/miss falls through to the unchanged
|
|
1913
|
+
// upstream edit/send path, so off behavior is byte-identical.
|
|
1914
|
+
if (
|
|
1915
|
+
shouldPromoteRich({
|
|
1916
|
+
enabled: this.opts.rich?.enabled === false ? false : true,
|
|
1917
|
+
send,
|
|
1918
|
+
})
|
|
1919
|
+
) {
|
|
1920
|
+
const sendHtmlFallback = async () => {
|
|
1921
|
+
// Fairness: this frame consumed exactly one token, so send only the
|
|
1922
|
+
// first HTML chunk now and requeue any continuations as their own
|
|
1923
|
+
// non-editable, HTML-only pool items (rich markers stripped) — same
|
|
1924
|
+
// per-token discipline as the non-rich split path.
|
|
1925
|
+
const chunks = splitTelegramHtml(send.text!);
|
|
1926
|
+
await this.botApi.call("sendMessage", {
|
|
1927
|
+
chat_id: this.opts.chatId,
|
|
1928
|
+
...threadField,
|
|
1929
|
+
text: chunks[0]!,
|
|
1930
|
+
parse_mode: TELEGRAM_PARSE_MODE,
|
|
1931
|
+
});
|
|
1932
|
+
for (let i = 1; i < chunks.length; i++) {
|
|
1933
|
+
this.pool.submit({
|
|
1934
|
+
sessionId: item.sessionId,
|
|
1935
|
+
lane: item.lane,
|
|
1936
|
+
payload: {
|
|
1937
|
+
send: {
|
|
1938
|
+
...send,
|
|
1939
|
+
method: "sendMessage",
|
|
1940
|
+
text: chunks[i]!,
|
|
1941
|
+
editable: false,
|
|
1942
|
+
coalesceKey: undefined,
|
|
1943
|
+
photoBase64: undefined,
|
|
1944
|
+
documentBase64: undefined,
|
|
1945
|
+
richMarkdown: undefined,
|
|
1946
|
+
richDraftMarkdown: undefined,
|
|
1947
|
+
richClass: undefined,
|
|
1948
|
+
},
|
|
1949
|
+
topicId,
|
|
1950
|
+
},
|
|
1951
|
+
});
|
|
1952
|
+
}
|
|
1953
|
+
};
|
|
1954
|
+
const richMessageId = await deliverRichWithFallback(
|
|
1955
|
+
this.botApi,
|
|
1956
|
+
{ chat_id: this.opts.chatId, ...threadField },
|
|
1957
|
+
send,
|
|
1958
|
+
sendHtmlFallback,
|
|
1959
|
+
logger,
|
|
1960
|
+
);
|
|
1961
|
+
// Index the sent rich message so an inbound reply to it can restore
|
|
1962
|
+
// the original markdown as context (Telegram does not echo it back).
|
|
1963
|
+
if (richMessageId !== undefined) {
|
|
1964
|
+
await this.replyStore.record({
|
|
1965
|
+
chatId: this.opts.chatId,
|
|
1966
|
+
messageId: richMessageId,
|
|
1967
|
+
text: send.richMarkdown!,
|
|
1968
|
+
});
|
|
1969
|
+
}
|
|
1970
|
+
} else {
|
|
1971
|
+
const chunks = splitTelegramHtml(send.text);
|
|
1972
|
+
const existingId = editKey ? this.liveMessages.get(editKey) : undefined;
|
|
1973
|
+
let firstMessageId: number | undefined;
|
|
1974
|
+
if (editKey && existingId !== undefined) {
|
|
1975
|
+
// Edit the existing streamed message in place with the first chunk
|
|
1976
|
+
// so a finalized turn never leaves a stale live preview. A LOCAL
|
|
1977
|
+
// try/catch keeps a failed edit from aborting the continuation
|
|
1978
|
+
// requeue below; "message is not modified" is a success (the message
|
|
1979
|
+
// already shows this text); a missing/deleted backing message (or a
|
|
1980
|
+
// transport error) resends so the first chunk is never lost.
|
|
1981
|
+
let edited = false;
|
|
1982
|
+
try {
|
|
1983
|
+
const res = (await this.botApi.call("editMessageText", {
|
|
1984
|
+
chat_id: this.opts.chatId,
|
|
1985
|
+
message_id: existingId,
|
|
1986
|
+
text: chunks[0],
|
|
1987
|
+
parse_mode: TELEGRAM_PARSE_MODE,
|
|
1988
|
+
})) as { ok?: boolean; description?: string } | null;
|
|
1989
|
+
edited = res?.ok !== false || /not modified/i.test(String(res?.description ?? ""));
|
|
1990
|
+
} catch {
|
|
1991
|
+
edited = false;
|
|
1992
|
+
}
|
|
1993
|
+
if (edited) {
|
|
1994
|
+
firstMessageId = existingId;
|
|
1995
|
+
} else {
|
|
1996
|
+
const res = (await this.botApi.call("sendMessage", {
|
|
1997
|
+
chat_id: this.opts.chatId,
|
|
1998
|
+
...threadField,
|
|
1999
|
+
text: chunks[0]!,
|
|
2000
|
+
parse_mode: TELEGRAM_PARSE_MODE,
|
|
2001
|
+
})) as { result?: { message_id?: number } };
|
|
2002
|
+
firstMessageId = res?.result?.message_id;
|
|
2003
|
+
}
|
|
2004
|
+
} else {
|
|
2005
|
+
// No streamed message to edit: a single granted slot maps to a
|
|
2006
|
+
// single Telegram send.
|
|
2007
|
+
const res = (await this.botApi.call("sendMessage", {
|
|
2008
|
+
chat_id: this.opts.chatId,
|
|
2009
|
+
...threadField,
|
|
2010
|
+
text: chunks[0]!,
|
|
2011
|
+
parse_mode: TELEGRAM_PARSE_MODE,
|
|
2012
|
+
})) as { result?: { message_id?: number } };
|
|
2013
|
+
firstMessageId = res?.result?.message_id;
|
|
2014
|
+
}
|
|
2015
|
+
// Continuation chunks are FINALIZED-lane only. A live preview is a
|
|
2016
|
+
// single edit-safe chunk (its authoritative full text arrives with the
|
|
2017
|
+
// finalized frame), so a split live frame never fans out into stale,
|
|
2018
|
+
// non-coalesced continuation messages. Finalized continuations are
|
|
2019
|
+
// fresh, non-editable, HTML-only sends (rich markers stripped) so they
|
|
2020
|
+
// can never be re-promoted to a duplicate sendRichMessage.
|
|
2021
|
+
if (item.lane !== "live") {
|
|
2022
|
+
for (let i = 1; i < chunks.length; i++) {
|
|
2023
|
+
this.pool.submit({
|
|
2024
|
+
sessionId: item.sessionId,
|
|
2025
|
+
lane: item.lane,
|
|
2026
|
+
payload: {
|
|
2027
|
+
send: {
|
|
2028
|
+
...send,
|
|
2029
|
+
method: "sendMessage",
|
|
2030
|
+
text: chunks[i]!,
|
|
2031
|
+
editable: false,
|
|
2032
|
+
coalesceKey: undefined,
|
|
2033
|
+
photoBase64: undefined,
|
|
2034
|
+
documentBase64: undefined,
|
|
2035
|
+
richMarkdown: undefined,
|
|
2036
|
+
richDraftMarkdown: undefined,
|
|
2037
|
+
richClass: undefined,
|
|
2038
|
+
},
|
|
2039
|
+
topicId,
|
|
2040
|
+
},
|
|
2041
|
+
});
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
if (editKey && ckey !== undefined && firstMessageId !== undefined) {
|
|
2045
|
+
this.recordLiveMessage(item.sessionId, ckey, firstMessageId);
|
|
2046
|
+
}
|
|
1673
2047
|
}
|
|
1674
2048
|
}
|
|
1675
2049
|
} catch {
|
|
1676
|
-
// Best-effort: a failed send must never stop the daemon.
|
|
2050
|
+
// Best-effort: a failed send/edit must never stop the daemon.
|
|
1677
2051
|
}
|
|
1678
2052
|
}
|
|
1679
2053
|
}
|
|
1680
2054
|
|
|
2055
|
+
/**
|
|
2056
|
+
* Track the Telegram message id backing a streamed `(sessionId, coalesceKey)`
|
|
2057
|
+
* so later live/finalized frames edit it in place. Evicts this session's stale
|
|
2058
|
+
* same-category entries (e.g. prior turns) so the map stays bounded.
|
|
2059
|
+
*/
|
|
2060
|
+
private recordLiveMessage(sessionId: string, coalesceKey: string, messageId: number): void {
|
|
2061
|
+
const mapKey = `${sessionId}:${coalesceKey}`;
|
|
2062
|
+
const category = coalesceKey.split(":")[0] ?? "";
|
|
2063
|
+
const prefix = `${sessionId}:${category}:`;
|
|
2064
|
+
for (const k of [...this.liveMessages.keys()]) {
|
|
2065
|
+
if (k !== mapKey && k.startsWith(prefix)) this.liveMessages.delete(k);
|
|
2066
|
+
}
|
|
2067
|
+
this.liveMessages.set(mapKey, messageId);
|
|
2068
|
+
}
|
|
2069
|
+
|
|
1681
2070
|
/**
|
|
1682
2071
|
* Threaded Mode is unavailable (the bot owner has not enabled forum topics in
|
|
1683
2072
|
* @BotFather, so `createForumTopic` fails). Deliver the rendered frame flat to
|
|
@@ -1697,9 +2086,10 @@ export class TelegramNotificationDaemon {
|
|
|
1697
2086
|
}
|
|
1698
2087
|
|
|
1699
2088
|
/**
|
|
1700
|
-
* Resolve
|
|
1701
|
-
*
|
|
1702
|
-
*
|
|
2089
|
+
* Resolve (and cache successful resolution of) whether the paired `chatId` is a
|
|
2090
|
+
* private chat. Topic and flat delivery are only safe in a private DM; any
|
|
2091
|
+
* non-private chat fails closed, while a transient `getChat` failure fails closed
|
|
2092
|
+
* for the current attempt and is retried later.
|
|
1703
2093
|
*/
|
|
1704
2094
|
private async pairedChatIsPrivate(): Promise<boolean> {
|
|
1705
2095
|
if (this.pairedChatPrivate !== undefined) return this.pairedChatPrivate;
|
|
@@ -1708,20 +2098,21 @@ export class TelegramNotificationDaemon {
|
|
|
1708
2098
|
result?: { type?: string };
|
|
1709
2099
|
};
|
|
1710
2100
|
this.pairedChatPrivate = res.result?.type === "private";
|
|
1711
|
-
|
|
1712
|
-
|
|
2101
|
+
return this.pairedChatPrivate;
|
|
2102
|
+
} catch (e) {
|
|
2103
|
+
logger.warn(`notifications: getChat failed while checking Telegram chat privacy: ${String(e)}`);
|
|
2104
|
+
return false;
|
|
1713
2105
|
}
|
|
1714
|
-
return this.pairedChatPrivate;
|
|
1715
2106
|
}
|
|
1716
2107
|
|
|
1717
2108
|
/** Tell the user once (per daemon run) how to enable Threaded Mode. */
|
|
1718
2109
|
private async notifyThreadedFallback(): Promise<void> {
|
|
1719
|
-
if (this.threadedFallbackNoticeSent) return;
|
|
2110
|
+
if (this.threadedFallbackNoticeSent || !(await this.pairedChatIsPrivate())) return;
|
|
1720
2111
|
this.threadedFallbackNoticeSent = true;
|
|
1721
2112
|
try {
|
|
1722
2113
|
await this.botApi.call("sendMessage", {
|
|
1723
2114
|
chat_id: this.opts.chatId,
|
|
1724
|
-
text: "
|
|
2115
|
+
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.",
|
|
1725
2116
|
parse_mode: TELEGRAM_PARSE_MODE,
|
|
1726
2117
|
});
|
|
1727
2118
|
} catch {
|
|
@@ -1761,7 +2152,7 @@ export class TelegramNotificationDaemon {
|
|
|
1761
2152
|
/** Send a single `typing` chat action into a busy session's topic (best-effort). */
|
|
1762
2153
|
private async sendTyping(sessionId: string): Promise<void> {
|
|
1763
2154
|
const topicId = this.topics.get(sessionId)?.topicId;
|
|
1764
|
-
if (!topicId) return;
|
|
2155
|
+
if (!topicId || !(await this.pairedChatIsPrivate())) return;
|
|
1765
2156
|
try {
|
|
1766
2157
|
await this.botApi.call("sendChatAction", {
|
|
1767
2158
|
chat_id: this.opts.chatId,
|
|
@@ -1775,6 +2166,7 @@ export class TelegramNotificationDaemon {
|
|
|
1775
2166
|
|
|
1776
2167
|
/** Set a native reaction on an inbound thread message (best-effort). */
|
|
1777
2168
|
private async setReaction(messageId: number, emoji: string): Promise<void> {
|
|
2169
|
+
if (!(await this.pairedChatIsPrivate())) return;
|
|
1778
2170
|
try {
|
|
1779
2171
|
await this.botApi.call("setMessageReaction", {
|
|
1780
2172
|
chat_id: this.opts.chatId,
|
|
@@ -1802,12 +2194,12 @@ export class TelegramNotificationDaemon {
|
|
|
1802
2194
|
if (typeof msg?.type === "string" && TelegramNotificationDaemon.THREADED_FRAMES.has(msg.type)) {
|
|
1803
2195
|
const send = renderThreadedFrame(msg);
|
|
1804
2196
|
if (!send) return;
|
|
1805
|
-
const existingTopic = this.
|
|
2197
|
+
const existingTopic = await this.existingTopicForPrivateChat(session.sessionId);
|
|
1806
2198
|
if (!send.identity && !existingTopic && !this.flatIdentitySent.has(session.sessionId)) {
|
|
1807
2199
|
this.rememberPendingThreadedFrame(session.sessionId, send, msg as Record<string, unknown>);
|
|
1808
2200
|
return;
|
|
1809
2201
|
}
|
|
1810
|
-
if (send.identity) {
|
|
2202
|
+
if (send.identity && !this.sessionCanClaimIdentity(session, msg)) {
|
|
1811
2203
|
const ownerId = this.topicOwnerForIdentity(msg);
|
|
1812
2204
|
const ownerTopic = ownerId ? this.topics.get(ownerId) : undefined;
|
|
1813
2205
|
if (ownerId && ownerId !== session.sessionId && ownerTopic) {
|
|
@@ -1869,27 +2261,62 @@ export class TelegramNotificationDaemon {
|
|
|
1869
2261
|
});
|
|
1870
2262
|
const options = Array.isArray(msg.options) ? msg.options : [];
|
|
1871
2263
|
// Daemon keyboards use alias callback data with compact one-based tap targets;
|
|
1872
|
-
// full option text is rendered in the message body by buildActionMessage.
|
|
2264
|
+
// full option text is rendered in the message body by buildActionMessage/buildActionMarkdown.
|
|
1873
2265
|
const inline_keyboard = buildCompactChoiceGrid(options, (i: number) =>
|
|
1874
2266
|
this.aliasTable.put({ sessionId: session.sessionId, actionId: msg.id, answer: i }),
|
|
1875
2267
|
);
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
2268
|
+
// HTML delivery: one sendMessage per chunk, keyboard on the last chunk;
|
|
2269
|
+
// returns the last chunk's message_id (the reply-routable message).
|
|
2270
|
+
const sendHtmlChunks = async (): Promise<number | undefined> => {
|
|
2271
|
+
const chunks = splitTelegramHtml(rendered.text);
|
|
2272
|
+
let result: { result?: { message_id?: number } } = {};
|
|
2273
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
2274
|
+
result = (await this.botApi.call("sendMessage", {
|
|
2275
|
+
chat_id: this.opts.chatId,
|
|
2276
|
+
...threadField,
|
|
2277
|
+
text: chunks[i]!,
|
|
2278
|
+
parse_mode: TELEGRAM_PARSE_MODE,
|
|
2279
|
+
...(i === chunks.length - 1 && inline_keyboard.length ? { reply_markup: { inline_keyboard } } : {}),
|
|
2280
|
+
})) as { result?: { message_id?: number } };
|
|
2281
|
+
}
|
|
2282
|
+
return result.result?.message_id;
|
|
2283
|
+
};
|
|
2284
|
+
const kind = msg.kind === "idle" ? "idle" : "ask";
|
|
2285
|
+
if (this.opts.rich?.enabled !== false) {
|
|
2286
|
+
// Rich (default on): promote to sendRichMessage with a top-level
|
|
2287
|
+
// reply_markup (probe-confirmed). Any miss falls back to the HTML loop.
|
|
2288
|
+
|
|
2289
|
+
const outcome = await deliverRichActionWithFallback(
|
|
2290
|
+
this.botApi,
|
|
2291
|
+
{ chat_id: this.opts.chatId, ...threadField },
|
|
2292
|
+
{
|
|
2293
|
+
markdown: buildActionMarkdown({
|
|
2294
|
+
kind,
|
|
2295
|
+
question: msg.question,
|
|
2296
|
+
options: msg.options,
|
|
2297
|
+
summary: msg.summary,
|
|
2298
|
+
}),
|
|
2299
|
+
replyMarkup: kind === "ask" && inline_keyboard.length ? { inline_keyboard } : undefined,
|
|
2300
|
+
requireMessageId: kind === "ask",
|
|
2301
|
+
},
|
|
2302
|
+
sendHtmlChunks,
|
|
2303
|
+
logger,
|
|
2304
|
+
);
|
|
2305
|
+
// Only asks are reply-routable; idle pings register no route.
|
|
2306
|
+
if (kind === "ask" && outcome.messageId !== undefined)
|
|
2307
|
+
this.messageRoutes.set(String(outcome.messageId), { sessionId: session.sessionId, actionId: msg.id });
|
|
2308
|
+
} else {
|
|
2309
|
+
// Off: byte-identical to the pre-rich HTML path.
|
|
2310
|
+
const messageId = await sendHtmlChunks();
|
|
2311
|
+
// Only asks are reply-routable; idle pings register no route (parity
|
|
2312
|
+
// with the rich branch and correct even in the byte-identical off path).
|
|
2313
|
+
if (kind === "ask" && messageId !== undefined)
|
|
2314
|
+
this.messageRoutes.set(String(messageId), { sessionId: session.sessionId, actionId: msg.id });
|
|
1886
2315
|
}
|
|
1887
|
-
const messageId = result.result?.message_id;
|
|
1888
|
-
if (messageId !== undefined)
|
|
1889
|
-
this.messageRoutes.set(String(messageId), { sessionId: session.sessionId, actionId: msg.id });
|
|
1890
2316
|
await this.persistAliases();
|
|
1891
2317
|
} else if (msg.type === "action_resolved" && msg.id) {
|
|
1892
2318
|
session.pending.delete(msg.id);
|
|
2319
|
+
this.deleteMessageRoutes(session.sessionId, msg.id);
|
|
1893
2320
|
for (const [alias, route] of this.aliasTable.entries()) {
|
|
1894
2321
|
if (route.sessionId === session.sessionId && route.actionId === msg.id) this.aliasTable.delete(alias);
|
|
1895
2322
|
}
|
|
@@ -1897,19 +2324,22 @@ export class TelegramNotificationDaemon {
|
|
|
1897
2324
|
}
|
|
1898
2325
|
}
|
|
1899
2326
|
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
2327
|
+
private async answerCallbackQueryBestEffort(callbackId: unknown, text?: string): Promise<void> {
|
|
2328
|
+
if (typeof callbackId !== "string") return;
|
|
2329
|
+
try {
|
|
2330
|
+
await this.botApi.call("answerCallbackQuery", {
|
|
2331
|
+
callback_query_id: callbackId,
|
|
2332
|
+
...(text === undefined ? {} : { text }),
|
|
2333
|
+
});
|
|
2334
|
+
} catch {
|
|
2335
|
+
// Telegram callback acknowledgements only dismiss the client-side spinner;
|
|
2336
|
+
// they must never block the already-validated local reply path.
|
|
1905
2337
|
}
|
|
1906
|
-
|
|
1907
|
-
};
|
|
2338
|
+
}
|
|
1908
2339
|
|
|
1909
2340
|
private async sendStaleGuidance(callbackId: unknown): Promise<void> {
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
}
|
|
2341
|
+
await this.answerCallbackQueryBestEffort(callbackId, "Button is stale");
|
|
2342
|
+
if (!(await this.pairedChatIsPrivate())) return;
|
|
1913
2343
|
await this.botApi.call("sendMessage", {
|
|
1914
2344
|
chat_id: this.opts.chatId,
|
|
1915
2345
|
text: "This button is stale after notification daemon restart. Please answer locally in the SKC session or wait for a fresh notification.",
|
|
@@ -1924,12 +2354,77 @@ export class TelegramNotificationDaemon {
|
|
|
1924
2354
|
// session input.
|
|
1925
2355
|
{
|
|
1926
2356
|
const m = (update as { update_id?: number; message?: Record<string, unknown> }).message;
|
|
1927
|
-
const
|
|
2357
|
+
const chat = m?.chat as { id?: unknown; type?: unknown } | undefined;
|
|
2358
|
+
const chatId = chat?.id;
|
|
2359
|
+
const chatType = typeof chat?.type === "string" ? chat.type : undefined;
|
|
1928
2360
|
const cmdText = typeof m?.text === "string" ? m.text : undefined;
|
|
1929
|
-
|
|
2361
|
+
const commandCtx = { chatType, botUsername: this.botUsername };
|
|
2362
|
+
if (m !== undefined && String(chatId) === String(this.opts.chatId)) {
|
|
2363
|
+
if (chatType !== undefined && chatType !== "private" && isLifecycleCommandLikeText(cmdText)) return;
|
|
2364
|
+
if (isLifecycleCommandText(cmdText, commandCtx)) {
|
|
2365
|
+
const updateId = (update as { update_id?: number }).update_id;
|
|
2366
|
+
const threadId = typeof m.message_thread_id === "number" ? (m.message_thread_id as number) : undefined;
|
|
2367
|
+
if (await this.handleLifecycleCommand(cmdText, updateId, threadId, commandCtx)) return;
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2371
|
+
// Rich-message toggle (/rich on|off): daemon-local delivery policy, NOT a
|
|
2372
|
+
// session config forward. Handled at paired-chat pre-routing, before threaded
|
|
2373
|
+
// injection and independent of any session WebSocket, so it works even when
|
|
2374
|
+
// no session is connected and never becomes an ask answer.
|
|
2375
|
+
{
|
|
2376
|
+
const m = (update as { update_id?: number; message?: Record<string, unknown> }).message;
|
|
2377
|
+
const chat = m?.chat as { id?: unknown } | undefined;
|
|
2378
|
+
const cmdText = typeof m?.text === "string" ? m.text : undefined;
|
|
2379
|
+
const rawFirst = cmdText?.trim().split(/\s+/)[0]?.toLowerCase();
|
|
2380
|
+
// Fail-closed: intercept ANY "/rich" or "/rich@<anything>" form (Telegram
|
|
2381
|
+
// appends @botname in groups; the bot username may be unknown if getMe
|
|
2382
|
+
// failed) so a rich command is never leaked into threaded injection / an
|
|
2383
|
+
// ask answer. Argument validity is decided by parseRichToggleCommand below.
|
|
2384
|
+
const isRichCommand = rawFirst?.split("@")[0] === "/rich";
|
|
2385
|
+
if (m !== undefined && String(chat?.id) === String(this.opts.chatId) && isRichCommand) {
|
|
2386
|
+
// Fail-closed: /rich mutates global config, so honor it ONLY in a PRIVATE
|
|
2387
|
+
// paired chat — the same contract as session delivery and lifecycle
|
|
2388
|
+
// commands. A group/supergroup chatId (legacy or hand-edited) must never
|
|
2389
|
+
// let an arbitrary chat member toggle the owner's notification config.
|
|
2390
|
+
if (!(await this.pairedChatIsPrivate())) return;
|
|
1930
2391
|
const updateId = (update as { update_id?: number }).update_id;
|
|
1931
|
-
|
|
1932
|
-
if (
|
|
2392
|
+
// Dedupe redelivered updates so a toggle+confirmation runs at most once.
|
|
2393
|
+
if (typeof updateId === "number") {
|
|
2394
|
+
if (this.dispatchState.seenUpdateIds.has(updateId)) return;
|
|
2395
|
+
await this.rememberSeenUpdateId(updateId);
|
|
2396
|
+
}
|
|
2397
|
+
const threadField =
|
|
2398
|
+
typeof m.message_thread_id === "number" ? { message_thread_id: m.message_thread_id as number } : {};
|
|
2399
|
+
const reply = async (body: string): Promise<void> => {
|
|
2400
|
+
try {
|
|
2401
|
+
await this.botApi.call("sendMessage", {
|
|
2402
|
+
chat_id: this.opts.chatId,
|
|
2403
|
+
...threadField,
|
|
2404
|
+
text: body,
|
|
2405
|
+
parse_mode: TELEGRAM_PARSE_MODE,
|
|
2406
|
+
});
|
|
2407
|
+
} catch {
|
|
2408
|
+
// Best-effort confirmation; never block on the notice.
|
|
2409
|
+
}
|
|
2410
|
+
};
|
|
2411
|
+
const desired = parseRichToggleCommand(cmdText ?? "");
|
|
2412
|
+
if (desired === undefined) {
|
|
2413
|
+
await reply("Usage: /rich on|off");
|
|
2414
|
+
return;
|
|
2415
|
+
}
|
|
2416
|
+
try {
|
|
2417
|
+
await this.opts.settings.set("notifications.telegram.rich.enabled", desired);
|
|
2418
|
+
} catch (err) {
|
|
2419
|
+
logger.warn(
|
|
2420
|
+
`notifications: /rich settings write failed (${err instanceof Error ? err.message : String(err)}); runtime unchanged`,
|
|
2421
|
+
);
|
|
2422
|
+
await reply("Rich messages: unchanged (settings write failed)");
|
|
2423
|
+
return;
|
|
2424
|
+
}
|
|
2425
|
+
this.opts.rich = { enabled: desired };
|
|
2426
|
+
await reply(desired ? "Rich messages: on" : "Rich messages: off");
|
|
2427
|
+
return;
|
|
1933
2428
|
}
|
|
1934
2429
|
}
|
|
1935
2430
|
// Threaded injection: a free-text message in a known topic (not a button
|
|
@@ -1954,7 +2449,6 @@ export class TelegramNotificationDaemon {
|
|
|
1954
2449
|
});
|
|
1955
2450
|
if (inbound.kind === "duplicate") return;
|
|
1956
2451
|
if (inbound.kind === "inject") {
|
|
1957
|
-
this.dispatchState.seenUpdateIds.add(inbound.updateId);
|
|
1958
2452
|
const session = this.sessions.get(inbound.sessionId);
|
|
1959
2453
|
if (session?.ws.readyState === WebSocket.OPEN) {
|
|
1960
2454
|
const attachmentResult = inbound.attachment
|
|
@@ -1963,7 +2457,17 @@ export class TelegramNotificationDaemon {
|
|
|
1963
2457
|
const images = attachmentResult?.images ?? [];
|
|
1964
2458
|
const fileNotes = attachmentResult?.fileNotes ?? [];
|
|
1965
2459
|
const hasMedia = images.length > 0 || fileNotes.length > 0;
|
|
1966
|
-
const
|
|
2460
|
+
const baseInjectedText = [inbound.text, ...fileNotes].filter(Boolean).join("\n");
|
|
2461
|
+
// A reply to a rich message we sent (not an ask route) loses its original
|
|
2462
|
+
// text: Telegram does not echo it in reply_to_message. Restore it from the
|
|
2463
|
+
// reply index as a labeled context prefix; a miss leaves the turn unchanged.
|
|
2464
|
+
const repliedOriginal =
|
|
2465
|
+
typeof replyTo === "number"
|
|
2466
|
+
? this.replyStore.lookup({ chatId: this.opts.chatId, messageId: replyTo })
|
|
2467
|
+
: undefined;
|
|
2468
|
+
const injectedText = repliedOriginal
|
|
2469
|
+
? `> replied-to message:\n${repliedOriginal}\n\n${baseInjectedText}`
|
|
2470
|
+
: baseInjectedText;
|
|
1967
2471
|
const cfg = hasMedia ? undefined : parseInThreadConfigCommand(inbound.text);
|
|
1968
2472
|
// A plain (non-config) message while an ask is pending for this session
|
|
1969
2473
|
// answers that ask as free-input — instead of starting a new user turn.
|
|
@@ -1979,6 +2483,7 @@ export class TelegramNotificationDaemon {
|
|
|
1979
2483
|
token: session.token,
|
|
1980
2484
|
}),
|
|
1981
2485
|
);
|
|
2486
|
+
await this.rememberSeenUpdateId(inbound.updateId);
|
|
1982
2487
|
if (inbound.messageId !== undefined) await this.setReaction(inbound.messageId, QUEUED_REACTION);
|
|
1983
2488
|
return;
|
|
1984
2489
|
}
|
|
@@ -1997,6 +2502,7 @@ export class TelegramNotificationDaemon {
|
|
|
1997
2502
|
},
|
|
1998
2503
|
),
|
|
1999
2504
|
);
|
|
2505
|
+
await this.rememberSeenUpdateId(inbound.updateId);
|
|
2000
2506
|
// User turns get a native delivery double-check: queued on receipt,
|
|
2001
2507
|
// flipped to consumed when the session acks the turn that picks it
|
|
2002
2508
|
// up. Config commands are not user turns and get no reaction.
|
|
@@ -2012,7 +2518,6 @@ export class TelegramNotificationDaemon {
|
|
|
2012
2518
|
const decision = routeInboundUpdate(update, {
|
|
2013
2519
|
aliasTable: this.aliasTable,
|
|
2014
2520
|
messageRoutes: this.messageRoutes,
|
|
2015
|
-
pendingBySession: this.pendingBySession,
|
|
2016
2521
|
pairedChatId: this.opts.chatId,
|
|
2017
2522
|
});
|
|
2018
2523
|
if (decision.kind === "reply") {
|
|
@@ -2021,11 +2526,10 @@ export class TelegramNotificationDaemon {
|
|
|
2021
2526
|
await this.sendStaleGuidance(callbackId);
|
|
2022
2527
|
return;
|
|
2023
2528
|
}
|
|
2024
|
-
if (typeof callbackId === "string")
|
|
2025
|
-
await this.botApi.call("answerCallbackQuery", { callback_query_id: callbackId });
|
|
2026
2529
|
session.ws.send(
|
|
2027
2530
|
JSON.stringify({ type: "reply", id: decision.actionId, answer: decision.answer, token: session.token }),
|
|
2028
2531
|
);
|
|
2532
|
+
await this.answerCallbackQueryBestEffort(callbackId);
|
|
2029
2533
|
} else if (decision.kind === "stale") {
|
|
2030
2534
|
await this.sendStaleGuidance(callbackId);
|
|
2031
2535
|
}
|
|
@@ -2043,7 +2547,8 @@ export class TelegramNotificationDaemon {
|
|
|
2043
2547
|
{ command: "verbose", description: "Mirror full tool output + reasoning in this thread" },
|
|
2044
2548
|
{ command: "lean", description: "Mirror assistant text + tool names only (default)" },
|
|
2045
2549
|
{ command: "redact", description: "Toggle redaction of streamed content: /redact <on|off>" },
|
|
2046
|
-
{ command: "
|
|
2550
|
+
{ command: "rich", description: "Toggle rich Telegram delivery: /rich <on|off>" },
|
|
2551
|
+
{ command: "session_create", description: "Create a SKC session: path, worktree, or dir [--mpreset]" },
|
|
2047
2552
|
{ command: "session_recent", description: "List recent SKC sessions" },
|
|
2048
2553
|
{ command: "session_close", description: "Close a SKC-managed session" },
|
|
2049
2554
|
{ command: "session_resume", description: "Resume or reattach a session" },
|
|
@@ -2068,9 +2573,12 @@ export class TelegramNotificationDaemon {
|
|
|
2068
2573
|
this.startScanTimer();
|
|
2069
2574
|
this.startTypingTimer();
|
|
2070
2575
|
try {
|
|
2576
|
+
await this.refreshBotIdentity();
|
|
2071
2577
|
await this.registerBotCommands();
|
|
2072
2578
|
await this.loadAliases();
|
|
2073
2579
|
await this.loadTopics();
|
|
2580
|
+
await this.loadSeenUpdateIds();
|
|
2581
|
+
await this.replyStore.load();
|
|
2074
2582
|
await this.runScan();
|
|
2075
2583
|
// Owner-only: start the session-lifecycle control server now that
|
|
2076
2584
|
// ownership is confirmed (singleton-safe). Best-effort; degrades.
|
|
@@ -2091,31 +2599,30 @@ export class TelegramNotificationDaemon {
|
|
|
2091
2599
|
await this.runScan();
|
|
2092
2600
|
if (await this.controlStopRequested()) break;
|
|
2093
2601
|
const idleElapsed = this.runtime.now() - idleSince >= (this.opts.idleTimeoutMs ?? 60_000);
|
|
2094
|
-
if (this.sessions.size
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
//
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
}
|
|
2602
|
+
if (this.sessions.size > 0) {
|
|
2603
|
+
idleSince = this.runtime.now();
|
|
2604
|
+
} else if (idleElapsed) {
|
|
2605
|
+
// Zero sessions past the idle window: exit so the owner does not run
|
|
2606
|
+
// forever. An active session resets the idle window above.
|
|
2607
|
+
break;
|
|
2608
|
+
}
|
|
2609
|
+
// Poll getUpdates whenever the daemon owns the token — even with zero
|
|
2610
|
+
// sessions and no lifecycle control — so daemon-local commands (/rich,
|
|
2611
|
+
// /session_*) are always received until idle-exit.
|
|
2612
|
+
const activePoll = this.runtime.createAbortController();
|
|
2613
|
+
try {
|
|
2614
|
+
await this.pollOnce(activePoll.signal);
|
|
2615
|
+
this.loopBackoff.reset();
|
|
2616
|
+
} catch (e) {
|
|
2617
|
+
// A transient getUpdates/network failure must not kill the daemon.
|
|
2618
|
+
// Back off (bounded, below the heartbeat TTL) and keep renewing
|
|
2619
|
+
// ownership at the loop top.
|
|
2620
|
+
const backoffMs = this.loopBackoff.next();
|
|
2621
|
+
logger.warn(`notifications: getUpdates failed, backing off ${backoffMs}ms: ${String(e)}`);
|
|
2622
|
+
await this.runtime.sleep(backoffMs);
|
|
2623
|
+
continue;
|
|
2624
|
+
} finally {
|
|
2625
|
+
this.runtime.clearAbortController(activePoll);
|
|
2119
2626
|
}
|
|
2120
2627
|
if (await this.controlStopRequested()) break;
|
|
2121
2628
|
await this.runtime.sleep(10);
|
|
@@ -2131,6 +2638,7 @@ export class TelegramNotificationDaemon {
|
|
|
2131
2638
|
// (e.g. after reload) reloads aliases/topics seamlessly.
|
|
2132
2639
|
await this.persistAliases().catch(() => undefined);
|
|
2133
2640
|
await this.persistTopics().catch(() => undefined);
|
|
2641
|
+
await this.persistSeenUpdateIds().catch(() => undefined);
|
|
2134
2642
|
await this.opts.control?.clear?.(this.opts.ownerId).catch(() => undefined);
|
|
2135
2643
|
await releaseDaemonOwnership({
|
|
2136
2644
|
settings: this.opts.settings,
|