@stage-labs/metro 0.1.0-beta.0 → 0.1.0-beta.10

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,93 +1,249 @@
1
1
  # Metro
2
2
 
3
- Chat with your Claude Code or Codex agent over Telegram and Discord. Messages land in the session live, the agent reacts, types while it works, and replies ~700 lines of TypeScript, one stdio MCP, no hosted infra.
3
+ > **A live JSON stream of Telegram, Discord, webhooks, and cross-agent messages for your local Claude Code / Codex session.**
4
+
5
+ Metro is a small daemon you launch from inside your agent. It connects to Discord, Telegram, and any third-party service that can POST a webhook (GitHub, Intercom, Fireflies, …), 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` — for posting back. Cross-agent: any agent can ping any other via `metro send metro://claude/<agent-id>/<session-id>` and the daemon re-emits it on the stream.
6
+
7
+ ```
8
+ [Claude Code session]
9
+
10
+ $ metro & # backgrounded
11
+ $ Monitor( … metro's stdout … )
12
+
13
+ >>> {"kind":"inbound","station":"discord","line":"metro://discord/123…","messageId":"9876",
14
+ "text":"@bot we got a 5xx spike from /v1/sync. Look?",
15
+ "payload":{"channelId":"123…","guildId":"456…","content":"<@…> we got a 5xx spike…",
16
+ "mentions":{"users":["<bot-id>"],"roles":[],"everyone":false},…}}
17
+
18
+ [I'd run git log + read services/sync.ts, then…]
19
+ Bash: metro reply metro://discord/123… 9876 "three deploys in the last 24h…"
20
+ ```
21
+
22
+ The agent owns its own streaming, tool calls, and reply timing. Metro is the wire.
23
+
24
+ ---
4
25
 
5
26
  ## Quickstart
6
27
 
7
28
  ```bash
8
29
  npm install -g @stage-labs/metro@beta # or: bun add -g @stage-labs/metro@beta
30
+
31
+ metro setup discord <token> # https://discord.com/developers/applications
32
+ metro setup telegram <token> # https://t.me/BotFather
33
+ metro doctor # verify
34
+ metro # run the daemon
9
35
  ```
10
36
 
11
- > The `@beta` tag is required while Metro is in prerelease.
37
+ 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).
12
38
 
13
- Register Metro with your agent (use `claude` or `codex` interchangeably):
39
+ 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.
14
40
 
