@sayknow-cli/coding-agent 0.3.7 → 0.3.8
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 +105 -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/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/interactive-mode.d.ts +2 -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-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 +1 -0
- package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
- package/dist/types/notifications/config.d.ts +14 -1
- package/dist/types/notifications/daemon-paths.d.ts +1 -0
- package/dist/types/notifications/index.d.ts +6 -3
- package/dist/types/notifications/lifecycle-commands.d.ts +11 -3
- package/dist/types/notifications/telegram-daemon.d.ts +23 -3
- package/dist/types/notifications/telegram-reference.d.ts +0 -5
- package/dist/types/notifications/threaded-render.d.ts +6 -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/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/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 +92 -8
- package/src/cli/telegram-cli.ts +1 -1
- package/src/cli/update-cli.ts +85 -9
- 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 +2 -2
- package/src/config/model-profile-activation.ts +65 -0
- package/src/config/model-profiles.ts +31 -2
- package/src/config/settings-schema.ts +68 -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 +14 -5
- 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/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/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 +66 -47
- package/src/modes/controllers/extension-ui-controller.ts +4 -10
- package/src/modes/controllers/input-controller.ts +180 -18
- package/src/modes/controllers/selector-controller.ts +127 -24
- package/src/modes/interactive-mode.ts +51 -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 +29 -3
- 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 +1 -0
- package/src/modes/utils/hotkeys-markdown.ts +1 -0
- package/src/modes/utils/ui-helpers.ts +20 -7
- package/src/notifications/config.ts +25 -5
- package/src/notifications/daemon-paths.ts +2 -0
- package/src/notifications/index.ts +117 -14
- package/src/notifications/lifecycle-commands.ts +37 -7
- package/src/notifications/lifecycle-orchestrator.ts +19 -6
- package/src/notifications/telegram-daemon-control.ts +5 -3
- package/src/notifications/telegram-daemon.ts +302 -51
- package/src/notifications/telegram-reference.ts +0 -16
- package/src/notifications/threaded-render.ts +17 -1
- 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 +1 -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 +52 -28
- package/src/session/agent-session.ts +1011 -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/ultragoal-guard.ts +55 -66
- package/src/skc-runtime/ultragoal-receipt-freshness.ts +315 -0
- package/src/skc-runtime/ultragoal-runtime.ts +1663 -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 +218 -71
- 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/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,7 +8,7 @@ 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,
|
|
11
|
+
import { getNotificationConfig, isTelegramConfigured, tokenFingerprint } from "./config";
|
|
12
12
|
import { parseInThreadConfigCommand } from "./config-commands";
|
|
13
13
|
import { daemonPaths } from "./daemon-paths";
|
|
14
14
|
import {
|
|
@@ -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,
|
|
@@ -48,7 +49,6 @@ import {
|
|
|
48
49
|
buildActionMessage,
|
|
49
50
|
type CallbackRoute,
|
|
50
51
|
createAliasTable,
|
|
51
|
-
type PendingAsk,
|
|
52
52
|
readEndpoint,
|
|
53
53
|
routeInboundUpdate,
|
|
54
54
|
} from "./telegram-reference";
|
|
@@ -56,7 +56,7 @@ import { decideThreadedInbound, type InboundAttachment } from "./threaded-inboun
|
|
|
56
56
|
import { renderThreadedFrame, type ThreadedSend } from "./threaded-render";
|
|
57
57
|
import { TopicRegistry, type TopicRegistryState } from "./topic-registry";
|
|
58
58
|
|
|
59
|
-
export type EnsureDaemonResult = "owner_spawned" | "attached" | "disabled";
|
|
59
|
+
export type EnsureDaemonResult = "owner_spawned" | "attached" | "disabled" | "blocked";
|
|
60
60
|
|
|
61
61
|
export interface DaemonState {
|
|
62
62
|
pid: number;
|
|
@@ -126,6 +126,7 @@ const TYPING_REFRESH_INTERVAL_MS = 4_000;
|
|
|
126
126
|
// messages: queued on receipt, consumed once a turn picks the message up.
|
|
127
127
|
const QUEUED_REACTION = "👀";
|
|
128
128
|
const PENDING_TOPIC_FRAME_LIMIT = 20;
|
|
129
|
+
const SEEN_UPDATE_ID_LIMIT = 1_000;
|
|
129
130
|
const CONSUMED_REACTION = "✅";
|
|
130
131
|
|
|
131
132
|
function splitTelegramPlainText(text: string, max = TELEGRAM_MESSAGE_LIMIT): string[] {
|
|
@@ -267,7 +268,7 @@ export async function registerNotificationRoot(input: {
|
|
|
267
268
|
const fsImpl = input.fs ?? nodeFs;
|
|
268
269
|
const paths = daemonPaths(input.settings.getAgentDir());
|
|
269
270
|
await ensureDir(fsImpl, paths.dir);
|
|
270
|
-
const root =
|
|
271
|
+
const root = notificationRootForCwd(input.cwd);
|
|
271
272
|
await withFileLock(
|
|
272
273
|
paths.roots,
|
|
273
274
|
async () => {
|
|
@@ -286,6 +287,29 @@ export async function registerNotificationRoot(input: {
|
|
|
286
287
|
return root;
|
|
287
288
|
}
|
|
288
289
|
|
|
290
|
+
function notificationRootForCwd(cwd: string): string {
|
|
291
|
+
return path.join(cwd, ".skc", "state");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function ownerIdentityMatches(state: DaemonState, tokenFingerprint: string, chatId: string): boolean {
|
|
295
|
+
return state.tokenFingerprint === tokenFingerprint && state.chatId === chatId;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function liveOwnerUsesDifferentIdentity(input: {
|
|
299
|
+
state: DaemonState | undefined;
|
|
300
|
+
tokenFingerprint: string;
|
|
301
|
+
chatId: string;
|
|
302
|
+
pidAlive: (pid: number) => boolean;
|
|
303
|
+
}): boolean {
|
|
304
|
+
const { state } = input;
|
|
305
|
+
return Boolean(
|
|
306
|
+
state &&
|
|
307
|
+
state.version === DAEMON_VERSION &&
|
|
308
|
+
!ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) &&
|
|
309
|
+
input.pidAlive(state.pid),
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
289
313
|
export function isFreshLiveOwner(input: {
|
|
290
314
|
state: DaemonState | undefined;
|
|
291
315
|
now: number;
|
|
@@ -297,8 +321,7 @@ export function isFreshLiveOwner(input: {
|
|
|
297
321
|
return Boolean(
|
|
298
322
|
state &&
|
|
299
323
|
state.version === DAEMON_VERSION &&
|
|
300
|
-
state.tokenFingerprint
|
|
301
|
-
state.chatId === input.chatId &&
|
|
324
|
+
ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) &&
|
|
302
325
|
input.now - state.heartbeatAt <= HEARTBEAT_TTL_MS &&
|
|
303
326
|
input.pidAlive(state.pid),
|
|
304
327
|
);
|
|
@@ -314,7 +337,13 @@ export async function acquireDaemonOwnership(input: {
|
|
|
314
337
|
pid?: number;
|
|
315
338
|
pidAlive?: (pid: number) => boolean;
|
|
316
339
|
randomId?: () => string;
|
|
317
|
-
}): Promise<{
|
|
340
|
+
}): Promise<{
|
|
341
|
+
acquired: boolean;
|
|
342
|
+
ownerId?: string;
|
|
343
|
+
attached?: boolean;
|
|
344
|
+
blocked?: boolean;
|
|
345
|
+
reason?: "identity_mismatch";
|
|
346
|
+
}> {
|
|
318
347
|
const fsImpl = input.fs ?? nodeFs;
|
|
319
348
|
const now = input.now ?? Date.now;
|
|
320
349
|
const pid = input.pid ?? process.pid;
|
|
@@ -324,6 +353,16 @@ export async function acquireDaemonOwnership(input: {
|
|
|
324
353
|
const ownerId = input.randomId?.() ?? `${pid}-${now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
325
354
|
const roots = input.roots ?? (await readJson<{ roots?: string[] }>(fsImpl, paths.roots))?.roots ?? [];
|
|
326
355
|
const existing = await readJson<DaemonState>(fsImpl, paths.state);
|
|
356
|
+
if (
|
|
357
|
+
liveOwnerUsesDifferentIdentity({
|
|
358
|
+
state: existing,
|
|
359
|
+
tokenFingerprint: input.tokenFingerprint,
|
|
360
|
+
chatId: input.chatId,
|
|
361
|
+
pidAlive,
|
|
362
|
+
})
|
|
363
|
+
) {
|
|
364
|
+
return { acquired: false, blocked: true, reason: "identity_mismatch" };
|
|
365
|
+
}
|
|
327
366
|
if (
|
|
328
367
|
isFreshLiveOwner({
|
|
329
368
|
state: existing,
|
|
@@ -349,6 +388,16 @@ export async function acquireDaemonOwnership(input: {
|
|
|
349
388
|
return { acquired: true, ownerId };
|
|
350
389
|
}
|
|
351
390
|
const afterLock = await readJson<DaemonState>(fsImpl, paths.state);
|
|
391
|
+
if (
|
|
392
|
+
liveOwnerUsesDifferentIdentity({
|
|
393
|
+
state: afterLock,
|
|
394
|
+
tokenFingerprint: input.tokenFingerprint,
|
|
395
|
+
chatId: input.chatId,
|
|
396
|
+
pidAlive,
|
|
397
|
+
})
|
|
398
|
+
) {
|
|
399
|
+
return { acquired: false, blocked: true, reason: "identity_mismatch" };
|
|
400
|
+
}
|
|
352
401
|
if (
|
|
353
402
|
isFreshLiveOwner({
|
|
354
403
|
state: afterLock,
|
|
@@ -375,6 +424,16 @@ export async function acquireDaemonOwnership(input: {
|
|
|
375
424
|
) {
|
|
376
425
|
return { acquired: false, attached: true };
|
|
377
426
|
}
|
|
427
|
+
if (
|
|
428
|
+
liveOwnerUsesDifferentIdentity({
|
|
429
|
+
state: rechecked,
|
|
430
|
+
tokenFingerprint: input.tokenFingerprint,
|
|
431
|
+
chatId: input.chatId,
|
|
432
|
+
pidAlive,
|
|
433
|
+
})
|
|
434
|
+
) {
|
|
435
|
+
return { acquired: false, blocked: true, reason: "identity_mismatch" };
|
|
436
|
+
}
|
|
378
437
|
if (rechecked && pidAlive(rechecked.pid)) {
|
|
379
438
|
return { acquired: false, attached: true };
|
|
380
439
|
}
|
|
@@ -552,7 +611,16 @@ export async function spawnTelegramDaemonOwner(
|
|
|
552
611
|
ownerId: ownership.ownerId ?? "",
|
|
553
612
|
agentDir,
|
|
554
613
|
});
|
|
555
|
-
if (!ownership.acquired)
|
|
614
|
+
if (!ownership.acquired) {
|
|
615
|
+
if (ownership.blocked) {
|
|
616
|
+
return {
|
|
617
|
+
result: "blocked",
|
|
618
|
+
runtime,
|
|
619
|
+
warnings: ["live telegram daemon uses a different bot token or chat; refusing to attach"],
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
return { result: "attached", runtime, warnings: [] };
|
|
623
|
+
}
|
|
556
624
|
const spawnImpl = deps.spawn ?? defaultDaemonSpawn;
|
|
557
625
|
const child = spawnImpl(command, args, {
|
|
558
626
|
detached: true,
|
|
@@ -568,13 +636,18 @@ export async function ensureTelegramDaemonRunning(
|
|
|
568
636
|
deps: TelegramDaemonDeps = {},
|
|
569
637
|
): Promise<EnsureDaemonResult> {
|
|
570
638
|
const cfg = getNotificationConfig(input.settings);
|
|
571
|
-
if (!
|
|
572
|
-
const root =
|
|
639
|
+
if (!isTelegramConfigured(cfg)) return "disabled";
|
|
640
|
+
const root = notificationRootForCwd(input.cwd);
|
|
573
641
|
const fp = tokenFingerprint(cfg.botToken);
|
|
574
642
|
const spawned = await spawnTelegramDaemonOwner(
|
|
575
643
|
{ settings: input.settings, roots: [root], tokenFingerprint: fp, chatId: cfg.chatId },
|
|
576
644
|
deps,
|
|
577
645
|
);
|
|
646
|
+
if (spawned.result === "blocked") {
|
|
647
|
+
logger.warn(`notifications: failed to ensure Telegram daemon: ${spawned.warnings.join("; ")}`);
|
|
648
|
+
return spawned.result;
|
|
649
|
+
}
|
|
650
|
+
await registerNotificationRoot({ ...input, fs: deps.fs });
|
|
578
651
|
return spawned.result;
|
|
579
652
|
}
|
|
580
653
|
|
|
@@ -796,6 +869,8 @@ interface PendingThreadedFrame {
|
|
|
796
869
|
export class TelegramNotificationDaemon {
|
|
797
870
|
readonly aliasTable: AliasTable;
|
|
798
871
|
readonly messageRoutes = new Map<string | number, CallbackRoute | Omit<CallbackRoute, "answer">>();
|
|
872
|
+
/** Telegram message id backing each streamed `${sessionId}:${coalesceKey}`, for in-place edits. */
|
|
873
|
+
private readonly liveMessages = new Map<string, number>();
|
|
799
874
|
readonly sessions = new Map<string, SessionSocket>();
|
|
800
875
|
private readonly runtime: NotificationOperatorRuntime;
|
|
801
876
|
private readonly sessionRouter: OperatorEventRouter<SessionSocket>;
|
|
@@ -820,6 +895,8 @@ export class TelegramNotificationDaemon {
|
|
|
820
895
|
private readonly flatIdentitySent = new Set<string>();
|
|
821
896
|
/** Cached result of whether the paired chat is a private chat (flat-fallback gate). */
|
|
822
897
|
private pairedChatPrivate: boolean | undefined;
|
|
898
|
+
/** Bot username from getMe, cached once at owner startup for group/forum command targeting. */
|
|
899
|
+
private botUsername: string | undefined;
|
|
823
900
|
/** Sessions whose agent loop is currently busy (drives the typing indicator). */
|
|
824
901
|
private get busy(): Set<string> {
|
|
825
902
|
return this.dispatchState.busy;
|
|
@@ -1078,8 +1155,10 @@ export class TelegramNotificationDaemon {
|
|
|
1078
1155
|
text: string | undefined,
|
|
1079
1156
|
updateId: number | undefined,
|
|
1080
1157
|
threadId: number | undefined,
|
|
1158
|
+
commandCtx: { chatType?: string; botUsername?: string },
|
|
1081
1159
|
): Promise<boolean> {
|
|
1082
|
-
if (!isLifecycleCommandText(text)) return false;
|
|
1160
|
+
if (!isLifecycleCommandText(text, commandCtx)) return false;
|
|
1161
|
+
if (!(await this.pairedChatIsPrivate())) return true;
|
|
1083
1162
|
const reply = async (body: string): Promise<void> => {
|
|
1084
1163
|
for (const text of splitTelegramPlainText(body)) {
|
|
1085
1164
|
await this.botApi
|
|
@@ -1104,15 +1183,15 @@ export class TelegramNotificationDaemon {
|
|
|
1104
1183
|
}
|
|
1105
1184
|
};
|
|
1106
1185
|
|
|
1186
|
+
const parsed = parseLifecycleCommand(text, commandCtx);
|
|
1187
|
+
if (parsed.kind === "none") return false;
|
|
1107
1188
|
if (!this.lifecycleControlActive) {
|
|
1108
1189
|
await reply("Session lifecycle control is not available right now.");
|
|
1109
1190
|
return true;
|
|
1110
1191
|
}
|
|
1111
1192
|
if (updateId !== undefined && this.dispatchState.seenUpdateIds.has(updateId)) return true;
|
|
1112
|
-
if (updateId !== undefined) this.
|
|
1193
|
+
if (updateId !== undefined) await this.rememberSeenUpdateId(updateId);
|
|
1113
1194
|
|
|
1114
|
-
const parsed = parseLifecycleCommand(text);
|
|
1115
|
-
if (parsed.kind === "none") return false;
|
|
1116
1195
|
if (parsed.kind === "usage" || parsed.kind === "reject") {
|
|
1117
1196
|
await reply(parsed.message);
|
|
1118
1197
|
return true;
|
|
@@ -1145,6 +1224,17 @@ export class TelegramNotificationDaemon {
|
|
|
1145
1224
|
return true;
|
|
1146
1225
|
}
|
|
1147
1226
|
|
|
1227
|
+
private async refreshBotIdentity(): Promise<void> {
|
|
1228
|
+
try {
|
|
1229
|
+
const response = (await this.botApi.call("getMe", {})) as { result?: { username?: unknown } };
|
|
1230
|
+
const username = response.result?.username;
|
|
1231
|
+
this.botUsername =
|
|
1232
|
+
typeof username === "string" && username.trim() ? username.trim().replace(/^@/, "") : undefined;
|
|
1233
|
+
} catch {
|
|
1234
|
+
this.botUsername = undefined;
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1148
1238
|
/** Map a lifecycle response/error to a user-facing message (G010 surfacing). */
|
|
1149
1239
|
private formatLifecycleResponse(r: SessionLifecycleResponse): string {
|
|
1150
1240
|
return formatLifecycleOutcome(r);
|
|
@@ -1247,6 +1337,51 @@ export class TelegramNotificationDaemon {
|
|
|
1247
1337
|
await writeJsonAtomic(this.fsImpl, paths.aliases, this.aliasTable.serialize());
|
|
1248
1338
|
}
|
|
1249
1339
|
|
|
1340
|
+
async loadSeenUpdateIds(): Promise<void> {
|
|
1341
|
+
const raw = await readJson<{ updateIds?: unknown }>(
|
|
1342
|
+
this.fsImpl,
|
|
1343
|
+
daemonPaths(this.opts.settings.getAgentDir()).seenUpdates,
|
|
1344
|
+
);
|
|
1345
|
+
this.dispatchState.seenUpdateIds.clear();
|
|
1346
|
+
const updateIds = Array.isArray(raw?.updateIds) ? raw.updateIds : [];
|
|
1347
|
+
for (const updateId of updateIds) {
|
|
1348
|
+
if (Number.isSafeInteger(updateId) && Number(updateId) >= 0) {
|
|
1349
|
+
this.dispatchState.seenUpdateIds.add(Number(updateId));
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
this.pruneSeenUpdateIds();
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
async persistSeenUpdateIds(): Promise<void> {
|
|
1356
|
+
const paths = daemonPaths(this.opts.settings.getAgentDir());
|
|
1357
|
+
await ensureDir(this.fsImpl, paths.dir);
|
|
1358
|
+
await writeJsonAtomic(this.fsImpl, paths.seenUpdates, {
|
|
1359
|
+
version: 1,
|
|
1360
|
+
updateIds: [...this.dispatchState.seenUpdateIds].slice(-SEEN_UPDATE_ID_LIMIT),
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
private pruneSeenUpdateIds(): void {
|
|
1365
|
+
let extra = this.dispatchState.seenUpdateIds.size - SEEN_UPDATE_ID_LIMIT;
|
|
1366
|
+
if (extra <= 0) return;
|
|
1367
|
+
for (const updateId of this.dispatchState.seenUpdateIds) {
|
|
1368
|
+
this.dispatchState.seenUpdateIds.delete(updateId);
|
|
1369
|
+
extra -= 1;
|
|
1370
|
+
if (extra <= 0) break;
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
private async rememberSeenUpdateId(updateId: number): Promise<void> {
|
|
1375
|
+
if (!Number.isSafeInteger(updateId) || updateId < 0) return;
|
|
1376
|
+
this.dispatchState.seenUpdateIds.add(updateId);
|
|
1377
|
+
this.pruneSeenUpdateIds();
|
|
1378
|
+
try {
|
|
1379
|
+
await this.persistSeenUpdateIds();
|
|
1380
|
+
} catch (err) {
|
|
1381
|
+
logger.warn(`notifications: failed to persist Telegram update id ${updateId}: ${String(err)}`);
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1250
1385
|
async scanRoots(): Promise<void> {
|
|
1251
1386
|
const paths = daemonPaths(this.opts.settings.getAgentDir());
|
|
1252
1387
|
const rootState = await readJson<{ roots?: string[] }>(this.fsImpl, paths.roots);
|
|
@@ -1366,13 +1501,17 @@ export class TelegramNotificationDaemon {
|
|
|
1366
1501
|
* (so a delayed old close cannot delete a replacement), and best-effort closes
|
|
1367
1502
|
* the socket. `scanRoots()` then reconnects the session.
|
|
1368
1503
|
*/
|
|
1369
|
-
private dropSession(session: SessionSocket,
|
|
1504
|
+
private dropSession(session: SessionSocket, reason: string): void {
|
|
1370
1505
|
const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
|
|
1371
1506
|
if (session.pingTimer) {
|
|
1372
1507
|
clearIntervalImpl(session.pingTimer);
|
|
1373
1508
|
session.pingTimer = undefined;
|
|
1374
1509
|
}
|
|
1375
|
-
|
|
1510
|
+
const isCurrentSession = this.sessions.get(session.sessionId) === session;
|
|
1511
|
+
if (isCurrentSession || reason === "session_closed") {
|
|
1512
|
+
this.deleteMessageRoutes(session.sessionId);
|
|
1513
|
+
}
|
|
1514
|
+
if (isCurrentSession) {
|
|
1376
1515
|
this.sessions.delete(session.sessionId);
|
|
1377
1516
|
}
|
|
1378
1517
|
if (session.ws.readyState !== WebSocket.CLOSED) {
|
|
@@ -1382,6 +1521,14 @@ export class TelegramNotificationDaemon {
|
|
|
1382
1521
|
}
|
|
1383
1522
|
}
|
|
1384
1523
|
|
|
1524
|
+
private deleteMessageRoutes(sessionId: string, actionId?: string): void {
|
|
1525
|
+
for (const [messageId, route] of this.messageRoutes.entries()) {
|
|
1526
|
+
if (route.sessionId === sessionId && (actionId === undefined || route.actionId === actionId)) {
|
|
1527
|
+
this.messageRoutes.delete(messageId);
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1385
1532
|
private static readonly THREADED_FRAMES = new Set([
|
|
1386
1533
|
"identity_header",
|
|
1387
1534
|
"context_update",
|
|
@@ -1434,6 +1581,13 @@ export class TelegramNotificationDaemon {
|
|
|
1434
1581
|
return undefined;
|
|
1435
1582
|
}
|
|
1436
1583
|
|
|
1584
|
+
private sessionCanClaimIdentity(session: SessionSocket, msg: { repo?: unknown; branch?: unknown }): boolean {
|
|
1585
|
+
const current = this.sessions.get(session.sessionId);
|
|
1586
|
+
if (current) return current === session;
|
|
1587
|
+
const ownerId = this.topicOwnerForIdentity(msg);
|
|
1588
|
+
return !ownerId || ownerId === session.sessionId;
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1437
1591
|
private async submitThreadedFrame(sessionId: string, send: ThreadedSend, topicId: string): Promise<void> {
|
|
1438
1592
|
this.pool.submit({
|
|
1439
1593
|
sessionId,
|
|
@@ -1444,6 +1598,11 @@ export class TelegramNotificationDaemon {
|
|
|
1444
1598
|
await this.flushPool();
|
|
1445
1599
|
}
|
|
1446
1600
|
|
|
1601
|
+
private async existingTopicForPrivateChat(sessionId: string): Promise<string | undefined> {
|
|
1602
|
+
if (!(await this.pairedChatIsPrivate())) return undefined;
|
|
1603
|
+
return this.topics.get(sessionId)?.topicId;
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1447
1606
|
private rememberPendingThreadedFrame(sessionId: string, send: ThreadedSend, msg: Record<string, unknown>): void {
|
|
1448
1607
|
const frames = this.pendingThreadedFrames.get(sessionId) ?? [];
|
|
1449
1608
|
frames.push({ send, msg });
|
|
@@ -1465,6 +1624,7 @@ export class TelegramNotificationDaemon {
|
|
|
1465
1624
|
* one-time nudge) or drop fail-closed for a non-private chat.
|
|
1466
1625
|
*/
|
|
1467
1626
|
private async ensureTopic(sessionId: string, name: string): Promise<string | undefined> {
|
|
1627
|
+
if (!(await this.pairedChatIsPrivate())) return undefined;
|
|
1468
1628
|
const existing = this.topics.get(sessionId);
|
|
1469
1629
|
if (existing) return existing.topicId;
|
|
1470
1630
|
try {
|
|
@@ -1508,6 +1668,9 @@ export class TelegramNotificationDaemon {
|
|
|
1508
1668
|
})) as { ok?: boolean };
|
|
1509
1669
|
if (res?.ok === false) return;
|
|
1510
1670
|
this.topics.delete(sessionId);
|
|
1671
|
+
for (const k of [...this.liveMessages.keys()]) {
|
|
1672
|
+
if (k.startsWith(`${sessionId}:`)) this.liveMessages.delete(k);
|
|
1673
|
+
}
|
|
1511
1674
|
this.topicOwnerByIdentity.forEach((ownerSessionId, identityKey) => {
|
|
1512
1675
|
if (ownerSessionId === sessionId) this.topicOwnerByIdentity.delete(identityKey);
|
|
1513
1676
|
});
|
|
@@ -1637,10 +1800,25 @@ export class TelegramNotificationDaemon {
|
|
|
1637
1800
|
|
|
1638
1801
|
/** Drain the shared rate-limit pool and deliver each granted send to its topic. */
|
|
1639
1802
|
private async flushPool(): Promise<void> {
|
|
1640
|
-
|
|
1803
|
+
const batch = this.pool.drain();
|
|
1804
|
+
// Within a batch a finalized frame supersedes any still-queued live frame for
|
|
1805
|
+
// the same streamed message (finalized outranks live), so drop the stale live
|
|
1806
|
+
// edit — otherwise the authoritative final text could be overwritten by an
|
|
1807
|
+
// older partial delivered right after it.
|
|
1808
|
+
const finalizedKeys = new Set<string>();
|
|
1809
|
+
for (const item of batch) {
|
|
1810
|
+
if (item.lane === "finalized" && item.coalesceKey !== undefined) {
|
|
1811
|
+
finalizedKeys.add(`${item.sessionId}:${item.coalesceKey}`);
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
for (const item of batch) {
|
|
1641
1815
|
const { send, topicId } = item.payload;
|
|
1816
|
+
if (topicId && !(await this.pairedChatIsPrivate())) continue;
|
|
1642
1817
|
// Threaded topic when available; otherwise deliver flat to the paired chat.
|
|
1643
1818
|
const threadField = topicId ? { message_thread_id: Number(topicId) } : {};
|
|
1819
|
+
const ckey = send.editable ? item.coalesceKey : undefined;
|
|
1820
|
+
const editKey = ckey !== undefined ? `${item.sessionId}:${ckey}` : undefined;
|
|
1821
|
+
if (item.lane === "live" && editKey && finalizedKeys.has(editKey)) continue;
|
|
1644
1822
|
try {
|
|
1645
1823
|
if (send.method === "sendPhoto" && send.photoBase64) {
|
|
1646
1824
|
// Real photo upload (the default botApi multiparts base64 -> file).
|
|
@@ -1663,21 +1841,79 @@ export class TelegramNotificationDaemon {
|
|
|
1663
1841
|
parse_mode: TELEGRAM_PARSE_MODE,
|
|
1664
1842
|
});
|
|
1665
1843
|
} else if (send.text) {
|
|
1666
|
-
|
|
1667
|
-
|
|
1844
|
+
const chunks = splitTelegramHtml(send.text);
|
|
1845
|
+
const existingId = editKey ? this.liveMessages.get(editKey) : undefined;
|
|
1846
|
+
if (editKey && existingId !== undefined && chunks.length === 1) {
|
|
1847
|
+
// In-place edit of the streamed message. An unchanged edit is
|
|
1848
|
+
// rejected by Telegram ("message is not modified") and swallowed.
|
|
1849
|
+
await this.botApi.call("editMessageText", {
|
|
1668
1850
|
chat_id: this.opts.chatId,
|
|
1669
|
-
|
|
1670
|
-
text,
|
|
1851
|
+
message_id: existingId,
|
|
1852
|
+
text: chunks[0],
|
|
1671
1853
|
parse_mode: TELEGRAM_PARSE_MODE,
|
|
1672
1854
|
});
|
|
1855
|
+
} else {
|
|
1856
|
+
// A single granted slot MUST map to a single Telegram send. When the
|
|
1857
|
+
// rendered text splits into multiple chunks (e.g. a long finalized
|
|
1858
|
+
// turn raised via SKC_NOTIFICATIONS_TURN_MAX), deliver the first
|
|
1859
|
+
// chunk on this token and re-submit the remaining chunks as their
|
|
1860
|
+
// own pool items so each consumes a token on a later drain.
|
|
1861
|
+
// Otherwise one frame would fan out into many sends against a single
|
|
1862
|
+
// slot, bypassing the per-chat rate-limit / fairness invariant.
|
|
1863
|
+
const res = (await this.botApi.call("sendMessage", {
|
|
1864
|
+
chat_id: this.opts.chatId,
|
|
1865
|
+
...threadField,
|
|
1866
|
+
text: chunks[0]!,
|
|
1867
|
+
parse_mode: TELEGRAM_PARSE_MODE,
|
|
1868
|
+
})) as { result?: { message_id?: number } };
|
|
1869
|
+
for (let i = 1; i < chunks.length; i++) {
|
|
1870
|
+
// Continuation chunks are fresh, non-editable text sends: no
|
|
1871
|
+
// coalesce key (they neither replace nor are replaced by other
|
|
1872
|
+
// frames) and no media payload.
|
|
1873
|
+
this.pool.submit({
|
|
1874
|
+
sessionId: item.sessionId,
|
|
1875
|
+
lane: item.lane,
|
|
1876
|
+
payload: {
|
|
1877
|
+
send: {
|
|
1878
|
+
...send,
|
|
1879
|
+
method: "sendMessage",
|
|
1880
|
+
text: chunks[i]!,
|
|
1881
|
+
editable: false,
|
|
1882
|
+
coalesceKey: undefined,
|
|
1883
|
+
photoBase64: undefined,
|
|
1884
|
+
documentBase64: undefined,
|
|
1885
|
+
},
|
|
1886
|
+
topicId,
|
|
1887
|
+
},
|
|
1888
|
+
});
|
|
1889
|
+
}
|
|
1890
|
+
const firstMessageId = res?.result?.message_id;
|
|
1891
|
+
if (editKey && ckey !== undefined && firstMessageId !== undefined) {
|
|
1892
|
+
this.recordLiveMessage(item.sessionId, ckey, firstMessageId);
|
|
1893
|
+
}
|
|
1673
1894
|
}
|
|
1674
1895
|
}
|
|
1675
1896
|
} catch {
|
|
1676
|
-
// Best-effort: a failed send must never stop the daemon.
|
|
1897
|
+
// Best-effort: a failed send/edit must never stop the daemon.
|
|
1677
1898
|
}
|
|
1678
1899
|
}
|
|
1679
1900
|
}
|
|
1680
1901
|
|
|
1902
|
+
/**
|
|
1903
|
+
* Track the Telegram message id backing a streamed `(sessionId, coalesceKey)`
|
|
1904
|
+
* so later live/finalized frames edit it in place. Evicts this session's stale
|
|
1905
|
+
* same-category entries (e.g. prior turns) so the map stays bounded.
|
|
1906
|
+
*/
|
|
1907
|
+
private recordLiveMessage(sessionId: string, coalesceKey: string, messageId: number): void {
|
|
1908
|
+
const mapKey = `${sessionId}:${coalesceKey}`;
|
|
1909
|
+
const category = coalesceKey.split(":")[0] ?? "";
|
|
1910
|
+
const prefix = `${sessionId}:${category}:`;
|
|
1911
|
+
for (const k of [...this.liveMessages.keys()]) {
|
|
1912
|
+
if (k !== mapKey && k.startsWith(prefix)) this.liveMessages.delete(k);
|
|
1913
|
+
}
|
|
1914
|
+
this.liveMessages.set(mapKey, messageId);
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1681
1917
|
/**
|
|
1682
1918
|
* Threaded Mode is unavailable (the bot owner has not enabled forum topics in
|
|
1683
1919
|
* @BotFather, so `createForumTopic` fails). Deliver the rendered frame flat to
|
|
@@ -1697,9 +1933,10 @@ export class TelegramNotificationDaemon {
|
|
|
1697
1933
|
}
|
|
1698
1934
|
|
|
1699
1935
|
/**
|
|
1700
|
-
* Resolve
|
|
1701
|
-
*
|
|
1702
|
-
*
|
|
1936
|
+
* Resolve (and cache successful resolution of) whether the paired `chatId` is a
|
|
1937
|
+
* private chat. Topic and flat delivery are only safe in a private DM; any
|
|
1938
|
+
* non-private chat fails closed, while a transient `getChat` failure fails closed
|
|
1939
|
+
* for the current attempt and is retried later.
|
|
1703
1940
|
*/
|
|
1704
1941
|
private async pairedChatIsPrivate(): Promise<boolean> {
|
|
1705
1942
|
if (this.pairedChatPrivate !== undefined) return this.pairedChatPrivate;
|
|
@@ -1708,15 +1945,16 @@ export class TelegramNotificationDaemon {
|
|
|
1708
1945
|
result?: { type?: string };
|
|
1709
1946
|
};
|
|
1710
1947
|
this.pairedChatPrivate = res.result?.type === "private";
|
|
1711
|
-
|
|
1712
|
-
|
|
1948
|
+
return this.pairedChatPrivate;
|
|
1949
|
+
} catch (e) {
|
|
1950
|
+
logger.warn(`notifications: getChat failed while checking Telegram chat privacy: ${String(e)}`);
|
|
1951
|
+
return false;
|
|
1713
1952
|
}
|
|
1714
|
-
return this.pairedChatPrivate;
|
|
1715
1953
|
}
|
|
1716
1954
|
|
|
1717
1955
|
/** Tell the user once (per daemon run) how to enable Threaded Mode. */
|
|
1718
1956
|
private async notifyThreadedFallback(): Promise<void> {
|
|
1719
|
-
if (this.threadedFallbackNoticeSent) return;
|
|
1957
|
+
if (this.threadedFallbackNoticeSent || !(await this.pairedChatIsPrivate())) return;
|
|
1720
1958
|
this.threadedFallbackNoticeSent = true;
|
|
1721
1959
|
try {
|
|
1722
1960
|
await this.botApi.call("sendMessage", {
|
|
@@ -1761,7 +1999,7 @@ export class TelegramNotificationDaemon {
|
|
|
1761
1999
|
/** Send a single `typing` chat action into a busy session's topic (best-effort). */
|
|
1762
2000
|
private async sendTyping(sessionId: string): Promise<void> {
|
|
1763
2001
|
const topicId = this.topics.get(sessionId)?.topicId;
|
|
1764
|
-
if (!topicId) return;
|
|
2002
|
+
if (!topicId || !(await this.pairedChatIsPrivate())) return;
|
|
1765
2003
|
try {
|
|
1766
2004
|
await this.botApi.call("sendChatAction", {
|
|
1767
2005
|
chat_id: this.opts.chatId,
|
|
@@ -1775,6 +2013,7 @@ export class TelegramNotificationDaemon {
|
|
|
1775
2013
|
|
|
1776
2014
|
/** Set a native reaction on an inbound thread message (best-effort). */
|
|
1777
2015
|
private async setReaction(messageId: number, emoji: string): Promise<void> {
|
|
2016
|
+
if (!(await this.pairedChatIsPrivate())) return;
|
|
1778
2017
|
try {
|
|
1779
2018
|
await this.botApi.call("setMessageReaction", {
|
|
1780
2019
|
chat_id: this.opts.chatId,
|
|
@@ -1802,12 +2041,12 @@ export class TelegramNotificationDaemon {
|
|
|
1802
2041
|
if (typeof msg?.type === "string" && TelegramNotificationDaemon.THREADED_FRAMES.has(msg.type)) {
|
|
1803
2042
|
const send = renderThreadedFrame(msg);
|
|
1804
2043
|
if (!send) return;
|
|
1805
|
-
const existingTopic = this.
|
|
2044
|
+
const existingTopic = await this.existingTopicForPrivateChat(session.sessionId);
|
|
1806
2045
|
if (!send.identity && !existingTopic && !this.flatIdentitySent.has(session.sessionId)) {
|
|
1807
2046
|
this.rememberPendingThreadedFrame(session.sessionId, send, msg as Record<string, unknown>);
|
|
1808
2047
|
return;
|
|
1809
2048
|
}
|
|
1810
|
-
if (send.identity) {
|
|
2049
|
+
if (send.identity && !this.sessionCanClaimIdentity(session, msg)) {
|
|
1811
2050
|
const ownerId = this.topicOwnerForIdentity(msg);
|
|
1812
2051
|
const ownerTopic = ownerId ? this.topics.get(ownerId) : undefined;
|
|
1813
2052
|
if (ownerId && ownerId !== session.sessionId && ownerTopic) {
|
|
@@ -1890,6 +2129,7 @@ export class TelegramNotificationDaemon {
|
|
|
1890
2129
|
await this.persistAliases();
|
|
1891
2130
|
} else if (msg.type === "action_resolved" && msg.id) {
|
|
1892
2131
|
session.pending.delete(msg.id);
|
|
2132
|
+
this.deleteMessageRoutes(session.sessionId, msg.id);
|
|
1893
2133
|
for (const [alias, route] of this.aliasTable.entries()) {
|
|
1894
2134
|
if (route.sessionId === session.sessionId && route.actionId === msg.id) this.aliasTable.delete(alias);
|
|
1895
2135
|
}
|
|
@@ -1897,19 +2137,22 @@ export class TelegramNotificationDaemon {
|
|
|
1897
2137
|
}
|
|
1898
2138
|
}
|
|
1899
2139
|
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
2140
|
+
private async answerCallbackQueryBestEffort(callbackId: unknown, text?: string): Promise<void> {
|
|
2141
|
+
if (typeof callbackId !== "string") return;
|
|
2142
|
+
try {
|
|
2143
|
+
await this.botApi.call("answerCallbackQuery", {
|
|
2144
|
+
callback_query_id: callbackId,
|
|
2145
|
+
...(text === undefined ? {} : { text }),
|
|
2146
|
+
});
|
|
2147
|
+
} catch {
|
|
2148
|
+
// Telegram callback acknowledgements only dismiss the client-side spinner;
|
|
2149
|
+
// they must never block the already-validated local reply path.
|
|
1905
2150
|
}
|
|
1906
|
-
|
|
1907
|
-
};
|
|
2151
|
+
}
|
|
1908
2152
|
|
|
1909
2153
|
private async sendStaleGuidance(callbackId: unknown): Promise<void> {
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
}
|
|
2154
|
+
await this.answerCallbackQueryBestEffort(callbackId, "Button is stale");
|
|
2155
|
+
if (!(await this.pairedChatIsPrivate())) return;
|
|
1913
2156
|
await this.botApi.call("sendMessage", {
|
|
1914
2157
|
chat_id: this.opts.chatId,
|
|
1915
2158
|
text: "This button is stale after notification daemon restart. Please answer locally in the SKC session or wait for a fresh notification.",
|
|
@@ -1924,12 +2167,18 @@ export class TelegramNotificationDaemon {
|
|
|
1924
2167
|
// session input.
|
|
1925
2168
|
{
|
|
1926
2169
|
const m = (update as { update_id?: number; message?: Record<string, unknown> }).message;
|
|
1927
|
-
const
|
|
2170
|
+
const chat = m?.chat as { id?: unknown; type?: unknown } | undefined;
|
|
2171
|
+
const chatId = chat?.id;
|
|
2172
|
+
const chatType = typeof chat?.type === "string" ? chat.type : undefined;
|
|
1928
2173
|
const cmdText = typeof m?.text === "string" ? m.text : undefined;
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
if (
|
|
2174
|
+
const commandCtx = { chatType, botUsername: this.botUsername };
|
|
2175
|
+
if (m !== undefined && String(chatId) === String(this.opts.chatId)) {
|
|
2176
|
+
if (chatType !== undefined && chatType !== "private" && isLifecycleCommandLikeText(cmdText)) return;
|
|
2177
|
+
if (isLifecycleCommandText(cmdText, commandCtx)) {
|
|
2178
|
+
const updateId = (update as { update_id?: number }).update_id;
|
|
2179
|
+
const threadId = typeof m.message_thread_id === "number" ? (m.message_thread_id as number) : undefined;
|
|
2180
|
+
if (await this.handleLifecycleCommand(cmdText, updateId, threadId, commandCtx)) return;
|
|
2181
|
+
}
|
|
1933
2182
|
}
|
|
1934
2183
|
}
|
|
1935
2184
|
// Threaded injection: a free-text message in a known topic (not a button
|
|
@@ -1954,7 +2203,6 @@ export class TelegramNotificationDaemon {
|
|
|
1954
2203
|
});
|
|
1955
2204
|
if (inbound.kind === "duplicate") return;
|
|
1956
2205
|
if (inbound.kind === "inject") {
|
|
1957
|
-
this.dispatchState.seenUpdateIds.add(inbound.updateId);
|
|
1958
2206
|
const session = this.sessions.get(inbound.sessionId);
|
|
1959
2207
|
if (session?.ws.readyState === WebSocket.OPEN) {
|
|
1960
2208
|
const attachmentResult = inbound.attachment
|
|
@@ -1979,6 +2227,7 @@ export class TelegramNotificationDaemon {
|
|
|
1979
2227
|
token: session.token,
|
|
1980
2228
|
}),
|
|
1981
2229
|
);
|
|
2230
|
+
await this.rememberSeenUpdateId(inbound.updateId);
|
|
1982
2231
|
if (inbound.messageId !== undefined) await this.setReaction(inbound.messageId, QUEUED_REACTION);
|
|
1983
2232
|
return;
|
|
1984
2233
|
}
|
|
@@ -1997,6 +2246,7 @@ export class TelegramNotificationDaemon {
|
|
|
1997
2246
|
},
|
|
1998
2247
|
),
|
|
1999
2248
|
);
|
|
2249
|
+
await this.rememberSeenUpdateId(inbound.updateId);
|
|
2000
2250
|
// User turns get a native delivery double-check: queued on receipt,
|
|
2001
2251
|
// flipped to consumed when the session acks the turn that picks it
|
|
2002
2252
|
// up. Config commands are not user turns and get no reaction.
|
|
@@ -2012,7 +2262,6 @@ export class TelegramNotificationDaemon {
|
|
|
2012
2262
|
const decision = routeInboundUpdate(update, {
|
|
2013
2263
|
aliasTable: this.aliasTable,
|
|
2014
2264
|
messageRoutes: this.messageRoutes,
|
|
2015
|
-
pendingBySession: this.pendingBySession,
|
|
2016
2265
|
pairedChatId: this.opts.chatId,
|
|
2017
2266
|
});
|
|
2018
2267
|
if (decision.kind === "reply") {
|
|
@@ -2021,11 +2270,10 @@ export class TelegramNotificationDaemon {
|
|
|
2021
2270
|
await this.sendStaleGuidance(callbackId);
|
|
2022
2271
|
return;
|
|
2023
2272
|
}
|
|
2024
|
-
if (typeof callbackId === "string")
|
|
2025
|
-
await this.botApi.call("answerCallbackQuery", { callback_query_id: callbackId });
|
|
2026
2273
|
session.ws.send(
|
|
2027
2274
|
JSON.stringify({ type: "reply", id: decision.actionId, answer: decision.answer, token: session.token }),
|
|
2028
2275
|
);
|
|
2276
|
+
await this.answerCallbackQueryBestEffort(callbackId);
|
|
2029
2277
|
} else if (decision.kind === "stale") {
|
|
2030
2278
|
await this.sendStaleGuidance(callbackId);
|
|
2031
2279
|
}
|
|
@@ -2068,9 +2316,11 @@ export class TelegramNotificationDaemon {
|
|
|
2068
2316
|
this.startScanTimer();
|
|
2069
2317
|
this.startTypingTimer();
|
|
2070
2318
|
try {
|
|
2319
|
+
await this.refreshBotIdentity();
|
|
2071
2320
|
await this.registerBotCommands();
|
|
2072
2321
|
await this.loadAliases();
|
|
2073
2322
|
await this.loadTopics();
|
|
2323
|
+
await this.loadSeenUpdateIds();
|
|
2074
2324
|
await this.runScan();
|
|
2075
2325
|
// Owner-only: start the session-lifecycle control server now that
|
|
2076
2326
|
// ownership is confirmed (singleton-safe). Best-effort; degrades.
|
|
@@ -2131,6 +2381,7 @@ export class TelegramNotificationDaemon {
|
|
|
2131
2381
|
// (e.g. after reload) reloads aliases/topics seamlessly.
|
|
2132
2382
|
await this.persistAliases().catch(() => undefined);
|
|
2133
2383
|
await this.persistTopics().catch(() => undefined);
|
|
2384
|
+
await this.persistSeenUpdateIds().catch(() => undefined);
|
|
2134
2385
|
await this.opts.control?.clear?.(this.opts.ownerId).catch(() => undefined);
|
|
2135
2386
|
await releaseDaemonOwnership({
|
|
2136
2387
|
settings: this.opts.settings,
|