@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 CHANGED
@@ -1,15 +1,25 @@
1
1
  # Metro
2
2
 
3
- Run a long-lived daemon that bridges Discord and Telegram to your Codex + Claude Code agents. Each chat thread/topic gets its own agent session with streaming responses and inline, persistent tool-call traces. Both agents run side-by-side — pick per-message with a `with claude` / `with codex` suffix.
3
+ > **A live JSON stream of Telegram + Discord messages for your local Claude Code / Codex session.**
4
4
 
5
- ## Prereqs
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
- - **Node ≥ 22** (or Bun ≥ 1.3).
8
- - **One or both agent CLIs** installed and authenticated:
9
- - **Claude Code** — run `claude` once interactively to log in. Metro shells out per turn and inherits your auth, plugins, settings.
10
- - **Codex** run `codex` once interactively to log in. Metro spawns `codex app-server` and inherits your auth, MCPs, sandboxing.
11
- - **Discord bot** (optional) with **Message Content Intent** enabled (Developer Portal → Bot → Privileged Gateway Intents).
12
- - **Telegram bot** (optional). In supergroup forums, the bot also needs the **Manage Topics** admin permission so it can auto-create topics on @-mention.
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 orchestrator
32
+ metro # run the daemon
24
33
  ```
25
34
 
26
- Metro starts both agents at boot and listens on whichever platforms are configured. Each scope defaults to **Claude** for the first turn; once you've used an agent there, follow-up messages stick with it. Switch per-message by suffixing `with claude` or `with codex` (any casing):
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
- @Metro draft a release note
30
- → uses Claude (default for a new scope)
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
- ### Discord
72
+ Run `metro stations` to see live config status (`✓` configured, `✗` not, `·` informational).
37
73
 
38
- @-mention the bot in any channel:
39
- 1. Metro creates a thread anchored on your message (named after the message).
40
- 2. Spins up an agent session for that thread.
41
- 3. Streams the agent's reply with each tool call as its own block: plain header `🛠 **Read**` followed by two fenced code blocks input (`src/foo.ts`) above, output (file contents) below. Outputs are capped at 50 lines / 1500 chars per tool with a `_(N more lines)_` note if truncated. `Thinking…` shows as a transient status that vanishes once real content arrives.
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
- Follow-ups in the thread route automatically — no @-mention needed.
81
+ ---
44
82
 
45
- ### Telegram
83
+ ## Lines
46
84
 
47
- - **DM the bot** every message is implicitly addressed to it; one scope per chat.
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
- Regular (non-forum) groups are not routed — they have no thread boundary.
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
- ## How it works
97
+ ---
98
+
99
+ ## CLI
54
100
 
55
101
  ```
56
- Discord gateway ──┐ ┌─▶ codex app-server (long-lived subprocess, UDS JSON-RPC)
57
- ├─▶ metro orchestrator ─┤
58
- Telegram poller ──┘ └─▶ claude -p ... (per-turn subprocess, stream-json)
59
-
60
- └──── scope map (scopes.json)
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
- - **One metro = one daemon.** Lockfile at `$METRO_STATE_DIR/.tail-lock` keeps things singleton.
64
- - **Both agents side-by-side.** A scope can have up to one session per agent — independent histories. Routing is per-message: explicit `with claude` / `with codex` suffix, otherwise the scope's last-used agent, otherwise Claude.
65
- - **Streaming.** Replies edit one message every ~1500 ms while deltas stream in (leading-edge first flush for fast initial feedback). Long replies split past ~1900 chars onto a follow-up message.
66
- - **Tool-call visibility.** Each tool call is rendered as a plain `🛠 **<tool>**` header plus two fenced code blocks — input then output — paired by tool id so parallel calls don't collide. Both blocks are fully visible (no collapse). Outputs are capped at 50 lines / 1500 chars per tool.
67
- - **Telegram formatting.** Agent markdown (`**bold**`, `*italic*`, `` `code` ``, fenced blocks, `[link](url)`, blockquotes) is converted to Telegram's HTML parse mode on the way out, so it renders as formatted text instead of literal characters.
68
- - **No link previews.** Outgoing messages set `link_preview_options.is_disabled` on Telegram and the `SUPPRESS_EMBEDS` flag on Discord, so URLs in agent replies don't unfurl into giant auto-embeds.
69
- - **Queueing.** Messages that arrive while a turn is running are buffered per-scope and answered together in the next reply.
70
- - **Catchup-on-restart.** Discord uses a per-scope `lastSeenMessageId` watermark and REST-fetches anything newer when metro comes back up. Telegram leans on its own update-id queue (persisted offset in `telegram-offset.json`).
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
- ## Config
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, scope cache, codex socket, telegram offset, claude session set. |
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
- Token precedence: process env → `./.env` → `$METRO_CONFIG_DIR/.env`. Logs to stderr.
147
+ Precedence: process env → `./.env` → `$METRO_CONFIG_DIR/.env`. Logs go to stderr.
148
+
149
+ ---
82
150
 
83
- ## Develop locally
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
- ## Reference
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
- - `metro --help` command surface
95
- - `metro doctor` — health check (tokens + gateway/poller reachability + orchestrator status)
96
- - State files (`$METRO_STATE_DIR`, defaults to `~/.cache/metro/`):
97
- - `scopes.json` — Discord-thread / Telegram-topic ↔ agent-session map
98
- - `.tail-lock` — orchestrator pid
99
- - `codex-app-server.sock` — codex's UDS
100
- - `telegram-offset.json` last processed update id (used for catchup on restart)
101
- - `claude-sessions.json` set of started Claude session uuids (so restarts use `--resume` instead of `--session-id`)
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
+ }