switchroom 0.18.3 → 0.18.7
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 +3 -1
- package/dist/auth-broker/index.js +3 -1
- package/dist/cli/autoaccept-poll.js +140 -33
- package/dist/cli/notion-write-pretool.mjs +3 -1
- package/dist/cli/switchroom.js +386 -128
- package/dist/host-control/main.js +4 -2
- package/dist/vault/approvals/kernel-server.js +3 -1
- package/dist/vault/broker/server.js +38 -8
- package/package.json +3 -3
- package/profiles/_base/cron-session.sh.hbs +55 -16
- package/profiles/_base/start.sh.hbs +35 -16
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/skills/switchroom-release/SKILL.md +78 -0
- package/telegram-plugin/auth-snapshot-format.ts +15 -1
- package/telegram-plugin/dist/bridge/bridge.js +22 -0
- package/telegram-plugin/dist/gateway/gateway.js +2852 -1032
- package/telegram-plugin/dist/server.js +24 -0
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
- package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
- package/telegram-plugin/gateway/gateway.ts +1331 -151
- package/telegram-plugin/gateway/inbound-spool.ts +2 -1
- package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
- package/telegram-plugin/gateway/inject-handler.ts +17 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
- package/telegram-plugin/gateway/ipc-server.ts +40 -0
- package/telegram-plugin/gateway/model-command.ts +212 -51
- package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
- package/telegram-plugin/gateway/pending-card-store.ts +173 -0
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
- package/telegram-plugin/gateway/resolve-person.ts +304 -0
- package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
- package/telegram-plugin/gateway/session-model-source.ts +73 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +21 -1
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
- package/telegram-plugin/hooks/silent-end-scan.mjs +164 -40
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
- package/telegram-plugin/model-label.ts +69 -0
- package/telegram-plugin/operator-events.ts +45 -0
- package/telegram-plugin/pending-work-progress.ts +42 -7
- package/telegram-plugin/permission-diff.ts +128 -0
- package/telegram-plugin/quota-bar-format.ts +360 -0
- package/telegram-plugin/registry/subagents-schema.ts +80 -1
- package/telegram-plugin/registry/subagents.test.ts +90 -0
- package/telegram-plugin/session-tail.ts +28 -0
- package/telegram-plugin/silent-end.ts +49 -4
- package/telegram-plugin/subagent-watcher.ts +249 -46
- package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
- package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +21 -0
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +4 -2
- package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
- package/telegram-plugin/tests/model-command.test.ts +202 -42
- package/telegram-plugin/tests/model-label.test.ts +64 -0
- package/telegram-plugin/tests/operator-events.test.ts +17 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
- package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
- package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
- package/telegram-plugin/tests/pending-work-progress.test.ts +116 -3
- package/telegram-plugin/tests/permission-diff.test.ts +111 -0
- package/telegram-plugin/tests/quota-bar-format.test.ts +444 -0
- package/telegram-plugin/tests/resolve-person.test.ts +290 -0
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
- package/telegram-plugin/tests/session-model-source.test.ts +67 -0
- package/telegram-plugin/tests/session-tail.test.ts +64 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +53 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +138 -0
- package/telegram-plugin/tests/silent-end.test.ts +46 -1
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
- package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
- package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
- package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +115 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
- package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +19 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +108 -0
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
- package/telegram-plugin/tool-activity-summary.ts +22 -2
- package/telegram-plugin/typing-wrap.ts +72 -25
- package/telegram-plugin/worker-activity-feed.ts +229 -15
- package/profiles/default/CLAUDE.md +0 -116
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
- package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, injectable core for expiring an agent-initiated approval card
|
|
3
|
+
* (vault_request_access / vault_request_save / request_secret /
|
|
4
|
+
* mental_model_propose) whose TTL elapsed with no operator tap.
|
|
5
|
+
*
|
|
6
|
+
* Extracted from gateway.ts so the ORDERING and FAULT-ISOLATION contract is
|
|
7
|
+
* unit-testable behaviorally (pending-card-expiry.test.ts), not just pinned by
|
|
8
|
+
* source-text regex:
|
|
9
|
+
*
|
|
10
|
+
* 1. remove() FIRST — the in-memory map entry + durable store record are
|
|
11
|
+
* dropped before anything else, so the expiry is single-shot: a second
|
|
12
|
+
* reaper tick (or a concurrent lazy sweep) can never double-fire the
|
|
13
|
+
* synthetic wake for the same card.
|
|
14
|
+
* 2. editCard() — best-effort ⌛ card strip; a Telegram failure never blocks
|
|
15
|
+
* the wake.
|
|
16
|
+
* 3. recordMiss() — the missed-approvals re-offer entry is written BEFORE
|
|
17
|
+
* the deliver attempt, so a throwing IPC socket can't lose the re-offer:
|
|
18
|
+
* even if the wake never lands, the operator's return re-surfaces it.
|
|
19
|
+
* 4. deliver() — the timeout synthetic, wrapped in try/catch. A half-dead
|
|
20
|
+
* client socket that throws on write is contained here: the error is
|
|
21
|
+
* logged, `delivered: false` is returned, and the caller's sweep loop
|
|
22
|
+
* continues to the remaining entries/families.
|
|
23
|
+
*
|
|
24
|
+
* Every step is individually guarded — one failing dependency never skips the
|
|
25
|
+
* later steps or escapes to the caller (the reaper's setInterval callback,
|
|
26
|
+
* where an escaped throw would take the whole gateway down via
|
|
27
|
+
* uncaughtException).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { InboundMessage } from './ipc-protocol.js'
|
|
31
|
+
|
|
32
|
+
export interface ExpireCardDeps {
|
|
33
|
+
/** Drop the in-memory map entry AND the durable store record. Runs first. */
|
|
34
|
+
remove: () => void
|
|
35
|
+
/** Best-effort ⌛ card edit (strip keyboard). Failures are swallowed. */
|
|
36
|
+
editCard: () => void
|
|
37
|
+
/** Build the timeout synthetic inbound for this card's family. */
|
|
38
|
+
buildInbound: () => InboundMessage
|
|
39
|
+
/** Inject the synthetic (turn-safe gate). May throw on a dead socket. */
|
|
40
|
+
deliver: (inbound: InboundMessage) => boolean
|
|
41
|
+
/** Record the missed-approvals re-offer entry. Runs BEFORE deliver. */
|
|
42
|
+
recordMiss: () => void
|
|
43
|
+
/** Error sink (stderr in production). */
|
|
44
|
+
log: (msg: string) => void
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ExpireCardResult {
|
|
48
|
+
delivered: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function expirePendingCard(deps: ExpireCardDeps): ExpireCardResult {
|
|
52
|
+
// 1. Single-shot: entry gone before any fallible side effect.
|
|
53
|
+
deps.remove()
|
|
54
|
+
// 2. Card strip is cosmetic — never let it block the wake.
|
|
55
|
+
try {
|
|
56
|
+
deps.editCard()
|
|
57
|
+
} catch (err) {
|
|
58
|
+
deps.log(`card-expiry: card edit failed: ${(err as Error).message}`)
|
|
59
|
+
}
|
|
60
|
+
// 3. Re-offer entry BEFORE the deliver attempt so a throwing deliver can't
|
|
61
|
+
// lose it (the operator's return still re-surfaces the missed card).
|
|
62
|
+
try {
|
|
63
|
+
deps.recordMiss()
|
|
64
|
+
} catch (err) {
|
|
65
|
+
deps.log(`card-expiry: missed-approval record failed: ${(err as Error).message}`)
|
|
66
|
+
}
|
|
67
|
+
// 4. The wake itself — contained so one dead socket doesn't skip the
|
|
68
|
+
// remaining entries in the caller's sweep loop.
|
|
69
|
+
let delivered = false
|
|
70
|
+
try {
|
|
71
|
+
delivered = deps.deliver(deps.buildInbound())
|
|
72
|
+
} catch (err) {
|
|
73
|
+
deps.log(`card-expiry: timeout synthetic delivery failed: ${(err as Error).message}`)
|
|
74
|
+
}
|
|
75
|
+
return { delivered }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Sweep one pending-card map: expire every entry past its TTL via `expire`,
|
|
80
|
+
* guarding each entry so one throwing expiry can't skip the rest of the map
|
|
81
|
+
* (or, at the caller, the remaining families).
|
|
82
|
+
*/
|
|
83
|
+
export function sweepExpiredEntries<T>(
|
|
84
|
+
map: Map<string, T>,
|
|
85
|
+
isExpired: (value: T, now: number) => boolean,
|
|
86
|
+
expire: (stageId: string, value: T, now: number) => void,
|
|
87
|
+
now: number,
|
|
88
|
+
log: (msg: string) => void,
|
|
89
|
+
): void {
|
|
90
|
+
for (const [k, v] of map) {
|
|
91
|
+
if (!isExpired(v, now)) continue
|
|
92
|
+
try {
|
|
93
|
+
expire(k, v, now)
|
|
94
|
+
} catch (err) {
|
|
95
|
+
log(`card-expiry: expire threw for stage=${k}: ${(err as Error).message}`)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistence store for in-flight AGENT-INITIATED approval cards that park
|
|
3
|
+
* the requesting agent until the operator taps: `vault_request_access`,
|
|
4
|
+
* `vault_request_save`, `request_secret`, and `mental_model_propose`.
|
|
5
|
+
*
|
|
6
|
+
* Problem (mirror of the permission-card `permission-card-store.ts` bug): the
|
|
7
|
+
* gateway holds each staged request in an in-memory Map only
|
|
8
|
+
* (`pendingVaultRequestAccesses`, `pendingVaultRequestSaves`,
|
|
9
|
+
* `pendingSecretRequests`, `pendingMentalModelProposes`). When the gateway
|
|
10
|
+
* restarts (crash OR container restart), every entry is lost. The card in
|
|
11
|
+
* Telegram keeps its live inline keyboard, so when the operator taps it later
|
|
12
|
+
* they hit the "Card expired — ask the agent to re-request" tombstone: no
|
|
13
|
+
* grant, no injected inbound, and the agent that ended its turn to WAIT on the
|
|
14
|
+
* card stays parked forever.
|
|
15
|
+
*
|
|
16
|
+
* Fix: persist the METADATA for every posted card to a JSON file in STATE_DIR.
|
|
17
|
+
* On each resolution (tap / TTL-expire) remove the entry. On gateway boot,
|
|
18
|
+
* restore surviving entries into the in-memory maps so a post-restart tap on a
|
|
19
|
+
* still-valid (unexpired) card works exactly like a pre-restart tap.
|
|
20
|
+
*
|
|
21
|
+
* SECRETS HYGIENE — LOAD-BEARING: this file is written to disk unencrypted.
|
|
22
|
+
* It MUST NOT carry any secret VALUE. `vault_request_save` stages a secret
|
|
23
|
+
* value in gateway memory; only its key/metadata is persisted here (never the
|
|
24
|
+
* value), so a restored save card is re-associated with a tap but cannot
|
|
25
|
+
* complete the write — the caller degrades gracefully (tells the agent the
|
|
26
|
+
* value was lost to a restart). `request_secret` never holds a value at
|
|
27
|
+
* staging time (the value arrives after the tap), so its metadata is safe to
|
|
28
|
+
* persist. The "no value field" invariant is enforced by the record types
|
|
29
|
+
* below (PersistedVaultSaveCard has no `value` member, so a callsite can't
|
|
30
|
+
* compile one in) and pinned by the on-disk sentinel test in
|
|
31
|
+
* pending-card-store.test.ts.
|
|
32
|
+
*
|
|
33
|
+
* File format: JSON array of PersistedApprovalCard objects. Written
|
|
34
|
+
* synchronously (mode 0o600) to avoid interleaving on concurrent card posts;
|
|
35
|
+
* production rate is a handful of cards, so the file stays tiny.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { readFileSync, writeFileSync, unlinkSync, chmodSync } from 'node:fs'
|
|
39
|
+
import { join } from 'node:path'
|
|
40
|
+
|
|
41
|
+
/** The four agent-initiated approval-card families we persist. */
|
|
42
|
+
export type ApprovalCardFamily =
|
|
43
|
+
| 'vault_request_access'
|
|
44
|
+
| 'vault_request_save'
|
|
45
|
+
| 'request_secret'
|
|
46
|
+
| 'mental_model_propose'
|
|
47
|
+
|
|
48
|
+
interface BasePersistedCard {
|
|
49
|
+
family: ApprovalCardFamily
|
|
50
|
+
/** The staging id embedded in the card's callback_data (dedup + re-associate). */
|
|
51
|
+
stageId: string
|
|
52
|
+
/** Agent that requested (process.env.SWITCHROOM_AGENT_NAME). */
|
|
53
|
+
agent: string
|
|
54
|
+
/** Chat the card was rendered into; edited on tap / expiry. */
|
|
55
|
+
chatId: string
|
|
56
|
+
/** Card message id (filled after the card is sent). */
|
|
57
|
+
cardMessageId?: number
|
|
58
|
+
/** Forum topic the agent was working in, if any. */
|
|
59
|
+
threadId?: number
|
|
60
|
+
/** Unix-ms staging timestamp — the TTL clock. Preserved across restart. */
|
|
61
|
+
stagedAt: number
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface PersistedVaultAccessCard extends BasePersistedCard {
|
|
65
|
+
family: 'vault_request_access'
|
|
66
|
+
key: string
|
|
67
|
+
scope: 'read' | 'write'
|
|
68
|
+
reason?: string
|
|
69
|
+
ttlSeconds: number
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface PersistedVaultSaveCard extends BasePersistedCard {
|
|
73
|
+
family: 'vault_request_save'
|
|
74
|
+
key: string
|
|
75
|
+
kind: 'string' | 'binary'
|
|
76
|
+
why?: string
|
|
77
|
+
// NOTE: NO `value` — the staged secret never touches disk.
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface PersistedSecretRequestCard extends BasePersistedCard {
|
|
81
|
+
family: 'request_secret'
|
|
82
|
+
key: string
|
|
83
|
+
reason?: string
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface PersistedMentalModelCard extends BasePersistedCard {
|
|
87
|
+
family: 'mental_model_propose'
|
|
88
|
+
spec: {
|
|
89
|
+
name: string
|
|
90
|
+
source_query: string
|
|
91
|
+
refresh_after_consolidation?: boolean
|
|
92
|
+
max_tokens?: number
|
|
93
|
+
}
|
|
94
|
+
reason?: string
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type PersistedApprovalCard =
|
|
98
|
+
| PersistedVaultAccessCard
|
|
99
|
+
| PersistedVaultSaveCard
|
|
100
|
+
| PersistedSecretRequestCard
|
|
101
|
+
| PersistedMentalModelCard
|
|
102
|
+
|
|
103
|
+
export interface PendingCardStore {
|
|
104
|
+
/** Record a newly-posted card. Idempotent on stageId (replaces in place). */
|
|
105
|
+
add(entry: PersistedApprovalCard): void
|
|
106
|
+
/** Remove the entry for this stageId (resolved — tap or TTL). */
|
|
107
|
+
remove(stageId: string): void
|
|
108
|
+
/** All persisted entries (for boot-time restore). */
|
|
109
|
+
loadAll(): PersistedApprovalCard[]
|
|
110
|
+
/** Delete the backing file entirely. */
|
|
111
|
+
clear(): void
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function createPendingCardStore(stateDir: string): PendingCardStore {
|
|
115
|
+
const filePath = join(stateDir, 'pending-approval-cards.json')
|
|
116
|
+
|
|
117
|
+
function read(): PersistedApprovalCard[] {
|
|
118
|
+
try {
|
|
119
|
+
const raw = readFileSync(filePath, 'utf-8')
|
|
120
|
+
const parsed = JSON.parse(raw)
|
|
121
|
+
return Array.isArray(parsed) ? (parsed as PersistedApprovalCard[]) : []
|
|
122
|
+
} catch {
|
|
123
|
+
return []
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function write(entries: PersistedApprovalCard[]): void {
|
|
128
|
+
try {
|
|
129
|
+
writeFileSync(filePath, JSON.stringify(entries), { encoding: 'utf-8', mode: 0o600 })
|
|
130
|
+
// `mode` only applies when writeFileSync CREATES the file; an existing
|
|
131
|
+
// file keeps its prior perms. Re-assert 0600 on every write so the file
|
|
132
|
+
// can never stay laxer than intended.
|
|
133
|
+
chmodSync(filePath, 0o600)
|
|
134
|
+
} catch (err) {
|
|
135
|
+
process.stderr.write(
|
|
136
|
+
`telegram gateway: pending-card-store write failed: ${(err as Error).message}\n`,
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
add(entry) {
|
|
143
|
+
const entries = read()
|
|
144
|
+
const idx = entries.findIndex(e => e.stageId === entry.stageId)
|
|
145
|
+
if (idx >= 0) {
|
|
146
|
+
entries[idx] = entry
|
|
147
|
+
} else {
|
|
148
|
+
entries.push(entry)
|
|
149
|
+
}
|
|
150
|
+
write(entries)
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
remove(stageId) {
|
|
154
|
+
const entries = read()
|
|
155
|
+
const filtered = entries.filter(e => e.stageId !== stageId)
|
|
156
|
+
if (filtered.length !== entries.length) {
|
|
157
|
+
write(filtered)
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
loadAll() {
|
|
162
|
+
return read()
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
clear() {
|
|
166
|
+
try {
|
|
167
|
+
unlinkSync(filePath)
|
|
168
|
+
} catch {
|
|
169
|
+
// File may not exist — that's fine.
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -150,9 +150,19 @@ export function redeliverBufferedInbound(
|
|
|
150
150
|
* approvals, subagent handbacks, warmup, reaction triggers) all tag a
|
|
151
151
|
* `meta.source`; the user-message inbound built in gateway.ts sets none.
|
|
152
152
|
* Restricting to source-less inbounds keeps merge-on-drain away from the
|
|
153
|
-
* #1150 wake-up class entirely.
|
|
153
|
+
* #1150 wake-up class entirely.
|
|
154
|
+
*
|
|
155
|
+
* Button taps (#271, `meta.button_callback`) are ALSO excluded even though
|
|
156
|
+
* they carry no `meta.source`: `mergeRun` keeps only the anchor (last)
|
|
157
|
+
* message's meta, so a tap merged with an adjacent buffered user text would
|
|
158
|
+
* silently drop its `button_callback_data`/`button_text` whenever the text
|
|
159
|
+
* is last — the agent would see the `[user tapped button: …]` line without
|
|
160
|
+
* the machine-readable payload. Taps deliver individually. */
|
|
154
161
|
function isMergeableUserInbound(msg: InboundMessage): boolean {
|
|
155
|
-
return
|
|
162
|
+
return (
|
|
163
|
+
msg.type === 'inbound' &&
|
|
164
|
+
(msg.meta == null || (msg.meta.source == null && msg.meta.button_callback == null))
|
|
165
|
+
)
|
|
156
166
|
}
|
|
157
167
|
|
|
158
168
|
function inboundHasMedia(msg: InboundMessage): boolean {
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boot-time-only, chat-scoped resolution of a raw Telegram id/username into
|
|
3
|
+
* a human `person_id` (e.g. "Lisa") for display in the `<channel>` tag's
|
|
4
|
+
* `user` attribute.
|
|
5
|
+
*
|
|
6
|
+
* Design (converged after adversarial review — see the PR description for
|
|
7
|
+
* the tradeoffs, do not re-litigate here):
|
|
8
|
+
*
|
|
9
|
+
* - No MCP tool. Not callable by agents — display-only, gateway-internal.
|
|
10
|
+
* - No hot-reload. `PersonDirectory` is built ONCE at gateway boot from
|
|
11
|
+
* the static in-memory `people.json` the scaffold projected from
|
|
12
|
+
* `switchroom.yaml`'s `users:` block. A config change requires an agent
|
|
13
|
+
* restart to take effect — this file never re-reads anything.
|
|
14
|
+
* - `access.json` (the fail-CLOSED allow-list) is a completely separate
|
|
15
|
+
* concern and is never touched here. This feature is fail-OPEN: an
|
|
16
|
+
* unresolved id/username falls back to today's behavior (the caller
|
|
17
|
+
* keeps using the raw id/username) — it never blocks or denies
|
|
18
|
+
* anything.
|
|
19
|
+
* - Chat-scoped: a resolved name is only ever returned for a chat/group
|
|
20
|
+
* the person is actually a member of. In a DM the chat IS the sender,
|
|
21
|
+
* so resolution always applies. In a group, resolution only applies if
|
|
22
|
+
* the sender's id or username is explicitly present in that group's
|
|
23
|
+
* `allowFrom` (read from the EXISTING `access.json`/`loadAccess()`
|
|
24
|
+
* data — no new membership source). An empty/unset group `allowFrom`
|
|
25
|
+
* means membership can't be positively confirmed, so we conservatively
|
|
26
|
+
* do NOT resolve (the raw id/username is shown instead) — a name safe
|
|
27
|
+
* in a 1:1 DM could be a bigger leak in a shared group.
|
|
28
|
+
* - Per-entry validation: a malformed `users:` entry (duplicate
|
|
29
|
+
* `person_id` claimed by two different keys, empty/invalid
|
|
30
|
+
* `person_id`, no `telegram_ids`) drops ONLY that one entry — it never
|
|
31
|
+
* blanks resolution for the whole fleet. `buildPersonDirectory` never
|
|
32
|
+
* throws; every failure mode is reported via its `dropped` return
|
|
33
|
+
* value instead, so the caller can route it through the existing
|
|
34
|
+
* fleet-alert path (`emitGatewayOperatorEvent` in gateway.ts) at
|
|
35
|
+
* low/config severity — this must never page like a real outage.
|
|
36
|
+
*
|
|
37
|
+
* Fix note (accepted soft mitigation — see docs/configuration.md): there is
|
|
38
|
+
* no automated enforcement that a configured `person_id` stays safe to show
|
|
39
|
+
* if a group's membership changes AFTER the entry is written. This is an
|
|
40
|
+
* operator-discipline convention, not a closed gap. Low severity today —
|
|
41
|
+
* only two `person_id`s are configured fleet-wide.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
export interface RawPersonEntry {
|
|
45
|
+
/** The `users:` map key (e.g. "lisa") — carried through for alert text. */
|
|
46
|
+
key: string
|
|
47
|
+
person_id: string
|
|
48
|
+
telegram_ids: string[]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface PersonDirectoryEntry {
|
|
52
|
+
key: string
|
|
53
|
+
personId: string
|
|
54
|
+
/** Normalized (lowercased, no leading "@") telegram ids/usernames. */
|
|
55
|
+
telegramKeys: string[]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface PersonDirectory {
|
|
59
|
+
/** normalized telegram id/username -> directory entry */
|
|
60
|
+
byTelegramKey: Record<string, PersonDirectoryEntry>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface DroppedPersonEntry {
|
|
64
|
+
key: string
|
|
65
|
+
reason: string
|
|
66
|
+
/**
|
|
67
|
+
* Name-scrubbed reason CLASS — the same rejection without the embedded
|
|
68
|
+
* `person_id` human-name value or the colliding telegram id. Used to
|
|
69
|
+
* build the broadcast `alertDetail` so a config-warning card (which
|
|
70
|
+
* `emitGatewayOperatorEvent` sends to EVERY `allowFrom` chat, including
|
|
71
|
+
* group chats where the named person may NOT be a member) never surfaces
|
|
72
|
+
* a human name that the display path (`resolvePersonName`) deliberately
|
|
73
|
+
* chat-scopes. The verbose `reason` is still carried for the operator's
|
|
74
|
+
* own stderr `logLine` (private, not broadcast).
|
|
75
|
+
*/
|
|
76
|
+
reasonClass: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface BuildPersonDirectoryResult {
|
|
80
|
+
directory: PersonDirectory
|
|
81
|
+
dropped: DroppedPersonEntry[]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Normalize a telegram id or @username for keying: trim, strip a leading
|
|
85
|
+
* "@", lowercase (Telegram usernames are case-insensitive). */
|
|
86
|
+
function normalizeTelegramKey(raw: string): string {
|
|
87
|
+
return raw.trim().replace(/^@/, '').toLowerCase()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Validate and dedupe raw `users:` `person_id` entries into a lookup
|
|
92
|
+
* directory. Never throws — every rejection is reported via `dropped`
|
|
93
|
+
* instead, per the fail-open / drop-only-that-entry design.
|
|
94
|
+
*
|
|
95
|
+
* Drop reasons:
|
|
96
|
+
* - missing/blank `key` or `person_id`
|
|
97
|
+
* - `telegram_ids` missing or empty (nothing to key the entry by)
|
|
98
|
+
* - `person_id` already claimed by an earlier (different) entry key —
|
|
99
|
+
* first-declared entry wins, the later duplicate is dropped whole
|
|
100
|
+
* (not just the colliding id) so the alert is unambiguous about which
|
|
101
|
+
* entry lost
|
|
102
|
+
* - a `telegram_id`/username already claimed by an earlier (different)
|
|
103
|
+
* entry key — same first-declared-wins convention: the later duplicate
|
|
104
|
+
* is dropped whole so the alert is unambiguous about which entry lost
|
|
105
|
+
* and we never silently last-write-wins a numeric id into the wrong
|
|
106
|
+
* person's name
|
|
107
|
+
*/
|
|
108
|
+
export function buildPersonDirectory(entries: readonly RawPersonEntry[]): BuildPersonDirectoryResult {
|
|
109
|
+
const byTelegramKey: Record<string, PersonDirectoryEntry> = {}
|
|
110
|
+
const dropped: DroppedPersonEntry[] = []
|
|
111
|
+
const personIdOwner = new Map<string, string>() // person_id (lowercased) -> owning entry key
|
|
112
|
+
const telegramKeyOwner = new Map<string, string>() // normalized telegram id/username -> owning entry key
|
|
113
|
+
|
|
114
|
+
for (const raw of entries) {
|
|
115
|
+
const key = typeof raw.key === 'string' ? raw.key.trim() : ''
|
|
116
|
+
const personId = typeof raw.person_id === 'string' ? raw.person_id.trim() : ''
|
|
117
|
+
|
|
118
|
+
if (key.length === 0) {
|
|
119
|
+
dropped.push({ key: raw.key || '(unknown)', reason: 'missing users: map key', reasonClass: 'missing users: map key' })
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
if (personId.length === 0) {
|
|
123
|
+
dropped.push({ key, reason: 'empty or missing person_id', reasonClass: 'empty or missing person_id' })
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
if (!Array.isArray(raw.telegram_ids) || raw.telegram_ids.length === 0) {
|
|
127
|
+
dropped.push({ key, reason: 'no telegram_ids to resolve against', reasonClass: 'no telegram_ids to resolve against' })
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const personIdLower = personId.toLowerCase()
|
|
132
|
+
const existingOwner = personIdOwner.get(personIdLower)
|
|
133
|
+
if (existingOwner != null && existingOwner !== key) {
|
|
134
|
+
dropped.push({
|
|
135
|
+
key,
|
|
136
|
+
// Verbose reason (stderr logLine only — private to the operator):
|
|
137
|
+
// names the person_id value + the owning config key.
|
|
138
|
+
reason: `duplicate person_id "${personId}" already claimed by users.${existingOwner}`,
|
|
139
|
+
// reasonClass (broadcast alertDetail): scrubs the human-name
|
|
140
|
+
// person_id value; keeps the entry key + collision class. A
|
|
141
|
+
// config-warning card fans out to every allowFrom chat, so the
|
|
142
|
+
// name must not travel further than the display path allows.
|
|
143
|
+
reasonClass: `duplicate person_id already claimed by users.${existingOwner}`,
|
|
144
|
+
})
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
personIdOwner.set(personIdLower, key)
|
|
148
|
+
|
|
149
|
+
const telegramKeys = [...new Set(raw.telegram_ids.map(normalizeTelegramKey).filter((k) => k.length > 0))]
|
|
150
|
+
if (telegramKeys.length === 0) {
|
|
151
|
+
dropped.push({ key, reason: 'telegram_ids contained no usable id/username', reasonClass: 'telegram_ids contained no usable id/username' })
|
|
152
|
+
personIdOwner.delete(personIdLower)
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const collidingTelegramKey = telegramKeys.find((tk) => {
|
|
157
|
+
const existingTkOwner = telegramKeyOwner.get(tk)
|
|
158
|
+
return existingTkOwner != null && existingTkOwner !== key
|
|
159
|
+
})
|
|
160
|
+
if (collidingTelegramKey != null) {
|
|
161
|
+
const existingTkOwner = telegramKeyOwner.get(collidingTelegramKey)
|
|
162
|
+
dropped.push({
|
|
163
|
+
key,
|
|
164
|
+
reason: `duplicate telegram_id "${collidingTelegramKey}" already claimed by users.${existingTkOwner}`,
|
|
165
|
+
// Scrub the colliding telegram id/username from the broadcast
|
|
166
|
+
// reason; keep the owning config key (operator-chosen slug).
|
|
167
|
+
reasonClass: `duplicate telegram_id already claimed by users.${existingTkOwner}`,
|
|
168
|
+
})
|
|
169
|
+
personIdOwner.delete(personIdLower)
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const entry: PersonDirectoryEntry = { key, personId, telegramKeys }
|
|
174
|
+
for (const tk of telegramKeys) {
|
|
175
|
+
telegramKeyOwner.set(tk, key)
|
|
176
|
+
byTelegramKey[tk] = entry
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return { directory: { byTelegramKey }, dropped }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface PersonDirectoryBootResult {
|
|
184
|
+
directory: PersonDirectory
|
|
185
|
+
/** Non-null when the caller should route an alert through the fleet
|
|
186
|
+
* alert path (`emitGatewayOperatorEvent`, kind: 'config-warning'). */
|
|
187
|
+
alertDetail: string | null
|
|
188
|
+
/** Always present — one human-readable line for stderr. */
|
|
189
|
+
logLine: string
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Orchestrate the ONE-TIME boot-time check (requirement: boot-time-only,
|
|
194
|
+
* NOT periodic — call this exactly once, at gateway boot, never on a
|
|
195
|
+
* timer/interval). Extracted as a pure(ish) function — taking the file
|
|
196
|
+
* read as an injected dependency and RETURNING what to log/alert rather
|
|
197
|
+
* than performing the I/O itself — so it's unit-testable without booting
|
|
198
|
+
* the real gateway process.
|
|
199
|
+
*
|
|
200
|
+
* Dead-man's-switch: wraps the whole check in try/catch. If anything
|
|
201
|
+
* throws before completing (including `readEntries` itself), that failure
|
|
202
|
+
* is ALSO surfaced via `alertDetail` — mirrors a past incident where
|
|
203
|
+
* `access.json` validation failed silently in the wrong (fail-open)
|
|
204
|
+
* direction, undetected for hours. `PERSON_DIRECTORY` falls back to an
|
|
205
|
+
* empty directory on crash (fail-open: raw ids/usernames keep showing).
|
|
206
|
+
*/
|
|
207
|
+
export function runPersonDirectoryBootCheck(readEntries: () => RawPersonEntry[]): PersonDirectoryBootResult {
|
|
208
|
+
try {
|
|
209
|
+
const rawEntries = readEntries()
|
|
210
|
+
const { directory, dropped } = buildPersonDirectory(rawEntries)
|
|
211
|
+
|
|
212
|
+
if (dropped.length > 0) {
|
|
213
|
+
// Broadcast-safe summary for `alertDetail` (fans out to EVERY
|
|
214
|
+
// allowFrom chat via emitGatewayOperatorEvent, including group chats
|
|
215
|
+
// where the named person may not be a member): built from
|
|
216
|
+
// `reasonClass`, which scrubs the embedded person_id human-name
|
|
217
|
+
// value and the colliding telegram id. The verbose `reason`
|
|
218
|
+
// (names the values) is kept for the operator's own stderr
|
|
219
|
+
// `logLine` — private, never broadcast.
|
|
220
|
+
const alertSummary = dropped.map((d) => `${d.key} (${d.reasonClass})`).join('; ')
|
|
221
|
+
const logSummary = dropped.map((d) => `${d.key} (${d.reason})`).join('; ')
|
|
222
|
+
const plural = dropped.length === 1 ? 'y' : 'ies'
|
|
223
|
+
return {
|
|
224
|
+
directory,
|
|
225
|
+
alertDetail: `person_id: dropped ${dropped.length} malformed users: entr${plural} at boot — ${alertSummary}`,
|
|
226
|
+
logLine: `telegram gateway: person_id boot validation dropped ${dropped.length} entr${plural}: ${logSummary}`,
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
directory,
|
|
232
|
+
alertDetail: null,
|
|
233
|
+
logLine: rawEntries.length > 0
|
|
234
|
+
? `telegram gateway: person_id boot validation ok — ${Object.keys(directory.byTelegramKey).length} telegram id/username(s) resolved`
|
|
235
|
+
: `telegram gateway: person_id boot validation ok — no person_id entries configured`,
|
|
236
|
+
}
|
|
237
|
+
} catch (err) {
|
|
238
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
239
|
+
return {
|
|
240
|
+
directory: { byTelegramKey: {} },
|
|
241
|
+
// Crash alertDetail scrubs the raw error message too — it can
|
|
242
|
+
// carry a filesystem path or other host detail that shouldn't fan
|
|
243
|
+
// out to every allowFrom chat. The full message stays in logLine.
|
|
244
|
+
alertDetail: `person_id boot validation crashed — name resolution disabled this boot (fail-open, raw ids/usernames will show); see gateway stderr for detail`,
|
|
245
|
+
logLine: `telegram gateway: person_id boot validation CRASHED (name resolution disabled this boot, raw ids will show — fail-open): ${msg}`,
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export interface ResolvePersonOptions {
|
|
251
|
+
telegramId: string
|
|
252
|
+
username?: string | undefined
|
|
253
|
+
isDm: boolean
|
|
254
|
+
/** That chat's/group's configured allowFrom, if any (from access.json). */
|
|
255
|
+
groupAllowFrom?: readonly string[] | undefined
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Resolve a sender's display name for THIS chat, chat-scoped per the
|
|
260
|
+
* module doc above. Returns undefined (fall back to raw id/username) when
|
|
261
|
+
* unresolved OR when membership in a group chat can't be positively
|
|
262
|
+
* confirmed.
|
|
263
|
+
*/
|
|
264
|
+
export function resolvePersonName(directory: PersonDirectory, opts: ResolvePersonOptions): string | undefined {
|
|
265
|
+
const idKey = normalizeTelegramKey(opts.telegramId)
|
|
266
|
+
const usernameKey = opts.username ? normalizeTelegramKey(opts.username) : undefined
|
|
267
|
+
|
|
268
|
+
const entry = directory.byTelegramKey[idKey] ?? (usernameKey ? directory.byTelegramKey[usernameKey] : undefined)
|
|
269
|
+
if (!entry) return undefined
|
|
270
|
+
|
|
271
|
+
// DM: the chat IS the sender, so resolution always applies.
|
|
272
|
+
if (opts.isDm) return entry.personId
|
|
273
|
+
|
|
274
|
+
// Group: only resolve if the sender is explicitly present in that
|
|
275
|
+
// chat's allowFrom (the existing membership source) — conservative
|
|
276
|
+
// fallback (undefined) otherwise, since we can't positively confirm
|
|
277
|
+
// membership from an empty/unset list.
|
|
278
|
+
const allowFrom = opts.groupAllowFrom ?? []
|
|
279
|
+
const allowFromNormalized = allowFrom.map(normalizeTelegramKey)
|
|
280
|
+
const memberConfirmed =
|
|
281
|
+
allowFromNormalized.includes(idKey) || (usernameKey != null && allowFromNormalized.includes(usernameKey))
|
|
282
|
+
return memberConfirmed ? entry.personId : undefined
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Fail-open call-site wrapper around `resolvePersonName`, for use at the
|
|
287
|
+
* `handleInbound` call site (gateway.ts). This feature's whole design
|
|
288
|
+
* point is "never blocks or denies anything" (module doc above) — a throw
|
|
289
|
+
* from resolution must fall back to the raw id/username, not abort message
|
|
290
|
+
* handling. Mirrors the same defensive pattern already used for
|
|
291
|
+
* `readPeopleFile` (gateway.ts) and `runPersonDirectoryBootCheck` (this
|
|
292
|
+
* file): catch, fall back, never propagate.
|
|
293
|
+
*/
|
|
294
|
+
export function safeResolvePersonName(
|
|
295
|
+
directory: PersonDirectory,
|
|
296
|
+
opts: ResolvePersonOptions,
|
|
297
|
+
rawFallback: string,
|
|
298
|
+
): string {
|
|
299
|
+
try {
|
|
300
|
+
return resolvePersonName(directory, opts) ?? rawFallback
|
|
301
|
+
} catch {
|
|
302
|
+
return rawFallback
|
|
303
|
+
}
|
|
304
|
+
}
|