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

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,78 @@
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
+ Chat with your Claude Code or Codex agent over Telegram and Discord.
4
4
 
5
5
  ## Quickstart
6
6
 
7
+ In your shell:
8
+
7
9
  ```bash
8
10
  npm install -g @stage-labs/metro@beta # or: bun add -g @stage-labs/metro@beta
9
- ```
10
11
 
11
- > The `@beta` tag is required while Metro is in prerelease.
12
+ metro setup telegram <token> # https://t.me/BotFather
13
+ metro setup discord <token> # https://discord.com/developers/applications
12
14
 
13
- Register Metro with your agent (use `claude` or `codex` interchangeably):
14
-
15
- ```bash
16
- claude mcp add metro \
17
- --env TELEGRAM_BOT_TOKEN=123:ABC… \
18
- --env DISCORD_BOT_TOKEN=MTIz… \
19
- -- metro mcp
15
+ metro setup skill # writes SKILL.md so Claude Code + Codex auto-onboard
16
+ metro doctor # verify
20
17
  ```
21
18
 
22
- Both `--env` flags are optional configure at least one of Telegram or Discord.
19
+ > **Discord setup:** toggle **Message Content Intent** in Developer Portal Bot Privileged Gateway Intents.
23
20
 
24
- In your agent session, ask it to start the inbound stream:
21
+ ### Run with Claude Code
25
22
 
26
- > Run `metro tail` in the background and Monitor its stdout for inbound Telegram/Discord messages.
27
-
28
- DM your bot. The agent reacts on its next decision boundary (see Caveats for latency notes).
29
-
30
- ## Bot tokens
31
-
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.
23
+ ```bash
24
+ claude
25
+ > Run metro in the background.
26
+ ```
34
27
 
35
- ## How it works
28
+ Then DM your bot. The bundled skill auto-triggers — the agent launches metro via Bash + Monitor, watches stdout, and replies.
36
29
 
37
- Metro ships two commands:
30
+ ### Run with Codex
38
31
 
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.
32
+ Codex's `unified_exec` is poll-only ([#4751](https://github.com/openai/codex/issues/4751))there's no Monitor equivalent. Metro instead pushes each inbound into the agent's history via JSON-RPC. Two terminals plus a prompt the TUI's `--remote` flag only accepts `ws://`, so daemon and TUI share one URL:
41
33
 
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.
34
+ ```bash
35
+ # Terminal 1 — daemon (must be running first)
36
+ codex app-server --listen ws://127.0.0.1:8421
43
37
 
44
- ## MCP tools
38
+ # Terminal 2 — TUI attached to the daemon
39
+ codex --remote ws://127.0.0.1:8421
40
+ > Run metro in the background.
41
+ ```
45
42
 