15
- ```bash
16
- claude mcp add metro \
17
- --env TELEGRAM_BOT_TOKEN=123:ABC… \
18
- --env DISCORD_BOT_TOKEN=MTIz… \
19
- -- metro mcp
41
+ ---
42
+
43
+ ## Architecture
44
+
45
+ ```
46
+ Discord gateway ──┐
47
+ Telegram poller ──┤
48
+ Cloudflare tunnel ──┤ ── HTTP webhooks (GitHub, Intercom, …)
49
+
50
+ ├─▶ metro daemon ───▶ stdout (JSON events; Claude Code's Monitor reads here)
51
+ │ ───▶ codex-rc WebSocket (Codex turn/start; opt-in)
52
+ │ ◀── IPC Unix socket (metro send to agent lines)
53
+
54
+ agent CLI calls ────┴── REST → Discord / Telegram (metro reply / send / edit / react / download / fetch)
20
55
  ```
21
56
 
22
- Both `--env` flags are optional configure at least one of Telegram or Discord.
57
+ - **Inversion of control.** The agent (Claude Code, Codex) launches `metro`, not the other way around. Metro never spawns an agent process.
58
+ - **Single daemon per machine.** Lockfile at `$METRO_STATE_DIR/.tail-lock` enforces singleton.
59
+ - **Account-tied identity.** `to` on inbound and `from` on outbound resolve to a stable account-scoped URI per runtime: `metro://claude/user/<orgId>` (from `claude auth status --json`) or `metro://codex/user/<accountId>` (from `$CODEX_HOME/auth.json`). Same on any device for the same logged-in account.
60
+ - **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.
61
+ - **Cross-agent notification.** `metro send metro://claude/<agent-id>/<session-id>` (or `metro://codex/<agent-id>/<session-id>`) 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. Discover reachable agents/sessions via `metro stations` or `$METRO_STATE_DIR/agent-registry.json`.
62
+ - **Webhooks (opt-in).** `metro webhook add <label>` registers an HTTP receive endpoint; the daemon binds `127.0.0.1:8420` (override with `$METRO_WEBHOOK_PORT`). If you've run `metro tunnel setup`, a Cloudflare named tunnel exposes it publicly. Each POST is re-emitted on stdout as an inbound event.
63
+
64
+ ---
65
+
66
+ ## Stations
23
67
 
24
- In your agent session, ask it to start the inbound stream:
68
+ Each endpoint is a **station** with declared capabilities:
25
69
 
26
- > Run `metro tail` in the background and Monitor its stdout for inbound Telegram/Discord messages.
70
+ | Station | Kind | Modalities | Features | Config |
71
+ |------------|---------|---------------|-------------------------------------------------------|-----------------------------------------------------------------------------------------|
72
+ | `discord` | chat | text + image | reply, send, edit, react, download, fetch | `DISCORD_BOT_TOKEN` + Message Content Intent |
73
+ | `telegram` | chat | text + image | reply, send, edit, react, download | `TELEGRAM_BOT_TOKEN` |
74
+ | `claude` | agent | text | send, notify | auto-detected from `$CLAUDECODE`; identity via `claude auth status --json` |
75
+ | `codex` | agent | text | send, notify | auto-detected from `$METRO_CODEX_RC` / `$CODEX_HOME`; identity via `$CODEX_HOME/auth.json` |
76
+ | `webhook` | service | text | (receive-only; optional HMAC verify) | `metro webhook add <label>` + `metro tunnel setup` (Cloudflare named tunnel) |
27
77
 
28
- DM your bot. The agent reacts on its next decision boundary (see Caveats for latency notes).
78
+ Run `metro stations` to see live config status (`✓` configured, `✗` not, `·` informational).
29
79
 
30
- ## Bot tokens
80
+ Behaviors worth knowing:
81
+ - **No streaming / no edit machinery in metro.** The agent runs the show; metro is one-shot REST.
82
+ - **No link previews.** Outgoing messages set `link_preview_options.is_disabled` on Telegram and `SUPPRESS_EMBEDS` on Discord.
83
+ - **Image attachments inbound** — `[image]` placeholders surface inline in `text`; the agent calls `metro download` to materialize them. 20 MB cap.
84
+ - **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.
85
+ - **Telegram non-forum groups are skipped.** No thread boundary to scope on.
86
+ - **Webhook signature verification.** Pass `--secret=<shared-secret>` to `metro webhook add` and the daemon verifies `X-Hub-Signature-256` (GitHub/Intercom format) on every POST. Mismatches return 401 and never reach the stream.
31
87
 
32
- - **Telegram**: DM [@BotFather](https://t.me/BotFather), `/newbot`, copy the token.
33
- - **Discord**: [discord.com/developers/applications](https://discord.com/developers/applications) → New Application → Bot → Reset Token. **Toggle Message Content Intent** in the same Bot tab (Privileged Gateway Intents) — without it, message bodies arrive empty. Generate an OAuth invite with the `bot` scope, or DM the bot directly.
88
+ ---
34
89
 
35
- ## How it works
90
+ ## Webhooks
36
91
 
37
- Metro ships two commands:
92
+ Receive HTTP events from third parties (GitHub, Intercom, Fireflies, anything that POSTs) as standard metro inbound events. Each registered endpoint is one Line.
38
93
 
39
- - **`metro mcp`** — a stdio MCP server. Registers the tools below so the agent can reply, react, edit, and download attachments. Started once when the agent boots (via `claude mcp add` / `codex mcp add` above).
40
- - **`metro tail`**the inbound runtime. Polls Telegram and connects to Discord's WebSocket gateway, then prints one JSON line per inbound message to stdout. The agent watches that stdout (Bash+Monitor in Claude Code, unified_exec in Codex) and acts on each line at its next decision boundary. Started on demand from inside an agent session.
94
+ ```bash
95
+ # One-time per machinebring your own Cloudflare domain (free Registrar at-cost):
96
+ brew install cloudflared
97
+ cloudflared tunnel login # browser OAuth, pick your domain
98
+ metro tunnel setup metro webhook.example.com # creates tunnel + DNS CNAME
99
+
100
+ # Per endpoint — repeat for each provider:
101
+ metro webhook add github --secret=$(openssl rand -hex 32)
102
+ # → https://webhook.example.com/wh/<id>
103
+ # (without `metro tunnel setup`, falls back to http://127.0.0.1:8420/wh/<id> — local-only)
41
104
 
42
- While the agent works on a reply, both platforms show a typing indicator; when it replies, the indicator stops and the auto-ack reaction (👀) is cleared on the exact message replied to.
105
+ metro # daemon binds 8420 + spawns cloudflared automatically
106
+ ```
43
107
 
44
- ## MCP tools
108
+ Paste the URL into the provider's webhook settings (for GitHub: **Content type must be `application/json`** — form-encoded won't parse). Every POST becomes an inbound event with `station: "webhook"`, `line: metro://webhook/<id>`, `payload: { headers, body }`. If you set `--secret`, metro verifies the `X-Hub-Signature-256` header (GitHub/Intercom format) and rejects mismatches with 401.
45
109
 
46
- Registered by `metro mcp` — the agent calls these to act on the messages it sees from `metro tail`:
110
+ | Action | Command |
111
+ |---|---|
112
+ | Register an endpoint | `metro webhook add <label> [--secret=<shared-secret>]` |
113
+ | List endpoints + URLs | `metro webhook list` |
114
+ | Remove an endpoint | `metro webhook remove <id>` |
115
+ | One-time tunnel setup | `metro tunnel setup <tunnel-name> <hostname>` |
116
+ | Tunnel status | `metro tunnel status` |
47
117
 
48
- | Tool | Telegram | Discord | Purpose |
49
- |---|---|---|---|
50
- | Reply | `telegram-reply` | `discord-reply` | Quote-reply, threading under the original message. Clears the 👀 auto-ack. |
51
- | React | `telegram-react` | `discord-react` | Set or clear an emoji reaction. |
52
- | Edit | `telegram-edit-message` | `discord-edit-message` | Edit a message the bot previously sent. |
53
- | Download attachment | `telegram-download-attachment` | `discord-download-attachment` | Pull image attachments back as `image` content blocks. |
54
- | Fetch recent messages | — | `discord-fetch-messages` | Lookback for context. (Discord exposes no search API for bots; Telegram has none either.) |
118
+ The tunnel is optional without it the listener binds `127.0.0.1:8420` only (good for local testing or your own loopback tools). With Cloudflare named tunnels, the URL stays stable across daemon restarts and machines. See [docs/uri-scheme.md](docs/uri-scheme.md) and [docs/agents.md](docs/agents.md) for the full event shape.
55
119
 
56
- The agent reads `chat_id` / `channel_id` and `message_id` from the inbound JSON and threads them through. Voice / audio surface as `[voice]` / `[audio]` text placeholders — the agent sees them but can't download.
120
+ ---
57
121
 
58
- ## Config
122
+ ## Lines
59
123
 
60
- All settings come from environment variables passed via the MCP server's `--env` block:
124
+ Every conversational scope is identified by a **Line** — a URI in the form `metro://<station>/<path>`:
125
+
126
+ ```
127
+ metro://discord/1234567890123456789
128
+ metro://telegram/-1001234567890 # main chat / DM
129
+ metro://telegram/-1001234567890/42 # forum topic 42
130
+ metro://claude/9bfc7af0-…/50b00d11-… # claude agent session
131
+ metro://codex/8119ecb1-…/01997d4b-… # codex agent session
132
+ metro://webhook/fwaCgTKJuLAjS2K0 # HTTP webhook endpoint
133
+ ```
134
+
135
+ 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).
136
+
137
+ ---
138
+
139
+ ## CLI
140
+
141
+ ```
142
+ metro Run the daemon (emits JSON events on stdout).
143
+ metro setup [telegram|discord <token>] Save token, or show status.
144
+ metro setup clear [telegram|discord|all] Remove tokens.
145
+ metro doctor Health check.
146
+ metro stations List stations + capabilities.
147
+ metro lines List recently-seen conversations.
148
+ metro send <line> <text> [--image=…]… [--document=…]… [--voice=…] [--buttons=…]
149
+ Post a fresh message; --image/--document repeat for albums.
150
+ metro reply <line> <message_id> <text> [--image|--document|--voice|--buttons]
151
+ Threaded reply (same flags as send).
152
+ metro edit <line> <message_id> <text> [--buttons=<json>]
153
+ Edit a previously-sent message (text + URL-button keyboard).
154
+ metro react <line> <message_id> <emoji> Set or clear ('') a reaction.
155
+ metro download <line> <message_id> [--out=<dir>]
156
+ Download image attachments to disk.
157
+ metro fetch <line> [--limit=N] Recent-message lookback (Discord only).
158
+ metro history [--limit=N] [--line=…] [--station=…] [--kind=…] [--from=…] [--text=…] [--since=…]
159
+ Universal message log (every inbound + outbound), newest first.
160
+ metro webhook add <label> [--secret=…] Register an HTTP receive endpoint (GitHub, Intercom, …).
161
+ metro webhook list | remove <id> List or remove webhook endpoints.
162
+ metro tunnel setup <name> <hostname> Configure a Cloudflare named tunnel for public webhook URLs.
163
+ metro tunnel status Show current tunnel config.
164
+ metro update Upgrade in place.
165
+ ```
166
+
167
+ All commands accept `--json`. `reply` / `send` / `edit` read multi-line `<text>` from stdin if no positional is given.
168
+
169
+ **State files** in `$METRO_STATE_DIR` (default `~/.cache/metro`):
170
+ - `AGENTS.md` — agent skill copied from the package on every start (so the path is stable across upgrades)
171
+ - `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/user/<orgId>`, `metro://codex/user/<accountId>`) plus a `fromName` display field. The dispatcher auto-detects the consuming agent for `to` on inbound (`$CLAUDECODE` → `metro://claude/user/<orgId>` from `claude auth status --json`; `$METRO_CODEX_RC`/`$CODEX_HOME` → `metro://codex/user/<accountId>` from `$CODEX_HOME/auth.json`).
172
+ - `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).
173
+ - `lines.json` — line → last-seen / name cache (read by `metro lines`)
174
+ - `agent-registry.json` — every `(station, agent-id, sessions[])` tuple metro has seen; surfaced under each agent row in `metro stations`
175
+ - `stations/codex/session-id` — current codex-rc thread id (daemon writes on handshake; CLI processes read for `metro://codex/<agent-id>/<session>`)
176
+ - `webhooks.json` — registered HTTP receive endpoints (id, label, optional shared secret)
177
+ - `tunnel.json` — Cloudflare named-tunnel config (`{name, hostname}`); when present, the daemon spawns `cloudflared tunnel run`. The token is resolved via `cloudflared tunnel token <name>` and passed through as `TUNNEL_TOKEN`, so the per-tunnel credentials JSON at `~/.cloudflared/<id>.json` is not required (the named-form spawn is the fallback when the token call fails)
178
+ - `.tail-lock` — dispatcher pid
179
+ - `metro.sock` — daemon IPC socket
180
+ - `telegram-offset.json` — last processed update id
181
+
182
+ ---
183
+
184
+ ## Configuration
61
185
 
