@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/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));
|