@chatpanel/channels 0.1.0 → 0.3.3
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 +26 -8
- package/bin/chatpanel-channels.js +48 -38
- package/index.js +1 -0
- package/package.json +4 -3
- package/src/adapters/telegram.js +101 -16
- package/src/config.js +29 -1
- package/src/gateway.js +110 -0
- package/src/invoke.js +45 -1
- package/src/pairing.js +21 -6
- package/src/service.js +328 -0
- package/src/stream.js +20 -0
package/README.md
CHANGED
|
@@ -27,6 +27,7 @@ Telegram getUpdates ─long-poll→ @chatpanel/channels ─POST /chat (bridge to
|
|
|
27
27
|
|---|---|---|
|
|
28
28
|
| `src/normalize.js` | platform message → one normalized shape | ✅ |
|
|
29
29
|
| `src/pairing.js` | who may drive it, and their `reach` ceiling | ✅ |
|
|
30
|
+
| `src/service.js` | connect · pair · status · disconnect — the contract a UI drives | ✅ |
|
|
30
31
|
| `src/invoke.js` | normalized → capability invocation + pii redact + audit events | ✅ |
|
|
31
32
|
| `src/stream.js` | bridge SSE → folded reply (+ Telegram split/throttle) | ✅ |
|
|
32
33
|
| `src/bridge.js` | POST `/chat` (SSE) + `/cancel` on the local bridge | net |
|
|
@@ -34,7 +35,7 @@ Telegram getUpdates ─long-poll→ @chatpanel/channels ─POST /chat (bridge to
|
|
|
34
35
|
| `src/adapters/telegram.js` | `getUpdates` long-poll transport (the LOCAL shape) | net |
|
|
35
36
|
| `bin/chatpanel-channels.js` | CLI that wires it together | — |
|
|
36
37
|
|
|
37
|
-
The pure core is unit-testable without a bot or a bridge (`npm test` =
|
|
38
|
+
The pure core is unit-testable without a bot or a bridge (`npm test` = 45 tests, incl. a
|
|
38
39
|
real-socket SSE integration test).
|
|
39
40
|
|
|
40
41
|
Conversations are **multi-turn**: each chat keeps a bounded, redacted history so a follow-up
|
|
@@ -42,22 +43,38 @@ Conversations are **multi-turn**: each chat keeps a bounded, redacted history so
|
|
|
42
43
|
|
|
43
44
|
## Run
|
|
44
45
|
|
|
46
|
+
**Most people never touch this package directly.** The ChatPanel bridge hosts the same service
|
|
47
|
+
in-process, and the extension drives it: **Settings → Channels**, paste the token @BotFather
|
|
48
|
+
gives you, tap *Pair a phone*, open the link on the phone. Two steps, one paste, no terminal.
|
|
49
|
+
That is the supported path, and it is what the security posture below was written for.
|
|
50
|
+
|
|
51
|
+
What follows is the **headless** host — for a server, or for anyone who would rather run it
|
|
52
|
+
themselves.
|
|
53
|
+
|
|
45
54
|
```sh
|
|
46
55
|
npm install -g @chatpanel/channels # or npx @chatpanel/channels telegram
|
|
47
56
|
```
|
|
48
57
|
|
|
49
58
|
1. Start the bridge once (it writes `~/.chatpanel/bridge-token`): `chatpanel-bridge`.
|
|
50
59
|
2. Create a bot with [@BotFather](https://t.me/BotFather), copy its token.
|
|
51
|
-
3.
|
|
60
|
+
3. Put the token in a file only you can read — it is a bearer credential, and an env var is
|
|
61
|
+
visible in `ps` and shell history:
|
|
52
62
|
|
|
53
63
|
```sh
|
|
54
|
-
|
|
64
|
+
umask 077 && printf %s '123:abc' > ~/.chatpanel/telegram-token
|
|
65
|
+
chatpanel-channels telegram
|
|
55
66
|
```
|
|
56
67
|
|
|
57
|
-
4.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
68
|
+
4. With no phone paired yet, startup prints the link that pairs in one tap, and the code to
|
|
69
|
+
type if you are not on that phone:
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
[chatpanel-channels] no phone is paired yet. Open this on your phone:
|
|
73
|
+
https://t.me/your_bot?start=481920
|
|
74
|
+
…or send /pair 481920 to the bot (single use, expires in 10 minutes).
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
(`CHANNELS_ALLOW=<chatId>` still pre-pairs without a code for scripted setups.)
|
|
61
78
|
|
|
62
79
|
Chat commands: `/pair <code>`, `/new` (forget the conversation + fresh privacy vault), `/stop`, `/help`.
|
|
63
80
|
|
|
@@ -65,7 +82,8 @@ Chat commands: `/pair <code>`, `/new` (forget the conversation + fresh privacy v
|
|
|
65
82
|
|
|
66
83
|
| var | default | meaning |
|
|
67
84
|
|---|---|---|
|
|
68
|
-
| `
|
|
85
|
+
| `TELEGRAM_BOT_TOKEN_FILE` | `~/.chatpanel/telegram-token` | `0600` file holding the BotFather token (preferred) |
|
|
86
|
+
| `TELEGRAM_BOT_TOKEN` | — | the token via env instead (compat; visible in `ps`/history) |
|
|
69
87
|
| `CHANNELS_ALLOW` | — | comma list of chat ids to pre-pair (bootstrap) |
|
|
70
88
|
| `CHANNELS_AGENT` | `claude` | bridge engine id (`claude`, `codex`, …) |
|
|
71
89
|
| `CHANNELS_PRIVACY` | `standard` | `standard` restores real values for you · `strict` keeps `[[PERSON_1]]` in the reply |
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// chatpanel-channels — run a messaging adapter that drives your local ChatPanel agent.
|
|
3
3
|
//
|
|
4
|
+
// This is the HEADLESS host. Most people never see it: the bridge hosts the same
|
|
5
|
+
// `createChannelService` in-process and the ChatPanel extension drives it from Settings →
|
|
6
|
+
// Channels, so connecting a bot is paste-a-token-and-tap-a-link rather than a terminal. This
|
|
7
|
+
// exists for servers, and for anyone who would rather run it themselves.
|
|
8
|
+
//
|
|
4
9
|
// chatpanel-channels telegram
|
|
5
10
|
//
|
|
6
11
|
// Env:
|
|
7
|
-
//
|
|
12
|
+
// TELEGRAM_BOT_TOKEN_FILE path to a 0600 file holding the BotFather token (preferred)
|
|
13
|
+
// TELEGRAM_BOT_TOKEN token via env (compat; visible in ps/history) [or BOT_TOKEN]
|
|
14
|
+
// (default file if neither set: ~/.chatpanel/telegram-token)
|
|
8
15
|
// CHANNELS_ALLOW comma list of chat ids to pre-pair (bootstrap; optional)
|
|
9
16
|
// CHANNELS_AGENT bridge engine id (default: claude)
|
|
10
17
|
// CHANNELS_SYSTEM system prompt for the agent (optional)
|
|
@@ -13,57 +20,60 @@
|
|
|
13
20
|
// CHATPANEL_BRIDGE_URL default http://127.0.0.1:4319
|
|
14
21
|
// CHATPANEL_BRIDGE_TOKEN overrides ~/.chatpanel/bridge-token
|
|
15
22
|
|
|
16
|
-
import
|
|
17
|
-
import {
|
|
18
|
-
import { bridgeBaseUrl, readBridgeToken, channelsDataDir } from '../src/config.js';
|
|
19
|
-
import { createPairingStore } from '../src/pairing.js';
|
|
20
|
-
import { createEventLog } from '../src/eventlog.js';
|
|
21
|
-
import { startTelegram } from '../src/adapters/telegram.js';
|
|
22
|
-
|
|
23
|
-
async function loadPairing(file) {
|
|
24
|
-
try { return JSON.parse(await readFile(file, 'utf8')); } catch { return {}; }
|
|
25
|
-
}
|
|
23
|
+
import { bridgeBaseUrl, readBridgeToken, readBotToken, channelsHome, channelsDataDir } from '../src/config.js';
|
|
24
|
+
import { createChannelService } from '../src/service.js';
|
|
26
25
|
|
|
27
26
|
async function main() {
|
|
28
27
|
const cmd = process.argv[2] || 'telegram';
|
|
29
28
|
if (cmd === '--help' || cmd === '-h') { console.log('usage: chatpanel-channels telegram'); return; }
|
|
30
29
|
if (cmd !== 'telegram') { console.error(`unknown command '${cmd}'. try: chatpanel-channels telegram`); process.exit(1); }
|
|
31
30
|
|
|
32
|
-
const botToken = process.env.TELEGRAM_BOT_TOKEN || process.env.BOT_TOKEN;
|
|
33
|
-
if (!botToken) { console.error('set TELEGRAM_BOT_TOKEN (from @BotFather)'); process.exit(1); }
|
|
34
|
-
|
|
35
31
|
const baseUrl = bridgeBaseUrl();
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
32
|
+
const service = createChannelService({
|
|
33
|
+
home: channelsHome(),
|
|
34
|
+
dataDir: channelsDataDir(),
|
|
35
|
+
bridge: { baseUrl, token: await readBridgeToken() },
|
|
36
|
+
});
|
|
39
37
|
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
38
|
+
const settings = {
|
|
39
|
+
agent: process.env.CHANNELS_AGENT || undefined,
|
|
40
|
+
privacy: process.env.CHANNELS_PRIVACY || undefined,
|
|
41
|
+
tier: process.env.CHANNELS_PII_TIER || undefined,
|
|
42
|
+
system: process.env.CHANNELS_SYSTEM || undefined,
|
|
43
|
+
};
|
|
44
|
+
if (Object.values(settings).some(Boolean)) await service.update(settings);
|
|
43
45
|
|
|
44
|
-
// Bootstrap allow-list (
|
|
45
|
-
//
|
|
46
|
+
// Bootstrap allow-list (scripted setups). Each becomes a paired 'trusted' actor — explicitly,
|
|
47
|
+
// never silent enrollment. Everyone else pairs with a code or the one-tap link.
|
|
46
48
|
for (const chatId of (process.env.CHANNELS_ALLOW || '').split(',').map((s) => s.trim()).filter(Boolean)) {
|
|
47
|
-
|
|
49
|
+
await service.allow(`telegram:${chatId}`);
|
|
48
50
|
}
|
|
49
|
-
await savePairing();
|
|
50
51
|
|
|
51
|
-
|
|
52
|
+
// A token in the environment is a token in `ps` and in your shell history. Connecting it once
|
|
53
|
+
// verifies it with Telegram and moves it into a 0600 file, so the next start needs no env.
|
|
54
|
+
const { token: botToken, source } = await readBotToken();
|
|
55
|
+
if (!botToken) {
|
|
56
|
+
console.error('no bot token — write it to ~/.chatpanel/telegram-token (chmod 600), set TELEGRAM_BOT_TOKEN_FILE, or export TELEGRAM_BOT_TOKEN (from @BotFather)');
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
const started = source === 'env'
|
|
60
|
+
? await service.connect({ token: botToken }).then((r) => ({ ok: true, ...r }), (e) => ({ ok: false, error: e.message }))
|
|
61
|
+
: await service.start();
|
|
62
|
+
if (!started.ok) { console.error(`[chatpanel-channels] ${started.error}`); process.exit(1); }
|
|
52
63
|
|
|
53
|
-
const
|
|
54
|
-
|
|
64
|
+
const status = await service.status();
|
|
65
|
+
console.log(`[chatpanel-channels] telegram @${status.bot?.username} → bridge ${baseUrl} (agent=${status.settings.agent}, privacy=${status.settings.privacy}, tier=${status.settings.tier})`);
|
|
55
66
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
});
|
|
67
|
+
// TIME TO FIRST MESSAGE. With nothing paired the bot answers every message "get a code" and
|
|
68
|
+
// there is nowhere to get one, which reads as broken rather than as locked. The link pairs in
|
|
69
|
+
// one tap; the six digits are for reading off this screen onto a phone.
|
|
70
|
+
if (!status.paired.length) {
|
|
71
|
+
const { code, link } = await service.pair();
|
|
72
|
+
console.log(`[chatpanel-channels] no phone is paired yet. Open this on your phone:\n ${link}\n …or send /pair ${code} to the bot (single use, expires in 10 minutes).`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { service.stop().finally(() => process.exit(0)); });
|
|
76
|
+
await new Promise(() => {}); // the service owns the loop; stay alive until signalled
|
|
67
77
|
}
|
|
68
78
|
|
|
69
79
|
main().catch((e) => { console.error(e?.message || e); process.exit(1); });
|
package/index.js
CHANGED
|
@@ -16,3 +16,4 @@ export * from './src/stream.js';
|
|
|
16
16
|
export * as bridge from './src/bridge.js';
|
|
17
17
|
export { createEventLog, nullEventLog } from './src/eventlog.js';
|
|
18
18
|
export { startTelegram } from './src/adapters/telegram.js';
|
|
19
|
+
export { createChannelService, verifyBot, pairLink, DEFAULT_SETTINGS } from './src/service.js';
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/channels",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Drive ChatPanel agents from external messaging surfaces (Telegram, WhatsApp). One normalize
|
|
3
|
+
"version": "0.3.3",
|
|
4
|
+
"description": "Drive ChatPanel agents from external messaging surfaces (Telegram, WhatsApp). One normalize\u2192invoke\u2192stream core per platform, wired through @chatpanel/pii redaction and the @chatpanel/events audit log. The local Telegram shape is outbound-only (long-poll) and opens no inbound port \u2014 the same property as Claude Code Remote Control. Pure, dependency-free ESM.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"exports": {
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"./invoke.js": "./src/invoke.js",
|
|
12
12
|
"./stream.js": "./src/stream.js",
|
|
13
13
|
"./bridge.js": "./src/bridge.js",
|
|
14
|
+
"./service.js": "./src/service.js",
|
|
14
15
|
"./eventlog.js": "./src/eventlog.js",
|
|
15
16
|
"./adapters/telegram.js": "./src/adapters/telegram.js"
|
|
16
17
|
},
|
|
@@ -29,7 +30,7 @@
|
|
|
29
30
|
"start": "node bin/chatpanel-channels.js telegram"
|
|
30
31
|
},
|
|
31
32
|
"dependencies": {
|
|
32
|
-
"@chatpanel/events": "^0.
|
|
33
|
+
"@chatpanel/events": "^0.23.0",
|
|
33
34
|
"@chatpanel/pii": "^0.3.0"
|
|
34
35
|
},
|
|
35
36
|
"keywords": [
|
package/src/adapters/telegram.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { normalizeTelegram, actorId } from '../normalize.js';
|
|
7
7
|
import {
|
|
8
|
-
buildInvocation, redactInbound,
|
|
8
|
+
buildInvocation, redactInbound, outboundText, appendTurn,
|
|
9
9
|
appendInvoked, appendRedacted, appendEgress,
|
|
10
10
|
} from '../invoke.js';
|
|
11
11
|
import * as bridge from '../bridge.js';
|
|
@@ -18,7 +18,7 @@ const HELP = [
|
|
|
18
18
|
'ChatPanel — drive your local agent from here.',
|
|
19
19
|
'',
|
|
20
20
|
'Send a message and I run it on your machine.',
|
|
21
|
-
'/pair <code> — enroll this chat (
|
|
21
|
+
'/pair <code> — enroll this chat (ChatPanel → Settings → Channels)',
|
|
22
22
|
'/new — start fresh (forget this conversation + new privacy vault)',
|
|
23
23
|
'/stop — stop the current run',
|
|
24
24
|
'/help — this message',
|
|
@@ -29,6 +29,29 @@ const HELP = [
|
|
|
29
29
|
* it needs is injected — a bot token, the bridge address+token, a pairing store, an event sink
|
|
30
30
|
* — so nothing here reads a secret or reaches for global state.
|
|
31
31
|
*/
|
|
32
|
+
// What a turn driven from a phone can actually DO, told to the agent up front.
|
|
33
|
+
//
|
|
34
|
+
// Without this the agent plans as if it were sitting at a terminal: asked to create a note it
|
|
35
|
+
// announced it would look for a notes folder, discovered writes were not enabled, said "let me
|
|
36
|
+
// check what notes tools are available" — and stopped mid-thought, having done nothing and
|
|
37
|
+
// explained nothing. The ceiling is real and enforced elsewhere (reach caps the toolset); the
|
|
38
|
+
// agent simply had no way to know about it, so it spent a turn discovering it and left the
|
|
39
|
+
// user staring at half a sentence.
|
|
40
|
+
export function channelSystem(userSystem, reach) {
|
|
41
|
+
const limits = !reach || reach === 'any' ? '' : [
|
|
42
|
+
"You are answering a message sent from the user's PHONE, relayed to this machine.",
|
|
43
|
+
reach === 'device'
|
|
44
|
+
? 'You cannot read files, run commands, browse the web, or change anything.'
|
|
45
|
+
: "You can READ files and search the user's ChatPanel history. You cannot write or edit "
|
|
46
|
+
+ 'files, run shell commands, or browse the web — those are switched off for messages '
|
|
47
|
+
+ 'from a phone, and no tool will grant them.',
|
|
48
|
+
'If a request needs something you cannot do, say so plainly in your FIRST reply and offer '
|
|
49
|
+
+ 'what you can do instead. Never begin work you cannot finish.',
|
|
50
|
+
'Answers are read on a phone: be brief, and put the answer first.',
|
|
51
|
+
].join(' ');
|
|
52
|
+
return [limits, userSystem].filter(Boolean).join('\n\n');
|
|
53
|
+
}
|
|
54
|
+
|
|
32
55
|
export function startTelegram({
|
|
33
56
|
botToken,
|
|
34
57
|
baseUrl,
|
|
@@ -37,7 +60,23 @@ export function startTelegram({
|
|
|
37
60
|
savePairing = async () => {},
|
|
38
61
|
appender, // createEventLog(...) or nullEventLog()
|
|
39
62
|
agent = 'claude',
|
|
63
|
+
// Which transport answers, and with what. `bridge` runs a CLI agent on this machine;
|
|
64
|
+
// `gateway` reaches any destination the user configured there — an API provider or, via the
|
|
65
|
+
// gateway's own bridge backend, the same CLI agents. The adapter must not be able to tell
|
|
66
|
+
// which it has: both expose chat()/cancel() and fold into one reply state.
|
|
67
|
+
transport = bridge,
|
|
68
|
+
model = '',
|
|
69
|
+
provider = '',
|
|
40
70
|
system = '',
|
|
71
|
+
// Read at the START OF EACH MESSAGE rather than captured when the loop starts.
|
|
72
|
+
//
|
|
73
|
+
// `chats` below holds every conversation's history AND its privacy vault, and it lives only
|
|
74
|
+
// as long as this loop. Settings used to be baked in at start, so changing the model — or
|
|
75
|
+
// the privacy mode, or anything else — had to stop and respawn the loop, which silently threw
|
|
76
|
+
// all of that away: the next message arrived as "a fresh session with no prior conversation",
|
|
77
|
+
// and PERSON_1 stopped meaning the same person. Switching which model answers is not a
|
|
78
|
+
// reason to forget what you were talking about.
|
|
79
|
+
route = null,
|
|
41
80
|
redact = { tier: 'basic' },
|
|
42
81
|
privacy = 'standard',
|
|
43
82
|
logger = console,
|
|
@@ -85,19 +124,27 @@ export function startTelegram({
|
|
|
85
124
|
async function handleCommand(norm) {
|
|
86
125
|
const id = actorId('telegram', norm.chatId);
|
|
87
126
|
const { name, args } = norm.command;
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
127
|
+
// `/start <code>` is what a t.me/<bot>?start=<code> link SENDS. Telegram turns the link
|
|
128
|
+
// into that first message, so tapping "Pair this phone" in ChatPanel enrolls in one tap —
|
|
129
|
+
// no six digits thumbed in from another screen. Bare /start is still the greeting.
|
|
130
|
+
const pairCode = name === 'pair' || (name === 'start' && args) ? args : '';
|
|
131
|
+
if (pairCode) {
|
|
132
|
+
const r = pairing.redeem(id, pairCode, { label: norm.from?.name || '' });
|
|
91
133
|
await savePairing();
|
|
92
|
-
return void send(norm.chatId, r.ok
|
|
134
|
+
return void send(norm.chatId, r.ok
|
|
135
|
+
? `✅ paired (reach: ${r.reach}). Send me anything — I'll run it on your machine.`
|
|
136
|
+
: `⛔ ${r.reason}`);
|
|
93
137
|
}
|
|
138
|
+
if (name === 'help' || name === 'start') return void send(norm.chatId, HELP);
|
|
139
|
+
if (name === 'pair') return void send(norm.chatId, 'send /pair <code> — get the code in ChatPanel → Settings → Channels');
|
|
94
140
|
if (name === 'new') {
|
|
95
141
|
chats.set(norm.chatId, freshChat());
|
|
96
142
|
return void send(norm.chatId, '🧹 fresh conversation.');
|
|
97
143
|
}
|
|
98
144
|
if (name === 'stop') {
|
|
99
145
|
const st = chatState(norm.chatId);
|
|
100
|
-
const
|
|
146
|
+
const liveRoute = (route && route()) || {};
|
|
147
|
+
const ok = await (liveRoute.transport || transport).cancel(st.runId, { baseUrl: liveRoute.baseUrl || baseUrl, token: liveRoute.token || token });
|
|
101
148
|
st.runId = null;
|
|
102
149
|
return void send(norm.chatId, ok ? '⏹ stopped.' : 'nothing running.');
|
|
103
150
|
}
|
|
@@ -134,16 +181,29 @@ export function startTelegram({
|
|
|
134
181
|
// on the bridge renders all-but-last as history and the last as "answer this now".
|
|
135
182
|
const messages = [...st.history, { role: 'user', content: redacted }];
|
|
136
183
|
|
|
184
|
+
// Whatever the settings say RIGHT NOW — see `route` above.
|
|
185
|
+
const live = (route && route()) || {};
|
|
186
|
+
const useTransport = live.transport || transport;
|
|
137
187
|
try {
|
|
138
|
-
const finalState = await
|
|
139
|
-
{ agent, system, messages, images, options: { reach } },
|
|
188
|
+
const finalState = await useTransport.chat(
|
|
140
189
|
{
|
|
141
|
-
|
|
190
|
+
agent: live.agent ?? agent,
|
|
191
|
+
model: live.model ?? model,
|
|
192
|
+
provider: live.provider ?? provider,
|
|
193
|
+
system: channelSystem(live.system ?? system, reach),
|
|
194
|
+
messages,
|
|
195
|
+
images,
|
|
196
|
+
options: { reach },
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
baseUrl: live.baseUrl || baseUrl, token: live.token || token, signal,
|
|
142
200
|
onEvent: (ev, state) => {
|
|
143
|
-
if (
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
|
|
201
|
+
if (state.runId) st.runId = state.runId;
|
|
202
|
+
// Driven by the folded STATE, not by an event's `type`: the bridge emits
|
|
203
|
+
// {type:'delta'} and the gateway emits OpenAI chunks, and this has to work on both.
|
|
204
|
+
// The `first !== shown` guard below makes a no-text event a no-op anyway.
|
|
205
|
+
if (replyId && (state.done || gate.ready())) {
|
|
206
|
+
const text = outboundText(state.text, st.vault, { privacy });
|
|
147
207
|
const first = splitForTelegram(text || '…')[0];
|
|
148
208
|
if (first && first !== shown) { shown = first; edit(norm.chatId, replyId, first).catch(() => {}); }
|
|
149
209
|
}
|
|
@@ -152,7 +212,7 @@ export function startTelegram({
|
|
|
152
212
|
);
|
|
153
213
|
st.runId = null;
|
|
154
214
|
|
|
155
|
-
const restored =
|
|
215
|
+
const restored = outboundText(finalState.text, st.vault, { privacy });
|
|
156
216
|
const chunks = splitForTelegram(finalState.error ? `⚠️ ${finalState.error}` : (restored || '(no output)'));
|
|
157
217
|
if (replyId) await edit(norm.chatId, replyId, chunks[0]);
|
|
158
218
|
else await send(norm.chatId, chunks[0]);
|
|
@@ -175,6 +235,16 @@ export function startTelegram({
|
|
|
175
235
|
}
|
|
176
236
|
}
|
|
177
237
|
|
|
238
|
+
// A sleep that gives up when the loop is asked to stop, so Ctrl-C is immediate rather than
|
|
239
|
+
// "immediate in up to five seconds".
|
|
240
|
+
const nap = (ms) => new Promise((resolve) => {
|
|
241
|
+
if (signal?.aborted) return resolve();
|
|
242
|
+
let t;
|
|
243
|
+
const done = () => { clearTimeout(t); signal?.removeEventListener?.('abort', done); resolve(); };
|
|
244
|
+
t = setTimeout(done, ms);
|
|
245
|
+
signal?.addEventListener?.('abort', done, { once: true });
|
|
246
|
+
});
|
|
247
|
+
|
|
178
248
|
async function loop() {
|
|
179
249
|
let offset = 0;
|
|
180
250
|
logger.log?.('[telegram] long-poll started (outbound-only; no inbound port).');
|
|
@@ -182,11 +252,26 @@ export function startTelegram({
|
|
|
182
252
|
let updates;
|
|
183
253
|
try {
|
|
184
254
|
const res = await tg('getUpdates', { offset, timeout: 30, allowed_updates: ['message'] });
|
|
255
|
+
// Telegram REFUSES with a normal JSON body, not an HTTP error this code would throw
|
|
256
|
+
// on: a bad token answers {ok:false, 401} INSTANTLY, so `res.result || []` turned a
|
|
257
|
+
// wrong token into a silent hot loop — no message, no long-poll delay, and an API
|
|
258
|
+
// hammered hard enough to get rate-limited. The two refusals that actually happen
|
|
259
|
+
// during setup are named, because "nothing arrives" is the same symptom as "it works
|
|
260
|
+
// and nobody has texted you".
|
|
261
|
+
if (res && res.ok === false) {
|
|
262
|
+
const code = res.error_code;
|
|
263
|
+
const hint = code === 401 ? ' — check the bot token (@BotFather → /mybots → API token)'
|
|
264
|
+
: code === 409 ? ' — another chatpanel-channels (or another poller) is already reading this bot'
|
|
265
|
+
: '';
|
|
266
|
+
logger.error?.(`[telegram] getUpdates refused: ${res.description || `error_code ${code}`}${hint}`);
|
|
267
|
+
await nap(5000);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
185
270
|
updates = res?.result || [];
|
|
186
271
|
} catch (e) {
|
|
187
272
|
if (signal?.aborted) break;
|
|
188
273
|
logger.warn?.(`[telegram] getUpdates failed: ${e?.message || e}; retrying in 2s`);
|
|
189
|
-
await
|
|
274
|
+
await nap(2000);
|
|
190
275
|
continue;
|
|
191
276
|
}
|
|
192
277
|
for (const u of updates) {
|
package/src/config.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import { readFile } from 'node:fs/promises';
|
|
6
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
7
7
|
|
|
8
8
|
// The bridge binds a FIXED 127.0.0.1:4319 (see chatpanel-bridge/src/server.js) so the
|
|
9
9
|
// extension always finds it; the adapter is just another local client of the same port.
|
|
@@ -33,3 +33,31 @@ export async function readBridgeToken(env = process.env) {
|
|
|
33
33
|
} catch { /* fall through to a clear, actionable error */ }
|
|
34
34
|
throw new Error(`no bridge token — start chatpanel-bridge once (it writes ${p}), or set CHATPANEL_BRIDGE_TOKEN`);
|
|
35
35
|
}
|
|
36
|
+
|
|
37
|
+
// The BotFather token is a bearer credential: whoever holds it can send AND receive as your bot,
|
|
38
|
+
// i.e. read every message in the chat. So prefer a 0600 FILE (like the bridge token) over an env
|
|
39
|
+
// var, which leaks into `ps`, shell history, and every child process's environment. Order:
|
|
40
|
+
// 1. TELEGRAM_BOT_TOKEN_FILE (explicit path)
|
|
41
|
+
// 2. ~/.chatpanel/telegram-token (default file)
|
|
42
|
+
// 3. TELEGRAM_BOT_TOKEN / BOT_TOKEN (env — compat, with a warning)
|
|
43
|
+
// Returns { token, source }; token is null when nothing is configured (caller errors cleanly).
|
|
44
|
+
export async function readBotToken(env = process.env, { logger = console } = {}) {
|
|
45
|
+
const file = env.TELEGRAM_BOT_TOKEN_FILE || path.join(channelsHome(env), 'telegram-token');
|
|
46
|
+
try {
|
|
47
|
+
const raw = (await readFile(file, 'utf8')).trim();
|
|
48
|
+
if (raw) {
|
|
49
|
+
try {
|
|
50
|
+
const { mode } = await stat(file);
|
|
51
|
+
// Any group/other permission bit set on a file holding a bearer secret is worth flagging.
|
|
52
|
+
if (mode & 0o077) logger.warn?.(`[chatpanel-channels] ${file} is group/world-readable — run: chmod 600 ${file} (it holds your bot token).`);
|
|
53
|
+
} catch { /* stat is best-effort; a readable token still works */ }
|
|
54
|
+
return { token: raw, source: file };
|
|
55
|
+
}
|
|
56
|
+
} catch { /* no file → fall back to env */ }
|
|
57
|
+
const envTok = (env.TELEGRAM_BOT_TOKEN || env.BOT_TOKEN || '').trim();
|
|
58
|
+
if (envTok) {
|
|
59
|
+
logger.warn?.('[chatpanel-channels] reading the bot token from the environment — it is visible in `ps` and shell history. Prefer a 0600 file at ~/.chatpanel/telegram-token (or set TELEGRAM_BOT_TOKEN_FILE).');
|
|
60
|
+
return { token: envTok, source: 'env' };
|
|
61
|
+
}
|
|
62
|
+
return { token: null, source: null };
|
|
63
|
+
}
|
package/src/gateway.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Gateway backend: reach ANY configured destination — an API provider or a CLI agent —
|
|
2
|
+
// through the ChatPanel gateway's OpenAI-compatible endpoint.
|
|
3
|
+
//
|
|
4
|
+
// channel → POST http://127.0.0.1:4320/v1/chat/completions { model, messages, stream }
|
|
5
|
+
//
|
|
6
|
+
// WHY THIS EXISTS ALONGSIDE bridge.js. The bridge runs CLI agents, and that is all it can
|
|
7
|
+
// answer with — so a phone could only ever talk to Claude Code or Codex, never to the OpenAI,
|
|
8
|
+
// Anthropic or local-model endpoints the same user already configured. Those live in the
|
|
9
|
+
// gateway, which persists its `destinations` (0600, keys included) and routes a model id to
|
|
10
|
+
// an API provider OR back to the bridge for an agent. So the gateway is the superset, and
|
|
11
|
+
// pointing a channel at it is what makes "answer from my phone" work with every target the
|
|
12
|
+
// user has rather than a subset.
|
|
13
|
+
//
|
|
14
|
+
// NO NEW SECRET. The alternative was teaching the bridge to hold provider API keys, which
|
|
15
|
+
// would have put them on disk a second time, in a second format, with a second thing to
|
|
16
|
+
// rotate. The gateway already holds them and already guards them; this borrows the routing
|
|
17
|
+
// instead of copying the credentials.
|
|
18
|
+
//
|
|
19
|
+
// THE GATEWAY TOKEN. The /v1 data plane is open to local clients for API destinations —
|
|
20
|
+
// but a destination that is an AGENT spawns a process on this machine, and since gateway
|
|
21
|
+
// 0.9.0 that lane is closed to a caller that has not proved it runs as the user. The
|
|
22
|
+
// service reads ~/.chatpanel/gateway-token (0600) and hands it here as `token`; sent as a
|
|
23
|
+
// bearer, exactly as the bridge transport sends the bridge token. Absent → sent without,
|
|
24
|
+
// which an older gateway accepts and a newer one refuses with a message naming the fix.
|
|
25
|
+
|
|
26
|
+
import { parseSse, foldOpenAiEvent, initialState } from './stream.js';
|
|
27
|
+
|
|
28
|
+
// Per-turn aborts, so /stop can cancel a gateway turn the way it cancels a bridge run. The
|
|
29
|
+
// bridge hands out a run id for this; OpenAI's API has no such handle, so we mint one and
|
|
30
|
+
// keep the controller behind it rather than leaving /stop silently broken on this transport.
|
|
31
|
+
const inflight = new Map();
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Drive one turn against a gateway destination. Same shape as bridge.chat so the adapter does
|
|
35
|
+
* not know which transport it has: streams events to onEvent(ev, state) and returns the folded
|
|
36
|
+
* final state.
|
|
37
|
+
*/
|
|
38
|
+
export async function chat({ model = '', provider = '', system = '', messages = [], options = {} }, {
|
|
39
|
+
baseUrl, token = '', signal, onEvent = () => {},
|
|
40
|
+
} = {}) {
|
|
41
|
+
const runId = `gw_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
|
42
|
+
const ac = new AbortController();
|
|
43
|
+
inflight.set(runId, ac);
|
|
44
|
+
// The caller's signal (the poll loop shutting down) must still abort the turn.
|
|
45
|
+
const relay = () => ac.abort();
|
|
46
|
+
signal?.addEventListener?.('abort', relay, { once: true });
|
|
47
|
+
|
|
48
|
+
let state = initialState();
|
|
49
|
+
const emit = (ev) => { state = foldOpenAiEvent(state, ev); onEvent(ev, state); };
|
|
50
|
+
emit({ type: 'run', id: runId });
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const res = await fetch(`${String(baseUrl).replace(/\/$/, '')}/v1/chat/completions`, {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: {
|
|
56
|
+
'content-type': 'application/json',
|
|
57
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
58
|
+
// ChatPanel's routing metadata goes in HEADERS, never in the request body. It rode the
|
|
59
|
+
// body first and NVIDIA answered "unsupported parameters": OpenAI-compatible providers
|
|
60
|
+
// validate the body strictly and reject unknown fields, while ignoring unknown headers.
|
|
61
|
+
// The gateway strips x-chatpanel-* before forwarding, so the provider never sees it.
|
|
62
|
+
...(options?.reach ? { 'x-chatpanel-reach': options.reach } : {}),
|
|
63
|
+
// WHICH provider, not just which model. A model id is not a unique key — two providers
|
|
64
|
+
// can serve the same one — so without this the gateway picks whichever destination it
|
|
65
|
+
// lists first and the call goes out on a key the user never chose.
|
|
66
|
+
...(provider ? { 'x-chatpanel-destination': provider } : {}),
|
|
67
|
+
},
|
|
68
|
+
body: JSON.stringify({
|
|
69
|
+
model,
|
|
70
|
+
stream: true,
|
|
71
|
+
messages: [
|
|
72
|
+
...(system ? [{ role: 'system', content: system }] : []),
|
|
73
|
+
...messages.map((m) => ({ role: m.role, content: String(m.content ?? '') })),
|
|
74
|
+
],
|
|
75
|
+
}),
|
|
76
|
+
signal: ac.signal,
|
|
77
|
+
});
|
|
78
|
+
if (!res.ok || !res.body) {
|
|
79
|
+
const detail = await res.text().catch(() => '');
|
|
80
|
+
throw new Error(`gateway ${res.status}${detail ? `: ${detail.slice(0, 300)}` : ''}`);
|
|
81
|
+
}
|
|
82
|
+
let buffer = '';
|
|
83
|
+
const decoder = new TextDecoder();
|
|
84
|
+
for await (const chunk of res.body) {
|
|
85
|
+
buffer += typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true });
|
|
86
|
+
const { events, rest } = parseSse(buffer);
|
|
87
|
+
buffer = rest;
|
|
88
|
+
for (const ev of events) emit(ev);
|
|
89
|
+
}
|
|
90
|
+
// OpenAI streams end with `data: [DONE]`, which is not JSON and is dropped by parseSse —
|
|
91
|
+
// so a stream that ended cleanly still has to be marked done here.
|
|
92
|
+
if (!state.done) state = { ...state, done: true };
|
|
93
|
+
return state;
|
|
94
|
+
} catch (e) {
|
|
95
|
+
if (ac.signal.aborted) return { ...state, done: true };
|
|
96
|
+
return { ...state, done: true, error: e?.message || String(e) };
|
|
97
|
+
} finally {
|
|
98
|
+
inflight.delete(runId);
|
|
99
|
+
signal?.removeEventListener?.('abort', relay);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Stop a run by the id emitted as the first {type:'run'} event. Best-effort, like the bridge's. */
|
|
104
|
+
export async function cancel(runId) {
|
|
105
|
+
const ac = runId && inflight.get(runId);
|
|
106
|
+
if (!ac) return false;
|
|
107
|
+
ac.abort();
|
|
108
|
+
inflight.delete(runId);
|
|
109
|
+
return true;
|
|
110
|
+
}
|
package/src/invoke.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// adapter owns the network, this owns the contract.
|
|
5
5
|
|
|
6
6
|
import { validateInvocation } from '@chatpanel/events/capability.js';
|
|
7
|
-
import { redactText, restoreText, redactionSummary } from '@chatpanel/pii';
|
|
7
|
+
import { createVault, redactText, restoreText, redactionSummary } from '@chatpanel/pii';
|
|
8
8
|
|
|
9
9
|
// One capability every channel message invokes: "run an agent turn on the user's behalf".
|
|
10
10
|
// Effects are non-replayable — a turn runs shell/filesystem tools, so replaying it would
|
|
@@ -67,6 +67,50 @@ export function restoreOutbound(text, vault, { privacy = 'standard' } = {}) {
|
|
|
67
67
|
return privacy === 'strict' ? String(text ?? '') : restoreText(text ?? '', vault);
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
// A [[TYPE_n]] placeholder token; the capture is the entity TYPE.
|
|
71
|
+
const TOKEN_MARKER_RE = /\[\[([A-Z][A-Z0-9]*)_\d+\]\]/g;
|
|
72
|
+
|
|
73
|
+
// Hard credentials: catastrophic if they reach a third-party provider, and a user practically
|
|
74
|
+
// never types their own into a chat — so we mask these on EVERY egress regardless of privacy
|
|
75
|
+
// mode. Contact PII (EMAIL/PHONE/IP) is deliberately NOT here: re-masking it would gut standard
|
|
76
|
+
// mode (the user could never read their own data back), so it follows the privacy mode instead.
|
|
77
|
+
export const EGRESS_SECRET_TYPES = new Set(['SECRET', 'KEY', 'CARD', 'SSN']);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* EGRESS SCRUB. Inbound redaction only covers the user's typed message — but the agent can
|
|
81
|
+
* surface a NEW secret the vault never saw: a key/token/card it read from a file or a tool and
|
|
82
|
+
* echoed into the reply. The reply itself is an egress to a provider (Telegram/Meta) that is NOT
|
|
83
|
+
* end-to-end encrypted, so that fresh secret would transit in the clear. This runs a fresh
|
|
84
|
+
* detector pass into a THROWAWAY vault and permanently masks the hard-credential types to a
|
|
85
|
+
* readable '‹type redacted›' marker.
|
|
86
|
+
*
|
|
87
|
+
* Run this AFTER restore: at that point no real chat-vault tokens remain, so the single throwaway
|
|
88
|
+
* vault numbers cleanly (no [[TYPE_n]] collision) and non-secret detections can be re-expanded to
|
|
89
|
+
* the value the user is allowed to read. `restoreNonSecret:false` (strict mode) keeps everything
|
|
90
|
+
* tokenized so no real value — fresh or otherwise — is emitted.
|
|
91
|
+
*/
|
|
92
|
+
export function scrubEgress(text, { secretTypes = EGRESS_SECRET_TYPES, restoreNonSecret = true } = {}) {
|
|
93
|
+
if (text == null || text === '') return text ?? '';
|
|
94
|
+
const tv = createVault();
|
|
95
|
+
const masked = redactText(String(text), tv, { tier: 'basic' });
|
|
96
|
+
return masked.replace(TOKEN_MARKER_RE, (full, type) => {
|
|
97
|
+
if (secretTypes.has(type.toUpperCase())) return `‹${type.toLowerCase()} redacted›`;
|
|
98
|
+
return restoreNonSecret ? (tv.byToken.get(full) ?? full) : full;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The single outbound transform: what to actually SEND to the provider for one reply.
|
|
104
|
+
* - 'standard': restore the user's own values (they read their own data back), THEN scrub — so
|
|
105
|
+
* contact PII the user typed survives, but any hard credential the agent surfaced is masked.
|
|
106
|
+
* - 'strict': keep the user's values as placeholders AND still mask fresh credentials, so
|
|
107
|
+
* 'strict' is never weaker than 'standard'.
|
|
108
|
+
*/
|
|
109
|
+
export function outboundText(text, vault, { privacy = 'standard' } = {}) {
|
|
110
|
+
const restored = restoreOutbound(text, vault, { privacy });
|
|
111
|
+
return scrubEgress(restored, { restoreNonSecret: privacy !== 'strict' });
|
|
112
|
+
}
|
|
113
|
+
|
|
70
114
|
// Conversation memory. The adapter keeps ONE array of REDACTED turns per chat and replays it
|
|
71
115
|
// as context, so "…and the second one?" resolves against the prior answer. The bridge's
|
|
72
116
|
// buildCliPrompt already renders all-but-last as a labelled history transcript and the last as
|
package/src/pairing.js
CHANGED
|
@@ -8,12 +8,23 @@
|
|
|
8
8
|
// Reach reuses the router's tiers verbatim (device < trusted < any), so "a paired phone is
|
|
9
9
|
// trusted" means the exact same thing here as it does to the model router downstream.
|
|
10
10
|
|
|
11
|
-
import { REACH } from '@chatpanel/events/
|
|
11
|
+
import { REACH } from '@chatpanel/events/reach.js';
|
|
12
12
|
|
|
13
13
|
export { REACH };
|
|
14
14
|
|
|
15
15
|
// 6 digits: enough entropy for a short-lived, single-use enrollment code shown on a screen,
|
|
16
16
|
// short enough to thumb into a phone. It is NOT a password — it expires and burns on first use.
|
|
17
|
+
// A display name from a remote platform is untrusted text that lands in the owner's settings
|
|
18
|
+
// screen: strip control characters and bidi overrides (which can make one name render as
|
|
19
|
+
// another), collapse whitespace, and cap it. The UI escapes too — this is the other half.
|
|
20
|
+
function cleanLabel(value) {
|
|
21
|
+
return String(value || '')
|
|
22
|
+
.replace(/[\u0000-\u001f\u007f\u200b-\u200f\u202a-\u202e\u2066-\u2069]/g, '')
|
|
23
|
+
.replace(/\s+/g, ' ')
|
|
24
|
+
.trim()
|
|
25
|
+
.slice(0, 48);
|
|
26
|
+
}
|
|
27
|
+
|
|
17
28
|
function sixDigits(randomInt) {
|
|
18
29
|
return String(randomInt(0, 1_000_000)).padStart(6, '0');
|
|
19
30
|
}
|
|
@@ -46,20 +57,24 @@ export function createPairingStore(state = {}, {
|
|
|
46
57
|
pending.set(code, { at: now(), ttlMs });
|
|
47
58
|
return code;
|
|
48
59
|
},
|
|
49
|
-
/** Phone-side: "/pair 123456". Burns the code and pairs the actor at 'trusted'.
|
|
50
|
-
|
|
60
|
+
/** Phone-side: "/pair 123456". Burns the code and pairs the actor at 'trusted'.
|
|
61
|
+
* `label` is whatever the platform calls the person (a Telegram first name or @handle) —
|
|
62
|
+
* stored so the owner's screen can say WHICH phone it just enrolled. An opaque
|
|
63
|
+
* 'telegram:789795542' is not something anyone can recognise, and the whole point of the
|
|
64
|
+
* list is deciding whether to revoke one. Display only: authorization is by actorId. */
|
|
65
|
+
redeem(actorId, code, { reach = 'trusted', label = '' } = {}) {
|
|
51
66
|
prune();
|
|
52
67
|
const c = String(code || '').trim();
|
|
53
68
|
if (!pending.has(c)) return { ok: false, reason: 'unknown or expired code' };
|
|
54
69
|
if (!REACH.includes(reach)) return { ok: false, reason: `unknown reach '${reach}'` };
|
|
55
70
|
pending.delete(c);
|
|
56
|
-
paired.set(actorId, { reach, at: now() });
|
|
71
|
+
paired.set(actorId, { reach, at: now(), label: cleanLabel(label) });
|
|
57
72
|
return { ok: true, reach };
|
|
58
73
|
},
|
|
59
74
|
/** Bootstrap without a code — for an operator-supplied allow list. Explicit, not silent. */
|
|
60
|
-
allow(actorId, { reach = 'trusted' } = {}) {
|
|
75
|
+
allow(actorId, { reach = 'trusted', label = '' } = {}) {
|
|
61
76
|
if (!REACH.includes(reach)) throw new Error(`unknown reach '${reach}'`);
|
|
62
|
-
paired.set(actorId, { reach, at: now() });
|
|
77
|
+
paired.set(actorId, { reach, at: now(), label: cleanLabel(label) });
|
|
63
78
|
},
|
|
64
79
|
revoke(actorId) { return paired.delete(actorId); },
|
|
65
80
|
isPaired(actorId) { return paired.has(actorId); },
|
package/src/service.js
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
// The channel SERVICE — connect · pair · status · disconnect, as one contract a UI can drive.
|
|
2
|
+
//
|
|
3
|
+
// The adapter is a loop. A *service* is what a person can actually operate: is it connected,
|
|
4
|
+
// to which bot, who is paired, give me a code, stop it. That contract lives here rather than
|
|
5
|
+
// inside whichever process happens to host the loop, because there is more than one host — the
|
|
6
|
+
// bridge (always on, and what a non-technical user already has), the CLI (headless boxes), and
|
|
7
|
+
// a desktop app later. Three hosts implementing "connect a bot" is three different security
|
|
8
|
+
// postures for the same secret.
|
|
9
|
+
//
|
|
10
|
+
// It owns exactly the state a channel has:
|
|
11
|
+
// • the bot token — a 0600 file, read at start, NEVER returned by status();
|
|
12
|
+
// • the pairing store — who may drive an agent, and their reach ceiling;
|
|
13
|
+
// • the per-channel settings — which agent answers, and the privacy mode.
|
|
14
|
+
//
|
|
15
|
+
// It owns none of the transport around it: no HTTP, no auth, no UI. The host does that, which
|
|
16
|
+
// is why this module needs no server and is testable with a stub fetch.
|
|
17
|
+
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { readFile, writeFile, mkdir, rm, stat } from 'node:fs/promises';
|
|
20
|
+
import { readFileSync } from 'node:fs';
|
|
21
|
+
import { createPairingStore } from './pairing.js';
|
|
22
|
+
import * as bridgeTransport from './bridge.js';
|
|
23
|
+
import * as gateway from './gateway.js';
|
|
24
|
+
|
|
25
|
+
// The gateway's fixed local port (see chatpanel-gateway: 4319 bridge / 4320 gateway).
|
|
26
|
+
const DEFAULT_GATEWAY_URL = 'http://127.0.0.1:4320';
|
|
27
|
+
import { createEventLog } from './eventlog.js';
|
|
28
|
+
import { startTelegram } from './adapters/telegram.js';
|
|
29
|
+
|
|
30
|
+
const TELEGRAM_API = 'https://api.telegram.org';
|
|
31
|
+
// `agent` routes through the bridge (a CLI on this machine). `model` routes through the
|
|
32
|
+
// gateway, which reaches every destination the user configured there — API providers AND, via
|
|
33
|
+
// its own bridge backend, the same agents. They are mutually exclusive: update() clears one
|
|
34
|
+
// when the other is set, because "which thing answers" is one choice, not two.
|
|
35
|
+
export const DEFAULT_SETTINGS = Object.freeze({
|
|
36
|
+
agent: 'claude', model: '', provider: '', gatewayUrl: DEFAULT_GATEWAY_URL,
|
|
37
|
+
privacy: 'standard', tier: 'basic',
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Restart backoff. A long-poll that dies (network drop, laptop asleep, Telegram hiccup) must
|
|
41
|
+
// come back on its own — a channel nobody is watching is exactly the one that must self-heal —
|
|
42
|
+
// but a token that has been REVOKED would otherwise spin forever, so the wait grows.
|
|
43
|
+
const RETRY_MS = [2_000, 5_000, 15_000, 60_000];
|
|
44
|
+
|
|
45
|
+
const readJson = async (file, fallback) => {
|
|
46
|
+
try { return JSON.parse(await readFile(file, 'utf8')); } catch { return fallback; }
|
|
47
|
+
};
|
|
48
|
+
const writeJson = (file, value) => writeFile(file, JSON.stringify(value, null, 2), { mode: 0o600 });
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Ask Telegram who this token belongs to. This is the ONLY validation that matters at connect
|
|
52
|
+
* time: a typo'd token must fail in the settings screen, with a reason, rather than becoming a
|
|
53
|
+
* silent poll loop nobody sees the logs of.
|
|
54
|
+
*/
|
|
55
|
+
export async function verifyBot(botToken, { fetchImpl = fetch, signal } = {}) {
|
|
56
|
+
const res = await fetchImpl(`${TELEGRAM_API}/bot${String(botToken || '').trim()}/getMe`, { signal });
|
|
57
|
+
const body = await res.json().catch(() => null);
|
|
58
|
+
if (!body?.ok) {
|
|
59
|
+
const why = body?.description || `HTTP ${res.status}`;
|
|
60
|
+
throw new Error(/unauthorized/i.test(why) ? 'Telegram rejected that token — copy it again from @BotFather' : why);
|
|
61
|
+
}
|
|
62
|
+
return { id: body.result.id, username: body.result.username, name: body.result.first_name || '' };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The t.me link that pairs in one tap: Telegram turns it into "/start <code>" in the chat. */
|
|
66
|
+
export const pairLink = (username, code) => `https://t.me/${username}?start=${code}`;
|
|
67
|
+
|
|
68
|
+
export function createChannelService({
|
|
69
|
+
home, // ~/.chatpanel — where the token file lives
|
|
70
|
+
dataDir, // ~/.chatpanel/channels — pairing, config, event log
|
|
71
|
+
bridge, // { baseUrl, token } — how the adapter reaches the agent
|
|
72
|
+
logger = console,
|
|
73
|
+
fetchImpl = undefined, // injected in tests
|
|
74
|
+
now = () => Date.now(),
|
|
75
|
+
// The adapter is injectable for the same reason fetchImpl is: the thing worth asserting
|
|
76
|
+
// about the loop is WHAT it is handed, and a real Telegram long-poll cannot be asked.
|
|
77
|
+
startAdapter = startTelegram,
|
|
78
|
+
} = {}) {
|
|
79
|
+
const tokenFile = path.join(home, 'telegram-token');
|
|
80
|
+
const configFile = path.join(dataDir, 'config.json');
|
|
81
|
+
const pairingFile = path.join(dataDir, 'pairing.json');
|
|
82
|
+
|
|
83
|
+
let pairing = null; // created by load(), then kept — see the note there
|
|
84
|
+
let settings = { ...DEFAULT_SETTINGS };
|
|
85
|
+
let appender = null;
|
|
86
|
+
let bot = null; // { id, username, name } once verified
|
|
87
|
+
let controller = null; // aborts the running loop
|
|
88
|
+
let running = false;
|
|
89
|
+
let lastError = '';
|
|
90
|
+
let attempt = 0;
|
|
91
|
+
let stopped = true; // deliberate stop — suppresses the restart
|
|
92
|
+
|
|
93
|
+
const savePairing = () => writeJson(pairingFile, pairing ? pairing.toJSON() : {});
|
|
94
|
+
const saveSettings = () => writeJson(configFile, settings);
|
|
95
|
+
|
|
96
|
+
async function readToken() {
|
|
97
|
+
try {
|
|
98
|
+
const t = (await readFile(tokenFile, 'utf8')).trim();
|
|
99
|
+
if (!t) return '';
|
|
100
|
+
try {
|
|
101
|
+
const { mode } = await stat(tokenFile);
|
|
102
|
+
if (mode & 0o077) logger.warn?.(`[channels] ${tokenFile} is group/world-readable — chmod 600 it (it holds your bot token).`);
|
|
103
|
+
} catch { /* best effort */ }
|
|
104
|
+
return t;
|
|
105
|
+
} catch { return ''; }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function load() {
|
|
109
|
+
await mkdir(dataDir, { recursive: true });
|
|
110
|
+
// Built ONCE and never replaced. spawnLoop() hands this exact object to the adapter, which
|
|
111
|
+
// holds it for the life of a polling loop — so rebuilding it here (as every service call
|
|
112
|
+
// used to) broke pairing in both directions at once: `pair()` minted the code into a fresh
|
|
113
|
+
// store the adapter could not see, so every redeem answered "unknown or expired code" no
|
|
114
|
+
// matter how many codes you generated; and `savePairing()` serialises whichever store this
|
|
115
|
+
// variable currently points at, so a redeem that DID land would have been persisted from
|
|
116
|
+
// the wrong object. Two aliases of one thing is the bug — there is only ever one store.
|
|
117
|
+
if (!pairing) pairing = createPairingStore(await readJson(pairingFile, {}), { now });
|
|
118
|
+
settings = { ...DEFAULT_SETTINGS, ...(await readJson(configFile, {})) };
|
|
119
|
+
if (!appender) appender = await createEventLog({ file: path.join(dataDir, 'events.jsonl'), host: 'channel' });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// One supervised run of the loop. Resolves when the loop ends; schedules its own restart
|
|
123
|
+
// unless the stop was deliberate.
|
|
124
|
+
function spawnLoop(botToken) {
|
|
125
|
+
controller = new AbortController();
|
|
126
|
+
running = true;
|
|
127
|
+
// Resolved PER MESSAGE, not once at start. The loop owns every conversation's history and
|
|
128
|
+
// privacy vault, so restarting it to pick up a settings change threw away the thing the
|
|
129
|
+
// user came for. Now nothing needs a restart: the next message simply reads this.
|
|
130
|
+
// The gateway's own token, read when a turn needs it rather than once at startup: the
|
|
131
|
+
// gateway writes it on ITS first start, which may be after ours. Best effort — absent,
|
|
132
|
+
// the call goes without and the gateway's answer says what to do.
|
|
133
|
+
const gatewayToken = () => {
|
|
134
|
+
try { return readFileSync(path.join(home, 'gateway-token'), 'utf8').trim(); } catch { return ''; }
|
|
135
|
+
};
|
|
136
|
+
const route = () => {
|
|
137
|
+
const viaGateway = !!settings.model;
|
|
138
|
+
return {
|
|
139
|
+
transport: viaGateway ? gateway : bridgeTransport,
|
|
140
|
+
token: viaGateway ? gatewayToken() : bridge.token,
|
|
141
|
+
agent: viaGateway ? '' : settings.agent,
|
|
142
|
+
model: viaGateway ? settings.model : '',
|
|
143
|
+
provider: viaGateway ? (settings.provider || '') : '',
|
|
144
|
+
baseUrl: viaGateway ? (settings.gatewayUrl || DEFAULT_GATEWAY_URL) : bridge.baseUrl,
|
|
145
|
+
system: settings.system || '',
|
|
146
|
+
privacy: settings.privacy,
|
|
147
|
+
};
|
|
148
|
+
};
|
|
149
|
+
const done = startAdapter({
|
|
150
|
+
botToken,
|
|
151
|
+
route,
|
|
152
|
+
transport: route().transport,
|
|
153
|
+
model: route().model,
|
|
154
|
+
provider: route().provider,
|
|
155
|
+
baseUrl: route().baseUrl,
|
|
156
|
+
token: bridge.token,
|
|
157
|
+
pairing,
|
|
158
|
+
savePairing,
|
|
159
|
+
appender,
|
|
160
|
+
agent: settings.agent,
|
|
161
|
+
system: settings.system || '',
|
|
162
|
+
redact: { tier: settings.tier },
|
|
163
|
+
privacy: settings.privacy,
|
|
164
|
+
logger,
|
|
165
|
+
signal: controller.signal,
|
|
166
|
+
});
|
|
167
|
+
Promise.resolve(done)
|
|
168
|
+
.catch((e) => { lastError = e?.message || String(e); logger.warn?.(`[channels] telegram loop failed: ${lastError}`); })
|
|
169
|
+
.finally(() => {
|
|
170
|
+
running = false;
|
|
171
|
+
if (stopped) return;
|
|
172
|
+
const wait = RETRY_MS[Math.min(attempt++, RETRY_MS.length - 1)];
|
|
173
|
+
logger.warn?.(`[channels] telegram stopped unexpectedly — retrying in ${Math.round(wait / 1000)}s`);
|
|
174
|
+
setTimeout(() => { if (!stopped) spawnLoop(botToken); }, wait).unref?.();
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function start() {
|
|
179
|
+
if (running) return { ok: true, already: true };
|
|
180
|
+
await load();
|
|
181
|
+
const botToken = await readToken();
|
|
182
|
+
if (!botToken) return { ok: false, error: 'no bot token configured' };
|
|
183
|
+
if (settings.enabled === false) return { ok: false, error: 'channel is turned off' };
|
|
184
|
+
try {
|
|
185
|
+
bot = await verifyBot(botToken, { fetchImpl });
|
|
186
|
+
} catch (e) {
|
|
187
|
+
// A revoked or mistyped token must SAY so and stay stopped — a poll loop against a dead
|
|
188
|
+
// token is the failure that looks like "nobody has messaged me yet".
|
|
189
|
+
lastError = e?.message || String(e);
|
|
190
|
+
return { ok: false, error: lastError };
|
|
191
|
+
}
|
|
192
|
+
lastError = '';
|
|
193
|
+
attempt = 0;
|
|
194
|
+
stopped = false;
|
|
195
|
+
spawnLoop(botToken);
|
|
196
|
+
return { ok: true, bot };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
/** Start on host boot, but only if the user already connected one. Never throws. */
|
|
201
|
+
async startIfConfigured() {
|
|
202
|
+
await load();
|
|
203
|
+
if (!(await readToken()) || settings.enabled === false) return { ok: false, skipped: true };
|
|
204
|
+
return start().catch((e) => ({ ok: false, error: e?.message || String(e) }));
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
start,
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Connect a bot: verify the token FIRST, then persist it 0600 and start. Verifying before
|
|
211
|
+
* writing means a typo never leaves a dead secret on disk.
|
|
212
|
+
*/
|
|
213
|
+
async connect({ token: botToken, agent, privacy, tier } = {}) {
|
|
214
|
+
await load();
|
|
215
|
+
const verified = await verifyBot(botToken, { fetchImpl }); // throws with a readable reason
|
|
216
|
+
await writeFile(tokenFile, String(botToken).trim(), { mode: 0o600 });
|
|
217
|
+
settings = {
|
|
218
|
+
...settings,
|
|
219
|
+
...(agent ? { agent } : {}),
|
|
220
|
+
...(privacy ? { privacy } : {}),
|
|
221
|
+
...(tier ? { tier } : {}),
|
|
222
|
+
enabled: true,
|
|
223
|
+
};
|
|
224
|
+
await saveSettings();
|
|
225
|
+
await this.stop();
|
|
226
|
+
const r = await start();
|
|
227
|
+
if (!r.ok) throw new Error(r.error);
|
|
228
|
+
return { bot: verified, settings: { ...settings } };
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Mint a one-time enrollment code and the link that redeems it in one tap. The code is the
|
|
233
|
+
* fallback for someone reading it off a screen; the link is the path most people take.
|
|
234
|
+
*/
|
|
235
|
+
async pair({ ttlMs = 10 * 60_000 } = {}) {
|
|
236
|
+
await load();
|
|
237
|
+
if (!bot) throw new Error('connect a bot first');
|
|
238
|
+
const code = pairing.requestCode({ ttlMs });
|
|
239
|
+
await savePairing();
|
|
240
|
+
return { code, link: pairLink(bot.username, code), expiresAt: now() + ttlMs, bot: { ...bot } };
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Pre-pair without a code — an operator allow-list for scripted setups. Explicit by
|
|
245
|
+
* design: there is no path here that enrolls a chat because it messaged you.
|
|
246
|
+
*/
|
|
247
|
+
async allow(actorId, { reach = 'trusted' } = {}) {
|
|
248
|
+
await load();
|
|
249
|
+
pairing.allow(actorId, { reach });
|
|
250
|
+
await savePairing();
|
|
251
|
+
return { actorId, reach };
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
/** Revoke one phone. Takes effect on its NEXT message — nothing is cached per chat. */
|
|
255
|
+
async unpair(actorId) {
|
|
256
|
+
await load();
|
|
257
|
+
const removed = pairing.revoke(actorId);
|
|
258
|
+
await savePairing();
|
|
259
|
+
return { removed };
|
|
260
|
+
},
|
|
261
|
+
|
|
262
|
+
async update(patch = {}) {
|
|
263
|
+
await load();
|
|
264
|
+
const next = { ...settings };
|
|
265
|
+
for (const k of ['agent', 'model', 'provider', 'gatewayUrl', 'privacy', 'tier', 'system']) {
|
|
266
|
+
if (patch[k] != null) next[k] = patch[k];
|
|
267
|
+
}
|
|
268
|
+
// Picking one target unpicks the other. Without this a stale `model` would silently win
|
|
269
|
+
// over the agent the user just chose, and the screen would disagree with the machine.
|
|
270
|
+
if (patch.agent != null && patch.model == null) { next.model = ''; next.provider = ''; }
|
|
271
|
+
if (patch.model) next.agent = '';
|
|
272
|
+
settings = next;
|
|
273
|
+
await saveSettings();
|
|
274
|
+
// NO RESTART. The loop reads settings per message through `route`, and restarting it
|
|
275
|
+
// would discard every conversation's history and vault — which is exactly what made
|
|
276
|
+
// changing the model answer the next question with "this looks like a fresh session".
|
|
277
|
+
// The only change that still needs a restart is a new bot token, and connect() does that.
|
|
278
|
+
return { settings: { ...settings } };
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
/** Stop the loop. `forget` also deletes the token and every pairing — a real disconnect. */
|
|
282
|
+
async stop({ forget = false } = {}) {
|
|
283
|
+
stopped = true;
|
|
284
|
+
controller?.abort();
|
|
285
|
+
controller = null;
|
|
286
|
+
running = false;
|
|
287
|
+
if (forget) {
|
|
288
|
+
await load();
|
|
289
|
+
settings = { ...settings, enabled: false };
|
|
290
|
+
await saveSettings();
|
|
291
|
+
await rm(tokenFile, { force: true });
|
|
292
|
+
for (const p of pairing.list()) pairing.revoke(p.actorId);
|
|
293
|
+
await savePairing();
|
|
294
|
+
bot = null;
|
|
295
|
+
}
|
|
296
|
+
return { ok: true };
|
|
297
|
+
},
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Everything a settings screen needs and nothing it must not have: no bot token, ever.
|
|
301
|
+
* `configured` says a token exists; `running` says the loop is actually polling.
|
|
302
|
+
*/
|
|
303
|
+
async status() {
|
|
304
|
+
await load();
|
|
305
|
+
const configured = !!(await readToken());
|
|
306
|
+
return {
|
|
307
|
+
channel: 'telegram',
|
|
308
|
+
configured,
|
|
309
|
+
enabled: settings.enabled !== false,
|
|
310
|
+
running,
|
|
311
|
+
bot: bot ? { ...bot } : null,
|
|
312
|
+
error: lastError,
|
|
313
|
+
paired: pairing.list(),
|
|
314
|
+
settings: {
|
|
315
|
+
agent: settings.agent,
|
|
316
|
+
model: settings.model || '',
|
|
317
|
+
provider: settings.provider || '',
|
|
318
|
+
gatewayUrl: settings.gatewayUrl || DEFAULT_GATEWAY_URL,
|
|
319
|
+
privacy: settings.privacy,
|
|
320
|
+
tier: settings.tier,
|
|
321
|
+
},
|
|
322
|
+
// Which transport a message will actually take, so a screen can say so rather than
|
|
323
|
+
// inferring it from two fields and getting it wrong.
|
|
324
|
+
via: settings.model ? 'gateway' : 'bridge',
|
|
325
|
+
};
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
}
|
package/src/stream.js
CHANGED
|
@@ -26,6 +26,26 @@ export function foldEvent(state, ev) {
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Fold one OpenAI-style chunk (what the gateway streams) into the same reply state, so a
|
|
31
|
+
* caller cannot tell which transport it has. Shares parseSse: `data: [DONE]` is not JSON and
|
|
32
|
+
* is dropped there, which is why the transport marks `done` itself when the stream ends.
|
|
33
|
+
*/
|
|
34
|
+
export function foldOpenAiEvent(state, ev) {
|
|
35
|
+
if (ev?.type === 'run') return { ...state, runId: ev.id || state.runId };
|
|
36
|
+
// An error can arrive as a streamed frame rather than an HTTP status.
|
|
37
|
+
if (ev?.error) return { ...state, done: true, error: ev.error.message || String(ev.error) };
|
|
38
|
+
const choice = ev?.choices?.[0];
|
|
39
|
+
let next = state;
|
|
40
|
+
const delta = choice?.delta?.content;
|
|
41
|
+
if (typeof delta === 'string' && delta) next = { ...next, text: next.text + delta };
|
|
42
|
+
// Non-streaming replies (a gateway destination that cannot stream) carry the whole message.
|
|
43
|
+
const whole = choice?.message?.content;
|
|
44
|
+
if (!next.text && typeof whole === 'string' && whole) next = { ...next, text: whole };
|
|
45
|
+
if (choice?.finish_reason) next = { ...next, done: true };
|
|
46
|
+
return next;
|
|
47
|
+
}
|
|
48
|
+
|
|
29
49
|
/**
|
|
30
50
|
* Pull complete SSE events out of a growing buffer. Returns the parsed events and the
|
|
31
51
|
* UNCONSUMED tail (a partial frame still arriving), which the caller prepends next read.
|