@stage-labs/metro 0.1.0-beta.0 → 0.1.0-beta.1
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 +26 -64
- package/dist/channels/discord.js +70 -39
- package/dist/channels/telegram.js +7 -3
- package/dist/cli.js +557 -9
- package/dist/lib/address.js +21 -0
- package/dist/lib/dotenv.js +31 -0
- package/dist/log.js +3 -2
- package/dist/paths.js +37 -0
- package/dist/tail.js +28 -14
- package/package.json +4 -5
- package/skills/metro/SKILL.md +99 -0
- package/dist/config.js +0 -33
- package/dist/server.js +0 -158
package/README.md
CHANGED
|
@@ -1,93 +1,55 @@
|
|
|
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
|
-
> The `@beta` tag is required while Metro is in prerelease.
|
|
12
11
|
|
|
13
|
-
|
|
12
|
+
metro setup telegram <token> # https://t.me/BotFather
|
|
13
|
+
metro setup discord <token> # https://discord.com/developers/applications
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
In your agent session, ask it to start the inbound stream:
|
|
25
|
-
|
|
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.
|
|
34
|
-
|
|
35
|
-
## How it works
|
|
19
|
+
> **Discord setup:** toggle **Message Content Intent** in Developer Portal → Bot → Privileged Gateway Intents.
|
|
36
20
|
|
|
37
|
-
|
|
21
|
+
Open Claude Code (`claude`) or Codex (`codex`), and tell it:
|
|
38
22
|
|
|
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.
|
|
23
|
+
> Run `metro` in the background.
|
|
41
24
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
## MCP tools
|
|
45
|
-
|
|
46
|
-
Registered by `metro mcp` — the agent calls these to act on the messages it sees from `metro tail`:
|
|
47
|
-
|
|
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.) |
|
|
55
|
-
|
|
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.
|
|
25
|
+
DM your bot. The agent picks up the next inbound and replies — the bundled skill handles launching, stdout watching, reactions, and replies.
|
|
57
26
|
|
|
58
27
|
## Config
|
|
59
28
|
|
|
60
|
-
All settings come from environment variables passed via the MCP server's `--env` block:
|
|
61
|
-
|
|
62
29
|
| Variable | Default | Description |
|
|
63
30
|
|---|---|---|
|
|
64
|
-
| `TELEGRAM_BOT_TOKEN` | — |
|
|
65
|
-
| `
|
|
66
|
-
| `
|
|
67
|
-
| `
|
|
31
|
+
| `TELEGRAM_BOT_TOKEN`, `DISCORD_BOT_TOKEN` | — | Bot tokens. `metro setup` writes them here. |
|
|
32
|
+
| `METRO_CONFIG_DIR` | `~/.config/metro` | Where the global `.env` lives. |
|
|
33
|
+
| `METRO_STATE_DIR` | `~/.cache/metro` | Lockfile, attachment cache, default download dir. |
|
|
34
|
+
| `METRO_LOG_LEVEL` | `info` | `trace` / `debug` / `info` / `warn` / `error` / `fatal`. |
|
|
68
35
|
|
|
69
|
-
|
|
36
|
+
Token precedence: process env → `./.env` → `$METRO_CONFIG_DIR/.env`. Logs to stderr.
|
|
70
37
|
|
|
71
|
-
|
|
38
|
+
## Reference
|
|
72
39
|
|
|
73
|
-
|
|
40
|
+
- `metro --help` — command surface
|
|
41
|
+
- `metro doctor` — health check
|
|
42
|
+
- [SKILL.md](skills/metro/SKILL.md) — agent-facing flow
|
|
74
43
|
|
|
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`
|
|
44
|
+
## Uninstall
|
|
80
45
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
46
|
+
```bash
|
|
47
|
+
metro setup clear; metro setup skill --clear
|
|
48
|
+
rm -rf ~/.cache/metro/
|
|
49
|
+
npm uninstall -g @stage-labs/metro
|
|
85
50
|
```
|
|
86
51
|
|
|
87
52
|
## Caveats
|
|
88
53
|
|
|
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
54
|
- **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.
|
|
55
|
+
- **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)
|