@0xmaxma/claude-gateway 1.3.24 → 1.3.31
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/README.md +172 -50
- package/config.template.json +6 -2
- package/dist/agent/incident-store.d.ts +89 -0
- package/dist/agent/incident-store.d.ts.map +1 -0
- package/dist/agent/incident-store.js +299 -0
- package/dist/agent/incident-store.js.map +1 -0
- package/dist/agent/incident.d.ts +156 -0
- package/dist/agent/incident.d.ts.map +1 -0
- package/dist/agent/incident.js +177 -0
- package/dist/agent/incident.js.map +1 -0
- package/dist/agent/recovery-executor.d.ts +117 -0
- package/dist/agent/recovery-executor.d.ts.map +1 -0
- package/dist/agent/recovery-executor.js +168 -0
- package/dist/agent/recovery-executor.js.map +1 -0
- package/dist/agent/recovery-policy.d.ts +97 -0
- package/dist/agent/recovery-policy.d.ts.map +1 -0
- package/dist/agent/recovery-policy.js +164 -0
- package/dist/agent/recovery-policy.js.map +1 -0
- package/dist/agent/runner.d.ts +44 -0
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +272 -2
- package/dist/agent/runner.js.map +1 -1
- package/dist/agent/safe-mode.d.ts +61 -0
- package/dist/agent/safe-mode.d.ts.map +1 -0
- package/dist/agent/safe-mode.js +102 -0
- package/dist/agent/safe-mode.js.map +1 -0
- package/dist/agent/triage.d.ts +94 -0
- package/dist/agent/triage.d.ts.map +1 -0
- package/dist/agent/triage.js +209 -0
- package/dist/agent/triage.js.map +1 -0
- package/dist/agent/turn-trace.d.ts +120 -0
- package/dist/agent/turn-trace.d.ts.map +1 -0
- package/dist/agent/turn-trace.js +122 -0
- package/dist/agent/turn-trace.js.map +1 -0
- package/dist/api/gateway-router.d.ts +21 -0
- package/dist/api/gateway-router.d.ts.map +1 -1
- package/dist/api/gateway-router.js +58 -17
- package/dist/api/gateway-router.js.map +1 -1
- package/dist/api/line-pending-senders.d.ts +7 -2
- package/dist/api/line-pending-senders.d.ts.map +1 -1
- package/dist/api/line-pending-senders.js +7 -2
- package/dist/api/line-pending-senders.js.map +1 -1
- package/dist/api/router.d.ts.map +1 -1
- package/dist/api/router.js +563 -172
- package/dist/api/router.js.map +1 -1
- package/dist/api/wizard-state.d.ts +1 -4
- package/dist/api/wizard-state.d.ts.map +1 -1
- package/dist/api/wizard-state.js.map +1 -1
- package/dist/config/migrator.d.ts +4 -0
- package/dist/config/migrator.d.ts.map +1 -1
- package/dist/config/migrator.js +60 -3
- package/dist/config/migrator.js.map +1 -1
- package/dist/discord/receiver.d.ts +1 -0
- package/dist/discord/receiver.d.ts.map +1 -1
- package/dist/discord/receiver.js +24 -6
- package/dist/discord/receiver.js.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/session/process.d.ts +18 -0
- package/dist/session/process.d.ts.map +1 -1
- package/dist/session/process.js +61 -1
- package/dist/session/process.js.map +1 -1
- package/dist/shell/claude-pty-shell.js +59 -0
- package/dist/shell/claude-pty-shell.js.map +1 -1
- package/dist/shell/control-channel.d.ts +74 -0
- package/dist/shell/control-channel.d.ts.map +1 -0
- package/dist/shell/control-channel.js +114 -0
- package/dist/shell/control-channel.js.map +1 -0
- package/dist/telegram/receiver.d.ts +1 -0
- package/dist/telegram/receiver.d.ts.map +1 -1
- package/dist/telegram/receiver.js +18 -6
- package/dist/telegram/receiver.js.map +1 -1
- package/dist/types.d.ts +21 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/ui/web-ui.d.ts.map +1 -1
- package/dist/ui/web-ui.js +103 -2
- package/dist/ui/web-ui.js.map +1 -1
- package/mcp/tools/discord/access.ts +136 -20
- package/mcp/tools/discord/client.ts +3 -1
- package/mcp/tools/discord/module.ts +75 -4
- package/mcp/tools/discord/skills/access/SKILL.md +66 -8
- package/mcp/tools/discord/skills/configure/SKILL.md +11 -1
- package/mcp/tools/discord/types.ts +21 -2
- package/mcp/tools/skills/handlers.ts +4 -2
- package/mcp/tools/telegram/dedup.ts +4 -1
- package/mcp/tools/telegram/module.ts +5 -14
- package/mcp/tools/telegram/pure.ts +169 -31
- package/mcp/tools/telegram/receiver-server.ts +271 -78
- package/mcp/tools/telegram/skills/access/SKILL.md +93 -23
- package/mcp/tools/telegram/skills/configure/SKILL.md +31 -26
- package/mcp/tools/telegram/typing.ts +124 -0
- package/package.json +3 -1
|
@@ -28,15 +28,22 @@ import { randomBytes } from 'crypto'
|
|
|
28
28
|
import { readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, statSync, renameSync, realpathSync, chmodSync, existsSync } from 'fs'
|
|
29
29
|
import { homedir } from 'os'
|
|
30
30
|
import { join, extname, sep } from 'path'
|
|
31
|
+
import { execFileSync } from 'child_process'
|
|
31
32
|
import { createWorkingStateManager, drainOrphanForwards } from './typing'
|
|
33
|
+
// Import compiled dist/, not raw src/ — src/ is not published (files: ["mcp/"]),
|
|
34
|
+
// so a src/ import crashes this bun-run receiver on installed packages (the bug
|
|
35
|
+
// that silenced every bot on systemd installs). Enforced by
|
|
36
|
+
// tests/unit/mcp-no-src-imports.test.ts.
|
|
37
|
+
import { formatTurnIncident, type TurnIncident } from '../../../dist/agent/turn-trace.js'
|
|
38
|
+
import { createIncidentStore } from '../../../dist/agent/incident-store.js'
|
|
39
|
+
import type { RecoveryOutcome } from '../../../dist/agent/incident.js'
|
|
32
40
|
import { initDedupDir, isDuplicate as _isDuplicate, pruneDedup as _pruneDedup } from './dedup'
|
|
33
|
-
import { hasMarkdown, toTelegramHtml } from './pure'
|
|
41
|
+
import { hasMarkdown, toTelegramHtml, migrateAccess } from './pure'
|
|
34
42
|
|
|
35
43
|
// Standalone fallback: default state dir to ~/.claude/channels/telegram
|
|
36
44
|
const STATE_DIR = process.env.TELEGRAM_STATE_DIR ?? join(homedir(), '.claude', 'channels', 'telegram')
|
|
37
45
|
const ACCESS_FILE = join(STATE_DIR, 'access.json')
|
|
38
46
|
const APPROVED_DIR = join(STATE_DIR, 'approved')
|
|
39
|
-
const AWAITING_OWNER_FILE = join(STATE_DIR, 'awaiting-owner')
|
|
40
47
|
const ENV_FILE = join(STATE_DIR, '.env')
|
|
41
48
|
|
|
42
49
|
// Load .env fallback when token not injected via env block (standalone mode).
|
|
@@ -105,6 +112,70 @@ let botUsername = ''
|
|
|
105
112
|
|
|
106
113
|
const TYPING_DIR = join(STATE_DIR, 'typing')
|
|
107
114
|
|
|
115
|
+
// ─── Incident store (Epic #195, Phase 2) ────────────────────────────────────
|
|
116
|
+
// Persist turn-trace stalls as scrubbed on-disk bundles, deduped by fingerprint
|
|
117
|
+
// (stage + failure class + CLI version) with escalation. The store owns all
|
|
118
|
+
// disk IO; the pure decision logic lives in src/agent/incident.ts.
|
|
119
|
+
const INCIDENTS_DIR = join(homedir(), '.claude-gateway', 'incidents')
|
|
120
|
+
|
|
121
|
+
// The Claude CLI version is part of the fingerprint. Resolve it once and cache
|
|
122
|
+
// it — a new version is a new fingerprint, and the receiver is restarted when
|
|
123
|
+
// the CLI is updated, so a per-process cache is safe.
|
|
124
|
+
let cachedCliVersion: string | null = null
|
|
125
|
+
function getCliVersion(): string {
|
|
126
|
+
if (cachedCliVersion !== null) return cachedCliVersion
|
|
127
|
+
try {
|
|
128
|
+
const out = execFileSync('claude', ['--version'], {
|
|
129
|
+
encoding: 'utf8',
|
|
130
|
+
timeout: 5000,
|
|
131
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
132
|
+
})
|
|
133
|
+
const m = out.trim().match(/^(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/)
|
|
134
|
+
cachedCliVersion = m ? m[1] : 'unknown'
|
|
135
|
+
} catch {
|
|
136
|
+
cachedCliVersion = 'unknown'
|
|
137
|
+
}
|
|
138
|
+
return cachedCliVersion
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Gateway version, best-effort, for manifest context only (not fingerprinted).
|
|
142
|
+
function readGatewayVersion(): string {
|
|
143
|
+
for (const rel of ['../../../package.json', '../../package.json']) {
|
|
144
|
+
try {
|
|
145
|
+
const raw = readFileSync(join(__dirname, rel), 'utf8')
|
|
146
|
+
const v = (JSON.parse(raw) as { version?: string }).version
|
|
147
|
+
if (typeof v === 'string' && v.length > 0) return v
|
|
148
|
+
} catch {
|
|
149
|
+
// try next candidate
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return 'unknown'
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const incidentStore = createIncidentStore({
|
|
156
|
+
dir: INCIDENTS_DIR,
|
|
157
|
+
fs: { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync },
|
|
158
|
+
now: () => Date.now(),
|
|
159
|
+
channel: 'telegram',
|
|
160
|
+
gatewayVersion: readGatewayVersion(),
|
|
161
|
+
getCliVersion,
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
// Prune old bundles at startup and daily thereafter (retention is enforced in
|
|
165
|
+
// the store; this just triggers it periodically).
|
|
166
|
+
try {
|
|
167
|
+
incidentStore.prune()
|
|
168
|
+
} catch {
|
|
169
|
+
// Non-fatal: a prune failure must not stop the receiver from serving.
|
|
170
|
+
}
|
|
171
|
+
setInterval(() => {
|
|
172
|
+
try {
|
|
173
|
+
incidentStore.prune()
|
|
174
|
+
} catch {
|
|
175
|
+
/* non-fatal */
|
|
176
|
+
}
|
|
177
|
+
}, 24 * 60 * 60 * 1000).unref()
|
|
178
|
+
|
|
108
179
|
const typingManager = createWorkingStateManager(
|
|
109
180
|
TYPING_DIR,
|
|
110
181
|
{
|
|
@@ -118,25 +189,126 @@ const typingManager = createWorkingStateManager(
|
|
|
118
189
|
]),
|
|
119
190
|
},
|
|
120
191
|
{ mkdirSync, writeFileSync, existsSync, rmSync, readFileSync, statSync },
|
|
192
|
+
// Turn-trace watchdog sink (Epic #195, Phase 2): persist a scrubbed incident
|
|
193
|
+
// bundle, then notify the affected chat only when the escalation rules say so
|
|
194
|
+
// (quiet on the first stall, louder once a fingerprint repeats). Any failure
|
|
195
|
+
// here is swallowed — incident bookkeeping must never break the live channel.
|
|
196
|
+
(incident, evidence) => {
|
|
197
|
+
process.stderr.write(`telegram channel: ${formatTurnIncident(incident)}\n`)
|
|
198
|
+
try {
|
|
199
|
+
const result = incidentStore.record(incident, evidence)
|
|
200
|
+
if (result.escalation.notify) {
|
|
201
|
+
void notifyIncident(incident.chatId, result)
|
|
202
|
+
incidentStore.markNotified(result.id, result.escalation.level)
|
|
203
|
+
}
|
|
204
|
+
// Cross-process recovery bridge (Epic #195, Phase 3b): for stages whose
|
|
205
|
+
// recovery lives in the runner (session/CLI), ask it to attempt recovery
|
|
206
|
+
// and persist the outcome. Fire-and-forget — recovery must never block the
|
|
207
|
+
// channel, and it is a no-op unless the operator enabled autoRecover.
|
|
208
|
+
void attemptRecover(incident, result.id)
|
|
209
|
+
} catch (err) {
|
|
210
|
+
process.stderr.write(
|
|
211
|
+
`telegram channel: incident persist failed: ${String(err)}\n`,
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
},
|
|
121
215
|
)
|
|
122
216
|
|
|
217
|
+
// Stages whose recovery must run in the runner process (live session control):
|
|
218
|
+
// session injection, CLI startup, and no-output progress wedges. Inbound
|
|
219
|
+
// (channel ingress) and delivery/dispatch (transport) are receiver-side and are
|
|
220
|
+
// not bridged here.
|
|
221
|
+
const RUNNER_RECOVERABLE_STAGES: ReadonlySet<string> = new Set(['inject', 'startup', 'progress'])
|
|
222
|
+
|
|
223
|
+
// A stable-per-turn key for the runner's intervention budget. Uses the stalled
|
|
224
|
+
// stage's start second (at − sinceMs), so repeated detections of the SAME wedged
|
|
225
|
+
// turn share a budget while a genuinely new turn gets a fresh one.
|
|
226
|
+
function recoveryTurnKey(incident: TurnIncident): string {
|
|
227
|
+
const startSec = Math.round((incident.at - incident.sinceMs) / 1000)
|
|
228
|
+
return `${incident.chatId}:${startSec}`
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Ask the runner to attempt recovery for a session/CLI-stage stall, then persist
|
|
232
|
+
// the returned outcome to the incident bundle. Best-effort and non-blocking.
|
|
233
|
+
async function attemptRecover(incident: TurnIncident, incidentId: string): Promise<void> {
|
|
234
|
+
if (!CALLBACK_URL_BASE) return
|
|
235
|
+
if (!RUNNER_RECOVERABLE_STAGES.has(incident.stage)) return
|
|
236
|
+
try {
|
|
237
|
+
const res = await fetch(CALLBACK_URL_BASE + '/recover', {
|
|
238
|
+
method: 'POST',
|
|
239
|
+
headers: { 'Content-Type': 'application/json' },
|
|
240
|
+
body: JSON.stringify({
|
|
241
|
+
incidentId,
|
|
242
|
+
chatId: incident.chatId,
|
|
243
|
+
stage: incident.stage,
|
|
244
|
+
failureClass: incident.failureClass,
|
|
245
|
+
turnKey: recoveryTurnKey(incident),
|
|
246
|
+
}),
|
|
247
|
+
})
|
|
248
|
+
if (!res.ok) return
|
|
249
|
+
const data = (await res.json()) as { ok?: boolean; outcome?: RecoveryOutcome }
|
|
250
|
+
if (data.ok && data.outcome) {
|
|
251
|
+
incidentStore.appendRecovery(incidentId, data.outcome)
|
|
252
|
+
}
|
|
253
|
+
} catch (err) {
|
|
254
|
+
process.stderr.write(`telegram channel: recover POST failed: ${String(err)}\n`)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Short, content-free notice for a stalled turn. No message text or chat id is
|
|
259
|
+
// echoed back — only the pipeline stage and (on repeat) the occurrence count.
|
|
260
|
+
async function notifyIncident(
|
|
261
|
+
chatId: string,
|
|
262
|
+
result: { escalation: { level: string }; occurrences: number; manifest: { stage: string } },
|
|
263
|
+
): Promise<void> {
|
|
264
|
+
const stage = result.manifest.stage
|
|
265
|
+
let text: string
|
|
266
|
+
if (result.escalation.level === 'investigate') {
|
|
267
|
+
text =
|
|
268
|
+
`⚠️ Recurring stall detected at the "${stage}" stage ` +
|
|
269
|
+
`(${result.occurrences}× recently). This looks like a persistent issue — ` +
|
|
270
|
+
`it has been logged for investigation.`
|
|
271
|
+
} else {
|
|
272
|
+
// Neutral wording: automatic recovery only runs when the operator has
|
|
273
|
+
// enabled gateway.selfHealing.autoRecover (off by default), and the receiver
|
|
274
|
+
// cannot see that runner-side flag — so do not promise it here.
|
|
275
|
+
text =
|
|
276
|
+
`⚠️ A turn stalled at the "${stage}" stage and has been logged.`
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
await bot.api.sendMessage(chatId, text)
|
|
280
|
+
} catch {
|
|
281
|
+
// Best-effort notify; the incident is already persisted regardless.
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
123
285
|
type PendingEntry = {
|
|
124
286
|
senderId: string
|
|
125
287
|
chatId: string
|
|
126
288
|
createdAt: number
|
|
127
289
|
expiresAt: number
|
|
128
290
|
replies: number
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
requireMention: boolean
|
|
133
|
-
allowFrom: string[]
|
|
291
|
+
// Absent ⇒ 'dm'. 'group' entries hold a group id in chatId; approval pushes
|
|
292
|
+
// it into groupAllowlist (mirrors LINE).
|
|
293
|
+
kind?: 'dm' | 'group'
|
|
134
294
|
}
|
|
135
295
|
|
|
136
296
|
type Access = {
|
|
137
|
-
dmPolicy: 'open' | '
|
|
297
|
+
dmPolicy: 'open' | 'allowlist' | 'disabled'
|
|
298
|
+
// Orthogonal pairing toggle (mirrors LINE). Only meaningful when
|
|
299
|
+
// dmPolicy === 'allowlist': true ⇒ unknown senders get a one-time code and
|
|
300
|
+
// land in pending; false ⇒ silently dropped (pure allowlist).
|
|
301
|
+
pairing: boolean
|
|
138
302
|
allowFrom: string[]
|
|
139
|
-
|
|
303
|
+
// Group access tier (mirrors LINE): base policy, allowlisted group ids, and a
|
|
304
|
+
// single requireMention gate. `pairing` governs group code-minting too.
|
|
305
|
+
groupPolicy: 'open' | 'allowlist' | 'disabled'
|
|
306
|
+
groupAllowlist: string[]
|
|
307
|
+
requireMention: boolean
|
|
308
|
+
// Migration-only artifact — mirrors pure.ts Access — keep in sync. Enforced
|
|
309
|
+
// in gate() below so migrating a pre-split file can't silently widen a
|
|
310
|
+
// group that was previously restricted to specific senders.
|
|
311
|
+
legacyGroupAllowFrom?: Record<string, string[]>
|
|
140
312
|
pending: Record<string, PendingEntry>
|
|
141
313
|
mentionPatterns?: string[]
|
|
142
314
|
// delivery/UX config — optional, defaults live in the reply handler
|
|
@@ -152,9 +324,12 @@ type Access = {
|
|
|
152
324
|
|
|
153
325
|
function defaultAccess(): Access {
|
|
154
326
|
return {
|
|
155
|
-
dmPolicy: '
|
|
327
|
+
dmPolicy: 'allowlist',
|
|
328
|
+
pairing: true,
|
|
156
329
|
allowFrom: [],
|
|
157
|
-
|
|
330
|
+
groupPolicy: 'allowlist',
|
|
331
|
+
groupAllowlist: [],
|
|
332
|
+
requireMention: true,
|
|
158
333
|
pending: {},
|
|
159
334
|
ackReaction: '👀',
|
|
160
335
|
}
|
|
@@ -181,18 +356,8 @@ function assertSendable(f: string): void {
|
|
|
181
356
|
function readAccessFile(): Access {
|
|
182
357
|
try {
|
|
183
358
|
const raw = readFileSync(ACCESS_FILE, 'utf8')
|
|
184
|
-
const parsed = JSON.parse(raw) as Partial<Access>
|
|
185
|
-
return
|
|
186
|
-
dmPolicy: parsed.dmPolicy ?? 'pairing',
|
|
187
|
-
allowFrom: parsed.allowFrom ?? [],
|
|
188
|
-
groups: parsed.groups ?? {},
|
|
189
|
-
pending: parsed.pending ?? {},
|
|
190
|
-
mentionPatterns: parsed.mentionPatterns,
|
|
191
|
-
ackReaction: parsed.ackReaction,
|
|
192
|
-
replyToMode: parsed.replyToMode,
|
|
193
|
-
textChunkLimit: parsed.textChunkLimit,
|
|
194
|
-
chunkMode: parsed.chunkMode,
|
|
195
|
-
}
|
|
359
|
+
const parsed = JSON.parse(raw) as Partial<Access> & { dmPolicy?: string }
|
|
360
|
+
return migrateAccess(parsed) as Access
|
|
196
361
|
} catch (err) {
|
|
197
362
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return defaultAccess()
|
|
198
363
|
try {
|
|
@@ -212,7 +377,7 @@ function loadAccess(): Access {
|
|
|
212
377
|
function assertAllowedChat(chat_id: string): void {
|
|
213
378
|
const access = loadAccess()
|
|
214
379
|
if (access.allowFrom.includes(chat_id)) return
|
|
215
|
-
if (
|
|
380
|
+
if (access.groupAllowlist.includes(chat_id)) return
|
|
216
381
|
throw new Error(`chat ${chat_id} is not allowlisted — add via /telegram:access`)
|
|
217
382
|
}
|
|
218
383
|
|
|
@@ -238,47 +403,20 @@ function pruneExpired(a: Access, now?: number): boolean {
|
|
|
238
403
|
type GateResult =
|
|
239
404
|
| { action: 'deliver'; access: Access }
|
|
240
405
|
| { action: 'drop' }
|
|
241
|
-
| { action: 'pair'; code: string; isResend: boolean }
|
|
406
|
+
| { action: 'pair'; code: string; isResend: boolean; isGroup?: boolean }
|
|
242
407
|
|
|
243
408
|
function gate(ctx: Context): GateResult {
|
|
244
409
|
const access = loadAccess()
|
|
245
410
|
const pruned = pruneExpired(access)
|
|
246
411
|
if (pruned) saveAccess(access)
|
|
247
412
|
|
|
248
|
-
if (access.dmPolicy === 'disabled') return { action: 'drop' }
|
|
249
|
-
|
|
250
413
|
const from = ctx.from
|
|
251
414
|
if (!from) return { action: 'drop' }
|
|
252
415
|
const senderId = String(from.id)
|
|
253
416
|
const chatType = ctx.chat?.type
|
|
254
417
|
|
|
255
|
-
// Owner init-pairing sentinel: first private message auto-approves sender as owner.
|
|
256
|
-
if (chatType === 'private') {
|
|
257
|
-
try {
|
|
258
|
-
const stat = statSync(AWAITING_OWNER_FILE)
|
|
259
|
-
const age = Date.now() - stat.mtimeMs
|
|
260
|
-
if (age < 10 * 60 * 1000) {
|
|
261
|
-
// Sentinel is valid — approve this sender as owner
|
|
262
|
-
if (!access.allowFrom.includes(senderId)) access.allowFrom.push(senderId)
|
|
263
|
-
saveAccess(access)
|
|
264
|
-
rmSync(AWAITING_OWNER_FILE, { force: true })
|
|
265
|
-
// Write approved file so receiver sends confirmation message
|
|
266
|
-
mkdirSync(APPROVED_DIR, { recursive: true })
|
|
267
|
-
writeFileSync(join(APPROVED_DIR, senderId), String(ctx.chat!.id))
|
|
268
|
-
return { action: 'deliver', access }
|
|
269
|
-
} else {
|
|
270
|
-
rmSync(AWAITING_OWNER_FILE, { force: true })
|
|
271
|
-
}
|
|
272
|
-
} catch (err) {
|
|
273
|
-
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
274
|
-
// Unexpected I/O error — deny to be safe rather than silently allowing
|
|
275
|
-
return { action: 'drop' }
|
|
276
|
-
}
|
|
277
|
-
// ENOENT — no sentinel, continue normal gate logic
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
418
|
if (chatType === 'private') {
|
|
419
|
+
if (access.dmPolicy === 'disabled') return { action: 'drop' }
|
|
282
420
|
if (access.dmPolicy === 'open') {
|
|
283
421
|
if (!access.allowFrom.includes(senderId)) {
|
|
284
422
|
access.allowFrom.push(senderId)
|
|
@@ -287,11 +425,13 @@ function gate(ctx: Context): GateResult {
|
|
|
287
425
|
return { action: 'deliver', access }
|
|
288
426
|
}
|
|
289
427
|
if (access.allowFrom.includes(senderId)) return { action: 'deliver', access }
|
|
290
|
-
|
|
428
|
+
// Base policy is 'allowlist' ('open'/'disabled' handled above). Pairing is
|
|
429
|
+
// the orthogonal toggle: off ⇒ pure allowlist (drop strangers, no code).
|
|
430
|
+
if (!access.pairing) return { action: 'drop' }
|
|
291
431
|
|
|
292
432
|
// pairing mode — check for existing non-expired code for this sender
|
|
293
433
|
for (const [code, p] of Object.entries(access.pending)) {
|
|
294
|
-
if (p.senderId === senderId) {
|
|
434
|
+
if ((p.kind ?? 'dm') === 'dm' && p.senderId === senderId) {
|
|
295
435
|
// Reply twice max (initial + one reminder), then go silent.
|
|
296
436
|
if ((p.replies ?? 1) >= 2) return { action: 'drop' }
|
|
297
437
|
p.replies = (p.replies ?? 1) + 1
|
|
@@ -299,8 +439,8 @@ function gate(ctx: Context): GateResult {
|
|
|
299
439
|
return { action: 'pair', code, isResend: true }
|
|
300
440
|
}
|
|
301
441
|
}
|
|
302
|
-
// Cap pending at 3. Extra attempts are silently dropped.
|
|
303
|
-
if (
|
|
442
|
+
// Cap pending per-kind at 3. Extra attempts are silently dropped.
|
|
443
|
+
if (countPending(access, 'dm') >= 3) return { action: 'drop' }
|
|
304
444
|
|
|
305
445
|
const code = randomBytes(3).toString('hex') // 6 hex chars
|
|
306
446
|
const now = Date.now()
|
|
@@ -310,6 +450,7 @@ function gate(ctx: Context): GateResult {
|
|
|
310
450
|
createdAt: now,
|
|
311
451
|
expiresAt: now + 60 * 60 * 1000, // 1h
|
|
312
452
|
replies: 1,
|
|
453
|
+
kind: 'dm',
|
|
313
454
|
}
|
|
314
455
|
saveAccess(access)
|
|
315
456
|
return { action: 'pair', code, isResend: false }
|
|
@@ -317,14 +458,42 @@ function gate(ctx: Context): GateResult {
|
|
|
317
458
|
|
|
318
459
|
if (chatType === 'group' || chatType === 'supergroup') {
|
|
319
460
|
const groupId = String(ctx.chat!.id)
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
461
|
+
if (access.groupPolicy === 'disabled') return { action: 'drop' }
|
|
462
|
+
|
|
463
|
+
if (access.groupPolicy === 'allowlist' && !access.groupAllowlist.includes(groupId)) {
|
|
464
|
+
// Unknown group. Pairing off ⇒ silent drop. On ⇒ mint a code keyed on the
|
|
465
|
+
// group id and post it here; a member relays it to the admin (mirrors LINE).
|
|
466
|
+
if (!access.pairing) return { action: 'drop' }
|
|
467
|
+
for (const [code, p] of Object.entries(access.pending)) {
|
|
468
|
+
if (p.kind === 'group' && p.chatId === groupId) {
|
|
469
|
+
if ((p.replies ?? 1) >= 2) return { action: 'drop' }
|
|
470
|
+
p.replies = (p.replies ?? 1) + 1
|
|
471
|
+
saveAccess(access)
|
|
472
|
+
return { action: 'pair', code, isResend: true, isGroup: true }
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
if (countPending(access, 'group') >= 3) return { action: 'drop' }
|
|
476
|
+
const code = randomBytes(3).toString('hex') // 6 hex chars
|
|
477
|
+
const now = Date.now()
|
|
478
|
+
access.pending[code] = {
|
|
479
|
+
senderId,
|
|
480
|
+
chatId: groupId,
|
|
481
|
+
createdAt: now,
|
|
482
|
+
expiresAt: now + 60 * 60 * 1000, // 1h
|
|
483
|
+
replies: 1,
|
|
484
|
+
kind: 'group',
|
|
485
|
+
}
|
|
486
|
+
saveAccess(access)
|
|
487
|
+
return { action: 'pair', code, isResend: false, isGroup: true }
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Allowlisted (or open policy) → enforce any legacy per-sender restriction
|
|
491
|
+
// that survived migration, then the single mention gate.
|
|
492
|
+
const legacyAllowed = access.legacyGroupAllowFrom?.[groupId]
|
|
493
|
+
if (legacyAllowed && legacyAllowed.length > 0 && !legacyAllowed.includes(senderId)) {
|
|
325
494
|
return { action: 'drop' }
|
|
326
495
|
}
|
|
327
|
-
if (requireMention && !isMentioned(ctx, access.mentionPatterns)) {
|
|
496
|
+
if (access.requireMention !== false && !isMentioned(ctx, access.mentionPatterns)) {
|
|
328
497
|
return { action: 'drop' }
|
|
329
498
|
}
|
|
330
499
|
return { action: 'deliver', access }
|
|
@@ -333,6 +502,15 @@ function gate(ctx: Context): GateResult {
|
|
|
333
502
|
return { action: 'drop' }
|
|
334
503
|
}
|
|
335
504
|
|
|
505
|
+
/** Count pending entries of a given kind (absent kind ⇒ 'dm'). */
|
|
506
|
+
function countPending(access: Access, kind: 'dm' | 'group'): number {
|
|
507
|
+
let n = 0
|
|
508
|
+
for (const p of Object.values(access.pending)) {
|
|
509
|
+
if ((p.kind ?? 'dm') === kind) n++
|
|
510
|
+
}
|
|
511
|
+
return n
|
|
512
|
+
}
|
|
513
|
+
|
|
336
514
|
function isMentioned(ctx: Context, extraPatterns?: string[]): boolean {
|
|
337
515
|
const entities = ctx.message?.entities ?? ctx.message?.caption_entities ?? []
|
|
338
516
|
const text = ctx.message?.text ?? ctx.message?.caption ?? ''
|
|
@@ -750,6 +928,17 @@ type AttachmentMeta = {
|
|
|
750
928
|
name?: string
|
|
751
929
|
}
|
|
752
930
|
|
|
931
|
+
// Pairing reply shown to an un-allowlisted sender: their one-time code plus
|
|
932
|
+
// the instruction to report it to the admin. Shared by handleInbound (normal
|
|
933
|
+
// message) and the /start command so both hand the code out identically.
|
|
934
|
+
function pairingReplyText(code: string, isResend: boolean, isGroup = false): string {
|
|
935
|
+
const lead = isResend ? 'Still waiting for approval.' : 'This bot is private.'
|
|
936
|
+
const share = isGroup
|
|
937
|
+
? 'Share this code with an admin to enable me in this group.'
|
|
938
|
+
: 'Share this code with the admin to get access.'
|
|
939
|
+
return `${lead}\n\nPairing code: ${code}\n\n${share}`
|
|
940
|
+
}
|
|
941
|
+
|
|
753
942
|
async function handleInbound(
|
|
754
943
|
ctx: Context,
|
|
755
944
|
text: string,
|
|
@@ -767,10 +956,11 @@ async function handleInbound(
|
|
|
767
956
|
if (result.action === 'drop') return
|
|
768
957
|
|
|
769
958
|
if (result.action === 'pair') {
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
959
|
+
// LINE-style: hand the user their code and let the admin approve it from
|
|
960
|
+
// the web UI. The user does NOT run any command (they may not have Claude
|
|
961
|
+
// Code at all) — they just report the code to the admin, who verifies it
|
|
962
|
+
// matches what's shown in Pending and clicks Approve.
|
|
963
|
+
await ctx.reply(pairingReplyText(result.code, result.isResend, result.isGroup))
|
|
774
964
|
return
|
|
775
965
|
}
|
|
776
966
|
|
|
@@ -915,18 +1105,21 @@ if (!SEND_ONLY) {
|
|
|
915
1105
|
|
|
916
1106
|
bot.command('start', async ctx => {
|
|
917
1107
|
if (ctx.chat?.type !== 'private') return
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
1108
|
+
// Route /start through the same gate as a first message so a new user gets
|
|
1109
|
+
// their pairing code immediately (LINE-style), instead of an instruction to
|
|
1110
|
+
// send another message. The literal "/start" text is never relayed to Claude.
|
|
1111
|
+
const result = gate(ctx)
|
|
1112
|
+
if (result.action === 'pair') {
|
|
1113
|
+
await ctx.reply(pairingReplyText(result.code, result.isResend))
|
|
921
1114
|
return
|
|
922
1115
|
}
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
)
|
|
1116
|
+
if (result.action === 'deliver') {
|
|
1117
|
+
// Already allowlisted (or an open-policy bot) — nothing to pair.
|
|
1118
|
+
await ctx.reply(`You're paired. Just send me a message and it reaches the assistant.`)
|
|
1119
|
+
return
|
|
1120
|
+
}
|
|
1121
|
+
// drop — dmPolicy disabled, or pure allowlist (pairing off) for an unknown sender.
|
|
1122
|
+
await ctx.reply(`This bot isn't accepting new connections right now.`)
|
|
930
1123
|
})
|
|
931
1124
|
|
|
932
1125
|
bot.command('help', async ctx => {
|
|
@@ -969,7 +1162,7 @@ bot.command('status', async ctx => {
|
|
|
969
1162
|
for (const [code, p] of Object.entries(access.pending)) {
|
|
970
1163
|
if (p.senderId === senderId) {
|
|
971
1164
|
await ctx.reply(
|
|
972
|
-
`Pending pairing
|
|
1165
|
+
`Pending approval.\n\nYour pairing code: ${code}\n\nShare it with the admin to get access.`
|
|
973
1166
|
)
|
|
974
1167
|
return
|
|
975
1168
|
}
|
|
@@ -63,22 +63,58 @@ APPROVED_DIR = {STATE_DIR}/approved
|
|
|
63
63
|
|
|
64
64
|
```json
|
|
65
65
|
{
|
|
66
|
-
"dmPolicy": "
|
|
66
|
+
"dmPolicy": "allowlist",
|
|
67
|
+
"pairing": true,
|
|
67
68
|
"allowFrom": ["<senderId>", ...],
|
|
68
|
-
"
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
"groupPolicy": "allowlist",
|
|
70
|
+
"groupAllowlist": ["<groupId>", ...],
|
|
71
|
+
"requireMention": true,
|
|
72
|
+
"legacyGroupAllowFrom": { "<groupId>": ["<senderId>", ...] },
|
|
71
73
|
"pending": {
|
|
72
74
|
"<6-char-code>": {
|
|
73
75
|
"senderId": "...", "chatId": "...",
|
|
74
|
-
"createdAt": <ms>, "expiresAt": <ms
|
|
76
|
+
"createdAt": <ms>, "expiresAt": <ms>,
|
|
77
|
+
"kind": "dm"
|
|
75
78
|
}
|
|
76
79
|
},
|
|
77
80
|
"mentionPatterns": ["@mybot"]
|
|
78
81
|
}
|
|
79
82
|
```
|
|
80
83
|
|
|
81
|
-
|
|
84
|
+
`dmPolicy` is the base access policy: `open` | `allowlist` | `disabled`.
|
|
85
|
+
`pairing` is an **orthogonal on/off toggle** (mirrors LINE), only meaningful
|
|
86
|
+
when `dmPolicy`/`groupPolicy` is `allowlist`: `true` ⇒ an unknown sender or
|
|
87
|
+
group gets a one-time 6-char code that lands in `pending` for the admin to
|
|
88
|
+
approve; `false` ⇒ silently dropped (pure allowlist).
|
|
89
|
+
|
|
90
|
+
The **group tier** mirrors LINE: `groupPolicy` (`open` | `allowlist` |
|
|
91
|
+
`disabled`) is the base policy for groups, `groupAllowlist` holds the approved
|
|
92
|
+
group ids (negative numbers, e.g. `-1001234567890`), and `requireMention` (a
|
|
93
|
+
single boolean) gates whether the bot answers in an allowlisted group only when
|
|
94
|
+
@mentioned. A `pending` entry with `"kind": "group"` is a group knock — its
|
|
95
|
+
`chatId` is the group id and `pair`-ing it adds that id to `groupAllowlist`
|
|
96
|
+
(not `allowFrom`). Entries with no `kind` (or `"kind": "dm"`) are DM knocks.
|
|
97
|
+
|
|
98
|
+
**Group delivery caveat (Telegram Privacy Mode).** Allowlisting a group only
|
|
99
|
+
decides how the gate *responds* — it does nothing if Telegram never delivers the
|
|
100
|
+
message. Bots default to **Privacy Mode ON** (`getMe` →
|
|
101
|
+
`can_read_all_group_messages: false`), so in a group the bot only receives
|
|
102
|
+
`/commands`, @mentions of its username, and replies to its own messages; plain
|
|
103
|
+
messages are filtered out before the gateway sees them. And bot commands are
|
|
104
|
+
DM-only (dropped in groups). So a group can be correctly allowlisted yet stay
|
|
105
|
+
silent, and an unknown group may never mint a pairing code. Tell the user to
|
|
106
|
+
**promote the bot to Admin in the group** (an admin bot receives everything
|
|
107
|
+
regardless of Privacy Mode) or **disable Privacy Mode in BotFather and re-add the
|
|
108
|
+
bot**. This is a Telegram-side setting — `/telegram:access` cannot change it.
|
|
109
|
+
|
|
110
|
+
`legacyGroupAllowFrom` is a **migration-only artifact**, not a live feature —
|
|
111
|
+
it's how a pre-split file's per-group sender restriction survives migration
|
|
112
|
+
(the old schema could lock a group to specific senders; the current model has
|
|
113
|
+
no per-user group tier). If present, it's enforced silently in addition to
|
|
114
|
+
`requireMention`. Preserve this key as-is whenever you read/rewrite the file —
|
|
115
|
+
never invent, edit, or add entries to it; there is no command for that.
|
|
116
|
+
|
|
117
|
+
Missing file = `{dmPolicy:"allowlist", pairing:true, allowFrom:[], groupPolicy:"allowlist", groupAllowlist:[], requireMention:true, pending:{}}`.
|
|
82
118
|
|
|
83
119
|
---
|
|
84
120
|
|
|
@@ -89,22 +125,27 @@ Parse `$ARGUMENTS` (space-separated). If empty or unrecognized, show status.
|
|
|
89
125
|
### No args — status
|
|
90
126
|
|
|
91
127
|
1. Read `{STATE_DIR}/access.json` (handle missing file).
|
|
92
|
-
2. Show: dmPolicy,
|
|
93
|
-
sender IDs + age,
|
|
128
|
+
2. Show: dmPolicy, the pairing toggle (on/off), allowFrom count and list,
|
|
129
|
+
pending count with codes + sender IDs + kind (dm/group) + age, groupPolicy,
|
|
130
|
+
requireMention, and the groupAllowlist count and list.
|
|
94
131
|
|
|
95
132
|
### `pair <code>`
|
|
96
133
|
|
|
97
134
|
1. Read `{STATE_DIR}/access.json`.
|
|
98
135
|
2. Look up `pending[<code>]`. If not found or `expiresAt < Date.now()`,
|
|
99
136
|
tell the user and stop.
|
|
100
|
-
3.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
137
|
+
3. **Kind-aware.** If the entry's `kind` is `"group"`:
|
|
138
|
+
- Add its `chatId` (the group id) to `groupAllowlist` (dedupe).
|
|
139
|
+
- Delete `pending[<code>]`, write access.json. **No** `approved/` file (a
|
|
140
|
+
group has no single recipient — the bot silently starts answering there).
|
|
141
|
+
- Confirm which group id was allowed.
|
|
142
|
+
Otherwise (DM knock, `kind` absent or `"dm"`):
|
|
143
|
+
- Add `senderId` to `allowFrom` (dedupe).
|
|
144
|
+
- Delete `pending[<code>]`, write access.json.
|
|
145
|
+
- `mkdir -p {STATE_DIR}/approved` then write
|
|
146
|
+
`{STATE_DIR}/approved/<senderId>` with `chatId` as the file contents. The
|
|
147
|
+
channel server polls this dir and sends "you're in".
|
|
148
|
+
- Confirm who was approved (senderId).
|
|
108
149
|
|
|
109
150
|
### `deny <code>`
|
|
110
151
|
|
|
@@ -123,19 +164,48 @@ Parse `$ARGUMENTS` (space-separated). If empty or unrecognized, show status.
|
|
|
123
164
|
|
|
124
165
|
### `policy <mode>`
|
|
125
166
|
|
|
126
|
-
1. Validate `<mode>` is one of `
|
|
167
|
+
1. Validate `<mode>` is one of `open`, `allowlist`, `disabled`.
|
|
168
|
+
(Pairing is no longer a policy value — it's the separate `pairing` toggle
|
|
169
|
+
below. `allowlist` + `pairing on` is the capture-unknown-users mode.)
|
|
127
170
|
2. Read (create default if missing), set `dmPolicy`, write.
|
|
128
171
|
|
|
129
|
-
### `
|
|
172
|
+
### `pairing <on|off>`
|
|
173
|
+
|
|
174
|
+
Toggle the orthogonal pairing code layer (only affects `dmPolicy: "allowlist"`).
|
|
175
|
+
|
|
176
|
+
1. Validate `<value>` is `on` or `off`.
|
|
177
|
+
2. Read (create default if missing), set `pairing` to `true`/`false`, write.
|
|
178
|
+
3. Confirm. When `on`: unknown senders receive a one-time code and appear in
|
|
179
|
+
`pending` for you to `pair`. When `off`: unknown senders are dropped
|
|
180
|
+
silently (pure allowlist).
|
|
181
|
+
|
|
182
|
+
### `group policy <mode>`
|
|
183
|
+
|
|
184
|
+
1. Validate `<mode>` is one of `open`, `allowlist`, `disabled`.
|
|
185
|
+
2. Read (create default if missing), set `groupPolicy`, write.
|
|
186
|
+
(`allowlist` + `pairing on` is the capture-unknown-groups mode: an unknown
|
|
187
|
+
group gets a pairing code posted in it.)
|
|
188
|
+
|
|
189
|
+
### `group allow <groupId>`
|
|
130
190
|
|
|
131
191
|
1. Read (create default if missing).
|
|
132
|
-
2.
|
|
133
|
-
|
|
134
|
-
|
|
192
|
+
2. Add `<groupId>` to `groupAllowlist` (dedupe). Write.
|
|
193
|
+
(Group ids are negative numbers, e.g. `-1001234567890`.)
|
|
194
|
+
|
|
195
|
+
### `group deny <groupId>`
|
|
196
|
+
|
|
197
|
+
1. Read, filter `groupAllowlist` to exclude `<groupId>`, also delete
|
|
198
|
+
`legacyGroupAllowFrom[<groupId>]` if present (so a later re-add doesn't
|
|
199
|
+
resurrect a stale restriction), write.
|
|
200
|
+
|
|
201
|
+
### `group mention <on|off>`
|
|
135
202
|
|
|
136
|
-
|
|
203
|
+
Toggle the single group mention gate (`requireMention`).
|
|
137
204
|
|
|
138
|
-
1.
|
|
205
|
+
1. Validate `<value>` is `on` or `off`.
|
|
206
|
+
2. Read (create default if missing), set `requireMention` to `true`/`false`, write.
|
|
207
|
+
3. Confirm. When `on`: the bot answers in an allowlisted group only when
|
|
208
|
+
@mentioned (or replied to). When `off`: it answers every message there.
|
|
139
209
|
|
|
140
210
|
### `set <key> <value>`
|
|
141
211
|
|