-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.ts
More file actions
6104 lines (5850 loc) · 284 KB
/
Copy pathserver.ts
File metadata and controls
6104 lines (5850 loc) · 284 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env bun
/**
* Telegram channel for Claude Code.
*
* Self-contained MCP server with full access control: pairing, allowlists,
* group support with mention-triggering. State lives in
* ~/.claude/channels/telegram/access.json — managed by the /telegram:access skill.
*
* Telegram's Bot API has no history or search. Reply-only tools.
*/
import { execFile } from 'child_process'
import { promisify } from 'util'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import {
ListToolsRequestSchema,
CallToolRequestSchema,
} from '@modelcontextprotocol/sdk/types.js'
import { z } from 'zod'
import { Bot, GrammyError, InlineKeyboard, InputFile, type Context } from 'grammy'
import type { ReactionTypeEmoji } from 'grammy/types'
import { randomBytes } from 'crypto'
import { readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, statSync, renameSync, realpathSync, chmodSync } from 'fs'
import { readAccessFile as readAccessFileCore } from './access-core.ts'
import { homedir } from 'os'
import { join, extname, sep } from 'path'
import { COMMAND_REGISTRY, renderHelpBody, botFatherCommands, MODEL_ALIASES, applyModelAliases, EFFORT_LEVELS } from './commands'
import { botGuardShouldDrop, type BotToBotConfig } from './botguard'
import { TNA_RE, resolveTnaAnswer, OPT_RE, optionChoices, parseOptions, tapEvidenceArgs, yesNoChoice, describeTapError, tapLanding, tapFailureCopy, type TapLanding } from './tna'
// DIVE-2846: aliased so the durable tap-failure record needs no surgery on
// (or collision with) whatever each plugin already imports from 'fs'.
import { appendFileSync as tapAppendFileSync, mkdirSync as tapMkdirSync, statSync as tapStatSync, renameSync as tapRenameSync } from 'fs'
import { parseGateReply, resolveGateReply, gateAlertIdent } from './gatereply'
import { renderRoster, renderLog, renderLineage, renderVerify, COUNCIL_BUTTONS, parseVetoTap, parseCvoteTap } from './council'
import { createFiveRunner, createFailureBreaker, type FiveRunner } from './cliexec.ts'
import { planAutoAttach, autoAttachFooter, AUTO_PHOTO_EXTS, type AutoAttachPlan } from './autoattach'
import { resolveQuestionTap } from './hooks/lib/question-bridge'
import { sweepStaleRelayIn } from './hooks/lib/relay-quarantine'
import { summarizeNeeds, reconcileBanner, type BannerState, type NeedSummary } from './banner'
import { installLifecycle } from './lifecycle.ts'
import {
appendMessage as msglogAppend,
readMessages as msglogRead,
formatRecent as msglogFormat,
mostRecentChatId as msglogMostRecent,
MSGLOG_MAX_PER_CHAT,
} from './msglog'
// Plugin version is sourced from .claude-plugin/plugin.json — the same
// manifest the Claude Code plugin system reads, so /status can never
// drift from what users have installed. Wrapped to never throw.
let PLUGIN_VERSION = '?'
try {
PLUGIN_VERSION =
JSON.parse(readFileSync(join(import.meta.dir, '.claude-plugin', 'plugin.json'), 'utf8')).version ?? '?'
} catch {}
const STATE_DIR = process.env.TELEGRAM_STATE_DIR ?? join(homedir(), '.claude', 'channels', 'telegram')
// DIVE-2846: a tap that fails must leave a record that outlives the process.
// stderr is captured by the harness that spawns us (measured: "Server stderr:
// telegram channel: …" rows in Claude Code's MCP log), but that log is per
// session and the tmux-hosted forks have only scrollback — which is why the two
// taps that failed on 2026-08-06 could never be diagnosed. So also append a
// bounded JSONL at a fixed path anyone can grep after the fact. Best-effort by
// construction: a record we cannot write must never take the tap handler down.
const TAP_FAIL_LOG = join(STATE_DIR, 'tap-failures.jsonl')
const TAP_FAIL_MAX_BYTES = 256_000
function recordTapFailure(line: string): void {
process.stderr.write(`telegram tna: ${line}\n`)
try {
tapMkdirSync(STATE_DIR, { recursive: true, mode: 0o700 })
try {
if (tapStatSync(TAP_FAIL_LOG).size > TAP_FAIL_MAX_BYTES) tapRenameSync(TAP_FAIL_LOG, `${TAP_FAIL_LOG}.1`)
} catch {
// no log yet (or unstatable) — nothing to rotate
}
tapAppendFileSync(TAP_FAIL_LOG, JSON.stringify({ ts: new Date().toISOString(), event: 'tap_failed', line }) + '\n', { mode: 0o600 })
} catch {
// The stderr line above is the floor; never throw out of the tap handler.
}
}
const ACCESS_FILE = join(STATE_DIR, 'access.json')
const APPROVED_DIR = join(STATE_DIR, 'approved')
const ENV_FILE = join(STATE_DIR, '.env')
const SILENCE_FILE = join(STATE_DIR, 'silence.json')
// Where the human last talked to this agent (DIVE-259/261). The CLI's
// _task_send_owner reads this to route gate/approve alerts to the live
// conversation — but only if the chat is still allowlisted in access.json,
// so a stale or hand-edited pointer can never widen the audience.
const LAST_HUMAN_CHAT_FILE = join(STATE_DIR, 'last-human-chat.json')
const GOAL_FILE = join(STATE_DIR, 'goal.json')
// Opt-in flag for the context carry-over nudge (DIVE-114). The /nudges command
// writes {enabled} here; the context-nudge Stop hook reads it and stays silent
// unless enabled===true. Default OFF — the nudge only fires after the user opts
// in for this agent. Path mirrors hooks/lib/paths.ts NUDGE_FILE.
const NUDGE_FILE = join(STATE_DIR, 'context-nudge.json')
// /checkpoint bookkeeping: the saved session id + label. /resume reads this.
const CHECKPOINT_FILE = join(STATE_DIR, 'checkpoint.json')
// DIVE-1503: per-DM pinned "needs-you" banner bookkeeping. Maps a paired DM
// chat id → { messageId, fingerprint } so each reconcile edits the existing pin
// instead of posting a fresh banner (the DIVE-1107 banner-storm lesson).
const NEEDS_BANNER_FILE = join(STATE_DIR, 'needs-banner.json')
// One-shot handoff to 5dive-agent-start: /resume writes the bare session id
// here; the launcher reads it on the next unit start, adds `--resume <id>`
// to the claude invocation, and deletes it. The launcher hardcodes the
// DEFAULT path ($HOME/.claude/channels/telegram/resume-next), so a
// TELEGRAM_STATE_DIR override (tests only) won't reach the real launcher —
// intentional: resume is a production-runtime feature, not a test path.
const RESUME_MARKER_FILE = join(STATE_DIR, 'resume-next')
// DIVE-1027: filesystem handshake for bridging the native picker tools
// (AskUserQuestion / ExitPlanMode) to a Telegram inline keyboard. The
// pretool-question PreToolUse hook drops `<reqid>.req.json` here and posts the
// keyboard; a `q:<reqid>:<idx>` tap lands in the callback_query router below,
// which resolves the idx against the persisted labels and writes
// `<reqid>.ans.json` — the hook polls for that and returns it as the tool
// result. Mirrors hooks/lib/paths.ts QUESTION_DIR.
const QUESTION_DIR = join(STATE_DIR, 'questions')
// Load ~/.claude/channels/telegram/.env into process.env. Real env wins.
// Plugin-spawned servers don't get an env block — this is where the token lives.
try {
// Token is a credential — lock to owner. No-op on Windows (would need ACLs).
chmodSync(ENV_FILE, 0o600)
for (const line of readFileSync(ENV_FILE, 'utf8').split('\n')) {
const m = line.match(/^(\w+)=(.*)$/)
if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2]
}
} catch {}
const TOKEN = process.env.TELEGRAM_BOT_TOKEN
const STATIC = process.env.TELEGRAM_ACCESS_MODE === 'static'
// DIVE-159 team-bot: when an agent is a member of the shared team bot, it runs
// SEND-ONLY against the team token — it MUST NOT poll getUpdates (Telegram allows
// exactly one consumer per token; a second poller = 409 = dead channel for the
// whole fleet). Inbound is instead handed to us by the single listener as atomic
// JSON file-drops in relay-in/ (see the watcher below). Opt-in: unset = pure old
// per-agent behavior, nothing changes.
const SEND_ONLY = process.env.TELEGRAM_SEND_ONLY === '1'
if (!TOKEN) {
process.stderr.write(
`telegram channel: TELEGRAM_BOT_TOKEN required\n` +
` set in ${ENV_FILE}\n` +
` format: TELEGRAM_BOT_TOKEN=123456789:AAH...\n`,
)
process.exit(1)
}
const INBOX_DIR = join(STATE_DIR, 'inbox')
// DIVE-1028: per-chat rolling message log so an agent can recover recent
// context after a restart (the Bot API has no history/search). Bounded +
// local-only; see msglog.ts for the privacy posture.
const MSGLOG_DIR = join(STATE_DIR, 'msglog')
const PID_FILE = join(STATE_DIR, 'bot.pid')
// Liveness beacon for the single getUpdates slot (DIVE-818). The active poller
// bumps this file's mtime every HEARTBEAT_MS; a newcomer treats the slot as HELD
// only while the beacon is fresh. Acquisition happens in the poll bootstrap at
// the bottom of the file.
const HEARTBEAT_FILE = join(STATE_DIR, 'bot.heartbeat')
const HEARTBEAT_MS = 3000
// 3 missed beats — how long a newcomer waits before deciding the incumbent died.
const HEARTBEAT_STALE_MS = 9000
mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 })
// DIVE-818: Telegram allows exactly one getUpdates consumer per token; a SECOND
// consumer 409-conflicts the live one. The recurring failure was a TRANSIENT
// spawn — `claude mcp list`, or an overlapping respawn — running this same
// server.ts: the old code eagerly SIGTERM'd whatever PID held the slot and
// claimed it, then (for `mcp list`) died milliseconds later, leaving NO poller.
// The channel went deaf (inbound backed up) and the MCP reply tool vanished
// until a manual `systemctl restart`.
//
// Fix: never stomp a HEALTHY incumbent. The eager kill is gone; acquisition is
// deferred to the poll bootstrap, which waits out a fresh heartbeat and only
// reclaims the slot once the beacon goes stale (incumbent actually dead). A
// transient spawn parks harmlessly and is killed by its parent before it polls.
// SEND_ONLY never polls and never touches the slot.
function pidAlive(pid: number): boolean {
try { process.kill(pid, 0); return true } catch { return false }
}
function heartbeatFresh(): boolean {
try { return Date.now() - statSync(HEARTBEAT_FILE).mtimeMs < HEARTBEAT_STALE_MS } catch { return false }
}
// A live, actively-polling incumbent owns the slot iff its PID is alive AND its
// heartbeat is fresh. A stale/absent beacon means we may take over.
function incumbentHolds(): boolean {
try {
const pid = parseInt(readFileSync(PID_FILE, 'utf8'), 10)
return pid > 1 && pid !== process.pid && pidAlive(pid) && heartbeatFresh()
} catch { return false }
}
// Set once this process becomes the active poller; cleared on shutdown.
let heartbeatTimer: ReturnType<typeof setInterval> | undefined
// Last-resort safety net — without these the process dies silently on any
// unhandled promise rejection. With them it logs and keeps serving tools.
process.on('unhandledRejection', err => {
process.stderr.write(`telegram channel: unhandled rejection: ${err}\n`)
})
process.on('uncaughtException', err => {
process.stderr.write(`telegram channel: uncaught exception: ${err}\n`)
})
// Permission-reply spec from anthropics/claude-cli-internal
// src/services/mcp/channelPermissions.ts — inlined (no CC repo dep).
// 5 lowercase letters a-z minus 'l'. Case-insensitive for phone autocorrect.
// Strict: no bare yes/no (conversational), no prefix/suffix chatter.
const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i
const bot = new Bot(TOKEN)
// Telegram rejects sendMessage/editMessageText text over 4096 chars
// (400: message is too long). A rejected ctx.reply only surfaces in
// bot.catch — the sender sees nothing (DIVE-313: /tasks went silent).
// The MCP reply tool chunks before sending; this API-layer guard covers
// every other path (slash-command handlers, button callbacks) by degrading
// an oversized send to a truncated one. parse_mode is dropped on truncation
// because a cut MarkdownV2 entity would itself 400 on unbalanced markup.
bot.api.config.use((prev, method, payload, signal) => {
if (method === 'sendMessage' || method === 'editMessageText') {
const p = payload as { text?: string; parse_mode?: string }
// DIVE-1674: never deliver a bare 'undefined'/empty payload to the user.
// This is the single transport choke point every send flows through, so a
// guard here kills the symptom regardless of which caller passed undefined
// (or a template that stringified to the literal string 'undefined').
if (p.text == null || p.text.trim() === '' || p.text.trim() === 'undefined') {
throw new Error(`telegram ${method}: refusing to send empty/undefined text`)
}
if (typeof p.text === 'string' && p.text.length > MAX_CHUNK_LIMIT) {
p.text = p.text.slice(0, MAX_CHUNK_LIMIT - 32) + '\n…(message truncated)'
delete p.parse_mode
}
}
return prev(method, payload, signal)
})
let botUsername = ''
// Telegram clears the "typing…" indicator ~5s after each sendChatAction.
// To keep it visible for long agent turns we re-send every 4s per chat
// until the next outbound reply (or a 5min ceiling, in case the agent
// crashes and never replies, so we don't loop forever).
const TYPING_INTERVAL_MS = 4_000
const TYPING_CEILING_MS = 5 * 60 * 1000
// The Stop hook (hooks/stop-reply-check.ts) bumps this file's mtime when a
// turn ends, since auto-relays are sent from a separate process and never
// reach the reply tool that would otherwise stop the loop. See DIVE-146.
const TYPING_STOP_FILE = join(STATE_DIR, 'typing-stop')
const typingLoops = new Map<string, ReturnType<typeof setInterval>>()
function startTypingLoop(chat_id: string) {
stopTypingLoop(chat_id)
const startedAt = Date.now()
void bot.api.sendChatAction(chat_id, 'typing').catch(() => {})
const handle = setInterval(() => {
// Stop if the hook signalled turn-end after this loop began. Wrapped in
// try/catch so a missing/unreadable file falls back to the prior
// ceiling-only behavior.
try {
if (statSync(TYPING_STOP_FILE).mtimeMs > startedAt) {
stopTypingLoop(chat_id)
return
}
} catch {
// file absent → keep prior behavior
}
void bot.api.sendChatAction(chat_id, 'typing').catch(() => {})
}, TYPING_INTERVAL_MS)
typingLoops.set(chat_id, handle)
setTimeout(() => stopTypingLoop(chat_id), TYPING_CEILING_MS)
}
function stopTypingLoop(chat_id: string) {
const handle = typingLoops.get(chat_id)
if (handle) {
clearInterval(handle)
typingLoops.delete(chat_id)
}
}
type PendingEntry = {
senderId: string
chatId: string
createdAt: number
expiresAt: number
replies: number
}
type GroupPolicy = {
requireMention: boolean
allowFrom: string[]
// DIVE-159: bind a group entry to one forum topic — the agent only responds there.
message_thread_id?: number
}
// DIVE-242: a group the bot was added to but that isn't allowlisted yet.
// Written by the my_chat_member handler; read by /telegram:access and the
// dashboard access modal so the owner can approve a group without hunting
// for its id. Entries persist across re-adds (announcedAt = send-once guard).
type DiscoveredGroup = {
title: string
type: 'group' | 'supergroup'
/** user id of whoever added the bot, when Telegram includes it */
addedBy?: string
firstSeenAt: number
/** set after the one-time announce — never announce this group again */
announcedAt?: number
/** set when the bot is removed; cleared on re-add (UIs hide removed entries) */
removedAt?: number
}
type Access = {
dmPolicy: 'pairing' | 'allowlist' | 'disabled'
allowFrom: string[]
groups: Record<string, GroupPolicy>
pending: Record<string, PendingEntry>
/** DIVE-242: groups the bot sits in that await approval (not in `groups` yet) */
discovered?: Record<string, DiscoveredGroup>
mentionPatterns?: string[]
// delivery/UX config — optional, defaults live in the reply handler
/** Emoji to react with on receipt. Empty string disables. Telegram only accepts its fixed whitelist. */
ackReaction?: string
/** Which chunks get Telegram's reply reference when reply_to is passed. Default: 'first'. 'off' = never thread. */
replyToMode?: 'off' | 'first' | 'all'
/** Max chars per outbound message before splitting. Default: 4096 (Telegram's hard cap). */
textChunkLimit?: number
/** Split on paragraph boundaries instead of hard char count. */
chunkMode?: 'length' | 'newline'
/**
* Bot-to-bot comms (Bot API 10.0). Senders with from.is_bot are DROPPED by
* default — two auto-replying bots in one group otherwise ping-pong forever
* (DIVE-162). Opt in per fleet, and even then dedupe + a per-group rate cap
* keep a runaway loop from blowing Telegram's ~20-msg/min/group limit.
*/
botToBot?: BotToBotConfig
}
function defaultAccess(): Access {
return {
dmPolicy: 'pairing',
allowFrom: [],
groups: {},
pending: {},
}
}
const MAX_CHUNK_LIMIT = 4096
const MAX_ATTACHMENT_BYTES = 50 * 1024 * 1024
// reply's files param takes any path. .env is ~60 bytes and ships as a
// document. Claude can already Read+paste file contents, so this isn't a new
// exfil channel for arbitrary paths — but the server's own state is the one
// thing Claude has no reason to ever send.
function assertSendable(f: string): void {
let real, stateReal: string
try {
real = realpathSync(f)
stateReal = realpathSync(STATE_DIR)
} catch { return } // statSync will fail properly; or STATE_DIR absent → nothing to leak
const inbox = join(stateReal, 'inbox')
if (real.startsWith(stateReal + sep) && !real.startsWith(inbox + sep)) {
throw new Error(`refusing to send channel state: ${f}`)
}
}
// Field defaulting only. The failure taxonomy this loader established
// (DIVE-159: ENOENT → defaults · any fs error code → throw, NEVER empty access ·
// no code ⇒ corrupt JSON → move aside) now lives in access-core.ts so the
// telegram-{codex,grok,agy} forks answer a failed read identically (DIVE-3962).
// The operator-facing strings are unchanged.
function normalizeAccess(raw: unknown): Access {
const parsed = (raw ?? {}) as Partial<Access>
return {
dmPolicy: parsed.dmPolicy ?? 'pairing',
allowFrom: parsed.allowFrom ?? [],
groups: parsed.groups ?? {},
pending: parsed.pending ?? {},
discovered: parsed.discovered,
mentionPatterns: parsed.mentionPatterns,
ackReaction: parsed.ackReaction,
replyToMode: parsed.replyToMode,
textChunkLimit: parsed.textChunkLimit,
chunkMode: parsed.chunkMode,
botToBot: parsed.botToBot,
}
}
function readAccessFile(): Access {
return readAccessFileCore({
accessFile: ACCESS_FILE,
label: 'telegram channel',
normalize: normalizeAccess,
fallback: defaultAccess,
})
}
// In static mode, access is snapshotted at boot and never re-read or written.
// Pairing requires runtime mutation, so it's downgraded to allowlist with a
// startup warning — handing out codes that never get approved would be worse.
const BOOT_ACCESS: Access | null = STATIC
? (() => {
const a = readAccessFile()
if (a.dmPolicy === 'pairing') {
process.stderr.write(
'telegram channel: static mode — dmPolicy "pairing" downgraded to "allowlist"\n',
)
a.dmPolicy = 'allowlist'
}
a.pending = {}
return a
})()
: null
function loadAccess(): Access {
return BOOT_ACCESS ?? readAccessFile()
}
// Outbound gate — reply/react/edit can only target chats the inbound gate
// would deliver from. Telegram DM chat_id == user_id, so allowFrom covers DMs.
function assertAllowedChat(chat_id: string): void {
const access = loadAccess()
if (access.allowFrom.includes(chat_id)) return
if (chat_id in access.groups) return
throw new Error(`chat ${chat_id} is not allowlisted — add via /telegram:access`)
}
function saveAccess(a: Access): void {
if (STATIC) return
mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 })
const tmp = ACCESS_FILE + '.tmp'
writeFileSync(tmp, JSON.stringify(a, null, 2) + '\n', { mode: 0o600 })
renameSync(tmp, ACCESS_FILE)
}
// DIVE-243: enabling Topics (or hitting other supergroup-upgrade triggers)
// migrates a plain group to a NEW chat id and the old id goes dead. Without
// this, the `groups` access entry keyed on the old id silently stops matching
// and the bot goes deaf to the group — DMs fine, group dead, no trace (cost
// ~2h live during PH demo prep). Move config to the new id; in STATIC mode
// the in-memory mutation still applies for the session, saveAccess no-ops.
function migrateGroupChatId(oldId: string, newId: string): void {
const access = loadAccess()
let moved = false
if (access.groups[oldId] && !access.groups[newId]) {
access.groups[newId] = access.groups[oldId]
delete access.groups[oldId]
moved = true
}
const discovered = access.discovered
if (discovered?.[oldId] && !discovered[newId]) {
discovered[newId] = discovered[oldId]
delete discovered[oldId]
moved = true
}
if (moved) saveAccess(access)
process.stderr.write(
`telegram channel: group ${oldId} migrated to supergroup ${newId}` +
(moved ? ' — access config moved to the new id\n' : ' (no access entry to move)\n'),
)
}
// DIVE-243: an unconfigured group used to drop with zero trace. Log it so
// "bot is deaf in group X" is greppable in stderr; rate-limited per chat so a
// busy unapproved group can't flood the log.
const unknownGroupLoggedAt = new Map<string, number>()
function logUnknownGroupDrop(chatId: string): void {
const now = Date.now()
if (now - (unknownGroupLoggedAt.get(chatId) ?? 0) < 10 * 60 * 1000) return
unknownGroupLoggedAt.set(chatId, now)
process.stderr.write(
`telegram channel: dropping message from group ${chatId} — no groups entry in access.json ` +
`(if Topics were just enabled the group id changed; re-approve via /telegram:access)\n`,
)
}
// Silence-watchdog state shared with hooks/silence-watchdog.sh. Both sides
// merge-and-write — the hook bumps toolCallsSinceReply on every tool call;
// this side resets it on reply/edit_message and stamps lastInboundAt on
// delivery. Atomic via tmp+rename; the brief read-modify-write window with
// the hook is acceptable because the file is a heuristic, not source of
// truth. Wrapped in try/catch so a disk hiccup never blocks a Telegram send.
type SilenceState = {
lastInboundAt: number
lastInboundChatId: string
lastInboundMessageId: number
lastReplyAt: number
lastContactAt: number
lastReminderAt: number
toolCallsSinceReply: number
}
function readSilence(): SilenceState {
try {
const raw = readFileSync(SILENCE_FILE, 'utf8')
const j = JSON.parse(raw) as Partial<SilenceState>
return {
lastInboundAt: j.lastInboundAt ?? 0,
lastInboundChatId: j.lastInboundChatId ?? '',
lastInboundMessageId: j.lastInboundMessageId ?? 0,
lastReplyAt: j.lastReplyAt ?? 0,
// Back-compat: a silence.json written by a pre-DIVE-4276 plugin has no
// lastContactAt. A reply IS contact, so fall back to it rather than
// reading an established thread as never-contacted.
lastContactAt: j.lastContactAt ?? j.lastReplyAt ?? 0,
lastReminderAt: j.lastReminderAt ?? 0,
toolCallsSinceReply: j.toolCallsSinceReply ?? 0,
}
} catch {
return {
lastInboundAt: 0,
lastInboundChatId: '',
lastInboundMessageId: 0,
lastReplyAt: 0,
lastContactAt: 0,
lastReminderAt: 0,
toolCallsSinceReply: 0,
}
}
}
function writeSilence(patch: Partial<SilenceState>): void {
try {
mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 })
const merged: SilenceState = { ...readSilence(), ...patch }
const tmp = SILENCE_FILE + '.tmp'
writeFileSync(tmp, JSON.stringify(merged) + '\n', { mode: 0o600 })
renameSync(tmp, SILENCE_FILE)
} catch {
// Heuristic state — losing a write is fine, never block a send for it.
}
}
// DIVE-4276: two stamps, deliberately not one.
//
// markContact — "the human has a sign of life from us". Resets the silence
// clock (and the tool-call counter) but says NOTHING about
// whether their newest message has been answered.
// markReplySent— contact AND "the newest inbound is answered". Only a real
// reply, or a reaction placed on the newest inbound itself,
// may claim this: lastReplyAt gates the resume prompt's "reply
// to the latest message" clause (hooks/lib/resume-prompt.ts)
// and the watchdog's reply-vs-edit verb, so stamping it from an
// edit of an OLDER message would silently bury a live question.
function markContact(): void {
writeSilence({ lastContactAt: Math.floor(Date.now() / 1000), toolCallsSinceReply: 0 })
}
function markReplySent(): void {
const now = Math.floor(Date.now() / 1000)
writeSilence({ lastReplyAt: now, lastContactAt: now, toolCallsSinceReply: 0 })
}
// True when (chat_id, message_id) IS the newest inbound we recorded — i.e. a
// reaction there answers the message the human is waiting on.
function isLatestInbound(chatId: string, messageId: number): boolean {
const s = readSilence()
return (
s.lastInboundMessageId > 0 &&
s.lastInboundMessageId === messageId &&
s.lastInboundChatId === String(chatId)
)
}
// The identity fields are ALWAYS rewritten, never merged forward: a new inbound
// with no usable message id (a button tap injects one) must CLEAR the previous
// message's identity, or a reaction on that older message would be credited as
// answering the newer one.
function markInbound(chatId?: string, messageId?: number | null): void {
const known = chatId != null && messageId != null
writeSilence({
lastInboundAt: Math.floor(Date.now() / 1000),
lastInboundChatId: known ? String(chatId) : '',
lastInboundMessageId: known ? Number(messageId) : 0,
})
}
// DIVE-261: remember where the human last spoke so task-gate alerts follow the
// conversation (Mark's DIVE-259 decision). Humans only — bot-to-bot (DIVE-161)
// traffic must not steal routing. DMs carry messageThreadId: null. Best-effort:
// a routing hint, never worth failing message handling over.
function recordLastHumanChat(chatId: string, messageThreadId: number | null): void {
try {
mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 })
const tmp = LAST_HUMAN_CHAT_FILE + '.tmp'
writeFileSync(
tmp,
JSON.stringify({ chatId, messageThreadId, at: new Date().toISOString() }) + '\n',
{ mode: 0o600 },
)
renameSync(tmp, LAST_HUMAN_CHAT_FILE)
} catch {}
}
// DIVE-1503 pinned-banner store I/O. Heuristic state: a lost read/write only
// costs one redundant banner send, never worth failing anything over.
function readBannerStore(): Record<string, BannerState> {
try {
const j = JSON.parse(readFileSync(NEEDS_BANNER_FILE, 'utf8')) as Record<string, BannerState>
return j && typeof j === 'object' ? j : {}
} catch {
return {}
}
}
function writeBannerStore(store: Record<string, BannerState>): void {
try {
mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 })
const tmp = NEEDS_BANNER_FILE + '.tmp'
writeFileSync(tmp, JSON.stringify(store) + '\n', { mode: 0o600 })
renameSync(tmp, NEEDS_BANNER_FILE)
} catch {}
}
// DIVE-1503: reconcile the pinned "needs-you" banner in every paired DM against
// the current gate backlog. Pin on the first gate, edit in place as the backlog
// changes, unpin at zero — so a pending gate can never scroll out of sight. Runs
// on a slow timer (below) in personal-bot/polled mode; 5dive-only (the inbox
// verb is a 5dive surface). Never throws into the timer.
// DIVE-1568: the resolved org coordinator (5dive task coordinator, DIVE-333):
// the sole role='coordinator', else the lone org root, else '' (ambiguous/no org
// — nobody pins). Returns null on a lookup error so the caller can skip the tick
// rather than unpin a live banner on a transient blip.
async function read5diveCoordinator(): Promise<string | null> {
const j = await read5diveJson(['task', 'coordinator', '--json'])
if (!j?.ok) return null
return typeof j.data?.coordinator === 'string' ? j.data.coordinator : ''
}
// DIVE-2041 — SAY IT OUT LOUD WHEN THE BANNER IS SUPPRESSED FLEET-WIDE.
//
// The single-pinner rule (DIVE-1568) is: only the resolved org coordinator pins
// the needs-you banner, and every other agent unpins any banner it left behind.
// When `5dive task coordinator` resolves to '' — a multi-root chart with nobody
// tagged — "every other agent" is ALL of them, so the else branch below runs
// everywhere, the pin is removed from every paired DM, and the 60s timer
// re-asserts that forever. That is exactly what DIVE-2031 was: 12 pending human
// gates, not one banner anywhere, for days.
//
// Nothing said a word. Every component reported success, because from each
// agent's point of view "I am not the coordinator, so I do not pin" is the
// normal, correct path — the outage and the healthy case are the SAME code path
// with a different fleet-wide precondition, which is why no local check could
// have caught it. Same family as DIVE-1968 / DIVE-1927: the item is filed, the
// filer believes it was surfaced, the human never sees it, and the no-op logs
// nothing.
//
// Rate-limited to one line an hour, and the timestamp RESETS the moment a
// coordinator resolves again, so a fresh outage is loud immediately instead of
// waiting out a window from the last one. This log is the local witness; the
// fleet-level one is `5dive doctor --category=channels`
// (needs-banner-coordinator), which computes the resolution itself rather than
// asking the bot — deliberately, because a bot that is down reports nothing at
// all, and "down" is precisely the state we most need named.
const COORDINATOR_MISS_LOG_INTERVAL_MS = 3_600_000
let coordinatorMissLoggedAt = 0
function noteCoordinatorSuppression(coordinator: string): void {
if (coordinator !== '') {
coordinatorMissLoggedAt = 0 // resolved again — next outage logs on its first tick
return
}
const now = Date.now()
if (coordinatorMissLoggedAt !== 0 && now - coordinatorMissLoggedAt < COORDINATOR_MISS_LOG_INTERVAL_MS) return
coordinatorMissLoggedAt = now
console.error(
'[needs-banner] SUPPRESSED FLEET-WIDE: `5dive task coordinator` resolved to nobody, ' +
'so no agent pins the needs-you banner and every paired DM has it unpinned — ' +
'pending human gates are invisible there until this is fixed. ' +
'Cause: the org chart has no agent tagged coordinator and more than one root. ' +
"Fix: `5dive org set <agent> --manager=<mgr>` to leave one root, or put 'coordinator' " +
"in one agent's role. Check: `5dive doctor --category=channels` (DIVE-2031/2041).",
)
}
let reconcilingBanner = false
async function reconcileNeedsBanner(): Promise<void> {
if (reconcilingBanner) return // never overlap: a slow inbox read must not double-run
reconcilingBanner = true
try {
if (!(await read5diveVersion())) return // OSS host: no inbox verb, no banner
const dmChats = loadAccess().allowFrom // DM chat_id == user id (see access notes)
if (dmChats.length === 0) return
// DIVE-1568: pin on ONE agent only — the resolved org coordinator. Otherwise
// the founder gets the SAME open-gate reminder pinned across every paired
// agent's DM (base + forks). A non-coordinator never pins, and unpins any
// banner it left behind. Empty/ambiguous org resolves to nobody (fail-quiet).
const coordinator = await read5diveCoordinator()
if (coordinator === null) return // lookup failed: do nothing, never flicker a live pin
noteCoordinatorSuppression(coordinator) // DIVE-2041: '' is an OUTAGE, not a quiet no-op
const iAmCoordinator = coordinator !== '' && coordinator === thisAgentName()
let summary: NeedSummary
if (iAmCoordinator) {
const j = await read5diveJson(['task', 'inbox', '--json'])
// On a read error, do NOTHING — never unpin a live backlog on a transient blip.
if (!j?.ok || !Array.isArray(j.data?.inbox)) return
summary = summarizeNeeds(j.data.inbox)
} else {
summary = { count: 0, oldestCreatedAt: null } // force unpin of any stale banner
}
const now = Date.now()
const store = readBannerStore()
let dirty = false
for (const chat of dmChats) {
const act = reconcileBanner(store[chat], summary, now)
try {
if (act.kind === 'send') {
const m = await bot.api.sendMessage(chat, act.text)
await bot.api.pinChatMessage(chat, m.message_id, { disable_notification: true }).catch(() => {})
store[chat] = { messageId: m.message_id, fingerprint: act.fingerprint }
dirty = true
} else if (act.kind === 'edit') {
await bot.api.editMessageText(chat, act.messageId, act.text)
store[chat] = { messageId: act.messageId, fingerprint: act.fingerprint }
dirty = true
} else if (act.kind === 'unpin') {
await bot.api.unpinChatMessage(chat, act.messageId).catch(() => {})
await bot.api.editMessageText(chat, act.messageId, act.clearText).catch(() => {})
delete store[chat]
dirty = true
}
} catch (err) {
// If the pinned message is gone (user deleted it), forget it so the next
// tick re-sends a fresh pin. Other errors are transient — retry next tick.
const msg = String((err as { description?: unknown })?.description ?? err)
if (/message to edit not found|message can't be edited|MESSAGE_ID_INVALID|to unpin not found/i.test(msg)) {
delete store[chat]
dirty = true
}
}
}
if (dirty) writeBannerStore(store)
} catch {
// heuristic surface — a timer must never crash the bot
} finally {
reconcilingBanner = false
}
}
// /goal state — one standing goal per agent (we don't multiplex across chats;
// a single Claude session can only work on one thing at a time anyway). The
// file is the source of truth for /goal status — Claude's own /loop state
// isn't introspectable from outside.
type GoalState = {
goal: string
startedAt: number
chatId: string
setBy: string
/** Set when the user runs /goal pause; cleared on resume/set. */
pausedAt?: number
}
function readGoal(): GoalState | null {
try {
const j = JSON.parse(readFileSync(GOAL_FILE, 'utf8')) as Partial<GoalState>
if (typeof j.goal !== 'string' || typeof j.startedAt !== 'number') return null
return {
goal: j.goal,
startedAt: j.startedAt,
chatId: j.chatId ?? '',
setBy: j.setBy ?? '',
pausedAt: typeof j.pausedAt === 'number' ? j.pausedAt : undefined,
}
} catch {
return null
}
}
function clearGoal(): void {
try { rmSync(GOAL_FILE, { force: true }) } catch {}
}
// Context carry-over nudge opt-in (DIVE-114). OFF unless the file says so —
// any read error or missing file reads as off, matching the hook's own gate.
function readNudgeEnabled(): boolean {
try {
return JSON.parse(readFileSync(NUDGE_FILE, 'utf8')).enabled === true
} catch {
return false
}
}
function writeNudgeEnabled(enabled: boolean): void {
mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 })
const tmp = NUDGE_FILE + '.tmp'
writeFileSync(tmp, JSON.stringify({ enabled }, null, 2) + '\n', { mode: 0o600 })
renameSync(tmp, NUDGE_FILE)
}
function writeGoal(g: GoalState): void {
mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 })
const tmp = GOAL_FILE + '.tmp'
writeFileSync(tmp, JSON.stringify(g, null, 2) + '\n', { mode: 0o600 })
renameSync(tmp, GOAL_FILE)
}
// /checkpoint state — the claude session the user pinned to continue later.
// label is optional free text; savedAt is for the /checkpoint status line.
type CheckpointState = {
sessionId: string
label?: string
savedAt: number
}
function readCheckpoint(): CheckpointState | null {
try {
const j = JSON.parse(readFileSync(CHECKPOINT_FILE, 'utf8')) as Partial<CheckpointState>
if (typeof j.sessionId !== 'string' || !j.sessionId) return null
return {
sessionId: j.sessionId,
label: typeof j.label === 'string' && j.label ? j.label : undefined,
savedAt: typeof j.savedAt === 'number' ? j.savedAt : 0,
}
} catch {
return null
}
}
function writeCheckpoint(c: CheckpointState): void {
mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 })
const tmp = CHECKPOINT_FILE + '.tmp'
writeFileSync(tmp, JSON.stringify(c, null, 2) + '\n', { mode: 0o600 })
renameSync(tmp, CHECKPOINT_FILE)
}
// Arm the one-shot resume marker the launcher consumes on next unit start.
// Bare session id + newline keeps the launcher's bash parse trivial (no jq).
function armResume(sessionId: string): void {
mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 })
const tmp = RESUME_MARKER_FILE + '.tmp'
writeFileSync(tmp, sessionId + '\n', { mode: 0o600 })
renameSync(tmp, RESUME_MARKER_FILE)
}
// Sticky-header anchors: every reply is remembered so subsequent
// edit_message calls can prepend the original text. Without this the
// agent overwrites a task ack with later progress and the user loses
// context if they didn't read the earlier version. First-write-wins —
// edits never overwrite the anchor. In-memory only; on restart the
// cache empties and edits fall back to legacy replace-all behavior.
const ANCHOR_CAP = 500
const ANCHOR_SEPARATOR = '\n\n→ '
const anchors = new Map<string, string>()
function anchorKey(chat_id: string, message_id: number): string {
return `${chat_id}:${message_id}`
}
function rememberAnchor(chat_id: string, message_id: number, text: string): void {
const key = anchorKey(chat_id, message_id)
if (anchors.has(key)) return
if (anchors.size >= ANCHOR_CAP) {
const oldest = anchors.keys().next().value
if (oldest != null) anchors.delete(oldest)
}
anchors.set(key, text)
}
function getAnchor(chat_id: string, message_id: number): string | undefined {
return anchors.get(anchorKey(chat_id, message_id))
}
function pruneExpired(a: Access): boolean {
const now = Date.now()
let changed = false
for (const [code, p] of Object.entries(a.pending)) {
if (p.expiresAt < now) {
delete a.pending[code]
changed = true
}
}
return changed
}
type GateResult =
| { action: 'deliver'; access: Access }
| { action: 'drop' }
| { action: 'pair'; code: string; isResend: boolean }
function gate(ctx: Context): GateResult {
const access = loadAccess()
const pruned = pruneExpired(access)
if (pruned) saveAccess(access)
if (access.dmPolicy === 'disabled') return { action: 'drop' }
const from = ctx.from
if (!from) return { action: 'drop' }
const senderId = String(from.id)
const chatType = ctx.chat?.type
// Bot-to-bot loop guard. Applies to any chat type and runs BEFORE the normal
// allowlist/pairing/mention logic so a bot sender can never trigger pairing
// codes or DM auto-replies. Default-deny: bots only pass when explicitly
// enabled, and then only within dedupe + rate limits.
if (from.is_bot) {
const chatKey = ctx.chat ? String(ctx.chat.id) : senderId
const senderKey = from.username ?? senderId
const text = ctx.message?.text ?? ctx.message?.caption ?? ''
if (botGuardShouldDrop(access.botToBot, chatKey, senderKey, text)) return { action: 'drop' }
// Survived the guards. Reuse the same per-chat-type access checks below,
// but bots never pair and must already be allowlisted for their chat.
if (chatType === 'private') {
return access.allowFrom.includes(senderId) ? { action: 'deliver', access } : { action: 'drop' }
}
if (chatType === 'group' || chatType === 'supergroup') {
const policy = access.groups[String(ctx.chat!.id)]
if (!policy) {
logUnknownGroupDrop(String(ctx.chat!.id))
return { action: 'drop' }
}
const groupAllowFrom = policy.allowFrom ?? []
if (groupAllowFrom.length > 0 && !groupAllowFrom.includes(senderId)) return { action: 'drop' }
return { action: 'deliver', access }
}
return { action: 'drop' }
}
if (chatType === 'private') {
if (access.allowFrom.includes(senderId)) return { action: 'deliver', access }
if (access.dmPolicy === 'allowlist') return { action: 'drop' }
// pairing mode — check for existing non-expired code for this sender
for (const [code, p] of Object.entries(access.pending)) {
if (p.senderId === senderId) {
// Reply twice max (initial + one reminder), then go silent.
if ((p.replies ?? 1) >= 2) return { action: 'drop' }
p.replies = (p.replies ?? 1) + 1
saveAccess(access)
return { action: 'pair', code, isResend: true }
}
}
// Cap pending at 3. Extra attempts are silently dropped.
if (Object.keys(access.pending).length >= 3) return { action: 'drop' }
const code = randomBytes(3).toString('hex') // 6 hex chars
const now = Date.now()
access.pending[code] = {
senderId,
chatId: String(ctx.chat!.id),
createdAt: now,
expiresAt: now + 60 * 60 * 1000, // 1h
replies: 1,
}
saveAccess(access)
return { action: 'pair', code, isResend: false }
}
if (chatType === 'group' || chatType === 'supergroup') {
const groupId = String(ctx.chat!.id)
const policy = access.groups[groupId]
if (!policy) {
logUnknownGroupDrop(groupId)
return { action: 'drop' }
}
const groupAllowFrom = policy.allowFrom ?? []
const requireMention = policy.requireMention ?? true
// DIVE-159: if this group entry is bound to a forum topic, only respond IN
// that topic (the agent's own lane). Messages in other topics / the General
// channel are dropped — lets one personal bot sit in a multi-agent team group
// and speak only in its own topic.
if (typeof policy.message_thread_id === 'number' &&
ctx.message?.message_thread_id !== policy.message_thread_id) {
return { action: 'drop' }
}
if (groupAllowFrom.length > 0 && !groupAllowFrom.includes(senderId)) {
return { action: 'drop' }
}
if (requireMention && !isMentioned(ctx, access.mentionPatterns)) {
return { action: 'drop' }
}
return { action: 'deliver', access }
}
return { action: 'drop' }
}
// Like gate() but for bot commands: no pairing side effects, just allow/drop.
function dmCommandGate(ctx: Context): { access: Access; senderId: string } | null {
if (ctx.chat?.type !== 'private') return null
if (!ctx.from) return null
const senderId = String(ctx.from.id)
const access = loadAccess()
const pruned = pruneExpired(access)
if (pruned) saveAccess(access)
if (access.dmPolicy === 'disabled') return null
if (access.dmPolicy === 'allowlist' && !access.allowFrom.includes(senderId)) return null
return { access, senderId }
}
function isMentioned(ctx: Context, extraPatterns?: string[]): boolean {
const entities = ctx.message?.entities ?? ctx.message?.caption_entities ?? []
const text = ctx.message?.text ?? ctx.message?.caption ?? ''
for (const e of entities) {
if (e.type === 'mention') {
const mentioned = text.slice(e.offset, e.offset + e.length)
if (mentioned.toLowerCase() === `@${botUsername}`.toLowerCase()) return true
}
if (e.type === 'text_mention' && e.user?.is_bot && e.user.username === botUsername) {
return true
}
}
// Reply to one of our messages counts as an implicit mention.
if (ctx.message?.reply_to_message?.from?.username === botUsername) return true
for (const pat of extraPatterns ?? []) {
try {
if (new RegExp(pat, 'i').test(text)) return true
} catch {
// Invalid user-supplied regex — skip it.
}
}
return false
}
// The /telegram:access skill drops a file at approved/<senderId> when it pairs
// someone. Poll for it, send confirmation, clean up. For Telegram DMs,
// chatId == senderId, so we can send directly without stashing chatId.