connelio 0.0.0-stage → 0.1.2
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 +21 -0
- package/README.md +93 -2
- package/TRADEMARKS.md +5 -0
- package/bin/connelio.mjs +198 -0
- package/package.json +36 -4
- package/src/adapters/claude-code.mjs +110 -0
- package/src/adapters/command.mjs +42 -0
- package/src/api.mjs +47 -0
- package/src/config.mjs +51 -0
- package/src/hermes.mjs +89 -0
- package/src/i18n.mjs +68 -0
- package/src/pair.mjs +63 -0
- package/src/progress.mjs +52 -0
- package/src/qr.mjs +46 -0
- package/src/runner.mjs +203 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Connelio
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,94 @@
|
|
|
1
|
-
#
|
|
1
|
+
# connelio
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Put your AI agent in [Connelio](https://connelio.app) and talk to it from your phone — like a contact.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx connelio connect
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
A QR code appears in your terminal. On your phone open **Connelio → Chats → + → Connect your AI**, scan it, tap **Connect**. Your agent shows up in your chat list and says it is online. No bot tokens to copy, no BotFather.
|
|
10
|
+
|
|
11
|
+
## What you get
|
|
12
|
+
|
|
13
|
+
- **Claude Code in your pocket.** Each message runs Claude Code in your project folder. Every step shows up as a live progress card. When Claude wants to edit a file or run a command, your phone asks **Allow / Always in this chat / Deny** — only you can tap it.
|
|
14
|
+
- **Any CLI agent.** `npx connelio connect -- <command>` sends each message to the command on stdin and replies with its stdout. Works with `codex exec`, `gemini`, `llm`, `ollama run …`, or your own script.
|
|
15
|
+
- **Groups.** Add your agent to a group; it answers when someone @mentions it. Put two agents in one group and let them work together while you watch.
|
|
16
|
+
- **Telegram-compatible.** Agents built on the Telegram Bot API (python-telegram-bot, grammY, aiogram, Telegraf, Hermes) connect by changing two settings: the token and the API address `https://connelio.app/tg`. See [Hermes](#hermes).
|
|
17
|
+
|
|
18
|
+
## Private and public agents
|
|
19
|
+
|
|
20
|
+
| | `--mode private` (default) | `--mode public` |
|
|
21
|
+
| --- | --- | --- |
|
|
22
|
+
| Who can use it | only you | anyone you share it with |
|
|
23
|
+
| Where it works | your `--dir` (default: current folder) | an isolated folder per chat |
|
|
24
|
+
| Edit files / run commands | yes, with your approval on the phone | no |
|
|
25
|
+
| Reads | anything Claude Code can read | its own chat folder + an optional read-only `--knowledge` folder |
|
|
26
|
+
|
|
27
|
+
**Every chat is isolated.** Each conversation gets its own folder under `~/.connelio/sessions/` and its own Claude session, so one person's chat can never see another's. In public mode, the first time someone new writes to your agent they are told it runs on your computer and that you can see what they send.
|
|
28
|
+
|
|
29
|
+
## Commands
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
npx connelio connect [--mode private|public] [--dir PATH] [--knowledge PATH] [--name TEXT] [-- COMMAND ...]
|
|
33
|
+
npx connelio run [name] start a paired agent again
|
|
34
|
+
npx connelio list paired agents on this computer
|
|
35
|
+
npx connelio forget <name> delete a saved token
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Tokens are stored in `~/.connelio/agents/` with file mode 600. `forget` removes one; resetting the agent on your phone revokes it.
|
|
39
|
+
|
|
40
|
+
## Hermes
|
|
41
|
+
|
|
42
|
+
One command on the computer that runs Hermes:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npx connelio connect --hermes
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
That's the whole thing — it never asks you to choose between commands. If the Hermes on this computer already talks to a Telegram bot, Connelio gets its own Hermes profile (`connelio`) and leaves that bot alone; if a `connelio` profile is already there, it is reused. Only `--replace` swaps the bot your current profile uses. To put Connelio on a specific profile instead: `--hermes-profile <name>`.
|
|
49
|
+
|
|
50
|
+
Each step prints as it happens — checking Hermes, preparing the config, waiting for your phone, connected, restarting Hermes — and a failed step says what to do next. The QR code is drawn large (two characters per module, quiet zone around it, and the pairing code on its own line below), and falls back to the compact version only when the terminal is too narrow for it.
|
|
51
|
+
|
|
52
|
+
Scan the QR code, and the command writes Hermes's settings (address, token, and an allow-list with only you) through `hermes config set`, then restarts the Hermes gateway (`hermes gateway ensure` when it is not running yet).
|
|
53
|
+
|
|
54
|
+
Doing it by hand: Hermes talks to Telegram through python-telegram-bot and lets you set the API address. Create a Bot Agent in Connelio, copy its token once, then in your Hermes config:
|
|
55
|
+
|
|
56
|
+
```yaml
|
|
57
|
+
platforms:
|
|
58
|
+
telegram:
|
|
59
|
+
enabled: true
|
|
60
|
+
extra:
|
|
61
|
+
base_url: https://connelio.app/tg/bot
|
|
62
|
+
base_file_url: https://connelio.app/tg/file/bot
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
and set `TELEGRAM_BOT_TOKEN` to the agent's token and `TELEGRAM_ALLOWED_USERS` to your own user id.
|
|
66
|
+
|
|
67
|
+
## Costs and accounts
|
|
68
|
+
|
|
69
|
+
The agent runs on your computer with your own Claude Code login or API key. Connelio never sees your model credentials.
|
|
70
|
+
|
|
71
|
+
## Requirements
|
|
72
|
+
|
|
73
|
+
Node.js 20+. For Claude Code: the Claude Agent SDK (installed with this package) and a working Claude Code login on this computer.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
# connelio(中文)
|
|
78
|
+
|
|
79
|
+
把你的 AI 代理接进 Connelio,在手机上像跟联系人聊天一样指挥它。
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
npx connelio connect
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
终端里出一个二维码。手机打开 **Connelio → 消息 → 右上 + → 接入我的 AI**,扫码,点 **连接**。代理会出现在聊天列表里并说"已上线"。不用复制 token。
|
|
86
|
+
|
|
87
|
+
- **Claude Code**:每条消息在你的项目目录里跑;每一步实时显示在进度卡上;要改文件、跑命令时手机弹「允许 / 本会话都允许 / 拒绝」,只有你能点。
|
|
88
|
+
- **任意命令行代理**:`npx connelio connect -- <命令>`,消息从 stdin 进,stdout 作为回复。
|
|
89
|
+
- **群聊**:把代理拉进群,有人 @ 它就回答;两个代理放一个群可以协作。
|
|
90
|
+
- **兼容 Telegram**:基于 Telegram Bot API 的代理(含 Hermes)改 token 和地址两项即可接入。
|
|
91
|
+
|
|
92
|
+
**私用 / 公开**:私用(默认)只有你能用,在你指定的目录里工作;公开模式谁都能聊,但每个会话一个独立目录、不能改文件和跑命令,陌生人第一次来会被告知"代理由你运行、内容你看得到"。
|
|
93
|
+
|
|
94
|
+
模型费用和账号都是你自己的,Connelio 看不到你的模型密钥。
|
package/TRADEMARKS.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Trademarks
|
|
2
|
+
|
|
3
|
+
The code in this repository is MIT-licensed: use it, fork it, ship it.
|
|
4
|
+
|
|
5
|
+
The MIT license does not cover the **Connelio** name or logo. You may say your project "works with Connelio" or "connects to Connelio", but please do not use the name or logo in a way that suggests your project is made or endorsed by Connelio.
|
package/bin/connelio.mjs
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// connelio — 把你的 AI 代理接进 Connelio:扫码配对,手机上像聊天一样指挥它。
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { createApi } from '../src/api.mjs';
|
|
5
|
+
import { listAgents, loadAgent, removeAgent, saveAgent } from '../src/config.mjs';
|
|
6
|
+
import { pair } from '../src/pair.mjs';
|
|
7
|
+
import { runAgent } from '../src/runner.mjs';
|
|
8
|
+
import { claudeCodeAdapter } from '../src/adapters/claude-code.mjs';
|
|
9
|
+
import { commandAdapter } from '../src/adapters/command.mjs';
|
|
10
|
+
import { configureHermes, hermesCli, ISOLATED_PROFILE, isConnelioBase } from '../src/hermes.mjs';
|
|
11
|
+
import { createProgress } from '../src/progress.mjs';
|
|
12
|
+
import { copy as copyFor, detectLang } from '../src/i18n.mjs';
|
|
13
|
+
|
|
14
|
+
const HELP = `connelio — put your AI agent in Connelio
|
|
15
|
+
|
|
16
|
+
npx connelio connect pair Claude Code in this folder (scan the QR with Connelio on your phone)
|
|
17
|
+
npx connelio connect --hermes connect the Hermes agent on this computer (writes its config, restarts it)
|
|
18
|
+
npx connelio connect -- <command> pair any CLI agent: each message goes to <command> on stdin, stdout is the reply
|
|
19
|
+
npx connelio run [name] start a paired agent again (no new pairing)
|
|
20
|
+
npx connelio list show paired agents on this computer
|
|
21
|
+
npx connelio forget <name> remove a paired agent's saved token from this computer
|
|
22
|
+
|
|
23
|
+
Options for connect:
|
|
24
|
+
--mode private|public private (default): only you can use it, it works in --dir with your approval on the phone
|
|
25
|
+
public: anyone you share it with can chat; each chat is isolated, no file changes or commands
|
|
26
|
+
--dir <path> project folder for private mode (default: current folder)
|
|
27
|
+
--knowledge <path> read-only folder that public chats may read
|
|
28
|
+
--name <text> how this computer is shown on your phone
|
|
29
|
+
--server <url> Connelio server (default https://connelio.app)
|
|
30
|
+
--hermes-profile <name> with --hermes: use (or create) this Hermes profile instead of choosing one for you
|
|
31
|
+
--replace with --hermes: replace the Telegram bot this Hermes profile already uses
|
|
32
|
+
|
|
33
|
+
With --hermes, if the Hermes on this computer already talks to a Telegram bot, Connelio
|
|
34
|
+
gets its own Hermes profile (${ISOLATED_PROFILE}) and leaves that bot alone. No questions asked.
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
function parse(argv) {
|
|
38
|
+
const dash = argv.indexOf('--');
|
|
39
|
+
const head = dash >= 0 ? argv.slice(0, dash) : argv;
|
|
40
|
+
const command = dash >= 0 ? argv.slice(dash + 1) : [];
|
|
41
|
+
const [verb, ...rest] = head;
|
|
42
|
+
const flags = {};
|
|
43
|
+
const positional = [];
|
|
44
|
+
for (let i = 0; i < rest.length; i++) {
|
|
45
|
+
const arg = rest[i];
|
|
46
|
+
if (arg.startsWith('--')) {
|
|
47
|
+
const [key, inline] = arg.slice(2).split('=');
|
|
48
|
+
flags[key] = inline ?? (rest[i + 1] && !rest[i + 1].startsWith('--') ? rest[++i] : true);
|
|
49
|
+
} else positional.push(arg);
|
|
50
|
+
}
|
|
51
|
+
return { verb, flags, positional, command };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const flagOn = (value) => value === true || value === 'true' || value === '1';
|
|
55
|
+
const firstLine = (message) => String(message || '').split('\n').map((l) => l.trim()).filter(Boolean)[0] || '';
|
|
56
|
+
|
|
57
|
+
async function adapterFor(config) {
|
|
58
|
+
return config.command?.length ? commandAdapter(config.command) : claudeCodeAdapter();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function start(config) {
|
|
62
|
+
const controller = new AbortController();
|
|
63
|
+
const stop = () => { controller.abort(); process.stdout.write('\n[connelio] stopped.\n'); setTimeout(() => process.exit(0), 200).unref(); };
|
|
64
|
+
process.on('SIGINT', stop);
|
|
65
|
+
process.on('SIGTERM', stop);
|
|
66
|
+
const api = createApi({ apiBase: config.apiBase, token: config.token, consumerId: `bridge-${config.deviceName ?? 'local'}`.slice(0, 64) });
|
|
67
|
+
const adapter = await adapterFor(config);
|
|
68
|
+
await runAgent({ api, config, adapter, signal: controller.signal });
|
|
69
|
+
saveAgent(config);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** connect --hermes:一步不停,不再让用户在两行命令里选。 */
|
|
73
|
+
async function connectHermes({ server, flags, out = process.stdout }) {
|
|
74
|
+
const text = copyFor(detectLang());
|
|
75
|
+
const progress = createProgress(out);
|
|
76
|
+
const replace = flagOn(flags.replace);
|
|
77
|
+
const explicit = typeof flags['hermes-profile'] === 'string' ? flags['hermes-profile'] : '';
|
|
78
|
+
const deviceName = typeof flags.name === 'string' ? flags.name : undefined;
|
|
79
|
+
|
|
80
|
+
// 1 检查 Hermes
|
|
81
|
+
if (!hermesCli('').available()) {
|
|
82
|
+
progress.fail(text.steps.check, text.errors.noHermes.why, text.errors.noHermes.next);
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
progress.ok(text.steps.check);
|
|
86
|
+
|
|
87
|
+
// 2 建配置:已有别的 Telegram 机器人就自动开独立 profile(原来的不受影响),只有 --replace 才替换
|
|
88
|
+
let profile = explicit;
|
|
89
|
+
let notice = '';
|
|
90
|
+
if (!explicit && !replace) {
|
|
91
|
+
const state = hermesCli('').telegramState();
|
|
92
|
+
if (state.token && !isConnelioBase(state.baseUrl, server)) profile = ISOLATED_PROFILE;
|
|
93
|
+
else if (state.token) notice = text.alreadyConnelio;
|
|
94
|
+
}
|
|
95
|
+
if (explicit && !replace) {
|
|
96
|
+
const state = hermesCli(explicit).telegramState();
|
|
97
|
+
if (state.token && !isConnelioBase(state.baseUrl, server)) {
|
|
98
|
+
progress.fail(text.steps.prepare, text.errors.profileHasBot.why(explicit), text.errors.profileHasBot.next);
|
|
99
|
+
return 1;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const hermes = hermesCli(profile);
|
|
103
|
+
let prepared;
|
|
104
|
+
try {
|
|
105
|
+
prepared = hermes.ensureProfile();
|
|
106
|
+
} catch (error) {
|
|
107
|
+
progress.fail(text.steps.prepare, text.errors.profileFailed.why, text.errors.profileFailed.next);
|
|
108
|
+
progress.note(firstLine(error.message));
|
|
109
|
+
return 1;
|
|
110
|
+
}
|
|
111
|
+
progress.ok(text.steps.prepare);
|
|
112
|
+
if (profile === ISOLATED_PROFILE && !explicit) progress.note(prepared.existed ? text.reuseProfile : text.createProfile);
|
|
113
|
+
else if (replace) progress.note(text.keepActive);
|
|
114
|
+
if (notice) progress.note(notice);
|
|
115
|
+
|
|
116
|
+
// 3 二维码 + 等手机扫码
|
|
117
|
+
progress.waiting(text.steps.scan);
|
|
118
|
+
let result;
|
|
119
|
+
try {
|
|
120
|
+
result = await pair({ server, kind: 'hermes', deviceName, out });
|
|
121
|
+
} catch (error) {
|
|
122
|
+
const known = text.errors[error.code];
|
|
123
|
+
progress.fail(text.steps.scan, known?.why || text.errors.network.why, known?.next || text.errors.network.next);
|
|
124
|
+
if (!known && error.message) progress.note(firstLine(error.message));
|
|
125
|
+
return 1;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 4 写配置 = 已连接
|
|
129
|
+
try {
|
|
130
|
+
configureHermes({ hermes, server, token: result.token, ownerTelegramId: result.owner?.telegramId });
|
|
131
|
+
} catch (error) {
|
|
132
|
+
progress.fail(text.steps.connected, text.errors.configWrite.why, text.errors.configWrite.next);
|
|
133
|
+
progress.note(firstLine(error.message));
|
|
134
|
+
return 1;
|
|
135
|
+
}
|
|
136
|
+
const bot = result.bot ?? {};
|
|
137
|
+
progress.ok(`${text.steps.connected}${bot.displayName ? ` — ${bot.displayName}${bot.username ? ` (@${bot.username})` : ''}` : ''}`);
|
|
138
|
+
|
|
139
|
+
// 5 重启 Hermes
|
|
140
|
+
const startCommand = `hermes${profile ? ` -p ${profile}` : ''} gateway run`;
|
|
141
|
+
progress.begin(text.steps.restart);
|
|
142
|
+
if (hermes.restart()) progress.ok(text.steps.restart);
|
|
143
|
+
else progress.warn(text.steps.restart, `${text.manualStart} ${startCommand}`);
|
|
144
|
+
|
|
145
|
+
progress.blank();
|
|
146
|
+
out.write(` ${text.done}\n`);
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function main() {
|
|
151
|
+
const { verb, flags, positional, command } = parse(process.argv.slice(2));
|
|
152
|
+
if (!verb || verb === 'help' || flags.help || flags.h) { process.stdout.write(HELP); return; }
|
|
153
|
+
const server = String(flags.server || process.env.CONNELIO_SERVER || 'https://connelio.app');
|
|
154
|
+
|
|
155
|
+
if (verb === 'connect' && flags.hermes) {
|
|
156
|
+
process.exitCode = await connectHermes({ server, flags });
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (verb === 'connect') {
|
|
160
|
+
const mode = flags.mode === 'public' ? 'public' : 'private';
|
|
161
|
+
const kind = command.length ? 'generic' : 'claude-code';
|
|
162
|
+
if (!command.length) await claudeCodeAdapter(); // 先确认本机能跑 Claude Code,再去配对,避免配完才报错
|
|
163
|
+
const result = await pair({ server, kind, deviceName: typeof flags.name === 'string' ? flags.name : undefined });
|
|
164
|
+
const config = {
|
|
165
|
+
apiBase: server.replace(/\/+$/, ''), token: result.token, bot: result.bot, owner: result.owner,
|
|
166
|
+
conversationId: result.conversationId, deviceName: typeof flags.name === 'string' ? flags.name : undefined,
|
|
167
|
+
kind, mode, command,
|
|
168
|
+
dir: mode === 'private' ? resolve(typeof flags.dir === 'string' ? flags.dir : process.cwd()) : null,
|
|
169
|
+
knowledge: typeof flags.knowledge === 'string' ? resolve(flags.knowledge) : null,
|
|
170
|
+
};
|
|
171
|
+
const file = saveAgent(config);
|
|
172
|
+
process.stdout.write(`✓ Paired as ${result.bot.displayName} (@${result.bot.username}). Saved to ${file}\n`);
|
|
173
|
+
if (flags['no-run']) return;
|
|
174
|
+
await start(config);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (verb === 'run') {
|
|
178
|
+
const config = loadAgent(positional[0]);
|
|
179
|
+
if (!config) throw new Error(positional[0] ? `No paired agent named ${positional[0]}.` : 'Pick one: `npx connelio run <name>` (see `npx connelio list`).');
|
|
180
|
+
await start(config);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (verb === 'list') {
|
|
184
|
+
const agents = listAgents();
|
|
185
|
+
if (!agents.length) process.stdout.write('No paired agents. Run `npx connelio connect`.\n');
|
|
186
|
+
for (const a of agents) process.stdout.write(`${a.bot.username}\t${a.bot.displayName}\t${a.mode}\t${a.command?.length ? a.command.join(' ') : 'claude-code'}\n`);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (verb === 'forget') {
|
|
190
|
+
if (!positional[0]) throw new Error('Usage: npx connelio forget <name>');
|
|
191
|
+
process.stdout.write(removeAgent(positional[0]) ? 'Removed.\n' : 'Not found.\n');
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
process.stdout.write(HELP);
|
|
195
|
+
process.exitCode = 1;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
main().catch((error) => { process.stderr.write(`connelio: ${error?.message || error}\n`); process.exit(1); });
|
package/package.json
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "connelio",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Put your AI agent (Claude Code, Hermes, any CLI) in Connelio: pair with a QR code, chat with it from your phone.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"connelio": "bin/connelio.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"src",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE",
|
|
15
|
+
"TRADEMARKS.md"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=20"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"qrcode": "1.5.4"
|
|
22
|
+
},
|
|
23
|
+
"optionalDependencies": {
|
|
24
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.318"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "node --test test/*.test.mjs"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"connelio",
|
|
31
|
+
"claude-code",
|
|
32
|
+
"hermes",
|
|
33
|
+
"agent",
|
|
34
|
+
"telegram",
|
|
35
|
+
"remote-control",
|
|
36
|
+
"chat"
|
|
37
|
+
]
|
|
38
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Claude Code 适配器:用官方 Claude Agent SDK 在本机跑 Claude Code。
|
|
2
|
+
// - 每个会话独立的 Claude 会话(resume 上次的 session_id),历史互不可见。
|
|
3
|
+
// - 进度:工具调用一步一行,回到手机上的进度卡。
|
|
4
|
+
// - 权限:Claude 要改文件 / 跑命令时,手机上弹「允许 / 本会话都允许 / 拒绝」,只有主人能点。
|
|
5
|
+
// - public 模式:不给改文件、跑命令的工具;最多只读本会话目录和 --knowledge 指定的只读资料目录。
|
|
6
|
+
// 用的是你本机 Claude Code 的登录 / API key,费用和额度都是你自己的。
|
|
7
|
+
import { resolve, sep } from 'node:path';
|
|
8
|
+
import { realpathSync } from 'node:fs';
|
|
9
|
+
|
|
10
|
+
const READ_ONLY = ['Read', 'Grep', 'Glob'];
|
|
11
|
+
// public 模式只给 Claude 认证真正需要的环境变量;其余(可能含主人机器上的密钥)一律不带。
|
|
12
|
+
const PUBLIC_ENV_KEEP = ['PATH', 'LANG', 'LC_ALL', 'TERM', 'TMPDIR', 'SYSTEMROOT', 'COMSPEC',
|
|
13
|
+
'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_MODEL', 'ANTHROPIC_SMALL_FAST_MODEL',
|
|
14
|
+
'CLAUDE_CODE_OAUTH_TOKEN', 'CLAUDE_CONFIG_DIR'];
|
|
15
|
+
|
|
16
|
+
const real = (p) => { try { return realpathSync(p); } catch { return null; } };
|
|
17
|
+
const hasMagic = (part) => /[*?[\]{}]/.test(part);
|
|
18
|
+
|
|
19
|
+
export async function claudeCodeAdapter({ sdk } = {}) {
|
|
20
|
+
try { sdk ??= await import('@anthropic-ai/claude-agent-sdk'); }
|
|
21
|
+
catch {
|
|
22
|
+
throw new Error('Claude Agent SDK is not installed. Run `npm i -g @anthropic-ai/claude-agent-sdk` (or reinstall connelio) and make sure Claude Code is logged in.');
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
name: 'claude-code',
|
|
26
|
+
async run(ctx) {
|
|
27
|
+
const state = ctx.state();
|
|
28
|
+
const allowedAlways = new Set(state.alwaysAllow ?? []);
|
|
29
|
+
// 根目录本身也解析成真实路径(macOS 的 /tmp、/var 都是软链接,字符串前缀比较会全判错)
|
|
30
|
+
const roots = [ctx.dir, ...(ctx.knowledge ? [ctx.knowledge] : [])].map((p) => resolve(p)).map(real).filter(Boolean);
|
|
31
|
+
const underRoots = (full) => Boolean(full) && roots.some((root) => full === root || full.startsWith(root + sep));
|
|
32
|
+
// 目标必须先真实存在:软链接按其指向算(知识目录里放一个指向 ~/.ssh 的软链接不能读出去)
|
|
33
|
+
const inRoots = (p) => typeof p === 'string' && p !== '' && underRoots(real(resolve(ctx.cwd, p)));
|
|
34
|
+
// Glob 的 pattern 带通配符,realpath 整串会失败:取第一段通配符之前的目录(相对路径按 cwd 解析,
|
|
35
|
+
// 绝对路径原样),解析成真实路径再比。pattern 不存在 / 解析不出来 → 拒绝。
|
|
36
|
+
const globRoot = (pattern) => {
|
|
37
|
+
if (typeof pattern !== 'string' || pattern === '') return null;
|
|
38
|
+
const full = resolve(ctx.cwd, pattern);
|
|
39
|
+
const parts = full.split(sep);
|
|
40
|
+
const at = parts.findIndex(hasMagic);
|
|
41
|
+
const base = at === -1 ? full : (parts.slice(0, at).join(sep) || sep);
|
|
42
|
+
return real(base);
|
|
43
|
+
};
|
|
44
|
+
const isPublic = ctx.mode === 'public';
|
|
45
|
+
const prompt = [ctx.text, ...ctx.attachments.map((p) => `(The user attached a file, saved at ${p})`)].filter(Boolean).join('\n\n');
|
|
46
|
+
|
|
47
|
+
const options = {
|
|
48
|
+
cwd: ctx.cwd,
|
|
49
|
+
resume: state.claudeSessionId || undefined,
|
|
50
|
+
permissionMode: 'default',
|
|
51
|
+
// 对外代理:只读工具,且只在本会话目录 / 知识库目录内;不读主人的用户级设置(避免带出主人的 MCP、记忆等)
|
|
52
|
+
...(isPublic ? { tools: ctx.knowledge ? READ_ONLY : [], settingSources: [], additionalDirectories: ctx.knowledge ? [ctx.knowledge] : [] }
|
|
53
|
+
: { settingSources: ['user', 'project', 'local'] }),
|
|
54
|
+
// private:主人自己的机器,本来就用这份环境。
|
|
55
|
+
// public:对外代理,只给 Claude 认证需要的那几项 + PATH/HOME(HOME 指会话目录,不把主人的家目录交出去);
|
|
56
|
+
// 登录凭据仍从主人自己的 Claude 配置目录读(订阅登录不会因此失效)。
|
|
57
|
+
env: isPublic
|
|
58
|
+
? { ...Object.fromEntries(PUBLIC_ENV_KEEP.filter((k) => process.env[k]).map((k) => [k, process.env[k]])),
|
|
59
|
+
HOME: ctx.dir,
|
|
60
|
+
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR || resolve(process.env.HOME || ctx.dir, '.claude'),
|
|
61
|
+
CONNELIO_SESSION_DIR: ctx.dir,
|
|
62
|
+
CLAUDE_AGENT_SDK_CLIENT_APP: 'connelio-bridge/0.1.0' }
|
|
63
|
+
: { ...process.env, CLAUDE_AGENT_SDK_CLIENT_APP: 'connelio-bridge/0.1.0' },
|
|
64
|
+
canUseTool: async (toolName, input) => {
|
|
65
|
+
if (isPublic) {
|
|
66
|
+
if (!READ_ONLY.includes(toolName)) return { behavior: 'deny', message: 'This agent is in public mode: no file changes or commands.' };
|
|
67
|
+
const allowed = toolName === 'Glob'
|
|
68
|
+
? underRoots(globRoot(input.pattern)) // pattern 可能是绝对路径 / 含通配符
|
|
69
|
+
: inRoots(input.file_path ?? input.path ?? ctx.cwd);
|
|
70
|
+
return allowed ? { behavior: 'allow', updatedInput: input } : { behavior: 'deny', message: 'Outside the allowed folders.' };
|
|
71
|
+
}
|
|
72
|
+
if (allowedAlways.has(toolName)) return { behavior: 'allow', updatedInput: input };
|
|
73
|
+
const decision = await ctx.askApproval(`Claude wants to use ${toolName}`, describe(toolName, input));
|
|
74
|
+
if (decision === 'always') {
|
|
75
|
+
allowedAlways.add(toolName);
|
|
76
|
+
ctx.setState({ alwaysAllow: [...allowedAlways] });
|
|
77
|
+
}
|
|
78
|
+
return decision === 'deny' ? { behavior: 'deny', message: 'The owner denied this on their phone.' } : { behavior: 'allow', updatedInput: input };
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
let finalText = '';
|
|
83
|
+
let lastAssistant = '';
|
|
84
|
+
for await (const message of sdk.query({ prompt, options })) {
|
|
85
|
+
if (message.type === 'system' && message.subtype === 'init' && message.session_id) ctx.setState({ claudeSessionId: message.session_id });
|
|
86
|
+
if (message.type === 'assistant') {
|
|
87
|
+
for (const block of message.message?.content ?? []) {
|
|
88
|
+
if (block.type === 'text' && block.text) lastAssistant = block.text;
|
|
89
|
+
if (block.type === 'tool_use') ctx.progress.step(`${block.name} ${describe(block.name, block.input)}`.trim());
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (message.type === 'result') {
|
|
93
|
+
if (message.session_id) ctx.setState({ claudeSessionId: message.session_id });
|
|
94
|
+
if (message.subtype === 'success') finalText = message.result || lastAssistant;
|
|
95
|
+
else throw new Error(message.subtype === 'error_max_turns' ? 'Stopped: too many steps.' : 'Claude Code stopped with an error.');
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return { text: finalText || lastAssistant || '(done)' };
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function describe(tool, input = {}) {
|
|
104
|
+
if (input.command) return String(input.command).slice(0, 200);
|
|
105
|
+
if (input.file_path) return String(input.file_path);
|
|
106
|
+
if (input.pattern) return String(input.pattern).slice(0, 120);
|
|
107
|
+
if (input.url) return String(input.url).slice(0, 200);
|
|
108
|
+
if (input.description) return String(input.description).slice(0, 160);
|
|
109
|
+
return '';
|
|
110
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// 通用命令行适配器:`npx connelio connect -- <命令> [参数…]`
|
|
2
|
+
// 每条消息启动一次该命令,消息正文从 stdin 进、stdout 作为回复。工作目录 = 本会话的隔间(或主人指定的项目目录)。
|
|
3
|
+
// 适合任何"读 stdin、写 stdout"的工具:codex exec、gemini、llm、ollama run、自己写的脚本……
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
export function commandAdapter(argv) {
|
|
7
|
+
if (!argv.length) throw new Error('No command given after `--`.');
|
|
8
|
+
return {
|
|
9
|
+
name: argv[0],
|
|
10
|
+
async run(ctx) {
|
|
11
|
+
const prompt = [ctx.text, ...ctx.attachments.map((p) => `[attached file: ${p}]`)].filter(Boolean).join('\n');
|
|
12
|
+
ctx.progress.step(`${argv[0]} started`);
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const child = spawn(argv[0], argv.slice(1), {
|
|
15
|
+
cwd: ctx.cwd,
|
|
16
|
+
// 只给必要的环境:不把本机所有环境变量(可能含密钥)原样交给对外代理
|
|
17
|
+
env: ctx.mode === 'public' ? minimalEnv(ctx) : { ...process.env, ...connelioEnv(ctx) },
|
|
18
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
19
|
+
});
|
|
20
|
+
let out = '', err = '';
|
|
21
|
+
child.stdout.on('data', (chunk) => { out += chunk; });
|
|
22
|
+
child.stderr.on('data', (chunk) => { err += chunk; });
|
|
23
|
+
child.on('error', reject);
|
|
24
|
+
child.on('close', (code) => {
|
|
25
|
+
if (code === 0) resolve({ text: out.trim() || '(no output)' });
|
|
26
|
+
else reject(new Error(`${argv[0]} exited with ${code}${err ? `: ${err.trim().slice(-400)}` : ''}`));
|
|
27
|
+
});
|
|
28
|
+
// 命令可能根本不读 stdin、早早退出(比如 pwd):写管道会 EPIPE,不能让它把整个桥打崩
|
|
29
|
+
child.stdin.on('error', () => undefined);
|
|
30
|
+
child.stdin.end(prompt);
|
|
31
|
+
});
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function connelioEnv(ctx) {
|
|
37
|
+
return { CONNELIO_CONVERSATION_ID: ctx.conversationId, CONNELIO_SENDER: ctx.sender?.username ?? '', CONNELIO_IS_OWNER: ctx.isOwner ? '1' : '0', CONNELIO_SESSION_DIR: ctx.dir };
|
|
38
|
+
}
|
|
39
|
+
function minimalEnv(ctx) {
|
|
40
|
+
const keep = ['PATH', 'HOME', 'LANG', 'LC_ALL', 'TERM', 'TMPDIR', 'SYSTEMROOT', 'COMSPEC'];
|
|
41
|
+
return { ...Object.fromEntries(keep.filter((k) => process.env[k]).map((k) => [k, process.env[k]])), HOME: ctx.dir, ...connelioEnv(ctx) };
|
|
42
|
+
}
|
package/src/api.mjs
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Connelio Bot API 客户端(原生 /api/bot/v1/*)。只用 Node 自带 fetch,不依赖第三方。
|
|
2
|
+
|
|
3
|
+
export class ConnelioApiError extends Error {
|
|
4
|
+
constructor(status, code, detail) {
|
|
5
|
+
super(`${status} ${code}${detail ? ` (${detail})` : ''}`);
|
|
6
|
+
this.status = status;
|
|
7
|
+
this.code = code;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createApi({ apiBase, token, consumerId = 'connelio-bridge' }) {
|
|
12
|
+
const base = apiBase.replace(/\/+$/, '');
|
|
13
|
+
async function call(method, body, { get = false, signal } = {}) {
|
|
14
|
+
const response = await fetch(`${base}/api/bot/v1/${method}`, {
|
|
15
|
+
method: get ? 'GET' : 'POST',
|
|
16
|
+
headers: { authorization: `Bearer ${token}`, 'x-bot-consumer': consumerId, ...(body instanceof FormData || !body ? {} : { 'content-type': 'application/json' }) },
|
|
17
|
+
body: body instanceof FormData ? body : body ? JSON.stringify(body) : undefined,
|
|
18
|
+
signal,
|
|
19
|
+
});
|
|
20
|
+
const payload = await response.json().catch(() => ({}));
|
|
21
|
+
if (!response.ok || payload.ok === false) throw new ConnelioApiError(response.status, payload.error || `HTTP_${response.status}`, payload.detail);
|
|
22
|
+
return payload;
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
base,
|
|
26
|
+
getMe: async () => (await call('getMe', null, { get: true })).result,
|
|
27
|
+
getUpdates: async ({ offset = 0, timeout = 25, signal } = {}) => call('getUpdates', { offset, timeout, limit: 50, consumerId }, { signal }),
|
|
28
|
+
sendMessage: async (conversationId, text, replyMarkup) =>
|
|
29
|
+
(await call('sendMessage', { conversationId, text, ...(replyMarkup ? { reply_markup: replyMarkup } : {}) })).result,
|
|
30
|
+
editMessage: async (messageId, fields) => call('editMessage', { messageId, ...fields }),
|
|
31
|
+
editButtons: async (messageId, replyMarkup) => call('editMessageReplyMarkup', { messageId, reply_markup: replyMarkup }),
|
|
32
|
+
typing: async (conversationId) => call('sendChatAction', { conversationId, action: 'typing' }).catch(() => undefined),
|
|
33
|
+
answerCallback: async (callbackQueryId, text) => call('answerCallbackQuery', { callbackQueryId, text }).catch(() => undefined),
|
|
34
|
+
sendDocument: async (conversationId, filename, bytes, caption) => {
|
|
35
|
+
const form = new FormData();
|
|
36
|
+
form.set('conversationId', conversationId);
|
|
37
|
+
if (caption) form.set('caption', caption);
|
|
38
|
+
form.set('file', new Blob([bytes]), filename);
|
|
39
|
+
return (await call('sendDocument', form)).result;
|
|
40
|
+
},
|
|
41
|
+
download: async (path) => {
|
|
42
|
+
const response = await fetch(`${base}${path}`, { headers: { authorization: `Bearer ${token}` } });
|
|
43
|
+
if (!response.ok) throw new ConnelioApiError(response.status, 'DOWNLOAD_FAILED');
|
|
44
|
+
return Buffer.from(await response.arrayBuffer());
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// 本机保存的代理凭据:~/.connelio/agents/<username>.json,权限 600。token 只存在这里。
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export const home = () => process.env.CONNELIO_HOME || join(homedir(), '.connelio');
|
|
7
|
+
const agentsDir = () => join(home(), 'agents');
|
|
8
|
+
|
|
9
|
+
export function saveAgent(config) {
|
|
10
|
+
mkdirSync(agentsDir(), { recursive: true, mode: 0o700 });
|
|
11
|
+
const file = join(agentsDir(), `${config.bot.username}.json`);
|
|
12
|
+
writeFileSync(file, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
13
|
+
chmodSync(file, 0o600);
|
|
14
|
+
return file;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function loadAgent(name) {
|
|
18
|
+
const dir = agentsDir();
|
|
19
|
+
if (!existsSync(dir)) return null;
|
|
20
|
+
const files = readdirSync(dir).filter((f) => f.endsWith('.json'));
|
|
21
|
+
const pick = name ? files.find((f) => f === `${name}.json` || f === `${name}-agent.json`) : files.length === 1 ? files[0] : null;
|
|
22
|
+
return pick ? JSON.parse(readFileSync(join(dir, pick), 'utf8')) : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function listAgents() {
|
|
26
|
+
const dir = agentsDir();
|
|
27
|
+
if (!existsSync(dir)) return [];
|
|
28
|
+
return readdirSync(dir).filter((f) => f.endsWith('.json')).map((f) => JSON.parse(readFileSync(join(dir, f), 'utf8')));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function removeAgent(name) {
|
|
32
|
+
const file = join(agentsDir(), `${name}.json`);
|
|
33
|
+
if (!existsSync(file)) return false;
|
|
34
|
+
rmSync(file);
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 每个会话一个隔间:独立工作目录 + 独立状态(对话历史 id 等),互相看不见。 */
|
|
39
|
+
export function sessionDir(username, conversationId) {
|
|
40
|
+
const safe = String(conversationId).replace(/[^A-Za-z0-9-]/g, '');
|
|
41
|
+
const dir = join(home(), 'sessions', username, safe);
|
|
42
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
43
|
+
return dir;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function readState(dir) {
|
|
47
|
+
try { return JSON.parse(readFileSync(join(dir, '.connelio-state.json'), 'utf8')); } catch { return {}; }
|
|
48
|
+
}
|
|
49
|
+
export function writeState(dir, state) {
|
|
50
|
+
writeFileSync(join(dir, '.connelio-state.json'), JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
51
|
+
}
|
package/src/hermes.mjs
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Hermes 一键接入:配对拿到 token 后,用 Hermes 自己的 `hermes config set` 把 Telegram 通道指向 Connelio。
|
|
2
|
+
// Hermes 走 python-telegram-bot,原生支持 platforms.telegram.extra.base_url —— 不需要给 Hermes 装任何插件。
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
/** 独立 profile 名字:已有 Telegram 机器人时,Connelio 用它自己的配置,互不影响。 */
|
|
9
|
+
export const ISOLATED_PROFILE = 'connelio';
|
|
10
|
+
|
|
11
|
+
export function hermesCli(profile) {
|
|
12
|
+
const bin = process.env.CONNELIO_HERMES_BIN || 'hermes';
|
|
13
|
+
const prefix = profile ? ['-p', profile] : [];
|
|
14
|
+
const run = (args) => spawnSync(bin, [...prefix, ...args], { encoding: 'utf8', env: { ...process.env, NO_COLOR: '1' } });
|
|
15
|
+
const cli = {
|
|
16
|
+
profile,
|
|
17
|
+
available() {
|
|
18
|
+
const r = spawnSync(bin, ['--version'], { encoding: 'utf8', env: { ...process.env, NO_COLOR: '1' } });
|
|
19
|
+
return !r.error && r.status === 0;
|
|
20
|
+
},
|
|
21
|
+
/** 这个 profile 名是否已经存在(`hermes profile list` 第一列) */
|
|
22
|
+
profileExists() {
|
|
23
|
+
if (!profile) return true;
|
|
24
|
+
const r = spawnSync(bin, ['profile', 'list'], { encoding: 'utf8', env: { ...process.env, NO_COLOR: '1' } });
|
|
25
|
+
if (r.error || r.status !== 0) return existsSync(join(homedir(), '.hermes', 'profiles', profile));
|
|
26
|
+
return r.stdout.split('\n')
|
|
27
|
+
.map((line) => line.replace(/[─━│]/g, ' ').trim().split(/\s+/)[0] || '')
|
|
28
|
+
.map((name) => name.replace(/^[^\w.-]+/, ''))
|
|
29
|
+
.includes(profile);
|
|
30
|
+
},
|
|
31
|
+
/** 读一个配置项;没设置返回空串(Hermes 会打“Config key not set”并退出 1) */
|
|
32
|
+
get(key) {
|
|
33
|
+
const r = run(['config', 'get', key]);
|
|
34
|
+
if (r.error || r.status !== 0) return '';
|
|
35
|
+
const value = (r.stdout || '').trim();
|
|
36
|
+
return /^config key not set/i.test(value) ? '' : value;
|
|
37
|
+
},
|
|
38
|
+
/** 当前 profile 的 Telegram 状况:是否已有机器人、指向哪里 */
|
|
39
|
+
telegramState() {
|
|
40
|
+
const token = cli.get('TELEGRAM_BOT_TOKEN');
|
|
41
|
+
const baseUrl = cli.get('platforms.telegram.extra.base_url');
|
|
42
|
+
return { token, baseUrl, enabled: /^(true|1|yes)$/i.test(cli.get('platforms.telegram.enabled')) };
|
|
43
|
+
},
|
|
44
|
+
/** 建 profile(--clone:不带原来的机器人 token);已存在就复用 */
|
|
45
|
+
ensureProfile() {
|
|
46
|
+
if (!profile) return { existed: true, created: false };
|
|
47
|
+
if (cli.profileExists()) return { existed: true, created: false };
|
|
48
|
+
const r = spawnSync(bin, ['profile', 'create', profile, '--clone'], { encoding: 'utf8', env: { ...process.env, NO_COLOR: '1' } });
|
|
49
|
+
if (r.error || r.status !== 0) throw new Error((r.stderr || r.error?.message || `hermes profile create exited ${r.status}`).trim());
|
|
50
|
+
return { existed: false, created: true };
|
|
51
|
+
},
|
|
52
|
+
set(key, value) {
|
|
53
|
+
const r = run(['config', 'set', key, String(value)]);
|
|
54
|
+
if (r.error || r.status !== 0) throw new Error(`hermes config set ${key} failed: ${(r.stderr || r.error?.message || '').trim()}`);
|
|
55
|
+
},
|
|
56
|
+
/** 网关现在是否在跑(`gateway status` 没跑时也退出 0,只能看文字) */
|
|
57
|
+
gatewayRunning() {
|
|
58
|
+
const r = run(['gateway', 'status']);
|
|
59
|
+
return !r.error && r.status === 0 && !/not running/i.test(`${r.stdout || ''}${r.stderr || ''}`);
|
|
60
|
+
},
|
|
61
|
+
/** 让新配置生效:在跑就重启;没跑就拉起来(ensure 起本地运行时,不装服务)。
|
|
62
|
+
* 真机上这两条都要几十秒,给足时间但不许无限等。 */
|
|
63
|
+
restart({ timeout = 150_000 } = {}) {
|
|
64
|
+
const r = spawnSync(bin, [...prefix, 'gateway', cli.gatewayRunning() ? 'restart' : 'ensure'], { encoding: 'utf8', timeout, env: { ...process.env, NO_COLOR: '1' } });
|
|
65
|
+
return !r.error && r.status === 0;
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
return cli;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 同一台 Connelio 服务器的配对地址(已经接着 Connelio 时不必再开独立 profile) */
|
|
72
|
+
export function isConnelioBase(baseUrl, server) {
|
|
73
|
+
if (!baseUrl) return false;
|
|
74
|
+
try {
|
|
75
|
+
const configured = new URL(baseUrl);
|
|
76
|
+
const target = new URL(server);
|
|
77
|
+
return configured.origin === target.origin && /\/tg\/bot\/?$/.test(configured.pathname);
|
|
78
|
+
} catch { return false; }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function configureHermes({ hermes, server, token, ownerTelegramId }) {
|
|
82
|
+
const base = server.replace(/\/+$/, '');
|
|
83
|
+
hermes.set('platforms.telegram.enabled', 'true');
|
|
84
|
+
hermes.set('platforms.telegram.extra.base_url', `${base}/tg/bot`);
|
|
85
|
+
hermes.set('platforms.telegram.extra.base_file_url', `${base}/tg/file/bot`);
|
|
86
|
+
hermes.set('TELEGRAM_BOT_TOKEN', token);
|
|
87
|
+
// Hermes 默认谁都不放行:只放主人本人
|
|
88
|
+
if (ownerTelegramId) hermes.set('TELEGRAM_ALLOWED_USERS', ownerTelegramId);
|
|
89
|
+
}
|
package/src/i18n.mjs
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// 命令行文字:跟系统语言走(中文系统出中文,其余出英文)。只影响终端里的提示,不影响协议。
|
|
2
|
+
export function detectLang(env = process.env, intlLocale) {
|
|
3
|
+
const raw = String(env.CONNELIO_LANG || env.LC_ALL || env.LC_MESSAGES || env.LANG || '').toLowerCase();
|
|
4
|
+
const tag = raw || String(intlLocale || (typeof Intl !== 'undefined' ? Intl.DateTimeFormat().resolvedOptions().locale : '') || 'en').toLowerCase();
|
|
5
|
+
if (tag.startsWith('zh')) return 'zh';
|
|
6
|
+
return 'en';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const COPY = {
|
|
10
|
+
zh: {
|
|
11
|
+
steps: { check: '检查 Hermes', prepare: '建配置', scan: '等手机扫码…', connected: '已连接', restart: '重启 Hermes' },
|
|
12
|
+
createProfile: '已为 Connelio 单独建了一个 Hermes 配置,原来的 Telegram 不受影响。',
|
|
13
|
+
reuseProfile: '这台电脑上已有 connelio 配置,接着用它,原来的 Telegram 不受影响。',
|
|
14
|
+
keepActive: '用的就是当前这个 Hermes 配置(--replace)。',
|
|
15
|
+
alreadyConnelio: '这个 Hermes 已经接着 Connelio,这次会换成新的配对。',
|
|
16
|
+
scanHint: '手机上打开 Connelio:消息 → 右上 + → 接入 Agent,扫上面的码。',
|
|
17
|
+
codeLabel: '配对码',
|
|
18
|
+
manualStart: 'Hermes 没在后台运行,手动启动一次:',
|
|
19
|
+
done: '好了,在手机 Connelio 里给它发条消息试试。',
|
|
20
|
+
errors: {
|
|
21
|
+
noHermes: { why: '这台电脑上没找到 Hermes。', next: '先安装 Hermes(https://hermes-agent.nousresearch.com),装好再跑一次。' },
|
|
22
|
+
profileHasBot: {
|
|
23
|
+
why: (name) => `配置 ${name} 已经接着一个 Telegram 机器人。`,
|
|
24
|
+
next: '想保留它:去掉 --hermes-profile,让 Connelio 自己建配置;确实要替换:加 --replace。',
|
|
25
|
+
},
|
|
26
|
+
profileFailed: { why: '新建 Hermes 配置失败。', next: '跑 hermes profile create connelio --clone 看具体报错,修好再重试。' },
|
|
27
|
+
'start-failed': { why: '连不上 Connelio 服务器。', next: '检查网络;换服务器用 --server https://…,然后重试。' },
|
|
28
|
+
declined: { why: '手机上取消了配对。', next: '想接的话再跑一次:npx connelio connect --hermes' },
|
|
29
|
+
expired: { why: '配对码过期了。', next: '再跑一次,重新出码。' },
|
|
30
|
+
claimed: { why: '这个配对码已经被用过了。', next: '再跑一次,重新出码。' },
|
|
31
|
+
cancelled: { why: '已取消。', next: '重新跑一次即可。' },
|
|
32
|
+
notSupported: { why: '这台服务器还没开配对功能。', next: '升级 Connelio 服务端,或用 --server 指向已开通的服务器。' },
|
|
33
|
+
network: { why: '和服务器通信中断了。', next: '网络恢复后再跑一次。' },
|
|
34
|
+
configWrite: { why: '写入 Hermes 配置失败。', next: '看上面那行报错;修好后重跑本命令。' },
|
|
35
|
+
},
|
|
36
|
+
retry: '再跑一次:npx connelio connect --hermes',
|
|
37
|
+
},
|
|
38
|
+
en: {
|
|
39
|
+
steps: { check: 'Checking Hermes', prepare: 'Preparing config', scan: 'Waiting for your phone…', connected: 'Connected', restart: 'Restarting Hermes' },
|
|
40
|
+
createProfile: 'Connelio got its own Hermes profile. Your existing Telegram bot is untouched.',
|
|
41
|
+
reuseProfile: 'Reusing the connelio profile on this computer. Your existing Telegram bot is untouched.',
|
|
42
|
+
keepActive: 'Using this Hermes profile (--replace).',
|
|
43
|
+
alreadyConnelio: 'This Hermes is already connected to Connelio — this run replaces the pairing.',
|
|
44
|
+
scanHint: 'On your phone open Connelio: Chats → + → Connect an Agent, then scan the code above.',
|
|
45
|
+
codeLabel: 'Pairing code',
|
|
46
|
+
manualStart: 'Hermes is not running in the background. Start it once:',
|
|
47
|
+
done: 'Done. Send it a message from Connelio on your phone.',
|
|
48
|
+
errors: {
|
|
49
|
+
noHermes: { why: 'Hermes was not found on this computer.', next: 'Install Hermes (https://hermes-agent.nousresearch.com), then run this again.' },
|
|
50
|
+
profileHasBot: {
|
|
51
|
+
why: (name) => `The ${name} profile already talks to a Telegram bot.`,
|
|
52
|
+
next: 'To keep it, drop --hermes-profile and let Connelio make its own profile; to replace it, add --replace.',
|
|
53
|
+
},
|
|
54
|
+
profileFailed: { why: 'Could not create the Hermes profile.', next: 'Run hermes profile create connelio --clone to see the error, fix it, then retry.' },
|
|
55
|
+
'start-failed': { why: 'Could not reach the Connelio server.', next: 'Check your network; use --server https://… for another server, then retry.' },
|
|
56
|
+
declined: { why: 'Pairing was declined on the phone.', next: 'Run it again when you want to connect: npx connelio connect --hermes' },
|
|
57
|
+
expired: { why: 'The pairing code expired.', next: 'Run it again for a fresh code.' },
|
|
58
|
+
claimed: { why: 'That pairing code was already used.', next: 'Run it again for a fresh code.' },
|
|
59
|
+
cancelled: { why: 'Cancelled.', next: 'Just run it again.' },
|
|
60
|
+
notSupported: { why: 'This server does not have pairing enabled.', next: 'Upgrade the Connelio server, or point --server at one that has it.' },
|
|
61
|
+
network: { why: 'Lost contact with the server.', next: 'Run it again when the network is back.' },
|
|
62
|
+
configWrite: { why: 'Writing the Hermes settings failed.', next: 'Read the error line above, fix it, then run this again.' },
|
|
63
|
+
},
|
|
64
|
+
retry: 'Run it again: npx connelio connect --hermes',
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const copy = (lang) => COPY[lang] || COPY.en;
|
package/src/pair.mjs
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// 设备码配对:终端出二维码 + 8 位码 → 手机确认 → 一次性领 token。
|
|
2
|
+
import { hostname } from 'node:os';
|
|
3
|
+
import { fitsWide, renderQr } from './qr.mjs';
|
|
4
|
+
import { copy as copyFor, detectLang } from './i18n.mjs';
|
|
5
|
+
|
|
6
|
+
export class PairError extends Error {
|
|
7
|
+
constructor(code, message) {
|
|
8
|
+
super(message || code);
|
|
9
|
+
this.code = code;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const BOLD = '\x1b[1m';
|
|
14
|
+
const RESET = '\x1b[0m';
|
|
15
|
+
const bold = (text) => `${BOLD}${text}${RESET}`;
|
|
16
|
+
|
|
17
|
+
export async function pair({ server, kind, deviceName = prettyHost(), out = process.stdout, signal, lang = detectLang(), columns = out.columns, tty = out.isTTY }) {
|
|
18
|
+
const text = copyFor(lang);
|
|
19
|
+
const base = server.replace(/\/+$/, '');
|
|
20
|
+
let started;
|
|
21
|
+
try {
|
|
22
|
+
started = await fetch(`${base}/api/agent-pair/start`, { method: 'POST', headers: { 'content-type': 'application/json' },
|
|
23
|
+
body: JSON.stringify({ deviceName, kind }), signal });
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (error?.name === 'AbortError') throw new PairError('cancelled');
|
|
26
|
+
throw new PairError('start-failed', error?.message);
|
|
27
|
+
}
|
|
28
|
+
if (started.status === 404) throw new PairError('notSupported');
|
|
29
|
+
if (!started.ok) throw new PairError('start-failed', `HTTP ${started.status}`);
|
|
30
|
+
const pairing = await started.json();
|
|
31
|
+
|
|
32
|
+
// 大码优先:每格两字符,放不进终端才退小码
|
|
33
|
+
const big = fitsWide(pairing.qr, columns);
|
|
34
|
+
out.write(`\n${renderQr(pairing.qr, { small: !big, indent: 2 })}\n\n`);
|
|
35
|
+
out.write(` ${text.scanHint}\n\n`);
|
|
36
|
+
const strong = tty ? bold : (value) => value;
|
|
37
|
+
out.write(` ${strong(text.codeLabel)} ${strong(pairing.code)}\n\n`);
|
|
38
|
+
|
|
39
|
+
const deadline = Date.now() + pairing.expiresInSeconds * 1000;
|
|
40
|
+
let dropped = 0; // 连续连不上就别死等:网络断了要立刻说人话
|
|
41
|
+
while (Date.now() < deadline) {
|
|
42
|
+
if (signal?.aborted) throw new PairError('cancelled');
|
|
43
|
+
const response = await fetch(`${base}/api/agent-pair/poll`, { method: 'POST', headers: { 'content-type': 'application/json' },
|
|
44
|
+
body: JSON.stringify({ pairingId: pairing.pairingId, pollSecret: pairing.pollSecret, timeout: 20 }), signal }).catch(() => null);
|
|
45
|
+
if (!response) {
|
|
46
|
+
if (++dropped >= 3) throw new PairError('network');
|
|
47
|
+
await sleep(2000);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
dropped = 0;
|
|
51
|
+
const result = await response.json().catch(() => ({}));
|
|
52
|
+
if (result.status === 'approved') return result;
|
|
53
|
+
if (result.status === 'denied') throw new PairError('declined');
|
|
54
|
+
if (result.status === 'expired' || response.status === 404) throw new PairError('expired');
|
|
55
|
+
if (result.status === 'claimed') throw new PairError('claimed');
|
|
56
|
+
}
|
|
57
|
+
throw new PairError('expired');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function prettyHost() {
|
|
61
|
+
return hostname().replace(/\.local$/, '').slice(0, 48) || 'Computer';
|
|
62
|
+
}
|
|
63
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
package/src/progress.mjs
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// 每一步一行:终端里先显示「… 这一步」,做完原地变成「✓ 这一步」;
|
|
2
|
+
// 输出被重定向到文件时只留结果行,日志照样看得懂。
|
|
3
|
+
export function createProgress(out = process.stdout, { tty = Boolean(out.isTTY) } = {}) {
|
|
4
|
+
let open = false;
|
|
5
|
+
const close = () => { if (open) { if (tty) out.write('\r\x1b[2K'); open = false; } };
|
|
6
|
+
return {
|
|
7
|
+
starts: () => tty,
|
|
8
|
+
/** 开始一步(可打印「…」),fn 返回后收尾成 ✓;fn 抛错就只写 ✗ 交给调用方 */
|
|
9
|
+
async step(label, fn) {
|
|
10
|
+
if (tty) { close(); out.write(` … ${label}`); open = true; }
|
|
11
|
+
try {
|
|
12
|
+
const value = await fn();
|
|
13
|
+
close();
|
|
14
|
+
out.write(` ✓ ${label}\n`);
|
|
15
|
+
return value;
|
|
16
|
+
} catch (error) {
|
|
17
|
+
close();
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
/** 耗时又不确定多久的一步:先把「…」打出来(重定向到日志也打),做完再收尾 */
|
|
22
|
+
begin(label) {
|
|
23
|
+
close();
|
|
24
|
+
out.write(` … ${label}\n`);
|
|
25
|
+
},
|
|
26
|
+
/** 长时间等待(等手机扫码):这行要一直留着 */
|
|
27
|
+
waiting(label) {
|
|
28
|
+
close();
|
|
29
|
+
if (tty) { out.write(` … ${label}`); open = true; } else out.write(` … ${label}\n`);
|
|
30
|
+
},
|
|
31
|
+
ok(label) {
|
|
32
|
+
close();
|
|
33
|
+
out.write(` ✓ ${label}\n`);
|
|
34
|
+
},
|
|
35
|
+
fail(label, why, next) {
|
|
36
|
+
close();
|
|
37
|
+
out.write(` ✗ ${label}\n`);
|
|
38
|
+
if (why) out.write(` ${why}\n`);
|
|
39
|
+
if (next) out.write(` → ${next}\n`);
|
|
40
|
+
},
|
|
41
|
+
warn(label, next) {
|
|
42
|
+
close();
|
|
43
|
+
out.write(` ! ${label}\n`);
|
|
44
|
+
if (next) out.write(` → ${next}\n`);
|
|
45
|
+
},
|
|
46
|
+
note(line) {
|
|
47
|
+
close();
|
|
48
|
+
if (line) out.write(` ${line}\n`);
|
|
49
|
+
},
|
|
50
|
+
blank() { close(); out.write('\n'); },
|
|
51
|
+
};
|
|
52
|
+
}
|
package/src/qr.mjs
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// 终端二维码:每格两字符(大码,手机好扫),四周留白两格;终端太窄才退回半高小码。
|
|
2
|
+
// 自己按矩阵画,不依赖 ANSI 背景色 —— 复制粘贴到别处也不会变成乱码。
|
|
3
|
+
import QRCode from 'qrcode';
|
|
4
|
+
|
|
5
|
+
export const MARGIN = 2;
|
|
6
|
+
|
|
7
|
+
function matrixOf(text, errorCorrectionLevel) {
|
|
8
|
+
const { modules } = QRCode.create(text, { errorCorrectionLevel });
|
|
9
|
+
return { size: modules.size, dark: (x, y) => x >= 0 && y >= 0 && x < modules.size && y < modules.size && Boolean(modules.get(x, y)) };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** 大码宽度(含留白),用于判断终端放不放得下 */
|
|
13
|
+
export function wideWidth(text, { margin = MARGIN, errorCorrectionLevel = 'M' } = {}) {
|
|
14
|
+
return (matrixOf(text, errorCorrectionLevel).size + margin * 2) * 2;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** 窄终端用:一格一字符、两行并一行(半块字符),宽度减半 */
|
|
18
|
+
export function fitsWide(text, columns, options) {
|
|
19
|
+
return !columns || wideWidth(text, options) <= columns;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function renderQr(text, { small = false, margin = MARGIN, errorCorrectionLevel = 'M', indent = 0 } = {}) {
|
|
23
|
+
const { size, dark } = matrixOf(text, errorCorrectionLevel);
|
|
24
|
+
const pad = ' '.repeat(indent);
|
|
25
|
+
const lines = [];
|
|
26
|
+
const from = -margin;
|
|
27
|
+
const to = size + margin;
|
|
28
|
+
if (!small) {
|
|
29
|
+
for (let y = from; y < to; y++) {
|
|
30
|
+
let line = '';
|
|
31
|
+
for (let x = from; x < to; x++) line += dark(x, y) ? '██' : ' ';
|
|
32
|
+
lines.push(pad + line);
|
|
33
|
+
}
|
|
34
|
+
return lines.join('\n');
|
|
35
|
+
}
|
|
36
|
+
for (let y = from; y < to; y += 2) {
|
|
37
|
+
let line = '';
|
|
38
|
+
for (let x = from; x < to; x++) {
|
|
39
|
+
const top = dark(x, y);
|
|
40
|
+
const bottom = dark(x, y + 1);
|
|
41
|
+
line += top && bottom ? '█' : top ? '▀' : bottom ? '▄' : ' ';
|
|
42
|
+
}
|
|
43
|
+
lines.push(pad + line);
|
|
44
|
+
}
|
|
45
|
+
return lines.join('\n');
|
|
46
|
+
}
|
package/src/runner.mjs
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// 主循环:长轮询收消息 → 按会话排队(同一会话串行、不同会话并行)→ 交给适配器 → 进度卡 / 授权按钮 / 回复。
|
|
2
|
+
//
|
|
3
|
+
// 会话隔离(对外代理的底线):每个会话一个独立目录和独立状态(对话历史 id、附件),适配器只拿到本会话的东西。
|
|
4
|
+
// 访问控制:private 模式只认主人;public 模式谁都能用,但陌生人第一次来先告知"代理由谁运行、内容对方可见"。
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { basename, join } from 'node:path';
|
|
8
|
+
import { readState, sessionDir, writeState } from './config.mjs';
|
|
9
|
+
|
|
10
|
+
const MAX_CHUNK = 3800;
|
|
11
|
+
const APPROVAL_TIMEOUT_MS = 10 * 60_000;
|
|
12
|
+
const PROGRESS_THROTTLE_MS = 1500;
|
|
13
|
+
|
|
14
|
+
export async function runAgent({ api, config, adapter, log = console.log, signal }) {
|
|
15
|
+
const me = await api.getMe();
|
|
16
|
+
const owner = config.owner;
|
|
17
|
+
const pendingApprovals = new Map(); // id → {resolve, conversationId, messageId, title}
|
|
18
|
+
const queues = new Map(); // conversationId → Promise chain
|
|
19
|
+
const told = new Set(); // 已经提示过的 conversationId:accountId
|
|
20
|
+
let offset = 0;
|
|
21
|
+
let leaseWarned = false;
|
|
22
|
+
|
|
23
|
+
if (config.conversationId && !config.greeted) {
|
|
24
|
+
await api.sendMessage(config.conversationId, `✅ ${me.displayName} is online`).catch(() => undefined);
|
|
25
|
+
config.greeted = true;
|
|
26
|
+
}
|
|
27
|
+
log(`[connelio] ${me.displayName} (@${me.username}) is online — mode=${config.mode}, agent=${adapter.name}`);
|
|
28
|
+
|
|
29
|
+
while (!signal?.aborted) {
|
|
30
|
+
let batch;
|
|
31
|
+
try {
|
|
32
|
+
batch = await api.getUpdates({ offset, timeout: 25, signal });
|
|
33
|
+
leaseWarned = false;
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (signal?.aborted) break;
|
|
36
|
+
if (error.code === 'BOT_LEASE_HELD') {
|
|
37
|
+
if (!leaseWarned) log('[connelio] This agent is already running on another computer. Waiting…');
|
|
38
|
+
leaseWarned = true;
|
|
39
|
+
await sleep(10_000);
|
|
40
|
+
} else if (error.status === 401) {
|
|
41
|
+
log('[connelio] The token was revoked or the agent was disabled. Run `npx connelio connect` again.');
|
|
42
|
+
return;
|
|
43
|
+
} else {
|
|
44
|
+
log(`[connelio] connection problem (${error.message}), retrying…`);
|
|
45
|
+
await sleep(3000);
|
|
46
|
+
}
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
for (const item of batch.result ?? []) {
|
|
50
|
+
if (item.callbackQuery) { await onCallback(item.callbackQuery); continue; }
|
|
51
|
+
if (item.message) enqueue(item.message, item.chat);
|
|
52
|
+
}
|
|
53
|
+
if (batch.cursor?.nextOffset) offset = batch.cursor.nextOffset;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function onCallback(tap) {
|
|
57
|
+
const match = /^ap:([0-9a-f-]{36}):(allow|always|deny)$/.exec(tap.data || '');
|
|
58
|
+
if (!match) { await api.answerCallback(tap.id); return; }
|
|
59
|
+
const pending = pendingApprovals.get(match[1]);
|
|
60
|
+
if (!pending) { await api.answerCallback(tap.id, 'Expired'); return; }
|
|
61
|
+
if (tap.from?.id !== owner.accountId) { await api.answerCallback(tap.id, 'Only the owner can approve this.'); return; }
|
|
62
|
+
pendingApprovals.delete(match[1]);
|
|
63
|
+
await api.answerCallback(tap.id, match[2] === 'deny' ? 'Denied' : 'Allowed');
|
|
64
|
+
const label = match[2] === 'deny' ? '✗ Denied' : match[2] === 'always' ? '✓ Allowed for this chat' : '✓ Allowed';
|
|
65
|
+
await api.editMessage(pending.messageId, { text: `${pending.title}\n${label}` }).catch(() => undefined);
|
|
66
|
+
await api.editButtons(pending.messageId, null).catch(() => undefined);
|
|
67
|
+
pending.resolve(match[2]);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function enqueue(message, chat) {
|
|
71
|
+
const key = chat.id;
|
|
72
|
+
const previous = queues.get(key) ?? Promise.resolve();
|
|
73
|
+
const next = previous.then(() => handle(message, chat)).catch((error) => log(`[connelio] ${error?.stack || error}`));
|
|
74
|
+
queues.set(key, next);
|
|
75
|
+
next.finally(() => { if (queues.get(key) === next) queues.delete(key); });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function handle(message, chat) {
|
|
79
|
+
const sender = message.sender ?? {};
|
|
80
|
+
const isOwner = sender.id === owner.accountId;
|
|
81
|
+
const conversationId = chat.id;
|
|
82
|
+
let text = String(message.content ?? '');
|
|
83
|
+
if (chat.type !== 'direct') {
|
|
84
|
+
const addressed = text.includes(`@${me.username}`) || (message.mentionedAccountIds ?? []).includes(me.accountId)
|
|
85
|
+
|| message.repliedTo?.senderId === me.accountId || text.startsWith('/');
|
|
86
|
+
if (!addressed) return;
|
|
87
|
+
text = text.replaceAll(`@${me.username}`, '').trim();
|
|
88
|
+
}
|
|
89
|
+
const toldKey = `${conversationId}:${sender.id}`;
|
|
90
|
+
if (config.mode === 'private' && !isOwner) {
|
|
91
|
+
if (!told.has(toldKey)) { told.add(toldKey); await api.sendMessage(conversationId, 'This agent only works for its owner.'); }
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (config.mode === 'public' && !isOwner && !told.has(toldKey)) {
|
|
95
|
+
told.add(toldKey);
|
|
96
|
+
const ownerName = owner.username ? `@${owner.username}` : 'its owner';
|
|
97
|
+
await api.sendMessage(conversationId, `ℹ️ This agent is run by ${ownerName} on their own computer. Anything you send here is visible to them.`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const dir = sessionDir(me.username, conversationId);
|
|
101
|
+
const attachments = [];
|
|
102
|
+
if (message.attachmentUrl) {
|
|
103
|
+
try {
|
|
104
|
+
const bytes = await api.download(message.attachmentUrl);
|
|
105
|
+
const inbox = join(dir, 'inbox');
|
|
106
|
+
mkdirSync(inbox, { recursive: true });
|
|
107
|
+
const name = `${Date.now()}-${basename(message.attachmentName || 'file').replace(/[^\w.\-]/g, '_')}`;
|
|
108
|
+
writeFileSync(join(inbox, name), bytes);
|
|
109
|
+
attachments.push(join(inbox, name));
|
|
110
|
+
if (text === '[Image]' || text === message.attachmentName) text = '';
|
|
111
|
+
} catch (error) { log(`[connelio] attachment download failed: ${error.message}`); }
|
|
112
|
+
}
|
|
113
|
+
if (!text && !attachments.length) return;
|
|
114
|
+
|
|
115
|
+
const progress = createProgress(conversationId);
|
|
116
|
+
const typing = setInterval(() => void api.typing(conversationId), 2000);
|
|
117
|
+
void api.typing(conversationId);
|
|
118
|
+
try {
|
|
119
|
+
const result = await adapter.run({
|
|
120
|
+
text, attachments, conversationId, chatType: chat.type, sender, isOwner, mode: config.mode,
|
|
121
|
+
dir,
|
|
122
|
+
// 只有主人在 private 模式下才进你指定的项目目录;其余一律关在本会话的隔间里
|
|
123
|
+
cwd: config.mode === 'private' && isOwner && config.dir ? config.dir : dir,
|
|
124
|
+
knowledge: config.knowledge ?? null,
|
|
125
|
+
state: () => readState(dir),
|
|
126
|
+
setState: (patch) => writeState(dir, { ...readState(dir), ...patch }),
|
|
127
|
+
progress,
|
|
128
|
+
askApproval: (title, detail) => askApproval(conversationId, title, detail),
|
|
129
|
+
log,
|
|
130
|
+
});
|
|
131
|
+
await progress.finish();
|
|
132
|
+
if (result?.text) await reply(conversationId, result.text);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
await progress.finish('✗ Failed');
|
|
135
|
+
await reply(conversationId, `Sorry, something went wrong: ${error?.message || error}`);
|
|
136
|
+
} finally {
|
|
137
|
+
clearInterval(typing);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function createProgress(conversationId) {
|
|
142
|
+
let messageId = null, lines = [], last = 0, timer = null, steps = 0;
|
|
143
|
+
const flush = async () => {
|
|
144
|
+
timer = null; last = Date.now();
|
|
145
|
+
const body = `⏳ Working…\n${lines.slice(-6).join('\n')}`.slice(0, MAX_CHUNK);
|
|
146
|
+
if (!messageId) messageId = (await api.sendMessage(conversationId, body).catch(() => null))?.id ?? null;
|
|
147
|
+
else await api.editMessage(messageId, { text: body }).catch(() => undefined);
|
|
148
|
+
};
|
|
149
|
+
return {
|
|
150
|
+
step(line) {
|
|
151
|
+
steps += 1;
|
|
152
|
+
lines.push(`• ${String(line).slice(0, 160)}`);
|
|
153
|
+
if (timer) return;
|
|
154
|
+
const wait = Math.max(0, PROGRESS_THROTTLE_MS - (Date.now() - last));
|
|
155
|
+
timer = setTimeout(() => void flush(), wait);
|
|
156
|
+
},
|
|
157
|
+
async finish(label) {
|
|
158
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
159
|
+
if (!messageId) return;
|
|
160
|
+
await api.editMessage(messageId, { text: `${label ?? `✓ Done · ${steps} step${steps === 1 ? '' : 's'}`}\n${lines.slice(-6).join('\n')}`.slice(0, MAX_CHUNK) }).catch(() => undefined);
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function askApproval(conversationId, title, detail) {
|
|
166
|
+
const id = randomUUID();
|
|
167
|
+
const body = `🔐 ${title}${detail ? `\n${String(detail).slice(0, 600)}` : ''}`;
|
|
168
|
+
const sent = await api.sendMessage(conversationId, body, { inline_keyboard: [[
|
|
169
|
+
{ text: 'Allow', callback_data: `ap:${id}:allow`, style: 'primary' },
|
|
170
|
+
{ text: 'Always in this chat', callback_data: `ap:${id}:always` },
|
|
171
|
+
{ text: 'Deny', callback_data: `ap:${id}:deny`, style: 'danger' },
|
|
172
|
+
]] });
|
|
173
|
+
return new Promise((resolve) => {
|
|
174
|
+
pendingApprovals.set(id, { resolve, conversationId, messageId: sent.id, title: body });
|
|
175
|
+
setTimeout(() => {
|
|
176
|
+
if (!pendingApprovals.has(id)) return;
|
|
177
|
+
pendingApprovals.delete(id);
|
|
178
|
+
void api.editButtons(sent.id, null).catch(() => undefined);
|
|
179
|
+
resolve('deny');
|
|
180
|
+
}, APPROVAL_TIMEOUT_MS).unref?.();
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function reply(conversationId, text) {
|
|
185
|
+
for (const chunk of split(String(text))) await api.sendMessage(conversationId, chunk);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function split(text) {
|
|
190
|
+
if (text.length <= MAX_CHUNK) return [text];
|
|
191
|
+
const out = [];
|
|
192
|
+
let rest = text;
|
|
193
|
+
while (rest.length > MAX_CHUNK) {
|
|
194
|
+
let cut = rest.lastIndexOf('\n', MAX_CHUNK);
|
|
195
|
+
if (cut < MAX_CHUNK * 0.5) cut = MAX_CHUNK;
|
|
196
|
+
out.push(rest.slice(0, cut));
|
|
197
|
+
rest = rest.slice(cut).replace(/^\n/, '');
|
|
198
|
+
}
|
|
199
|
+
if (rest) out.push(rest);
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|