@chatpanel/channels 0.1.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/LICENSE +168 -0
- package/README.md +102 -0
- package/bin/chatpanel-channels.js +69 -0
- package/index.js +18 -0
- package/package.json +54 -0
- package/src/adapters/telegram.js +206 -0
- package/src/bridge.js +50 -0
- package/src/config.js +35 -0
- package/src/eventlog.js +48 -0
- package/src/invoke.js +119 -0
- package/src/normalize.js +49 -0
- package/src/pairing.js +71 -0
- package/src/stream.js +77 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# PolyForm Shield License 1.0.0
|
|
2
|
+
|
|
3
|
+
<https://polyformproject.org/licenses/shield/1.0.0>
|
|
4
|
+
|
|
5
|
+
Required Notice: Copyright © 2026 ChatPanel (https://chatpanel.net)
|
|
6
|
+
|
|
7
|
+
Licensor Line of Business: ChatPanel — an AI browser side-panel, its local
|
|
8
|
+
bridge, and related developer tools and services (https://chatpanel.net)
|
|
9
|
+
|
|
10
|
+
## Acceptance
|
|
11
|
+
|
|
12
|
+
In order to get any license under these terms, you must agree
|
|
13
|
+
to them as both strict obligations and conditions to all
|
|
14
|
+
your licenses.
|
|
15
|
+
|
|
16
|
+
## Copyright License
|
|
17
|
+
|
|
18
|
+
The licensor grants you a copyright license for the
|
|
19
|
+
software to do everything you might do with the software
|
|
20
|
+
that would otherwise infringe the licensor's copyright
|
|
21
|
+
in it for any permitted purpose. However, you may
|
|
22
|
+
only distribute the software according to [Distribution
|
|
23
|
+
License](#distribution-license) and make changes or new works
|
|
24
|
+
based on the software according to [Changes and New Works
|
|
25
|
+
License](#changes-and-new-works-license).
|
|
26
|
+
|
|
27
|
+
## Distribution License
|
|
28
|
+
|
|
29
|
+
The licensor grants you an additional copyright license to
|
|
30
|
+
distribute copies of the software. Your license to distribute
|
|
31
|
+
covers distributing the software with changes and new works
|
|
32
|
+
permitted by [Changes and New Works License](#changes-and-new-works-license).
|
|
33
|
+
|
|
34
|
+
## Notices
|
|
35
|
+
|
|
36
|
+
You must ensure that anyone who gets a copy of any part of
|
|
37
|
+
the software from you also gets a copy of these terms or the
|
|
38
|
+
URL for them above, as well as copies of any plain-text lines
|
|
39
|
+
beginning with `Required Notice:` that the licensor provided
|
|
40
|
+
with the software. For example:
|
|
41
|
+
|
|
42
|
+
> Required Notice: Copyright © 2026 ChatPanel (https://chatpanel.net)
|
|
43
|
+
|
|
44
|
+
## Changes and New Works License
|
|
45
|
+
|
|
46
|
+
The licensor grants you an additional copyright license to
|
|
47
|
+
make changes and new works based on the software for any
|
|
48
|
+
permitted purpose.
|
|
49
|
+
|
|
50
|
+
## Patent License
|
|
51
|
+
|
|
52
|
+
The licensor grants you a patent license for the software that
|
|
53
|
+
covers patent claims the licensor can license, or becomes able
|
|
54
|
+
to license, that you would infringe by using the software.
|
|
55
|
+
|
|
56
|
+
## Noncompete
|
|
57
|
+
|
|
58
|
+
Any purpose is a permitted purpose, except for providing any
|
|
59
|
+
product that competes with the software or any product the
|
|
60
|
+
licensor or any of its affiliates provides using the software.
|
|
61
|
+
|
|
62
|
+
## Competition
|
|
63
|
+
|
|
64
|
+
Goods and services compete even when they provide functionality
|
|
65
|
+
through different kinds of interfaces or for different technical
|
|
66
|
+
platforms. Applications can compete with services, libraries
|
|
67
|
+
with plugins, frameworks with development tools, and so on,
|
|
68
|
+
even if they're written in different programming languages
|
|
69
|
+
or for different computer architectures. Goods and services
|
|
70
|
+
compete even when provided free of charge. If you market a
|
|
71
|
+
product as a practical substitute for the software or another
|
|
72
|
+
product, it definitely competes.
|
|
73
|
+
|
|
74
|
+
## New Products
|
|
75
|
+
|
|
76
|
+
If you are using the software to provide a product that does
|
|
77
|
+
not compete, but the licensor or any of its affiliates brings
|
|
78
|
+
your product into competition by providing a new version of
|
|
79
|
+
the software or another product using the software, you may
|
|
80
|
+
continue using versions of the software available under these
|
|
81
|
+
terms beforehand to provide your competing product, but not
|
|
82
|
+
any later versions.
|
|
83
|
+
|
|
84
|
+
## Discontinued Products
|
|
85
|
+
|
|
86
|
+
You may begin using the software to compete with a product
|
|
87
|
+
or service that the licensor or any of its affiliates has
|
|
88
|
+
stopped providing, unless the licensor includes a plain-text
|
|
89
|
+
line beginning with `Licensor Line of Business:` with the
|
|
90
|
+
software that mentions that line of business. For example:
|
|
91
|
+
|
|
92
|
+
> Licensor Line of Business: ChatPanel — an AI browser side-panel, its local
|
|
93
|
+
> bridge, and related developer tools and services (https://chatpanel.net)
|
|
94
|
+
|
|
95
|
+
## Sales of Business
|
|
96
|
+
|
|
97
|
+
If the licensor or any of its affiliates sells a line of
|
|
98
|
+
business developing the software or using the software
|
|
99
|
+
to provide a product, the buyer can also enforce
|
|
100
|
+
Noncompete for that product.
|
|
101
|
+
|
|
102
|
+
## Fair Use
|
|
103
|
+
|
|
104
|
+
You may have "fair use" rights for the software under the
|
|
105
|
+
law. These terms do not limit them.
|
|
106
|
+
|
|
107
|
+
## No Other Rights
|
|
108
|
+
|
|
109
|
+
These terms do not allow you to sublicense or transfer any of
|
|
110
|
+
your licenses to anyone else, or prevent the licensor from
|
|
111
|
+
granting licenses to anyone else. These terms do not imply
|
|
112
|
+
any other licenses.
|
|
113
|
+
|
|
114
|
+
## Patent Defense
|
|
115
|
+
|
|
116
|
+
If you make any written claim that the software infringes or
|
|
117
|
+
contributes to infringement of any patent, your patent license
|
|
118
|
+
for the software granted under these terms ends immediately. If
|
|
119
|
+
your company makes such a claim, your patent license ends
|
|
120
|
+
immediately for work on behalf of your company.
|
|
121
|
+
|
|
122
|
+
## Violations
|
|
123
|
+
|
|
124
|
+
The first time you are notified in writing that you have
|
|
125
|
+
violated any of these terms, or done anything with the software
|
|
126
|
+
not covered by your licenses, your licenses can nonetheless
|
|
127
|
+
continue if you come into full compliance with these terms,
|
|
128
|
+
and take practical steps to correct past violations, within
|
|
129
|
+
32 days of receiving notice. Otherwise, all your licenses
|
|
130
|
+
end immediately.
|
|
131
|
+
|
|
132
|
+
## No Liability
|
|
133
|
+
|
|
134
|
+
***As far as the law allows, the software comes as is, without
|
|
135
|
+
any warranty or condition, and the licensor will not be liable
|
|
136
|
+
to you for any damages arising out of these terms or the use
|
|
137
|
+
or nature of the software, under any kind of legal claim.***
|
|
138
|
+
|
|
139
|
+
## Definitions
|
|
140
|
+
|
|
141
|
+
The **licensor** is the individual or entity offering these
|
|
142
|
+
terms, and the **software** is the software the licensor makes
|
|
143
|
+
available under these terms.
|
|
144
|
+
|
|
145
|
+
A **product** can be a good or service, or a combination
|
|
146
|
+
of them.
|
|
147
|
+
|
|
148
|
+
**You** refers to the individual or entity agreeing to these
|
|
149
|
+
terms.
|
|
150
|
+
|
|
151
|
+
**Your company** is any legal entity, sole proprietorship,
|
|
152
|
+
or other kind of organization that you work for, plus all
|
|
153
|
+
its affiliates.
|
|
154
|
+
|
|
155
|
+
**Affiliates** means the other organizations that an
|
|
156
|
+
organization has control over, is under the control of, or is
|
|
157
|
+
under common control with.
|
|
158
|
+
|
|
159
|
+
**Control** means ownership of substantially all the assets of
|
|
160
|
+
an entity, or the power to direct its management and policies
|
|
161
|
+
by vote, contract, or otherwise. Control can be direct or
|
|
162
|
+
indirect.
|
|
163
|
+
|
|
164
|
+
**Your licenses** are all the licenses granted to you for the
|
|
165
|
+
software under these terms.
|
|
166
|
+
|
|
167
|
+
**Use** means anything you do with the software requiring one
|
|
168
|
+
of your licenses.
|
package/README.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# @chatpanel/channels
|
|
2
|
+
|
|
3
|
+
Drive your local ChatPanel agent from an external messaging surface — today **Telegram**,
|
|
4
|
+
next **WhatsApp**. A phone message becomes an agent turn on your machine, streamed back to the
|
|
5
|
+
chat, with PII redacted before anything leaves for the model and every run written to an audit
|
|
6
|
+
log.
|
|
7
|
+
|
|
8
|
+
Design doc: [`chatpanel/docs/feature-f7-remote-channels.md`](../chatpanel/docs/feature-f7-remote-channels.md).
|
|
9
|
+
|
|
10
|
+
## Why this shape
|
|
11
|
+
|
|
12
|
+
The Telegram adapter uses `getUpdates` long-poll, which is **outbound-only** — the same
|
|
13
|
+
property as Claude Code Remote Control: your machine never opens an inbound port, works behind
|
|
14
|
+
NAT, needs no tunnel or public IP. The only hop that reaches the agent is the bridge on
|
|
15
|
+
`127.0.0.1:4319`, and it never leaves the box.
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
Telegram getUpdates ─long-poll→ @chatpanel/channels ─POST /chat (bridge token)→ chatpanel-bridge
|
|
19
|
+
↑ editMessageText (throttled) ←── SSE delta/done ──────────────────────────────────┘
|
|
20
|
+
│ normalize → pairing gate → pii.redact → capability(actor.kind:'channel')
|
|
21
|
+
▼ append capability.invoked / privacy.redacted / privacy.egress
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Layout
|
|
25
|
+
|
|
26
|
+
| file | role | pure? |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| `src/normalize.js` | platform message → one normalized shape | ✅ |
|
|
29
|
+
| `src/pairing.js` | who may drive it, and their `reach` ceiling | ✅ |
|
|
30
|
+
| `src/invoke.js` | normalized → capability invocation + pii redact + audit events | ✅ |
|
|
31
|
+
| `src/stream.js` | bridge SSE → folded reply (+ Telegram split/throttle) | ✅ |
|
|
32
|
+
| `src/bridge.js` | POST `/chat` (SSE) + `/cancel` on the local bridge | net |
|
|
33
|
+
| `src/eventlog.js` | append-only JSONL sink over `@chatpanel/events` appender | net |
|
|
34
|
+
| `src/adapters/telegram.js` | `getUpdates` long-poll transport (the LOCAL shape) | net |
|
|
35
|
+
| `bin/chatpanel-channels.js` | CLI that wires it together | — |
|
|
36
|
+
|
|
37
|
+
The pure core is unit-testable without a bot or a bridge (`npm test` = 26 tests, incl. a
|
|
38
|
+
real-socket SSE integration test).
|
|
39
|
+
|
|
40
|
+
Conversations are **multi-turn**: each chat keeps a bounded, redacted history so a follow-up
|
|
41
|
+
("…and the second one?") resolves against the prior answer. `/new` forgets it.
|
|
42
|
+
|
|
43
|
+
## Run
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
npm install -g @chatpanel/channels # or npx @chatpanel/channels telegram
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
1. Start the bridge once (it writes `~/.chatpanel/bridge-token`): `chatpanel-bridge`.
|
|
50
|
+
2. Create a bot with [@BotFather](https://t.me/BotFather), copy its token.
|
|
51
|
+
3. Run the adapter:
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
TELEGRAM_BOT_TOKEN=123:abc chatpanel-channels telegram
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
4. Message your bot. It will say *not paired*. Pair the chat:
|
|
58
|
+
- **Bootstrap (dev):** set `CHANNELS_ALLOW=<yourChatId>` before starting, or
|
|
59
|
+
- **Codes (real):** mint a one-time code (`pairing.requestCode()` — surfaced in the
|
|
60
|
+
extension) and send `/pair <code>` from the phone.
|
|
61
|
+
|
|
62
|
+
Chat commands: `/pair <code>`, `/new` (forget the conversation + fresh privacy vault), `/stop`, `/help`.
|
|
63
|
+
|
|
64
|
+
### Env
|
|
65
|
+
|
|
66
|
+
| var | default | meaning |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| `TELEGRAM_BOT_TOKEN` | — | BotFather token (required) |
|
|
69
|
+
| `CHANNELS_ALLOW` | — | comma list of chat ids to pre-pair (bootstrap) |
|
|
70
|
+
| `CHANNELS_AGENT` | `claude` | bridge engine id (`claude`, `codex`, …) |
|
|
71
|
+
| `CHANNELS_PRIVACY` | `standard` | `standard` restores real values for you · `strict` keeps `[[PERSON_1]]` in the reply |
|
|
72
|
+
| `CHANNELS_PII_TIER` | `basic` | `basic` regex · `full` (needs a roster) also pseudonymizes people/orgs |
|
|
73
|
+
| `CHATPANEL_BRIDGE_URL` | `http://127.0.0.1:4319` | bridge address |
|
|
74
|
+
| `CHATPANEL_BRIDGE_TOKEN` | — | overrides the token file |
|
|
75
|
+
|
|
76
|
+
## Security posture (read before shipping)
|
|
77
|
+
|
|
78
|
+
This is the honest §7 of the design doc, made concrete:
|
|
79
|
+
|
|
80
|
+
- **PII redaction on egress is on by default and mandatory.** Telegram bot traffic is **not**
|
|
81
|
+
end-to-end encrypted, so inbound text is redacted (`@chatpanel/pii`) before it reaches the
|
|
82
|
+
agent/model. `CHANNELS_PRIVACY=strict` additionally keeps placeholders in the *reply* so the
|
|
83
|
+
provider never sees a real value either.
|
|
84
|
+
- **Pairing is authentication, not authorization.** A chat-id allowlist + a paired code proves
|
|
85
|
+
*who* sent a message. It does **not** bound *what* that message may do. Prompt-injection →
|
|
86
|
+
tool execution is the real risk, because the bridge `/chat` runs shell/filesystem tools.
|
|
87
|
+
- **`reach` is carried but tool-scoping is the next layer** (not yet built). Until it lands,
|
|
88
|
+
run against a bridge whose agent is read-only / ask-mode for anything destructive. Do not
|
|
89
|
+
point a write-capable, auto-approving agent at an untrusted chat.
|
|
90
|
+
- **Secrets:** the BotFather token and the bridge token are the crown jewels. The bridge token
|
|
91
|
+
is read `0600` from `~/.chatpanel`; keep the bot token out of shell history (use a file / a
|
|
92
|
+
keychain in a shipped build).
|
|
93
|
+
|
|
94
|
+
Every run leaves `capability.invoked` / `privacy.redacted` / `privacy.egress` in
|
|
95
|
+
`~/.chatpanel/channels/events.jsonl` — the audit trail neither Claude Code Remote nor Hermes
|
|
96
|
+
has.
|
|
97
|
+
|
|
98
|
+
## Status
|
|
99
|
+
|
|
100
|
+
**Local (Telegram) shape: built.** The WhatsApp/Cloudflare relay shape and the
|
|
101
|
+
per-actor tool-authorization policy are designed but not built — see the feature doc, §5 build
|
|
102
|
+
order.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// chatpanel-channels — run a messaging adapter that drives your local ChatPanel agent.
|
|
3
|
+
//
|
|
4
|
+
// chatpanel-channels telegram
|
|
5
|
+
//
|
|
6
|
+
// Env:
|
|
7
|
+
// TELEGRAM_BOT_TOKEN BotFather token (required) [or BOT_TOKEN]
|
|
8
|
+
// CHANNELS_ALLOW comma list of chat ids to pre-pair (bootstrap; optional)
|
|
9
|
+
// CHANNELS_AGENT bridge engine id (default: claude)
|
|
10
|
+
// CHANNELS_SYSTEM system prompt for the agent (optional)
|
|
11
|
+
// CHANNELS_PRIVACY 'standard' (restore for you) | 'strict' (keep placeholders)
|
|
12
|
+
// CHANNELS_PII_TIER 'basic' (regex) | 'full' (needs a roster) — default basic
|
|
13
|
+
// CHATPANEL_BRIDGE_URL default http://127.0.0.1:4319
|
|
14
|
+
// CHATPANEL_BRIDGE_TOKEN overrides ~/.chatpanel/bridge-token
|
|
15
|
+
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
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
|
+
}
|
|
26
|
+
|
|
27
|
+
async function main() {
|
|
28
|
+
const cmd = process.argv[2] || 'telegram';
|
|
29
|
+
if (cmd === '--help' || cmd === '-h') { console.log('usage: chatpanel-channels telegram'); return; }
|
|
30
|
+
if (cmd !== 'telegram') { console.error(`unknown command '${cmd}'. try: chatpanel-channels telegram`); process.exit(1); }
|
|
31
|
+
|
|
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
|
+
const baseUrl = bridgeBaseUrl();
|
|
36
|
+
const token = await readBridgeToken();
|
|
37
|
+
const dataDir = channelsDataDir();
|
|
38
|
+
await mkdir(dataDir, { recursive: true });
|
|
39
|
+
|
|
40
|
+
const pairingFile = path.join(dataDir, 'pairing.json');
|
|
41
|
+
const pairing = createPairingStore(await loadPairing(pairingFile));
|
|
42
|
+
const savePairing = () => writeFile(pairingFile, JSON.stringify(pairing.toJSON(), null, 2), { mode: 0o600 });
|
|
43
|
+
|
|
44
|
+
// Bootstrap allow-list (prototype convenience). Each becomes a paired 'trusted' actor —
|
|
45
|
+
// explicitly, never silent enrollment. Prefer /pair codes for real use.
|
|
46
|
+
for (const chatId of (process.env.CHANNELS_ALLOW || '').split(',').map((s) => s.trim()).filter(Boolean)) {
|
|
47
|
+
pairing.allow(`telegram:${chatId}`);
|
|
48
|
+
}
|
|
49
|
+
await savePairing();
|
|
50
|
+
|
|
51
|
+
const appender = await createEventLog({ file: path.join(dataDir, 'events.jsonl'), host: 'channel' });
|
|
52
|
+
|
|
53
|
+
const controller = new AbortController();
|
|
54
|
+
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => controller.abort());
|
|
55
|
+
|
|
56
|
+
const agent = process.env.CHANNELS_AGENT || 'claude';
|
|
57
|
+
const privacy = process.env.CHANNELS_PRIVACY || 'standard';
|
|
58
|
+
console.log(`[chatpanel-channels] telegram → bridge ${baseUrl} (agent=${agent}, privacy=${privacy}, tier=${process.env.CHANNELS_PII_TIER || 'basic'})`);
|
|
59
|
+
await startTelegram({
|
|
60
|
+
botToken, baseUrl, token, pairing, savePairing, appender,
|
|
61
|
+
agent,
|
|
62
|
+
system: process.env.CHANNELS_SYSTEM || '',
|
|
63
|
+
redact: { tier: process.env.CHANNELS_PII_TIER || 'basic' },
|
|
64
|
+
privacy,
|
|
65
|
+
signal: controller.signal,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
main().catch((e) => { console.error(e?.message || e); process.exit(1); });
|
package/index.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// @chatpanel/channels — drive ChatPanel agents from external messaging surfaces.
|
|
2
|
+
//
|
|
3
|
+
// The pure core is platform-agnostic and unit-testable without a bot or a bridge:
|
|
4
|
+
// normalize.js platform message → one normalized shape
|
|
5
|
+
// pairing.js who may drive it, and how far a request may travel (reach)
|
|
6
|
+
// invoke.js normalized message → capability invocation + pii redaction + audit events
|
|
7
|
+
// stream.js the bridge's SSE stream → a folded reply (+ Telegram splitting/throttling)
|
|
8
|
+
// Adapters are dumb transport; the CLI (bin/) wires them together.
|
|
9
|
+
//
|
|
10
|
+
// import { normalizeTelegram, createPairingStore, redactInbound } from '@chatpanel/channels';
|
|
11
|
+
|
|
12
|
+
export * from './src/normalize.js';
|
|
13
|
+
export * from './src/pairing.js';
|
|
14
|
+
export * from './src/invoke.js';
|
|
15
|
+
export * from './src/stream.js';
|
|
16
|
+
export * as bridge from './src/bridge.js';
|
|
17
|
+
export { createEventLog, nullEventLog } from './src/eventlog.js';
|
|
18
|
+
export { startTelegram } from './src/adapters/telegram.js';
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@chatpanel/channels",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Drive ChatPanel agents from external messaging surfaces (Telegram, WhatsApp). One normalize→invoke→stream 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 — the same property as Claude Code Remote Control. Pure, dependency-free ESM.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./normalize.js": "./src/normalize.js",
|
|
10
|
+
"./pairing.js": "./src/pairing.js",
|
|
11
|
+
"./invoke.js": "./src/invoke.js",
|
|
12
|
+
"./stream.js": "./src/stream.js",
|
|
13
|
+
"./bridge.js": "./src/bridge.js",
|
|
14
|
+
"./eventlog.js": "./src/eventlog.js",
|
|
15
|
+
"./adapters/telegram.js": "./src/adapters/telegram.js"
|
|
16
|
+
},
|
|
17
|
+
"bin": {
|
|
18
|
+
"chatpanel-channels": "bin/chatpanel-channels.js"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"index.js",
|
|
22
|
+
"src",
|
|
23
|
+
"bin",
|
|
24
|
+
"LICENSE",
|
|
25
|
+
"README.md"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "node --test tests/*.test.js",
|
|
29
|
+
"start": "node bin/chatpanel-channels.js telegram"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@chatpanel/events": "^0.21.0",
|
|
33
|
+
"@chatpanel/pii": "^0.3.0"
|
|
34
|
+
},
|
|
35
|
+
"keywords": [
|
|
36
|
+
"chatpanel",
|
|
37
|
+
"telegram",
|
|
38
|
+
"whatsapp",
|
|
39
|
+
"remote-control",
|
|
40
|
+
"agent",
|
|
41
|
+
"privacy",
|
|
42
|
+
"pii"
|
|
43
|
+
],
|
|
44
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "git+https://github.com/chatpanel/chatpanel-channels.git"
|
|
48
|
+
},
|
|
49
|
+
"homepage": "https://chatpanel.net",
|
|
50
|
+
"engines": {
|
|
51
|
+
"node": ">=20"
|
|
52
|
+
},
|
|
53
|
+
"sideEffects": false
|
|
54
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Telegram adapter — the LOCAL shape (§3 of feature-f7). getUpdates long-poll is
|
|
2
|
+
// OUTBOUND-ONLY, so this mirrors Claude Code Remote Control's key property: the machine never
|
|
3
|
+
// opens an inbound port, works behind NAT, needs no tunnel. The adapter is dumb transport;
|
|
4
|
+
// normalize → gate → redact → invoke → stream → restore is the shared core it drives.
|
|
5
|
+
|
|
6
|
+
import { normalizeTelegram, actorId } from '../normalize.js';
|
|
7
|
+
import {
|
|
8
|
+
buildInvocation, redactInbound, restoreOutbound, appendTurn,
|
|
9
|
+
appendInvoked, appendRedacted, appendEgress,
|
|
10
|
+
} from '../invoke.js';
|
|
11
|
+
import * as bridge from '../bridge.js';
|
|
12
|
+
import { splitForTelegram, createGate } from '../stream.js';
|
|
13
|
+
import { createVault } from '@chatpanel/pii';
|
|
14
|
+
|
|
15
|
+
const TG_HOST = 'api.telegram.org';
|
|
16
|
+
|
|
17
|
+
const HELP = [
|
|
18
|
+
'ChatPanel — drive your local agent from here.',
|
|
19
|
+
'',
|
|
20
|
+
'Send a message and I run it on your machine.',
|
|
21
|
+
'/pair <code> — enroll this chat (get the code in the ChatPanel extension)',
|
|
22
|
+
'/new — start fresh (forget this conversation + new privacy vault)',
|
|
23
|
+
'/stop — stop the current run',
|
|
24
|
+
'/help — this message',
|
|
25
|
+
].join('\n');
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Start the long-poll loop. Returns the loop promise; abort `signal` to stop it. Everything
|
|
29
|
+
* it needs is injected — a bot token, the bridge address+token, a pairing store, an event sink
|
|
30
|
+
* — so nothing here reads a secret or reaches for global state.
|
|
31
|
+
*/
|
|
32
|
+
export function startTelegram({
|
|
33
|
+
botToken,
|
|
34
|
+
baseUrl,
|
|
35
|
+
token, // bridge token
|
|
36
|
+
pairing, // createPairingStore(...)
|
|
37
|
+
savePairing = async () => {},
|
|
38
|
+
appender, // createEventLog(...) or nullEventLog()
|
|
39
|
+
agent = 'claude',
|
|
40
|
+
system = '',
|
|
41
|
+
redact = { tier: 'basic' },
|
|
42
|
+
privacy = 'standard',
|
|
43
|
+
logger = console,
|
|
44
|
+
signal, // AbortSignal to stop the whole loop
|
|
45
|
+
}) {
|
|
46
|
+
const api = `https://${TG_HOST}/bot${botToken}`;
|
|
47
|
+
const fileApi = `https://${TG_HOST}/file/bot${botToken}`;
|
|
48
|
+
// Per-chat session: a persistent vault (stable placeholders across turns), the live run id
|
|
49
|
+
// (/stop), and the redacted conversation history (multi-turn context).
|
|
50
|
+
const chats = new Map(); // chatId -> { vault, runId, history }
|
|
51
|
+
const freshChat = () => ({ vault: createVault(), runId: null, history: [] });
|
|
52
|
+
const chatState = (id) => {
|
|
53
|
+
if (!chats.has(id)) chats.set(id, freshChat());
|
|
54
|
+
return chats.get(id);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
async function tg(method, body) {
|
|
58
|
+
const res = await fetch(`${api}/${method}`, {
|
|
59
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal,
|
|
60
|
+
});
|
|
61
|
+
return res.json();
|
|
62
|
+
}
|
|
63
|
+
const send = (chatId, text) => tg('sendMessage', { chat_id: chatId, text });
|
|
64
|
+
const edit = (chatId, messageId, text) => tg('editMessageText', { chat_id: chatId, message_id: messageId, text });
|
|
65
|
+
|
|
66
|
+
// Resolve Telegram photos to the { dataUrl } shape the bridge's engines expect (Claude Code
|
|
67
|
+
// writes them to temp files and reads them as vision). A failed fetch drops that one image
|
|
68
|
+
// rather than failing the whole turn.
|
|
69
|
+
async function toBridgeImages(photos) {
|
|
70
|
+
const out = [];
|
|
71
|
+
for (const p of photos || []) {
|
|
72
|
+
try {
|
|
73
|
+
const info = await tg('getFile', { file_id: p.fileId });
|
|
74
|
+
const fp = info?.result?.file_path;
|
|
75
|
+
if (!fp) continue;
|
|
76
|
+
const bin = await fetch(`${fileApi}/${fp}`, { signal });
|
|
77
|
+
const buf = Buffer.from(await bin.arrayBuffer());
|
|
78
|
+
const mime = /\.png$/i.test(fp) ? 'image/png' : /\.webp$/i.test(fp) ? 'image/webp' : 'image/jpeg';
|
|
79
|
+
out.push({ dataUrl: `data:${mime};base64,${buf.toString('base64')}` });
|
|
80
|
+
} catch (e) { logger.warn?.(`[telegram] image fetch failed: ${e?.message || e}`); }
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function handleCommand(norm) {
|
|
86
|
+
const id = actorId('telegram', norm.chatId);
|
|
87
|
+
const { name, args } = norm.command;
|
|
88
|
+
if (name === 'help' || name === 'start') return void send(norm.chatId, HELP);
|
|
89
|
+
if (name === 'pair') {
|
|
90
|
+
const r = pairing.redeem(id, args);
|
|
91
|
+
await savePairing();
|
|
92
|
+
return void send(norm.chatId, r.ok ? `✅ paired (reach: ${r.reach}). Send me anything.` : `⛔ ${r.reason}`);
|
|
93
|
+
}
|
|
94
|
+
if (name === 'new') {
|
|
95
|
+
chats.set(norm.chatId, freshChat());
|
|
96
|
+
return void send(norm.chatId, '🧹 fresh conversation.');
|
|
97
|
+
}
|
|
98
|
+
if (name === 'stop') {
|
|
99
|
+
const st = chatState(norm.chatId);
|
|
100
|
+
const ok = await bridge.cancel(st.runId, { baseUrl, token });
|
|
101
|
+
st.runId = null;
|
|
102
|
+
return void send(norm.chatId, ok ? '⏹ stopped.' : 'nothing running.');
|
|
103
|
+
}
|
|
104
|
+
return void send(norm.chatId, `unknown command /${name} — try /help`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function handleMessage(norm) {
|
|
108
|
+
const id = actorId('telegram', norm.chatId);
|
|
109
|
+
// AUTHENTICATION gate: an unpaired sender cannot drive anything. This proves WHO, not WHAT
|
|
110
|
+
// — tool scoping (reach → per-actor allowlist) is the next layer and lives above the bridge.
|
|
111
|
+
const reach = pairing.reachOf(id);
|
|
112
|
+
if (!reach) {
|
|
113
|
+
return void send(norm.chatId, '🔒 not paired. Get a code in the ChatPanel extension, then send: /pair <code>');
|
|
114
|
+
}
|
|
115
|
+
if (!norm.text && !norm.photos.length) return;
|
|
116
|
+
const st = chatState(norm.chatId);
|
|
117
|
+
|
|
118
|
+
// Contract + audit BEFORE anything leaves for the agent.
|
|
119
|
+
let invocation;
|
|
120
|
+
try { invocation = buildInvocation(norm); }
|
|
121
|
+
catch (e) { return void send(norm.chatId, `⚠️ ${e.message}`); }
|
|
122
|
+
|
|
123
|
+
const { redacted, counts } = redactInbound(norm.text, st.vault, redact);
|
|
124
|
+
await appendInvoked(appender, invocation);
|
|
125
|
+
await appendRedacted(appender, counts);
|
|
126
|
+
|
|
127
|
+
const images = await toBridgeImages(norm.photos);
|
|
128
|
+
const placeholder = await tg('sendMessage', { chat_id: norm.chatId, text: '…' });
|
|
129
|
+
const replyId = placeholder?.result?.message_id;
|
|
130
|
+
const gate = createGate(1200); // ~1 edit/sec, Telegram's ceiling for a chat
|
|
131
|
+
let shown = '';
|
|
132
|
+
|
|
133
|
+
// Replay prior turns as context; the new (redacted) message is the live one. buildCliPrompt
|
|
134
|
+
// on the bridge renders all-but-last as history and the last as "answer this now".
|
|
135
|
+
const messages = [...st.history, { role: 'user', content: redacted }];
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
const finalState = await bridge.chat(
|
|
139
|
+
{ agent, system, messages, images, options: { reach } },
|
|
140
|
+
{
|
|
141
|
+
baseUrl, token, signal,
|
|
142
|
+
onEvent: (ev, state) => {
|
|
143
|
+
if (ev.type === 'run') st.runId = state.runId;
|
|
144
|
+
// Throttled live edit: restore placeholders so the user watches real text stream in.
|
|
145
|
+
if ((ev.type === 'delta' || ev.type === 'done') && replyId && (ev.type === 'done' || gate.ready())) {
|
|
146
|
+
const text = restoreOutbound(state.text, st.vault, { privacy });
|
|
147
|
+
const first = splitForTelegram(text || '…')[0];
|
|
148
|
+
if (first && first !== shown) { shown = first; edit(norm.chatId, replyId, first).catch(() => {}); }
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
);
|
|
153
|
+
st.runId = null;
|
|
154
|
+
|
|
155
|
+
const restored = restoreOutbound(finalState.text, st.vault, { privacy });
|
|
156
|
+
const chunks = splitForTelegram(finalState.error ? `⚠️ ${finalState.error}` : (restored || '(no output)'));
|
|
157
|
+
if (replyId) await edit(norm.chatId, replyId, chunks[0]);
|
|
158
|
+
else await send(norm.chatId, chunks[0]);
|
|
159
|
+
for (const extra of chunks.slice(1)) await send(norm.chatId, extra);
|
|
160
|
+
|
|
161
|
+
// Remember the exchange for follow-ups — the REDACTED forms, so what we replay next turn
|
|
162
|
+
// never carries a real value and stays consistent with the vault. A failed turn (error /
|
|
163
|
+
// no text) records nothing, so history never implies an answer that didn't happen.
|
|
164
|
+
if (!finalState.error && finalState.text) st.history = appendTurn(st.history, redacted, finalState.text);
|
|
165
|
+
|
|
166
|
+
// Egress to Telegram, recorded: 'standard' restores real values (a third party sees
|
|
167
|
+
// them → redacted:false); 'strict' keeps placeholders (redacted:true). controlled:false
|
|
168
|
+
// either way — Telegram is not ours.
|
|
169
|
+
await appendEgress(appender, { host: TG_HOST, redacted: privacy === 'strict', controlled: false });
|
|
170
|
+
} catch (e) {
|
|
171
|
+
st.runId = null;
|
|
172
|
+
const msg = `⚠️ ${e?.message || e}`;
|
|
173
|
+
if (replyId) await edit(norm.chatId, replyId, msg).catch(() => {});
|
|
174
|
+
else await send(norm.chatId, msg).catch(() => {});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function loop() {
|
|
179
|
+
let offset = 0;
|
|
180
|
+
logger.log?.('[telegram] long-poll started (outbound-only; no inbound port).');
|
|
181
|
+
while (!signal?.aborted) {
|
|
182
|
+
let updates;
|
|
183
|
+
try {
|
|
184
|
+
const res = await tg('getUpdates', { offset, timeout: 30, allowed_updates: ['message'] });
|
|
185
|
+
updates = res?.result || [];
|
|
186
|
+
} catch (e) {
|
|
187
|
+
if (signal?.aborted) break;
|
|
188
|
+
logger.warn?.(`[telegram] getUpdates failed: ${e?.message || e}; retrying in 2s`);
|
|
189
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
for (const u of updates) {
|
|
193
|
+
offset = u.update_id + 1;
|
|
194
|
+
const norm = normalizeTelegram(u);
|
|
195
|
+
if (!norm) continue;
|
|
196
|
+
// Fire-and-forget per message so one slow turn doesn't stall the poll; errors are
|
|
197
|
+
// caught so a single bad message never kills the loop.
|
|
198
|
+
const run = norm.command ? handleCommand(norm) : handleMessage(norm);
|
|
199
|
+
Promise.resolve(run).catch((e) => logger.error?.(`[telegram] handler error: ${e?.message || e}`));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
logger.log?.('[telegram] stopped.');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return loop();
|
|
206
|
+
}
|
package/src/bridge.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// The transport to the local agent: POST /chat (SSE) and POST /cancel on the bridge. The
|
|
2
|
+
// bridge binds 127.0.0.1:4319 and runs Claude Code / Codex / etc. as CLIs — so this is the ONE
|
|
3
|
+
// hop where the (already-redacted) conversation reaches the agent, and it never leaves the
|
|
4
|
+
// machine. A channel adapter is a non-browser local client, so it presents the per-install
|
|
5
|
+
// bridge token, exactly like any privileged caller.
|
|
6
|
+
|
|
7
|
+
import { parseSse, foldEvent, initialState } from './stream.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Drive one turn. Streams bridge events to onEvent(ev, state) as they arrive AND folds them
|
|
11
|
+
* into a final reply state it returns. `agent` is a bridge engine id ('claude','codex',…).
|
|
12
|
+
*/
|
|
13
|
+
export async function chat({ agent = 'claude', system = '', messages, images = [], options = {} }, {
|
|
14
|
+
baseUrl, token, signal, onEvent = () => {},
|
|
15
|
+
} = {}) {
|
|
16
|
+
const res = await fetch(`${baseUrl}/chat`, {
|
|
17
|
+
method: 'POST',
|
|
18
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
|
19
|
+
body: JSON.stringify({ agent, system, messages, images, options }),
|
|
20
|
+
signal,
|
|
21
|
+
});
|
|
22
|
+
if (!res.ok || !res.body) {
|
|
23
|
+
const detail = await res.text().catch(() => '');
|
|
24
|
+
throw new Error(`bridge /chat ${res.status}${detail ? `: ${detail.slice(0, 300)}` : ''}`);
|
|
25
|
+
}
|
|
26
|
+
let state = initialState();
|
|
27
|
+
let buffer = '';
|
|
28
|
+
const decoder = new TextDecoder();
|
|
29
|
+
for await (const chunk of res.body) {
|
|
30
|
+
buffer += typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true });
|
|
31
|
+
const { events, rest } = parseSse(buffer);
|
|
32
|
+
buffer = rest;
|
|
33
|
+
for (const ev of events) { state = foldEvent(state, ev); onEvent(ev, state); }
|
|
34
|
+
}
|
|
35
|
+
return state;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Stop a run by the id the bridge emitted as its first {type:'run'} event. Best-effort. */
|
|
39
|
+
export async function cancel(runId, { baseUrl, token } = {}) {
|
|
40
|
+
if (!runId) return false;
|
|
41
|
+
try {
|
|
42
|
+
const res = await fetch(`${baseUrl}/cancel`, {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
|
45
|
+
body: JSON.stringify({ id: runId }),
|
|
46
|
+
});
|
|
47
|
+
const out = await res.json().catch(() => ({}));
|
|
48
|
+
return !!out.cancelled;
|
|
49
|
+
} catch { return false; }
|
|
50
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Where things live and how the adapter authenticates. Kept tiny and in one place so a
|
|
2
|
+
// deployment overrides everything with env, and so no secret is read anywhere but here.
|
|
3
|
+
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { readFile } from 'node:fs/promises';
|
|
7
|
+
|
|
8
|
+
// The bridge binds a FIXED 127.0.0.1:4319 (see chatpanel-bridge/src/server.js) so the
|
|
9
|
+
// extension always finds it; the adapter is just another local client of the same port.
|
|
10
|
+
export const DEFAULT_BRIDGE_URL = 'http://127.0.0.1:4319';
|
|
11
|
+
|
|
12
|
+
export function bridgeBaseUrl(env = process.env) {
|
|
13
|
+
return (env.CHATPANEL_BRIDGE_URL || DEFAULT_BRIDGE_URL).replace(/\/+$/, '');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function channelsHome(env = process.env) {
|
|
17
|
+
return env.CHATPANEL_HOME || path.join(os.homedir(), '.chatpanel');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function channelsDataDir(env = process.env) {
|
|
21
|
+
return path.join(channelsHome(env), 'channels');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The bridge writes ~/.chatpanel/bridge-token 0600 on first start. A channel adapter is a
|
|
25
|
+
// non-browser local client, so — like any privileged caller — it presents this token instead
|
|
26
|
+
// of an Origin header. Read it fresh each start; never cache a secret longer than needed.
|
|
27
|
+
export async function readBridgeToken(env = process.env) {
|
|
28
|
+
if (env.CHATPANEL_BRIDGE_TOKEN) return env.CHATPANEL_BRIDGE_TOKEN.trim();
|
|
29
|
+
const p = path.join(channelsHome(env), 'bridge-token');
|
|
30
|
+
try {
|
|
31
|
+
const t = (await readFile(p, 'utf8')).trim();
|
|
32
|
+
if (t) return t;
|
|
33
|
+
} catch { /* fall through to a clear, actionable error */ }
|
|
34
|
+
throw new Error(`no bridge token — start chatpanel-bridge once (it writes ${p}), or set CHATPANEL_BRIDGE_TOKEN`);
|
|
35
|
+
}
|
package/src/eventlog.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// A file-backed sink for the capability/privacy events a channel run produces — the audit
|
|
2
|
+
// trail neither Claude Code Remote nor Hermes has. Events are metadata only (counts, ids;
|
|
3
|
+
// never message content — see chatpanel-events/event.js), so this JSONL is safe to keep and
|
|
4
|
+
// replicate. One line per event, append-only. seq is owned by the appender and recovered from
|
|
5
|
+
// the file on restart so ordering survives a bounce.
|
|
6
|
+
|
|
7
|
+
import { appendFile, readFile, mkdir } from 'node:fs/promises';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { createAppender } from '@chatpanel/events/event.js';
|
|
10
|
+
|
|
11
|
+
async function nextSeq(file) {
|
|
12
|
+
try {
|
|
13
|
+
const txt = await readFile(file, 'utf8');
|
|
14
|
+
let max = -1;
|
|
15
|
+
for (const l of txt.split('\n')) {
|
|
16
|
+
if (!l) continue;
|
|
17
|
+
try { const e = JSON.parse(l); if (Number.isInteger(e.seq)) max = Math.max(max, e.seq); } catch { /* skip */ }
|
|
18
|
+
}
|
|
19
|
+
return max + 1;
|
|
20
|
+
} catch { return 0; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function createEventLog({ file, host = 'channel' }) {
|
|
24
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
25
|
+
const seq = await nextSeq(file);
|
|
26
|
+
const appender = createAppender({ host, seq, newId: () => globalThis.crypto.randomUUID() });
|
|
27
|
+
return {
|
|
28
|
+
host,
|
|
29
|
+
get seq() { return appender.seq; },
|
|
30
|
+
// Same signature as the raw appender, but persists. Returns the validated event.
|
|
31
|
+
async append(type, payload, causes = []) {
|
|
32
|
+
const e = appender.append(type, payload, causes);
|
|
33
|
+
await appendFile(file, JSON.stringify(e) + '\n');
|
|
34
|
+
return e;
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// A no-op sink for the allow-list prototype or tests — same interface, writes nothing but
|
|
40
|
+
// still builds + validates each event, so a bad payload fails loudly here too.
|
|
41
|
+
export function nullEventLog({ host = 'channel' } = {}) {
|
|
42
|
+
const appender = createAppender({ host, seq: 0, newId: () => globalThis.crypto.randomUUID() });
|
|
43
|
+
return {
|
|
44
|
+
host,
|
|
45
|
+
get seq() { return appender.seq; },
|
|
46
|
+
async append(type, payload, causes = []) { return appender.append(type, payload, causes); },
|
|
47
|
+
};
|
|
48
|
+
}
|
package/src/invoke.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// The invariant core: an inbound message becomes ONE capability invocation with
|
|
2
|
+
// actor.kind:'channel', its text is redacted before it can leave for the agent, and both facts
|
|
3
|
+
// are written to the event log. Everything here is pure given a vault + an appender — the
|
|
4
|
+
// adapter owns the network, this owns the contract.
|
|
5
|
+
|
|
6
|
+
import { validateInvocation } from '@chatpanel/events/capability.js';
|
|
7
|
+
import { redactText, restoreText, redactionSummary } from '@chatpanel/pii';
|
|
8
|
+
|
|
9
|
+
// One capability every channel message invokes: "run an agent turn on the user's behalf".
|
|
10
|
+
// Effects are non-replayable — a turn runs shell/filesystem tools, so replaying it would
|
|
11
|
+
// repeat side effects; that is exactly why validateInvocation demands an idempotencyKey.
|
|
12
|
+
export const CHANNEL_CAPABILITY = 'channel.chat';
|
|
13
|
+
export const CHANNEL_EFFECTS = 'non-replayable';
|
|
14
|
+
|
|
15
|
+
/** Build + validate the invocation for one inbound message. Throws EventError on a bad shape. */
|
|
16
|
+
export function buildInvocation({ platform, chatId, messageId }, causes = []) {
|
|
17
|
+
const id = `${platform}:${chatId}`;
|
|
18
|
+
const inv = {
|
|
19
|
+
capability: CHANNEL_CAPABILITY,
|
|
20
|
+
actor: { kind: 'channel', id },
|
|
21
|
+
scope: { kind: 'session', id },
|
|
22
|
+
causes,
|
|
23
|
+
effects: CHANNEL_EFFECTS,
|
|
24
|
+
// Same platform + chat + message = same turn. Retried delivery must not run it twice.
|
|
25
|
+
idempotencyKey: `${platform}:${chatId}:${messageId}`,
|
|
26
|
+
};
|
|
27
|
+
return validateInvocation(inv);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Entries per entity type in a vault, so we can diff before/after and report how many NEW
|
|
31
|
+
// values a turn redacted — never the values (privacy.redacted is counts-only, by contract).
|
|
32
|
+
function countsByType(vault) {
|
|
33
|
+
const counts = {};
|
|
34
|
+
for (const t of redactionSummary(vault).types) counts[t.type] = t.count;
|
|
35
|
+
return counts;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Redact inbound text into the chat's vault. Returns the redacted text plus the per-turn
|
|
40
|
+
* counts delta (for a privacy.redacted event). The vault persists across turns, so PERSON_1
|
|
41
|
+
* means the same person every message.
|
|
42
|
+
*
|
|
43
|
+
* `tier:'basic'` (regex: emails, phones, cards, keys, IPs) is the safe default with no roster;
|
|
44
|
+
* pass `tier:'full'` + `entities` to also pseudonymize known people/orgs.
|
|
45
|
+
*/
|
|
46
|
+
export function redactInbound(text, vault, { tier = 'basic', entities = [], dictionary = [] } = {}) {
|
|
47
|
+
const before = countsByType(vault);
|
|
48
|
+
const redacted = redactText(text ?? '', vault, { tier, entities, dictionary });
|
|
49
|
+
const after = countsByType(vault);
|
|
50
|
+
const counts = {};
|
|
51
|
+
for (const type of Object.keys(after)) {
|
|
52
|
+
const d = after[type] - (before[type] || 0);
|
|
53
|
+
if (d > 0) counts[type] = d;
|
|
54
|
+
}
|
|
55
|
+
return { redacted, counts };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Restore the agent's reply for the user. Two honest modes:
|
|
60
|
+
* - 'standard' (default): swap placeholders back to real values. The user reads their own
|
|
61
|
+
* data on their own phone — but Telegram/Meta bot traffic is NOT end-to-end encrypted, so
|
|
62
|
+
* the provider carries it in the clear. That is the accepted trade for a readable reply.
|
|
63
|
+
* - 'strict': leave placeholders in the outbound message. The provider never sees a real
|
|
64
|
+
* value; the user sees [[PERSON_1]]. Choose per deployment.
|
|
65
|
+
*/
|
|
66
|
+
export function restoreOutbound(text, vault, { privacy = 'standard' } = {}) {
|
|
67
|
+
return privacy === 'strict' ? String(text ?? '') : restoreText(text ?? '', vault);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Conversation memory. The adapter keeps ONE array of REDACTED turns per chat and replays it
|
|
71
|
+
// as context, so "…and the second one?" resolves against the prior answer. The bridge's
|
|
72
|
+
// buildCliPrompt already renders all-but-last as a labelled history transcript and the last as
|
|
73
|
+
// the live message — so multi-turn just works once the array is threaded through.
|
|
74
|
+
//
|
|
75
|
+
// We store the redacted user text and the agent's (already-placeholdered) reply — never real
|
|
76
|
+
// values — so history is exactly as safe to hold as the event log, and the vault stays the one
|
|
77
|
+
// source of truth for what PERSON_1 means. The window is bounded so a long chat can't grow the
|
|
78
|
+
// prompt without end.
|
|
79
|
+
export const DEFAULT_HISTORY_MESSAGES = 16;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Append one exchange to a chat's history and return the new, bounded array (never mutates).
|
|
83
|
+
* `assistantText` is optional — a failed turn stores nothing, so history never implies the
|
|
84
|
+
* agent answered when it didn't.
|
|
85
|
+
*/
|
|
86
|
+
export function appendTurn(history, userText, assistantText, { maxMessages = DEFAULT_HISTORY_MESSAGES } = {}) {
|
|
87
|
+
const next = Array.isArray(history) ? history.slice() : [];
|
|
88
|
+
next.push({ role: 'user', content: String(userText ?? '') });
|
|
89
|
+
const reply = assistantText == null ? '' : String(assistantText);
|
|
90
|
+
if (reply) next.push({ role: 'assistant', content: reply });
|
|
91
|
+
// Keep only the last maxMessages, and never let the window open on an assistant turn — a
|
|
92
|
+
// transcript that starts with "Assistant:" reads as if the agent spoke first.
|
|
93
|
+
let windowed = maxMessages > 0 ? next.slice(-maxMessages) : next;
|
|
94
|
+
while (windowed.length && windowed[0].role === 'assistant') windowed = windowed.slice(1);
|
|
95
|
+
return windowed;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Event builders — thin, so the adapter appends without knowing payload shapes. Each returns
|
|
99
|
+
// whatever the appender's append() returns (a validated event, or a promise of one).
|
|
100
|
+
export function appendInvoked(appender, invocation, causes = []) {
|
|
101
|
+
return appender.append('capability.invoked', {
|
|
102
|
+
capability: invocation.capability,
|
|
103
|
+
actor: invocation.actor,
|
|
104
|
+
scope: invocation.scope,
|
|
105
|
+
effects: invocation.effects,
|
|
106
|
+
idempotencyKey: invocation.idempotencyKey,
|
|
107
|
+
}, causes);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function appendRedacted(appender, counts, causes = []) {
|
|
111
|
+
// Nothing redacted → nothing to record. An empty privacy.redacted still validates, but a
|
|
112
|
+
// log line that says "0 of nothing" is noise.
|
|
113
|
+
if (!counts || !Object.keys(counts).length) return null;
|
|
114
|
+
return appender.append('privacy.redacted', { counts }, causes);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function appendEgress(appender, { host, redacted, controlled }, causes = []) {
|
|
118
|
+
return appender.append('privacy.egress', { host, redacted: !!redacted, controlled: !!controlled }, causes);
|
|
119
|
+
}
|
package/src/normalize.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Platform message → one normalized shape the invoke/stream core understands. PURE: no
|
|
2
|
+
// network, no token — a Telegram update object in, a plain record out — so the whole gate
|
|
3
|
+
// (pairing, redaction, command routing) is unit-testable without a bot.
|
|
4
|
+
//
|
|
5
|
+
// One shape for every platform is the same discipline capability.js keeps for actors: the
|
|
6
|
+
// adapters are dumb transport, and everything above them speaks 'normalized message'.
|
|
7
|
+
|
|
8
|
+
export const CHANNEL_ACTOR_KIND = 'channel';
|
|
9
|
+
|
|
10
|
+
/** The actor id a paired surface invokes under: '<platform>:<chatId>'. Stable per chat. */
|
|
11
|
+
export function actorId(platform, chatId) {
|
|
12
|
+
return `${platform}:${chatId}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// A leading "/word" is a command to US (pair/stop/new/help), not a prompt for the agent.
|
|
16
|
+
// Telegram appends "@BotName" to commands in groups; strip it so "/stop@mybot" === "/stop".
|
|
17
|
+
function parseCommand(text) {
|
|
18
|
+
const m = /^\/([a-zA-Z0-9_]+)(?:@\w+)?(?:\s+([\s\S]*))?$/.exec(text.trim());
|
|
19
|
+
if (!m) return null;
|
|
20
|
+
return { name: m[1].toLowerCase(), args: (m[2] || '').trim() };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Normalize a Telegram getUpdates result item. Returns null for updates we don't act on
|
|
25
|
+
* (edited messages, channel posts, join/leave events) so the caller skips them uniformly.
|
|
26
|
+
* A photo's caption is treated as its text, matching how a person reads the message.
|
|
27
|
+
*/
|
|
28
|
+
export function normalizeTelegram(update) {
|
|
29
|
+
const msg = update?.message;
|
|
30
|
+
if (!msg || !msg.chat) return null;
|
|
31
|
+
const text = typeof msg.text === 'string' ? msg.text
|
|
32
|
+
: typeof msg.caption === 'string' ? msg.caption : '';
|
|
33
|
+
// Largest photo variant only — Telegram sends a size ladder, last is the biggest.
|
|
34
|
+
const photos = Array.isArray(msg.photo) && msg.photo.length
|
|
35
|
+
? [{ fileId: msg.photo[msg.photo.length - 1].file_id }]
|
|
36
|
+
: [];
|
|
37
|
+
const command = text.startsWith('/') ? parseCommand(text) : null;
|
|
38
|
+
return {
|
|
39
|
+
platform: 'telegram',
|
|
40
|
+
chatId: String(msg.chat.id),
|
|
41
|
+
chatType: msg.chat.type || 'private',
|
|
42
|
+
messageId: String(msg.message_id),
|
|
43
|
+
from: { id: String(msg.from?.id ?? msg.chat.id), name: msg.from?.first_name || msg.from?.username || '' },
|
|
44
|
+
text,
|
|
45
|
+
command,
|
|
46
|
+
photos,
|
|
47
|
+
replyToMessageId: msg.reply_to_message ? String(msg.reply_to_message.message_id) : null,
|
|
48
|
+
};
|
|
49
|
+
}
|
package/src/pairing.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Pairing — who may drive an agent from a phone, and how far their requests may travel.
|
|
2
|
+
//
|
|
3
|
+
// This is the AUTHENTICATION half of §7: it proves WHO sent a message (Telegram authenticates
|
|
4
|
+
// the sender's chat id; a one-time code makes enrollment deliberate, not silent). It says
|
|
5
|
+
// nothing about WHAT a message may do — a paired-but-injected message is still injected, so
|
|
6
|
+
// `reach` is a ceiling, never a licence. Tool authorization is the next layer up.
|
|
7
|
+
//
|
|
8
|
+
// Reach reuses the router's tiers verbatim (device < trusted < any), so "a paired phone is
|
|
9
|
+
// trusted" means the exact same thing here as it does to the model router downstream.
|
|
10
|
+
|
|
11
|
+
import { REACH } from '@chatpanel/events/router.js';
|
|
12
|
+
|
|
13
|
+
export { REACH };
|
|
14
|
+
|
|
15
|
+
// 6 digits: enough entropy for a short-lived, single-use enrollment code shown on a screen,
|
|
16
|
+
// short enough to thumb into a phone. It is NOT a password — it expires and burns on first use.
|
|
17
|
+
function sixDigits(randomInt) {
|
|
18
|
+
return String(randomInt(0, 1_000_000)).padStart(6, '0');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A pairing store over a plain-JSON state object, with clock and RNG injected so enrollment
|
|
23
|
+
* is deterministic in tests. Persistence is the caller's job: load the JSON at start, call
|
|
24
|
+
* toJSON() after a mutation, write it back — the same shape as pii's createVault/vaultToJSON.
|
|
25
|
+
*
|
|
26
|
+
* state: { paired: { [actorId]: { reach, at } }, pending: { [code]: { at, ttlMs } } }
|
|
27
|
+
*/
|
|
28
|
+
export function createPairingStore(state = {}, {
|
|
29
|
+
now = () => Date.now(),
|
|
30
|
+
randomInt = (min, max) => min + Math.floor(Math.random() * (max - min)),
|
|
31
|
+
} = {}) {
|
|
32
|
+
const paired = new Map(Object.entries(state.paired || {}));
|
|
33
|
+
const pending = new Map(Object.entries(state.pending || {}));
|
|
34
|
+
|
|
35
|
+
const prune = () => {
|
|
36
|
+
const t = now();
|
|
37
|
+
for (const [code, p] of pending) if (t - p.at > (p.ttlMs || 0)) pending.delete(code);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
/** Owner-side: mint a one-time code to read out to the phone. Shown in the extension/CLI. */
|
|
42
|
+
requestCode({ ttlMs = 10 * 60_000 } = {}) {
|
|
43
|
+
prune();
|
|
44
|
+
let code;
|
|
45
|
+
do { code = sixDigits(randomInt); } while (pending.has(code));
|
|
46
|
+
pending.set(code, { at: now(), ttlMs });
|
|
47
|
+
return code;
|
|
48
|
+
},
|
|
49
|
+
/** Phone-side: "/pair 123456". Burns the code and pairs the actor at 'trusted'. */
|
|
50
|
+
redeem(actorId, code, { reach = 'trusted' } = {}) {
|
|
51
|
+
prune();
|
|
52
|
+
const c = String(code || '').trim();
|
|
53
|
+
if (!pending.has(c)) return { ok: false, reason: 'unknown or expired code' };
|
|
54
|
+
if (!REACH.includes(reach)) return { ok: false, reason: `unknown reach '${reach}'` };
|
|
55
|
+
pending.delete(c);
|
|
56
|
+
paired.set(actorId, { reach, at: now() });
|
|
57
|
+
return { ok: true, reach };
|
|
58
|
+
},
|
|
59
|
+
/** Bootstrap without a code — for an operator-supplied allow list. Explicit, not silent. */
|
|
60
|
+
allow(actorId, { reach = 'trusted' } = {}) {
|
|
61
|
+
if (!REACH.includes(reach)) throw new Error(`unknown reach '${reach}'`);
|
|
62
|
+
paired.set(actorId, { reach, at: now() });
|
|
63
|
+
},
|
|
64
|
+
revoke(actorId) { return paired.delete(actorId); },
|
|
65
|
+
isPaired(actorId) { return paired.has(actorId); },
|
|
66
|
+
/** The reach ceiling for this actor, or null when it isn't paired (→ refuse the message). */
|
|
67
|
+
reachOf(actorId) { return paired.get(actorId)?.reach || null; },
|
|
68
|
+
list() { return [...paired.entries()].map(([id, v]) => ({ actorId: id, ...v })); },
|
|
69
|
+
toJSON() { return { paired: Object.fromEntries(paired), pending: Object.fromEntries(pending) }; },
|
|
70
|
+
};
|
|
71
|
+
}
|
package/src/stream.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// The model's reply, assembled from the bridge's SSE stream — PURE so accumulation and the
|
|
2
|
+
// token-restore boundary are testable without a socket. Per chatpanel-bridge /chat, the bridge
|
|
3
|
+
// emits `data: <json>\n\n` frames of:
|
|
4
|
+
// {type:'run', id} a cancel-by-name handle, emitted first
|
|
5
|
+
// {type:'workdir'|'status'|'reasoning'|'tool', ...} progress a caller MAY show
|
|
6
|
+
// {type:'delta', text} incremental assistant text
|
|
7
|
+
// {type:'done', text?} text only when it wasn't streamed
|
|
8
|
+
// {type:'error', error}
|
|
9
|
+
// We only need run/delta/done/error to build a reply.
|
|
10
|
+
|
|
11
|
+
export function initialState() {
|
|
12
|
+
return { runId: null, text: '', status: '', error: null, done: false };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Fold one bridge event into the running reply. Returns a NEW state (never mutates). */
|
|
16
|
+
export function foldEvent(state, ev) {
|
|
17
|
+
switch (ev?.type) {
|
|
18
|
+
case 'run': return { ...state, runId: ev.id || state.runId };
|
|
19
|
+
case 'delta': return { ...state, text: state.text + (ev.text || '') };
|
|
20
|
+
case 'status': return { ...state, status: ev.text || state.status };
|
|
21
|
+
// A `done` may carry the whole text (engines that don't stream). Only take it when we
|
|
22
|
+
// streamed nothing, or the reply doubles.
|
|
23
|
+
case 'done': return { ...state, done: true, text: (!state.text && ev.text) ? ev.text : state.text };
|
|
24
|
+
case 'error': return { ...state, done: true, error: ev.error || 'unknown error' };
|
|
25
|
+
default: return state;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Pull complete SSE events out of a growing buffer. Returns the parsed events and the
|
|
31
|
+
* UNCONSUMED tail (a partial frame still arriving), which the caller prepends next read.
|
|
32
|
+
*/
|
|
33
|
+
export function parseSse(buffer) {
|
|
34
|
+
const events = [];
|
|
35
|
+
let rest = String(buffer);
|
|
36
|
+
let idx;
|
|
37
|
+
while ((idx = rest.indexOf('\n\n')) >= 0) {
|
|
38
|
+
const raw = rest.slice(0, idx);
|
|
39
|
+
rest = rest.slice(idx + 2);
|
|
40
|
+
for (const line of raw.split('\n')) {
|
|
41
|
+
if (!line.startsWith('data:')) continue;
|
|
42
|
+
const json = line.slice(5).trim();
|
|
43
|
+
if (!json) continue;
|
|
44
|
+
try { events.push(JSON.parse(json)); } catch { /* skip a malformed frame */ }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { events, rest };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Telegram rejects a message body over 4096 chars. Split on paragraph, then line, then a hard
|
|
51
|
+
// cut, so a long answer arrives as several messages instead of one API error.
|
|
52
|
+
export function splitForTelegram(text, max = 4096) {
|
|
53
|
+
let s = String(text ?? '');
|
|
54
|
+
const out = [];
|
|
55
|
+
while (s.length > max) {
|
|
56
|
+
let cut = s.lastIndexOf('\n\n', max);
|
|
57
|
+
if (cut < max * 0.5) cut = s.lastIndexOf('\n', max);
|
|
58
|
+
if (cut < max * 0.5) cut = max;
|
|
59
|
+
out.push(s.slice(0, cut));
|
|
60
|
+
s = s.slice(cut).replace(/^\n+/, '');
|
|
61
|
+
}
|
|
62
|
+
if (s) out.push(s);
|
|
63
|
+
return out.length ? out : [''];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// A minimal time gate for throttling live edits — Telegram allows ~1 edit/sec to a chat.
|
|
67
|
+
// `now` injected for tests. ready() returns true and advances only when `ms` has elapsed.
|
|
68
|
+
export function createGate(ms, { now = () => Date.now() } = {}) {
|
|
69
|
+
// -Infinity, not 0, so the FIRST call always fires (replace the "…" placeholder promptly)
|
|
70
|
+
// regardless of the clock's magnitude — then throttle. With last=0 this only worked because
|
|
71
|
+
// the real Date.now() dwarfs `ms`; an injected test clock at t=0 exposed the latent bug.
|
|
72
|
+
let last = -Infinity;
|
|
73
|
+
return {
|
|
74
|
+
ready() { const t = now(); if (t - last >= ms) { last = t; return true; } return false; },
|
|
75
|
+
reset() { last = -Infinity; },
|
|
76
|
+
};
|
|
77
|
+
}
|