@stage-labs/metro 0.1.0-beta.5 → 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 +92 -177
- 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 +107 -181
- 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 +66 -157
- package/dist/history.js +84 -0
- package/dist/ipc.js +71 -0
- package/dist/stations/discord.js +198 -0
- package/dist/stations/index.js +73 -0
- package/dist/stations/{telegram/format.js → telegram-md.js} +9 -14
- package/dist/stations/telegram-upload.js +113 -0
- package/dist/stations/telegram.js +235 -0
- package/docs/agents.md +124 -48
- package/docs/uri-scheme.md +42 -23
- package/package.json +3 -2
- package/skills/metro/SKILL.md +220 -0
- package/dist/cli/lines.js +0 -40
- package/dist/cli/update.js +0 -27
- package/dist/helpers/async-queue.js +0 -41
- package/dist/helpers/scope-cache.js +0 -67
- package/dist/helpers/streaming.js +0 -219
- package/dist/helpers/turn.js +0 -62
- package/dist/stations/claude/index.js +0 -224
- package/dist/stations/codex/index.js +0 -226
- package/dist/stations/discord/index.js +0 -160
- package/dist/stations/github/index.js +0 -135
- package/dist/stations/line.js +0 -54
- package/dist/stations/listing.js +0 -16
- package/dist/stations/send.js +0 -19
- package/dist/stations/telegram/files.js +0 -31
- package/dist/stations/telegram/index.js +0 -209
- package/dist/stations/types.js +0 -2
package/README.md
CHANGED
|
@@ -1,23 +1,24 @@
|
|
|
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
|
-
Metro is a small daemon
|
|
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
7
|
```
|
|
8
|
-
[
|
|
8
|
+
[Claude Code session]
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
> 🛠 Read services/sync.ts
|
|
10
|
+
$ metro & # backgrounded
|
|
11
|
+
$ Monitor( … metro's stdout … )
|
|
13
12
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
me to open a PR?
|
|
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,…}
|
|
17
15
|
|
|
18
|
-
|
|
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…"
|
|
19
18
|
```
|
|
20
19
|
|
|
20
|
+
The agent owns its own streaming, tool calls, and reply timing. Metro is the wire.
|
|
21
|
+
|
|
21
22
|
---
|
|
22
23
|
|
|
23
24
|
## Quickstart
|
|
@@ -28,141 +29,108 @@ npm install -g @stage-labs/metro@beta # or: bun add -g @stage-labs/metro@beta
|
|
|
28
29
|
metro setup discord <token> # https://discord.com/developers/applications
|
|
29
30
|
metro setup telegram <token> # https://t.me/BotFather
|
|
30
31
|
metro doctor # verify
|
|
31
|
-
metro # run the
|
|
32
|
+
metro # run the daemon
|
|
32
33
|
```
|
|
33
34
|
|
|
34
|
-
Requires **Node ≥ 22 or Bun ≥ 1.3
|
|
35
|
-
|
|
36
|
-
In **Discord**: DM the bot, or `@<bot>` in any channel. In **Telegram**: DM, or `@<bot>` in a forum's General topic. In **GitHub**: see [Testing GitHub](#testing-github).
|
|
37
|
-
|
|
38
|
-
---
|
|
39
|
-
|
|
40
|
-
## Stations
|
|
41
|
-
|
|
42
|
-
Everything in metro is a **station** with declared capabilities:
|
|
43
|
-
|
|
44
|
-
| Station | Kind | Modalities | Features | Config |
|
|
45
|
-
|------------|-------|---------------|-------------------------------------|-------------------------------------------------------------------|
|
|
46
|
-
| `claude` | agent | text + image | stream, tools, cancel, attachments | `claude` CLI on PATH, logged in |
|
|
47
|
-
| `codex` | agent | text + image | stream, tools, cancel, attachments | `codex` CLI on PATH, logged in |
|
|
48
|
-
| `discord` | chat | text + image | stream, edit, attachments | `DISCORD_BOT_TOKEN` + Message Content Intent |
|
|
49
|
-
| `telegram` | chat | text + image | stream, edit, attachments | `TELEGRAM_BOT_TOKEN` + Manage Topics admin (for forums) |
|
|
50
|
-
| `github` | chat | text | edit | `GITHUB_WEBHOOK_SECRET` + `GITHUB_BOT_USERNAME` + `GITHUB_TOKEN` |
|
|
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).
|
|
51
36
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
Each chat platform behaves slightly differently — what's universal is captured by the capabilities; the rest is in [Conversations](#conversations).
|
|
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.
|
|
55
38
|
|
|
56
39
|
---
|
|
57
40
|
|
|
58
|
-
##
|
|
59
|
-
|
|
60
|
-
Every conversational scope is identified by a **Line** — a URI in the form `metro://<station>/<path>`:
|
|
41
|
+
## Architecture
|
|
61
42
|
|
|
62
43
|
```
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
metro
|
|
67
|
-
|
|
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)
|
|
68
52
|
```
|
|
69
53
|
|
|
70
|
-
|
|
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.
|
|
71
58
|
|
|
72
59
|
---
|
|
73
60
|
|
|
74
|
-
##
|
|
75
|
-
|
|
76
|
-
### Discord
|
|
77
|
-
|
|
78
|
-
- **DM the bot** — every message is implicit; one line per DM.
|
|
79
|
-
- **`@<bot>` in any guild channel** — metro creates a thread from your message, allocates an agent session, and streams the reply. Follow-ups in the thread route automatically.
|
|
80
|
-
- **Tool calls** — render as `🛠 <tool>` headers plus two fenced code blocks (input → output). Outputs cap at 50 lines / 1500 chars with a `_(N more lines)_` note when truncated. Parallel tool calls are paired by id and don't collide.
|
|
81
|
-
- **Stop button** — every in-flight turn carries an `⏹ Stop` button that aborts the underlying subprocess (Claude via `SIGTERM`, Codex via `turn/interrupt`).
|
|
82
|
-
- **Catchup on restart** — Discord uses a per-line `lastSeenMessageId` watermark; metro REST-fetches anything newer when it comes back up.
|
|
83
|
-
|
|
84
|
-
### Telegram
|
|
85
|
-
|
|
86
|
-
- **DM the bot** — implicit; one line per chat.
|
|
87
|
-
- **`@<bot>` in a forum supergroup's General topic** — metro creates a new forum topic for the conversation and posts a deep link back in General so it's one tap away. Follow-ups in that topic route automatically.
|
|
88
|
-
- **Inside an existing custom topic** — routes to that topic's line on every message.
|
|
89
|
-
- **Markdown → Telegram HTML** — agent markdown (`**bold**`, `*italic*`, `` `code` ``, fences, `[link](url)`, blockquotes) is converted on the way out. Plain-text fallback if Telegram rejects the HTML.
|
|
90
|
-
|
|
91
|
-
Regular (non-forum) groups are skipped — without a per-thread boundary the routing model breaks down.
|
|
92
|
-
|
|
93
|
-
### GitHub
|
|
94
|
-
|
|
95
|
-
`@<bot-user>` in an **issue body, issue comment, PR body, or PR comment** allocates a per-issue agent session and the bot replies as a comment on the same issue/PR. Streaming is simulated via `PATCH /issues/comments` edits to the bot's own comment. Each issue/PR holds its own session across follow-ups.
|
|
61
|
+
## Stations
|
|
96
62
|
|
|
97
|
-
|
|
63
|
+
Each endpoint is a **station** with declared capabilities:
|
|
98
64
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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 |
|
|
105
71
|
|
|
106
|
-
|
|
72
|
+
Run `metro stations` to see live config status (`✓` configured, `✗` not, `·` informational).
|
|
107
73
|
|
|
108
|
-
|
|
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.
|
|
109
80
|
|
|
110
81
|
---
|
|
111
82
|
|
|
112
|
-
##
|
|
113
|
-
|
|
114
|
-
Both agents run side-by-side at boot. Each line defaults to **Claude** for the first turn; once you've used an agent in a line, it sticks. Switch per-message with a `with claude` / `with codex` suffix:
|
|
83
|
+
## Lines
|
|
115
84
|
|
|
116
|
-
|
|
117
|
-
@bot draft a release note
|
|
118
|
-
→ uses Claude (default for a new line)
|
|
85
|
+
Every conversational scope is identified by a **Line** — a URI in the form `metro://<station>/<path>`:
|
|
119
86
|
|
|
120
|
-
How would Codex have done this? with codex
|
|
121
|
-
→ routes this turn to Codex; the line stays Codex on follow-ups
|
|
122
87
|
```
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
## Cross-station relay
|
|
129
|
-
|
|
130
|
-
Agents can post to any line through the CLI:
|
|
131
|
-
|
|
132
|
-
```bash
|
|
133
|
-
metro send metro://telegram/-1001234567890/42 "patch deployed"
|
|
134
|
-
metro send metro://github/bonustrack/metro/issues/1 "all clear"
|
|
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
|
|
135
93
|
```
|
|
136
94
|
|
|
137
|
-
|
|
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).
|
|
138
96
|
|
|
139
97
|
---
|
|
140
98
|
|
|
141
|
-
##
|
|
99
|
+
## CLI
|
|
142
100
|
|
|
143
101
|
```
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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.
|
|
149
122
|
```
|
|
150
123
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
- **`Station`** — name, capabilities, `start`/`stop` lifecycle.
|
|
154
|
-
- **`AgentStation`** — `createThread()`, `sendTurn(req)` returns an `AsyncIterable<TurnEvent>` of `delta` / `tool-start` / `tool-end`. Cancellation via `AbortSignal`.
|
|
155
|
-
- **`ChatStation<TMeta>`** — `onMessage`/`onStop` event hooks, `send`/`edit` for posting back. Typed meta carries platform extras (`inGuild`, `inForum`, `isPR`, …).
|
|
156
|
-
- **`Line`** — branded URI string. Each station owns its parse/format helpers in [`src/stations/line.ts`](src/stations/line.ts).
|
|
124
|
+
All commands accept `--json`. `reply` / `send` / `edit` read multi-line `<text>` from stdin if no positional is given.
|
|
157
125
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
-
|
|
162
|
-
-
|
|
163
|
-
-
|
|
164
|
-
-
|
|
165
|
-
-
|
|
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
|
|
166
134
|
|
|
167
135
|
---
|
|
168
136
|
|
|
@@ -171,40 +139,15 @@ Behaviors worth knowing:
|
|
|
171
139
|
| Variable | Default | Description |
|
|
172
140
|
|---|---|---|
|
|
173
141
|
| `TELEGRAM_BOT_TOKEN`, `DISCORD_BOT_TOKEN` | — | Bot tokens. `metro setup` writes them here. |
|
|
174
|
-
| `
|
|
142
|
+
| `METRO_CODEX_RC` | — | Codex app-server URL (`ws://…`, `wss://…`, `unix:///…`). When set, the daemon pushes each event via JSON-RPC `turn/start`. |
|
|
175
143
|
| `METRO_CONFIG_DIR` | `~/.config/metro` | Where the global `.env` lives. |
|
|
176
|
-
| `METRO_STATE_DIR` | `~/.cache/metro` | Lockfile, line cache,
|
|
144
|
+
| `METRO_STATE_DIR` | `~/.cache/metro` | Lockfile, line cache, IPC socket, telegram offset. |
|
|
177
145
|
| `METRO_LOG_LEVEL` | `info` | `trace` / `debug` / `info` / `warn` / `error` / `fatal`. |
|
|
178
146
|
|
|
179
147
|
Precedence: process env → `./.env` → `$METRO_CONFIG_DIR/.env`. Logs go to stderr.
|
|
180
148
|
|
|
181
149
|
---
|
|
182
150
|
|
|
183
|
-
## CLI
|
|
184
|
-
|
|
185
|
-
```
|
|
186
|
-
metro Run the dispatcher daemon.
|
|
187
|
-
metro setup [telegram|discord <token>] Save token, or show status.
|
|
188
|
-
metro setup clear [telegram|discord|all] Remove tokens.
|
|
189
|
-
metro doctor Health check.
|
|
190
|
-
metro stations List stations + capabilities.
|
|
191
|
-
metro lines List active conversations (sorted by recency, with names).
|
|
192
|
-
metro send <line> <text> Post to any metro:// line.
|
|
193
|
-
metro update Upgrade in place.
|
|
194
|
-
```
|
|
195
|
-
|
|
196
|
-
All commands accept `--json` for machine-readable output.
|
|
197
|
-
|
|
198
|
-
**State files** in `$METRO_STATE_DIR`:
|
|
199
|
-
- `scopes.json` — Line → agent-session map
|
|
200
|
-
- `AGENTS.md` — skill doc copied from the package on every dispatcher start; surfaced into each agent's per-turn context so it knows about `metro lines` / `metro send` / `metro stations`
|
|
201
|
-
- `.tail-lock` — dispatcher pid
|
|
202
|
-
- `codex-app-server.sock` — codex Unix socket
|
|
203
|
-
- `telegram-offset.json` — last processed update id
|
|
204
|
-
- `claude-sessions.json` — known Claude session uuids (so restarts use `--resume`)
|
|
205
|
-
|
|
206
|
-
---
|
|
207
|
-
|
|
208
151
|
## Develop
|
|
209
152
|
|
|
210
153
|
```bash
|
|
@@ -219,52 +162,24 @@ bun run lint # eslint
|
|
|
219
162
|
|
|
220
163
|
Source map:
|
|
221
164
|
|
|
222
|
-
- [`src/cli/`](src/cli/) —
|
|
223
|
-
- [`src/dispatcher.ts`](src/dispatcher.ts) — the daemon:
|
|
224
|
-
- [`src/stations/`](src/stations/) —
|
|
225
|
-
- [`src/
|
|
226
|
-
- [`
|
|
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.
|
|
227
172
|
|
|
228
173
|
CI runs typecheck + lint + build on every PR via [`.github/workflows/ci.yml`](.github/workflows/ci.yml).
|
|
229
174
|
|
|
230
175
|
---
|
|
231
176
|
|
|
232
|
-
## Testing GitHub
|
|
233
|
-
|
|
234
|
-
1. **Pick a GitHub user** the bot acts as (your own account works for testing) and generate a token. Two options:
|
|
235
|
-
- **Fine-grained PAT** at `github.com/settings/tokens?type=beta` — scoped to a test repo with **Issues: Read & write** (+ **Pull requests: Read & write** for PR comments). Works for *your* repos. For org repos, an org admin must enable fine-grained PATs in *Organization settings → Personal access tokens*.
|
|
236
|
-
- **Classic PAT** at `github.com/settings/tokens?type=classic` — pick the `repo` scope. Works for any repo you have access to, no org opt-in needed.
|
|
237
|
-
2. **Set env vars**:
|
|
238
|
-
```bash
|
|
239
|
-
export GITHUB_WEBHOOK_SECRET="$(openssl rand -hex 32)"
|
|
240
|
-
export GITHUB_BOT_USERNAME="metrobot" # see solo-testing note below
|
|
241
|
-
export GITHUB_TOKEN="ghp_..."
|
|
242
|
-
```
|
|
243
|
-
3. **Tunnel** your local webhook port to the internet:
|
|
244
|
-
```bash
|
|
245
|
-
npx smee-client --target http://localhost:4321/webhook --url <https://smee.io/your-channel>
|
|
246
|
-
```
|
|
247
|
-
4. **Add a webhook** on the test repo (*Settings → Webhooks*): payload URL = the smee channel, content type `application/json`, the same secret, events **Issues** + **Issue comments**.
|
|
248
|
-
5. **Run metro**: `METRO_LOG_LEVEL=debug metro` — you should see `github station: listening`.
|
|
249
|
-
6. **Open an issue** with body `@metrobot hello, what's this repo?` — the bot comments back (as the token's owner).
|
|
250
|
-
|
|
251
|
-
**Solo testing note.** Metro filters out self-mentions (`sender === bot`) to prevent reply loops. If you set `GITHUB_BOT_USERNAME` to your *own* username and then try to `@<yourself>` on an issue, the filter drops it — you can't trigger the bot alone. Workaround: use a pseudo-name like `metrobot` (doesn't need to be a real GitHub user — it's just a string match). The bot still replies as the token owner, but the filter passes because `your-username ≠ metrobot`.
|
|
252
|
-
|
|
253
|
-
**Common gotchas:**
|
|
254
|
-
- Webhook 401 → secret mismatch between `.env` and the webhook config.
|
|
255
|
-
- Webhook 200 but no reply → token lacks `issues:write`, or `GITHUB_BOT_USERNAME` doesn't appear in the body verbatim (case-sensitive).
|
|
256
|
-
- Bot replies in a loop → the bot's own comment somehow includes `@<bot-username>`; rare with the default prompt, but check `AGENTS.md` if it happens.
|
|
257
|
-
|
|
258
|
-
---
|
|
259
|
-
|
|
260
177
|
## Caveats
|
|
261
178
|
|
|
262
|
-
- **No allowlist.** Anyone who can DM/`@`-mention your bot can
|
|
263
|
-
- **Per-agent histories are separate.** `with codex` mid-line starts a fresh Codex session; it doesn't see what Claude saw, and vice versa.
|
|
264
|
-
- **Telegram non-forum groups are skipped.** No thread boundary to scope on. DMs and forum topics work normally.
|
|
179
|
+
- **No allowlist.** Anyone who can DM/`@`-mention your bot can produce events. Run against bots you own.
|
|
265
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.
|
|
266
|
-
- **
|
|
267
|
-
- **
|
|
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.
|
|
268
183
|
|
|
269
184
|
---
|
|
270
185
|
|
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
|
+
}
|