@justfortytwo/telegram 0.1.4 → 1.0.0-0
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 +73 -26
- package/dist/bridge.d.ts +31 -12
- package/dist/bridge.js +109 -143
- package/dist/bridge.js.map +1 -1
- package/dist/chat-sessions.d.ts +36 -0
- package/dist/chat-sessions.js +268 -0
- package/dist/chat-sessions.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +7 -5
- package/dist/index.js.map +1 -1
- package/dist/notify.d.ts +57 -0
- package/dist/notify.js +38 -0
- package/dist/notify.js.map +1 -0
- package/dist/telegram.d.ts +1 -1
- package/dist/telegram.js +4 -3
- package/dist/telegram.js.map +1 -1
- package/package.json +6 -7
package/README.md
CHANGED
|
@@ -1,21 +1,66 @@
|
|
|
1
1
|
# @justfortytwo/telegram
|
|
2
2
|
|
|
3
3
|
The **Telegram channel adapter** for [fortytwo](https://github.com/justfortytwo) — a
|
|
4
|
-
standalone, always-on **bridge**
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
3. **
|
|
18
|
-
|
|
4
|
+
standalone, always-on **bridge** connecting a Telegram bot to persistent Claude
|
|
5
|
+
Code sessions through `@justfortytwo/runner`.
|
|
6
|
+
|
|
7
|
+
1. **Transport.** Each active authorized chat normally has one live RunnerSession.
|
|
8
|
+
After authorization, attachment ingestion and optional journaling, input waits
|
|
9
|
+
only for `session.send()` to write it. Polling continues while Claude works;
|
|
10
|
+
another message goes to the same session immediately after preprocessing.
|
|
11
|
+
2. **Output and approvals.** Complete assistant-message events are coalesced into
|
|
12
|
+
an evolving Telegram reply, edited at most once per second during streaming.
|
|
13
|
+
A result finalizes the reply; long replies use additional messages of at most
|
|
14
|
+
4000 UTF-16 units. Typing refreshes while submitted inputs await results.
|
|
15
|
+
Existing gate deferrals produce approval cards. Approving sends the existing
|
|
16
|
+
continuation prompt to the live session, or recreates it using a saved ID.
|
|
17
|
+
3. **Scheduler separation.** Proactive wakes remain the responsibility of the
|
|
18
|
+
external `@justfortytwo/scheduler` daemon.
|
|
19
|
+
|
|
20
|
+
## Session lifecycle and recovery
|
|
21
|
+
|
|
22
|
+
The bridge owns the chat-to-session mapping. Runner owns the Claude process and
|
|
23
|
+
stream-json transport; Claude Code owns the agent. Telegram makes no provider or
|
|
24
|
+
model decisions. The runner inherits process configuration and receives
|
|
25
|
+
`CLAUDE_CODE_DISABLE_AUTO_MEMORY=1` explicitly, without changing `process.env`.
|
|
26
|
+
Persistent sessions have no default wall-clock turn timeout.
|
|
27
|
+
|
|
28
|
+
Claude stays alive between messages. When runner exposes a session ID, the bridge
|
|
29
|
+
writes it to `sessions` in `state/bridge-state.json`. Following a bridge restart,
|
|
30
|
+
the next authorized message lazily creates a session with `resumeSessionId`.
|
|
31
|
+
These IDs are recovery hints: the state file is not a transactional message queue,
|
|
32
|
+
and in-flight inputs, partial replies, or unobserved IDs can be lost on a crash.
|
|
33
|
+
Recovery also requires Claude Code's underlying session data to remain available.
|
|
34
|
+
|
|
35
|
+
An unexpected Claude exit removes the live mapping, retains the last known ID,
|
|
36
|
+
and reports an error if input was awaiting a result. The next message attempts
|
|
37
|
+
resumption; there is no automatic restart supervisor or input replay. Stderr is
|
|
38
|
+
logged locally. Asynchronous Telegram delivery failures are logged; there is no
|
|
39
|
+
new durable output queue. Assistant events are complete messages, not token-level
|
|
40
|
+
streaming, and concurrent inputs share the session's output stream.
|
|
41
|
+
|
|
42
|
+
SIGINT/SIGTERM abort polling, stop new session work and deterministically close
|
|
43
|
+
live RunnerSessions. `/logout` closes the chat's live process and removes its
|
|
44
|
+
binding, while retaining durable conversation IDs and existing pending approval
|
|
45
|
+
recovery data. The existing `ALLOWED_CHAT_IDS` bootstrap authorization still applies
|
|
46
|
+
independently of bindings.
|
|
47
|
+
|
|
48
|
+
The runner dependency is pinned to the published prerelease
|
|
49
|
+
`@justfortytwo/runner@1.0.0-0` (npm dist-tag `next`), the first release with
|
|
50
|
+
persistent sessions. Move to a stable `^1` range once runner publishes one.
|
|
51
|
+
|
|
52
|
+
### Manual check against a real Claude Code
|
|
53
|
+
|
|
54
|
+
The automated suite uses fake runners only. To verify concurrency end to end, run
|
|
55
|
+
the bridge with a test bot (`TELEGRAM_BOT_TOKEN`, `ALLOWED_CHAT_IDS=<your chat>`,
|
|
56
|
+
`claude` on `PATH`), then:
|
|
57
|
+
|
|
58
|
+
1. Send a message that makes Claude work for a while (e.g. "count slowly to 20
|
|
59
|
+
using a Bash `sleep 1` between numbers").
|
|
60
|
+
2. While typing is shown, send a second message. The bridge log should print the
|
|
61
|
+
second `inbound` line immediately, and `ps` should show a single `claude` child.
|
|
62
|
+
3. Stop the bridge with Ctrl-C and confirm no `claude` process remains; restart
|
|
63
|
+
it and send a message: the reply should continue the same conversation.
|
|
19
64
|
|
|
20
65
|
## Pairing / login binding model
|
|
21
66
|
|
|
@@ -33,7 +78,7 @@ channel side: /login <code> -> verify(chatId, code) -> bindin
|
|
|
33
78
|
bind.
|
|
34
79
|
- `/login <code>` verifies the code and, on success, **persists a binding**
|
|
35
80
|
`(channelType, channelUserId) -> owner`.
|
|
36
|
-
- `/logout` removes the binding.
|
|
81
|
+
- `/logout` removes the binding and closes the live session (recovery IDs remain).
|
|
37
82
|
- Every inbound message is authorized against **persisted bindings ∪ the
|
|
38
83
|
optional `ALLOWED_CHAT_IDS` bootstrap set**. The bootstrap set lets the first
|
|
39
84
|
owner chat reach the bridge before any binding exists; it is not required once
|
|
@@ -64,20 +109,23 @@ sqlite db exists:
|
|
|
64
109
|
> table, swap the default store for a memory-backed `BindingStore`. The login
|
|
65
110
|
> flow is unaffected.
|
|
66
111
|
|
|
67
|
-
##
|
|
112
|
+
## fortytwo package dependencies
|
|
68
113
|
|
|
69
|
-
|
|
70
|
-
|
|
114
|
+
`@justfortytwo/gate` is a regular dependency: `attachments.ts` imports its
|
|
115
|
+
`createSourceEnvelope` provenance helper at runtime (gate has no dependencies of
|
|
116
|
+
its own). Using gate as a *policy hook* remains optional and is configured
|
|
117
|
+
separately. `@justfortytwo/memory` is an **optional peer**: it is loaded
|
|
118
|
+
dynamically, and it is also a devDependency so types and tests resolve.
|
|
71
119
|
|
|
72
|
-
|
|
|
120
|
+
| Package | Used for | Contract |
|
|
73
121
|
| --- | --- | --- |
|
|
74
122
|
| `@justfortytwo/memory` | Memory store, jobs, pending-decision records, embeddings | `MEMORY_TOOL_CONTRACT_VERSION` (tools `mcp__fortytwo-memory__*`) |
|
|
75
123
|
| `@justfortytwo/gate` | Bash exact-command allowlist behind "Allow _N_h"; provenance envelopes | `POLICY_SCHEMA_VERSION` |
|
|
76
124
|
|
|
77
|
-
Install
|
|
125
|
+
Install memory alongside this package to enable persistence:
|
|
78
126
|
|
|
79
127
|
```sh
|
|
80
|
-
npm install @justfortytwo/telegram @justfortytwo/memory
|
|
128
|
+
npm install @justfortytwo/telegram @justfortytwo/memory
|
|
81
129
|
```
|
|
82
130
|
|
|
83
131
|
The **memory** data path is wired: the bridge opens + migrates the memory DB at
|
|
@@ -85,8 +133,8 @@ startup and **persists each channel event** to `@justfortytwo/memory`; recall
|
|
|
85
133
|
happens inside the headless `claude` turn via the Memory MCP tools. If the memory
|
|
86
134
|
peer is absent, the bridge degrades to **channel-only** (messages relay but are
|
|
87
135
|
not persisted) rather than refusing to start. The **gate** "Allow _N_h" path
|
|
88
|
-
still uses a local
|
|
89
|
-
exact-command allowlist
|
|
136
|
+
still uses a local placeholder; wiring it to `@justfortytwo/gate`'s
|
|
137
|
+
exact-command allowlist remains a stub, as does the cross-process pending-decision write. This migration preserves those existing seams; it does not implement gate permissions.
|
|
90
138
|
|
|
91
139
|
## Environment
|
|
92
140
|
|
|
@@ -95,7 +143,6 @@ exact-command allowlist is the remaining seam.
|
|
|
95
143
|
| `TELEGRAM_BOT_TOKEN` | yes | Bot token. The bridge is the **only** long-poller on this bot. |
|
|
96
144
|
| `ALLOWED_CHAT_IDS` | bootstrap | Comma-separated chat IDs allowed before any binding exists. Optional once bindings exist. |
|
|
97
145
|
| `CLAUDE_BIN` | no | Path to the `claude` binary (default `claude`). |
|
|
98
|
-
| `FORTYTWO_TURN_TIMEOUT` | no | Hard cap (seconds) on a single turn so a stalled model can't wedge the bridge (default 300). |
|
|
99
146
|
| `FORTYTWO_BASH_ALLOW_TTL_HOURS` | no | Default TTL (hours) for "Allow _N_h" bash approvals (default 8). |
|
|
100
147
|
| `TELEGRAM_BINDINGS_DB` | no | Path to the self-owned bindings db (default `state/telegram-bindings.db`). |
|
|
101
148
|
| `DB_PATH` | no | Path to the memory DB the bridge opens/migrates at startup (default `db/fortytwo.db`, relative to `FORTYTWO_ROOT`). |
|
|
@@ -104,7 +151,7 @@ exact-command allowlist is the remaining seam.
|
|
|
104
151
|
| `ASSISTANT_NAME` / `ASSISTANT_ACTOR` / `OWNER_ACTOR` | no | Display name + journal actor labels. |
|
|
105
152
|
| `FORTYTWO_ROOT` | no | Working root for the headless `claude` process + state files. |
|
|
106
153
|
|
|
107
|
-
> The historical `FORD_*` names (`FORD_ROOT`,
|
|
154
|
+
> The historical `FORD_*` names (`FORD_ROOT`,
|
|
108
155
|
> `FORD_BASH_ALLOW_TTL_HOURS`) are still honored as deprecated aliases — set the
|
|
109
156
|
> `FORTYTWO_*` equivalents in new deployments.
|
|
110
157
|
|
package/dist/bridge.d.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { ChatSessions } from './chat-sessions.js';
|
|
3
|
+
import { Telegram, type TgMessage, type AttachmentSpec } from './telegram.js';
|
|
3
4
|
import { type Attachment } from './attachments.js';
|
|
5
|
+
import { type AuthContext } from './login.js';
|
|
4
6
|
import type { SourceKind } from '@justfortytwo/gate';
|
|
5
|
-
import type { MemoryInput } from '@justfortytwo/memory';
|
|
7
|
+
import type { DbHandles as MemDbHandles, Embedder as MemEmbedder, MemoryInput } from '@justfortytwo/memory';
|
|
8
|
+
type DbHandles = MemDbHandles | null;
|
|
9
|
+
type Embedder = MemEmbedder | null;
|
|
6
10
|
interface ChannelEvent {
|
|
7
11
|
channel: string;
|
|
8
12
|
direction: string;
|
|
@@ -15,6 +19,16 @@ interface ChannelEvent {
|
|
|
15
19
|
}
|
|
16
20
|
/** Map a channel event onto memory's generic MemoryInput (content + provenance). */
|
|
17
21
|
export declare function mapChannelEventToMemoryInput(entry: ChannelEvent): MemoryInput;
|
|
22
|
+
/**
|
|
23
|
+
* Load the optional memory engine: open + migrate the DB and build an embedder
|
|
24
|
+
* (Ollama when EMBED_MODEL is set, else a deterministic fake). Returns null
|
|
25
|
+
* handles when memory is not installed or fails to open — the bridge then runs
|
|
26
|
+
* channel-only rather than refusing to start.
|
|
27
|
+
*/
|
|
28
|
+
export declare function loadMemoryEngine(dbPath: string): Promise<{
|
|
29
|
+
h: DbHandles;
|
|
30
|
+
embedder: Embedder;
|
|
31
|
+
}>;
|
|
18
32
|
export interface DeferredTool {
|
|
19
33
|
id: string;
|
|
20
34
|
name: string;
|
|
@@ -28,13 +42,6 @@ export interface Interpreted {
|
|
|
28
42
|
/** Read a `claude -p --output-format json` result into a reply OR a deferred tool. */
|
|
29
43
|
export declare function interpretResult(r: any): Interpreted;
|
|
30
44
|
export declare function formatApprovalCard(d: DeferredTool, bashTtlHours?: number): string;
|
|
31
|
-
export interface Schedule {
|
|
32
|
-
name: string;
|
|
33
|
-
minute: number;
|
|
34
|
-
hour: number;
|
|
35
|
-
dow?: number;
|
|
36
|
-
trigger: string;
|
|
37
|
-
}
|
|
38
45
|
export declare function telegramSourceKind(message: TgMessage): SourceKind;
|
|
39
46
|
export declare function hasHandleableContent(message: TgMessage): boolean;
|
|
40
47
|
export declare function telegramCommandReply(text: string): string | undefined;
|
|
@@ -57,7 +64,19 @@ export declare function ingestMessageAttachments(io: AttachmentIo, message: TgMe
|
|
|
57
64
|
attachments: Attachment[];
|
|
58
65
|
skipped: AttachmentSpec[];
|
|
59
66
|
}>;
|
|
60
|
-
export
|
|
61
|
-
|
|
62
|
-
|
|
67
|
+
export interface BridgeState {
|
|
68
|
+
lastUpdateId: number;
|
|
69
|
+
sessions: Record<number, string>;
|
|
70
|
+
pending: Record<number, {
|
|
71
|
+
toolUseId: string;
|
|
72
|
+
sessionId?: string;
|
|
73
|
+
toolName?: string;
|
|
74
|
+
input?: Record<string, unknown>;
|
|
75
|
+
}>;
|
|
76
|
+
}
|
|
77
|
+
export declare function createBridgeSessions(tg: Telegram, state: BridgeState, h: DbHandles, embedder: Embedder): ChatSessions;
|
|
78
|
+
export declare function handleInbound(tg: Telegram, auth: AuthContext, state: BridgeState, h: DbHandles, embedder: Embedder, chatId: number, message: TgMessage, sessions: ChatSessions): Promise<void>;
|
|
79
|
+
/** Handle an inline-keyboard tap on an approval card. */
|
|
80
|
+
export declare function handleCallback(tg: Telegram, state: BridgeState, h: DbHandles, embedder: Embedder, chatId: number, cbId: string, data: string, cardMessageId: number, sessions: ChatSessions): Promise<void>;
|
|
81
|
+
export declare function pollLoop(tg: Telegram, auth: AuthContext, state: BridgeState, h: DbHandles, embedder: Embedder, sessions: ChatSessions, signal: AbortSignal): Promise<void>;
|
|
63
82
|
export {};
|
package/dist/bridge.js
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
// Telegram bridge adapter. Always-on process (tmux / wakeup loop). It does:
|
|
3
3
|
// 1. Long-poll Telegram, authorize each inbound against persisted bindings ∪
|
|
4
4
|
// ALLOWED_CHAT_IDS bootstrap (login.ts), journal inbound on receipt, drive a
|
|
5
|
-
//
|
|
5
|
+
// persistent RunnerSession per chat, and transport the assistant's reply back.
|
|
6
6
|
// 2. Approval UX: when the gate defers an [external]/[irreversible] call the
|
|
7
7
|
// headless turn ends with stop_reason tool_deferred; surface a card with an
|
|
8
8
|
// inline keyboard (Approve/Deny). The tap records the decision + resumes so
|
|
9
9
|
// the one-shot allow fires.
|
|
10
|
-
// 3. Proactive wakes
|
|
10
|
+
// 3. Proactive wakes are handled by @justfortytwo/scheduler (external daemon).
|
|
11
11
|
//
|
|
12
12
|
// CROSS-PACKAGE NOTE: this bridge orchestrates two PEER packages —
|
|
13
13
|
// - @justfortytwo/memory : memory store, jobs, pending-decision records, embeds.
|
|
@@ -15,10 +15,12 @@
|
|
|
15
15
|
// Their imports below are STUBBED with `// TODO(wire):` markers. We do not
|
|
16
16
|
// copy their code; we declare them as peerDependencies and reference the
|
|
17
17
|
// contract names (MEMORY_TOOL_CONTRACT_VERSION, POLICY_SCHEMA_VERSION).
|
|
18
|
-
import { spawn } from 'node:child_process';
|
|
19
18
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, realpathSync } from 'node:fs';
|
|
20
19
|
import { resolve, dirname } from 'node:path';
|
|
21
20
|
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import { createRunner } from '@justfortytwo/runner';
|
|
22
|
+
import { ChatSessions } from './chat-sessions.js';
|
|
23
|
+
import { enqueueReply } from './notify.js';
|
|
22
24
|
import { Telegram, parseAllowed, approvalKeyboard, messageAttachmentSpecs } from './telegram.js';
|
|
23
25
|
import { buildAttachment, inboxRelPath, withinSizeLimit, extensionFor } from './attachments.js';
|
|
24
26
|
import { loadAdaptersConfig } from './adapters-config.js';
|
|
@@ -45,13 +47,16 @@ export function mapChannelEventToMemoryInput(entry) {
|
|
|
45
47
|
}
|
|
46
48
|
// memory.store, captured by loadMemoryEngine when the peer is present.
|
|
47
49
|
let memStore = null;
|
|
50
|
+
// memory job helpers, captured when the peer is present.
|
|
51
|
+
let memEnqueue = null;
|
|
52
|
+
let memExistsPending = null;
|
|
48
53
|
/**
|
|
49
54
|
* Load the optional memory engine: open + migrate the DB and build an embedder
|
|
50
55
|
* (Ollama when EMBED_MODEL is set, else a deterministic fake). Returns null
|
|
51
56
|
* handles when memory is not installed or fails to open — the bridge then runs
|
|
52
57
|
* channel-only rather than refusing to start.
|
|
53
58
|
*/
|
|
54
|
-
async function loadMemoryEngine(dbPath) {
|
|
59
|
+
export async function loadMemoryEngine(dbPath) {
|
|
55
60
|
let mod;
|
|
56
61
|
try {
|
|
57
62
|
mod = (await import('@justfortytwo/memory'));
|
|
@@ -69,6 +74,8 @@ async function loadMemoryEngine(dbPath) {
|
|
|
69
74
|
? new mod.OllamaEmbedder(embedModel, process.env.OLLAMA_BASE_URL)
|
|
70
75
|
: new mod.FakeEmbedder();
|
|
71
76
|
memStore = mod.store;
|
|
77
|
+
memEnqueue = mod.enqueue;
|
|
78
|
+
memExistsPending = mod.existsPending;
|
|
72
79
|
console.log(`[bridge] memory wired (db=${dbPath}, embedder=${embedModel ? 'ollama:' + embedModel : 'fake'}).`);
|
|
73
80
|
return { h, embedder };
|
|
74
81
|
}
|
|
@@ -77,16 +84,30 @@ async function loadMemoryEngine(dbPath) {
|
|
|
77
84
|
return { h: null, embedder: null };
|
|
78
85
|
}
|
|
79
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Build an EnqueueReplyDeps bound to the bridge's memory handle.
|
|
89
|
+
* Returns null when memory is not wired (channel-only mode).
|
|
90
|
+
*
|
|
91
|
+
* FLAG: the trigger condition for enqueueReply is best-effort. The bridge
|
|
92
|
+
* does not have a hard "busy/unreachable" state — it's used here in the
|
|
93
|
+
* pollLoop error handler when a message cannot be delivered inline, so the
|
|
94
|
+
* scheduler can drain it later. The dedup + DI is the primary deliverable.
|
|
95
|
+
*/
|
|
96
|
+
function makeEnqueueReplyDeps(h) {
|
|
97
|
+
if (!h || !memEnqueue || !memExistsPending)
|
|
98
|
+
return null;
|
|
99
|
+
const boundEnqueue = memEnqueue.bind(null, h);
|
|
100
|
+
const boundExistsPending = memExistsPending.bind(null, h);
|
|
101
|
+
return {
|
|
102
|
+
enqueue: (j) => boundEnqueue(j),
|
|
103
|
+
existsPending: (kind, id) => boundExistsPending(kind, id),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
80
106
|
async function store(h, e, entry) {
|
|
81
107
|
if (!h || !e || !memStore)
|
|
82
108
|
return 0; // channel-only mode
|
|
83
109
|
return memStore(h, e, mapChannelEventToMemoryInput(entry));
|
|
84
110
|
}
|
|
85
|
-
async function addJob(_h, _item) {
|
|
86
|
-
// No-op: the standalone memory server embeds inline on `store`, so there is no
|
|
87
|
-
// separate reembed job, and the deferred-jobs runner was left out of memory's scope.
|
|
88
|
-
return 0;
|
|
89
|
-
}
|
|
90
111
|
async function setPendingDecisionByToolUseId(_h, _tuid, _status, _by) {
|
|
91
112
|
// Best-effort no-op: the authoritative one-shot approval lives in the gate's
|
|
92
113
|
// store and is consumed when the headless turn re-fires the deferred call; the
|
|
@@ -220,86 +241,33 @@ function telegramSourceMeta(message) {
|
|
|
220
241
|
},
|
|
221
242
|
};
|
|
222
243
|
}
|
|
223
|
-
export const SCHEDULES = [
|
|
224
|
-
{ name: 'daily-briefing', minute: 57, hour: 7, trigger: 'Run the daily-briefing skill and send me the rundown for today.' },
|
|
225
|
-
{ name: 'sweep-afternoon', minute: 3, hour: 13, trigger: 'Open-thread sweep: check pending approvals and threads awaiting a reply; give me a one-line status.' },
|
|
226
|
-
{ name: 'sweep-evening', minute: 3, hour: 18, trigger: 'Open-thread sweep: check pending approvals and threads awaiting reply; one-line status.' },
|
|
227
|
-
{ name: 'learn-review', minute: 17, hour: 9, dow: 0, trigger: 'Run the learn-review skill: scan the recent Journal for patterns worth promoting. Propose-only — nothing installed.' },
|
|
228
|
-
];
|
|
229
|
-
/** Schedules whose cron slot matches `now` and haven't fired in this slot yet. */
|
|
230
|
-
export function dueSchedules(now, fired) {
|
|
231
|
-
const slot = (s) => `${s.name}-${now.toISOString().slice(0, 13)}`; // dedupe within the hour
|
|
232
|
-
return SCHEDULES.filter((s) => {
|
|
233
|
-
if (now.getMinutes() !== s.minute || now.getHours() !== s.hour)
|
|
234
|
-
return false;
|
|
235
|
-
if (s.dow !== undefined && now.getDay() !== s.dow)
|
|
236
|
-
return false;
|
|
237
|
-
return fired[slot(s)] !== '1';
|
|
238
|
-
});
|
|
239
|
-
}
|
|
240
244
|
function loadState() {
|
|
241
245
|
if (existsSync(STATE_PATH)) {
|
|
242
246
|
try {
|
|
243
|
-
return { lastUpdateId: 0, sessions: {}, pending: {},
|
|
247
|
+
return { lastUpdateId: 0, sessions: {}, pending: {}, ...JSON.parse(readFileSync(STATE_PATH, 'utf8')) };
|
|
244
248
|
}
|
|
245
249
|
catch { /* fall through */ }
|
|
246
250
|
}
|
|
247
|
-
return { lastUpdateId: 0, sessions: {}, pending: {}
|
|
251
|
+
return { lastUpdateId: 0, sessions: {}, pending: {} };
|
|
248
252
|
}
|
|
249
253
|
function saveState(s) {
|
|
250
254
|
mkdirSync(dirname(STATE_PATH), { recursive: true });
|
|
251
255
|
writeFileSync(STATE_PATH, JSON.stringify(s, null, 2));
|
|
252
256
|
}
|
|
253
|
-
// ---
|
|
254
|
-
function
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
const timeoutSec = Number(envCompat(process.env, 'FORTYTWO_TURN_TIMEOUT', 'FORD_TURN_TIMEOUT') ?? 300);
|
|
261
|
-
return new Promise((resolveTurn) => {
|
|
262
|
-
const child = spawn(CLAUDE_BIN, args, {
|
|
263
|
-
cwd: ROOT,
|
|
264
|
-
// The assistant's canonical memory is the Memory MCP — skip Claude Code's
|
|
265
|
-
// built-in file-memory so context isn't double-booked. Scoped to bridge turns.
|
|
266
|
-
env: { ...process.env, CLAUDE_CODE_DISABLE_AUTO_MEMORY: '1' },
|
|
267
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
268
|
-
});
|
|
269
|
-
let out = '';
|
|
270
|
-
let err = '';
|
|
271
|
-
let settled = false;
|
|
272
|
-
const finish = (r) => { if (!settled) {
|
|
273
|
-
settled = true;
|
|
274
|
-
clearTimeout(timer);
|
|
275
|
-
resolveTurn(r);
|
|
276
|
-
} };
|
|
277
|
-
const timer = setTimeout(() => {
|
|
278
|
-
child.kill('SIGTERM');
|
|
279
|
-
console.error(`[bridge] turn aborted after ${timeoutSec}s (timeout)`);
|
|
280
|
-
finish({ result: `⏳ ${ASSISTANT_NAME}'s turn exceeded ${timeoutSec}s and was aborted (the proxy/model may have stalled). Try again.` });
|
|
281
|
-
}, timeoutSec * 1000);
|
|
282
|
-
child.stdout.on('data', (d) => { out += d; });
|
|
283
|
-
child.stderr.on('data', (d) => { err += d; });
|
|
284
|
-
child.on('error', (e) => finish({ result: `(claude failed to start: ${e.message})` }));
|
|
285
|
-
child.on('close', () => {
|
|
286
|
-
try {
|
|
287
|
-
finish(JSON.parse(out));
|
|
288
|
-
}
|
|
289
|
-
catch {
|
|
290
|
-
finish({ result: (out || err).slice(0, 4000) || '(empty claude response)' });
|
|
291
|
-
}
|
|
292
|
-
});
|
|
257
|
+
// --- persistent Claude sessions ---
|
|
258
|
+
export function createBridgeSessions(tg, state, h, embedder) {
|
|
259
|
+
const runner = createRunner({ bin: CLAUDE_BIN, cwd: ROOT, env: { CLAUDE_CODE_DISABLE_AUTO_MEMORY: '1' } });
|
|
260
|
+
return new ChatSessions(runner, tg, {
|
|
261
|
+
ids: state.sessions,
|
|
262
|
+
persist: () => saveState(state),
|
|
263
|
+
onResult: (chatId, result, text) => respond(tg, h, embedder, state, chatId, result, text),
|
|
293
264
|
});
|
|
294
265
|
}
|
|
295
266
|
// --- I/O glue ---
|
|
296
267
|
async function logBridgeEntry(h, embedder, entry) {
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
|
|
300
|
-
// TODO(wire): @justfortytwo/memory job kind 'reembed_memory'.
|
|
301
|
-
await addJob(h, { kind: 'reembed_memory', payload: { memory_id: id }, max_attempts: 5 });
|
|
302
|
-
return id;
|
|
268
|
+
// memory's store() inline-enqueues a reembed_memory job when needed; no
|
|
269
|
+
// separate addJob call required here (that stub has been removed).
|
|
270
|
+
return store(h, embedder, entry);
|
|
303
271
|
}
|
|
304
272
|
async function sendReply(tg, chatId, placeholderId, text) {
|
|
305
273
|
if (placeholderId !== null && text && text.length <= 4000) {
|
|
@@ -311,27 +279,23 @@ async function sendReply(tg, chatId, placeholderId, text) {
|
|
|
311
279
|
}
|
|
312
280
|
await tg.sendMessage(chatId, text || '(no reply)');
|
|
313
281
|
}
|
|
314
|
-
async function respond(tg, h, embedder, state, chatId,
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
if (interp.deferred) {
|
|
282
|
+
async function respond(tg, h, embedder, state, chatId, result, text) {
|
|
283
|
+
// Raw result access is confined to the existing gate compatibility seam.
|
|
284
|
+
const deferred = interpretResult(result.raw).deferred;
|
|
285
|
+
if (deferred) {
|
|
319
286
|
state.pending[chatId] = {
|
|
320
|
-
toolUseId:
|
|
321
|
-
toolName:
|
|
287
|
+
toolUseId: deferred.id, sessionId: state.sessions[chatId],
|
|
288
|
+
toolName: deferred.name, input: deferred.input,
|
|
322
289
|
};
|
|
323
|
-
|
|
324
|
-
const keyboardOpts =
|
|
325
|
-
await tg.sendMessage(chatId, formatApprovalCard(
|
|
326
|
-
|
|
327
|
-
else {
|
|
328
|
-
const text = interp.reply ?? '(no reply)';
|
|
329
|
-
await logBridgeEntry(h, embedder, { channel: 'telegram', direction: 'outbound', actor: ASSISTANT_ACTOR, kind: 'message', content: text });
|
|
330
|
-
console.log(`[bridge] chat ${chatId}: replied (${text.length} chars)`);
|
|
331
|
-
await sendReply(tg, chatId, placeholderId, text);
|
|
290
|
+
saveState(state);
|
|
291
|
+
const keyboardOpts = deferred.name === 'Bash' ? { bashTtlHours: BASH_ALLOW_TTL_HOURS } : {};
|
|
292
|
+
await tg.sendMessage(chatId, formatApprovalCard(deferred), approvalKeyboard(deferred.id, keyboardOpts));
|
|
293
|
+
return true;
|
|
332
294
|
}
|
|
295
|
+
await logBridgeEntry(h, embedder, { channel: 'telegram', direction: 'outbound', actor: ASSISTANT_ACTOR, kind: 'message', content: text });
|
|
296
|
+
return false;
|
|
333
297
|
}
|
|
334
|
-
async function handleInbound(tg, auth, state, h, embedder, chatId, message) {
|
|
298
|
+
export async function handleInbound(tg, auth, state, h, embedder, chatId, message, sessions) {
|
|
335
299
|
const text = message.text ?? '';
|
|
336
300
|
// --- AUTH GATE (lockdown) -------------------------------------------------
|
|
337
301
|
// Auth commands (/login, /logout) and the unbound-sender drop are handled
|
|
@@ -345,6 +309,7 @@ async function handleInbound(tg, auth, state, h, embedder, chatId, message) {
|
|
|
345
309
|
await sendReply(tg, chatId, null, action.reply);
|
|
346
310
|
return;
|
|
347
311
|
case 'logout':
|
|
312
|
+
await sessions.close(chatId);
|
|
348
313
|
console.log(`[bridge] chat ${chatId}: unbound`);
|
|
349
314
|
await sendReply(tg, chatId, null, action.reply);
|
|
350
315
|
return;
|
|
@@ -363,7 +328,6 @@ async function handleInbound(tg, auth, state, h, embedder, chatId, message) {
|
|
|
363
328
|
}
|
|
364
329
|
}
|
|
365
330
|
// --- end auth gate --------------------------------------------------------
|
|
366
|
-
const t0 = Date.now();
|
|
367
331
|
const io = {
|
|
368
332
|
download: async (fileId) => tg.downloadFile(await tg.getFilePath(fileId)),
|
|
369
333
|
write: (relPath, bytes) => { const abs = resolve(ROOT, relPath); mkdirSync(dirname(abs), { recursive: true }); writeFileSync(abs, bytes); },
|
|
@@ -399,14 +363,10 @@ async function handleInbound(tg, auth, state, h, embedder, chatId, message) {
|
|
|
399
363
|
}
|
|
400
364
|
const prompt = buildInboundPrompt(text, attachments);
|
|
401
365
|
console.log(`[bridge] chat ${chatId}: inbound "${journalContent.slice(0, 50)}" (${attachments.length} att) — waking assistant`);
|
|
402
|
-
|
|
403
|
-
const result = await withTyping(tg, chatId, () => runClaude(prompt, state.sessions[chatId]));
|
|
404
|
-
const claudeMs = Date.now() - t1;
|
|
405
|
-
await respond(tg, h, embedder, state, chatId, null, result);
|
|
406
|
-
console.log(`[bridge] chat ${chatId}: turn ${Date.now() - t0}ms (claude ${claudeMs}ms)`);
|
|
366
|
+
await sessions.send(chatId, prompt);
|
|
407
367
|
}
|
|
408
368
|
/** Handle an inline-keyboard tap on an approval card. */
|
|
409
|
-
async function handleCallback(tg, state, h, embedder, chatId, cbId, data, cardMessageId) {
|
|
369
|
+
export async function handleCallback(tg, state, h, embedder, chatId, cbId, data, cardMessageId, sessions) {
|
|
410
370
|
const sep = data.indexOf(':');
|
|
411
371
|
const action = sep >= 0 ? data.slice(0, sep) : data;
|
|
412
372
|
const tuid = sep >= 0 ? data.slice(sep + 1) : '';
|
|
@@ -443,8 +403,8 @@ async function handleCallback(tg, state, h, embedder, chatId, cbId, data, cardMe
|
|
|
443
403
|
delete state.pending[chatId];
|
|
444
404
|
await tg.answerCallbackQuery(cbId, statusText).catch(() => { });
|
|
445
405
|
await tg.editMessageText(chatId, cardMessageId, `${statusText}...`).catch(() => { });
|
|
446
|
-
|
|
447
|
-
await
|
|
406
|
+
saveState(state);
|
|
407
|
+
await sessions.send(chatId, 'Approved - proceed with the deferred action now.', pending.sessionId);
|
|
448
408
|
}
|
|
449
409
|
else {
|
|
450
410
|
console.log(`[bridge] chat ${chatId}: denied ${tuid}`);
|
|
@@ -454,18 +414,24 @@ async function handleCallback(tg, state, h, embedder, chatId, cbId, data, cardMe
|
|
|
454
414
|
await tg.editMessageText(chatId, cardMessageId, 'Denied - the action was not run.').catch(() => { });
|
|
455
415
|
}
|
|
456
416
|
}
|
|
457
|
-
async function pollLoop(tg, auth, state, h, embedder) {
|
|
458
|
-
|
|
417
|
+
export async function pollLoop(tg, auth, state, h, embedder, sessions, signal) {
|
|
418
|
+
// Pre-build dedup deps once; null when memory is unavailable (channel-only).
|
|
419
|
+
const replyDeps = makeEnqueueReplyDeps(h);
|
|
420
|
+
while (!signal.aborted) {
|
|
459
421
|
let updates = [];
|
|
460
422
|
try {
|
|
461
|
-
updates = await tg.getUpdates(state.lastUpdateId + 1);
|
|
423
|
+
updates = await tg.getUpdates(state.lastUpdateId + 1, 30, signal);
|
|
462
424
|
}
|
|
463
425
|
catch (e) {
|
|
426
|
+
if (signal.aborted)
|
|
427
|
+
break;
|
|
464
428
|
console.error(`[bridge] getUpdates error: ${e?.message}; retrying in 5s`);
|
|
465
429
|
await sleep(5000);
|
|
466
430
|
continue;
|
|
467
431
|
}
|
|
468
432
|
for (const u of updates) {
|
|
433
|
+
if (signal.aborted)
|
|
434
|
+
break;
|
|
469
435
|
state.lastUpdateId = Math.max(state.lastUpdateId, u.update_id);
|
|
470
436
|
const chatId = u.message?.chat?.id ?? u.callback_query?.message?.chat?.id;
|
|
471
437
|
if (chatId === undefined)
|
|
@@ -475,60 +441,44 @@ async function pollLoop(tg, auth, state, h, embedder) {
|
|
|
475
441
|
// chat has no pending card, but we still gate to be safe.
|
|
476
442
|
try {
|
|
477
443
|
if (u.message && hasHandleableContent(u.message)) {
|
|
478
|
-
await handleInbound(tg, auth, state, h, embedder, chatId, u.message);
|
|
444
|
+
await handleInbound(tg, auth, state, h, embedder, chatId, u.message, sessions);
|
|
479
445
|
}
|
|
480
446
|
else if (u.callback_query?.data && u.callback_query.message) {
|
|
481
447
|
if (!isAuthorized(auth, chatId)) {
|
|
482
448
|
console.log(`[bridge] chat ${chatId}: callback dropped (unbound, lockdown)`);
|
|
483
449
|
continue;
|
|
484
450
|
}
|
|
485
|
-
await handleCallback(tg, state, h, embedder, chatId, u.callback_query.id, u.callback_query.data, u.callback_query.message.message_id);
|
|
451
|
+
await handleCallback(tg, state, h, embedder, chatId, u.callback_query.id, u.callback_query.data, u.callback_query.message.message_id, sessions);
|
|
486
452
|
}
|
|
487
453
|
}
|
|
488
454
|
catch (e) {
|
|
455
|
+
if (signal.aborted)
|
|
456
|
+
break;
|
|
489
457
|
console.error(`[bridge] handler error: ${e?.message}`);
|
|
490
458
|
if (isAuthorized(auth, chatId)) {
|
|
459
|
+
const errMsg = `⚠️ Something went wrong handling that: ${e?.message ?? e}`;
|
|
460
|
+
// Best-effort inline reply; on failure, enqueue via scheduler so the
|
|
461
|
+
// reply is delivered when the bridge recovers (dedup guards against
|
|
462
|
+
// double-delivery if the inline send succeeds on retry).
|
|
463
|
+
// FLAG: this is a conservative wiring — the "unreachable" trigger is
|
|
464
|
+
// a handler exception, not a true busy/offline state. The dedup
|
|
465
|
+
// helper + its tests are the primary deliverable of Part C.
|
|
466
|
+
let inlineSent = false;
|
|
491
467
|
try {
|
|
492
|
-
await tg.sendMessage(chatId,
|
|
468
|
+
await tg.sendMessage(chatId, errMsg);
|
|
469
|
+
inlineSent = true;
|
|
493
470
|
}
|
|
494
471
|
catch { /* ignore */ }
|
|
472
|
+
if (!inlineSent && replyDeps) {
|
|
473
|
+
await enqueueReply(replyDeps, chatId, errMsg, new Date().toISOString());
|
|
474
|
+
}
|
|
495
475
|
}
|
|
496
476
|
}
|
|
497
477
|
saveState(state);
|
|
498
478
|
}
|
|
499
479
|
}
|
|
500
480
|
}
|
|
501
|
-
async function schedulerLoop(tg, state, h, embedder, ownerChatId) {
|
|
502
|
-
while (true) {
|
|
503
|
-
const now = new Date();
|
|
504
|
-
for (const s of dueSchedules(now, state.fired)) {
|
|
505
|
-
state.fired[`${s.name}-${now.toISOString().slice(0, 13)}`] = '1';
|
|
506
|
-
saveState(state);
|
|
507
|
-
console.log(`[bridge] scheduled wake: ${s.name}`);
|
|
508
|
-
try {
|
|
509
|
-
await logBridgeEntry(h, embedder, { channel: 'internal', direction: 'internal', actor: ASSISTANT_ACTOR, kind: 'wake', content: `scheduled: ${s.name}` });
|
|
510
|
-
const result = await runClaude(s.trigger);
|
|
511
|
-
await respond(tg, h, embedder, state, ownerChatId, null, result);
|
|
512
|
-
}
|
|
513
|
-
catch (e) {
|
|
514
|
-
console.error(`[bridge] schedule ${s.name} failed: ${e?.message}`);
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
await sleep(60_000);
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
481
|
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
|
521
|
-
/** Shows Telegram's native "typing…" indicator (refreshed every 4s) while `fn` runs. */
|
|
522
|
-
async function withTyping(tg, chatId, fn) {
|
|
523
|
-
await tg.sendChatAction(chatId, 'typing').catch(() => { });
|
|
524
|
-
const iv = setInterval(() => tg.sendChatAction(chatId, 'typing').catch(() => { }), 4000);
|
|
525
|
-
try {
|
|
526
|
-
return await fn();
|
|
527
|
-
}
|
|
528
|
-
finally {
|
|
529
|
-
clearInterval(iv);
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
482
|
// --- main ---
|
|
533
483
|
async function main() {
|
|
534
484
|
const token = process.env.TELEGRAM_BOT_TOKEN;
|
|
@@ -550,16 +500,27 @@ async function main() {
|
|
|
550
500
|
console.error('[bridge] no ALLOWED_CHAT_IDS bootstrap and no existing bindings — nobody can reach the bridge. Set ALLOWED_CHAT_IDS or pre-seed a binding.');
|
|
551
501
|
process.exit(1);
|
|
552
502
|
}
|
|
553
|
-
const ownerChatId = bootstrap.size ? [...bootstrap][0] : Number(store.list('telegram')[0].channelUserId);
|
|
554
503
|
// Persist channel events to the memory store (optional peer; channel-only if absent).
|
|
555
504
|
const dbPath = process.env.DB_PATH ? resolve(ROOT, process.env.DB_PATH) : resolve(ROOT, 'db', 'fortytwo.db');
|
|
556
505
|
const { h, embedder } = await loadMemoryEngine(dbPath);
|
|
557
506
|
const tg = new Telegram(token, bootstrap);
|
|
558
507
|
const state = loadState();
|
|
559
|
-
console.log(`[bridge] up.
|
|
560
|
-
// Proactive wakes
|
|
561
|
-
|
|
562
|
-
|
|
508
|
+
console.log(`[bridge] up. bootstrap=${bootstrap.size}, bindings=${store.list('telegram').length}, bindingsDb=${BINDINGS_DB_PATH}`);
|
|
509
|
+
// Proactive wakes are now handled by @justfortytwo/scheduler (external daemon).
|
|
510
|
+
const sessions = createBridgeSessions(tg, state, h, embedder);
|
|
511
|
+
const controller = new AbortController();
|
|
512
|
+
const shutdown = () => { controller.abort(); void sessions.closeAll(); };
|
|
513
|
+
process.once('SIGINT', shutdown);
|
|
514
|
+
process.once('SIGTERM', shutdown);
|
|
515
|
+
try {
|
|
516
|
+
await pollLoop(tg, auth, state, h, embedder, sessions, controller.signal);
|
|
517
|
+
}
|
|
518
|
+
finally {
|
|
519
|
+
controller.abort();
|
|
520
|
+
await sessions.closeAll();
|
|
521
|
+
process.off('SIGINT', shutdown);
|
|
522
|
+
process.off('SIGTERM', shutdown);
|
|
523
|
+
}
|
|
563
524
|
}
|
|
564
525
|
// Run only when invoked directly (the bin / node dist/bridge.js), not when
|
|
565
526
|
// imported by tests. Realpath comparison so the npm bin symlink resolves here.
|
|
@@ -574,6 +535,11 @@ function invokedAsBin() {
|
|
|
574
535
|
return false;
|
|
575
536
|
}
|
|
576
537
|
}
|
|
577
|
-
|
|
578
|
-
|
|
538
|
+
// main() resolves only after polling stopped and closeAll() awaited every Claude
|
|
539
|
+
// process and queued delivery. Exit naturally so piped stdout/stderr can flush; the
|
|
540
|
+
// unref'd timer forces exit only if an unrelated handle would keep the process up.
|
|
541
|
+
if (invokedAsBin()) {
|
|
542
|
+
main().then(() => { process.exitCode = 0; }, (e) => { console.error('[bridge] fatal:', e); process.exitCode = 1; })
|
|
543
|
+
.finally(() => { setTimeout(() => process.exit(), 2000).unref(); });
|
|
544
|
+
}
|
|
579
545
|
//# sourceMappingURL=bridge.js.map
|