switchroom 0.19.48 → 0.20.1
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/bin/handoff-briefing.sh +213 -74
- package/dist/agent-scheduler/index.js +18 -1
- package/dist/auth-broker/index.js +19 -2
- package/dist/buzz-gateway/index.js +9367 -0
- package/dist/cli/notion-write-pretool.mjs +18 -1
- package/dist/cli/switchroom.js +24734 -16371
- package/dist/host-control/main.js +59 -9
- package/dist/vault/approvals/kernel-server.js +19 -2
- package/dist/vault/broker/server.js +19 -2
- package/package.json +6 -4
- package/profiles/_base/start.sh.hbs +148 -2
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/skills/dev-protocol/SKILL.md +30 -1
- package/skills/switchroom-architecture/SKILL.md +5 -0
- package/skills/switchroom-cli/SKILL.md +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +7 -4
- package/telegram-plugin/dist/gateway/gateway.js +2376 -1039
- package/telegram-plugin/dist/server.js +7 -4
- package/telegram-plugin/gateway/access-store.test.ts +234 -0
- package/telegram-plugin/gateway/access-store.ts +194 -0
- package/telegram-plugin/gateway/boot-briefing-builder.ts +586 -0
- package/telegram-plugin/gateway/boot-briefing-capability.ts +31 -0
- package/telegram-plugin/gateway/boot-briefing-wiring.ts +332 -0
- package/telegram-plugin/gateway/buzz-mirror-correlation-store.ts +285 -0
- package/telegram-plugin/gateway/buzz-mirror.ts +494 -0
- package/telegram-plugin/gateway/buzz-type-guards.ts +34 -0
- package/telegram-plugin/gateway/channel-route.ts +272 -0
- package/telegram-plugin/gateway/gateway.ts +115 -203
- package/telegram-plugin/gateway/inbound-router.ts +93 -3
- package/telegram-plugin/gateway/inbound-spool.ts +33 -1
- package/telegram-plugin/gateway/ipc-protocol.ts +81 -2
- package/telegram-plugin/gateway/ipc-server.ts +197 -2
- package/telegram-plugin/gateway/outbound-send-path.ts +85 -2
- package/telegram-plugin/gateway/pending-turn-env.ts +70 -0
- package/telegram-plugin/gateway/stream-render.ts +21 -0
- package/telegram-plugin/gateway/subagent-handback-marker.ts +12 -0
- package/telegram-plugin/gateway/user-failure-notices.ts +172 -0
- package/telegram-plugin/history.ts +15 -0
- package/telegram-plugin/llm-error-present.ts +9 -4
- package/telegram-plugin/model-unavailable.ts +4 -0
- package/telegram-plugin/operator-events.fixtures.json +12 -12
- package/telegram-plugin/operator-events.ts +81 -9
- package/telegram-plugin/session-tail.ts +7 -1
- package/telegram-plugin/tests/boot-briefing-builder.test.ts +995 -0
- package/telegram-plugin/tests/buzz-mirror-correlation-store.test.ts +173 -0
- package/telegram-plugin/tests/buzz-mirror.test.ts +538 -0
- package/telegram-plugin/tests/buzz-origin-stamp-gate.test.ts +159 -0
- package/telegram-plugin/tests/channel-route.test.ts +306 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +47 -0
- package/telegram-plugin/tests/ipc-server-buzz-dedup.test.ts +124 -0
- package/telegram-plugin/tests/ipc-server-buzz-peer.test.ts +269 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +63 -0
- package/telegram-plugin/tests/operator-events.test.ts +71 -7
- package/telegram-plugin/tests/outbound-send-path.test.ts +24 -0
- package/telegram-plugin/tests/reply-to-buffer-fallback.test.ts +273 -0
- package/telegram-plugin/tests/reply-to-buffer-history.test.ts +134 -0
- package/telegram-plugin/tests/user-failure-notices.test.ts +165 -0
- package/telegram-plugin/voice-normalize-text.ts +5 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +4 -0
- package/vendor/hindsight-memory/scripts/recall.py +7 -2
|
@@ -17188,12 +17188,12 @@ function classifyClaudeError(raw) {
|
|
|
17188
17188
|
try {
|
|
17189
17189
|
return classifyInner(raw);
|
|
17190
17190
|
} catch {
|
|
17191
|
-
return "unknown-
|
|
17191
|
+
return "unknown-5xx";
|
|
17192
17192
|
}
|
|
17193
17193
|
}
|
|
17194
17194
|
function classifyInner(raw) {
|
|
17195
17195
|
if (raw == null)
|
|
17196
|
-
return "unknown-
|
|
17196
|
+
return "unknown-5xx";
|
|
17197
17197
|
const obj = typeof raw === "object" ? raw : {};
|
|
17198
17198
|
const errorType = extractString(obj, "error_type") ?? extractString(obj, "type") ?? extractString(getNestedObj(obj, "error"), "type") ?? "";
|
|
17199
17199
|
const errorCode = extractString(obj, "code") ?? extractString(getNestedObj(obj, "error"), "code") ?? "";
|
|
@@ -17238,13 +17238,16 @@ ${message}`;
|
|
|
17238
17238
|
if (errorType === "agent-restarted-unexpectedly" || errorCode === "agent-restarted-unexpectedly") {
|
|
17239
17239
|
return "agent-restarted-unexpectedly";
|
|
17240
17240
|
}
|
|
17241
|
+
if ((status == null || status >= 500) && (errorType === "server_error" || errorCode === "server_error" || sdkCode === "server_error" || errorType === "api_error" || errorCode === "api_error" || sdkCode === "api_error")) {
|
|
17242
|
+
return "transport-transient";
|
|
17243
|
+
}
|
|
17241
17244
|
if (status != null) {
|
|
17242
17245
|
if (status >= 400 && status < 500)
|
|
17243
17246
|
return "unknown-4xx";
|
|
17244
17247
|
if (status >= 500 && status < 600)
|
|
17245
17248
|
return "unknown-5xx";
|
|
17246
17249
|
}
|
|
17247
|
-
return "unknown-
|
|
17250
|
+
return "unknown-5xx";
|
|
17248
17251
|
}
|
|
17249
17252
|
function extractString(obj, key) {
|
|
17250
17253
|
const v = obj[key];
|
|
@@ -17792,7 +17795,7 @@ ${errStr}`) ? "rate-limited" : "quota-exhausted" : classifyClaudeError({ type: e
|
|
|
17792
17795
|
const raw = embeddedError ?? obj;
|
|
17793
17796
|
const kind = classifyClaudeError(embeddedError ?? obj);
|
|
17794
17797
|
const detail = extractDetailMessage(embeddedError) ?? extractDetailMessage(obj) ?? String(type ?? "");
|
|
17795
|
-
const transient = kind === "rate-limited";
|
|
17798
|
+
const transient = kind === "rate-limited" || kind === "transport-transient";
|
|
17796
17799
|
const retry = extractRetryState(obj);
|
|
17797
17800
|
const terminal = !transient ? true : retry.retryAttempt != null && retry.maxRetries != null ? retry.retryAttempt >= retry.maxRetries : isErrorLine;
|
|
17798
17801
|
return { kind, raw, detail, transient, terminal };
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for access-store.ts — the access/allowlist file layer extracted
|
|
3
|
+
* from gateway.ts (switchroom#4248).
|
|
4
|
+
*
|
|
5
|
+
* These lock in the behavior that used to be inline in gateway.ts, in
|
|
6
|
+
* particular the init-time-only static-mode snapshot (BOOT_ACCESS): in static
|
|
7
|
+
* mode the allowlist is read ONCE when the store is built and frozen for the
|
|
8
|
+
* life of the store, so a later edit to access.json on disk is NOT observed.
|
|
9
|
+
* That timing is the whole reason the store is a factory rather than a set of
|
|
10
|
+
* lazy free functions — this suite is the outcome guard for it.
|
|
11
|
+
*
|
|
12
|
+
* Run with: npx vitest run telegram-plugin/gateway/access-store.test.ts
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
16
|
+
import { mkdtempSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync } from 'node:fs'
|
|
17
|
+
import { tmpdir } from 'node:os'
|
|
18
|
+
import { join } from 'node:path'
|
|
19
|
+
import { createAccessStore, type AccessStoreDeps } from './access-store.js'
|
|
20
|
+
import type { Access } from './gateway.js'
|
|
21
|
+
|
|
22
|
+
function makeDeps(dir: string, isStatic: boolean): AccessStoreDeps {
|
|
23
|
+
return {
|
|
24
|
+
accessFile: join(dir, 'access.json'),
|
|
25
|
+
peopleFile: join(dir, 'people.json'),
|
|
26
|
+
stateDir: dir,
|
|
27
|
+
isStatic,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('access-store', () => {
|
|
32
|
+
let dir: string
|
|
33
|
+
let stderrSpy: ReturnType<typeof vi.spyOn>
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
dir = mkdtempSync(join(tmpdir(), 'access-store-'))
|
|
37
|
+
stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
stderrSpy.mockRestore()
|
|
42
|
+
rmSync(dir, { recursive: true, force: true })
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
// ─── defaultAccess ─────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
it('defaultAccess returns a pairing-mode empty allowlist', () => {
|
|
48
|
+
const store = createAccessStore(makeDeps(dir, false))
|
|
49
|
+
expect(store.defaultAccess()).toEqual({
|
|
50
|
+
dmPolicy: 'pairing',
|
|
51
|
+
allowFrom: [],
|
|
52
|
+
groups: {},
|
|
53
|
+
pending: {},
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
// ─── readAccessFile ────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
it('readAccessFile returns defaultAccess when the file is missing (ENOENT)', () => {
|
|
60
|
+
const store = createAccessStore(makeDeps(dir, false))
|
|
61
|
+
expect(store.readAccessFile()).toEqual(store.defaultAccess())
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('readAccessFile parses a valid access.json and projects known fields', () => {
|
|
65
|
+
writeFileSync(
|
|
66
|
+
join(dir, 'access.json'),
|
|
67
|
+
JSON.stringify({
|
|
68
|
+
dmPolicy: 'allowlist',
|
|
69
|
+
allowFrom: ['111'],
|
|
70
|
+
groups: { '-100': { requireMention: true, allowFrom: ['222'] } },
|
|
71
|
+
pending: {},
|
|
72
|
+
parseMode: 'text',
|
|
73
|
+
historyEnabled: false,
|
|
74
|
+
}),
|
|
75
|
+
)
|
|
76
|
+
const store = createAccessStore(makeDeps(dir, false))
|
|
77
|
+
const a = store.readAccessFile()
|
|
78
|
+
expect(a.dmPolicy).toBe('allowlist')
|
|
79
|
+
expect(a.allowFrom).toEqual(['111'])
|
|
80
|
+
expect(a.groups['-100']).toEqual({ requireMention: true, allowFrom: ['222'] })
|
|
81
|
+
expect(a.parseMode).toBe('text')
|
|
82
|
+
expect(a.historyEnabled).toBe(false)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('readAccessFile fails closed on number-array fields (the hand-edit bug)', () => {
|
|
86
|
+
// Unquoted IDs parse as numbers; validateStringArray must drop them.
|
|
87
|
+
writeFileSync(
|
|
88
|
+
join(dir, 'access.json'),
|
|
89
|
+
JSON.stringify({ dmPolicy: 'allowlist', allowFrom: [12345], groups: {}, pending: {} }),
|
|
90
|
+
)
|
|
91
|
+
const store = createAccessStore(makeDeps(dir, false))
|
|
92
|
+
expect(store.readAccessFile().allowFrom).toEqual([])
|
|
93
|
+
expect(stderrSpy).toHaveBeenCalled()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('readAccessFile moves a corrupt file aside and returns defaultAccess', () => {
|
|
97
|
+
writeFileSync(join(dir, 'access.json'), '{ not valid json')
|
|
98
|
+
const store = createAccessStore(makeDeps(dir, false))
|
|
99
|
+
expect(store.readAccessFile()).toEqual(store.defaultAccess())
|
|
100
|
+
// Original replaced by a .corrupt-* sibling.
|
|
101
|
+
expect(existsSync(join(dir, 'access.json'))).toBe(false)
|
|
102
|
+
const corrupt = readdirSync(dir).filter((f) => f.startsWith('access.json.corrupt-'))
|
|
103
|
+
expect(corrupt.length).toBe(1)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// ─── loadAccess — non-static re-reads live ─────────────────────────────────
|
|
107
|
+
|
|
108
|
+
it('loadAccess re-reads the file on every call in non-static mode', () => {
|
|
109
|
+
writeFileSync(
|
|
110
|
+
join(dir, 'access.json'),
|
|
111
|
+
JSON.stringify({ dmPolicy: 'allowlist', allowFrom: ['111'], groups: {}, pending: {} }),
|
|
112
|
+
)
|
|
113
|
+
const store = createAccessStore(makeDeps(dir, false))
|
|
114
|
+
expect(store.loadAccess().allowFrom).toEqual(['111'])
|
|
115
|
+
// Edit on disk is observed immediately (no snapshot in non-static mode).
|
|
116
|
+
writeFileSync(
|
|
117
|
+
join(dir, 'access.json'),
|
|
118
|
+
JSON.stringify({ dmPolicy: 'allowlist', allowFrom: ['222'], groups: {}, pending: {} }),
|
|
119
|
+
)
|
|
120
|
+
expect(store.loadAccess().allowFrom).toEqual(['222'])
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
// ─── Static-mode BOOT_ACCESS snapshot (init-time semantics) ────────────────
|
|
124
|
+
|
|
125
|
+
it('static mode snapshots at build time: downgrades pairing→allowlist and clears pending', () => {
|
|
126
|
+
writeFileSync(
|
|
127
|
+
join(dir, 'access.json'),
|
|
128
|
+
JSON.stringify({
|
|
129
|
+
dmPolicy: 'pairing',
|
|
130
|
+
allowFrom: ['111'],
|
|
131
|
+
groups: {},
|
|
132
|
+
pending: { code123: { senderId: 's', chatId: 'c', createdAt: 1, expiresAt: 2, replies: 0 } },
|
|
133
|
+
}),
|
|
134
|
+
)
|
|
135
|
+
const store = createAccessStore(makeDeps(dir, true))
|
|
136
|
+
const a = store.loadAccess()
|
|
137
|
+
expect(a.dmPolicy).toBe('allowlist')
|
|
138
|
+
expect(a.pending).toEqual({})
|
|
139
|
+
expect(a.allowFrom).toEqual(['111'])
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('static mode freezes the allowlist: an on-disk edit after build is NOT observed', () => {
|
|
143
|
+
writeFileSync(
|
|
144
|
+
join(dir, 'access.json'),
|
|
145
|
+
JSON.stringify({ dmPolicy: 'allowlist', allowFrom: ['111'], groups: {}, pending: {} }),
|
|
146
|
+
)
|
|
147
|
+
const store = createAccessStore(makeDeps(dir, true))
|
|
148
|
+
expect(store.loadAccess().allowFrom).toEqual(['111'])
|
|
149
|
+
// Change the file after the store was built.
|
|
150
|
+
writeFileSync(
|
|
151
|
+
join(dir, 'access.json'),
|
|
152
|
+
JSON.stringify({ dmPolicy: 'allowlist', allowFrom: ['999'], groups: {}, pending: {} }),
|
|
153
|
+
)
|
|
154
|
+
// Still the boot snapshot — this is the init-time-only guarantee.
|
|
155
|
+
expect(store.loadAccess().allowFrom).toEqual(['111'])
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// ─── saveAccess ────────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
it('saveAccess writes access.json atomically in non-static mode', () => {
|
|
161
|
+
const store = createAccessStore(makeDeps(dir, false))
|
|
162
|
+
const a: Access = { dmPolicy: 'allowlist', allowFrom: ['abc'], groups: {}, pending: {} }
|
|
163
|
+
store.saveAccess(a)
|
|
164
|
+
const written = JSON.parse(readFileSync(join(dir, 'access.json'), 'utf8'))
|
|
165
|
+
expect(written.allowFrom).toEqual(['abc'])
|
|
166
|
+
// No leftover temp file.
|
|
167
|
+
expect(existsSync(join(dir, 'access.json.tmp'))).toBe(false)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('saveAccess is a no-op in static mode', () => {
|
|
171
|
+
const store = createAccessStore(makeDeps(dir, true))
|
|
172
|
+
store.saveAccess({ dmPolicy: 'allowlist', allowFrom: ['abc'], groups: {}, pending: {} })
|
|
173
|
+
expect(existsSync(join(dir, 'access.json'))).toBe(false)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
// ─── pruneExpired ──────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
it('pruneExpired removes expired pending entries and reports whether it changed', () => {
|
|
179
|
+
const now = Date.now()
|
|
180
|
+
const store = createAccessStore(makeDeps(dir, false))
|
|
181
|
+
const a: Access = {
|
|
182
|
+
dmPolicy: 'pairing',
|
|
183
|
+
allowFrom: [],
|
|
184
|
+
groups: {},
|
|
185
|
+
pending: {
|
|
186
|
+
old: { senderId: 's', chatId: 'c', createdAt: 1, expiresAt: now - 1000, replies: 0 },
|
|
187
|
+
fresh: { senderId: 's', chatId: 'c', createdAt: 1, expiresAt: now + 100000, replies: 0 },
|
|
188
|
+
},
|
|
189
|
+
}
|
|
190
|
+
expect(store.pruneExpired(a)).toBe(true)
|
|
191
|
+
expect(Object.keys(a.pending)).toEqual(['fresh'])
|
|
192
|
+
// Second pass: nothing expired now → no change.
|
|
193
|
+
expect(store.pruneExpired(a)).toBe(false)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
// ─── assertAllowedChat ─────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
it('assertAllowedChat permits an allowFrom chat, a group chat, and rejects others', () => {
|
|
199
|
+
writeFileSync(
|
|
200
|
+
join(dir, 'access.json'),
|
|
201
|
+
JSON.stringify({
|
|
202
|
+
dmPolicy: 'allowlist',
|
|
203
|
+
allowFrom: ['111'],
|
|
204
|
+
groups: { '-100': { requireMention: false, allowFrom: [] } },
|
|
205
|
+
pending: {},
|
|
206
|
+
}),
|
|
207
|
+
)
|
|
208
|
+
const store = createAccessStore(makeDeps(dir, false))
|
|
209
|
+
expect(() => store.assertAllowedChat('111')).not.toThrow()
|
|
210
|
+
expect(() => store.assertAllowedChat(111)).not.toThrow() // number coerced
|
|
211
|
+
expect(() => store.assertAllowedChat('-100')).not.toThrow()
|
|
212
|
+
expect(() => store.assertAllowedChat('404')).toThrow(/not allowlisted/)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
// ─── readPeopleFile ────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
it('readPeopleFile returns entries for a valid people.json', () => {
|
|
218
|
+
writeFileSync(
|
|
219
|
+
join(dir, 'people.json'),
|
|
220
|
+
JSON.stringify({ entries: [{ telegramUserId: '5', personId: 'p1' }] }),
|
|
221
|
+
)
|
|
222
|
+
const store = createAccessStore(makeDeps(dir, false))
|
|
223
|
+
expect(store.readPeopleFile()).toEqual([{ telegramUserId: '5', personId: 'p1' }])
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('readPeopleFile fails open to [] on missing or corrupt file', () => {
|
|
227
|
+
const store = createAccessStore(makeDeps(dir, false))
|
|
228
|
+
expect(store.readPeopleFile()).toEqual([]) // ENOENT
|
|
229
|
+
writeFileSync(join(dir, 'people.json'), 'not json')
|
|
230
|
+
expect(store.readPeopleFile()).toEqual([]) // corrupt
|
|
231
|
+
writeFileSync(join(dir, 'people.json'), JSON.stringify({ entries: 'nope' }))
|
|
232
|
+
expect(store.readPeopleFile()).toEqual([]) // entries not an array
|
|
233
|
+
})
|
|
234
|
+
})
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Access / allowlist file layer for the Telegram gateway.
|
|
3
|
+
*
|
|
4
|
+
* This module owns the read/write side of `access.json` (the per-agent DM
|
|
5
|
+
* allowlist + group policy + pairing state) and the read-only `people.json`
|
|
6
|
+
* projection. It was extracted verbatim out of `gateway.ts` (switchroom#4248)
|
|
7
|
+
* to relieve the gateway line-ratchet; the behavior is byte-identical to the
|
|
8
|
+
* inline version.
|
|
9
|
+
*
|
|
10
|
+
* Init-time semantics preserved: in static-access mode the gateway snapshots
|
|
11
|
+
* `access.json` ONCE at module-init time (the `BOOT_ACCESS` constant below),
|
|
12
|
+
* downgrades a `pairing` dmPolicy to `allowlist`, and clears pending pairings —
|
|
13
|
+
* so a static agent's allowlist is frozen for the life of the process. That
|
|
14
|
+
* snapshot must happen at the same point in startup as before, so the store is
|
|
15
|
+
* built by a factory (`createAccessStore`) that the gateway calls at the
|
|
16
|
+
* original `BOOT_ACCESS` site; the snapshot runs eagerly inside the factory,
|
|
17
|
+
* NOT lazily on first `loadAccess()`.
|
|
18
|
+
*
|
|
19
|
+
* The gateway-internal dependencies (`ACCESS_FILE`, `PEOPLE_FILE`, `STATE_DIR`
|
|
20
|
+
* path constants and the `STATIC` mode flag) are injected as explicit params so
|
|
21
|
+
* this module holds no runtime import of gateway.ts. The only back-reference is
|
|
22
|
+
* the `Access` / `GroupPolicy` types, imported type-only (erased under
|
|
23
|
+
* `isolatedModules`, so no runtime cycle) — the same seam
|
|
24
|
+
* `turn-start-surfaces.ts` already uses.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
28
|
+
import { validateStringArray } from './access-validator.js'
|
|
29
|
+
import type { RawPersonEntry } from './resolve-person.js'
|
|
30
|
+
import type { Access } from './gateway.js'
|
|
31
|
+
|
|
32
|
+
/** The per-group policy shape, derived from the exported `Access` type so the
|
|
33
|
+
* store needs no extra symbol export from gateway.ts. */
|
|
34
|
+
type GroupPolicy = Access['groups'][string]
|
|
35
|
+
|
|
36
|
+
/** Gateway-internal deps the store needs, injected at build time. */
|
|
37
|
+
export interface AccessStoreDeps {
|
|
38
|
+
/** Absolute path to `access.json` (gateway's `ACCESS_FILE`). */
|
|
39
|
+
accessFile: string
|
|
40
|
+
/** Absolute path to `people.json` (gateway's `PEOPLE_FILE`). */
|
|
41
|
+
peopleFile: string
|
|
42
|
+
/** State dir created (mode 0o700) before an atomic access.json write. */
|
|
43
|
+
stateDir: string
|
|
44
|
+
/** Static-access mode (`TELEGRAM_ACCESS_MODE === 'static'`). Freezes the
|
|
45
|
+
* allowlist at init and turns `saveAccess` into a no-op. */
|
|
46
|
+
isStatic: boolean
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The access/allowlist file layer, bound to one set of paths + mode. */
|
|
50
|
+
export interface AccessStore {
|
|
51
|
+
defaultAccess(): Access
|
|
52
|
+
readAccessFile(): Access
|
|
53
|
+
loadAccess(): Access
|
|
54
|
+
readPeopleFile(): RawPersonEntry[]
|
|
55
|
+
assertAllowedChat(chat_id: string | number): void
|
|
56
|
+
saveAccess(a: Access): void
|
|
57
|
+
pruneExpired(a: Access): boolean
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Build the access store. The `BOOT_ACCESS` snapshot happens eagerly here, so
|
|
62
|
+
* call this at the same point in gateway startup where `BOOT_ACCESS` used to be
|
|
63
|
+
* initialized — that keeps the static-mode freeze timing identical.
|
|
64
|
+
*/
|
|
65
|
+
export function createAccessStore(deps: AccessStoreDeps): AccessStore {
|
|
66
|
+
const { accessFile, peopleFile, stateDir, isStatic } = deps
|
|
67
|
+
|
|
68
|
+
function defaultAccess(): Access {
|
|
69
|
+
return { dmPolicy: 'pairing', allowFrom: [], groups: {}, pending: {} }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readAccessFile(): Access {
|
|
73
|
+
try {
|
|
74
|
+
const raw = readFileSync(accessFile, 'utf8')
|
|
75
|
+
const parsed = JSON.parse(raw) as Partial<Access>
|
|
76
|
+
const allowFrom = validateStringArray('allowFrom', parsed.allowFrom ?? [])
|
|
77
|
+
const groups: Record<string, GroupPolicy> = {}
|
|
78
|
+
for (const [chatId, policy] of Object.entries(parsed.groups ?? {})) {
|
|
79
|
+
groups[chatId] = {
|
|
80
|
+
...policy,
|
|
81
|
+
allowFrom: validateStringArray(`groups.${chatId}.allowFrom`, policy.allowFrom ?? []),
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
dmPolicy: parsed.dmPolicy ?? 'pairing',
|
|
86
|
+
allowFrom,
|
|
87
|
+
groups,
|
|
88
|
+
pending: parsed.pending ?? {},
|
|
89
|
+
mentionPatterns: parsed.mentionPatterns,
|
|
90
|
+
ackReaction: parsed.ackReaction,
|
|
91
|
+
replyToMode: parsed.replyToMode,
|
|
92
|
+
textChunkLimit: parsed.textChunkLimit,
|
|
93
|
+
chunkMode: parsed.chunkMode,
|
|
94
|
+
parseMode: parsed.parseMode,
|
|
95
|
+
disableLinkPreview: parsed.disableLinkPreview,
|
|
96
|
+
coalescingGapMs: parsed.coalescingGapMs,
|
|
97
|
+
litellmNoticeWindowMs: parsed.litellmNoticeWindowMs,
|
|
98
|
+
coalesceMaxAttachments: parsed.coalesceMaxAttachments,
|
|
99
|
+
interruptSafeBoundary: parsed.interruptSafeBoundary,
|
|
100
|
+
interruptMaxWaitMs: parsed.interruptMaxWaitMs,
|
|
101
|
+
statusReactions: parsed.statusReactions,
|
|
102
|
+
historyEnabled: parsed.historyEnabled,
|
|
103
|
+
historyRetentionDays: parsed.historyRetentionDays,
|
|
104
|
+
// #596: telegram features projected into access.json by scaffold.
|
|
105
|
+
// Without these passthroughs, gateway readers (`access.voice_in`,
|
|
106
|
+
// `access.telegraph`, `access.stickers`) silently see undefined.
|
|
107
|
+
stickers: parsed.stickers,
|
|
108
|
+
voice_in: parsed.voice_in,
|
|
109
|
+
voice_out: parsed.voice_out,
|
|
110
|
+
telegraph: parsed.telegraph,
|
|
111
|
+
// #789: button-choice-confirmation config projected by scaffold.
|
|
112
|
+
button_choice_confirmation: parsed.button_choice_confirmation,
|
|
113
|
+
}
|
|
114
|
+
} catch (err) {
|
|
115
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return defaultAccess()
|
|
116
|
+
try { renameSync(accessFile, `${accessFile}.corrupt-${Date.now()}`) } catch {}
|
|
117
|
+
process.stderr.write(`telegram gateway: access.json is corrupt, moved aside. Starting fresh.\n`)
|
|
118
|
+
return defaultAccess()
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const BOOT_ACCESS: Access | null = isStatic
|
|
123
|
+
? (() => {
|
|
124
|
+
const a = readAccessFile()
|
|
125
|
+
if (a.dmPolicy === 'pairing') {
|
|
126
|
+
process.stderr.write('telegram gateway: static mode — dmPolicy "pairing" downgraded to "allowlist"\n')
|
|
127
|
+
a.dmPolicy = 'allowlist'
|
|
128
|
+
}
|
|
129
|
+
a.pending = {}
|
|
130
|
+
return a
|
|
131
|
+
})()
|
|
132
|
+
: null
|
|
133
|
+
|
|
134
|
+
function loadAccess(): Access {
|
|
135
|
+
return BOOT_ACCESS ?? readAccessFile()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Read `people.json` (the scaffold's plain projection of `users:` entries
|
|
140
|
+
* that carry a `person_id`). Fail-open: ENOENT or corrupt/malformed JSON
|
|
141
|
+
* returns an empty array rather than throwing — this feature must never
|
|
142
|
+
* block startup. Unlike `access.json` this file is never gateway-mutated,
|
|
143
|
+
* so there's no "move corrupt file aside" concern; the scaffold owns and
|
|
144
|
+
* regenerates it on every reconcile.
|
|
145
|
+
*/
|
|
146
|
+
function readPeopleFile(): RawPersonEntry[] {
|
|
147
|
+
try {
|
|
148
|
+
const raw = readFileSync(peopleFile, 'utf8')
|
|
149
|
+
const parsed = JSON.parse(raw) as { entries?: unknown }
|
|
150
|
+
if (!Array.isArray(parsed.entries)) return []
|
|
151
|
+
return parsed.entries as RawPersonEntry[]
|
|
152
|
+
} catch {
|
|
153
|
+
return []
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function assertAllowedChat(chat_id: string | number): void {
|
|
158
|
+
const id = String(chat_id)
|
|
159
|
+
const access = loadAccess()
|
|
160
|
+
if (access.allowFrom.includes(id)) return
|
|
161
|
+
if (id in access.groups) return
|
|
162
|
+
throw new Error(`chat ${id} is not allowlisted — add via /telegram:access`)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function saveAccess(a: Access): void {
|
|
166
|
+
if (isStatic) return
|
|
167
|
+
mkdirSync(stateDir, { recursive: true, mode: 0o700 })
|
|
168
|
+
const tmp = accessFile + '.tmp'
|
|
169
|
+
writeFileSync(tmp, JSON.stringify(a, null, 2) + '\n', { mode: 0o600 })
|
|
170
|
+
renameSync(tmp, accessFile)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function pruneExpired(a: Access): boolean {
|
|
174
|
+
const now = Date.now()
|
|
175
|
+
let changed = false
|
|
176
|
+
for (const [code, p] of Object.entries(a.pending)) {
|
|
177
|
+
if (p.expiresAt < now) {
|
|
178
|
+
delete a.pending[code]
|
|
179
|
+
changed = true
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return changed
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
defaultAccess,
|
|
187
|
+
readAccessFile,
|
|
188
|
+
loadAccess,
|
|
189
|
+
readPeopleFile,
|
|
190
|
+
assertAllowedChat,
|
|
191
|
+
saveAccess,
|
|
192
|
+
pruneExpired,
|
|
193
|
+
}
|
|
194
|
+
}
|