62
186
  | Variable | Default | Description |
63
187
  |---|---|---|
64
- | `TELEGRAM_BOT_TOKEN` | — | Telegram bot token. Required for the Telegram channel. |
65
- | `DISCORD_BOT_TOKEN` | — | Discord bot token. Required for the Discord channel. |
66
- | `METRO_LOG_LEVEL` | `info` | `trace`/`debug`/`info`/`warn`/`error`/`fatal`. |
67
- | `METRO_STATE_DIR` | `~/.cache/metro` | Where the lockfile, typing-stop signals, and the Telegram attachment cache live. |
188
+ | `TELEGRAM_BOT_TOKEN`, `DISCORD_BOT_TOKEN` | — | Bot tokens. `metro setup` writes them here. |
189
+ | `METRO_CODEX_RC` | — | Codex app-server URL (`ws://…`, `wss://…`, `unix:///…`). When set, the daemon pushes each event via JSON-RPC `turn/start`. |
190
+ | `METRO_WEBHOOK_PORT` | `8420` | Local port the HTTP webhook listener binds to (always `127.0.0.1`; expose publicly via Cloudflare tunnel). |
191
+ | `METRO_AGENT_ID` | | Override the resolved agent id (orgId / accountId) used in `metro://<station>/user/<id>` and `metro://<station>/<id>/<session>`. Useful for testing. |
192
+ | `METRO_AGENT_SESSION_ID` | — | Override the resolved session id (Claude session / Codex thread). |
193
+ | `METRO_FROM` | — | Pin a custom `from` URI for all writes (overrides runtime detection). |
194
+ | `METRO_CONFIG_DIR` | `~/.config/metro` | Where the global `.env` lives. |
195
+ | `METRO_STATE_DIR` | `~/.cache/metro` | Lockfile, line cache, IPC socket, telegram offset, registries, tunnel config. |
196
+ | `METRO_LOG_LEVEL` | `info` | `trace` / `debug` / `info` / `warn` / `error` / `fatal`. |
68
197
 
