switchroom 0.18.11 → 0.18.13
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/agent-scheduler/index.js +37 -5
- package/dist/auth-broker/index.js +116 -78
- package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
- package/dist/cli/ms-365-write-pretool.mjs +31 -8
- package/dist/cli/notion-write-pretool.mjs +38 -6
- package/dist/cli/skill-validate-pretool.mjs +144 -2847
- package/dist/cli/switchroom.js +3529 -4543
- package/dist/cli/ui/index.html +163 -17
- package/dist/host-control/main.js +605 -2847
- package/dist/vault/approvals/kernel-server.js +120 -13
- package/dist/vault/broker/server.js +259 -157
- package/package.json +3 -4
- package/profiles/_base/start.sh.hbs +65 -0
- package/profiles/_shared/vault-protocol.md.hbs +3 -1
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/bridge/bridge.ts +37 -0
- package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
- package/telegram-plugin/dist/bridge/bridge.js +122 -4
- package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
- package/telegram-plugin/dist/server.js +139 -5
- package/telegram-plugin/flood-circuit-breaker.ts +493 -21
- package/telegram-plugin/format.ts +19 -0
- package/telegram-plugin/gateway/approval-hold.ts +602 -0
- package/telegram-plugin/gateway/auth-command.ts +92 -2
- package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
- package/telegram-plugin/gateway/boot-card.ts +12 -5
- package/telegram-plugin/gateway/callback-query-handlers.ts +88 -1
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
- package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
- package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
- package/telegram-plugin/gateway/gateway.ts +1695 -230
- package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
- package/telegram-plugin/gateway/idle-clear.ts +90 -6
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
- package/telegram-plugin/gateway/inject-handler.ts +8 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
- package/telegram-plugin/gateway/ipc-server.ts +43 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
- package/telegram-plugin/gateway/model-command.ts +9 -3
- package/telegram-plugin/gateway/pending-session-command.ts +13 -1
- package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
- package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
- package/telegram-plugin/gateway/queued-card-store.ts +217 -0
- package/telegram-plugin/gateway/session-model-file.ts +26 -1
- package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
- package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
- package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
- package/telegram-plugin/history.ts +51 -0
- package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
- package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
- package/telegram-plugin/hooks/hooks.json +9 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +303 -2
- package/telegram-plugin/model-unavailable.ts +41 -11
- package/telegram-plugin/operator-events.ts +23 -0
- package/telegram-plugin/outbound-field-redact.ts +69 -0
- package/telegram-plugin/package.json +0 -1
- package/telegram-plugin/permission-rule.ts +1 -0
- package/telegram-plugin/permission-title.ts +1 -0
- package/telegram-plugin/render/render.ts +32 -14
- package/telegram-plugin/retry-api-call.ts +212 -2
- package/telegram-plugin/scoped-approval.ts +11 -2
- package/telegram-plugin/secret-detect/chunker.ts +18 -4
- package/telegram-plugin/secret-detect/index.ts +12 -56
- package/telegram-plugin/send-gate-degraded.test.ts +574 -0
- package/telegram-plugin/send-gate-observability.test.ts +470 -0
- package/telegram-plugin/send-gate-observability.ts +355 -0
- package/telegram-plugin/send-gate.test.ts +717 -0
- package/telegram-plugin/send-gate.ts +1056 -0
- package/telegram-plugin/session-tail.ts +82 -7
- package/telegram-plugin/shared/bot-runtime.ts +17 -5
- package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
- package/telegram-plugin/status-pin-driver.ts +52 -7
- package/telegram-plugin/status-pin.ts +81 -0
- package/telegram-plugin/subagent-watcher.ts +173 -18
- package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
- package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +327 -0
- package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
- package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
- package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
- package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
- package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
- package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
- package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
- package/telegram-plugin/tests/history.test.ts +115 -0
- package/telegram-plugin/tests/idle-clear.test.ts +233 -3
- package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
- package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -0
- package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
- package/telegram-plugin/tests/model-command.test.ts +14 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
- package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
- package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
- package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
- package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
- package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
- package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
- package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
- package/telegram-plugin/tests/render/render.test.ts +88 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
- package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
- package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
- package/telegram-plugin/tests/session-model-file.test.ts +50 -0
- package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
- package/telegram-plugin/tests/status-pin.test.ts +275 -1
- package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
- package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
- package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
- package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
- package/telegram-plugin/typing-emitter.ts +224 -0
- package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
- package/telegram-plugin/welcome-text.ts +42 -0
- package/telegram-plugin/worktree-watch-cwds.ts +194 -5
- package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
- package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
- package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
- package/vendor/hindsight-memory/scripts/session_end.py +14 -3
- package/vendor/hindsight-memory/scripts/session_start.py +21 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
- package/vendor/hindsight-memory/tests/test_pending.py +44 -0
- package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
- package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
- package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
- package/telegram-plugin/channel-envelope-safety.ts +0 -56
- package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
- package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
|
@@ -83,6 +83,7 @@ import { getAuthBrokerClient } from './auth-broker-client.js'
|
|
|
83
83
|
import { chatKey } from './chat-key.js'
|
|
84
84
|
import { tryHostdDispatch, hostdRequestId } from './hostd-dispatch.js'
|
|
85
85
|
import type { HostdRequest } from '../../src/host-control/protocol.js'
|
|
86
|
+
import type { OperatorEvent } from '../operator-events.js'
|
|
86
87
|
import type { InboundMessage } from './ipc-protocol.js'
|
|
87
88
|
import type { SweepableCardStore } from './approval-card-stores.js'
|
|
88
89
|
import type { SweepableStore } from './pending-state-stores.js'
|
|
@@ -389,6 +390,13 @@ export interface CallbackQueryHandlersDeps {
|
|
|
389
390
|
vaultKeyRegex: RegExp
|
|
390
391
|
/** MENTAL_MODEL_PROPOSE_TTL_MS (config-driven approval-card lifetime). */
|
|
391
392
|
mentalModelProposeTtlMs: number
|
|
393
|
+
/**
|
|
394
|
+
* Emit an operator-visible event through the gateway's single funnel
|
|
395
|
+
* (`emitGatewayOperatorEvent`: cooldown + record + broadcast). Used by the
|
|
396
|
+
* #2975 Stage-1 backstop to loudly surface an approved mental-model persist
|
|
397
|
+
* whose rate-window retry also failed.
|
|
398
|
+
*/
|
|
399
|
+
emitOperatorEvent: (event: OperatorEvent) => void
|
|
392
400
|
}
|
|
393
401
|
|
|
394
402
|
// Freshness throttle for the /auth dashboard ↻ refresh button — one live
|
|
@@ -435,6 +443,7 @@ export function createCallbackQueryHandlers(deps: CallbackQueryHandlersDeps) {
|
|
|
435
443
|
getAdminOnlyKeys,
|
|
436
444
|
vaultKeyRegex: VAULT_KEY_REGEX,
|
|
437
445
|
mentalModelProposeTtlMs: MENTAL_MODEL_PROPOSE_TTL_MS,
|
|
446
|
+
emitOperatorEvent,
|
|
438
447
|
} = deps
|
|
439
448
|
const bot = deps.bot as CallbackBotApi
|
|
440
449
|
const lockedBot = deps.lockedBot as CallbackBotApi
|
|
@@ -1035,7 +1044,20 @@ async function handleMentalModelProposeCallback(ctx: Context, data: string): Pro
|
|
|
1035
1044
|
return { state: 'error' as const, reason: 'hostd config-edit is not configured (host_control disabled or socket absent)' }
|
|
1036
1045
|
}
|
|
1037
1046
|
if (resp.result === 'completed') return { state: 'applied' as const }
|
|
1038
|
-
if (resp.result === 'denied')
|
|
1047
|
+
if (resp.result === 'denied') {
|
|
1048
|
+
// #2975 Stage 1 — a rate-limit denial carries a structured
|
|
1049
|
+
// `fix.retry_after` (hostd server.ts ~2270-2280). Surface it as a
|
|
1050
|
+
// distinct `rate_limited` state so the resolver schedules ONE retry
|
|
1051
|
+
// at the window-open time instead of dropping the approved change.
|
|
1052
|
+
const env = resp.error_envelope
|
|
1053
|
+
if (env?.code === 'E_RATE_LIMITED' && env.fix?.kind === 'retry_after') {
|
|
1054
|
+
const retryAtMs = Date.parse(env.fix.retry_at)
|
|
1055
|
+
if (!Number.isNaN(retryAtMs)) {
|
|
1056
|
+
return { state: 'rate_limited' as const, reason: resp.error ?? 'config_propose_edit rate limit exceeded', retryAtMs }
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
return { state: 'denied' as const, reason: resp.error ?? 'operator/host denied the edit' }
|
|
1060
|
+
}
|
|
1039
1061
|
return { state: 'error' as const, reason: resp.error ?? `hostd returned '${resp.result}'` }
|
|
1040
1062
|
},
|
|
1041
1063
|
// Ensure is delegated to reconcile: config_propose_edit's apply triggers a
|
|
@@ -1046,6 +1068,52 @@ async function handleMentalModelProposeCallback(ctx: Context, data: string): Pro
|
|
|
1046
1068
|
injectInbound: (inbound: InboundMessage) => {
|
|
1047
1069
|
deliverResumeSyntheticOrBuffer(pending.agent, inbound)
|
|
1048
1070
|
},
|
|
1071
|
+
// #2975 Stage 1 — schedule EXACTLY ONE re-dispatch of a rate-limited but
|
|
1072
|
+
// operator-approved persist. A bare setTimeout (bounded, single-shot; the
|
|
1073
|
+
// resolver never re-schedules). Stage-1 caveat: this timer lives only in
|
|
1074
|
+
// gateway memory, so a gateway restart before it fires LOSES the retry —
|
|
1075
|
+
// acceptable for Stage 1 (Stage 2's hostd checkPreApproved bypass removes
|
|
1076
|
+
// the collision). Clamp the delay to a signed-32-bit setTimeout ceiling so
|
|
1077
|
+
// a far-future window doesn't overflow into an immediate fire.
|
|
1078
|
+
scheduleRetry: (delayMs: number, fn: () => void | Promise<void>) => {
|
|
1079
|
+
const clamped = Math.min(Math.max(0, delayMs), 2_147_483_647)
|
|
1080
|
+
setTimeout(() => {
|
|
1081
|
+
void (async () => {
|
|
1082
|
+
try {
|
|
1083
|
+
await fn()
|
|
1084
|
+
} catch (err) {
|
|
1085
|
+
process.stderr.write(
|
|
1086
|
+
`telegram gateway: mental_model_propose retry threw: ${(err as Error).message}\n`,
|
|
1087
|
+
)
|
|
1088
|
+
}
|
|
1089
|
+
})()
|
|
1090
|
+
}, clamped)
|
|
1091
|
+
},
|
|
1092
|
+
editProposalCardRateWindow: (retryAtMs: number) => {
|
|
1093
|
+
if (pending.card_message_id == null) return
|
|
1094
|
+
const at = new Date(retryAtMs)
|
|
1095
|
+
const hh = String(at.getHours()).padStart(2, '0')
|
|
1096
|
+
const mm = String(at.getMinutes()).padStart(2, '0')
|
|
1097
|
+
void ctx.api
|
|
1098
|
+
.editMessageText(
|
|
1099
|
+
pending.chat_id,
|
|
1100
|
+
pending.card_message_id,
|
|
1101
|
+
richMessage(
|
|
1102
|
+
`⏳ _Approved — applying **${escapeHtmlForTg(pending.agent)}**'s mental model \`${pending.spec.name}\` at ${hh}:${mm} (config-edit rate window). It isn't lost; it'll persist automatically when the window opens._`,
|
|
1103
|
+
),
|
|
1104
|
+
{ reply_markup: { inline_keyboard: [] }, link_preview_options: { is_disabled: true } },
|
|
1105
|
+
)
|
|
1106
|
+
.catch(() => {})
|
|
1107
|
+
},
|
|
1108
|
+
notifyPersistFailed: (reason: string) => {
|
|
1109
|
+
emitOperatorEvent({
|
|
1110
|
+
kind: 'mental-model-persist-failed',
|
|
1111
|
+
agent: pending.agent,
|
|
1112
|
+
detail: `Mental model "${pending.spec.name}" — ${reason}`,
|
|
1113
|
+
suggestedActions: [],
|
|
1114
|
+
firstSeenAt: new Date(),
|
|
1115
|
+
})
|
|
1116
|
+
},
|
|
1049
1117
|
log: (m: string) => process.stderr.write(`telegram gateway: ${m}\n`),
|
|
1050
1118
|
}
|
|
1051
1119
|
|
|
@@ -1088,6 +1156,13 @@ async function handleMentalModelProposeCallback(ctx: Context, data: string): Pro
|
|
|
1088
1156
|
process.stderr.write(`telegram gateway: mental_model_propose approve threw: ${(err as Error).message}\n`)
|
|
1089
1157
|
result = { outcome: 'failed' as const, reason: (err as Error).message }
|
|
1090
1158
|
}
|
|
1159
|
+
// #2975 Stage 1 — a rate-limited persist already edited the card to the
|
|
1160
|
+
// "applying at HH:MM (rate window)" state and scheduled its own retry,
|
|
1161
|
+
// which will resolve the turn via its own inbound. Leave that card intact;
|
|
1162
|
+
// do NOT overwrite it with a success/failure label here.
|
|
1163
|
+
if (result.outcome === 'scheduled_retry') {
|
|
1164
|
+
return
|
|
1165
|
+
}
|
|
1091
1166
|
if (pending.card_message_id != null) {
|
|
1092
1167
|
const label =
|
|
1093
1168
|
result.outcome === 'applied'
|
|
@@ -2459,6 +2534,18 @@ async function handleOperatorEventCallback(ctx: Context, data: string): Promise<
|
|
|
2459
2534
|
// stub so any stale pinned message that fires an `auth:*` tap is
|
|
2460
2535
|
// silently dismissed instead of crashing the gateway.
|
|
2461
2536
|
async function handleAuthDashboardCallback(ctx: Context): Promise<void> {
|
|
2537
|
+
// Strict allowFrom gate, identical to every other mutating handler in
|
|
2538
|
+
// this file (handleOperatorEventCallback, the vra:/vrs:/vd:/vg: families).
|
|
2539
|
+
// Its absence was a security hole: `auth:use:<label>` drives
|
|
2540
|
+
// `client.setActive(label)` — a fleet-wide OAuth account swap — so an
|
|
2541
|
+
// ungated handler let any tapper (e.g. a member of an admin forum/
|
|
2542
|
+
// supergroup with an empty group allowFrom) swap the active account.
|
|
2543
|
+
const senderId = String(ctx.from?.id ?? '')
|
|
2544
|
+
const access = loadAccess()
|
|
2545
|
+
if (!access.allowFrom.includes(senderId)) {
|
|
2546
|
+
await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
|
|
2547
|
+
return
|
|
2548
|
+
}
|
|
2462
2549
|
const data = ctx.callbackQuery?.data ?? ''
|
|
2463
2550
|
const currentAgent = getMyAgentName()
|
|
2464
2551
|
|
|
@@ -474,7 +474,12 @@ export async function handleRequestConfigFinalize(
|
|
|
474
474
|
const body =
|
|
475
475
|
msg.outcome === "applied"
|
|
476
476
|
? `✅ **Applied**${msg.detail ? `\n${escapeMarkdown(msg.detail)}` : ""}${liveNote}`
|
|
477
|
-
:
|
|
477
|
+
: msg.outcome === "aborted_config_changed"
|
|
478
|
+
? // Nothing was written: the config drifted during the approval window
|
|
479
|
+
// and the apply aborted rather than land a different effect than the
|
|
480
|
+
// operator approved (#3121 follow-up).
|
|
481
|
+
`🚫 **Not applied — config changed since proposal**${msg.detail ? `\n${escapeMarkdown(msg.detail)}` : ""}`
|
|
482
|
+
: `⚠️ **Reconcile failed; rolled back**${msg.detail ? `\n${escapeMarkdown(msg.detail)}` : ""}`;
|
|
478
483
|
try {
|
|
479
484
|
// Finalize is terminal — strip the keyboard so the buttons are gone.
|
|
480
485
|
await deps.editCard({
|
|
@@ -57,6 +57,15 @@ export interface DisconnectFlushDeps<Ctrl extends { finalize: (reason?: 'done' |
|
|
|
57
57
|
/** Progress driver — disposed with `preservePending: true` for sub-agent JTBDs (#393). */
|
|
58
58
|
disposeProgressDriver: () => void
|
|
59
59
|
|
|
60
|
+
/** #2650: stop EVERY live turn-typing loop (the per-(chat,thread)
|
|
61
|
+
* `setInterval` started by `turn-typing-loop.ts`). The bridge just died
|
|
62
|
+
* mid-turn; its turn-long `typing…` loop is never stopped by the canonical
|
|
63
|
+
* turn-end (which will never arrive), so a stale "typing…" shows to the user
|
|
64
|
+
* until the next turn's `start()` self-heals it. Wire to
|
|
65
|
+
* `() => turnTypingLoop.stopAll()`. Called ONLY on a registered-agent
|
|
66
|
+
* disconnect — an anonymous one-shot never owned a turn-typing loop. */
|
|
67
|
+
stopTurnTypingLoops: () => void
|
|
68
|
+
|
|
60
69
|
/** Optional: called when the registered-agent disconnect found dangling
|
|
61
70
|
* `activeTurnStartedAt` entries the controller loop did not clear (i.e.
|
|
62
71
|
* `finalize()` already ran on the canonical reply path, leaving
|
|
@@ -92,6 +101,7 @@ export function flushOnAgentDisconnect<
|
|
|
92
101
|
activeDraftStreams,
|
|
93
102
|
clearActiveReactions,
|
|
94
103
|
disposeProgressDriver,
|
|
104
|
+
stopTurnTypingLoops,
|
|
95
105
|
onDanglingTurnsSwept,
|
|
96
106
|
log,
|
|
97
107
|
} = deps
|
|
@@ -181,6 +191,15 @@ export function flushOnAgentDisconnect<
|
|
|
181
191
|
// deferred-completion-timeout path remain active. Fix for #393.
|
|
182
192
|
disposeProgressDriver()
|
|
183
193
|
|
|
194
|
+
// #2650: sweep the turn-typing loops. The bridge died mid-turn, so the
|
|
195
|
+
// canonical turn-end that would normally call `turnTypingLoop.stop` will
|
|
196
|
+
// never arrive — every live per-(chat,thread) `typing…` interval is now
|
|
197
|
+
// orphaned and would show a stale "typing…" to the user until the next
|
|
198
|
+
// turn's `start()` self-heals it. This is gated to a real registered-agent
|
|
199
|
+
// disconnect (we returned early above for anonymous clients), so an
|
|
200
|
+
// anonymous one-shot never triggers the sweep.
|
|
201
|
+
stopTurnTypingLoops()
|
|
202
|
+
|
|
184
203
|
// Finalize any open draft streams so they don't hang mid-edit.
|
|
185
204
|
for (const [key, stream] of activeDraftStreams.entries()) {
|
|
186
205
|
if (!stream.isFinal()) void stream.finalize().catch(() => {})
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
isDmChatId,
|
|
4
|
+
collectDmChatIdsFromStores,
|
|
5
|
+
createDmPinSweeper,
|
|
6
|
+
unexpiredStoreRepinIds,
|
|
7
|
+
type DmPinSweeperDeps,
|
|
8
|
+
} from './dm-pin-sweep'
|
|
9
|
+
|
|
10
|
+
// Synthetic chat IDs only (check-no-pii-secrets): positive = DM, negative =
|
|
11
|
+
// group/supergroup.
|
|
12
|
+
const DM_A = '5000001'
|
|
13
|
+
const DM_B = '5000002'
|
|
14
|
+
const GROUP = '-1002000000001'
|
|
15
|
+
|
|
16
|
+
describe('isDmChatId', () => {
|
|
17
|
+
it('true for positive integer DM ids', () => {
|
|
18
|
+
expect(isDmChatId(DM_A)).toBe(true)
|
|
19
|
+
expect(isDmChatId('1')).toBe(true)
|
|
20
|
+
})
|
|
21
|
+
it('false for group/supergroup (negative), zero, non-integer, non-numeric', () => {
|
|
22
|
+
expect(isDmChatId(GROUP)).toBe(false)
|
|
23
|
+
expect(isDmChatId('-1')).toBe(false)
|
|
24
|
+
expect(isDmChatId('0')).toBe(false)
|
|
25
|
+
expect(isDmChatId('1.5')).toBe(false)
|
|
26
|
+
expect(isDmChatId('abc')).toBe(false)
|
|
27
|
+
expect(isDmChatId('')).toBe(false)
|
|
28
|
+
expect(isDmChatId('Infinity')).toBe(false)
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('collectDmChatIdsFromStores', () => {
|
|
33
|
+
it('collects distinct DM chat ids across all three stores and drops non-DMs', () => {
|
|
34
|
+
const ids = collectDmChatIdsFromStores({
|
|
35
|
+
statusPins: [{ chatId: DM_A }, { chatId: GROUP }, { chatId: DM_A }],
|
|
36
|
+
activityCards: [{ chatId: DM_B }, { chatId: '0' }],
|
|
37
|
+
queuedCards: [{ chatId: DM_A }, { chatId: '-77' }],
|
|
38
|
+
})
|
|
39
|
+
expect(new Set(ids)).toEqual(new Set([DM_A, DM_B]))
|
|
40
|
+
expect(ids).toHaveLength(2)
|
|
41
|
+
})
|
|
42
|
+
it('handles missing store arrays', () => {
|
|
43
|
+
expect(collectDmChatIdsFromStores({})).toEqual([])
|
|
44
|
+
expect(collectDmChatIdsFromStores({ statusPins: [{ chatId: GROUP }] })).toEqual([])
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
function makeDeps(overrides: Partial<DmPinSweeperDeps> = {}): {
|
|
49
|
+
deps: DmPinSweeperDeps
|
|
50
|
+
unpinAll: ReturnType<typeof vi.fn>
|
|
51
|
+
pinSilent: ReturnType<typeof vi.fn>
|
|
52
|
+
} {
|
|
53
|
+
const unpinAll = vi.fn(async () => undefined)
|
|
54
|
+
const pinSilent = vi.fn(async () => undefined)
|
|
55
|
+
const deps: DmPinSweeperDeps = {
|
|
56
|
+
unpinAll,
|
|
57
|
+
pinSilent,
|
|
58
|
+
liveTrackedMessageIds: () => [],
|
|
59
|
+
eligible: () => true,
|
|
60
|
+
log: () => {},
|
|
61
|
+
...overrides,
|
|
62
|
+
}
|
|
63
|
+
return { deps, unpinAll, pinSilent }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe('createDmPinSweeper', () => {
|
|
67
|
+
it('DM with tracked pins: unpin-all exactly once, then re-pin exactly the live ids', async () => {
|
|
68
|
+
const { deps, unpinAll, pinSilent } = makeDeps({
|
|
69
|
+
liveTrackedMessageIds: (chatId) => (chatId === DM_A ? [111, 222] : []),
|
|
70
|
+
})
|
|
71
|
+
const sweeper = createDmPinSweeper(deps)
|
|
72
|
+
await sweeper.sweep(DM_A)
|
|
73
|
+
|
|
74
|
+
expect(unpinAll).toHaveBeenCalledTimes(1)
|
|
75
|
+
expect(unpinAll).toHaveBeenCalledWith(DM_A)
|
|
76
|
+
expect(pinSilent.mock.calls).toEqual([
|
|
77
|
+
[DM_A, 111],
|
|
78
|
+
[DM_A, 222],
|
|
79
|
+
])
|
|
80
|
+
expect(sweeper.hasSwept(DM_A)).toBe(true)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('DM with no tracked pins: unpin-all once, no re-pin', async () => {
|
|
84
|
+
const { deps, unpinAll, pinSilent } = makeDeps()
|
|
85
|
+
const sweeper = createDmPinSweeper(deps)
|
|
86
|
+
await sweeper.sweep(DM_A)
|
|
87
|
+
expect(unpinAll).toHaveBeenCalledTimes(1)
|
|
88
|
+
expect(pinSilent).not.toHaveBeenCalled()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('group/supergroup id: never unpin-all (keeps human pins)', async () => {
|
|
92
|
+
const { deps, unpinAll, pinSilent } = makeDeps()
|
|
93
|
+
const sweeper = createDmPinSweeper(deps)
|
|
94
|
+
await sweeper.sweep(GROUP)
|
|
95
|
+
await sweeper.sweep('-1')
|
|
96
|
+
expect(unpinAll).not.toHaveBeenCalled()
|
|
97
|
+
expect(pinSilent).not.toHaveBeenCalled()
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('not eligible (lost startup mutex): does nothing', async () => {
|
|
101
|
+
const { deps, unpinAll } = makeDeps({ eligible: () => false })
|
|
102
|
+
const sweeper = createDmPinSweeper(deps)
|
|
103
|
+
await sweeper.sweep(DM_A)
|
|
104
|
+
expect(unpinAll).not.toHaveBeenCalled()
|
|
105
|
+
expect(sweeper.hasSwept(DM_A)).toBe(false)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('second sweep of same chat does not re-sweep', async () => {
|
|
109
|
+
const { deps, unpinAll } = makeDeps()
|
|
110
|
+
const sweeper = createDmPinSweeper(deps)
|
|
111
|
+
await sweeper.sweep(DM_A)
|
|
112
|
+
await sweeper.sweep(DM_A)
|
|
113
|
+
expect(unpinAll).toHaveBeenCalledTimes(1)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('concurrent triggers on the same chat fire unpin-all only once', async () => {
|
|
117
|
+
let resolveUnpin: () => void = () => {}
|
|
118
|
+
const unpinAll = vi.fn(
|
|
119
|
+
() =>
|
|
120
|
+
new Promise<void>((res) => {
|
|
121
|
+
resolveUnpin = res
|
|
122
|
+
}),
|
|
123
|
+
)
|
|
124
|
+
const { deps } = makeDeps({ unpinAll })
|
|
125
|
+
const sweeper = createDmPinSweeper(deps)
|
|
126
|
+
const p1 = sweeper.sweep(DM_A)
|
|
127
|
+
const p2 = sweeper.sweep(DM_A)
|
|
128
|
+
resolveUnpin()
|
|
129
|
+
await Promise.all([p1, p2])
|
|
130
|
+
expect(unpinAll).toHaveBeenCalledTimes(1)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('429 / unpin-all rejects: degrades without throwing, still re-pins live ids', async () => {
|
|
134
|
+
const unpinAll = vi.fn(async () => {
|
|
135
|
+
throw new Error('429: Too Many Requests: retry after 30')
|
|
136
|
+
})
|
|
137
|
+
const { deps, pinSilent } = makeDeps({
|
|
138
|
+
unpinAll,
|
|
139
|
+
liveTrackedMessageIds: () => [333],
|
|
140
|
+
})
|
|
141
|
+
const sweeper = createDmPinSweeper(deps)
|
|
142
|
+
// Must not reject.
|
|
143
|
+
await expect(sweeper.sweep(DM_A)).resolves.toBeUndefined()
|
|
144
|
+
expect(unpinAll).toHaveBeenCalledTimes(1)
|
|
145
|
+
expect(pinSilent).toHaveBeenCalledWith(DM_A, 333)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('re-pin failure is absorbed and does not reject', async () => {
|
|
149
|
+
const pinSilent = vi.fn(async () => {
|
|
150
|
+
throw new Error('pin failed')
|
|
151
|
+
})
|
|
152
|
+
const { deps } = makeDeps({
|
|
153
|
+
pinSilent,
|
|
154
|
+
liveTrackedMessageIds: () => [444],
|
|
155
|
+
})
|
|
156
|
+
const sweeper = createDmPinSweeper(deps)
|
|
157
|
+
await expect(sweeper.sweep(DM_A)).resolves.toBeUndefined()
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('no unhandled rejection escapes a failing sweep (fire-and-forget safe)', async () => {
|
|
161
|
+
const unhandled: unknown[] = []
|
|
162
|
+
const onUnhandled = (r: unknown): void => {
|
|
163
|
+
unhandled.push(r)
|
|
164
|
+
}
|
|
165
|
+
process.on('unhandledRejection', onUnhandled)
|
|
166
|
+
try {
|
|
167
|
+
const { deps } = makeDeps({
|
|
168
|
+
unpinAll: async () => {
|
|
169
|
+
throw new Error('boom')
|
|
170
|
+
},
|
|
171
|
+
liveTrackedMessageIds: () => [555],
|
|
172
|
+
})
|
|
173
|
+
const sweeper = createDmPinSweeper(deps)
|
|
174
|
+
// Fire-and-forget, as the gateway does.
|
|
175
|
+
void sweeper.sweep(DM_A)
|
|
176
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
177
|
+
} finally {
|
|
178
|
+
process.off('unhandledRejection', onUnhandled)
|
|
179
|
+
}
|
|
180
|
+
expect(unhandled).toEqual([])
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
describe('tool-pin survival (#3001 contract, review blocker on #3074)', () => {
|
|
185
|
+
const NOW = 1_752_000_000_000
|
|
186
|
+
|
|
187
|
+
it('unexpiredStoreRepinIds keeps unexpired tool rows for the chat, drops expired / work-scoped / other-chat rows', () => {
|
|
188
|
+
const rows = [
|
|
189
|
+
// Unexpired tool pin in this chat — MUST survive.
|
|
190
|
+
{ chatId: DM_A, messageId: 10, expiresAt: NOW + 60_000 },
|
|
191
|
+
// Expired tool pin — due for sweeping, NOT re-pinned.
|
|
192
|
+
{ chatId: DM_A, messageId: 11, expiresAt: NOW - 1 },
|
|
193
|
+
// Work-scoped row (no expiresAt) — stale after restart, NOT re-pinned.
|
|
194
|
+
{ chatId: DM_A, messageId: 12 },
|
|
195
|
+
// Unexpired tool pin in a DIFFERENT chat — not this chat's re-pin set.
|
|
196
|
+
{ chatId: DM_B, messageId: 13, expiresAt: NOW + 60_000 },
|
|
197
|
+
]
|
|
198
|
+
expect(unexpiredStoreRepinIds(rows, DM_A, NOW)).toEqual([10])
|
|
199
|
+
expect(unexpiredStoreRepinIds(rows, DM_B, NOW)).toEqual([13])
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
// Composition test mirroring the gateway wiring: liveTrackedMessageIds
|
|
203
|
+
// unions in-memory claims with unexpired store rows.
|
|
204
|
+
function gatewayStyleDeps(args: {
|
|
205
|
+
inMemory: number[]
|
|
206
|
+
storeRows: { chatId: string; messageId: number; expiresAt?: number }[]
|
|
207
|
+
}): ReturnType<typeof makeDeps> {
|
|
208
|
+
return makeDeps({
|
|
209
|
+
liveTrackedMessageIds: (chatId) => [
|
|
210
|
+
...args.inMemory,
|
|
211
|
+
...unexpiredStoreRepinIds(args.storeRows, chatId, NOW),
|
|
212
|
+
],
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
it('(a) boot sweep: DM with an unexpired tool: row → unpinAll once, then that messageId re-pinned', async () => {
|
|
217
|
+
const { deps, unpinAll, pinSilent } = gatewayStyleDeps({
|
|
218
|
+
inMemory: [], // fresh boot — in-memory claims empty
|
|
219
|
+
storeRows: [{ chatId: DM_A, messageId: 777, expiresAt: NOW + 60_000 }],
|
|
220
|
+
})
|
|
221
|
+
const sweeper = createDmPinSweeper(deps)
|
|
222
|
+
await sweeper.sweep(DM_A)
|
|
223
|
+
expect(unpinAll).toHaveBeenCalledTimes(1)
|
|
224
|
+
expect(pinSilent.mock.calls).toEqual([[DM_A, 777]])
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('(b) expired tool: row is NOT re-pinned', async () => {
|
|
228
|
+
const { deps, unpinAll, pinSilent } = gatewayStyleDeps({
|
|
229
|
+
inMemory: [],
|
|
230
|
+
storeRows: [{ chatId: DM_A, messageId: 778, expiresAt: NOW - 1 }],
|
|
231
|
+
})
|
|
232
|
+
const sweeper = createDmPinSweeper(deps)
|
|
233
|
+
await sweeper.sweep(DM_A)
|
|
234
|
+
expect(unpinAll).toHaveBeenCalledTimes(1)
|
|
235
|
+
expect(pinSilent).not.toHaveBeenCalled()
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('(c) first-inbound sweep preserves an unexpired tool pin alongside a live in-memory claim, deduped', async () => {
|
|
239
|
+
const { deps, pinSilent } = gatewayStyleDeps({
|
|
240
|
+
inMemory: [500, 777], // live fg: claim + the tool pin also claimed in-memory
|
|
241
|
+
storeRows: [{ chatId: DM_A, messageId: 777, expiresAt: NOW + 60_000 }],
|
|
242
|
+
})
|
|
243
|
+
const sweeper = createDmPinSweeper(deps)
|
|
244
|
+
await sweeper.sweep(DM_A)
|
|
245
|
+
// 777 appears in BOTH sources but is re-pinned exactly once.
|
|
246
|
+
expect(pinSilent.mock.calls).toEqual([
|
|
247
|
+
[DM_A, 500],
|
|
248
|
+
[DM_A, 777],
|
|
249
|
+
])
|
|
250
|
+
})
|
|
251
|
+
})
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DM pin sweep (#3026) — durable clearing of stale bot-authored pins in
|
|
3
|
+
* user DM chats.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists (two independent gaps the probe-based boot sweep can't
|
|
6
|
+
* close for DMs):
|
|
7
|
+
*
|
|
8
|
+
* 1. The boot pin sweep (`shouldSweepChatAtBoot`) deliberately SKIPS
|
|
9
|
+
* positive (DM) chat IDs — the Bot API returns `400 chat not found`
|
|
10
|
+
* for a user who never messaged the bot. So DM chats were never
|
|
11
|
+
* boot-swept at all, and stale pins from a prior session lingered
|
|
12
|
+
* across restarts (observed fleet-wide 2026-07-11).
|
|
13
|
+
*
|
|
14
|
+
* 2. Even when a DM chat IS reachable, `getChat()` exposes ONLY the
|
|
15
|
+
* NEWEST pinned message. Telegram DMs STACK pins and the Bot API has
|
|
16
|
+
* no list-pins method, so older orphan pins are invisible to a
|
|
17
|
+
* probe-based (unpin-one-by-message-id) sweep forever.
|
|
18
|
+
*
|
|
19
|
+
* The durable fix, DM-only: call `unpinAllChatMessages` ONCE per DM chat
|
|
20
|
+
* per gateway boot (clearing the whole stack — safe in a DM, where
|
|
21
|
+
* virtually every pin is bot-authored), then immediately RE-PIN the live
|
|
22
|
+
* tracked cards from the in-memory pin claims (silent, `disable_notification`)
|
|
23
|
+
* so a genuinely in-flight status/worker pin survives the sweep.
|
|
24
|
+
*
|
|
25
|
+
* Groups/supergroups (negative IDs) are NOT touched here — an unpin-all
|
|
26
|
+
* there would nuke human pins. They keep the existing probe-based path.
|
|
27
|
+
*
|
|
28
|
+
* This module is the PURE half (chat-id classification, store→DM-chat-id
|
|
29
|
+
* collection, and a dependency-injected sweeper with the once-per-boot
|
|
30
|
+
* guard). The gateway binds the live Bot API + in-memory claim map.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* True iff `chatId` is a user DM chat: a positive integer. Group and
|
|
35
|
+
* supergroup IDs are negative; zero / non-numeric / non-integer are
|
|
36
|
+
* malformed and never DMs.
|
|
37
|
+
*/
|
|
38
|
+
export function isDmChatId(chatId: string): boolean {
|
|
39
|
+
const n = Number(chatId)
|
|
40
|
+
return Number.isFinite(n) && Number.isInteger(n) && n > 0
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Collect the DISTINCT DM chat IDs that appear in ANY of the persisted pin
|
|
45
|
+
* stores — the set of DM chats the gateway has a record of having pinned
|
|
46
|
+
* something in during a prior session. These are the DM chats worth
|
|
47
|
+
* sweeping at boot (an unpin-all is wasted on a DM we never pinned in).
|
|
48
|
+
*
|
|
49
|
+
* Pure over its input records; callers pass the loaded store snapshots
|
|
50
|
+
* (status pins, activity cards, queued/busy-ack cards). Non-DM chat IDs
|
|
51
|
+
* are filtered out.
|
|
52
|
+
*/
|
|
53
|
+
export function collectDmChatIdsFromStores(input: {
|
|
54
|
+
statusPins?: readonly { chatId: string }[]
|
|
55
|
+
activityCards?: readonly { chatId: string }[]
|
|
56
|
+
queuedCards?: readonly { chatId: string }[]
|
|
57
|
+
}): string[] {
|
|
58
|
+
const out = new Set<string>()
|
|
59
|
+
const add = (rows?: readonly { chatId: string }[]): void => {
|
|
60
|
+
if (rows == null) return
|
|
61
|
+
for (const r of rows) if (isDmChatId(r.chatId)) out.add(r.chatId)
|
|
62
|
+
}
|
|
63
|
+
add(input.statusPins)
|
|
64
|
+
add(input.activityCards)
|
|
65
|
+
add(input.queuedCards)
|
|
66
|
+
return [...out]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The messageIds in `chatId` that must SURVIVE a DM unpin-all because they
|
|
71
|
+
* belong to deliberately-retained store rows: unexpired time-scoped `tool:`
|
|
72
|
+
* pins (the `pin_message` MCP tool, #3001), which `runStatusPinBootCleanup`
|
|
73
|
+
* intentionally KEEPS across restarts. Work-scoped rows (no `expiresAt`) are
|
|
74
|
+
* stale by definition after a restart and are NOT re-pin candidates; expired
|
|
75
|
+
* time-scoped rows are due for sweeping.
|
|
76
|
+
*
|
|
77
|
+
* Pure over a loaded store snapshot; the gateway binds a LIVE store read into
|
|
78
|
+
* `liveTrackedMessageIds` so BOTH the boot sweep and a first-inbound sweep
|
|
79
|
+
* see the kept rows (the in-memory claim maps are empty at boot).
|
|
80
|
+
*/
|
|
81
|
+
export function unexpiredStoreRepinIds(
|
|
82
|
+
rows: readonly { chatId: string; messageId: number; expiresAt?: number }[],
|
|
83
|
+
chatId: string,
|
|
84
|
+
now: number,
|
|
85
|
+
): number[] {
|
|
86
|
+
const out: number[] = []
|
|
87
|
+
for (const r of rows) {
|
|
88
|
+
if (r.chatId !== chatId) continue
|
|
89
|
+
if (r.expiresAt != null && r.expiresAt > now) out.push(r.messageId)
|
|
90
|
+
}
|
|
91
|
+
return out
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface DmPinSweeperDeps {
|
|
95
|
+
/** Clear EVERY pinned message in a chat. Bound to the gateway's
|
|
96
|
+
* robust/retry API wrapper so a 429 flood-wait is retried, then
|
|
97
|
+
* degrades (rejects) rather than crashing — the sweeper absorbs it. */
|
|
98
|
+
unpinAll: (chatId: string) => Promise<unknown>
|
|
99
|
+
/** Silently re-pin an EXISTING message (disable_notification). Bound to
|
|
100
|
+
* the gateway's robust/retry API wrapper. */
|
|
101
|
+
pinSilent: (chatId: string, messageId: number) => Promise<unknown>
|
|
102
|
+
/** Pin message ids in this chat that must survive the unpin-all: the
|
|
103
|
+
* in-memory status-pin claims still owned by an in-flight turn/worker,
|
|
104
|
+
* UNIONED with the deliberately-retained store rows (unexpired `tool:`
|
|
105
|
+
* pins, #3001 — see unexpiredStoreRepinIds). The gateway binds both
|
|
106
|
+
* sources; duplicates are deduped by the sweeper before re-pinning. */
|
|
107
|
+
liveTrackedMessageIds: (chatId: string) => number[]
|
|
108
|
+
/** Gate: sweep ONLY when the gateway won the startup mutex. The stores
|
|
109
|
+
* and the chat's pins are shared per-agent state; a LOSING double-boot
|
|
110
|
+
* must never unpin the live holder's legitimate pins. */
|
|
111
|
+
eligible: () => boolean
|
|
112
|
+
/** Optional structured log sink. */
|
|
113
|
+
log?: (line: string) => void
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface DmPinSweeper {
|
|
117
|
+
/**
|
|
118
|
+
* Sweep a single DM chat: unpin-all then re-pin live tracked cards.
|
|
119
|
+
* At most ONCE per chat per sweeper lifetime (one gateway boot). No-op
|
|
120
|
+
* for non-DM chats, when not eligible, or when already swept. Never
|
|
121
|
+
* throws — a failed unpin-all/re-pin degrades and is logged.
|
|
122
|
+
*/
|
|
123
|
+
sweep(chatId: string): Promise<void>
|
|
124
|
+
/** True if this chat has already been swept this boot (test/observability). */
|
|
125
|
+
hasSwept(chatId: string): boolean
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function createDmPinSweeper(deps: DmPinSweeperDeps): DmPinSweeper {
|
|
129
|
+
const swept = new Set<string>()
|
|
130
|
+
return {
|
|
131
|
+
hasSwept: (chatId) => swept.has(chatId),
|
|
132
|
+
async sweep(chatId: string): Promise<void> {
|
|
133
|
+
if (!isDmChatId(chatId)) return
|
|
134
|
+
if (!deps.eligible()) return
|
|
135
|
+
if (swept.has(chatId)) return
|
|
136
|
+
// Claim the once-guard BEFORE the first await so two concurrent
|
|
137
|
+
// triggers (boot + first-inbound landing in the same tick) can't
|
|
138
|
+
// both fire the unpin-all.
|
|
139
|
+
swept.add(chatId)
|
|
140
|
+
|
|
141
|
+
// Snapshot live claims BEFORE the unpin-all so we know exactly which
|
|
142
|
+
// messages to restore. Dedupe — the in-memory and store-row sources
|
|
143
|
+
// can both report the same messageId.
|
|
144
|
+
const liveIds = [...new Set(deps.liveTrackedMessageIds(chatId))]
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
await deps.unpinAll(chatId)
|
|
148
|
+
} catch (err) {
|
|
149
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
150
|
+
deps.log?.(
|
|
151
|
+
`telegram gateway: dm-pin-sweep: unpin-all failed (chat=${chatId}): ${msg}\n`,
|
|
152
|
+
)
|
|
153
|
+
// Degrade: the stack may still hold stale pins, but we don't
|
|
154
|
+
// crash and we don't retry this boot. Fall through to re-pin the
|
|
155
|
+
// live claims (harmless if the unpin didn't land).
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
for (const messageId of liveIds) {
|
|
159
|
+
try {
|
|
160
|
+
await deps.pinSilent(chatId, messageId)
|
|
161
|
+
} catch (err) {
|
|
162
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
163
|
+
deps.log?.(
|
|
164
|
+
`telegram gateway: dm-pin-sweep: re-pin failed ` +
|
|
165
|
+
`(chat=${chatId} msg=${messageId}): ${msg}\n`,
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (liveIds.length > 0) {
|
|
171
|
+
deps.log?.(
|
|
172
|
+
`telegram gateway: dm-pin-sweep: cleared stacked pins in DM ${chatId}, ` +
|
|
173
|
+
`re-pinned ${liveIds.length} live card(s)\n`,
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
}
|
|
178
|
+
}
|