@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.
- package/README.md +125 -49
- 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/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/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/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/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 +108 -78
- package/mcp/tools/telegram/skills/access/SKILL.md +93 -23
- package/mcp/tools/telegram/skills/configure/SKILL.md +31 -26
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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' | '
|
|
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
|
-
|
|
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: '
|
|
164
|
+
dmPolicy: 'allowlist',
|
|
165
|
+
pairing: true,
|
|
156
166
|
allowFrom: [],
|
|
157
|
-
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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
|
-
|
|
919
|
-
|
|
920
|
-
|
|
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
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
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
|
|
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": "
|
|
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
|
|
|
@@ -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: "
|
|
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,
|
|
63
|
-
Telegram. It replies with a code
|
|
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
|
-
**
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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>`).
|