@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.
Files changed (92) hide show
  1. package/README.md +172 -50
  2. package/config.template.json +6 -2
  3. package/dist/agent/incident-store.d.ts +89 -0
  4. package/dist/agent/incident-store.d.ts.map +1 -0
  5. package/dist/agent/incident-store.js +299 -0
  6. package/dist/agent/incident-store.js.map +1 -0
  7. package/dist/agent/incident.d.ts +156 -0
  8. package/dist/agent/incident.d.ts.map +1 -0
  9. package/dist/agent/incident.js +177 -0
  10. package/dist/agent/incident.js.map +1 -0
  11. package/dist/agent/recovery-executor.d.ts +117 -0
  12. package/dist/agent/recovery-executor.d.ts.map +1 -0
  13. package/dist/agent/recovery-executor.js +168 -0
  14. package/dist/agent/recovery-executor.js.map +1 -0
  15. package/dist/agent/recovery-policy.d.ts +97 -0
  16. package/dist/agent/recovery-policy.d.ts.map +1 -0
  17. package/dist/agent/recovery-policy.js +164 -0
  18. package/dist/agent/recovery-policy.js.map +1 -0
  19. package/dist/agent/runner.d.ts +44 -0
  20. package/dist/agent/runner.d.ts.map +1 -1
  21. package/dist/agent/runner.js +272 -2
  22. package/dist/agent/runner.js.map +1 -1
  23. package/dist/agent/safe-mode.d.ts +61 -0
  24. package/dist/agent/safe-mode.d.ts.map +1 -0
  25. package/dist/agent/safe-mode.js +102 -0
  26. package/dist/agent/safe-mode.js.map +1 -0
  27. package/dist/agent/triage.d.ts +94 -0
  28. package/dist/agent/triage.d.ts.map +1 -0
  29. package/dist/agent/triage.js +209 -0
  30. package/dist/agent/triage.js.map +1 -0
  31. package/dist/agent/turn-trace.d.ts +120 -0
  32. package/dist/agent/turn-trace.d.ts.map +1 -0
  33. package/dist/agent/turn-trace.js +122 -0
  34. package/dist/agent/turn-trace.js.map +1 -0
  35. package/dist/api/gateway-router.d.ts +21 -0
  36. package/dist/api/gateway-router.d.ts.map +1 -1
  37. package/dist/api/gateway-router.js +58 -17
  38. package/dist/api/gateway-router.js.map +1 -1
  39. package/dist/api/line-pending-senders.d.ts +7 -2
  40. package/dist/api/line-pending-senders.d.ts.map +1 -1
  41. package/dist/api/line-pending-senders.js +7 -2
  42. package/dist/api/line-pending-senders.js.map +1 -1
  43. package/dist/api/router.d.ts.map +1 -1
  44. package/dist/api/router.js +563 -172
  45. package/dist/api/router.js.map +1 -1
  46. package/dist/api/wizard-state.d.ts +1 -4
  47. package/dist/api/wizard-state.d.ts.map +1 -1
  48. package/dist/api/wizard-state.js.map +1 -1
  49. package/dist/config/migrator.d.ts +4 -0
  50. package/dist/config/migrator.d.ts.map +1 -1
  51. package/dist/config/migrator.js +60 -3
  52. package/dist/config/migrator.js.map +1 -1
  53. package/dist/discord/receiver.d.ts +1 -0
  54. package/dist/discord/receiver.d.ts.map +1 -1
  55. package/dist/discord/receiver.js +24 -6
  56. package/dist/discord/receiver.js.map +1 -1
  57. package/dist/index.js +3 -0
  58. package/dist/index.js.map +1 -1
  59. package/dist/session/process.d.ts +18 -0
  60. package/dist/session/process.d.ts.map +1 -1
  61. package/dist/session/process.js +61 -1
  62. package/dist/session/process.js.map +1 -1
  63. package/dist/shell/claude-pty-shell.js +59 -0
  64. package/dist/shell/claude-pty-shell.js.map +1 -1
  65. package/dist/shell/control-channel.d.ts +74 -0
  66. package/dist/shell/control-channel.d.ts.map +1 -0
  67. package/dist/shell/control-channel.js +114 -0
  68. package/dist/shell/control-channel.js.map +1 -0
  69. package/dist/telegram/receiver.d.ts +1 -0
  70. package/dist/telegram/receiver.d.ts.map +1 -1
  71. package/dist/telegram/receiver.js +18 -6
  72. package/dist/telegram/receiver.js.map +1 -1
  73. package/dist/types.d.ts +21 -0
  74. package/dist/types.d.ts.map +1 -1
  75. package/dist/ui/web-ui.d.ts.map +1 -1
  76. package/dist/ui/web-ui.js +103 -2
  77. package/dist/ui/web-ui.js.map +1 -1
  78. package/mcp/tools/discord/access.ts +136 -20
  79. package/mcp/tools/discord/client.ts +3 -1
  80. package/mcp/tools/discord/module.ts +75 -4
  81. package/mcp/tools/discord/skills/access/SKILL.md +66 -8
  82. package/mcp/tools/discord/skills/configure/SKILL.md +11 -1
  83. package/mcp/tools/discord/types.ts +21 -2
  84. package/mcp/tools/skills/handlers.ts +4 -2
  85. package/mcp/tools/telegram/dedup.ts +4 -1
  86. package/mcp/tools/telegram/module.ts +5 -14
  87. package/mcp/tools/telegram/pure.ts +169 -31
  88. package/mcp/tools/telegram/receiver-server.ts +271 -78
  89. package/mcp/tools/telegram/skills/access/SKILL.md +93 -23
  90. package/mcp/tools/telegram/skills/configure/SKILL.md +31 -26
  91. package/mcp/tools/telegram/typing.ts +124 -0
  92. 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
