@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Local bridge that exposes the AI coding agents installed on your machine \u2014 Claude Code (CLI), Codex (CLI), and Antigravity CLI (formerly Gemini CLI, which remains available for business/enterprise) \u2014 to the ChatPanel Chrome extension over a localhost SSE endpoint. Bring your own agent.",
|
|
6
6
|
"keywords": [
|
|
@@ -38,7 +38,11 @@
|
|
|
38
38
|
"test": "node --test tests/*.test.mjs",
|
|
39
39
|
"build:bin": "bash scripts/build-binaries.sh",
|
|
40
40
|
"sync:events": "node scripts/sync-events.mjs",
|
|
41
|
-
"test:events-sync": "node scripts/sync-events.mjs --check"
|
|
41
|
+
"test:events-sync": "node scripts/sync-events.mjs --check",
|
|
42
|
+
"sync:pii": "node scripts/sync-pii.mjs",
|
|
43
|
+
"sync:channels": "node scripts/sync-channels.mjs",
|
|
44
|
+
"test:pii-sync": "node scripts/sync-pii.mjs --check",
|
|
45
|
+
"test:channels-sync": "node scripts/sync-channels.mjs --check"
|
|
42
46
|
},
|
|
43
47
|
"dependencies": {},
|
|
44
48
|
"optionalDependencies": {
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Vendors the channel core from `chatpanel-channels`.
|
|
3
|
+
//
|
|
4
|
+
// WHY THE BRIDGE HOSTS IT. A messaging channel has to be running when nobody is looking — the
|
|
5
|
+
// whole point is to reach your machine from a phone while the browser is closed — and the
|
|
6
|
+
// bridge is the only always-on local process a ChatPanel user already has. Putting the loop
|
|
7
|
+
// anywhere else (a second daemon, an npm install, a service worker that Chrome suspends) is a
|
|
8
|
+
// second thing a non-technical person has to install and keep alive.
|
|
9
|
+
//
|
|
10
|
+
// node scripts/sync-channels.mjs refresh src/channels/ from the package
|
|
11
|
+
// node scripts/sync-channels.mjs --check verify it matches (drift guard); exit 1 if not
|
|
12
|
+
//
|
|
13
|
+
// The package imports `@chatpanel/pii` and `@chatpanel/events/*` by name; the bridge has no
|
|
14
|
+
// node_modules, so those specifiers are REWRITTEN to the vendored copies as the files are
|
|
15
|
+
// copied. The rewrite is table-driven and fails loudly on an unknown bare import rather than
|
|
16
|
+
// emitting a file that throws at runtime inside a compiled binary.
|
|
17
|
+
|
|
18
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import { dirname, join } from 'node:path';
|
|
21
|
+
|
|
22
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
23
|
+
|
|
24
|
+
// The service and everything under it. `config.js` is deliberately absent: it resolves paths
|
|
25
|
+
// and reads the bridge token from disk, and the bridge already knows both.
|
|
26
|
+
const FILES = [
|
|
27
|
+
'normalize.js', 'pairing.js', 'invoke.js', 'stream.js', 'bridge.js',
|
|
28
|
+
'eventlog.js', 'service.js', 'adapters/telegram.js',
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
// bare specifier → path relative to src/channels/ (adjusted per file depth below).
|
|
32
|
+
const REWRITE = {
|
|
33
|
+
'@chatpanel/pii': '../pii/index.js',
|
|
34
|
+
'@chatpanel/events/event.js': '../events/event.js',
|
|
35
|
+
'@chatpanel/events/capability.js': '../events/capability.js',
|
|
36
|
+
'@chatpanel/events/reach.js': '../events/reach.js',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function pkgDir() {
|
|
40
|
+
return [
|
|
41
|
+
join(ROOT, 'node_modules', '@chatpanel', 'channels', 'src'),
|
|
42
|
+
join(ROOT, '..', 'chatpanel-channels', 'src'),
|
|
43
|
+
].find((d) => existsSync(join(d, 'service.js')));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const check = process.argv.includes('--check');
|
|
47
|
+
const src = pkgDir();
|
|
48
|
+
|
|
49
|
+
if (!src) {
|
|
50
|
+
const msg = 'chatpanel-channels not found (check out ../chatpanel-channels).';
|
|
51
|
+
if (check) { console.error(`sync-channels --check: ${msg}`); process.exit(1); }
|
|
52
|
+
console.warn(`sync-channels: ${msg} Leaving src/channels as-is.`);
|
|
53
|
+
process.exit(0);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const banner = (f) => `// GENERATED — do not edit.\n`
|
|
57
|
+
+ `// Source of truth: chatpanel-channels/src/${f} (npm @chatpanel/channels).\n`
|
|
58
|
+
+ `// Edit there, then run: npm run sync:channels\n`
|
|
59
|
+
+ `//\n`
|
|
60
|
+
+ `// Vendored rather than depended on: the bridge ships zero runtime dependencies so a\n`
|
|
61
|
+
+ `// curl one-liner install cannot fail on someone's registry, and so the compiled\n`
|
|
62
|
+
+ `// single-file binary has nothing to resolve. Package imports are rewritten to the\n`
|
|
63
|
+
+ `// vendored engines (src/pii, src/events) by the sync script.\n\n`;
|
|
64
|
+
|
|
65
|
+
// Rewrite every bare @chatpanel specifier, and refuse to emit a file with one left over.
|
|
66
|
+
function vendor(file, text) {
|
|
67
|
+
const up = '../'.repeat(file.split('/').length - 1); // adapters/telegram.js sits one deeper
|
|
68
|
+
let out = text.replace(/(from\s+['"])(@chatpanel\/[^'"]+)(['"])/g, (full, a, spec, b) => {
|
|
69
|
+
const to = REWRITE[spec];
|
|
70
|
+
if (!to) throw new Error(`sync-channels: no rewrite for '${spec}' (imported by ${file}) — add it to REWRITE`);
|
|
71
|
+
return `${a}${up}${to}${b}`;
|
|
72
|
+
});
|
|
73
|
+
const left = out.match(/from\s+['"]@chatpanel\/[^'"]+['"]/);
|
|
74
|
+
if (left) throw new Error(`sync-channels: unrewritten import in ${file}: ${left[0]}`);
|
|
75
|
+
return banner(file) + out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let drift = 0;
|
|
79
|
+
for (const f of FILES) {
|
|
80
|
+
const want = vendor(f, readFileSync(join(src, f), 'utf8'));
|
|
81
|
+
const dest = join(ROOT, 'src', 'channels', f);
|
|
82
|
+
if (!check) mkdirSync(dirname(dest), { recursive: true });
|
|
83
|
+
const have = existsSync(dest) ? readFileSync(dest, 'utf8') : null;
|
|
84
|
+
if (have === want) continue;
|
|
85
|
+
if (check) { console.error(`sync-channels --check: src/channels/${f} differs from chatpanel-channels`); drift += 1; continue; }
|
|
86
|
+
writeFileSync(dest, want);
|
|
87
|
+
console.log(`sync-channels: updated src/channels/${f}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (check) {
|
|
91
|
+
if (drift) { console.error('Run `npm run sync:channels` and commit the result.'); process.exit(1); }
|
|
92
|
+
console.log('sync-channels --check: src/channels matches chatpanel-channels ✓');
|
|
93
|
+
}
|
package/scripts/sync-events.mjs
CHANGED
|
@@ -21,7 +21,15 @@ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
21
21
|
// Deliberately short. `skill-manifest.js` imports only `scopes.js`, which is why that
|
|
22
22
|
// vocabulary was split out of `capability.js` — vendoring the capability machinery and
|
|
23
23
|
// the event schema to reach a five-element array would defeat the point.
|
|
24
|
-
|
|
24
|
+
// The channel service brings the second group: `event.js` (+ its `ref.js`) is the audit
|
|
25
|
+
// appender every channel turn writes through, `capability.js` (+ `view.js`, `scopes.js`) is
|
|
26
|
+
// the invocation contract it validates against, and `reach.js` is the ladder that caps a
|
|
27
|
+
// paired phone. `reach.js` exists as its own module for exactly this reason — the ladder is
|
|
28
|
+
// three strings, and taking it out of `router.js` would have vendored 50 KB of model routing.
|
|
29
|
+
const FILES = [
|
|
30
|
+
'scopes.js', 'skill-manifest.js', 'skill-scan.js',
|
|
31
|
+
'ref.js', 'event.js', 'view.js', 'capability.js', 'reach.js',
|
|
32
|
+
];
|
|
25
33
|
|
|
26
34
|
function pkgDir() {
|
|
27
35
|
return [
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Vendors the redaction engine the channel service needs from `chatpanel-pii`.
|
|
3
|
+
//
|
|
4
|
+
// The bridge ships ZERO runtime dependencies on purpose — it installs by curl one-liner and
|
|
5
|
+
// compiles to a single binary, and every dependency is something that can fail on a stranger's
|
|
6
|
+
// laptop. So the engine arrives the way the event contracts do: copied in, generated, never
|
|
7
|
+
// hand-edited.
|
|
8
|
+
//
|
|
9
|
+
// node scripts/sync-pii.mjs refresh src/pii/ from the package
|
|
10
|
+
// node scripts/sync-pii.mjs --check verify it matches (drift guard); exit 1 if not
|
|
11
|
+
//
|
|
12
|
+
// This is also what retires the oldest hand-copy in the repo: `src/sanitize.js` used to be a
|
|
13
|
+
// manual copy of the same engine's sanitizer, kept in step by memory. It is now a two-line
|
|
14
|
+
// re-export of the vendored copy, so there is one file to diverge from and a test that notices.
|
|
15
|
+
|
|
16
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { dirname, join } from 'node:path';
|
|
19
|
+
|
|
20
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
21
|
+
|
|
22
|
+
// The whole engine, because redaction is not a subset you can take half of: `index.js` is the
|
|
23
|
+
// entry the channel core imports, and every file below it is on that graph.
|
|
24
|
+
const FILES = [
|
|
25
|
+
'index.js', 'pii-redact.js', 'pii-detect.js', 'pipeline.js',
|
|
26
|
+
'tool-rank.js', 'tool-harness.js', 'sanitize.js', 'net.js',
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
function pkgDir() {
|
|
30
|
+
return [
|
|
31
|
+
join(ROOT, 'node_modules', '@chatpanel', 'pii'),
|
|
32
|
+
join(ROOT, '..', 'chatpanel-pii'),
|
|
33
|
+
].find((d) => existsSync(join(d, 'pii-redact.js')));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const check = process.argv.includes('--check');
|
|
37
|
+
const src = pkgDir();
|
|
38
|
+
|
|
39
|
+
if (!src) {
|
|
40
|
+
const msg = 'chatpanel-pii not found (check out ../chatpanel-pii).';
|
|
41
|
+
if (check) { console.error(`sync-pii --check: ${msg}`); process.exit(1); }
|
|
42
|
+
console.warn(`sync-pii: ${msg} Leaving src/pii as-is.`);
|
|
43
|
+
process.exit(0);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const outDir = join(ROOT, 'src', 'pii');
|
|
47
|
+
if (!check) mkdirSync(outDir, { recursive: true });
|
|
48
|
+
|
|
49
|
+
const banner = (f) => `// GENERATED — do not edit.\n`
|
|
50
|
+
+ `// Source of truth: chatpanel-pii/${f} (npm @chatpanel/pii).\n`
|
|
51
|
+
+ `// Edit there, then run: npm run sync:pii\n`
|
|
52
|
+
+ `//\n`
|
|
53
|
+
+ `// Vendored rather than depended on: the bridge ships zero runtime dependencies so a\n`
|
|
54
|
+
+ `// curl one-liner install cannot fail on someone's registry, and so the compiled\n`
|
|
55
|
+
+ `// single-file binary has nothing to resolve.\n\n`;
|
|
56
|
+
|
|
57
|
+
let drift = 0;
|
|
58
|
+
for (const f of FILES) {
|
|
59
|
+
const want = banner(f) + readFileSync(join(src, f), 'utf8');
|
|
60
|
+
const dest = join(outDir, f);
|
|
61
|
+
const have = existsSync(dest) ? readFileSync(dest, 'utf8') : null;
|
|
62
|
+
if (have === want) continue;
|
|
63
|
+
if (check) { console.error(`sync-pii --check: src/pii/${f} differs from chatpanel-pii`); drift += 1; continue; }
|
|
64
|
+
writeFileSync(dest, want);
|
|
65
|
+
console.log(`sync-pii: updated src/pii/${f}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (check) {
|
|
69
|
+
if (drift) { console.error('Run `npm run sync:pii` and commit the result.'); process.exit(1); }
|
|
70
|
+
console.log('sync-pii --check: src/pii matches chatpanel-pii ✓');
|
|
71
|
+
}
|
package/src/api-compat.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// are rejected when accepting them would produce misleading behavior.
|
|
7
7
|
|
|
8
8
|
import { randomUUID } from 'node:crypto';
|
|
9
|
+
import { normalizeNames } from './mcp-quarantine.js';
|
|
9
10
|
|
|
10
11
|
const AGENT_IDS = new Set(['claude', 'codex', 'antigravity', 'pi', 'opencode', 'kiro', 'copilot', 'deepseek']);
|
|
11
12
|
|
|
@@ -76,6 +77,9 @@ function resolveTarget(model, chatpanel = {}) {
|
|
|
76
77
|
workingDir: chatpanel.working_dir || chatpanel.workingDir || process.env.CHATPANEL_API_WORKING_DIR || '',
|
|
77
78
|
permissionMode,
|
|
78
79
|
useLocalConfig: chatpanel.use_local_config ?? chatpanel.useLocalConfig ?? true,
|
|
80
|
+
// Servers in the agent's OWN MCP config to leave out of this run — one that can't
|
|
81
|
+
// authenticate or can't be reached otherwise kills the whole turn (see mcp-quarantine.js).
|
|
82
|
+
mcpDisabled: normalizeNames(chatpanel.mcp_disabled ?? chatpanel.mcpDisabled),
|
|
79
83
|
...(engineModel ? { model: engineModel } : {}),
|
|
80
84
|
},
|
|
81
85
|
};
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// GENERATED — do not edit.
|
|
2
|
+
// Source of truth: chatpanel-channels/src/adapters/telegram.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
|
+
// Telegram adapter — the LOCAL shape (§3 of feature-f7). getUpdates long-poll is
|
|
11
|
+
// OUTBOUND-ONLY, so this mirrors Claude Code Remote Control's key property: the machine never
|
|
12
|
+
// opens an inbound port, works behind NAT, needs no tunnel. The adapter is dumb transport;
|
|
13
|
+
// normalize → gate → redact → invoke → stream → restore is the shared core it drives.
|
|
14
|
+
|
|
15
|
+
import { normalizeTelegram, actorId } from '../normalize.js';
|
|
16
|
+
import {
|
|
17
|
+
buildInvocation, redactInbound, outboundText, appendTurn,
|
|
18
|
+
appendInvoked, appendRedacted, appendEgress,
|
|
19
|
+
} from '../invoke.js';
|
|
20
|
+
import * as bridge from '../bridge.js';
|
|
21
|
+
import { splitForTelegram, createGate } from '../stream.js';
|
|
22
|
+
import { createVault } from '../../pii/index.js';
|
|
23
|
+
|
|
24
|
+
const TG_HOST = 'api.telegram.org';
|
|
25
|
+
|
|
26
|
+
const HELP = [
|
|
27
|
+
'ChatPanel — drive your local agent from here.',
|
|
28
|
+
'',
|
|
29
|
+
'Send a message and I run it on your machine.',
|
|
30
|
+
'/pair <code> — enroll this chat (ChatPanel → Settings → Channels)',
|
|
31
|
+
'/new — start fresh (forget this conversation + new privacy vault)',
|
|
32
|
+
'/stop — stop the current run',
|
|
33
|
+
'/help — this message',
|
|
34
|
+
].join('\n');
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Start the long-poll loop. Returns the loop promise; abort `signal` to stop it. Everything
|
|
38
|
+
* it needs is injected — a bot token, the bridge address+token, a pairing store, an event sink
|
|
39
|
+
* — so nothing here reads a secret or reaches for global state.
|
|
40
|
+
*/
|
|
41
|
+
export function startTelegram({
|
|
42
|
+
botToken,
|
|
43
|
+
baseUrl,
|
|
44
|
+
token, // bridge token
|
|
45
|
+
pairing, // createPairingStore(...)
|
|
46
|
+
savePairing = async () => {},
|
|
47
|
+
appender, // createEventLog(...) or nullEventLog()
|
|
48
|
+
agent = 'claude',
|
|
49
|
+
system = '',
|
|
50
|
+
redact = { tier: 'basic' },
|
|
51
|
+
privacy = 'standard',
|
|
52
|
+
logger = console,
|
|
53
|
+
signal, // AbortSignal to stop the whole loop
|
|
54
|
+
}) {
|
|
55
|
+
const api = `https://${TG_HOST}/bot${botToken}`;
|
|
56
|
+
const fileApi = `https://${TG_HOST}/file/bot${botToken}`;
|
|
57
|
+
// Per-chat session: a persistent vault (stable placeholders across turns), the live run id
|
|
58
|
+
// (/stop), and the redacted conversation history (multi-turn context).
|
|
59
|
+
const chats = new Map(); // chatId -> { vault, runId, history }
|
|
60
|
+
const freshChat = () => ({ vault: createVault(), runId: null, history: [] });
|
|
61
|
+
const chatState = (id) => {
|
|
62
|
+
if (!chats.has(id)) chats.set(id, freshChat());
|
|
63
|
+
return chats.get(id);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
async function tg(method, body) {
|
|
67
|
+
const res = await fetch(`${api}/${method}`, {
|
|
68
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal,
|
|
69
|
+
});
|
|
70
|
+
return res.json();
|
|
71
|
+
}
|
|
72
|
+
const send = (chatId, text) => tg('sendMessage', { chat_id: chatId, text });
|
|
73
|
+
const edit = (chatId, messageId, text) => tg('editMessageText', { chat_id: chatId, message_id: messageId, text });
|
|
74
|
+
|
|
75
|
+
// Resolve Telegram photos to the { dataUrl } shape the bridge's engines expect (Claude Code
|
|
76
|
+
// writes them to temp files and reads them as vision). A failed fetch drops that one image
|
|
77
|
+
// rather than failing the whole turn.
|
|
78
|
+
async function toBridgeImages(photos) {
|
|
79
|
+
const out = [];
|
|
80
|
+
for (const p of photos || []) {
|
|
81
|
+
try {
|
|
82
|
+
const info = await tg('getFile', { file_id: p.fileId });
|
|
83
|
+
const fp = info?.result?.file_path;
|
|
84
|
+
if (!fp) continue;
|
|
85
|
+
const bin = await fetch(`${fileApi}/${fp}`, { signal });
|
|
86
|
+
const buf = Buffer.from(await bin.arrayBuffer());
|
|
87
|
+
const mime = /\.png$/i.test(fp) ? 'image/png' : /\.webp$/i.test(fp) ? 'image/webp' : 'image/jpeg';
|
|
88
|
+
out.push({ dataUrl: `data:${mime};base64,${buf.toString('base64')}` });
|
|
89
|
+
} catch (e) { logger.warn?.(`[telegram] image fetch failed: ${e?.message || e}`); }
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function handleCommand(norm) {
|
|
95
|
+
const id = actorId('telegram', norm.chatId);
|
|
96
|
+
const { name, args } = norm.command;
|
|
97
|
+
// `/start <code>` is what a t.me/<bot>?start=<code> link SENDS. Telegram turns the link
|
|
98
|
+
// into that first message, so tapping "Pair this phone" in ChatPanel enrolls in one tap —
|
|
99
|
+
// no six digits thumbed in from another screen. Bare /start is still the greeting.
|
|
100
|
+
const pairCode = name === 'pair' || (name === 'start' && args) ? args : '';
|
|
101
|
+
if (pairCode) {
|
|
102
|
+
const r = pairing.redeem(id, pairCode);
|
|
103
|
+
await savePairing();
|
|
104
|
+
return void send(norm.chatId, r.ok
|
|
105
|
+
? `✅ paired (reach: ${r.reach}). Send me anything — I'll run it on your machine.`
|
|
106
|
+
: `⛔ ${r.reason}`);
|
|
107
|
+
}
|
|
108
|
+
if (name === 'help' || name === 'start') return void send(norm.chatId, HELP);
|
|
109
|
+
if (name === 'pair') return void send(norm.chatId, 'send /pair <code> — get the code in ChatPanel → Settings → Channels');
|
|
110
|
+
if (name === 'new') {
|
|
111
|
+
chats.set(norm.chatId, freshChat());
|
|
112
|
+
return void send(norm.chatId, '🧹 fresh conversation.');
|
|
113
|
+
}
|
|
114
|
+
if (name === 'stop') {
|
|
115
|
+
const st = chatState(norm.chatId);
|
|
116
|
+
const ok = await bridge.cancel(st.runId, { baseUrl, token });
|
|
117
|
+
st.runId = null;
|
|
118
|
+
return void send(norm.chatId, ok ? '⏹ stopped.' : 'nothing running.');
|
|
119
|
+
}
|
|
120
|
+
return void send(norm.chatId, `unknown command /${name} — try /help`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function handleMessage(norm) {
|
|
124
|
+
const id = actorId('telegram', norm.chatId);
|
|
125
|
+
// AUTHENTICATION gate: an unpaired sender cannot drive anything. This proves WHO, not WHAT
|
|
126
|
+
// — tool scoping (reach → per-actor allowlist) is the next layer and lives above the bridge.
|
|
127
|
+
const reach = pairing.reachOf(id);
|
|
128
|
+
if (!reach) {
|
|
129
|
+
return void send(norm.chatId, '🔒 not paired. Get a code in the ChatPanel extension, then send: /pair <code>');
|
|
130
|
+
}
|
|
131
|
+
if (!norm.text && !norm.photos.length) return;
|
|
132
|
+
const st = chatState(norm.chatId);
|
|
133
|
+
|
|
134
|
+
// Contract + audit BEFORE anything leaves for the agent.
|
|
135
|
+
let invocation;
|
|
136
|
+
try { invocation = buildInvocation(norm); }
|
|
137
|
+
catch (e) { return void send(norm.chatId, `⚠️ ${e.message}`); }
|
|
138
|
+
|
|
139
|
+
const { redacted, counts } = redactInbound(norm.text, st.vault, redact);
|
|
140
|
+
await appendInvoked(appender, invocation);
|
|
141
|
+
await appendRedacted(appender, counts);
|
|
142
|
+
|
|
143
|
+
const images = await toBridgeImages(norm.photos);
|
|
144
|
+
const placeholder = await tg('sendMessage', { chat_id: norm.chatId, text: '…' });
|
|
145
|
+
const replyId = placeholder?.result?.message_id;
|
|
146
|
+
const gate = createGate(1200); // ~1 edit/sec, Telegram's ceiling for a chat
|
|
147
|
+
let shown = '';
|
|
148
|
+
|
|
149
|
+
// Replay prior turns as context; the new (redacted) message is the live one. buildCliPrompt
|
|
150
|
+
// on the bridge renders all-but-last as history and the last as "answer this now".
|
|
151
|
+
const messages = [...st.history, { role: 'user', content: redacted }];
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
const finalState = await bridge.chat(
|
|
155
|
+
{ agent, system, messages, images, options: { reach } },
|
|
156
|
+
{
|
|
157
|
+
baseUrl, token, signal,
|
|
158
|
+
onEvent: (ev, state) => {
|
|
159
|
+
if (ev.type === 'run') st.runId = state.runId;
|
|
160
|
+
// Throttled live edit: restore the user's own values + egress-scrub fresh secrets, so
|
|
161
|
+
// the user watches real text stream in but the provider never carries a leaked secret.
|
|
162
|
+
if ((ev.type === 'delta' || ev.type === 'done') && replyId && (ev.type === 'done' || gate.ready())) {
|
|
163
|
+
const text = outboundText(state.text, st.vault, { privacy });
|
|
164
|
+
const first = splitForTelegram(text || '…')[0];
|
|
165
|
+
if (first && first !== shown) { shown = first; edit(norm.chatId, replyId, first).catch(() => {}); }
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
);
|
|
170
|
+
st.runId = null;
|
|
171
|
+
|
|
172
|
+
const restored = outboundText(finalState.text, st.vault, { privacy });
|
|
173
|
+
const chunks = splitForTelegram(finalState.error ? `⚠️ ${finalState.error}` : (restored || '(no output)'));
|
|
174
|
+
if (replyId) await edit(norm.chatId, replyId, chunks[0]);
|
|
175
|
+
else await send(norm.chatId, chunks[0]);
|
|
176
|
+
for (const extra of chunks.slice(1)) await send(norm.chatId, extra);
|
|
177
|
+
|
|
178
|
+
// Remember the exchange for follow-ups — the REDACTED forms, so what we replay next turn
|
|
179
|
+
// never carries a real value and stays consistent with the vault. A failed turn (error /
|
|
180
|
+
// no text) records nothing, so history never implies an answer that didn't happen.
|
|
181
|
+
if (!finalState.error && finalState.text) st.history = appendTurn(st.history, redacted, finalState.text);
|
|
182
|
+
|
|
183
|
+
// Egress to Telegram, recorded: 'standard' restores real values (a third party sees
|
|
184
|
+
// them → redacted:false); 'strict' keeps placeholders (redacted:true). controlled:false
|
|
185
|
+
// either way — Telegram is not ours.
|
|
186
|
+
await appendEgress(appender, { host: TG_HOST, redacted: privacy === 'strict', controlled: false });
|
|
187
|
+
} catch (e) {
|
|
188
|
+
st.runId = null;
|
|
189
|
+
const msg = `⚠️ ${e?.message || e}`;
|
|
190
|
+
if (replyId) await edit(norm.chatId, replyId, msg).catch(() => {});
|
|
191
|
+
else await send(norm.chatId, msg).catch(() => {});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// A sleep that gives up when the loop is asked to stop, so Ctrl-C is immediate rather than
|
|
196
|
+
// "immediate in up to five seconds".
|
|
197
|
+
const nap = (ms) => new Promise((resolve) => {
|
|
198
|
+
if (signal?.aborted) return resolve();
|
|
199
|
+
let t;
|
|
200
|
+
const done = () => { clearTimeout(t); signal?.removeEventListener?.('abort', done); resolve(); };
|
|
201
|
+
t = setTimeout(done, ms);
|
|
202
|
+
signal?.addEventListener?.('abort', done, { once: true });
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
async function loop() {
|
|
206
|
+
let offset = 0;
|
|
207
|
+
logger.log?.('[telegram] long-poll started (outbound-only; no inbound port).');
|
|
208
|
+
while (!signal?.aborted) {
|
|
209
|
+
let updates;
|
|
210
|
+
try {
|
|
211
|
+
const res = await tg('getUpdates', { offset, timeout: 30, allowed_updates: ['message'] });
|
|
212
|
+
// Telegram REFUSES with a normal JSON body, not an HTTP error this code would throw
|
|
213
|
+
// on: a bad token answers {ok:false, 401} INSTANTLY, so `res.result || []` turned a
|
|
214
|
+
// wrong token into a silent hot loop — no message, no long-poll delay, and an API
|
|
215
|
+
// hammered hard enough to get rate-limited. The two refusals that actually happen
|
|
216
|
+
// during setup are named, because "nothing arrives" is the same symptom as "it works
|
|
217
|
+
// and nobody has texted you".
|
|
218
|
+
if (res && res.ok === false) {
|
|
219
|
+
const code = res.error_code;
|
|
220
|
+
const hint = code === 401 ? ' — check the bot token (@BotFather → /mybots → API token)'
|
|
221
|
+
: code === 409 ? ' — another chatpanel-channels (or another poller) is already reading this bot'
|
|
222
|
+
: '';
|
|
223
|
+
logger.error?.(`[telegram] getUpdates refused: ${res.description || `error_code ${code}`}${hint}`);
|
|
224
|
+
await nap(5000);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
updates = res?.result || [];
|
|
228
|
+
} catch (e) {
|
|
229
|
+
if (signal?.aborted) break;
|
|
230
|
+
logger.warn?.(`[telegram] getUpdates failed: ${e?.message || e}; retrying in 2s`);
|
|
231
|
+
await nap(2000);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
for (const u of updates) {
|
|
235
|
+
offset = u.update_id + 1;
|
|
236
|
+
const norm = normalizeTelegram(u);
|
|
237
|
+
if (!norm) continue;
|
|
238
|
+
// Fire-and-forget per message so one slow turn doesn't stall the poll; errors are
|
|
239
|
+
// caught so a single bad message never kills the loop.
|
|
240
|
+
const run = norm.command ? handleCommand(norm) : handleMessage(norm);
|
|
241
|
+
Promise.resolve(run).catch((e) => logger.error?.(`[telegram] handler error: ${e?.message || e}`));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
logger.log?.('[telegram] stopped.');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return loop();
|
|
248
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// GENERATED — do not edit.
|
|
2
|
+
// Source of truth: chatpanel-channels/src/bridge.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 transport to the local agent: POST /chat (SSE) and POST /cancel on the bridge. The
|
|
11
|
+
// bridge binds 127.0.0.1:4319 and runs Claude Code / Codex / etc. as CLIs — so this is the ONE
|
|
12
|
+
// hop where the (already-redacted) conversation reaches the agent, and it never leaves the
|
|
13
|
+
// machine. A channel adapter is a non-browser local client, so it presents the per-install
|
|
14
|
+
// bridge token, exactly like any privileged caller.
|
|
15
|
+
|
|
16
|
+
import { parseSse, foldEvent, initialState } from './stream.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Drive one turn. Streams bridge events to onEvent(ev, state) as they arrive AND folds them
|
|
20
|
+
* into a final reply state it returns. `agent` is a bridge engine id ('claude','codex',…).
|
|
21
|
+
*/
|
|
22
|
+
export async function chat({ agent = 'claude', system = '', messages, images = [], options = {} }, {
|
|
23
|
+
baseUrl, token, signal, onEvent = () => {},
|
|
24
|
+
} = {}) {
|
|
25
|
+
const res = await fetch(`${baseUrl}/chat`, {
|
|
26
|
+
method: 'POST',
|
|
27
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
|
28
|
+
body: JSON.stringify({ agent, system, messages, images, options }),
|
|
29
|
+
signal,
|
|
30
|
+
});
|
|
31
|
+
if (!res.ok || !res.body) {
|
|
32
|
+
const detail = await res.text().catch(() => '');
|
|
33
|
+
throw new Error(`bridge /chat ${res.status}${detail ? `: ${detail.slice(0, 300)}` : ''}`);
|
|
34
|
+
}
|
|
35
|
+
let state = initialState();
|
|
36
|
+
let buffer = '';
|
|
37
|
+
const decoder = new TextDecoder();
|
|
38
|
+
for await (const chunk of res.body) {
|
|
39
|
+
buffer += typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true });
|
|
40
|
+
const { events, rest } = parseSse(buffer);
|
|
41
|
+
buffer = rest;
|
|
42
|
+
for (const ev of events) { state = foldEvent(state, ev); onEvent(ev, state); }
|
|
43
|
+
}
|
|
44
|
+
return state;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Stop a run by the id the bridge emitted as its first {type:'run'} event. Best-effort. */
|
|
48
|
+
export async function cancel(runId, { baseUrl, token } = {}) {
|
|
49
|
+
if (!runId) return false;
|
|
50
|
+
try {
|
|
51
|
+
const res = await fetch(`${baseUrl}/cancel`, {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
|
54
|
+
body: JSON.stringify({ id: runId }),
|
|
55
|
+
});
|
|
56
|
+
const out = await res.json().catch(() => ({}));
|
|
57
|
+
return !!out.cancelled;
|
|
58
|
+
} catch { return false; }
|
|
59
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// GENERATED — do not edit.
|
|
2
|
+
// Source of truth: chatpanel-channels/src/eventlog.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
|
+
// A file-backed sink for the capability/privacy events a channel run produces — the audit
|
|
11
|
+
// trail neither Claude Code Remote nor Hermes has. Events are metadata only (counts, ids;
|
|
12
|
+
// never message content — see chatpanel-events/event.js), so this JSONL is safe to keep and
|
|
13
|
+
// replicate. One line per event, append-only. seq is owned by the appender and recovered from
|
|
14
|
+
// the file on restart so ordering survives a bounce.
|
|
15
|
+
|
|
16
|
+
import { appendFile, readFile, mkdir } from 'node:fs/promises';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { createAppender } from '../events/event.js';
|
|
19
|
+
|
|
20
|
+
async function nextSeq(file) {
|
|
21
|
+
try {
|
|
22
|
+
const txt = await readFile(file, 'utf8');
|
|
23
|
+
let max = -1;
|
|
24
|
+
for (const l of txt.split('\n')) {
|
|
25
|
+
if (!l) continue;
|
|
26
|
+
try { const e = JSON.parse(l); if (Number.isInteger(e.seq)) max = Math.max(max, e.seq); } catch { /* skip */ }
|
|
27
|
+
}
|
|
28
|
+
return max + 1;
|
|
29
|
+
} catch { return 0; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function createEventLog({ file, host = 'channel' }) {
|
|
33
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
34
|
+
const seq = await nextSeq(file);
|
|
35
|
+
const appender = createAppender({ host, seq, newId: () => globalThis.crypto.randomUUID() });
|
|
36
|
+
return {
|
|
37
|
+
host,
|
|
38
|
+
get seq() { return appender.seq; },
|
|
39
|
+
// Same signature as the raw appender, but persists. Returns the validated event.
|
|
40
|
+
async append(type, payload, causes = []) {
|
|
41
|
+
const e = appender.append(type, payload, causes);
|
|
42
|
+
await appendFile(file, JSON.stringify(e) + '\n');
|
|
43
|
+
return e;
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// A no-op sink for the allow-list prototype or tests — same interface, writes nothing but
|
|
49
|
+
// still builds + validates each event, so a bad payload fails loudly here too.
|
|
50
|
+
export function nullEventLog({ host = 'channel' } = {}) {
|
|
51
|
+
const appender = createAppender({ host, seq: 0, newId: () => globalThis.crypto.randomUUID() });
|
|
52
|
+
return {
|
|
53
|
+
host,
|
|
54
|
+
get seq() { return appender.seq; },
|
|
55
|
+
async append(type, payload, causes = []) { return appender.append(type, payload, causes); },
|
|
56
|
+
};
|
|
57
|
+
}
|