@0xmaxma/claude-gateway 1.3.24 → 1.3.25

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.
@@ -30,13 +30,12 @@ import { homedir } from 'os'
30
30
  import { join, extname, sep } from 'path'
31
31
  import { createWorkingStateManager, drainOrphanForwards } from './typing'
32
32
  import { initDedupDir, isDuplicate as _isDuplicate, pruneDedup as _pruneDedup } from './dedup'
33
- import { hasMarkdown, toTelegramHtml } from './pure'
33
+ import { hasMarkdown, toTelegramHtml, migrateAccess } from './pure'
34
34
 
35
35
  // Standalone fallback: default state dir to ~/.claude/channels/telegram
36
36
  const STATE_DIR = process.env.TELEGRAM_STATE_DIR ?? join(homedir(), '.claude', 'channels', 'telegram')
37
37
  const ACCESS_FILE = join(STATE_DIR, 'access.json')
38
38
  const APPROVED_DIR = join(STATE_DIR, 'approved')
39
- const AWAITING_OWNER_FILE = join(STATE_DIR, 'awaiting-owner')
40
39
  const ENV_FILE = join(STATE_DIR, '.env')
41
40
 
42
41
  // Load .env fallback when token not injected via env block (standalone mode).
@@ -126,17 +125,27 @@ type PendingEntry = {
126
125
  createdAt: number
127
126
  expiresAt: number
128
127
  replies: number
129
- }
130
-
131
- type GroupPolicy = {
132
- requireMention: boolean
133
- allowFrom: string[]
128
+ // Absent ⇒ 'dm'. 'group' entries hold a group id in chatId; approval pushes
129
+ // it into groupAllowlist (mirrors LINE).
130
+ kind?: 'dm' | 'group'
134
131
  }
135
132
 