- type GroupPolicy = {
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' | 'pairing' | 'allowlist' | 'disabled'
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
- groups: Record<string, GroupPolicy>
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: 'pairing',
327
+ dmPolicy: 'allowlist',
328
+ pairing: true,
156
329
  allowFrom: [],
157
- groups: {},
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 (chat_id in access.groups) return
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
- if (access.dmPolicy === 'allowlist') return { action: 'drop' }
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 (Object.keys(access.pending).length >= 3) return { action: 'drop' }
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
- const policy = access.groups[groupId]
321
- if (!policy) return { action: 'drop' }
322
- const groupAllowFrom = policy.allowFrom ?? []
323
- const requireMention = policy.requireMention ?? true
324
- if (groupAllowFrom.length > 0 && !groupAllowFrom.includes(senderId)) {
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
- const lead = result.isResend ? 'Still pending' : 'Pairing required'
771
- await ctx.reply(
772
- `${lead} — run in Claude Code:\n\n/telegram:access pair ${result.code}`,
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
- const access = loadAccess()
919
- if (access.dmPolicy === 'disabled') {
920
- await ctx.reply(`This bot isn't accepting new connections.`)
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
- await ctx.reply(
924
- `This bot bridges Telegram to a Claude Code session.\n\n` +
925
- `To pair:\n` +
926
- `1. DM me anything — you'll get a 6-char code\n` +
927
- `2. In Claude Code: /telegram:access pair <code>\n\n` +
928
- `After that, DMs here reach that session.`
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 — run in Claude Code:\n\n/telegram:access pair ${code}`
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": "pairing",
66
+ "dmPolicy": "allowlist",
67
+ "pairing": true,
67
68
  "allowFrom": ["<senderId>", ...],
68
- "groups": {
69
- "<groupId>": { "requireMention": true, "allowFrom": [] }
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
- Missing file = `{dmPolicy:"pairing", allowFrom:[], groups:{}, pending:{}}`.
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, allowFrom count and list, pending count with codes +
93
- sender IDs + age, groups count.
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. Extract `senderId` and `chatId` from the pending entry.
101
- 4. Add `senderId` to `allowFrom` (dedupe).
102
- 5. Delete `pending[<code>]`.
103
- 6. Write the updated access.json.
104
- 7. `mkdir -p {STATE_DIR}/approved` then write
105
- `{STATE_DIR}/approved/<senderId>` with `chatId` as the
106
- file contents. The channel server polls this dir and sends "you're in".
107
- 8. Confirm: who was approved (senderId).
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 `pairing`, `allowlist`, `disabled`.
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
- ### `group add <groupId>` (optional: `--no-mention`, `--allow id1,id2`)
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. Set `groups[<groupId>] = { requireMention: !hasFlag("--no-mention"),
133
- allowFrom: parsedAllowList }`.
134
- 3. Write.
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
- ### `group rm <groupId>`
203
+ Toggle the single group mention gate (`requireMention`).
137
204
 
138
- 1. Read, `delete groups[<groupId>]`, write.
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