@stage-labs/metro 0.1.0-beta.4 → 0.1.0-beta.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +138 -63
- package/dist/cache.js +74 -0
- package/dist/cli/actions.js +156 -0
- package/dist/cli/config.js +184 -0
- package/dist/cli/index.js +162 -0
- package/dist/cli/skill.js +62 -0
- package/dist/cli/util.js +72 -0
- package/dist/codex-rc.js +236 -0
- package/dist/dispatcher.js +112 -0
- package/dist/history.js +84 -0
- package/dist/ipc.js +71 -0
- package/dist/log.js +7 -2
- package/dist/stations/discord.js +198 -0
- package/dist/stations/index.js +73 -0
- package/dist/{helpers/telegram-format.js → stations/telegram-md.js} +9 -14
- package/dist/stations/telegram-upload.js +113 -0
- package/dist/stations/telegram.js +235 -0
- package/docs/agents.md +168 -0
- package/docs/uri-scheme.md +71 -0
- package/package.json +6 -3
- package/skills/metro/SKILL.md +220 -0
- package/dist/agents/claude.js +0 -207
- package/dist/agents/codex.js +0 -207
- package/dist/agents/types.js +0 -2
- package/dist/channels/discord.js +0 -104
- package/dist/channels/telegram.js +0 -189
- package/dist/cli.js +0 -221
- package/dist/helpers/scope-cache.js +0 -65
- package/dist/helpers/streaming.js +0 -209
- package/dist/helpers/turn.js +0 -40
- package/dist/orchestrator.js +0 -208
package/README.md
CHANGED
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
# Metro
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> **A live JSON stream of Telegram + Discord messages for your local Claude Code / Codex session.**
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Metro is a small daemon you launch from inside your agent. It connects to Discord and Telegram, emits each inbound as one JSON line on stdout (which Claude Code's `Monitor` consumes natively, and Codex picks up via an app-server WebSocket push), and exposes a tiny CLI — `metro reply`, `metro send`, `metro edit`, `metro react`, `metro download`, `metro fetch`, `metro notify` — for posting back. Cross-agent: any agent can ping any other via `metro send metro://claude/<topic>` and the daemon re-emits it on the stream.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
```
|
|
8
|
+
[Claude Code session]
|
|
9
|
+
|
|
10
|
+
$ metro & # backgrounded
|
|
11
|
+
$ Monitor( … metro's stdout … )
|
|
12
|
+
|
|
13
|
+
>>> {"type":"inbound","station":"discord","line":"metro://discord/123…","messageId":"9876",
|
|
14
|
+
"text":"@bot we got a 5xx spike from /v1/sync. Look?","mentionsBot":true,…}
|
|
15
|
+
|
|
16
|
+
[I'd run git log + read services/sync.ts, then…]
|
|
17
|
+
Bash: metro reply metro://discord/123… 9876 "three deploys in the last 24h…"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The agent owns its own streaming, tool calls, and reply timing. Metro is the wire.
|
|
21
|
+
|
|
22
|
+
---
|
|
13
23
|
|
|
14
24
|
## Quickstart
|
|
15
25
|
|
|
@@ -18,100 +28,165 @@ npm install -g @stage-labs/metro@beta # or: bun add -g @stage-labs/metro@beta
|
|
|
18
28
|
|
|
19
29
|
metro setup discord <token> # https://discord.com/developers/applications
|
|
20
30
|
metro setup telegram <token> # https://t.me/BotFather
|
|
21
|
-
|
|
22
31
|
metro doctor # verify
|
|
23
|
-
metro # run the
|
|
32
|
+
metro # run the daemon
|
|
24
33
|
```
|
|
25
34
|
|
|
26
|
-
|
|
35
|
+
Requires **Node ≥ 22 or Bun ≥ 1.3**. Metro doesn't launch Claude or Codex — you do, and the agent launches metro. See [`docs/agents.md`](docs/agents.md).
|
|
27
36
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
37
|
+
In **Discord**: DM the bot, or `@<bot>` in any channel. In **Telegram**: DM, or `@<bot>` in a forum supergroup. Every inbound becomes one JSON line on `metro`'s stdout.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Architecture
|
|
31
42
|
|
|
32
|
-
How would Codex have done this? with codex
|
|
33
|
-
→ routes this turn to Codex; stays Codex on subsequent turns
|
|
34
43
|
```
|
|
44
|
+
Discord gateway ──┐
|
|
45
|
+
Telegram poller ──┤
|
|
46
|
+
│
|
|
47
|
+
├─▶ metro daemon ───▶ stdout (JSON events; Claude Code's Monitor reads here)
|
|
48
|
+
│ ───▶ codex-rc WebSocket (Codex turn/start; opt-in)
|
|
49
|
+
│ ◀── IPC Unix socket (metro notify / metro send to agent lines)
|
|
50
|
+
│
|
|
51
|
+
agent CLI calls ──┴── REST → Discord / Telegram (metro reply / send / edit / react / download / fetch)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
- **Inversion of control.** The agent (Claude Code, Codex) launches `metro`, not the other way around. Metro never spawns an agent process.
|
|
55
|
+
- **Single daemon per machine.** Lockfile at `$METRO_STATE_DIR/.tail-lock` enforces singleton.
|
|
56
|
+
- **Codex push (opt-in).** Set `METRO_CODEX_RC=ws://127.0.0.1:8421` and metro pushes each event via JSON-RPC `turn/start` to the Codex app-server. Codex's TUI must be attached with `--remote` to the same URL.
|
|
57
|
+
- **Cross-agent notification.** `metro send metro://claude/<topic>` or `metro notify metro://codex/<topic>` routes through the daemon's IPC socket; the daemon re-emits on its stdout (and pushes to codex-rc), so the peer agent sees it.
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Stations
|
|
62
|
+
|
|
63
|
+
Each endpoint is a **station** with declared capabilities:
|
|
64
|
+
|
|
65
|
+
| Station | Kind | Modalities | Features | Config |
|
|
66
|
+
|------------|-------|---------------|-------------------------------------------------------|-------------------------------------------------------------|
|
|
67
|
+
| `discord` | chat | text + image | reply, send, edit, react, download, fetch | `DISCORD_BOT_TOKEN` + Message Content Intent |
|
|
68
|
+
| `telegram` | chat | text + image | reply, send, edit, react, download | `TELEGRAM_BOT_TOKEN` |
|
|
69
|
+
| `claude` | agent | text | notify | watches metro stdout via Claude Code's `Monitor` |
|
|
70
|
+
| `codex` | agent | text | notify | set `METRO_CODEX_RC=ws://…` to push |
|
|
35
71
|
|
|
36
|
-
|
|
72
|
+
Run `metro stations` to see live config status (`✓` configured, `✗` not, `·` informational).
|
|
37
73
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
74
|
+
Behaviors worth knowing:
|
|
75
|
+
- **No streaming / no edit machinery in metro.** The agent runs the show; metro is one-shot REST.
|
|
76
|
+
- **No link previews.** Outgoing messages set `link_preview_options.is_disabled` on Telegram and `SUPPRESS_EMBEDS` on Discord.
|
|
77
|
+
- **Image attachments inbound** — `[image]` placeholders surface inline in `text`; the agent calls `metro download` to materialize them. 20 MB cap.
|
|
78
|
+
- **Rich content outbound.** `metro send` / `reply` accept `--image=<path>` (repeatable: albums of up to 10), `--document=<path>` (repeatable), `--voice=<path>` (single voice message — Telegram renders the voice bubble), and `--buttons='[[{"text":"…","url":"…"}]]'` for inline URL-button keyboards. `metro edit` accepts `--buttons` (pass `'[]'` to clear). 20 MB / file. URL buttons only for now — no callback/interactive components.
|
|
79
|
+
- **Telegram non-forum groups are skipped.** No thread boundary to scope on.
|
|
42
80
|
|
|
43
|
-
|
|
81
|
+
---
|
|
44
82
|
|
|
45
|
-
|
|
83
|
+
## Lines
|
|
46
84
|
|
|
47
|
-
|
|
48
|
-
- **@-mention the bot in a forum supergroup's General topic** — metro auto-creates a new topic for the conversation (Discord-style "thread from message") and posts a deep link back in General so the new topic is one tap away. Subsequent messages in that topic route automatically.
|
|
49
|
-
- **Inside an existing custom topic** — routes to that topic's scope on every message.
|
|
85
|
+
Every conversational scope is identified by a **Line** — a URI in the form `metro://<station>/<path>`:
|
|
50
86
|
|
|
51
|
-
|
|
87
|
+
```
|
|
88
|
+
metro://discord/1234567890123456789
|
|
89
|
+
metro://telegram/-1001234567890 # main chat / DM
|
|
90
|
+
metro://telegram/-1001234567890/42 # forum topic 42
|
|
91
|
+
metro://claude/deploys # agent notification sink
|
|
92
|
+
metro://codex/ci
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Anyone can post to a line via [`metro send`](#cli) — daemon required only for agent lines. Full grammar in [`docs/uri-scheme.md`](docs/uri-scheme.md).
|
|
52
96
|
|
|
53
|
-
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## CLI
|
|
54
100
|
|
|
55
101
|
```
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
102
|
+
metro Run the daemon (emits JSON events on stdout).
|
|
103
|
+
metro setup [telegram|discord <token>] Save token, or show status.
|
|
104
|
+
metro setup clear [telegram|discord|all] Remove tokens.
|
|
105
|
+
metro doctor Health check.
|
|
106
|
+
metro stations List stations + capabilities.
|
|
107
|
+
metro lines List recently-seen conversations.
|
|
108
|
+
metro send <line> <text> [--image=…]… [--document=…]… [--voice=…] [--buttons=…]
|
|
109
|
+
Post a fresh message; --image/--document repeat for albums.
|
|
110
|
+
metro reply <line> <message_id> <text> [--image|--document|--voice|--buttons]
|
|
111
|
+
Threaded reply (same flags as send).
|
|
112
|
+
metro edit <line> <message_id> <text> [--buttons=<json>]
|
|
113
|
+
Edit a previously-sent message (text + URL-button keyboard).
|
|
114
|
+
metro react <line> <message_id> <emoji> Set or clear ('') a reaction.
|
|
115
|
+
metro download <line> <message_id> [--out=<dir>]
|
|
116
|
+
Download image attachments to disk.
|
|
117
|
+
metro fetch <line> [--limit=N] Recent-message lookback (Discord only).
|
|
118
|
+
metro notify <line> <text> [--from=<line>] Emit a notification on the daemon's stream.
|
|
119
|
+
metro history [--limit=N] [--line=…] [--station=…] [--kind=…] [--from=…] [--text=…] [--since=…]
|
|
120
|
+
Universal message log (every inbound + outbound), newest first.
|
|
121
|
+
metro update Upgrade in place.
|
|
61
122
|
```
|
|
62
123
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
-
|
|
67
|
-
-
|
|
68
|
-
-
|
|
69
|
-
-
|
|
70
|
-
-
|
|
124
|
+
All commands accept `--json`. `reply` / `send` / `edit` read multi-line `<text>` from stdin if no positional is given.
|
|
125
|
+
|
|
126
|
+
**State files** in `$METRO_STATE_DIR` (default `~/.cache/metro`):
|
|
127
|
+
- `AGENTS.md` — agent skill copied from the package on every start (so the path is stable across upgrades)
|
|
128
|
+
- `history.jsonl` — universal message log (one JSON object per line; append-only). Read with `metro history`. Each entry carries `from` and `to` as universal participant URIs (`metro://<station>/user/<id>`, `metro://claude/<topic>`, `metro://codex/<topic>`) plus a `fromName` display field. The dispatcher auto-detects the consuming agent for `to` on inbound (`$CLAUDECODE` → `metro://claude/agent`; `$METRO_CODEX_RC`/`$CODEX_HOME` → `metro://codex/agent`).
|
|
129
|
+
- `bot-ids.json` — `{discord: "<botUserId>", telegram: "<botUserId>"}` written by the daemon on startup (cached for the few historical lookups that still need a bot identity).
|
|
130
|
+
- `lines.json` — line → last-seen / name cache (read by `metro lines`)
|
|
131
|
+
- `.tail-lock` — dispatcher pid
|
|
132
|
+
- `metro.sock` — daemon IPC socket
|
|
133
|
+
- `telegram-offset.json` — last processed update id
|
|
71
134
|
|
|
72
|
-
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## Configuration
|
|
73
138
|
|
|
74
139
|
| Variable | Default | Description |
|
|
75
140
|
|---|---|---|
|
|
76
141
|
| `TELEGRAM_BOT_TOKEN`, `DISCORD_BOT_TOKEN` | — | Bot tokens. `metro setup` writes them here. |
|
|
142
|
+
| `METRO_CODEX_RC` | — | Codex app-server URL (`ws://…`, `wss://…`, `unix:///…`). When set, the daemon pushes each event via JSON-RPC `turn/start`. |
|
|
77
143
|
| `METRO_CONFIG_DIR` | `~/.config/metro` | Where the global `.env` lives. |
|
|
78
|
-
| `METRO_STATE_DIR` | `~/.cache/metro` | Lockfile,
|
|
144
|
+
| `METRO_STATE_DIR` | `~/.cache/metro` | Lockfile, line cache, IPC socket, telegram offset. |
|
|
79
145
|
| `METRO_LOG_LEVEL` | `info` | `trace` / `debug` / `info` / `warn` / `error` / `fatal`. |
|
|
80
146
|
|
|
81
|
-
|
|
147
|
+
Precedence: process env → `./.env` → `$METRO_CONFIG_DIR/.env`. Logs go to stderr.
|
|
148
|
+
|
|
149
|
+
---
|
|
82
150
|
|
|
83
|
-
## Develop
|
|
151
|
+
## Develop
|
|
84
152
|
|
|
85
153
|
```bash
|
|
86
154
|
git clone https://github.com/bonustrack/metro && cd metro
|
|
87
155
|
bun install && bun run build
|
|
88
156
|
bun link # makes `metro` resolve to this checkout
|
|
89
157
|
METRO_LOG_LEVEL=debug metro
|
|
158
|
+
|
|
159
|
+
bun run typecheck # ts
|
|
160
|
+
bun run lint # eslint
|
|
90
161
|
```
|
|
91
162
|
|
|
92
|
-
|
|
163
|
+
Source map:
|
|
164
|
+
|
|
165
|
+
- [`src/cli/`](src/cli/) — `metro` binary entry ([`index.ts`](src/cli/index.ts)) + admin commands ([`config.ts`](src/cli/config.ts): setup/doctor/update) + shared CLI primitives ([`util.ts`](src/cli/util.ts)).
|
|
166
|
+
- [`src/dispatcher.ts`](src/dispatcher.ts) — the daemon: starts each chat station, emits events on stdout, listens on the IPC socket, optionally pushes to codex-rc.
|
|
167
|
+
- [`src/stations/`](src/stations/) — Line URI scheme + ChatStation interface + listing ([`index.ts`](src/stations/index.ts)), the two chat-station impls (`discord.ts`, `telegram.ts`), plus the Telegram markdown→HTML helper ([`telegram-md.ts`](src/stations/telegram-md.ts)). Agent stations have no impl — they're notification sinks expressed only as URI prefixes.
|
|
168
|
+
- [`src/codex-rc.ts`](src/codex-rc.ts) — Codex app-server WebSocket push client.
|
|
169
|
+
- [`src/ipc.ts`](src/ipc.ts) — Unix-socket IPC between the daemon and one-shot CLI commands.
|
|
170
|
+
- [`src/cache.ts`](src/cache.ts) — in-memory line cache with debounced flush to `lines.json`.
|
|
171
|
+
- [`docs/uri-scheme.md`](docs/uri-scheme.md) specs the Line format; [`docs/agents.md`](docs/agents.md) is the in-context skill for agents.
|
|
93
172
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
173
|
+
CI runs typecheck + lint + build on every PR via [`.github/workflows/ci.yml`](.github/workflows/ci.yml).
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## Caveats
|
|
178
|
+
|
|
179
|
+
- **No allowlist.** Anyone who can DM/`@`-mention your bot can produce events. Run against bots you own.
|
|
180
|
+
- **Telegram bot privacy is on by default**, which can block `@`-mentions in groups. Disable via [@BotFather](https://t.me/BotFather) → Bot Settings → Group Privacy, then kick + re-invite.
|
|
181
|
+
- **Telegram non-forum groups are skipped.** No thread boundary to scope on. DMs and forum topics work normally.
|
|
182
|
+
- **Telegram fetch isn't supported** (bot API doesn't expose history); `metro fetch` returns `[]` on Telegram lines.
|
|
183
|
+
|
|
184
|
+
---
|
|
102
185
|
|
|
103
186
|
## Uninstall
|
|
104
187
|
|
|
105
188
|
```bash
|
|
106
189
|
metro setup clear
|
|
107
|
-
rm -rf ~/.cache/metro
|
|
190
|
+
rm -rf ~/.cache/metro
|
|
108
191
|
npm uninstall -g @stage-labs/metro
|
|
109
192
|
```
|
|
110
|
-
|
|
111
|
-
## Caveats
|
|
112
|
-
|
|
113
|
-
- **No allowlist.** Anyone who can DM/@-mention your bot can spawn an agent session. Run against bots you own.
|
|
114
|
-
- **Per-agent histories are separate.** Switching with `with codex` mid-scope spins up a fresh Codex session — it has no idea what you discussed with Claude in the same scope. Each agent only sees what was sent to it.
|
|
115
|
-
- **One agent missing is OK.** If only `claude` or only `codex` is installed/authenticated, metro still starts; messages asking for the missing one surface an error in chat.
|
|
116
|
-
- **Telegram non-forum groups are skipped.** Without a per-topic thread boundary the routing model breaks down. DMs and forum topics (incl. auto-created ones from General) work normally.
|
|
117
|
-
- **Telegram bot privacy.** Default Telegram bot privacy is *enabled*, which can block group messages even with @-mentions. Disable in [@BotFather](https://t.me/BotFather) → Bot Settings → Group Privacy → Turn off, then kick + re-invite the bot.
|
package/dist/cache.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/** Per-machine caches: seen lines (lines.json) + bot ids (bot-ids.json). */
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { errMsg, log } from './log.js';
|
|
5
|
+
import { STATE_DIR } from './paths.js';
|
|
6
|
+
import { Line } from './stations/index.js';
|
|
7
|
+
const cacheFile = join(STATE_DIR, 'lines.json');
|
|
8
|
+
const FLUSH_DELAY_MS = 5_000;
|
|
9
|
+
let cache = null;
|
|
10
|
+
let dirty = false;
|
|
11
|
+
let flushTimer = null;
|
|
12
|
+
function read() {
|
|
13
|
+
if (cache)
|
|
14
|
+
return cache;
|
|
15
|
+
if (!existsSync(cacheFile))
|
|
16
|
+
return cache = {};
|
|
17
|
+
try {
|
|
18
|
+
cache = JSON.parse(readFileSync(cacheFile, 'utf8'));
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
log.warn({ err: errMsg(err), path: cacheFile }, 'lines cache read failed; treating as empty');
|
|
22
|
+
cache = {};
|
|
23
|
+
}
|
|
24
|
+
return cache;
|
|
25
|
+
}
|
|
26
|
+
function flush() {
|
|
27
|
+
if (!dirty || !cache)
|
|
28
|
+
return;
|
|
29
|
+
try {
|
|
30
|
+
writeFileSync(cacheFile, JSON.stringify(cache, null, 2));
|
|
31
|
+
dirty = false;
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
log.warn({ err: errMsg(err), path: cacheFile }, 'lines cache write failed');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
process.on('exit', flush);
|
|
38
|
+
export function noteSeen(line, name) {
|
|
39
|
+
const c = read();
|
|
40
|
+
const entry = c[line] ??= { createdAt: new Date().toISOString() };
|
|
41
|
+
entry.lastSeenAt = new Date().toISOString();
|
|
42
|
+
if (name && entry.name !== name)
|
|
43
|
+
entry.name = name;
|
|
44
|
+
dirty = true;
|
|
45
|
+
if (!flushTimer)
|
|
46
|
+
flushTimer = setTimeout(() => { flushTimer = null; flush(); }, FLUSH_DELAY_MS);
|
|
47
|
+
}
|
|
48
|
+
export const listLines = () => Object.entries(read()).map(([line, entry]) => ({ line: line, entry }));
|
|
49
|
+
/** Bot identity cache: `{discord: "<userId>", telegram: "<userId>"}`. Daemon writes after getMe(). */
|
|
50
|
+
const botIdsFile = join(STATE_DIR, 'bot-ids.json');
|
|
51
|
+
export function saveBotId(station, id) {
|
|
52
|
+
const cur = existsSync(botIdsFile)
|
|
53
|
+
? JSON.parse(readFileSync(botIdsFile, 'utf8')) : {};
|
|
54
|
+
if (cur[station] === id)
|
|
55
|
+
return;
|
|
56
|
+
cur[station] = id;
|
|
57
|
+
try {
|
|
58
|
+
writeFileSync(botIdsFile, JSON.stringify(cur, null, 2));
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
log.warn({ err: errMsg(err) }, 'bot-ids cache write failed');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** Resolve the bot's URI for a station. Returns `metro://<station>/bot/<id>` or the placeholder. */
|
|
65
|
+
export function botLine(station) {
|
|
66
|
+
try {
|
|
67
|
+
const ids = existsSync(botIdsFile)
|
|
68
|
+
? JSON.parse(readFileSync(botIdsFile, 'utf8')) : {};
|
|
69
|
+
return ids[station] ? Line.bot(station, ids[station]) : `metro://${station}/bot`;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return `metro://${station}/bot`;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/** CLI action handlers: send/reply/edit/react/download/fetch/notify + helpers. */
|
|
2
|
+
import { mkdirSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { errMsg } from '../log.js';
|
|
6
|
+
import { DiscordStation } from '../stations/discord.js';
|
|
7
|
+
import { TelegramStation } from '../stations/telegram.js';
|
|
8
|
+
import { ipcCall } from '../ipc.js';
|
|
9
|
+
import { agentSelf, appendHistory, lookupEntry, mintId, resolvePlatformId, } from '../history.js';
|
|
10
|
+
import { asLine, Line } from '../stations/index.js';
|
|
11
|
+
import { loadMetroEnv } from '../paths.js';
|
|
12
|
+
import { emit, flagList, flagOne, isJson, need, resolveText, writeJson, } from './util.js';
|
|
13
|
+
export function chatStationOf(line) {
|
|
14
|
+
const s = Line.station(line);
|
|
15
|
+
if (s === 'discord')
|
|
16
|
+
return new DiscordStation();
|
|
17
|
+
if (s === 'telegram')
|
|
18
|
+
return new TelegramStation();
|
|
19
|
+
throw new Error(`no chat station for line "${line}" (try metro://{discord|telegram}/...)`);
|
|
20
|
+
}
|
|
21
|
+
function parseButtons(f) {
|
|
22
|
+
const raw = flagOne(f, 'buttons');
|
|
23
|
+
if (raw === undefined)
|
|
24
|
+
return undefined;
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(raw);
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
throw new Error(`--buttons must be JSON like '[[{"text":"…","url":"…"}]]': ${errMsg(err)}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function richOpts(f) {
|
|
33
|
+
const opts = {};
|
|
34
|
+
const images = flagList(f, 'image');
|
|
35
|
+
if (images.length)
|
|
36
|
+
opts.images = images;
|
|
37
|
+
const documents = flagList(f, 'document');
|
|
38
|
+
if (documents.length)
|
|
39
|
+
opts.documents = documents;
|
|
40
|
+
const voice = flagOne(f, 'voice');
|
|
41
|
+
if (voice)
|
|
42
|
+
opts.voice = voice;
|
|
43
|
+
const buttons = parseButtons(f);
|
|
44
|
+
if (buttons)
|
|
45
|
+
opts.buttons = buttons;
|
|
46
|
+
return opts;
|
|
47
|
+
}
|
|
48
|
+
/** Append an outbound action to history.jsonl; pass `to` = the original sender when replying/reacting. */
|
|
49
|
+
function logOutbound(f, kind, line, text, platformMessageId, replyTo, opts, emoji, to) {
|
|
50
|
+
const id = mintId();
|
|
51
|
+
const station = Line.station(line) ?? '?';
|
|
52
|
+
const fromOverride = flagOne(f, 'from');
|
|
53
|
+
const from = fromOverride ? asLine(fromOverride) : agentSelf();
|
|
54
|
+
appendHistory({
|
|
55
|
+
id, ts: new Date().toISOString(), kind, station, line, from, to: to ?? line,
|
|
56
|
+
text, platformMessageId, replyTo, emoji,
|
|
57
|
+
attachments: [...(opts?.images ?? []), ...(opts?.documents ?? []), ...(opts?.voice ? [opts.voice] : [])],
|
|
58
|
+
});
|
|
59
|
+
return id;
|
|
60
|
+
}
|
|
61
|
+
/** When replying/reacting/editing, the recipient is the original message's sender (if we have it). */
|
|
62
|
+
const recipientFor = (idOrPlatform) => lookupEntry(idOrPlatform)?.from;
|
|
63
|
+
export async function cmdSend(p, f) {
|
|
64
|
+
need(p, 1, 'metro send <line> <text> [--image=<path>]… [--document=<path>]… [--voice=<path>] [--buttons=<json>]');
|
|
65
|
+
loadMetroEnv();
|
|
66
|
+
const text = await resolveText(p, 1), line = asLine(p[0]);
|
|
67
|
+
if (Line.isAgent(line)) {
|
|
68
|
+
const resp = await ipcCall({ op: 'notify', line, text });
|
|
69
|
+
if (!resp.ok)
|
|
70
|
+
throw new Error(resp.error);
|
|
71
|
+
return emit(f, `notified ${line}`, { ok: true, line, id: null, messageId: null });
|
|
72
|
+
}
|
|
73
|
+
const opts = richOpts(f);
|
|
74
|
+
const messageId = await chatStationOf(line).send(line, text, opts);
|
|
75
|
+
const id = logOutbound(f, 'outbound', line, text, messageId, undefined, opts);
|
|
76
|
+
emit(f, `sent ${id} (${messageId}) to ${line}`, { ok: true, line, id, messageId });
|
|
77
|
+
}
|
|
78
|
+
export async function cmdReply(p, f) {
|
|
79
|
+
need(p, 2, 'metro reply <line> <message_id> <text> [--image=… --document=… --voice=… --buttons=…]');
|
|
80
|
+
loadMetroEnv();
|
|
81
|
+
const [to, replyToArg] = p, text = await resolveText(p, 2), line = asLine(to);
|
|
82
|
+
const replyTo = resolvePlatformId(replyToArg);
|
|
83
|
+
const opts = richOpts(f);
|
|
84
|
+
const messageId = await chatStationOf(line).send(line, text, { ...opts, replyTo });
|
|
85
|
+
const id = logOutbound(f, 'outbound', line, text, messageId, replyToArg, opts, undefined, recipientFor(replyToArg));
|
|
86
|
+
emit(f, `replied ${id} (${messageId}) to ${line}#${replyTo}`, { ok: true, line, id, replyTo: replyToArg, messageId });
|
|
87
|
+
}
|
|
88
|
+
export async function cmdEdit(p, f) {
|
|
89
|
+
need(p, 2, 'metro edit <line> <message_id> <text> [--buttons=<json>]');
|
|
90
|
+
loadMetroEnv();
|
|
91
|
+
const [to, msgArg] = p, text = await resolveText(p, 2), line = asLine(to);
|
|
92
|
+
const platformId = resolvePlatformId(msgArg);
|
|
93
|
+
const buttons = parseButtons(f);
|
|
94
|
+
await chatStationOf(line).edit(line, platformId, text, buttons ? { buttons } : undefined);
|
|
95
|
+
/** Carry forward the original recipient if we have a row for this message. */
|
|
96
|
+
const original = lookupEntry(msgArg);
|
|
97
|
+
const id = logOutbound(f, 'edit', line, text, platformId, msgArg, undefined, undefined, original?.to);
|
|
98
|
+
emit(f, `edited ${line}#${platformId} (${id})`, { ok: true, line, id, messageId: platformId });
|
|
99
|
+
}
|
|
100
|
+
export async function cmdReact(p, f) {
|
|
101
|
+
need(p, 2, 'metro react <line> <message_id> <emoji> (empty emoji clears)');
|
|
102
|
+
loadMetroEnv();
|
|
103
|
+
const [to, msgArg, emoji = ''] = p, line = asLine(to);
|
|
104
|
+
const platformId = resolvePlatformId(msgArg);
|
|
105
|
+
await chatStationOf(line).react(line, platformId, emoji);
|
|
106
|
+
const id = logOutbound(f, 'react', line, undefined, platformId, undefined, undefined, emoji, recipientFor(msgArg));
|
|
107
|
+
const human = emoji ? `reacted ${emoji} on ${line}#${platformId}` : `cleared reaction on ${line}#${platformId}`;
|
|
108
|
+
emit(f, human, { ok: true, line, id, messageId: platformId, emoji });
|
|
109
|
+
}
|
|
110
|
+
export async function cmdDownload(p, f) {
|
|
111
|
+
need(p, 2, 'metro download <line> <message_id> [--out=<dir>]');
|
|
112
|
+
loadMetroEnv();
|
|
113
|
+
const [to, msgArg] = p, line = asLine(to);
|
|
114
|
+
const messageId = resolvePlatformId(msgArg);
|
|
115
|
+
const outDir = typeof f.out === 'string' ? f.out : join(tmpdir(), 'metro-downloads');
|
|
116
|
+
mkdirSync(outDir, { recursive: true });
|
|
117
|
+
/** Telegram has no get-message-by-id REST endpoint — daemon holds the in-memory snapshot. */
|
|
118
|
+
let files;
|
|
119
|
+
if (Line.station(line) === 'telegram') {
|
|
120
|
+
const resp = await ipcCall({ op: 'download', line, messageId, outDir });
|
|
121
|
+
if (!resp.ok)
|
|
122
|
+
throw new Error(resp.error);
|
|
123
|
+
files = 'files' in resp ? resp.files : [];
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
files = await chatStationOf(line).download(line, messageId, outDir);
|
|
127
|
+
}
|
|
128
|
+
if (isJson(f))
|
|
129
|
+
return writeJson({ ok: true, line, files });
|
|
130
|
+
if (!files.length)
|
|
131
|
+
process.stdout.write(`(no image attachments on ${line}#${messageId})\n`);
|
|
132
|
+
for (const file of files)
|
|
133
|
+
process.stdout.write(file.path + '\n');
|
|
134
|
+
}
|
|
135
|
+
export async function cmdFetch(p, f) {
|
|
136
|
+
need(p, 1, 'metro fetch <line> [--limit=N]');
|
|
137
|
+
loadMetroEnv();
|
|
138
|
+
const line = asLine(p[0]);
|
|
139
|
+
const messages = await chatStationOf(line).fetch(line, Number(flagOne(f, 'limit')) || 20);
|
|
140
|
+
if (isJson(f))
|
|
141
|
+
return writeJson({ ok: true, line, messages });
|
|
142
|
+
if (!messages.length)
|
|
143
|
+
process.stdout.write(`(no messages on ${line})\n`);
|
|
144
|
+
for (const m of messages)
|
|
145
|
+
process.stdout.write(`${m.timestamp} ${m.author}: ${m.text}\n`);
|
|
146
|
+
}
|
|
147
|
+
export async function cmdNotify(p, f) {
|
|
148
|
+
need(p, 1, 'metro notify <line> <text> [--from=<line>]');
|
|
149
|
+
loadMetroEnv();
|
|
150
|
+
const text = await resolveText(p, 1), line = asLine(p[0]);
|
|
151
|
+
const from = flagOne(f, 'from');
|
|
152
|
+
const resp = await ipcCall({ op: 'notify', line, from, text });
|
|
153
|
+
if (!resp.ok)
|
|
154
|
+
throw new Error(resp.error);
|
|
155
|
+
emit(f, `notified ${line}`, { ok: true, line });
|
|
156
|
+
}
|