69
- Logs go to stderr. Claude Code captures them at `~/Library/Caches/claude-cli-nodejs/…/mcp-logs-plugin-metro-metro/*.jsonl`.
198
+ Precedence: process env `./.env` `$METRO_CONFIG_DIR/.env`. Logs go to stderr.
70
199
 
71
- For local dev (cloned repo, no host agent): `cp .env.example .env && chmod 600 .env`, then run `metro tail` / `metro mcp` from the repo dir — `.env` is read as a fallback when env vars aren't set.
200
+ ---
72
201
 
73
- ## Troubleshooting
202
+ ## Develop
74
203
 
75
204
  ```bash
76
- which metro # → e.g. ~/.bun/bin/metro
77
- metro # prints usage
205
+ git clone https://github.com/bonustrack/metro && cd metro
206
+ bun install && bun run build
207
+ bun link # makes `metro` resolve to this checkout
208
+ METRO_LOG_LEVEL=debug metro
78
209
 
79
- ps aux | grep metro | grep -v grep # one `metro mcp`, optionally one `metro tail`
210
+ bun run typecheck # ts
211
+ bun run lint # eslint
212
+ ```
80
213
 
81
- rm -rf ~/.cache/metro/ # clean stuck state — or whatever METRO_STATE_DIR points at
214
+ Source map:
82
215
 
