@chatpanel/bridge 0.10.41 → 0.11.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/package.json +6 -2
- package/scripts/sync-channels.mjs +93 -0
- package/scripts/sync-events.mjs +9 -1
- package/scripts/sync-pii.mjs +71 -0
- package/src/api-compat.js +4 -0
- package/src/channels/adapters/telegram.js +248 -0
- package/src/channels/bridge.js +59 -0
- package/src/channels/eventlog.js +57 -0
- package/src/channels/invoke.js +172 -0
- package/src/channels/normalize.js +58 -0
- package/src/channels/pairing.js +80 -0
- package/src/channels/service.js +270 -0
- package/src/channels/stream.js +86 -0
- package/src/cli-errors.js +75 -11
- package/src/engines/claude.js +41 -1
- package/src/engines/codex.js +134 -56
- package/src/events/capability.js +134 -0
- package/src/events/event.js +183 -0
- package/src/events/reach.js +31 -0
- package/src/events/ref.js +60 -0
- package/src/events/scopes.js +1 -1
- package/src/events/view.js +96 -0
- package/src/mcp-quarantine.js +110 -0
- package/src/pii/index.js +29 -0
- package/src/pii/net.js +111 -0
- package/src/pii/pii-detect.js +177 -0
- package/src/pii/pii-redact.js +399 -0
- package/src/pii/pipeline.js +137 -0
- package/src/pii/sanitize.js +179 -0
- package/src/pii/tool-harness.js +152 -0
- package/src/pii/tool-rank.js +110 -0
- package/src/sanitize.js +6 -136
- package/src/server.js +76 -2
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// GENERATED — do not edit.
|
|
2
|
+
// Source of truth: chatpanel-channels/src/invoke.js (npm @chatpanel/channels).
|
|
3
|
+
// Edit there, then run: npm run sync:channels
|
|
4
|
+
//
|
|
5
|
+
// Vendored rather than depended on: the bridge ships zero runtime dependencies so a
|
|
6
|
+
// curl one-liner install cannot fail on someone's registry, and so the compiled
|
|
7
|
+
// single-file binary has nothing to resolve. Package imports are rewritten to the
|
|
8
|
+
// vendored engines (src/pii, src/events) by the sync script.
|
|
9
|
+
|
|
10
|
+
// The invariant core: an inbound message becomes ONE capability invocation with
|
|
11
|
+
// actor.kind:'channel', its text is redacted before it can leave for the agent, and both facts
|
|
12
|
+
// are written to the event log. Everything here is pure given a vault + an appender — the
|
|
13
|
+
// adapter owns the network, this owns the contract.
|
|
14
|
+
|
|
15
|
+
import { validateInvocation } from '../events/capability.js';
|
|
16
|
+
import { createVault, redactText, restoreText, redactionSummary } from '../pii/index.js';
|
|
17
|
+
|
|
18
|
+
// One capability every channel message invokes: "run an agent turn on the user's behalf".
|
|
19
|
+
// Effects are non-replayable — a turn runs shell/filesystem tools, so replaying it would
|
|
20
|
+
// repeat side effects; that is exactly why validateInvocation demands an idempotencyKey.
|
|
21
|
+
export const CHANNEL_CAPABILITY = 'channel.chat';
|
|
22
|
+
export const CHANNEL_EFFECTS = 'non-replayable';
|
|
23
|
+
|
|
24
|
+
/** Build + validate the invocation for one inbound message. Throws EventError on a bad shape. */
|
|
25
|
+
export function buildInvocation({ platform, chatId, messageId }, causes = []) {
|
|
26
|
+
const id = `${platform}:${chatId}`;
|
|
27
|
+
const inv = {
|
|
28
|
+
capability: CHANNEL_CAPABILITY,
|
|
29
|
+
actor: { kind: 'channel', id },
|
|
30
|
+
scope: { kind: 'session', id },
|
|
31
|
+
causes,
|
|
32
|
+
effects: CHANNEL_EFFECTS,
|
|
33
|
+
// Same platform + chat + message = same turn. Retried delivery must not run it twice.
|
|
34
|
+
idempotencyKey: `${platform}:${chatId}:${messageId}`,
|
|
35
|
+
};
|
|
36
|
+
return validateInvocation(inv);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Entries per entity type in a vault, so we can diff before/after and report how many NEW
|
|
40
|
+
// values a turn redacted — never the values (privacy.redacted is counts-only, by contract).
|
|
41
|
+
function countsByType(vault) {
|
|
42
|
+
const counts = {};
|
|
43
|
+
for (const t of redactionSummary(vault).types) counts[t.type] = t.count;
|
|
44
|
+
return counts;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Redact inbound text into the chat's vault. Returns the redacted text plus the per-turn
|
|
49
|
+
* counts delta (for a privacy.redacted event). The vault persists across turns, so PERSON_1
|
|
50
|
+
* means the same person every message.
|
|
51
|
+
*
|
|
52
|
+
* `tier:'basic'` (regex: emails, phones, cards, keys, IPs) is the safe default with no roster;
|
|
53
|
+
* pass `tier:'full'` + `entities` to also pseudonymize known people/orgs.
|
|
54
|
+
*/
|
|
55
|
+
export function redactInbound(text, vault, { tier = 'basic', entities = [], dictionary = [] } = {}) {
|
|
56
|
+
const before = countsByType(vault);
|
|
57
|
+
const redacted = redactText(text ?? '', vault, { tier, entities, dictionary });
|
|
58
|
+
const after = countsByType(vault);
|
|
59
|
+
const counts = {};
|
|
60
|
+
for (const type of Object.keys(after)) {
|
|
61
|
+
const d = after[type] - (before[type] || 0);
|
|
62
|
+
if (d > 0) counts[type] = d;
|
|
63
|
+
}
|
|
64
|
+
return { redacted, counts };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Restore the agent's reply for the user. Two honest modes:
|
|
69
|
+
* - 'standard' (default): swap placeholders back to real values. The user reads their own
|
|
70
|
+
* data on their own phone — but Telegram/Meta bot traffic is NOT end-to-end encrypted, so
|
|
71
|
+
* the provider carries it in the clear. That is the accepted trade for a readable reply.
|
|
72
|
+
* - 'strict': leave placeholders in the outbound message. The provider never sees a real
|
|
73
|
+
* value; the user sees [[PERSON_1]]. Choose per deployment.
|
|
74
|
+
*/
|
|
75
|
+
export function restoreOutbound(text, vault, { privacy = 'standard' } = {}) {
|
|
76
|
+
return privacy === 'strict' ? String(text ?? '') : restoreText(text ?? '', vault);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// A [[TYPE_n]] placeholder token; the capture is the entity TYPE.
|
|
80
|
+
const TOKEN_MARKER_RE = /\[\[([A-Z][A-Z0-9]*)_\d+\]\]/g;
|
|
81
|
+
|
|
82
|
+
// Hard credentials: catastrophic if they reach a third-party provider, and a user practically
|
|
83
|
+
// never types their own into a chat — so we mask these on EVERY egress regardless of privacy
|
|
84
|
+
// mode. Contact PII (EMAIL/PHONE/IP) is deliberately NOT here: re-masking it would gut standard
|
|
85
|
+
// mode (the user could never read their own data back), so it follows the privacy mode instead.
|
|
86
|
+
export const EGRESS_SECRET_TYPES = new Set(['SECRET', 'KEY', 'CARD', 'SSN']);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* EGRESS SCRUB. Inbound redaction only covers the user's typed message — but the agent can
|
|
90
|
+
* surface a NEW secret the vault never saw: a key/token/card it read from a file or a tool and
|
|
91
|
+
* echoed into the reply. The reply itself is an egress to a provider (Telegram/Meta) that is NOT
|
|
92
|
+
* end-to-end encrypted, so that fresh secret would transit in the clear. This runs a fresh
|
|
93
|
+
* detector pass into a THROWAWAY vault and permanently masks the hard-credential types to a
|
|
94
|
+
* readable '‹type redacted›' marker.
|
|
95
|
+
*
|
|
96
|
+
* Run this AFTER restore: at that point no real chat-vault tokens remain, so the single throwaway
|
|
97
|
+
* vault numbers cleanly (no [[TYPE_n]] collision) and non-secret detections can be re-expanded to
|
|
98
|
+
* the value the user is allowed to read. `restoreNonSecret:false` (strict mode) keeps everything
|
|
99
|
+
* tokenized so no real value — fresh or otherwise — is emitted.
|
|
100
|
+
*/
|
|
101
|
+
export function scrubEgress(text, { secretTypes = EGRESS_SECRET_TYPES, restoreNonSecret = true } = {}) {
|
|
102
|
+
if (text == null || text === '') return text ?? '';
|
|
103
|
+
const tv = createVault();
|
|
104
|
+
const masked = redactText(String(text), tv, { tier: 'basic' });
|
|
105
|
+
return masked.replace(TOKEN_MARKER_RE, (full, type) => {
|
|
106
|
+
if (secretTypes.has(type.toUpperCase())) return `‹${type.toLowerCase()} redacted›`;
|
|
107
|
+
return restoreNonSecret ? (tv.byToken.get(full) ?? full) : full;
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The single outbound transform: what to actually SEND to the provider for one reply.
|
|
113
|
+
* - 'standard': restore the user's own values (they read their own data back), THEN scrub — so
|
|
114
|
+
* contact PII the user typed survives, but any hard credential the agent surfaced is masked.
|
|
115
|
+
* - 'strict': keep the user's values as placeholders AND still mask fresh credentials, so
|
|
116
|
+
* 'strict' is never weaker than 'standard'.
|
|
117
|
+
*/
|
|
118
|
+
export function outboundText(text, vault, { privacy = 'standard' } = {}) {
|
|
119
|
+
const restored = restoreOutbound(text, vault, { privacy });
|
|
120
|
+
return scrubEgress(restored, { restoreNonSecret: privacy !== 'strict' });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Conversation memory. The adapter keeps ONE array of REDACTED turns per chat and replays it
|
|
124
|
+
// as context, so "…and the second one?" resolves against the prior answer. The bridge's
|
|
125
|
+
// buildCliPrompt already renders all-but-last as a labelled history transcript and the last as
|
|
126
|
+
// the live message — so multi-turn just works once the array is threaded through.
|
|
127
|
+
//
|
|
128
|
+
// We store the redacted user text and the agent's (already-placeholdered) reply — never real
|
|
129
|
+
// values — so history is exactly as safe to hold as the event log, and the vault stays the one
|
|
130
|
+
// source of truth for what PERSON_1 means. The window is bounded so a long chat can't grow the
|
|
131
|
+
// prompt without end.
|
|
132
|
+
export const DEFAULT_HISTORY_MESSAGES = 16;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Append one exchange to a chat's history and return the new, bounded array (never mutates).
|
|
136
|
+
* `assistantText` is optional — a failed turn stores nothing, so history never implies the
|
|
137
|
+
* agent answered when it didn't.
|
|
138
|
+
*/
|
|
139
|
+
export function appendTurn(history, userText, assistantText, { maxMessages = DEFAULT_HISTORY_MESSAGES } = {}) {
|
|
140
|
+
const next = Array.isArray(history) ? history.slice() : [];
|
|
141
|
+
next.push({ role: 'user', content: String(userText ?? '') });
|
|
142
|
+
const reply = assistantText == null ? '' : String(assistantText);
|
|
143
|
+
if (reply) next.push({ role: 'assistant', content: reply });
|
|
144
|
+
// Keep only the last maxMessages, and never let the window open on an assistant turn — a
|
|
145
|
+
// transcript that starts with "Assistant:" reads as if the agent spoke first.
|
|
146
|
+
let windowed = maxMessages > 0 ? next.slice(-maxMessages) : next;
|
|
147
|
+
while (windowed.length && windowed[0].role === 'assistant') windowed = windowed.slice(1);
|
|
148
|
+
return windowed;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Event builders — thin, so the adapter appends without knowing payload shapes. Each returns
|
|
152
|
+
// whatever the appender's append() returns (a validated event, or a promise of one).
|
|
153
|
+
export function appendInvoked(appender, invocation, causes = []) {
|
|
154
|
+
return appender.append('capability.invoked', {
|
|
155
|
+
capability: invocation.capability,
|
|
156
|
+
actor: invocation.actor,
|
|
157
|
+
scope: invocation.scope,
|
|
158
|
+
effects: invocation.effects,
|
|
159
|
+
idempotencyKey: invocation.idempotencyKey,
|
|
160
|
+
}, causes);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function appendRedacted(appender, counts, causes = []) {
|
|
164
|
+
// Nothing redacted → nothing to record. An empty privacy.redacted still validates, but a
|
|
165
|
+
// log line that says "0 of nothing" is noise.
|
|
166
|
+
if (!counts || !Object.keys(counts).length) return null;
|
|
167
|
+
return appender.append('privacy.redacted', { counts }, causes);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function appendEgress(appender, { host, redacted, controlled }, causes = []) {
|
|
171
|
+
return appender.append('privacy.egress', { host, redacted: !!redacted, controlled: !!controlled }, causes);
|
|
172
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// GENERATED — do not edit.
|
|
2
|
+
// Source of truth: chatpanel-channels/src/normalize.js (npm @chatpanel/channels).
|
|
3
|
+
// Edit there, then run: npm run sync:channels
|
|
4
|
+
//
|
|
5
|
+
// Vendored rather than depended on: the bridge ships zero runtime dependencies so a
|
|
6
|
+
// curl one-liner install cannot fail on someone's registry, and so the compiled
|
|
7
|
+
// single-file binary has nothing to resolve. Package imports are rewritten to the
|
|
8
|
+
// vendored engines (src/pii, src/events) by the sync script.
|
|
9
|
+
|
|
10
|
+
// Platform message → one normalized shape the invoke/stream core understands. PURE: no
|
|
11
|
+
// network, no token — a Telegram update object in, a plain record out — so the whole gate
|
|
12
|
+
// (pairing, redaction, command routing) is unit-testable without a bot.
|
|
13
|
+
//
|
|
14
|
+
// One shape for every platform is the same discipline capability.js keeps for actors: the
|
|
15
|
+
// adapters are dumb transport, and everything above them speaks 'normalized message'.
|
|
16
|
+
|
|
17
|
+
export const CHANNEL_ACTOR_KIND = 'channel';
|
|
18
|
+
|
|
19
|
+
/** The actor id a paired surface invokes under: '<platform>:<chatId>'. Stable per chat. */
|
|
20
|
+
export function actorId(platform, chatId) {
|
|
21
|
+
return `${platform}:${chatId}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// A leading "/word" is a command to US (pair/stop/new/help), not a prompt for the agent.
|
|
25
|
+
// Telegram appends "@BotName" to commands in groups; strip it so "/stop@mybot" === "/stop".
|
|
26
|
+
function parseCommand(text) {
|
|
27
|
+
const m = /^\/([a-zA-Z0-9_]+)(?:@\w+)?(?:\s+([\s\S]*))?$/.exec(text.trim());
|
|
28
|
+
if (!m) return null;
|
|
29
|
+
return { name: m[1].toLowerCase(), args: (m[2] || '').trim() };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Normalize a Telegram getUpdates result item. Returns null for updates we don't act on
|
|
34
|
+
* (edited messages, channel posts, join/leave events) so the caller skips them uniformly.
|
|
35
|
+
* A photo's caption is treated as its text, matching how a person reads the message.
|
|
36
|
+
*/
|
|
37
|
+
export function normalizeTelegram(update) {
|
|
38
|
+
const msg = update?.message;
|
|
39
|
+
if (!msg || !msg.chat) return null;
|
|
40
|
+
const text = typeof msg.text === 'string' ? msg.text
|
|
41
|
+
: typeof msg.caption === 'string' ? msg.caption : '';
|
|
42
|
+
// Largest photo variant only — Telegram sends a size ladder, last is the biggest.
|
|
43
|
+
const photos = Array.isArray(msg.photo) && msg.photo.length
|
|
44
|
+
? [{ fileId: msg.photo[msg.photo.length - 1].file_id }]
|
|
45
|
+
: [];
|
|
46
|
+
const command = text.startsWith('/') ? parseCommand(text) : null;
|
|
47
|
+
return {
|
|
48
|
+
platform: 'telegram',
|
|
49
|
+
chatId: String(msg.chat.id),
|
|
50
|
+
chatType: msg.chat.type || 'private',
|
|
51
|
+
messageId: String(msg.message_id),
|
|
52
|
+
from: { id: String(msg.from?.id ?? msg.chat.id), name: msg.from?.first_name || msg.from?.username || '' },
|
|
53
|
+
text,
|
|
54
|
+
command,
|
|
55
|
+
photos,
|
|
56
|
+
replyToMessageId: msg.reply_to_message ? String(msg.reply_to_message.message_id) : null,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// GENERATED — do not edit.
|
|
2
|
+
// Source of truth: chatpanel-channels/src/pairing.js (npm @chatpanel/channels).
|
|
3
|
+
// Edit there, then run: npm run sync:channels
|
|
4
|
+
//
|
|
5
|
+
// Vendored rather than depended on: the bridge ships zero runtime dependencies so a
|
|
6
|
+
// curl one-liner install cannot fail on someone's registry, and so the compiled
|
|
7
|
+
// single-file binary has nothing to resolve. Package imports are rewritten to the
|
|
8
|
+
// vendored engines (src/pii, src/events) by the sync script.
|
|
9
|
+
|
|
10
|
+
// Pairing — who may drive an agent from a phone, and how far their requests may travel.
|
|
11
|
+
//
|
|
12
|
+
// This is the AUTHENTICATION half of §7: it proves WHO sent a message (Telegram authenticates
|
|
13
|
+
// the sender's chat id; a one-time code makes enrollment deliberate, not silent). It says
|
|
14
|
+
// nothing about WHAT a message may do — a paired-but-injected message is still injected, so
|
|
15
|
+
// `reach` is a ceiling, never a licence. Tool authorization is the next layer up.
|
|
16
|
+
//
|
|
17
|
+
// Reach reuses the router's tiers verbatim (device < trusted < any), so "a paired phone is
|
|
18
|
+
// trusted" means the exact same thing here as it does to the model router downstream.
|
|
19
|
+
|
|
20
|
+
import { REACH } from '../events/reach.js';
|
|
21
|
+
|
|
22
|
+
export { REACH };
|
|
23
|
+
|
|
24
|
+
// 6 digits: enough entropy for a short-lived, single-use enrollment code shown on a screen,
|
|
25
|
+
// short enough to thumb into a phone. It is NOT a password — it expires and burns on first use.
|
|
26
|
+
function sixDigits(randomInt) {
|
|
27
|
+
return String(randomInt(0, 1_000_000)).padStart(6, '0');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A pairing store over a plain-JSON state object, with clock and RNG injected so enrollment
|
|
32
|
+
* is deterministic in tests. Persistence is the caller's job: load the JSON at start, call
|
|
33
|
+
* toJSON() after a mutation, write it back — the same shape as pii's createVault/vaultToJSON.
|
|
34
|
+
*
|
|
35
|
+
* state: { paired: { [actorId]: { reach, at } }, pending: { [code]: { at, ttlMs } } }
|
|
36
|
+
*/
|
|
37
|
+
export function createPairingStore(state = {}, {
|
|
38
|
+
now = () => Date.now(),
|
|
39
|
+
randomInt = (min, max) => min + Math.floor(Math.random() * (max - min)),
|
|
40
|
+
} = {}) {
|
|
41
|
+
const paired = new Map(Object.entries(state.paired || {}));
|
|
42
|
+
const pending = new Map(Object.entries(state.pending || {}));
|
|
43
|
+
|
|
44
|
+
const prune = () => {
|
|
45
|
+
const t = now();
|
|
46
|
+
for (const [code, p] of pending) if (t - p.at > (p.ttlMs || 0)) pending.delete(code);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
/** Owner-side: mint a one-time code to read out to the phone. Shown in the extension/CLI. */
|
|
51
|
+
requestCode({ ttlMs = 10 * 60_000 } = {}) {
|
|
52
|
+
prune();
|
|
53
|
+
let code;
|
|
54
|
+
do { code = sixDigits(randomInt); } while (pending.has(code));
|
|
55
|
+
pending.set(code, { at: now(), ttlMs });
|
|
56
|
+
return code;
|
|
57
|
+
},
|
|
58
|
+
/** Phone-side: "/pair 123456". Burns the code and pairs the actor at 'trusted'. */
|
|
59
|
+
redeem(actorId, code, { reach = 'trusted' } = {}) {
|
|
60
|
+
prune();
|
|
61
|
+
const c = String(code || '').trim();
|
|
62
|
+
if (!pending.has(c)) return { ok: false, reason: 'unknown or expired code' };
|
|
63
|
+
if (!REACH.includes(reach)) return { ok: false, reason: `unknown reach '${reach}'` };
|
|
64
|
+
pending.delete(c);
|
|
65
|
+
paired.set(actorId, { reach, at: now() });
|
|
66
|
+
return { ok: true, reach };
|
|
67
|
+
},
|
|
68
|
+
/** Bootstrap without a code — for an operator-supplied allow list. Explicit, not silent. */
|
|
69
|
+
allow(actorId, { reach = 'trusted' } = {}) {
|
|
70
|
+
if (!REACH.includes(reach)) throw new Error(`unknown reach '${reach}'`);
|
|
71
|
+
paired.set(actorId, { reach, at: now() });
|
|
72
|
+
},
|
|
73
|
+
revoke(actorId) { return paired.delete(actorId); },
|
|
74
|
+
isPaired(actorId) { return paired.has(actorId); },
|
|
75
|
+
/** The reach ceiling for this actor, or null when it isn't paired (→ refuse the message). */
|
|
76
|
+
reachOf(actorId) { return paired.get(actorId)?.reach || null; },
|
|
77
|
+
list() { return [...paired.entries()].map(([id, v]) => ({ actorId: id, ...v })); },
|
|
78
|
+
toJSON() { return { paired: Object.fromEntries(paired), pending: Object.fromEntries(pending) }; },
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// GENERATED — do not edit.
|
|
2
|
+
// Source of truth: chatpanel-channels/src/service.js (npm @chatpanel/channels).
|
|
3
|
+
// Edit there, then run: npm run sync:channels
|
|
4
|
+
//
|
|
5
|
+
// Vendored rather than depended on: the bridge ships zero runtime dependencies so a
|
|
6
|
+
// curl one-liner install cannot fail on someone's registry, and so the compiled
|
|
7
|
+
// single-file binary has nothing to resolve. Package imports are rewritten to the
|
|
8
|
+
// vendored engines (src/pii, src/events) by the sync script.
|
|
9
|
+
|
|
10
|
+
// The channel SERVICE — connect · pair · status · disconnect, as one contract a UI can drive.
|
|
11
|
+
//
|
|
12
|
+
// The adapter is a loop. A *service* is what a person can actually operate: is it connected,
|
|
13
|
+
// to which bot, who is paired, give me a code, stop it. That contract lives here rather than
|
|
14
|
+
// inside whichever process happens to host the loop, because there is more than one host — the
|
|
15
|
+
// bridge (always on, and what a non-technical user already has), the CLI (headless boxes), and
|
|
16
|
+
// a desktop app later. Three hosts implementing "connect a bot" is three different security
|
|
17
|
+
// postures for the same secret.
|
|
18
|
+
//
|
|
19
|
+
// It owns exactly the state a channel has:
|
|
20
|
+
// • the bot token — a 0600 file, read at start, NEVER returned by status();
|
|
21
|
+
// • the pairing store — who may drive an agent, and their reach ceiling;
|
|
22
|
+
// • the per-channel settings — which agent answers, and the privacy mode.
|
|
23
|
+
//
|
|
24
|
+
// It owns none of the transport around it: no HTTP, no auth, no UI. The host does that, which
|
|
25
|
+
// is why this module needs no server and is testable with a stub fetch.
|
|
26
|
+
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import { readFile, writeFile, mkdir, rm, stat } from 'node:fs/promises';
|
|
29
|
+
import { createPairingStore } from './pairing.js';
|
|
30
|
+
import { createEventLog } from './eventlog.js';
|
|
31
|
+
import { startTelegram } from './adapters/telegram.js';
|
|
32
|
+
|
|
33
|
+
const TELEGRAM_API = 'https://api.telegram.org';
|
|
34
|
+
export const DEFAULT_SETTINGS = Object.freeze({ agent: 'claude', privacy: 'standard', tier: 'basic' });
|
|
35
|
+
|
|
36
|
+
// Restart backoff. A long-poll that dies (network drop, laptop asleep, Telegram hiccup) must
|
|
37
|
+
// come back on its own — a channel nobody is watching is exactly the one that must self-heal —
|
|
38
|
+
// but a token that has been REVOKED would otherwise spin forever, so the wait grows.
|
|
39
|
+
const RETRY_MS = [2_000, 5_000, 15_000, 60_000];
|
|
40
|
+
|
|
41
|
+
const readJson = async (file, fallback) => {
|
|
42
|
+
try { return JSON.parse(await readFile(file, 'utf8')); } catch { return fallback; }
|
|
43
|
+
};
|
|
44
|
+
const writeJson = (file, value) => writeFile(file, JSON.stringify(value, null, 2), { mode: 0o600 });
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Ask Telegram who this token belongs to. This is the ONLY validation that matters at connect
|
|
48
|
+
* time: a typo'd token must fail in the settings screen, with a reason, rather than becoming a
|
|
49
|
+
* silent poll loop nobody sees the logs of.
|
|
50
|
+
*/
|
|
51
|
+
export async function verifyBot(botToken, { fetchImpl = fetch, signal } = {}) {
|
|
52
|
+
const res = await fetchImpl(`${TELEGRAM_API}/bot${String(botToken || '').trim()}/getMe`, { signal });
|
|
53
|
+
const body = await res.json().catch(() => null);
|
|
54
|
+
if (!body?.ok) {
|
|
55
|
+
const why = body?.description || `HTTP ${res.status}`;
|
|
56
|
+
throw new Error(/unauthorized/i.test(why) ? 'Telegram rejected that token — copy it again from @BotFather' : why);
|
|
57
|
+
}
|
|
58
|
+
return { id: body.result.id, username: body.result.username, name: body.result.first_name || '' };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The t.me link that pairs in one tap: Telegram turns it into "/start <code>" in the chat. */
|
|
62
|
+
export const pairLink = (username, code) => `https://t.me/${username}?start=${code}`;
|
|
63
|
+
|
|
64
|
+
export function createChannelService({
|
|
65
|
+
home, // ~/.chatpanel — where the token file lives
|
|
66
|
+
dataDir, // ~/.chatpanel/channels — pairing, config, event log
|
|
67
|
+
bridge, // { baseUrl, token } — how the adapter reaches the agent
|
|
68
|
+
logger = console,
|
|
69
|
+
fetchImpl = undefined, // injected in tests
|
|
70
|
+
now = () => Date.now(),
|
|
71
|
+
} = {}) {
|
|
72
|
+
const tokenFile = path.join(home, 'telegram-token');
|
|
73
|
+
const configFile = path.join(dataDir, 'config.json');
|
|
74
|
+
const pairingFile = path.join(dataDir, 'pairing.json');
|
|
75
|
+
|
|
76
|
+
let pairing = createPairingStore();
|
|
77
|
+
let settings = { ...DEFAULT_SETTINGS };
|
|
78
|
+
let appender = null;
|
|
79
|
+
let bot = null; // { id, username, name } once verified
|
|
80
|
+
let controller = null; // aborts the running loop
|
|
81
|
+
let running = false;
|
|
82
|
+
let lastError = '';
|
|
83
|
+
let attempt = 0;
|
|
84
|
+
let stopped = true; // deliberate stop — suppresses the restart
|
|
85
|
+
|
|
86
|
+
const savePairing = () => writeJson(pairingFile, pairing.toJSON());
|
|
87
|
+
const saveSettings = () => writeJson(configFile, settings);
|
|
88
|
+
|
|
89
|
+
async function readToken() {
|
|
90
|
+
try {
|
|
91
|
+
const t = (await readFile(tokenFile, 'utf8')).trim();
|
|
92
|
+
if (!t) return '';
|
|
93
|
+
try {
|
|
94
|
+
const { mode } = await stat(tokenFile);
|
|
95
|
+
if (mode & 0o077) logger.warn?.(`[channels] ${tokenFile} is group/world-readable — chmod 600 it (it holds your bot token).`);
|
|
96
|
+
} catch { /* best effort */ }
|
|
97
|
+
return t;
|
|
98
|
+
} catch { return ''; }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function load() {
|
|
102
|
+
await mkdir(dataDir, { recursive: true });
|
|
103
|
+
pairing = createPairingStore(await readJson(pairingFile, {}), { now });
|
|
104
|
+
settings = { ...DEFAULT_SETTINGS, ...(await readJson(configFile, {})) };
|
|
105
|
+
if (!appender) appender = await createEventLog({ file: path.join(dataDir, 'events.jsonl'), host: 'channel' });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// One supervised run of the loop. Resolves when the loop ends; schedules its own restart
|
|
109
|
+
// unless the stop was deliberate.
|
|
110
|
+
function spawnLoop(botToken) {
|
|
111
|
+
controller = new AbortController();
|
|
112
|
+
running = true;
|
|
113
|
+
const done = startTelegram({
|
|
114
|
+
botToken,
|
|
115
|
+
baseUrl: bridge.baseUrl,
|
|
116
|
+
token: bridge.token,
|
|
117
|
+
pairing,
|
|
118
|
+
savePairing,
|
|
119
|
+
appender,
|
|
120
|
+
agent: settings.agent,
|
|
121
|
+
system: settings.system || '',
|
|
122
|
+
redact: { tier: settings.tier },
|
|
123
|
+
privacy: settings.privacy,
|
|
124
|
+
logger,
|
|
125
|
+
signal: controller.signal,
|
|
126
|
+
});
|
|
127
|
+
Promise.resolve(done)
|
|
128
|
+
.catch((e) => { lastError = e?.message || String(e); logger.warn?.(`[channels] telegram loop failed: ${lastError}`); })
|
|
129
|
+
.finally(() => {
|
|
130
|
+
running = false;
|
|
131
|
+
if (stopped) return;
|
|
132
|
+
const wait = RETRY_MS[Math.min(attempt++, RETRY_MS.length - 1)];
|
|
133
|
+
logger.warn?.(`[channels] telegram stopped unexpectedly — retrying in ${Math.round(wait / 1000)}s`);
|
|
134
|
+
setTimeout(() => { if (!stopped) spawnLoop(botToken); }, wait).unref?.();
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function start() {
|
|
139
|
+
if (running) return { ok: true, already: true };
|
|
140
|
+
await load();
|
|
141
|
+
const botToken = await readToken();
|
|
142
|
+
if (!botToken) return { ok: false, error: 'no bot token configured' };
|
|
143
|
+
if (settings.enabled === false) return { ok: false, error: 'channel is turned off' };
|
|
144
|
+
try {
|
|
145
|
+
bot = await verifyBot(botToken, { fetchImpl });
|
|
146
|
+
} catch (e) {
|
|
147
|
+
// A revoked or mistyped token must SAY so and stay stopped — a poll loop against a dead
|
|
148
|
+
// token is the failure that looks like "nobody has messaged me yet".
|
|
149
|
+
lastError = e?.message || String(e);
|
|
150
|
+
return { ok: false, error: lastError };
|
|
151
|
+
}
|
|
152
|
+
lastError = '';
|
|
153
|
+
attempt = 0;
|
|
154
|
+
stopped = false;
|
|
155
|
+
spawnLoop(botToken);
|
|
156
|
+
return { ok: true, bot };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
/** Start on host boot, but only if the user already connected one. Never throws. */
|
|
161
|
+
async startIfConfigured() {
|
|
162
|
+
await load();
|
|
163
|
+
if (!(await readToken()) || settings.enabled === false) return { ok: false, skipped: true };
|
|
164
|
+
return start().catch((e) => ({ ok: false, error: e?.message || String(e) }));
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
start,
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Connect a bot: verify the token FIRST, then persist it 0600 and start. Verifying before
|
|
171
|
+
* writing means a typo never leaves a dead secret on disk.
|
|
172
|
+
*/
|
|
173
|
+
async connect({ token: botToken, agent, privacy, tier } = {}) {
|
|
174
|
+
await load();
|
|
175
|
+
const verified = await verifyBot(botToken, { fetchImpl }); // throws with a readable reason
|
|
176
|
+
await writeFile(tokenFile, String(botToken).trim(), { mode: 0o600 });
|
|
177
|
+
settings = {
|
|
178
|
+
...settings,
|
|
179
|
+
...(agent ? { agent } : {}),
|
|
180
|
+
...(privacy ? { privacy } : {}),
|
|
181
|
+
...(tier ? { tier } : {}),
|
|
182
|
+
enabled: true,
|
|
183
|
+
};
|
|
184
|
+
await saveSettings();
|
|
185
|
+
await this.stop();
|
|
186
|
+
const r = await start();
|
|
187
|
+
if (!r.ok) throw new Error(r.error);
|
|
188
|
+
return { bot: verified, settings: { ...settings } };
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Mint a one-time enrollment code and the link that redeems it in one tap. The code is the
|
|
193
|
+
* fallback for someone reading it off a screen; the link is the path most people take.
|
|
194
|
+
*/
|
|
195
|
+
async pair({ ttlMs = 10 * 60_000 } = {}) {
|
|
196
|
+
await load();
|
|
197
|
+
if (!bot) throw new Error('connect a bot first');
|
|
198
|
+
const code = pairing.requestCode({ ttlMs });
|
|
199
|
+
await savePairing();
|
|
200
|
+
return { code, link: pairLink(bot.username, code), expiresAt: now() + ttlMs, bot: { ...bot } };
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Pre-pair without a code — an operator allow-list for scripted setups. Explicit by
|
|
205
|
+
* design: there is no path here that enrolls a chat because it messaged you.
|
|
206
|
+
*/
|
|
207
|
+
async allow(actorId, { reach = 'trusted' } = {}) {
|
|
208
|
+
await load();
|
|
209
|
+
pairing.allow(actorId, { reach });
|
|
210
|
+
await savePairing();
|
|
211
|
+
return { actorId, reach };
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
/** Revoke one phone. Takes effect on its NEXT message — nothing is cached per chat. */
|
|
215
|
+
async unpair(actorId) {
|
|
216
|
+
await load();
|
|
217
|
+
const removed = pairing.revoke(actorId);
|
|
218
|
+
await savePairing();
|
|
219
|
+
return { removed };
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
async update(patch = {}) {
|
|
223
|
+
await load();
|
|
224
|
+
const next = { ...settings };
|
|
225
|
+
for (const k of ['agent', 'privacy', 'tier', 'system']) if (patch[k] != null) next[k] = patch[k];
|
|
226
|
+
settings = next;
|
|
227
|
+
await saveSettings();
|
|
228
|
+
// Settings are read when the loop starts, so a change only lands on a restart.
|
|
229
|
+
if (running) { await this.stop(); await start(); }
|
|
230
|
+
return { settings: { ...settings } };
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
/** Stop the loop. `forget` also deletes the token and every pairing — a real disconnect. */
|
|
234
|
+
async stop({ forget = false } = {}) {
|
|
235
|
+
stopped = true;
|
|
236
|
+
controller?.abort();
|
|
237
|
+
controller = null;
|
|
238
|
+
running = false;
|
|
239
|
+
if (forget) {
|
|
240
|
+
await load();
|
|
241
|
+
settings = { ...settings, enabled: false };
|
|
242
|
+
await saveSettings();
|
|
243
|
+
await rm(tokenFile, { force: true });
|
|
244
|
+
for (const p of pairing.list()) pairing.revoke(p.actorId);
|
|
245
|
+
await savePairing();
|
|
246
|
+
bot = null;
|
|
247
|
+
}
|
|
248
|
+
return { ok: true };
|
|
249
|
+
},
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Everything a settings screen needs and nothing it must not have: no bot token, ever.
|
|
253
|
+
* `configured` says a token exists; `running` says the loop is actually polling.
|
|
254
|
+
*/
|
|
255
|
+
async status() {
|
|
256
|
+
await load();
|
|
257
|
+
const configured = !!(await readToken());
|
|
258
|
+
return {
|
|
259
|
+
channel: 'telegram',
|
|
260
|
+
configured,
|
|
261
|
+
enabled: settings.enabled !== false,
|
|
262
|
+
running,
|
|
263
|
+
bot: bot ? { ...bot } : null,
|
|
264
|
+
error: lastError,
|
|
265
|
+
paired: pairing.list(),
|
|
266
|
+
settings: { agent: settings.agent, privacy: settings.privacy, tier: settings.tier },
|
|
267
|
+
};
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// GENERATED — do not edit.
|
|
2
|
+
// Source of truth: chatpanel-channels/src/stream.js (npm @chatpanel/channels).
|
|
3
|
+
// Edit there, then run: npm run sync:channels
|
|
4
|
+
//
|
|
5
|
+
// Vendored rather than depended on: the bridge ships zero runtime dependencies so a
|
|
6
|
+
// curl one-liner install cannot fail on someone's registry, and so the compiled
|
|
7
|
+
// single-file binary has nothing to resolve. Package imports are rewritten to the
|
|
8
|
+
// vendored engines (src/pii, src/events) by the sync script.
|
|
9
|
+
|
|
10
|
+
// The model's reply, assembled from the bridge's SSE stream — PURE so accumulation and the
|
|
11
|
+
// token-restore boundary are testable without a socket. Per chatpanel-bridge /chat, the bridge
|
|
12
|
+
// emits `data: <json>\n\n` frames of:
|
|
13
|
+
// {type:'run', id} a cancel-by-name handle, emitted first
|
|
14
|
+
// {type:'workdir'|'status'|'reasoning'|'tool', ...} progress a caller MAY show
|
|
15
|
+
// {type:'delta', text} incremental assistant text
|
|
16
|
+
// {type:'done', text?} text only when it wasn't streamed
|
|
17
|
+
// {type:'error', error}
|
|
18
|
+
// We only need run/delta/done/error to build a reply.
|
|
19
|
+
|
|
20
|
+
export function initialState() {
|
|
21
|
+
return { runId: null, text: '', status: '', error: null, done: false };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Fold one bridge event into the running reply. Returns a NEW state (never mutates). */
|
|
25
|
+
export function foldEvent(state, ev) {
|
|
26
|
+
switch (ev?.type) {
|
|
27
|
+
case 'run': return { ...state, runId: ev.id || state.runId };
|
|
28
|
+
case 'delta': return { ...state, text: state.text + (ev.text || '') };
|
|
29
|
+
case 'status': return { ...state, status: ev.text || state.status };
|
|
30
|
+
// A `done` may carry the whole text (engines that don't stream). Only take it when we
|
|
31
|
+
// streamed nothing, or the reply doubles.
|
|
32
|
+
case 'done': return { ...state, done: true, text: (!state.text && ev.text) ? ev.text : state.text };
|
|
33
|
+
case 'error': return { ...state, done: true, error: ev.error || 'unknown error' };
|
|
34
|
+
default: return state;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Pull complete SSE events out of a growing buffer. Returns the parsed events and the
|
|
40
|
+
* UNCONSUMED tail (a partial frame still arriving), which the caller prepends next read.
|
|
41
|
+
*/
|
|
42
|
+
export function parseSse(buffer) {
|
|
43
|
+
const events = [];
|
|
44
|
+
let rest = String(buffer);
|
|
45
|
+
let idx;
|
|
46
|
+
while ((idx = rest.indexOf('\n\n')) >= 0) {
|
|
47
|
+
const raw = rest.slice(0, idx);
|
|
48
|
+
rest = rest.slice(idx + 2);
|
|
49
|
+
for (const line of raw.split('\n')) {
|
|
50
|
+
if (!line.startsWith('data:')) continue;
|
|
51
|
+
const json = line.slice(5).trim();
|
|
52
|
+
if (!json) continue;
|
|
53
|
+
try { events.push(JSON.parse(json)); } catch { /* skip a malformed frame */ }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { events, rest };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Telegram rejects a message body over 4096 chars. Split on paragraph, then line, then a hard
|
|
60
|
+
// cut, so a long answer arrives as several messages instead of one API error.
|
|
61
|
+
export function splitForTelegram(text, max = 4096) {
|
|
62
|
+
let s = String(text ?? '');
|
|
63
|
+
const out = [];
|
|
64
|
+
while (s.length > max) {
|
|
65
|
+
let cut = s.lastIndexOf('\n\n', max);
|
|
66
|
+
if (cut < max * 0.5) cut = s.lastIndexOf('\n', max);
|
|
67
|
+
if (cut < max * 0.5) cut = max;
|
|
68
|
+
out.push(s.slice(0, cut));
|
|
69
|
+
s = s.slice(cut).replace(/^\n+/, '');
|
|
70
|
+
}
|
|
71
|
+
if (s) out.push(s);
|
|
72
|
+
return out.length ? out : [''];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// A minimal time gate for throttling live edits — Telegram allows ~1 edit/sec to a chat.
|
|
76
|
+
// `now` injected for tests. ready() returns true and advances only when `ms` has elapsed.
|
|
77
|
+
export function createGate(ms, { now = () => Date.now() } = {}) {
|
|
78
|
+
// -Infinity, not 0, so the FIRST call always fires (replace the "…" placeholder promptly)
|
|
79
|
+
// regardless of the clock's magnitude — then throttle. With last=0 this only worked because
|
|
80
|
+
// the real Date.now() dwarfs `ms`; an injected test clock at t=0 exposed the latent bug.
|
|
81
|
+
let last = -Infinity;
|
|
82
|
+
return {
|
|
83
|
+
ready() { const t = now(); if (t - last >= ms) { last = t; return true; } return false; },
|
|
84
|
+
reset() { last = -Infinity; },
|
|
85
|
+
};
|
|
86
|
+
}
|