@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 +42 -57
- package/dist/channels/discord.js +70 -39
- package/dist/channels/telegram.js +7 -3
- package/dist/cli.js +569 -9
- package/dist/lib/address.js +21 -0
- package/dist/lib/codex-rc.js +274 -0
- package/dist/lib/dotenv.js +31 -0
- package/dist/log.js +10 -3
- package/dist/paths.js +45 -0
- package/dist/tail.js +45 -15
- package/package.json +5 -4
- package/skills/metro/SKILL.md +122 -0
- package/dist/config.js +0 -33
- package/dist/server.js +0 -158
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.
|
|
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
|
-
|
|
12
|
+
metro setup telegram <token> # https://t.me/BotFather
|
|
13
|
+
metro setup discord <token> # https://discord.com/developers/applications
|
|
12
14
|
|
|
13
|
-
|
|
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
|
-
|
|
19
|
+
> **Discord setup:** toggle **Message Content Intent** in Developer Portal → Bot → Privileged Gateway Intents.
|
|
23
20
|
|
|
24
|
-
|
|
21
|
+
### Run with Claude Code
|
|
25
22
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
28
|
+
Then DM your bot. The bundled skill auto-triggers — the agent launches metro via Bash + Monitor, watches stdout, and replies.
|
|
36
29
|
|
|
37
|
-
|
|
30
|
+
### Run with Codex
|
|
38
31
|
|
|
39
|
-
-
|
|
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
|
-
|
|
34
|
+
```bash
|
|
35
|
+
# Terminal 1 — daemon (must be running first)
|
|
36
|
+
codex app-server --listen ws://127.0.0.1:8421
|
|
43
37
|
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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` | — |
|
|
65
|
-
| `
|
|
66
|
-
| `
|
|
67
|
-
| `
|
|
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
|
-
|
|
59
|
+
Token precedence: process env → `./.env` → `$METRO_CONFIG_DIR/.env`. Logs to stderr.
|
|
70
60
|
|
|
71
|
-
|
|
61
|
+
## Reference
|
|
72
62
|
|
|
73
|
-
|
|
63
|
+
- `metro --help` — command surface
|
|
64
|
+
- `metro doctor` — health check
|
|
65
|
+
- [SKILL.md](skills/metro/SKILL.md) — agent-facing flow
|
|
74
66
|
|
|
75
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
- **
|
|
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.
|
package/dist/channels/discord.js
CHANGED
|
@@ -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
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
126
|
+
const msg = await rest('GET', `/channels/${channelId}/messages/${messageId}`);
|
|
96
127
|
const out = [];
|
|
97
|
-
for (const a of
|
|
98
|
-
if (!a.
|
|
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.
|
|
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
|
|
109
|
-
const msgs = await
|
|
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
|
|
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.
|
|
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 '../
|
|
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
|
|
73
|
-
|
|
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)
|