136
133
  type Access = {
137
- dmPolicy: 'open' | 'pairing' | 'allowlist' | 'disabled'
134
+ dmPolicy: 'open' | 'allowlist' | 'disabled'
135
+ // Orthogonal pairing toggle (mirrors LINE). Only meaningful when
136
+ // dmPolicy === 'allowlist': true ⇒ unknown senders get a one-time code and
137
+ // land in pending; false ⇒ silently dropped (pure allowlist).
138
+ pairing: boolean
138
139
  allowFrom: string[]
139
- groups: Record<string, GroupPolicy>
140
+ // Group access tier (mirrors LINE): base policy, allowlisted group ids, and a
141
+ // single requireMention gate. `pairing` governs group code-minting too.
142
+ groupPolicy: 'open' | 'allowlist' | 'disabled'
143
+ groupAllowlist: string[]
144
+ requireMention: boolean
145
+ // Migration-only artifact — mirrors pure.ts Access — keep in sync. Enforced
146
+ // in gate() below so migrating a pre-split file can't silently widen a
147
+ // group that was previously restricted to specific senders.
148
+ legacyGroupAllowFrom?: Record<string, string[]>
140
149
  pending: Record<string, PendingEntry>
141
150
  mentionPatterns?: string[]
142
151
  // delivery/UX config — optional, defaults live in the reply handler
@@ -152,9 +161,12 @@ type Access = {
152
161
 
153
162
  function defaultAccess(): Access {
154
163
  return {
155
- dmPolicy: 'pairing',
164
+ dmPolicy: 'allowlist',
165
+ pairing: true,
156
166
  allowFrom: [],
157
- groups: {},
167
+ groupPolicy: 'allowlist',
168
+ groupAllowlist: [],
169
+ requireMention: true,
158
170
  pending: {},
159
171
  ackReaction: '👀',
160
172
  }
@@ -181,18 +193,8 @@ function assertSendable(f: string): void {
181
193
  function readAccessFile(): Access {
182
194
  try {
183
195
  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
- }
196
+ const parsed = JSON.parse(raw) as Partial<Access> & { dmPolicy?: string }
197
+ return migrateAccess(parsed) as Access
196
198
  } catch (err) {
197
199
  if ((err as NodeJS.ErrnoException).code === 'ENOENT') return defaultAccess()
198
200
  try {
@@ -212,7 +214,7 @@ function loadAccess(): Access {
212
214
  function assertAllowedChat(chat_id: string): void {
213
215
  const access = loadAccess()
214
216
  if (access.allowFrom.includes(chat_id)) return
215
- if (chat_id in access.groups) return
217
+ if (access.groupAllowlist.includes(chat_id)) return
216
218
  throw new Error(`chat ${chat_id} is not allowlisted — add via /telegram:access`)
217
219
  }
218
220
 
@@ -238,47 +240,20 @@ function pruneExpired(a: Access, now?: number): boolean {
238
240
  type GateResult =
239
241
  | { action: 'deliver'; access: Access }
240
242
  | { action: 'drop' }
241
- | { action: 'pair'; code: string; isResend: boolean }
243
+ | { action: 'pair'; code: string; isResend: boolean; isGroup?: boolean }
242
244
 
243
245
  function gate(ctx: Context): GateResult {
244
246
  const access = loadAccess()
245
247
  const pruned = pruneExpired(access)
246
248
  if (pruned) saveAccess(access)
247
249
 
248
- if (access.dmPolicy === 'disabled') return { action: 'drop' }
249
-
250
250
  const from = ctx.from
251
251
  if (!from) return { action: 'drop' }
252
252
  const senderId = String(from.id)
253
253
  const chatType = ctx.chat?.type
254
254
 
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
255
  if (chatType === 'private') {
256
+ if (access.dmPolicy === 'disabled') return { action: 'drop' }
282
257
  if (access.dmPolicy === 'open') {
283
258
  if (!access.allowFrom.includes(senderId)) {
284
259
  access.allowFrom.push(senderId)
@@ -287,11 +262,13 @@ function gate(ctx: Context): GateResult {
287
262
  return { action: 'deliver', access }
288
263
  }
289
264
  if (access.allowFrom.includes(senderId)) return { action: 'deliver', access }
290
- if (access.dmPolicy === 'allowlist') return { action: 'drop' }
265
+ // Base policy is 'allowlist' ('open'/'disabled' handled above). Pairing is
266
+ // the orthogonal toggle: off ⇒ pure allowlist (drop strangers, no code).
267
+ if (!access.pairing) return { action: 'drop' }
291
268
 
292
269
  // pairing mode — check for existing non-expired code for this sender
293
270
  for (const [code, p] of Object.entries(access.pending)) {
294
- if (p.senderId === senderId) {
271
+ if ((p.kind ?? 'dm') === 'dm' && p.senderId === senderId) {
295
272
  // Reply twice max (initial + one reminder), then go silent.
296
273
  if ((p.replies ?? 1) >= 2) return { action: 'drop' }
297
274
  p.replies = (p.replies ?? 1) + 1
@@ -299,8 +276,8 @@ function gate(ctx: Context): GateResult {
299
276
  return { action: 'pair', code, isResend: true }
300
277
  }
301
278
  }
302
- // Cap pending at 3. Extra attempts are silently dropped.
303
- if (Object.keys(access.pending).length >= 3) return { action: 'drop' }
279
+ // Cap pending per-kind at 3. Extra attempts are silently dropped.
280
+ if (countPending(access, 'dm') >= 3) return { action: 'drop' }
304
281
 
305
282
  const code = randomBytes(3).toString('hex') // 6 hex chars
306
283
  const now = Date.now()
@@ -310,6 +287,7 @@ function gate(ctx: Context): GateResult {
310
287
  createdAt: now,
311
288
  expiresAt: now + 60 * 60 * 1000, // 1h
312
289
  replies: 1,
290
+ kind: 'dm',
313
291
  }
314
292
  saveAccess(access)
315
293
  return { action: 'pair', code, isResend: false }
@@ -317,14 +295,42 @@ function gate(ctx: Context): GateResult {
317
295
 
318
296
  if (chatType === 'group' || chatType === 'supergroup') {
319
297
  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)) {
298
+ if (access.groupPolicy === 'disabled') return { action: 'drop' }
299
+
300
+ if (access.groupPolicy === 'allowlist' && !access.groupAllowlist.includes(groupId)) {
301
+ // Unknown group. Pairing off ⇒ silent drop. On ⇒ mint a code keyed on the
302
+ // group id and post it here; a member relays it to the admin (mirrors LINE).
303
+ if (!access.pairing) return { action: 'drop' }
304
+ for (const [code, p] of Object.entries(access.pending)) {
305
+ if (p.kind === 'group' && p.chatId === groupId) {
306
+ if ((p.replies ?? 1) >= 2) return { action: 'drop' }
307
+ p.replies = (p.replies ?? 1) + 1
308
+ saveAccess(access)
309
+ return { action: 'pair', code, isResend: true, isGroup: true }
310
+ }
311
+ }
312
+ if (countPending(access, 'group') >= 3) return { action: 'drop' }
313
+ const code = randomBytes(3).toString('hex') // 6 hex chars
314
+ const now = Date.now()
315
+ access.pending[code] = {
316
+ senderId,
317
+ chatId: groupId,
318
+ createdAt: now,
319
+ expiresAt: now + 60 * 60 * 1000, // 1h
320
+ replies: 1,
321
+ kind: 'group',
322
+ }
323
+ saveAccess(access)
324
+ return { action: 'pair', code, isResend: false, isGroup: true }
325
+ }
326
+
327
+ // Allowlisted (or open policy) → enforce any legacy per-sender restriction
328
+ // that survived migration, then the single mention gate.
329
+ const legacyAllowed = access.legacyGroupAllowFrom?.[groupId]
330
+ if (legacyAllowed && legacyAllowed.length > 0 && !legacyAllowed.includes(senderId)) {
325
331
  return { action: 'drop' }
326
332
  }
327
- if (requireMention && !isMentioned(ctx, access.mentionPatterns)) {
333
+ if (access.requireMention !== false && !isMentioned(ctx, access.mentionPatterns)) {
328
334
  return { action: 'drop' }
329
335
  }
330
336
  return { action: 'deliver', access }
@@ -333,6 +339,15 @@ function gate(ctx: Context): GateResult {
333
339
  return { action: 'drop' }
334
340
  }
335
341
 
342
+ /** Count pending entries of a given kind (absent kind ⇒ 'dm'). */
343
+ function countPending(access: Access, kind: 'dm' | 'group'): number {
344
+ let n = 0
345
+ for (const p of Object.values(access.pending)) {
346
+ if ((p.kind ?? 'dm') === kind) n++
347
+ }
348
+ return n
349
+ }
350
+
336
351
  function isMentioned(ctx: Context, extraPatterns?: string[]): boolean {
337
352
  const entities = ctx.message?.entities ?? ctx.message?.caption_entities ?? []
338
353
  const text = ctx.message?.text ?? ctx.message?.caption ?? ''
@@ -750,6 +765,17 @@ type AttachmentMeta = {
750
765
  name?: string
751
766
  }
752
767
 
768
+ // Pairing reply shown to an un-allowlisted sender: their one-time code plus
769
+ // the instruction to report it to the admin. Shared by handleInbound (normal
770
+ // message) and the /start command so both hand the code out identically.
771
+ function pairingReplyText(code: string, isResend: boolean, isGroup = false): string {
772
+ const lead = isResend ? 'Still waiting for approval.' : 'This bot is private.'
773
+ const share = isGroup
774
+ ? 'Share this code with an admin to enable me in this group.'
775
+ : 'Share this code with the admin to get access.'
776
+ return `${lead}\n\nPairing code: ${code}\n\n${share}`
777
+ }
778
+
753
779
  async function handleInbound(
754
780
  ctx: Context,
755
781
  text: string,
@@ -767,10 +793,11 @@ async function handleInbound(
767
793
  if (result.action === 'drop') return
768
794
 
769
795
  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
- )
796
+ // LINE-style: hand the user their code and let the admin approve it from
797
+ // the web UI. The user does NOT run any command (they may not have Claude
798
+ // Code at all) they just report the code to the admin, who verifies it
799
+ // matches what's shown in Pending and clicks Approve.
800
+ await ctx.reply(pairingReplyText(result.code, result.isResend, result.isGroup))
774
801
  return
775
802
  }
776
803
 
@@ -915,18 +942,21 @@ if (!SEND_ONLY) {
915
942
 
916
943
  bot.command('start', async ctx => {
917
944
  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.`)
945
+ // Route /start through the same gate as a first message so a new user gets
946
+ // their pairing code immediately (LINE-style), instead of an instruction to
947
+ // send another message. The literal "/start" text is never relayed to Claude.
948
+ const result = gate(ctx)
949
+ if (result.action === 'pair') {
950
+ await ctx.reply(pairingReplyText(result.code, result.isResend))
921
951
  return
922
952
  }
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
- )
953
+ if (result.action === 'deliver') {
954
+ // Already allowlisted (or an open-policy bot) nothing to pair.
955
+ await ctx.reply(`You're paired. Just send me a message and it reaches the assistant.`)
956
+ return
957
+ }
958
+ // drop — dmPolicy disabled, or pure allowlist (pairing off) for an unknown sender.
959
+ await ctx.reply(`This bot isn't accepting new connections right now.`)
930
960
  })
931
961
 
932
962
  bot.command('help', async ctx => {
@@ -969,7 +999,7 @@ bot.command('status', async ctx => {
969
999
  for (const [code, p] of Object.entries(access.pending)) {
970
1000
  if (p.senderId === senderId) {
971
1001
  await ctx.reply(
972
- `Pending pairing run in Claude Code:\n\n/telegram:access pair ${code}`
1002
+ `Pending approval.\n\nYour pairing code: ${code}\n\nShare it with the admin to get access.`
973
1003
  )
974
1004
  return
975
1005
  }
@@ -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
 
@@ -51,46 +51,44 @@ Read both state files and give the user a complete picture:
51
51
  (`123456789:...`).
52
52
 
53
53
  2. **Access** — read `{STATE_DIR}/access.json` (missing file
54
- = defaults: `dmPolicy: "pairing"`, empty allowlist). Show:
55
- - DM policy and what it means in one line
54
+ = defaults: `dmPolicy: "allowlist"`, `pairing: true`, empty allowlist). Show:
55
+ - DM policy (`open`/`allowlist`/`disabled`) and what it means in one line
56
+ - Pairing toggle (on/off) and what it means in one line
56
57
  - Allowed senders: count, and list display names or IDs
57
58
  - Pending pairings: count, with codes and display names if any
58
59
 
59
60
  3. **What next** — end with a concrete next step based on state:
60
61
  - No token → *"Run `/telegram:configure <token>` with the token from
61
62
  BotFather."*
62
- - Token set, policy is pairing, nobody allowed → *"DM your bot on
63
- Telegram. It replies with a code; approve with `/telegram:access pair
64
- <code>`."*
63
+ - Token set, `allowlist` + pairing on, nobody allowed → *"DM your bot on
64
+ Telegram. It replies with a code approve it from the web Channels card
65
+ (or run `/telegram:access pair <code>` here)."*
65
66
  - Token set, someone allowed → *"Ready. DM your bot to reach the
66
67
  assistant."*
67
68
 
68
- **Push toward lockdown — always.** The goal for every setup is `allowlist`
69
- with a defined list. `pairing` is not a policy to stay on; it's a temporary
70
- way to capture Telegram user IDs you don't know. Once the IDs are in, pairing
71
- has done its job and should be turned off.
69
+ **The access model.** The base policy should be `allowlist` — only people on
70
+ the list reach the assistant. **Pairing** is an orthogonal on/off toggle
71
+ (default on) that sits on top of `allowlist`: when on, an unknown sender who
72
+ DMs the bot gets a one-time code that shows up in Pending, and the admin
73
+ approves it (from the web Channels card, or `/telegram:access pair <code>`).
74
+ It's a lightweight identity check, and it's fine to leave on as your standing
75
+ way to let new people in. Turn pairing **off** only if you want a hard
76
+ allowlist where strangers are dropped silently with no code.
72
77
 
73
78
  Drive the conversation this way:
74
79
 
75
80
  1. Read the allowlist. Tell the user who's in it.
76
81
  2. Ask: *"Is that everyone who should reach you through this bot?"*
77
- 3. **If yes and policy is still `pairing`** *"Good. Let's lock it down so
78
- nobody else can trigger pairing codes:"* and offer to run
79
- `/telegram:access policy allowlist`. Do this proactively — don't wait to
80
- be asked.
81
- 4. **If no, people are missing** *"Have them DM the bot; you'll approve
82
- each with `/telegram:access pair <code>`. Run this skill again once
83
- everyone's in and we'll lock it."*
84
- 5. **If the allowlist is empty and they haven't paired themselves yet** →
85
- *"DM your bot to capture your own ID first. Then we'll add anyone else
86
- and lock it down."*
87
- 6. **If policy is already `allowlist`** → confirm this is the locked state.
88
- If they need to add someone: *"They'll need to give you their numeric ID
89
- (have them message @userinfobot), or you can briefly flip to pairing:
90
- `/telegram:access policy pairing` → they DM → you pair → flip back."*
91
-
92
- Never frame `pairing` as the correct long-term choice. Don't skip the lockdown
93
- offer.
82
+ 3. **If someone's missing** *"Leave pairing on: have them DM the bot, then
83
+ approve the code that appears in Pending (web card or `/telegram:access
84
+ pair <code>`)."*
85
+ 4. **If the allowlist is empty and they haven't paired themselves yet** →
86
+ *"DM your bot to capture your own ID first approve the code and you're
87
+ in."*
88
+ 5. **If they want a hard lockdown** (no new codes for strangers) → offer to
89
+ run `/telegram:access pairing off`. This keeps `allowlist` but stops
90
+ minting codes; add people later by flipping pairing back on, or with
91
+ `/telegram:access allow <senderId>`.
94
92
 
95
93
  ### `<token>` — save it
96
94
 
@@ -116,3 +114,10 @@ Delete the `TELEGRAM_BOT_TOKEN=` line (or the file if that's the only line).
116
114
  or `/reload-plugins`. Say so after saving.
117
115
  - `access.json` is re-read on every inbound message — policy changes via
118
116
  `/telegram:access` take effect immediately, no restart.
117
+ - **Groups need more than allowlisting.** DMs work over long-polling, but in a
118
+ group Telegram's default **Privacy Mode** stops the bot from receiving plain
119
+ messages (it only gets `/commands`, @mentions, and replies), and commands are
120
+ dropped in groups anyway. To use groups, tell the user to promote the bot to
121
+ **Admin** in the group (or disable Privacy Mode in BotFather and re-add it),
122
+ then allowlist the group via `/telegram:access` (pairing code or `group allow
123
+ <groupId>`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@0xmaxma/claude-gateway",
3
- "version": "1.3.24",
3
+ "version": "1.3.25",
4
4
  "description": "Multi-agent gateway for Claude",
5
5
  "repository": {
6
6
  "type": "git",