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

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.
@@ -1,133 +0,0 @@
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 DELETED
@@ -1,13 +0,0 @@
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 DELETED
@@ -1,33 +0,0 @@
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/server.js DELETED
@@ -1,158 +0,0 @@
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 DELETED
@@ -1,131 +0,0 @@
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);