@stage-labs/metro 0.1.0-beta.21 → 0.1.0-beta.23

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.
Files changed (72) hide show
  1. package/dist/cli.js +18 -12
  2. package/dist/control.js +68 -12
  3. package/dist/prompt.js +51 -0
  4. package/package.json +1 -1
  5. package/runtime/node_modules/@metro-labs/{discord → discord-bot}/package.json +1 -1
  6. package/runtime/node_modules/@metro-labs/{discord → discord-bot}/src/accounts.ts +10 -10
  7. package/runtime/node_modules/@metro-labs/{discord → discord-bot}/src/actions.ts +1 -1
  8. package/runtime/node_modules/@metro-labs/{discord → discord-bot}/src/attachments.ts +1 -1
  9. package/runtime/node_modules/@metro-labs/{discord → discord-bot}/src/format.ts +7 -7
  10. package/runtime/node_modules/@metro-labs/{discord → discord-bot}/src/group-actions.ts +1 -1
  11. package/runtime/node_modules/@metro-labs/{discord → discord-bot}/src/index.ts +10 -10
  12. package/runtime/node_modules/@metro-labs/{discord → discord-bot}/src/station.ts +2 -2
  13. package/runtime/node_modules/@metro-labs/{discord → discord-bot}/src/voice-transcribe.ts +6 -6
  14. package/runtime/node_modules/@metro-labs/mcp/package.json +2 -2
  15. package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-api.ts +14 -8
  16. package/runtime/node_modules/@metro-labs/mcp/src/daemon/boot.ts +2 -4
  17. package/runtime/node_modules/@metro-labs/mcp/src/db/materialize.ts +49 -27
  18. package/runtime/node_modules/@metro-labs/mcp/src/db/schema.ts +2 -2
  19. package/runtime/node_modules/@metro-labs/mcp/src/mcp/tool-schemas.ts +6 -6
  20. package/runtime/node_modules/@metro-labs/mcp/src/stations/attach-interactive.ts +6 -6
  21. package/runtime/node_modules/@metro-labs/mcp/src/stations/attach.ts +6 -6
  22. package/runtime/node_modules/@metro-labs/mcp/src/stations/lines.ts +2 -2
  23. package/runtime/node_modules/@metro-labs/mcp/src/stations/registry.ts +4 -4
  24. package/runtime/node_modules/@metro-labs/telegram/package.json +5 -2
  25. package/runtime/node_modules/@metro-labs/telegram/src/accounts.ts +47 -86
  26. package/runtime/node_modules/@metro-labs/telegram/src/actions.ts +210 -251
  27. package/runtime/node_modules/@metro-labs/telegram/src/attachments.ts +66 -107
  28. package/runtime/node_modules/@metro-labs/{telegram-user → telegram}/src/client.ts +4 -4
  29. package/runtime/node_modules/@metro-labs/telegram/src/format.ts +110 -141
  30. package/runtime/node_modules/@metro-labs/{telegram-user → telegram}/src/group.ts +1 -1
  31. package/runtime/node_modules/@metro-labs/{telegram-user → telegram}/src/history.ts +1 -1
  32. package/runtime/node_modules/@metro-labs/{telegram-user → telegram}/src/inbound.ts +6 -6
  33. package/runtime/node_modules/@metro-labs/telegram/src/index.ts +35 -92
  34. package/runtime/node_modules/@metro-labs/{telegram-user → telegram}/src/login.ts +20 -20
  35. package/runtime/node_modules/@metro-labs/telegram/src/media-actions.ts +65 -151
  36. package/runtime/node_modules/@metro-labs/telegram/src/members.ts +60 -32
  37. package/runtime/node_modules/@metro-labs/{telegram-user → telegram}/src/reactions.ts +1 -1
  38. package/runtime/node_modules/@metro-labs/telegram/src/station.ts +8 -1
  39. package/runtime/node_modules/@metro-labs/telegram/src/types.ts +6 -47
  40. package/runtime/node_modules/@metro-labs/telegram/src/wire.ts +1 -3
  41. package/runtime/node_modules/@metro-labs/{telegram-user → telegram-bot}/package.json +3 -6
  42. package/runtime/node_modules/@metro-labs/telegram-bot/src/accounts.ts +120 -0
  43. package/runtime/node_modules/@metro-labs/telegram-bot/src/actions.ts +295 -0
  44. package/runtime/node_modules/@metro-labs/telegram-bot/src/attachments.ts +128 -0
  45. package/runtime/node_modules/@metro-labs/telegram-bot/src/format.ts +172 -0
  46. package/runtime/node_modules/@metro-labs/telegram-bot/src/index.ts +103 -0
  47. package/runtime/node_modules/@metro-labs/telegram-bot/src/media-actions.ts +166 -0
  48. package/runtime/node_modules/@metro-labs/telegram-bot/src/members.ts +47 -0
  49. package/runtime/node_modules/@metro-labs/telegram-bot/src/station.ts +17 -0
  50. package/runtime/node_modules/@metro-labs/telegram-bot/src/types.ts +48 -0
  51. package/runtime/node_modules/@metro-labs/telegram-bot/src/wire.ts +3 -0
  52. package/runtime/runtime.json +1 -1
  53. package/runtime/node_modules/@metro-labs/telegram-user/src/accounts.ts +0 -81
  54. package/runtime/node_modules/@metro-labs/telegram-user/src/actions.ts +0 -254
  55. package/runtime/node_modules/@metro-labs/telegram-user/src/attachments.ts +0 -87
  56. package/runtime/node_modules/@metro-labs/telegram-user/src/format.ts +0 -141
  57. package/runtime/node_modules/@metro-labs/telegram-user/src/index.ts +0 -46
  58. package/runtime/node_modules/@metro-labs/telegram-user/src/media-actions.ts +0 -80
  59. package/runtime/node_modules/@metro-labs/telegram-user/src/members.ts +0 -75
  60. package/runtime/node_modules/@metro-labs/telegram-user/src/station.ts +0 -24
  61. package/runtime/node_modules/@metro-labs/telegram-user/src/types.ts +0 -7
  62. package/runtime/node_modules/@metro-labs/telegram-user/src/wire.ts +0 -1
  63. /package/runtime/node_modules/@metro-labs/{discord → discord-bot}/src/api-base.ts +0 -0
  64. /package/runtime/node_modules/@metro-labs/{discord → discord-bot}/src/group.ts +0 -0
  65. /package/runtime/node_modules/@metro-labs/{discord → discord-bot}/src/members.ts +0 -0
  66. /package/runtime/node_modules/@metro-labs/{discord → discord-bot}/src/send-files.ts +0 -0
  67. /package/runtime/node_modules/@metro-labs/{discord → discord-bot}/src/verify.ts +0 -0
  68. /package/runtime/node_modules/@metro-labs/{discord → discord-bot}/src/voice-speak.ts +0 -0
  69. /package/runtime/node_modules/@metro-labs/{discord → discord-bot}/src/voice.ts +0 -0
  70. /package/runtime/node_modules/@metro-labs/{discord → discord-bot}/src/wire.ts +0 -0
  71. /package/runtime/node_modules/@metro-labs/{telegram-user → telegram}/src/normalize.ts +0 -0
  72. /package/runtime/node_modules/@metro-labs/{telegram → telegram-bot}/src/verify.ts +0 -0