46
- Registered by `metro mcp` — the agent calls these to act on the messages it sees from `metro tail`:
43
+ The agent launches `metro` (with `METRO_CODEX_RC=ws://127.0.0.1:8421` set) via its shell tool. Metro connects to the daemon and pushes each inbound as a `turn/start` on the active thread — the agent in terminal 2 reacts on its next turn. `codex remote-control` is stdio-only (no listener), so don't use it for this flow.
47
44
 
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.) |
45
+ Bare `codex` (no `--remote`) can't work with metro — the agent has no daemon to push to. The TUI must be attached to a running app-server.
55
46
 
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.
47
+ `METRO_CODEX_RC` accepts `ws://host:port` (required for use with the codex TUI) or `unix:///abs/path` (headless only — the daemon supports UDS but the TUI doesn't).
57
48
 
58
49
  ## Config
59
50
 
60
- All settings come from environment variables passed via the MCP server's `--env` block:
61
-
62
51
  | Variable | Default | Description |
63
52
  |---|---|---|
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. |
53
+ | `TELEGRAM_BOT_TOKEN`, `DISCORD_BOT_TOKEN` | — | Bot tokens. `metro setup` writes them here. |
54
+ | `METRO_CONFIG_DIR` | `~/.config/metro` | Where the global `.env` lives. |
55
+ | `METRO_STATE_DIR` | `~/.cache/metro` | Lockfile, attachment cache, default download dir. |
56
+ | `METRO_LOG_LEVEL` | `info` | `trace` / `debug` / `info` / `warn` / `error` / `fatal`. |
57
+ | `METRO_CODEX_RC` | — | Codex app-server URL (e.g. `ws://127.0.0.1:8421`). When set, metro pushes each inbound into the agent's history via JSON-RPC `turn/start` — the Codex equivalent of Claude Code's Monitor. Accepts `ws://host:port` (required for use with the codex TUI) or `unix:///abs/path` (headless only). See [Codex setup](#codex-setup). |
68
58
 
69
- Logs go to stderr. Claude Code captures them at `~/Library/Caches/claude-cli-nodejs/…/mcp-logs-plugin-metro-metro/*.jsonl`.
59
+ Token precedence: process env `./.env` `$METRO_CONFIG_DIR/.env`. Logs to stderr.
70
60
 
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.
61
+ ## Reference
72
62
 
73
- ## Troubleshooting
63
+ - `metro --help` — command surface
64
+ - `metro doctor` — health check
65
+ - [SKILL.md](skills/metro/SKILL.md) — agent-facing flow
74
66
 
75
- ```bash
76
- which metro # → e.g. ~/.bun/bin/metro
77
- metro # prints usage
78
-
79
- ps aux | grep metro | grep -v grep # one `metro mcp`, optionally one `metro tail`
67
+ ## Uninstall
80
68
 
81
- rm -rf ~/.cache/metro/ # clean stuck state — or whatever METRO_STATE_DIR points at
82
-
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
69
+ ```bash
70
+ metro setup clear; metro setup skill --clear
71
+ rm -rf ~/.cache/metro/
72
+ npm uninstall -g @stage-labs/metro
85
73
  ```
86
74
 
87
75
  ## Caveats
88
76
 
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
77
  - **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.
78
+ - **Latency.** Inbounds surface at the next agent decision boundary — sub-second on Claude Code, longer on Codex turns.
@@ -1,11 +1,40 @@
1
1
  import { Client, Events, GatewayIntentBits, Partials } from 'discord.js';
2
2
  import { errMsg, log } from '../log.js';
3
+ // Receive path: discord.js gateway, used by tail.ts only.
4
+ // Send path: raw REST against discord.com/api — no gateway login required,
5
+ // so cli.ts subcommands stay one-shot and fast.
6
+ const API_BASE = 'https://discord.com/api/v10';
7
+ function token() {
8
+ const t = process.env.DISCORD_BOT_TOKEN;
9
+ if (!t)
10
+ throw new Error('DISCORD_BOT_TOKEN is not set');
11
+ return t;
12
+ }
13
+ async function rest(method, path, body, timeoutMs = 30_000) {
14
+ const res = await fetch(`${API_BASE}${path}`, {
15
+ method,
16
+ headers: {
17
+ 'Authorization': `Bot ${token()}`,
18
+ 'User-Agent': 'metro (https://github.com/bonustrack/metro, dev)',
19
+ ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
20
+ },
21
+ body: body !== undefined ? JSON.stringify(body) : undefined,
22
+ signal: AbortSignal.timeout(timeoutMs),
23
+ });
24
+ if (!res.ok) {
25
+ const text = await res.text().catch(() => '');
26
+ throw new Error(`discord ${method} ${path}: ${res.status} ${text}`);
27
+ }
28
+ // 204 No Content for typing/reactions/clear.
29
+ if (res.status === 204)
30
+ return undefined;
31
+ return (await res.json());
32
+ }
33
+ // ---------- Receive path (gateway, discord.js) -----------------------------
3
34
  let client = null;
4
35
  function getClient() {
5
36
  if (client)
6
37
  return client;
7
- if (!process.env.DISCORD_BOT_TOKEN)
8
- throw new Error('DISCORD_BOT_TOKEN is not set');
9
38
  client = new Client({
10
39
  intents: [
11
40
  GatewayIntentBits.DirectMessages,
@@ -18,26 +47,26 @@ function getClient() {
18
47
  });
19
48
  return client;
20
49
  }
21
- async function getTextChannel(channelId) {
22
- const channel = await getClient().channels.fetch(channelId);
23
- if (!channel?.isTextBased() || !('messages' in channel)) {
24
- throw new Error(`discord: channel ${channelId} is not text-capable`);
25
- }
26
- return channel;
27
- }
28
- async function fetchMessage(channelId, messageId) {
29
- return (await getTextChannel(channelId)).messages.fetch(messageId);
30
- }
31
50
  let onInboundHandler = () => { };
32
51
  export function onInbound(handler) {
33
52
  onInboundHandler = handler;
34
53
  }
54
+ export async function shutdownGateway() {
55
+ if (!client)
56
+ return;
57
+ await client.destroy();
58
+ client = null;
59
+ }
35
60
  export async function startGateway() {
36
61
  const c = getClient();
37
62
  c.on(Events.MessageCreate, m => {
38
63
  if (m.author.bot)
39
64
  return;
40
65
  // Guild messages: only forward when the bot is mentioned. DMs always pass.
66
+ // The bot's own @-mention is preserved in `m.content` — stripping it would
67
+ // lose mid-sentence position ("Wdyt @Metro is this good?" → "Wdyt is this
68
+ // good?") and silently drop bare-mention pings. The agent recognizes
69
+ // `<@bot_id>` and acts on the request as a whole.
41
70
  if (m.guildId && c.user && !m.mentions.has(c.user.id))
42
71
  return;
43
72
  const tags = [...m.attachments.values()]
@@ -59,59 +88,61 @@ export async function startGateway() {
59
88
  await new Promise(r => c.once(Events.ClientReady, () => r()));
60
89
  }
61
90
  export async function getMe() {
62
- const c = getClient();
63
- if (!c.user)
64
- throw new Error('discord: gateway not ready');
65
- return { username: c.user.username };
91
+ const me = await rest('GET', '/users/@me');
92
+ return { username: me.username };
93
+ }
94
+ export async function sendMessage(channelId, text) {
95
+ const sent = await rest('POST', `/channels/${channelId}/messages`, { content: text });
96
+ return sent.id;
66
97
  }
67
98
  export async function replyToMessage(channelId, messageId, text) {
68
- await (await fetchMessage(channelId, messageId)).reply(text);
99
+ const sent = await rest('POST', `/channels/${channelId}/messages`, {
100
+ content: text,
101
+ message_reference: { message_id: messageId, fail_if_not_exists: false },
102
+ });
103
+ return sent.id;
69
104
  }
70
105
  export async function editMessage(channelId, messageId, text) {
71
- await (await fetchMessage(channelId, messageId)).edit(text);
106
+ const sent = await rest('PATCH', `/channels/${channelId}/messages/${messageId}`, { content: text });
107
+ return sent.id;
72
108
  }
73
109
  export async function sendTyping(channelId) {
74
- const channel = await getClient().channels.fetch(channelId);
75
- if (!channel?.isTextBased() || !('sendTyping' in channel))
76
- return;
77
- await channel.sendTyping();
110
+ await rest('POST', `/channels/${channelId}/typing`);
78
111
  }
79
112
  export async function setReaction(channelId, messageId, emoji) {
80
- const target = await fetchMessage(channelId, messageId);
81
113
  if (emoji) {
82
- await target.react(emoji);
114
+ await rest('PUT', `/channels/${channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}/@me`);
83
115
  return;
84
116
  }
85
117
  // Clear only the bot's own reactions (matches Telegram's clear semantics).
86
- const me = getClient().user;
87
- if (!me)
88
- return;
89
- for (const r of target.reactions.cache.values()) {
90
- if (r.users.cache.has(me.id))
91
- await r.users.remove(me.id);
118
+ const msg = await rest('GET', `/channels/${channelId}/messages/${messageId}`);
119
+ for (const r of msg.reactions ?? []) {
120
+ if (!r.me || !r.emoji.name)
121
+ continue;
122
+ await rest('DELETE', `/channels/${channelId}/messages/${messageId}/reactions/${encodeURIComponent(r.emoji.name)}/@me`);
92
123
  }
93
124
  }
94
125
  export async function fetchAttachments(channelId, messageId) {
95
- const target = await fetchMessage(channelId, messageId);
126
+ const msg = await rest('GET', `/channels/${channelId}/messages/${messageId}`);
96
127
  const out = [];
97
- for (const a of target.attachments.values()) {
98
- if (!a.contentType?.startsWith('image/'))
128
+ for (const a of msg.attachments) {
129
+ if (!a.content_type?.startsWith('image/'))
99
130
  continue;
100
- const res = await fetch(a.url);
131
+ const res = await fetch(a.url, { signal: AbortSignal.timeout(30_000) });
101
132
  if (!res.ok)
102
133
  throw new Error(`discord: download ${a.url}: ${res.status}`);
103
- out.push({ data: Buffer.from(await res.arrayBuffer()).toString('base64'), mime: a.contentType });
134
+ out.push({ data: Buffer.from(await res.arrayBuffer()).toString('base64'), mime: a.content_type });
104
135
  }
105
136
  return out;
106
137
  }
107
138
  export async function fetchRecentMessages(channelId, limit) {
108
- const channel = await getTextChannel(channelId);
109
- const msgs = await channel.messages.fetch({ limit: Math.min(Math.max(limit, 1), 100) });
139
+ const n = Math.min(Math.max(limit, 1), 100);
140
+ const msgs = await rest('GET', `/channels/${channelId}/messages?limit=${n}`);
110
141
  // Discord returns newest-first; reverse for chronological.
111
- return [...msgs.values()].reverse().map(m => ({
142
+ return [...msgs].reverse().map(m => ({
112
143
  message_id: m.id,
113
144
  author: m.author.username,
114
145
  text: m.content,
115
- timestamp: m.createdAt.toISOString(),
146
+ timestamp: m.timestamp,
116
147
  }));
117
148
  }
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { STATE_DIR } from '../config.js';
3
+ import { STATE_DIR } from '../paths.js';
4
4
  import { errMsg, log } from '../log.js';
5
5
  const API_BASE = 'https://api.telegram.org';
6
6
  function token() {
@@ -69,8 +69,12 @@ export async function downloadAttachment(fileId, mime) {
69
69
  });
70
70
  if (!res.ok)
71
71
  throw new Error(`download failed: ${res.status}`);
72
- const blob = await res.blob();
73
- return { data: Buffer.from(await blob.arrayBuffer()).toString('base64'), mime: blob.type || mime };
72
+ const buf = Buffer.from(await res.arrayBuffer());
73
+ // Trust the cached mime — it's the authoritative one from the message
74
+ // metadata (`image/jpeg` for photos, the document's mime_type for files).
75
+ // The Telegram CDN often returns `application/octet-stream` as Content-Type,
76
+ // which would otherwise wipe out our extension classification downstream.
77
+ return { data: buf.toString('base64'), mime };
74
78
  }
75
79
  async function messageToText(m, chatId) {
76
80
  if (m.text)