@stage-labs/metro 0.1.0-beta.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stage Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # Metro
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.
4
+
5
+ ## Quickstart
6
+
7
+ ```bash
8
+ 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
+
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
20
+ ```
21
+
22
+ Both `--env` flags are optional — configure at least one of Telegram or Discord.
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
36
+
37
+ Metro ships two commands:
38
+
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.
41
+
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.
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.
57
+
58
+ ## Config
59
+
60
+ All settings come from environment variables passed via the MCP server's `--env` block:
61
+
62
+ | Variable | Default | Description |
63
+ |---|---|---|
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. |
68
+
69
+ Logs go to stderr. Claude Code captures them at `~/Library/Caches/claude-cli-nodejs/…/mcp-logs-plugin-metro-metro/*.jsonl`.
70
+
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.
72
+
73
+ ## Troubleshooting
74
+
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`
80
+
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
85
+ ```
86
+
87
+ ## Caveats
88
+
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
+ - **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.
@@ -0,0 +1,117 @@
1
+ import { Client, Events, GatewayIntentBits, Partials } from 'discord.js';
2
+ import { errMsg, log } from '../log.js';
3
+ let client = null;
4
+ function getClient() {
5
+ if (client)
6
+ return client;
7
+ if (!process.env.DISCORD_BOT_TOKEN)
8
+ throw new Error('DISCORD_BOT_TOKEN is not set');
9
+ client = new Client({
10
+ intents: [
11
+ GatewayIntentBits.DirectMessages,
12
+ GatewayIntentBits.Guilds,
13
+ GatewayIntentBits.GuildMessages,
14
+ GatewayIntentBits.MessageContent,
15
+ ],
16
+ // DM channels arrive partial; without this messageCreate never fires.
17
+ partials: [Partials.Channel],
18
+ });
19
+ return client;
20
+ }
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
+ let onInboundHandler = () => { };
32
+ export function onInbound(handler) {
33
+ onInboundHandler = handler;
34
+ }
35
+ export async function startGateway() {
36
+ const c = getClient();
37
+ c.on(Events.MessageCreate, m => {
38
+ if (m.author.bot)
39
+ return;
40
+ // Guild messages: only forward when the bot is mentioned. DMs always pass.
41
+ if (m.guildId && c.user && !m.mentions.has(c.user.id))
42
+ return;
43
+ const tags = [...m.attachments.values()]
44
+ .map(a => {
45
+ if (a.contentType?.startsWith('image/'))
46
+ return '[image]';
47
+ if (a.contentType?.startsWith('audio/'))
48
+ return `[audio: ${a.name}]`;
49
+ return `[file: ${a.name}]`;
50
+ })
51
+ .join(' ');
52
+ const text = [m.content, tags].filter(Boolean).join(' ').trim();
53
+ if (!text)
54
+ return;
55
+ onInboundHandler({ channel_id: m.channelId, message_id: m.id, text });
56
+ });
57
+ c.on(Events.Error, err => log.error({ err: errMsg(err) }, 'discord error'));
58
+ await c.login(process.env.DISCORD_BOT_TOKEN);
59
+ await new Promise(r => c.once(Events.ClientReady, () => r()));
60
+ }
61
+ 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 };
66
+ }
67
+ export async function replyToMessage(channelId, messageId, text) {
68
+ await (await fetchMessage(channelId, messageId)).reply(text);
69
+ }
70
+ export async function editMessage(channelId, messageId, text) {
71
+ await (await fetchMessage(channelId, messageId)).edit(text);
72
+ }
73
+ 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();
78
+ }
79
+ export async function setReaction(channelId, messageId, emoji) {
80
+ const target = await fetchMessage(channelId, messageId);
81
+ if (emoji) {
82
+ await target.react(emoji);
83
+ return;
84
+ }
85
+ // 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);
92
+ }
93
+ }
94
+ export async function fetchAttachments(channelId, messageId) {
95
+ const target = await fetchMessage(channelId, messageId);
96
+ const out = [];
97
+ for (const a of target.attachments.values()) {
98
+ if (!a.contentType?.startsWith('image/'))
99
+ continue;
100
+ const res = await fetch(a.url);
101
+ if (!res.ok)
102
+ throw new Error(`discord: download ${a.url}: ${res.status}`);
103
+ out.push({ data: Buffer.from(await res.arrayBuffer()).toString('base64'), mime: a.contentType });
104
+ }
105
+ return out;
106
+ }
107
+ 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) });
110
+ // Discord returns newest-first; reverse for chronological.
111
+ return [...msgs.values()].reverse().map(m => ({
112
+ message_id: m.id,
113
+ author: m.author.username,
114
+ text: m.content,
115
+ timestamp: m.createdAt.toISOString(),
116
+ }));
117
+ }
@@ -0,0 +1,133 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { STATE_DIR } from '../config.js';
4
+ import { errMsg, log } from '../log.js';
5
+ const API_BASE = 'https://api.telegram.org';
6
+ function token() {
7
+ const t = process.env.TELEGRAM_BOT_TOKEN;
8
+ if (!t)
9
+ throw new Error('TELEGRAM_BOT_TOKEN is not set');
10
+ return t;
11
+ }
12
+ export async function tg(method, body, timeoutMs = 30_000) {
13
+ const res = await fetch(`${API_BASE}/bot${token()}/${method}`, {
14
+ method: 'POST',
15
+ headers: { 'Content-Type': 'application/json' },
16
+ body: JSON.stringify(body),
17
+ signal: AbortSignal.timeout(timeoutMs),
18
+ });
19
+ const json = (await res.json());
20
+ if (!json.ok)
21
+ throw new Error(`telegram ${method}: ${json.description ?? 'unknown error'}`);
22
+ return json.result;
23
+ }
24
+ export function buildSendBody(chatId, text, opts) {
25
+ const body = { chat_id: chatId, text };
26
+ if (opts.parseMode)
27
+ body.parse_mode = opts.parseMode;
28
+ if (opts.disableLinkPreview)
29
+ body.link_preview_options = { is_disabled: true };
30
+ if (opts.buttons?.length)
31
+ body.reply_markup = { inline_keyboard: opts.buttons };
32
+ return body;
33
+ }
34
+ export async function getMe() {
35
+ return tg('getMe', {});
36
+ }
37
+ const CACHE_MAX = 200;
38
+ const cacheFile = join(STATE_DIR, 'telegram-attachments.json');
39
+ function readCache() {
40
+ try {
41
+ return existsSync(cacheFile) ? JSON.parse(readFileSync(cacheFile, 'utf8')) : {};
42
+ }
43
+ catch (err) {
44
+ log.warn({ err: errMsg(err) }, 'telegram attachment cache read failed');
45
+ return {};
46
+ }
47
+ }
48
+ function cacheAttachment(chatId, messageId, att) {
49
+ try {
50
+ const data = readCache();
51
+ const key = `${chatId}:${messageId}`;
52
+ data[key] = [...(data[key] ?? []), att];
53
+ const keys = Object.keys(data);
54
+ for (const stale of keys.slice(0, Math.max(0, keys.length - CACHE_MAX)))
55
+ delete data[stale];
56
+ writeFileSync(cacheFile, JSON.stringify(data, null, 2));
57
+ }
58
+ catch (err) {
59
+ log.warn({ err: errMsg(err) }, 'telegram attachment cache write failed');
60
+ }
61
+ }
62
+ export function getCachedAttachments(chatId, messageId) {
63
+ return readCache()[`${chatId}:${messageId}`] ?? [];
64
+ }
65
+ export async function downloadAttachment(fileId, mime) {
66
+ const file = await tg('getFile', { file_id: fileId });
67
+ const res = await fetch(`${API_BASE}/file/bot${token()}/${file.file_path}`, {
68
+ signal: AbortSignal.timeout(30_000),
69
+ });
70
+ if (!res.ok)
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 };
74
+ }
75
+ async function messageToText(m, chatId) {
76
+ if (m.text)
77
+ return m.text;
78
+ const caption = m.caption ?? '';
79
+ if (m.photo?.length) {
80
+ const photo = m.photo[m.photo.length - 1];
81
+ cacheAttachment(chatId, m.message_id, { file_id: photo.file_id, mime: 'image/jpeg' });
82
+ return [caption, '[image]'].filter(Boolean).join(' ');
83
+ }
84
+ if (m.document?.mime_type?.startsWith('image/')) {
85
+ cacheAttachment(chatId, m.message_id, { file_id: m.document.file_id, mime: m.document.mime_type });
86
+ return [caption, '[image]'].filter(Boolean).join(' ');
87
+ }
88
+ if (m.voice)
89
+ return [caption, '[voice]'].filter(Boolean).join(' ');
90
+ if (m.audio)
91
+ return [caption, '[audio]'].filter(Boolean).join(' ');
92
+ return caption || null;
93
+ }
94
+ let onInboundHandler = () => { };
95
+ export function onInbound(handler) {
96
+ onInboundHandler = handler;
97
+ }
98
+ export async function startPolling() {
99
+ // A registered webhook short-circuits getUpdates — clear it defensively.
100
+ await tg('deleteWebhook', { drop_pending_updates: false }).catch(() => { });
101
+ let offset = 0;
102
+ const initial = await tg('getUpdates', { timeout: 0 });
103
+ if (initial.length)
104
+ offset = initial[initial.length - 1].update_id + 1;
105
+ while (true) {
106
+ try {
107
+ const updates = await tg('getUpdates', { offset, timeout: 50 }, 60_000);
108
+ for (const u of updates) {
109
+ offset = u.update_id + 1;
110
+ void dispatchUpdate(u);
111
+ }
112
+ }
113
+ catch (err) {
114
+ log.error({ err: errMsg(err) }, 'telegram poll error');
115
+ await new Promise(r => setTimeout(r, 1000));
116
+ }
117
+ }
118
+ }
119
+ async function dispatchUpdate(u) {
120
+ const m = u.message;
121
+ if (!m?.chat?.id || typeof m.message_id !== 'number')
122
+ return;
123
+ const base = { chat_id: m.chat.id, message_id: m.message_id };
124
+ try {
125
+ const text = await messageToText(m, m.chat.id);
126
+ if (text === null)
127
+ return;
128
+ onInboundHandler({ ...base, text });
129
+ }
130
+ catch (err) {
131
+ onInboundHandler({ ...base, text: `[message processing failed: ${errMsg(err)}]` });
132
+ }
133
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ const cmd = process.argv[2];
3
+ if (cmd === 'tail') {
4
+ await import('./tail.js');
5
+ }
6
+ else if (cmd === 'mcp') {
7
+ await import('./server.js');
8
+ }
9
+ else {
10
+ process.stderr.write('usage: metro <tail|mcp>\n');
11
+ process.exit(cmd ? 1 : 0);
12
+ }
13
+ export {};
package/dist/config.js ADDED
@@ -0,0 +1,33 @@
1
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { log } from './log.js';
5
+ // Lockfiles, typing-stop signals, and the attachment cache live here.
6
+ // Override with METRO_STATE_DIR.
7
+ export const STATE_DIR = process.env.METRO_STATE_DIR ?? join(homedir(), '.cache', 'metro');
8
+ mkdirSync(STATE_DIR, { recursive: true });
9
+ // Optional .env in cwd — convenience for local development. In production,
10
+ // env vars come from the MCP server's `env` block.
11
+ export function loadMetroEnv() {
12
+ const envFile = join(process.cwd(), '.env');
13
+ if (!existsSync(envFile))
14
+ return;
15
+ for (const line of readFileSync(envFile, 'utf8').split('\n')) {
16
+ const m = line.match(/^\s*([A-Za-z_]\w*)\s*=\s*(.*?)\s*$/);
17
+ if (m && process.env[m[1]] === undefined) {
18
+ process.env[m[1]] = m[2].replace(/^(['"])(.*)\1$/, '$2');
19
+ }
20
+ }
21
+ }
22
+ export function configuredPlatforms() {
23
+ return {
24
+ telegram: !!process.env.TELEGRAM_BOT_TOKEN,
25
+ discord: !!process.env.DISCORD_BOT_TOKEN,
26
+ };
27
+ }
28
+ export function requireConfiguredPlatform(p) {
29
+ if (p.telegram || p.discord)
30
+ return;
31
+ log.fatal('set TELEGRAM_BOT_TOKEN and/or DISCORD_BOT_TOKEN — pass via the MCP server `env` block, or in ./.env for local dev');
32
+ process.exit(1);
33
+ }
package/dist/log.js ADDED
@@ -0,0 +1,5 @@
1
+ // Pino → stderr. Stdout is reserved for MCP JSON-RPC; any stray write there
2
+ // breaks the protocol. Override level with METRO_LOG_LEVEL.
3
+ import pino from 'pino';
4
+ export const log = pino({ name: 'metro', level: process.env.METRO_LOG_LEVEL || 'info' }, pino.destination(2));
5
+ export const errMsg = (err) => (err instanceof Error ? err.message : String(err));
package/dist/server.js ADDED
@@ -0,0 +1,158 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
+ import { z } from 'zod';
6
+ import pkg from '../package.json' with { type: 'json' };
7
+ import * as discord from './channels/discord.js';
8
+ import * as telegram from './channels/telegram.js';
9
+ import { buildSendBody, tg } from './channels/telegram.js';
10
+ import { configuredPlatforms, loadMetroEnv, STATE_DIR, requireConfiguredPlatform } from './config.js';
11
+ import { errMsg, log } from './log.js';
12
+ loadMetroEnv();
13
+ const platforms = configuredPlatforms();
14
+ requireConfiguredPlatform(platforms);
15
+ // Tell tail.ts to stop refreshing typing for this chat (the agent has replied).
16
+ const TYPING_DIR = join(STATE_DIR, '.typing-stop');
17
+ function signalReplyComplete(platform, chat) {
18
+ try {
19
+ mkdirSync(TYPING_DIR, { recursive: true });
20
+ writeFileSync(join(TYPING_DIR, `${platform}_${chat}`), '');
21
+ }
22
+ catch (err) {
23
+ log.warn({ err: errMsg(err) }, 'typing stop-signal write failed');
24
+ }
25
+ }
26
+ // Discord tools need a logged-in client for REST calls; warm the gateway in
27
+ // the background so MCP stdio binds before discord.js's slow handshake. Tool
28
+ // handlers below await `discordReady` before touching the client.
29
+ const discordReady = platforms.discord
30
+ ? discord.startGateway().catch(err => log.warn({ err: errMsg(err) }, 'discord gateway warmup failed'))
31
+ : Promise.resolve();
32
+ const ok = (text) => ({ content: [{ type: 'text', text }] });
33
+ const enabled = [platforms.telegram && 'Telegram', platforms.discord && 'Discord']
34
+ .filter(Boolean)
35
+ .join(' + ');
36
+ const server = new McpServer({ name: 'metro', version: pkg.version }, {
37
+ capabilities: { tools: {} },
38
+ instructions: `Metro: ${enabled} channel.\n\n` +
39
+ 'Inbound messages arrive as JSON lines on stdout of `metro tail` running in ' +
40
+ 'the background (Bash+Monitor on Claude Code, unified_exec on Codex). Each line: ' +
41
+ '`{"platform":"telegram"|"discord","chat_id"|"channel_id":"…","message_id":…,"text":"…"}`.\n\n' +
42
+ 'VISIBILITY: when handling an inbound message, your FIRST visible line MUST echo ' +
43
+ 'the content so the user sees what arrived:\n' +
44
+ ' [telegram chat_id=<id>] <content>\n' +
45
+ ' [discord channel_id=<id>] <content>\n\n' +
46
+ 'Then call the matching tool — `telegram-*` or `discord-*` — passing `chat_id`, ' +
47
+ '`channel_id`, and `message_id` verbatim from the inbound. Defaults: `reply` for ' +
48
+ 'questions, `react` 👍 for quick acks, `edit-message` to update, ' +
49
+ '`*-download-attachment` for `[image]` markers (voice/audio are opaque), ' +
50
+ '`discord-fetch-messages` for prior context.',
51
+ });
52
+ const parseMode = z.enum(['HTML', 'MarkdownV2']).optional();
53
+ const disableLinkPreview = z.boolean().optional();
54
+ const buttons = z
55
+ .array(z.array(z.object({ text: z.string(), url: z.string() })))
56
+ .optional()
57
+ .describe('Inline URL buttons. Outer = rows, inner = buttons in row.');
58
+ if (platforms.telegram) {
59
+ const chatId = z.union([z.string(), z.number()]).describe('Telegram chat_id from the inbound tag.');
60
+ const messageId = z.number().int().describe('Telegram message_id from the inbound tag.');
61
+ const sendInputs = { chatId, messageId, text: z.string(), parseMode, disableLinkPreview, buttons };
62
+ server.registerTool('telegram-reply', { description: "Quote-reply to a Telegram message. Threads under the user's original.", inputSchema: sendInputs }, async ({ chatId, messageId, text, parseMode, disableLinkPreview, buttons }) => {
63
+ const body = buildSendBody(chatId, text, { parseMode, disableLinkPreview, buttons });
64
+ body.reply_parameters = { message_id: messageId, allow_sending_without_reply: true };
65
+ await tg('sendMessage', body);
66
+ signalReplyComplete('telegram', String(chatId));
67
+ // Clear the auto-acknowledgement emoji on the specific message we replied to.
68
+ await tg('setMessageReaction', { chat_id: chatId, message_id: messageId, reaction: [] }).catch(err => log.warn({ err: errMsg(err) }, 'telegram clear-reaction failed'));
69
+ return ok('sent');
70
+ });
71
+ server.registerTool('telegram-react', {
72
+ description: "Set or clear an emoji reaction. Pass '' to clear. Telegram's bot whitelist: " +
73
+ '👍 ❤️ 🔥 🥰 👏 😁 🤔 🎉 🙏 👌 💯 🤣 …',
74
+ inputSchema: { chatId, messageId, emoji: z.string() },
75
+ }, async ({ chatId, messageId, emoji }) => {
76
+ const reaction = emoji ? [{ type: 'emoji', emoji }] : [];
77
+ await tg('setMessageReaction', { chat_id: chatId, message_id: messageId, reaction });
78
+ return ok(emoji ? 'reacted' : 'cleared');
79
+ });
80
+ server.registerTool('telegram-edit-message', { description: 'Edit a Telegram message the bot previously sent.', inputSchema: sendInputs }, async ({ chatId, messageId, text, parseMode, disableLinkPreview, buttons }) => {
81
+ const body = buildSendBody(chatId, text, { parseMode, disableLinkPreview, buttons });
82
+ body.message_id = messageId;
83
+ await tg('editMessageText', body);
84
+ return ok('edited');
85
+ });
86
+ server.registerTool('telegram-download-attachment', {
87
+ description: 'Download image attachments as image content blocks.',
88
+ inputSchema: { chatId, messageId },
89
+ }, async ({ chatId, messageId }) => {
90
+ const atts = telegram.getCachedAttachments(chatId, messageId);
91
+ if (atts.length === 0)
92
+ return ok('no cached attachments — message may pre-date this session');
93
+ const blocks = await Promise.all(atts.map(async (a) => {
94
+ const { data, mime } = await telegram.downloadAttachment(a.file_id, a.mime);
95
+ return { type: 'image', data, mimeType: mime };
96
+ }));
97
+ return { content: blocks };
98
+ });
99
+ }
100
+ if (platforms.discord) {
101
+ const channelId = z.string().describe('Discord channel snowflake from the inbound tag.');
102
+ const messageId = z.string().describe('Discord message snowflake from the inbound tag.');
103
+ server.registerTool('discord-reply', {
104
+ description: 'Reply to a Discord message. Threads under the original.',
105
+ inputSchema: { channelId, messageId, text: z.string() },
106
+ }, async ({ channelId, messageId, text }) => {
107
+ await discordReady;
108
+ await discord.replyToMessage(channelId, messageId, text);
109
+ signalReplyComplete('discord', channelId);
110
+ // Clear the auto-acknowledgement emoji on the specific message we replied to.
111
+ await discord
112
+ .setReaction(channelId, messageId, '')
113
+ .catch(err => log.warn({ err: errMsg(err) }, 'discord clear-reaction failed'));
114
+ return ok('sent');
115
+ });
116
+ server.registerTool('discord-react', {
117
+ description: "Set or clear an emoji reaction on a Discord message. Pass '' to clear.",
118
+ inputSchema: { channelId, messageId, emoji: z.string() },
119
+ }, async ({ channelId, messageId, emoji }) => {
120
+ await discordReady;
121
+ await discord.setReaction(channelId, messageId, emoji);
122
+ return ok(emoji ? 'reacted' : 'cleared');
123
+ });
124
+ server.registerTool('discord-edit-message', {
125
+ description: 'Edit a Discord message the bot previously sent.',
126
+ inputSchema: { channelId, messageId, text: z.string() },
127
+ }, async ({ channelId, messageId, text }) => {
128
+ await discordReady;
129
+ await discord.editMessage(channelId, messageId, text);
130
+ return ok('edited');
131
+ });
132
+ server.registerTool('discord-download-attachment', {
133
+ description: 'Download image attachments as image content blocks. Non-images are skipped.',
134
+ inputSchema: { channelId, messageId },
135
+ }, async ({ channelId, messageId }) => {
136
+ await discordReady;
137
+ const atts = await discord.fetchAttachments(channelId, messageId);
138
+ if (atts.length === 0)
139
+ return ok('no image attachments on this message');
140
+ return { content: atts.map(a => ({ type: 'image', data: a.data, mimeType: a.mime })) };
141
+ });
142
+ server.registerTool('discord-fetch-messages', {
143
+ description: 'Fetch recent messages for context — Discord has no search API for bots.',
144
+ inputSchema: {
145
+ channelId,
146
+ limit: z.number().int().min(1).max(100).optional().describe('1–100, default 10.'),
147
+ },
148
+ }, async ({ channelId, limit }) => {
149
+ await discordReady;
150
+ const msgs = await discord.fetchRecentMessages(channelId, limit ?? 10);
151
+ const text = msgs
152
+ .map(m => `[message_id=${m.message_id} ${m.timestamp}] ${m.author}: ${m.text}`)
153
+ .join('\n');
154
+ return ok(text || '(channel is empty)');
155
+ });
156
+ }
157
+ await server.server.connect(new StdioServerTransport());
158
+ process.stdin.on('end', () => process.exit(0)).on('close', () => process.exit(0));
package/dist/tail.js ADDED
@@ -0,0 +1,131 @@
1
+ // Standalone inbound stream. Polls Telegram + connects to Discord, prints
2
+ // one JSON line per inbound message on stdout. Designed to be launched by
3
+ // an agent and observed via Bash+Monitor (Claude Code) or unified_exec
4
+ // polling (Codex).
5
+ //
6
+ // On every inbound: fires a 👀 reaction and starts a typing indicator that
7
+ // refreshes until the agent replies (signaled by server.ts touching
8
+ // .typing-stop/<key>) or the 60s safety cap is hit.
9
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import * as discord from './channels/discord.js';
12
+ import * as telegram from './channels/telegram.js';
13
+ import { tg } from './channels/telegram.js';
14
+ import { configuredPlatforms, loadMetroEnv, STATE_DIR, requireConfiguredPlatform } from './config.js';
15
+ import { errMsg, log } from './log.js';
16
+ loadMetroEnv();
17
+ const platforms = configuredPlatforms();
18
+ requireConfiguredPlatform(platforms);
19
+ // Telegram allows only one getUpdates poller per bot token. If another
20
+ // tail.ts is already running, exit cleanly instead of fighting (409 spam).
21
+ // Stale lockfiles (PID dead) are reclaimed.
22
+ const LOCK_FILE = join(STATE_DIR, '.tail-lock');
23
+ function processIsAlive(pid) {
24
+ try {
25
+ process.kill(pid, 0);
26
+ return true;
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ }
32
+ if (existsSync(LOCK_FILE)) {
33
+ const pid = Number(readFileSync(LOCK_FILE, 'utf8').trim());
34
+ if (Number.isInteger(pid) && pid > 0 && processIsAlive(pid)) {
35
+ log.info({ pid }, 'another tail.ts is already polling; exiting');
36
+ process.exit(0);
37
+ }
38
+ try {
39
+ unlinkSync(LOCK_FILE);
40
+ }
41
+ catch { }
42
+ }
43
+ writeFileSync(LOCK_FILE, String(process.pid));
44
+ function releaseLock() {
45
+ try {
46
+ if (existsSync(LOCK_FILE) && readFileSync(LOCK_FILE, 'utf8').trim() === String(process.pid)) {
47
+ unlinkSync(LOCK_FILE);
48
+ }
49
+ }
50
+ catch { }
51
+ }
52
+ process.on('exit', releaseLock);
53
+ const TYPING_DIR = join(STATE_DIR, '.typing-stop');
54
+ const TYPING_REFRESH_MS = 4_000;
55
+ const TYPING_MAX_MS = 60_000;
56
+ mkdirSync(TYPING_DIR, { recursive: true });
57
+ const emit = (line) => process.stdout.write(`${JSON.stringify(line)}\n`);
58
+ const typingActive = new Map();
59
+ const typingKey = (platform, chat) => `${platform}_${chat}`;
60
+ function fireTyping(platform, chat) {
61
+ if (platform === 'telegram') {
62
+ void tg('sendChatAction', { chat_id: chat, action: 'typing' }).catch(err => log.warn({ err: errMsg(err) }, 'telegram typing failed'));
63
+ }
64
+ else {
65
+ void discord.sendTyping(chat).catch(err => log.warn({ err: errMsg(err) }, 'discord typing failed'));
66
+ }
67
+ }
68
+ function startTyping(platform, chat) {
69
+ const k = typingKey(platform, chat);
70
+ typingActive.set(k, { platform, chat, started: Date.now() });
71
+ // Clear any stale stop signal so the new typing actually fires.
72
+ const stopFile = join(TYPING_DIR, k);
73
+ if (existsSync(stopFile)) {
74
+ try {
75
+ unlinkSync(stopFile);
76
+ }
77
+ catch { }
78
+ }
79
+ fireTyping(platform, chat);
80
+ }
81
+ setInterval(() => {
82
+ const now = Date.now();
83
+ for (const [k, e] of typingActive) {
84
+ const stopFile = join(TYPING_DIR, k);
85
+ if (existsSync(stopFile)) {
86
+ try {
87
+ unlinkSync(stopFile);
88
+ }
89
+ catch { }
90
+ typingActive.delete(k);
91
+ continue;
92
+ }
93
+ if (now - e.started > TYPING_MAX_MS) {
94
+ typingActive.delete(k);
95
+ continue;
96
+ }
97
+ fireTyping(e.platform, e.chat);
98
+ }
99
+ }, TYPING_REFRESH_MS);
100
+ if (platforms.telegram) {
101
+ const me = await telegram.getMe();
102
+ log.info({ bot: `@${me.username}` }, 'telegram ready');
103
+ telegram.onInbound(m => {
104
+ void tg('setMessageReaction', {
105
+ chat_id: m.chat_id,
106
+ message_id: m.message_id,
107
+ reaction: [{ type: 'emoji', emoji: '👀' }],
108
+ }).catch(err => log.warn({ err: errMsg(err) }, 'telegram auto-react failed'));
109
+ startTyping('telegram', String(m.chat_id));
110
+ emit({ platform: 'telegram', chat_id: String(m.chat_id), message_id: m.message_id, text: m.text });
111
+ });
112
+ void telegram.startPolling();
113
+ }
114
+ if (platforms.discord) {
115
+ await discord.startGateway();
116
+ const me = await discord.getMe();
117
+ log.info({ bot: me.username }, 'discord ready');
118
+ discord.onInbound(m => {
119
+ void discord
120
+ .setReaction(m.channel_id, m.message_id, '👀')
121
+ .catch(err => log.warn({ err: errMsg(err) }, 'discord auto-react failed'));
122
+ startTyping('discord', m.channel_id);
123
+ emit({ platform: 'discord', channel_id: m.channel_id, message_id: m.message_id, text: m.text });
124
+ });
125
+ }
126
+ // process.on('exit', releaseLock) above runs whenever process.exit is called.
127
+ const exit = () => process.exit(0);
128
+ process.stdin.on('end', exit);
129
+ process.stdin.on('close', exit);
130
+ process.on('SIGINT', exit);
131
+ process.on('SIGTERM', exit);
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@stage-labs/metro",
3
+ "version": "0.1.0-beta.0",
4
+ "description": "Chat with your Claude Code or Codex agent over Telegram and Discord. Ultra-lightweight: ~700 lines of TypeScript, one stdio MCP, no hosted infra.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/bonustrack/metro.git"
9
+ },
10
+ "homepage": "https://github.com/bonustrack/metro#readme",
11
+ "bugs": "https://github.com/bonustrack/metro/issues",
12
+ "type": "module",
13
+ "engines": {
14
+ "node": ">=22"
15
+ },
16
+ "bin": {
17
+ "metro": "./dist/cli.js"
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "scripts": {
28
+ "build": "tsc",
29
+ "prepublishOnly": "tsc",
30
+ "lint": "eslint src/",
31
+ "lint:fix": "eslint src/ --fix",
32
+ "typecheck": "tsc --noEmit"
33
+ },
34
+ "dependencies": {
35
+ "@modelcontextprotocol/sdk": "^1.29.0",
36
+ "discord.js": "^14.14.0",
37
+ "pino": "^9.5.0",
38
+ "zod": "^3.23.0"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^22.10.0",
42
+ "eslint": "^10.3.0",
43
+ "typescript": "^5",
44
+ "typescript-eslint": "^8.59.2"
45
+ },
46
+ "packageManager": "bun@1.3.9"
47
+ }