@@ -1,48 +1,7 @@
1
- export interface TgMsg {
2
- message_id: number;
3
- date: number;
4
- chat: { id: number; type: string; title?: string; first_name?: string };
5
- from?: {
6
- id: number;
7
- username?: string;
8
- first_name?: string;
9
- is_bot?: boolean;
10
- };
11
- text?: string;
12
- caption?: string;
13
- message_thread_id?: number;
14
- is_topic_message?: boolean;
15
- photo?: { file_id: string; file_size?: number }[];
16
- document?: { file_name?: string; file_id?: string; mime_type?: string };
17
- voice?: { file_id?: string; duration?: number; mime_type?: string };
18
- audio?: { file_id?: string; file_name?: string; mime_type?: string };
19
- video?: { file_id?: string; file_name?: string; mime_type?: string };
20
- animation?: { file_id?: string; file_name?: string; mime_type?: string };
21
- sticker?: { file_id?: string; emoji?: string; set_name?: string };
22
- location?: { latitude: number; longitude: number };
23
- dice?: { emoji: string; value: number };
24
- }
25
-
26
- export interface TgReaction {
27
- chat: { id: number; type: string };
28
- message_id: number;
29
- user?: {
30
- id: number;
31
- username?: string;
32
- first_name?: string;
33
- is_bot?: boolean;
34
- };
35
- date: number;
36
- old_reaction: { type: string; emoji?: string }[];
37
- new_reaction: { type: string; emoji?: string }[];
38
- }
39
-
40
- export interface TgReactionCount {
41
- chat: { id: number; type: string };
42
- message_id: number;
43
- date: number;
44
- reactions: {
45
- type: { type: string; emoji?: string };
46
- total_count: number;
47
- }[];
1
+ export interface UserAccount {
2
+ id: string;
3
+ session: string;
4
+ apiId?: number;
5
+ apiHash?: string;
6
+ owner?: string;
48
7
  }
@@ -1,3 +1 @@
1
- export { emit, respond, mintId } from '@metro-labs/mcp/stations/station-runtime';
2
-
3
- export const SELF_URI = process.env.METRO_SELF_URI ?? '';
1
+ export { emit, mintId } from '@metro-labs/mcp/stations/station-runtime';
@@ -1,28 +1,25 @@
1
1
  {
2
- "name": "@metro-labs/telegram-user",
2
+ "name": "@metro-labs/telegram-bot",
3
3
  "version": "0.1.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": "./src/station.ts",
8
8
  "./train": "./src/index.ts",
9
- "./login": "./src/login.ts"
9
+ "./verify": "./src/verify.ts"
10
10
  },
11
11
  "scripts": {
12
12
  "build": "tsc",
13
13
  "test": "tsc --noEmit && bun test test/"
14
14
  },
15
15
  "dependencies": {
16
- "@metro-labs/mcp": "workspace:*",
17
- "@mtcute/bun": "0.30.1"
16
+ "@metro-labs/mcp": "workspace:*"
18
17
  },
19
18
  "devDependencies": {
20
19
  "@stage-labs/config": "0.1.0-beta.2",
21
- "@types/qrcode-terminal": "^0.12.2",
22
20
  "eslint": "^10.3.0",
23
21
  "knip": "^6.15.0",
24
22
  "madge": "^8.0.0",
25
- "qrcode-terminal": "^0.12.0",
26
23
  "typescript": "^5"
27
24
  }
28
25
  }