83
- # Latest agent-side log (Claude Code):
84
- ls -t ~/Library/Caches/claude-cli-nodejs/-Users-*-metro/mcp-logs-plugin-metro-metro/*.jsonl | head -1 | xargs cat
85
- ```
216
+ - [`src/cli/`](src/cli/) `metro` binary entry ([`index.ts`](src/cli/index.ts)) + admin commands ([`config.ts`](src/cli/config.ts): setup/doctor/update), action handlers ([`actions.ts`](src/cli/actions.ts): send/reply/edit/react/download/fetch), webhook + tunnel commands ([`webhook.ts`](src/cli/webhook.ts)), and shared CLI primitives ([`util.ts`](src/cli/util.ts)).
217
+ - [`src/dispatcher.ts`](src/dispatcher.ts) the daemon: starts each station, emits events on stdout, listens on the IPC socket, optionally pushes to codex-rc, supervises the Cloudflare tunnel.
218
+ - [`src/stations/`](src/stations/) — Line URI scheme + ChatStation interface + listing ([`index.ts`](src/stations/index.ts)). Chat impls: [`discord.ts`](src/stations/discord.ts), [`telegram.ts`](src/stations/telegram.ts) (+ [`telegram-md.ts`](src/stations/telegram-md.ts) markdown helper). Agent identity resolvers: [`claude.ts`](src/stations/claude.ts) (orgId via `claude auth status --json`), [`codex.ts`](src/stations/codex.ts) (account_id via `auth.json`). HTTP receive: [`webhook.ts`](src/stations/webhook.ts).
219
+ - [`src/codex-rc.ts`](src/codex-rc.ts) — Codex app-server WebSocket push client (also exposes the rc thread id used as Codex session-id).
220
+ - [`src/tunnel.ts`](src/tunnel.ts) — Cloudflared named-tunnel supervisor.
221
+ - [`src/webhooks.ts`](src/webhooks.ts) — webhook endpoint store (`webhooks.json` CRUD).
222
+ - [`src/registry.ts`](src/registry.ts) — agent registry: `(station, agent-id, sessions[])` tracking.
223
+ - [`src/history.ts`](src/history.ts) — universal message log + `agentSelf()` / `selfLine()` identity helpers.
224
+ - [`src/ipc.ts`](src/ipc.ts) — Unix-socket IPC between the daemon and one-shot CLI commands.
225
+ - [`src/cache.ts`](src/cache.ts) — in-memory line cache with debounced flush to `lines.json`, plus bot-id cache.
226
+ - [`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.
227
+
228
+ CI runs typecheck + lint + build on every PR via [`.github/workflows/ci.yml`](.github/workflows/ci.yml).
229
+
230
+ ---
86
231
 
87
232
  ## Caveats
88
233
 
89
- - **Discord Message Content Intent** is privileged toggle it in the Developer Portal. See above.
90
- - **Telegram single-poller.** Telegram allows one `getUpdates` consumer per bot token. If two `metro tail` instances start, the second-comer detects the lockfile (`$METRO_STATE_DIR/.tail-lock`) and exits cleanly. Re-run after the first exits to take over.
91
- - **No allowlist.** Anyone who can DM your bot or @-mention it can talk to your session. Run against bots you own.
92
- - **Mid-task latency.** New messages surface at the next agent decision boundary sub-second on Claude Code (lots of small tool calls), longer on Codex turns. Neither runtime can interrupt an in-progress LLM generation.
93
- - **UI visibility.** Claude Code's `Monitor` collapses stdout into a card; Codex dims MCP tool args. Metro's MCP `instructions` direct the agent to echo each inbound in its visible reply so you see what arrived without expanding cards.
234
+ - **No allowlist on chat stations.** Anyone who can DM/`@`-mention your bot can produce events. Run against bots you own.
235
+ - **Webhook secrets are optional but recommended.** Without `--secret`, anyone who learns the endpoint URL can POST events. With it, metro verifies `X-Hub-Signature-256` and rejects mismatches.
236
+ - **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.
237
+ - **Telegram non-forum groups are skipped.** No thread boundary to scope on. DMs and forum topics work normally.
238
+ - **Telegram fetch isn't supported** (bot API doesn't expose history); `metro fetch` returns `[]` on Telegram lines.
239
+ - **Cloudflared is your responsibility.** `metro tunnel setup` records the named tunnel; you still install `cloudflared` (`brew install cloudflared`) and run `cloudflared tunnel login` once.
240
+
241
+ ---
242
+
243
+ ## Uninstall
244
+
245
+ ```bash
246
+ metro setup clear
247
+ rm -rf ~/.cache/metro
248
+ npm uninstall -g @stage-labs/metro
249
+ ```
package/dist/cache.js ADDED
@@ -0,0 +1,75 @@
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
+ const readBotIds = () => {
52
+ try {
53
+ return existsSync(botIdsFile) ? JSON.parse(readFileSync(botIdsFile, 'utf8')) : {};
54
+ }
55
+ catch {
56
+ return {};
57
+ }
58
+ };
59
+ export function saveBotId(station, id) {
60
+ const cur = readBotIds();
61
+ if (cur[station] === id)
62
+ return;
63
+ cur[station] = id;
64
+ try {
65
+ writeFileSync(botIdsFile, JSON.stringify(cur, null, 2));
66
+ }
67
+ catch (err) {
68
+ log.warn({ err: errMsg(err) }, 'bot-ids cache write failed');
69
+ }
70
+ }
71
+ /** Resolve the bot's URI for a station. Returns `metro://<station>/bot/<id>` or the placeholder. */
72
+ export function botLine(station) {
73
+ const id = readBotIds()[station];
74
+ return id ? Line.bot(station, id) : `metro://${station}/bot`;
75
+ }
@@ -0,0 +1,139 @@
1
+ /** CLI action handlers: send/reply/edit/react/download/fetch + 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; `to` = the original sender when replying/reacting. */
49
+ function logOutbound(f, e) {
50
+ const id = mintId();
51
+ const fromOverride = flagOne(f, 'from');
52
+ appendHistory({
53
+ id, ts: new Date().toISOString(), station: Line.station(e.line) ?? '?',
54
+ from: fromOverride ? asLine(fromOverride) : agentSelf(), to: e.to ?? e.line, ...e,
55
+ });
56
+ return id;
57
+ }
58
+ export async function cmdSend(p, f) {
59
+ need(p, 1, 'metro send <line> <text> [--image=<path>]… [--document=<path>]… [--voice=<path>] [--buttons=<json>]');
60
+ loadMetroEnv();
61
+ const text = await resolveText(p, 1), line = asLine(p[0]);
62
+ if (Line.isAgent(line)) {
63
+ const from = flagOne(f, 'from');
64
+ const resp = await ipcCall({ op: 'notify', line, from, text });
65
+ if (!resp.ok)
66
+ throw new Error(resp.error);
67
+ return emit(f, `notified ${line}`, { ok: true, line, id: null, messageId: null });
68
+ }
69
+ const messageId = await chatStationOf(line).send(line, text, richOpts(f));
70
+ const id = logOutbound(f, { kind: 'outbound', line, text, messageId });
71
+ emit(f, `sent ${id} (${messageId}) to ${line}`, { ok: true, line, id, messageId });
72
+ }
73
+ export async function cmdReply(p, f) {
74
+ need(p, 2, 'metro reply <line> <message_id> <text> [--image=… --document=… --voice=… --buttons=…]');
75
+ loadMetroEnv();
76
+ const [to, replyToArg] = p, text = await resolveText(p, 2), line = asLine(to);
77
+ const replyTo = resolvePlatformId(replyToArg);
78
+ const messageId = await chatStationOf(line).send(line, text, { ...richOpts(f), replyTo });
79
+ const id = logOutbound(f, { kind: 'outbound', line, text, messageId, replyTo: replyToArg, to: lookupEntry(replyToArg)?.from });
80
+ emit(f, `replied ${id} (${messageId}) to ${line}#${replyTo}`, { ok: true, line, id, replyTo: replyToArg, messageId });
81
+ }
82
+ export async function cmdEdit(p, f) {
83
+ need(p, 2, 'metro edit <line> <message_id> <text> [--buttons=<json>]');
84
+ loadMetroEnv();
85
+ const [to, msgArg] = p, text = await resolveText(p, 2), line = asLine(to);
86
+ const platformId = resolvePlatformId(msgArg);
87
+ const buttons = parseButtons(f);
88
+ await chatStationOf(line).edit(line, platformId, text, buttons ? { buttons } : undefined);
89
+ /** Carry forward the original recipient if we have a row for this message. */
90
+ const id = logOutbound(f, { kind: 'edit', line, text, messageId: platformId, replyTo: msgArg, to: lookupEntry(msgArg)?.to });
91
+ emit(f, `edited ${line}#${platformId} (${id})`, { ok: true, line, id, messageId: platformId });
92
+ }
93
+ export async function cmdReact(p, f) {
94
+ need(p, 2, 'metro react <line> <message_id> <emoji> (empty emoji clears)');
95
+ loadMetroEnv();
96
+ const [to, msgArg, emoji = ''] = p, line = asLine(to);
97
+ const platformId = resolvePlatformId(msgArg);
98
+ await chatStationOf(line).react(line, platformId, emoji);
99
+ const id = logOutbound(f, { kind: 'react', line, messageId: platformId, emoji, to: lookupEntry(msgArg)?.from });
100
+ const human = emoji ? `reacted ${emoji} on ${line}#${platformId}` : `cleared reaction on ${line}#${platformId}`;
101
+ emit(f, human, { ok: true, line, id, messageId: platformId, emoji });
102
+ }
103
+ export async function cmdDownload(p, f) {
104
+ need(p, 2, 'metro download <line> <message_id> [--out=<dir>]');
105
+ loadMetroEnv();
106
+ const [to, msgArg] = p, line = asLine(to);
107
+ const messageId = resolvePlatformId(msgArg);
108
+ const outDir = typeof f.out === 'string' ? f.out : join(tmpdir(), 'metro-downloads');
109
+ mkdirSync(outDir, { recursive: true });
110
+ /** Telegram has no get-message-by-id REST endpoint — daemon holds the in-memory snapshot. */
111
+ let files;
112
+ if (Line.station(line) === 'telegram') {
113
+ const resp = await ipcCall({ op: 'download', line, messageId, outDir });
114
+ if (!resp.ok)
115
+ throw new Error(resp.error);
116
+ files = 'files' in resp ? resp.files : [];
117
+ }
118
+ else {
119
+ files = await chatStationOf(line).download(line, messageId, outDir);
120
+ }
121
+ if (isJson(f))
122
+ return writeJson({ ok: true, line, files });
123
+ if (!files.length)
124
+ process.stdout.write(`(no image attachments on ${line}#${messageId})\n`);
125
+ for (const file of files)
126
+ process.stdout.write(file.path + '\n');
127
+ }
128
+ export async function cmdFetch(p, f) {
129
+ need(p, 1, 'metro fetch <line> [--limit=N]');
130
+ loadMetroEnv();
131
+ const line = asLine(p[0]);
132
+ const messages = await chatStationOf(line).fetch(line, Number(flagOne(f, 'limit')) || 20);
133
+ if (isJson(f))
134
+ return writeJson({ ok: true, line, messages });
135
+ if (!messages.length)
136
+ process.stdout.write(`(no messages on ${line})\n`);
137
+ for (const m of messages)
138
+ process.stdout.write(`${m.timestamp} ${m.author}: ${m.text}\n`);
139
+ }