@stage-labs/metro 0.1.0-beta.5 → 0.1.0-beta.7
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 +93 -177
- package/dist/cache.js +75 -0
- package/dist/cli/actions.js +139 -0
- package/dist/cli/config.js +185 -0
- package/dist/cli/index.js +106 -183
- 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 +55 -163
- package/dist/history.js +84 -0
- package/dist/ipc.js +72 -0
- package/dist/stations/discord.js +200 -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 +213 -0
- package/docs/agents.md +141 -48
- package/docs/uri-scheme.md +42 -23
- package/package.json +3 -2
- package/skills/metro/SKILL.md +241 -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,26 @@
|
|
|
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` — 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
|
-
|
|
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
17
|
|
|
18
|
-
|
|
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…"
|
|
19
20
|
```
|
|
20
21
|
|
|
22
|
+
The agent owns its own streaming, tool calls, and reply timing. Metro is the wire.
|
|
23
|
+
|
|
21
24
|
---
|
|
22
25
|
|
|
23
26
|
## Quickstart
|
|
@@ -28,141 +31,107 @@ npm install -g @stage-labs/metro@beta # or: bun add -g @stage-labs/metro@beta
|
|
|
28
31
|
metro setup discord <token> # https://discord.com/developers/applications
|
|
29
32
|
metro setup telegram <token> # https://t.me/BotFather
|
|
30
33
|
metro doctor # verify
|
|
31
|
-
metro # run the
|
|
34
|
+
metro # run the daemon
|
|
32
35
|
```
|
|
33
36
|
|
|
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` |
|
|
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).
|
|
51
38
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
Each chat platform behaves slightly differently — what's universal is captured by the capabilities; the rest is in [Conversations](#conversations).
|
|
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.
|
|
55
40
|
|
|
56
41
|
---
|
|
57
42
|
|
|
58
|
-
##
|
|
59
|
-
|
|
60
|
-
Every conversational scope is identified by a **Line** — a URI in the form `metro://<station>/<path>`:
|
|
43
|
+
## Architecture
|
|
61
44
|
|
|
62
45
|
```
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
metro
|
|
67
|
-
|
|
46
|
+
Discord gateway ──┐
|
|
47
|
+
Telegram poller ──┤
|
|
48
|
+
│
|
|
49
|
+
├─▶ metro daemon ───▶ stdout (JSON events; Claude Code's Monitor reads here)
|
|
50
|
+
│ ───▶ codex-rc WebSocket (Codex turn/start; opt-in)
|
|
51
|
+
│ ◀── IPC Unix socket (metro send to agent lines)
|
|
52
|
+
│
|
|
53
|
+
agent CLI calls ──┴── REST → Discord / Telegram (metro reply / send / edit / react / download / fetch)
|
|
68
54
|
```
|
|
69
55
|
|
|
70
|
-
|
|
56
|
+
- **Inversion of control.** The agent (Claude Code, Codex) launches `metro`, not the other way around. Metro never spawns an agent process.
|
|
57
|
+
- **Single daemon per machine.** Lockfile at `$METRO_STATE_DIR/.tail-lock` enforces singleton.
|
|
58
|
+
- **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.
|
|
59
|
+
- **Cross-agent notification.** `metro send metro://claude/<topic>` (or `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
60
|
|
|
72
61
|
---
|
|
73
62
|
|
|
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.
|
|
63
|
+
## Stations
|
|
96
64
|
|
|
97
|
-
|
|
65
|
+
Each endpoint is a **station** with declared capabilities:
|
|
98
66
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
67
|
+
| Station | Kind | Modalities | Features | Config |
|
|
68
|
+
|------------|-------|---------------|-------------------------------------------------------|-------------------------------------------------------------|
|
|
69
|
+
| `discord` | chat | text + image | reply, send, edit, react, download, fetch | `DISCORD_BOT_TOKEN` + Message Content Intent |
|
|
70
|
+
| `telegram` | chat | text + image | reply, send, edit, react, download | `TELEGRAM_BOT_TOKEN` |
|
|
71
|
+
| `claude` | agent | text | notify | watches metro stdout via Claude Code's `Monitor` |
|
|
72
|
+
| `codex` | agent | text | notify | set `METRO_CODEX_RC=ws://…` to push |
|
|
105
73
|
|
|
106
|
-
|
|
74
|
+
Run `metro stations` to see live config status (`✓` configured, `✗` not, `·` informational).
|
|
107
75
|
|
|
108
|
-
|
|
76
|
+
Behaviors worth knowing:
|
|
77
|
+
- **No streaming / no edit machinery in metro.** The agent runs the show; metro is one-shot REST.
|
|
78
|
+
- **No link previews.** Outgoing messages set `link_preview_options.is_disabled` on Telegram and `SUPPRESS_EMBEDS` on Discord.
|
|
79
|
+
- **Image attachments inbound** — `[image]` placeholders surface inline in `text`; the agent calls `metro download` to materialize them. 20 MB cap.
|
|
80
|
+
- **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.
|
|
81
|
+
- **Telegram non-forum groups are skipped.** No thread boundary to scope on.
|
|
109
82
|
|
|
110
83
|
---
|
|
111
84
|
|
|
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:
|
|
85
|
+
## Lines
|
|
115
86
|
|
|
116
|
-
|
|
117
|
-
@bot draft a release note
|
|
118
|
-
→ uses Claude (default for a new line)
|
|
87
|
+
Every conversational scope is identified by a **Line** — a URI in the form `metro://<station>/<path>`:
|
|
119
88
|
|
|
120
|
-
How would Codex have done this? with codex
|
|
121
|
-
→ routes this turn to Codex; the line stays Codex on follow-ups
|
|
122
89
|
```
|
|
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"
|
|
90
|
+
metro://discord/1234567890123456789
|
|
91
|
+
metro://telegram/-1001234567890 # main chat / DM
|
|
92
|
+
metro://telegram/-1001234567890/42 # forum topic 42
|
|
93
|
+
metro://claude/deploys # agent notification sink
|
|
94
|
+
metro://codex/ci
|
|
135
95
|
```
|
|
136
96
|
|
|
137
|
-
|
|
97
|
+
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
98
|
|
|
139
99
|
---
|
|
140
100
|
|
|
141
|
-
##
|
|
101
|
+
## CLI
|
|
142
102
|
|
|
143
103
|
```
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
104
|
+
metro Run the daemon (emits JSON events on stdout).
|
|
105
|
+
metro setup [telegram|discord <token>] Save token, or show status.
|
|
106
|
+
metro setup clear [telegram|discord|all] Remove tokens.
|
|
107
|
+
metro doctor Health check.
|
|
108
|
+
metro stations List stations + capabilities.
|
|
109
|
+
metro lines List recently-seen conversations.
|
|
110
|
+
metro send <line> <text> [--image=…]… [--document=…]… [--voice=…] [--buttons=…]
|
|
111
|
+
Post a fresh message; --image/--document repeat for albums.
|
|
112
|
+
metro reply <line> <message_id> <text> [--image|--document|--voice|--buttons]
|
|
113
|
+
Threaded reply (same flags as send).
|
|
114
|
+
metro edit <line> <message_id> <text> [--buttons=<json>]
|
|
115
|
+
Edit a previously-sent message (text + URL-button keyboard).
|
|
116
|
+
metro react <line> <message_id> <emoji> Set or clear ('') a reaction.
|
|
117
|
+
metro download <line> <message_id> [--out=<dir>]
|
|
118
|
+
Download image attachments to disk.
|
|
119
|
+
metro fetch <line> [--limit=N] Recent-message lookback (Discord only).
|
|
120
|
+
metro history [--limit=N] [--line=…] [--station=…] [--kind=…] [--from=…] [--text=…] [--since=…]
|
|
121
|
+
Universal message log (every inbound + outbound), newest first.
|
|
122
|
+
metro update Upgrade in place.
|
|
149
123
|
```
|
|
150
124
|
|
|
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).
|
|
125
|
+
All commands accept `--json`. `reply` / `send` / `edit` read multi-line `<text>` from stdin if no positional is given.
|
|
157
126
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
-
|
|
162
|
-
-
|
|
163
|
-
-
|
|
164
|
-
-
|
|
165
|
-
-
|
|
127
|
+
**State files** in `$METRO_STATE_DIR` (default `~/.cache/metro`):
|
|
128
|
+
- `AGENTS.md` — agent skill copied from the package on every start (so the path is stable across upgrades)
|
|
129
|
+
- `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`).
|
|
130
|
+
- `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).
|
|
131
|
+
- `lines.json` — line → last-seen / name cache (read by `metro lines`)
|
|
132
|
+
- `.tail-lock` — dispatcher pid
|
|
133
|
+
- `metro.sock` — daemon IPC socket
|
|
134
|
+
- `telegram-offset.json` — last processed update id
|
|
166
135
|
|
|
167
136
|
---
|
|
168
137
|
|
|
@@ -171,40 +140,15 @@ Behaviors worth knowing:
|
|
|
171
140
|
| Variable | Default | Description |
|
|
172
141
|
|---|---|---|
|
|
173
142
|
| `TELEGRAM_BOT_TOKEN`, `DISCORD_BOT_TOKEN` | — | Bot tokens. `metro setup` writes them here. |
|
|
174
|
-
| `
|
|
143
|
+
| `METRO_CODEX_RC` | — | Codex app-server URL (`ws://…`, `wss://…`, `unix:///…`). When set, the daemon pushes each event via JSON-RPC `turn/start`. |
|
|
175
144
|
| `METRO_CONFIG_DIR` | `~/.config/metro` | Where the global `.env` lives. |
|
|
176
|
-
| `METRO_STATE_DIR` | `~/.cache/metro` | Lockfile, line cache,
|
|
145
|
+
| `METRO_STATE_DIR` | `~/.cache/metro` | Lockfile, line cache, IPC socket, telegram offset. |
|
|
177
146
|
| `METRO_LOG_LEVEL` | `info` | `trace` / `debug` / `info` / `warn` / `error` / `fatal`. |
|
|
178
147
|
|
|
179
148
|
Precedence: process env → `./.env` → `$METRO_CONFIG_DIR/.env`. Logs go to stderr.
|
|
180
149
|
|
|
181
150
|
---
|
|
182
151
|
|
|
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
152
|
## Develop
|
|
209
153
|
|
|
210
154
|
```bash
|
|
@@ -219,52 +163,24 @@ bun run lint # eslint
|
|
|
219
163
|
|
|
220
164
|
Source map:
|
|
221
165
|
|
|
222
|
-
- [`src/cli/`](src/cli/) —
|
|
223
|
-
- [`src/dispatcher.ts`](src/dispatcher.ts) — the daemon:
|
|
224
|
-
- [`src/stations/`](src/stations/) —
|
|
225
|
-
- [`src/
|
|
226
|
-
- [`
|
|
166
|
+
- [`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)).
|
|
167
|
+
- [`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.
|
|
168
|
+
- [`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.
|
|
169
|
+
- [`src/codex-rc.ts`](src/codex-rc.ts) — Codex app-server WebSocket push client.
|
|
170
|
+
- [`src/ipc.ts`](src/ipc.ts) — Unix-socket IPC between the daemon and one-shot CLI commands.
|
|
171
|
+
- [`src/cache.ts`](src/cache.ts) — in-memory line cache with debounced flush to `lines.json`.
|
|
172
|
+
- [`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
173
|
|
|
228
174
|
CI runs typecheck + lint + build on every PR via [`.github/workflows/ci.yml`](.github/workflows/ci.yml).
|
|
229
175
|
|
|
230
176
|
---
|
|
231
177
|
|
|
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
178
|
## Caveats
|
|
261
179
|
|
|
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.
|
|
180
|
+
- **No allowlist.** Anyone who can DM/`@`-mention your bot can produce events. Run against bots you own.
|
|
265
181
|
- **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
|
-
- **
|
|
182
|
+
- **Telegram non-forum groups are skipped.** No thread boundary to scope on. DMs and forum topics work normally.
|
|
183
|
+
- **Telegram fetch isn't supported** (bot API doesn't expose history); `metro fetch` returns `[]` on Telegram lines.
|
|
268
184
|
|
|
269
185
|
---
|
|
270
186
|
|
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
|
+
}
|