@@ -0,0 +1,120 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import {
4
+ makeAccountStore,
5
+ resolveAccountId,
6
+ } from '@metro-labs/mcp/stations/account-store';
7
+ import { Line } from '@metro-labs/mcp/lines';
8
+
9
+ const ACCOUNTS_FILE =
10
+ process.env.TELEGRAM_BOT_ACCOUNTS_FILE ??
11
+ join(homedir(), '.metro', 'telegram-bot-accounts.json');
12
+
13
+ export interface AccountConfig {
14
+ id: string;
15
+ token: string;
16
+ owner?: string;
17
+ }
18
+
19
+ export const { loadAccounts } = makeAccountStore<AccountConfig>({
20
+ prefix: 'telegram-bot',
21
+ file: ACCOUNTS_FILE,
22
+ allowlistEnv: ['TELEGRAM_BOT_ONLY_ACCOUNTS', 'TELEGRAM_BOT_ACCOUNTS'],
23
+ validate(raw, die) {
24
+ const seenId = new Set<string>();
25
+ const seenTok = new Set<string>();
26
+ for (const a of raw) {
27
+ if (!a.id) die('account missing id');
28
+ if (!a.token || typeof a.token !== 'string')
29
+ die(`account '${a.id}' missing token`);
30
+ if (seenId.has(a.id)) die(`duplicate account id '${a.id}'`);
31
+ if (seenTok.has(a.token))
32
+ die(
33
+ `account '${a.id}' reuses a token used by another account (409 on getUpdates)`,
34
+ );
35
+ seenId.add(a.id);
36
+ seenTok.add(a.token);
37
+ }
38
+ },
39
+ });
40
+
41
+ export interface Account {
42
+ cfg: AccountConfig;
43
+ api: string;
44
+ fileApi: string;
45
+ offset: number;
46
+ }
47
+ export const accounts = new Map<string, Account>();
48
+
49
+ export async function tg<T>(
50
+ accountId: string,
51
+ method: string,
52
+ body: unknown,
53
+ timeoutMs = 30_000,
54
+ ): Promise<T> {
55
+ const acct = accounts.get(accountId);
56
+ if (!acct) throw new Error(`unknown account '${accountId}'`);
57
+ const res = await fetch(`${acct.api}/${method}`, {
58
+ method: 'POST',
59
+ headers: { 'Content-Type': 'application/json' },
60
+ body: JSON.stringify(body),
61
+ signal: AbortSignal.timeout(timeoutMs),
62
+ });
63
+ const json = (await res.json()) as {
64
+ ok: boolean;
65
+ description?: string;
66
+ result?: T;
67
+ };
68
+ if (!json.ok)
69
+ throw new Error(`telegram-bot ${method}: ${json.description ?? 'unknown'}`);
70
+ return json.result as T;
71
+ }
72
+
73
+ export async function tgForm<T>(
74
+ accountId: string,
75
+ method: string,
76
+ form: FormData,
77
+ timeoutMs = 60_000,
78
+ ): Promise<T> {
79
+ const acct = accounts.get(accountId);
80
+ if (!acct) throw new Error(`unknown account '${accountId}'`);
81
+ const res = await fetch(`${acct.api}/${method}`, {
82
+ method: 'POST',
83
+ body: form,
84
+ signal: AbortSignal.timeout(timeoutMs),
85
+ });
86
+ const json = (await res.json()) as {
87
+ ok: boolean;
88
+ description?: string;
89
+ result?: T;
90
+ };
91
+ if (!json.ok)
92
+ throw new Error(`telegram-bot ${method}: ${json.description ?? 'unknown'}`);
93
+ return json.result as T;
94
+ }
95
+
96
+ export function accountFor(args: { account?: string; line?: string }): string {
97
+ return resolveAccountId(
98
+ accounts,
99
+ args,
100
+ (line) => Line.parseTelegram(line)?.accountId,
101
+ );
102
+ }
103
+
104
+ export function lineOf(
105
+ accountId: string,
106
+ chatId: number | string,
107
+ topicId?: number,
108
+ ): string {
109
+ const tail = topicId !== undefined ? `${chatId}/${topicId}` : `${chatId}`;
110
+ return `metro://telegram-bot/${accountId}/${tail}`;
111
+ }
112
+
113
+ export function targetOf(
114
+ line: string,
115
+ accountOverride?: string,
116
+ ): { accountId: string; chatId: number; topicId?: number } {
117
+ const parsed = Line.parseTelegram(line);
118
+ if (!parsed) throw new Error(`bad telegram-bot line: ${line}`);
119
+ return { ...parsed, accountId: accountOverride ?? parsed.accountId };
120
+ }
@@ -0,0 +1,295 @@
1
+ import { accountFor, accounts, tg, targetOf } from './accounts.js';
2
+ import { respond } from './wire.js';
3
+ import { errMsg } from '@metro-labs/mcp/log';
4
+ import { normalizeTelegram } from '@metro-labs/mcp/stations/messaging-normalize';
5
+ import {
6
+ adminMemberList,
7
+ inaccessibleMemberList,
8
+ type TgChatMember,
9
+ } from './members.js';
10
+ import {
11
+ makeStation,
12
+ type CallMsg,
13
+ type StationHandler,
14
+ } from '@metro-labs/mcp/stations/station-runtime';
15
+ import { mediaKindOf } from './attachments.js';
16
+ import { assertContentLength } from '@metro-labs/mcp/stations/attachments';
17
+ import {
18
+ emitOutbound,
19
+ finishSend,
20
+ media,
21
+ MEDIA_METHOD_FIELD,
22
+ sendDice,
23
+ sendLocation,
24
+ sendMedia,
25
+ } from './media-actions.js';
26
+
27
+
28
+
29
+
30
+ export type { CallMsg };
31
+
32
+ const meCache = new Map<string, { id: number; username: string | null }>();
33
+ async function getMe(
34
+ accountId: string,
35
+ ): Promise<{ id: number; username: string | null } | null> {
36
+ const cached = meCache.get(accountId);
37
+ if (cached) return cached;
38
+ try {
39
+ const me = await tg<{ id: number; username?: string }>(
40
+ accountId,
41
+ 'getMe',
42
+ {},
43
+ 10_000,
44
+ );
45
+ const v = { id: me.id, username: me.username ?? null };
46
+ meCache.set(accountId, v);
47
+ return v;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ async function listAccounts(id: string): Promise<void> {
54
+ const list = await Promise.all(
55
+ [...accounts.values()].map(async (a) => {
56
+ const me = await getMe(a.cfg.id);
57
+ const username = me?.username ?? null;
58
+ return {
59
+ id: a.cfg.id,
60
+ handle: username === null ? null : `@${username}`,
61
+ url: username === null ? null : `https://t.me/${username}`,
62
+ owner: a.cfg.owner ?? null,
63
+ botId: me?.id ?? null,
64
+ username,
65
+ };
66
+ }),
67
+ );
68
+ respond(id, { result: { accounts: list } });
69
+ }
70
+
71
+ interface SendArgs {
72
+ line: string;
73
+ text: string;
74
+ replyTo?: string;
75
+ parseMode?: string;
76
+ buttons?: { text: string; url: string }[][];
77
+ account?: string;
78
+ attachments?: {
79
+ kind?: string;
80
+ path?: string;
81
+ url?: string;
82
+ mime?: string;
83
+ name?: string;
84
+ }[];
85
+ }
86
+
87
+
88
+ type WireAttachment = NonNullable<SendArgs['attachments']>[number];
89
+
90
+ const attachmentSrc = (att: WireAttachment): string | undefined =>
91
+ att.path ?? att.url;
92
+
93
+ const attachmentKind = (att: WireAttachment): string =>
94
+ mediaKindOf(att.kind, att.mime, attachmentSrc(att) ?? att.name);
95
+
96
+ async function sendOneAttachment(
97
+ a: SendArgs,
98
+ att: WireAttachment,
99
+ caption: string | undefined,
100
+ ): Promise<{ accountId: string; message_id: number; kind: string }> {
101
+ const kind = attachmentKind(att);
102
+ const mf = MEDIA_METHOD_FIELD[kind];
103
+ if (!mf) throw new Error(`telegram-bot cannot send a '${kind}' attachment`);
104
+ const sent = await sendMedia(mf.method, mf.field, {
105
+ line: a.line,
106
+ path: attachmentSrc(att),
107
+ caption,
108
+ replyTo: a.replyTo,
109
+ parseMode: a.parseMode,
110
+ account: a.account,
111
+ name: att.name,
112
+ });
113
+ return { ...sent, kind };
114
+ }
115
+
116
+ const captionLabel = (caption: string | undefined, kind: string): string =>
117
+ caption !== undefined && caption !== '' ? caption : `[${kind}]`;
118
+
119
+ async function sendAttachments(id: string, a: SendArgs): Promise<void> {
120
+ const { line, text, replyTo, attachments = [] } = a;
121
+ let last: { accountId: string; message_id: number } | undefined;
122
+ const delivered: string[] = [];
123
+ for (let i = 0; i < attachments.length; i++) {
124
+ const att = attachments[i];
125
+ if (!att) continue;
126
+ const caption = i === 0 ? text : undefined;
127
+ const sent = await sendOneAttachment(a, att, caption);
128
+ last = sent;
129
+ delivered.push(sent.kind);
130
+ emitOutbound(
131
+ sent.accountId,
132
+ line,
133
+ String(sent.message_id),
134
+ captionLabel(caption, sent.kind),
135
+ replyTo,
136
+ );
137
+ }
138
+ if (!last) throw new Error('no attachments were sent');
139
+ respond(id, {
140
+ result: {
141
+ messageId: String(last.message_id),
142
+ account: last.accountId,
143
+ attachments: delivered,
144
+ },
145
+ });
146
+ }
147
+
148
+ async function send(id: string, args: Record<string, unknown>): Promise<void> {
149
+ const a = args as unknown as SendArgs;
150
+ if (a.attachments?.length) {
151
+ await sendAttachments(id, a);
152
+ return;
153
+ }
154
+ const { line, text, replyTo, parseMode, buttons, account } = a;
155
+ const { accountId, chatId, topicId } = targetOf(line, account);
156
+ const body: Record<string, unknown> = { chat_id: chatId, text };
157
+ if (topicId !== undefined) body.message_thread_id = topicId;
158
+ if (replyTo) body.reply_parameters = { message_id: Number(replyTo) };
159
+ if (parseMode) body.parse_mode = parseMode;
160
+ if (buttons) body.reply_markup = { inline_keyboard: buttons };
161
+ const sent = await tg<{ message_id: number }>(accountId, 'sendMessage', body);
162
+ finishSend(id, accountId, line, String(sent.message_id), text, replyTo);
163
+ }
164
+
165
+ async function react(id: string, args: Record<string, unknown>): Promise<void> {
166
+ const { line, messageId, emoji, account } = args as {
167
+ line: string;
168
+ messageId: string;
169
+ emoji: string;
170
+ account?: string;
171
+ };
172
+ const { accountId, chatId } = targetOf(line, account);
173
+ await tg(accountId, 'setMessageReaction', {
174
+ chat_id: chatId,
175
+ message_id: Number(messageId),
176
+ reaction: emoji ? [{ type: 'emoji', emoji }] : [],
177
+ });
178
+ respond(id, { result: { ok: true, account: accountId } });
179
+ }
180
+
181
+ async function edit(id: string, args: Record<string, unknown>): Promise<void> {
182
+ const { line, messageId, text, parseMode, account } = args as {
183
+ line: string;
184
+ messageId: string;
185
+ text: string;
186
+ parseMode?: string;
187
+ account?: string;
188
+ };
189
+ const { accountId, chatId } = targetOf(line, account);
190
+ await tg(accountId, 'editMessageText', {
191
+ chat_id: chatId,
192
+ message_id: Number(messageId),
193
+ text,
194
+ parse_mode: parseMode,
195
+ });
196
+ respond(id, { result: { ok: true, account: accountId } });
197
+ }
198
+
199
+ async function remove(id: string, args: Record<string, unknown>): Promise<void> {
200
+ const { line, messageId, account } = args as {
201
+ line: string;
202
+ messageId: string;
203
+ account?: string;
204
+ };
205
+ const { accountId, chatId } = targetOf(line, account);
206
+ await tg(accountId, 'deleteMessage', {
207
+ chat_id: chatId,
208
+ message_id: Number(messageId),
209
+ });
210
+ respond(id, { result: { ok: true, account: accountId } });
211
+ }
212
+
213
+
214
+
215
+ async function download(id: string, args: Record<string, unknown>): Promise<void> {
216
+ const {
217
+ fileId,
218
+ outDir = '/tmp',
219
+ account,
220
+ } = args as { fileId: string; outDir?: string; account?: string };
221
+ const accountId = accountFor({ account });
222
+ const acct = accounts.get(accountId);
223
+ if (!acct) throw new Error(`unknown account '${accountId}'`);
224
+ const meta = await tg<{ file_path: string }>(accountId, 'getFile', {
225
+ file_id: fileId,
226
+ });
227
+ const res = await fetch(`${acct.fileApi}/${meta.file_path}`, {
228
+ signal: AbortSignal.timeout(60_000),
229
+ });
230
+ if (!res.ok) throw new Error(`telegram-bot download ${res.status}`);
231
+ assertContentLength(res.headers.get('content-length'));
232
+ const data = await res.arrayBuffer();
233
+ const filename = meta.file_path.split('/').pop() ?? `${fileId}.bin`;
234
+ const path = `${outDir}/${Date.now()}-${filename}`;
235
+ await Bun.write(path, data);
236
+ respond(id, {
237
+ result: { path, fileSize: data.byteLength, account: accountId },
238
+ });
239
+ }
240
+
241
+ async function listMembers(
242
+ id: string,
243
+ args: Record<string, unknown>,
244
+ ): Promise<void> {
245
+ const { line, account } = args as { line: string; account?: string };
246
+ const { accountId, chatId } = targetOf(line, account);
247
+ let admins: TgChatMember[];
248
+ try {
249
+ admins = await tg<TgChatMember[]>(accountId, 'getChatAdministrators', {
250
+ chat_id: chatId,
251
+ });
252
+ } catch (e) {
253
+ respond(id, { result: inaccessibleMemberList(errMsg(e)) });
254
+ return;
255
+ }
256
+ let total: number | null = null;
257
+ try {
258
+ total = await tg<number>(accountId, 'getChatMemberCount', {
259
+ chat_id: chatId,
260
+ });
261
+ } catch {
262
+ total = null;
263
+ }
264
+ respond(id, { result: adminMemberList(admins, total) });
265
+ }
266
+
267
+ const HANDLERS: Record<string, StationHandler> = {
268
+ accounts: (id) => listAccounts(id),
269
+ send,
270
+ react,
271
+ edit,
272
+ delete: remove,
273
+ listMembers,
274
+ send_photo: (id, args) =>
275
+ media(id, 'sendPhoto', 'photo', ((args.caption as string) ?? '') + ' [image]', args),
276
+ send_document: (id, args) =>
277
+ media(id, 'sendDocument', 'document', ((args.caption as string) ?? '') + ' [file]', args),
278
+ send_voice: (id, args) => media(id, 'sendVoice', 'voice', '[voice]', args),
279
+ send_sticker: (id, args) => media(id, 'sendSticker', 'sticker', '[sticker]', args),
280
+ send_dice: sendDice,
281
+ send_location: sendLocation,
282
+ download,
283
+ };
284
+
285
+ export const handleCall = makeStation({
286
+ handlers: HANDLERS,
287
+ normalize: normalizeTelegram,
288
+ preDispatch: (id, action) => {
289
+ if (action === 'read') {
290
+ respond(id, { error: "unsupported verb 'read' on telegram-bot" });
291
+ return true;
292
+ }
293
+ return false;
294
+ },
295
+ });
@@ -0,0 +1,128 @@
1
+ import {
2
+ saveBufferToCache,
3
+ assertContentLength,
4
+ } from '@metro-labs/mcp/stations/attachments';
5
+ import type { SavedAttachment } from '@metro-labs/mcp/stations/attachments';
6
+ import { tg, accounts } from './accounts.js';
7
+ import type { TgMsg } from './types.js';
8
+
9
+ export type { SavedAttachment };
10
+
11
+ function isOggRef(mime: string | undefined, ref: string | undefined): boolean {
12
+ const ext = ref?.split('?')[0]?.split('.').pop()?.toLowerCase();
13
+ return mime === 'audio/ogg' || ext === 'ogg' || ext === 'oga';
14
+ }
15
+
16
+ function isVoiceRef(
17
+ kind: string | undefined,
18
+ mime: string | undefined,
19
+ ref: string | undefined,
20
+ ): boolean {
21
+ const isOgg = isOggRef(mime, ref);
22
+ if (kind === 'voice') return true;
23
+ if (kind === 'audio') return isOgg;
24
+ return !kind && isOgg;
25
+ }
26
+
27
+ export function mediaKindOf(
28
+ kind: string | undefined,
29
+ mime: string | undefined,
30
+ ref: string | undefined,
31
+ ): 'image' | 'voice' | 'audio' | 'video' | 'document' {
32
+ const m = mime ?? '';
33
+ if (kind === 'image' || m.startsWith('image/')) return 'image';
34
+ if (isVoiceRef(kind, mime, ref)) return 'voice';
35
+ if (kind === 'video' || m.startsWith('video/')) return 'video';
36
+ if (kind === 'audio' || m.startsWith('audio/')) return 'audio';
37
+ return 'document';
38
+ }
39
+
40
+ export interface TgMediaRef {
41
+ fileId: string;
42
+ name?: string;
43
+ mime?: string;
44
+ }
45
+
46
+ const MEDIA_EXTRACTORS: ((m: TgMsg) => TgMediaRef | null)[] = [
47
+ (m) => {
48
+ const largest = m.photo?.[m.photo.length - 1];
49
+ return largest ? { fileId: largest.file_id, mime: 'image/jpeg' } : null;
50
+ },
51
+ (m) =>
52
+ m.document?.file_id
53
+ ? {
54
+ fileId: m.document.file_id,
55
+ name: m.document.file_name,
56
+ mime: m.document.mime_type,
57
+ }
58
+ : null,
59
+ (m) =>
60
+ m.video?.file_id
61
+ ? {
62
+ fileId: m.video.file_id,
63
+ name: m.video.file_name,
64
+ mime: m.video.mime_type ?? 'video/mp4',
65
+ }
66
+ : null,
67
+ (m) =>
68
+ m.animation?.file_id
69
+ ? {
70
+ fileId: m.animation.file_id,
71
+ name: m.animation.file_name,
72
+ mime: m.animation.mime_type,
73
+ }
74
+ : null,
75
+ (m) =>
76
+ m.audio?.file_id
77
+ ? {
78
+ fileId: m.audio.file_id,
79
+ name: m.audio.file_name,
80
+ mime: m.audio.mime_type,
81
+ }
82
+ : null,
83
+ (m) =>
84
+ m.voice?.file_id
85
+ ? { fileId: m.voice.file_id, mime: m.voice.mime_type ?? 'audio/ogg' }
86
+ : null,
87
+ (m) =>
88
+ m.sticker?.file_id
89
+ ? { fileId: m.sticker.file_id, mime: 'image/webp' }
90
+ : null,
91
+ ];
92
+
93
+ export function mediaRefOf(m: TgMsg): TgMediaRef | null {
94
+ for (const extract of MEDIA_EXTRACTORS) {
95
+ const ref = extract(m);
96
+ if (ref) return ref;
97
+ }
98
+ return null;
99
+ }
100
+
101
+ export async function saveTelegramMedia(
102
+ accountId: string,
103
+ ref: TgMediaRef,
104
+ messageId: string,
105
+ index = 0,
106
+ ): Promise<SavedAttachment> {
107
+ const file = await tg<{ file_path?: string }>(accountId, 'getFile', {
108
+ file_id: ref.fileId,
109
+ });
110
+ if (!file.file_path)
111
+ throw new Error(`telegram-bot getFile returned no file_path for ${ref.fileId}`);
112
+ const acct = accounts.get(accountId);
113
+ if (!acct) throw new Error(`unknown account '${accountId}'`);
114
+ const res = await fetch(`${acct.fileApi}/${file.file_path}`, {
115
+ signal: AbortSignal.timeout(60_000),
116
+ });
117
+ if (!res.ok)
118
+ throw new Error(
119
+ `telegram-bot file download ${res.status} for ${file.file_path}`,
120
+ );
121
+ assertContentLength(res.headers.get('content-length'));
122
+ const data = new Uint8Array(await res.arrayBuffer());
123
+ const saved = await saveBufferToCache(data, messageId, index, {
124
+ mime: ref.mime,
125
+ name: ref.name ?? file.file_path,
126
+ });
127
+ return { ...saved, name: ref.name };
128
+ }