@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
@@ -52,20 +52,20 @@ const STATION_TARGETS: Record<StationName, StationTarget> = {
52
52
  fileEnv: 'XMTP_ACCOUNTS_FILE',
53
53
  trainImport: '@metro-labs/xmtp/train',
54
54
  },
55
- telegram: {
55
+ 'telegram-bot': {
56
+ file: 'telegram-bot-accounts.json',
57
+ fileEnv: 'TELEGRAM_BOT_ACCOUNTS_FILE',
58
+ trainImport: '@metro-labs/telegram-bot/train',
59
+ },
60
+ 'telegram': {
56
61
  file: 'telegram-accounts.json',
57
62
  fileEnv: 'TELEGRAM_ACCOUNTS_FILE',
58
63
  trainImport: '@metro-labs/telegram/train',
59
64
  },
60
- 'telegram-user': {
61
- file: 'telegram-user-accounts.json',
62
- fileEnv: 'TELEGRAM_USER_ACCOUNTS_FILE',
63
- trainImport: '@metro-labs/telegram-user/train',
64
- },
65
- discord: {
66
- file: 'discord-accounts.json',
67
- fileEnv: 'DISCORD_ACCOUNTS_FILE',
68
- trainImport: '@metro-labs/discord/train',
65
+ 'discord-bot': {
66
+ file: 'discord-bot-accounts.json',
67
+ fileEnv: 'DISCORD_BOT_ACCOUNTS_FILE',
68
+ trainImport: '@metro-labs/discord-bot/train',
69
69
  },
70
70
  whatsapp: {
71
71
  file: 'whatsapp-accounts.json',
@@ -236,7 +236,12 @@ export const isLocalRuntime = (): boolean =>
236
236
  export const stationRunsHere = (station: StationName): boolean =>
237
237
  isLocalRuntime() || !MOVABLE_STATIONS.has(station);
238
238
 
239
- function writeStations(list: LoadedAgent[]): Map<StationName, number> {
239
+ interface WrittenStations {
240
+ active: Map<StationName, number>;
241
+ changed: StationName[];
242
+ }
243
+
244
+ function writeStations(list: LoadedAgent[]): WrittenStations {
240
245
  mkdirSync(METRO_DIR, { recursive: true });
241
246
  mkdirSync(TRAINS_DIR, { recursive: true });
242
247
 
@@ -247,6 +252,13 @@ function writeStations(list: LoadedAgent[]): Map<StationName, number> {
247
252
  for (const agent of list) {
248
253
  names[agent.id] = agent.name;
249
254
  for (const a of agent.accounts) {
255
+ if (!(a.station in STATION_TARGETS)) {
256
+ log.error(
257
+ { station: a.station, id: a.id },
258
+ 'unknown station — this metro is older or newer than the row; skipping it',
259
+ );
260
+ continue;
261
+ }
250
262
  map[`${a.station}/${a.id}`] = agent.id;
251
263
  if (a.allowlist) allow[`${a.station}/${a.id}`] = a.allowlist;
252
264
  if (stationRunsHere(a.station)) {
@@ -261,29 +273,36 @@ function writeStations(list: LoadedAgent[]): Map<StationName, number> {
261
273
  carryTokenStores(list.flatMap((agent) => agent.accounts));
262
274
 
263
275
  const active = new Map<StationName, number>();
276
+ const changed: StationName[] = [];
264
277
  for (const [station, accts] of byStation) {
265
278
  const records = accts.map((a) => ({ id: a.id, ...trainConfig(a.config) }));
266
- writeSecure(accountFilePath(station), JSON.stringify(records, null, 2));
279
+ const path = accountFilePath(station);
280
+ const content = JSON.stringify(records, null, 2);
281
+ if (currentText(path) !== content) {
282
+ writeSecure(path, content);
283
+ changed.push(station);
284
+ }
267
285
  const { trainImport } = STATION_TARGETS[station];
268
286
  if (trainImport !== null)
269
287
  writeIfChanged(trainStubPath(station), `import '${trainImport}';\n`);
270
288
  active.set(station, accts.length);
271
289
  }
272
- return active;
290
+ return { active, changed };
273
291
  }
274
292
 
293
+ const BLANKED = '[]\n';
294
+
275
295
  function pruneStations(present: Map<StationName, number>): StationName[] {
276
296
  const removed: StationName[] = [];
277
297
  for (const station of Object.keys(STATION_TARGETS) as StationName[]) {
278
298
  if (present.has(station)) continue;
279
299
  const hasTrain = STATION_TARGETS[station].trainImport !== null;
280
300
  const stub = trainStubPath(station);
281
- if (
282
- (!hasTrain || !existsSync(stub)) &&
283
- !existsSync(accountFilePath(station))
284
- )
285
- continue;
286
- writeSecure(accountFilePath(station), '[]\n');
301
+ const stubGone = !hasTrain || !existsSync(stub);
302
+ const file = currentText(accountFilePath(station));
303
+ const fileInert = file === null || file === BLANKED;
304
+ if (stubGone && fileInert) continue;
305
+ if (!fileInert) writeSecure(accountFilePath(station), BLANKED);
287
306
  if (hasTrain) rmSync(stub, { force: true });
288
307
  removed.push(station);
289
308
  }
@@ -310,14 +329,15 @@ export function localAgentKey(): string | null {
310
329
 
311
330
  async function loadAndWrite(
312
331
  source: StationSource,
313
- ): Promise<{ active: Map<StationName, number>; agents: number }> {
332
+ ): Promise<WrittenStations & { agents: number }> {
314
333
  const list = await source();
315
334
  applyKeyMap(list);
316
- return { active: writeStations(list), agents: list.length };
335
+ return { ...writeStations(list), agents: list.length };
317
336
  }
318
337
 
319
338
  export async function materializeFrom(source: StationSource): Promise<void> {
320
339
  const { active, agents: found } = await loadAndWrite(source);
340
+
321
341
  if (found === 0) throw new Error('no agents found — nothing to materialize');
322
342
  log.info(
323
343
  { stations: stationLabels(active), agents: found },
@@ -338,18 +358,20 @@ export async function materializeFromDb(): Promise<void> {
338
358
  export interface ReloadedStations {
339
359
  active: StationName[];
340
360
  removed: StationName[];
361
+ changed: StationName[];
341
362
  }
342
363
 
343
364
  export async function reloadFrom(
344
365
  source: StationSource,
345
366
  ): Promise<ReloadedStations> {
346
- const { active } = await loadAndWrite(source);
367
+ const { active, changed } = await loadAndWrite(source);
347
368
  const removed = pruneStations(active);
348
- log.info(
349
- { stations: stationLabels(active), removed },
350
- 'reloaded station accounts',
351
- );
352
- return { active: [...active.keys()], removed };
369
+ if (removed.length > 0 || changed.length > 0)
370
+ log.info(
371
+ { stations: stationLabels(active), removed, changed },
372
+ 'reloaded station accounts',
373
+ );
374
+ return { active: [...active.keys()], removed, changed };
353
375
  }
354
376
 
355
377
  export async function reloadAccountsFromDb(): Promise<ReloadedStations> {
@@ -8,9 +8,9 @@ import {
8
8
 
9
9
  export const STATIONS = [
10
10
  'xmtp',
11
+ 'telegram-bot',
11
12
  'telegram',
12
- 'telegram-user',
13
- 'discord',
13
+ 'discord-bot',
14
14
  'whatsapp',
15
15
  'webhook',
16
16
  ] as const;
@@ -235,7 +235,7 @@ export const COMMON_TOOLS = [
235
235
  'limit?. The station is derived from the line. Returns {line, station, memberCount, ' +
236
236
  'members:[{id, name?, display_name?, address?, roles?, is_admin?, is_bot?}], capability:' +
237
237
  '{supported, complete, reason?, total?}}. Each station fills the fields it has (xmtp: id=' +
238
- 'inboxId + address; discord: id + username/nick + roles + is_bot; telegram/telegram-user: ' +
238
+ 'inboxId + address; discord-bot: id + username/nick + roles + is_bot; telegram-bot/telegram: ' +
239
239
  'id + username/first_name). Never throws on "not supported": stations that cannot ' +
240
240
  'enumerate (e.g. the Telegram Bot API) return an empty or partial list with a reason in ' +
241
241
  '`capability`. Check `capability.complete` before assuming the roster is exhaustive.',
@@ -255,9 +255,9 @@ export const COMMON_TOOLS = [
255
255
  name: 'create_group',
256
256
  description:
257
257
  'Create a new group/channel on a station and add members, in a station-agnostic shape. ' +
258
- 'Args: station (required, xmtp|telegram-user|discord|telegram), name (required), members? ' +
259
- '(string[]; xmtp: 0x address or inboxId, telegram-user: @username or user id, discord: ' +
260
- 'user id), account? (which station account), parent? (discord only: the metro:// line of ' +
258
+ 'Args: station (required, xmtp|telegram|discord-bot|telegram-bot), name (required), members? ' +
259
+ '(string[]; xmtp: 0x address or inboxId, telegram: @username or user id, discord-bot: ' +
260
+ 'user id), account? (which station account), parent? (discord-bot only: the metro:// line of ' +
261
261
  'the channel to open the thread under). Returns {op, line (the NEW group line), station, ' +
262
262
  'supported, reason?, id?, name?, members:[{id, status: added|invited|removed|failed, ' +
263
263
  'reason?}], inviteLink?}. Never throws on "not supported": stations that lack the op ' +
@@ -269,7 +269,7 @@ export const COMMON_TOOLS = [
269
269
  properties: {
270
270
  station: {
271
271
  type: 'string',
272
- description: 'The station to create the group on (xmtp|telegram-user|discord).',
272
+ description: 'The station to create the group on (xmtp|telegram|discord-bot).',
273
273
  },
274
274
  name: { type: 'string', description: 'The group/channel name.' },
275
275
  members: {
@@ -357,7 +357,7 @@ export const COMMON_TOOLS = [
357
357
  description:
358
358
  'Get a join/invite link for a Metro group, for platforms where direct-add is not always ' +
359
359
  'permitted. Args: line (required). The station is derived from the line. Returns {op, ' +
360
- 'line, station, supported, reason?, inviteLink?}. Only telegram-user supports this today; ' +
360
+ 'line, station, supported, reason?, inviteLink?}. Only telegram supports this today; ' +
361
361
  'other stations return {supported:false, reason}. Never throws on "not supported".',
362
362
  inputSchema: {
363
363
  type: 'object',
@@ -1,6 +1,6 @@
1
1
  import { StationAttachError } from './attach.js';
2
2
 
3
- export const INTERACTIVE_STATIONS = ['telegram-user', 'whatsapp'] as const;
3
+ export const INTERACTIVE_STATIONS = ['telegram', 'whatsapp'] as const;
4
4
 
5
5
  export type InteractiveStation = (typeof INTERACTIVE_STATIONS)[number];
6
6
 
@@ -61,12 +61,12 @@ async function startTelegramUser(
61
61
  input: Record<string, unknown>,
62
62
  hooks: DriverHooks,
63
63
  ): Promise<StartedAttach> {
64
- const { TelegramUserLogin, TelegramUserLoginError, validateCredentials } =
65
- await import('@metro-labs/telegram-user/login');
66
- const isOwn = (e: unknown): boolean => e instanceof TelegramUserLoginError;
64
+ const { TelegramLogin, TelegramLoginError, validateCredentials } =
65
+ await import('@metro-labs/telegram/login');
66
+ const isOwn = (e: unknown): boolean => e instanceof TelegramLoginError;
67
67
  let login;
68
68
  try {
69
- login = new TelegramUserLogin(
69
+ login = new TelegramLogin(
70
70
  validateCredentials({
71
71
  apiId: input.apiId,
72
72
  apiHash: input.apiHash,
@@ -155,7 +155,7 @@ export async function startInteractiveAttach(
155
155
  input: Record<string, unknown>,
156
156
  hooks: DriverHooks,
157
157
  ): Promise<StartedAttach> {
158
- return station === 'telegram-user'
158
+ return station === 'telegram'
159
159
  ? startTelegramUser(input, hooks)
160
160
  : startWhatsapp(input, hooks);
161
161
  }
@@ -2,11 +2,11 @@ import { randomBytes } from 'node:crypto';
2
2
  import {
3
3
  DiscordTokenError,
4
4
  verifyDiscordBotToken,
5
- } from '@metro-labs/discord/verify';
5
+ } from '@metro-labs/discord-bot/verify';
6
6
  import {
7
7
  TelegramTokenError,
8
8
  verifyTelegramBotToken,
9
- } from '@metro-labs/telegram/verify';
9
+ } from '@metro-labs/telegram-bot/verify';
10
10
  import { ApiError } from '../daemon/api-error.js';
11
11
  import { publicBaseOrDefault } from '../daemon/attach-serve.js';
12
12
  import {
@@ -18,8 +18,8 @@ import {
18
18
  } from './attach-xmtp.js';
19
19
 
20
20
  export const ATTACHABLE_STATIONS = [
21
- 'discord',
22
- 'telegram',
21
+ 'discord-bot',
22
+ 'telegram-bot',
23
23
  'xmtp',
24
24
  'webhook',
25
25
  ] as const;
@@ -175,8 +175,8 @@ export async function prepareAccount(
175
175
  input: AttachInput,
176
176
  verify: VerifyXmtpKey = verifyXmtpKeyOutOfProcess,
177
177
  ): Promise<PreparedAccount> {
178
- if (input.station === 'discord') return prepareDiscord(input.token);
179
- if (input.station === 'telegram') return prepareTelegram(input.token);
178
+ if (input.station === 'discord-bot') return prepareDiscord(input.token);
179
+ if (input.station === 'telegram-bot') return prepareTelegram(input.token);
180
180
  if (input.station === 'webhook') return prepareWebhook();
181
181
  return prepareXmtp(verify);
182
182
  }
@@ -38,7 +38,7 @@ function parseTelegramLine(
38
38
  line: Line | string,
39
39
  ): { accountId: string; chatId: number; topicId?: number } | null {
40
40
  const p = Line.parse(line);
41
- if (p?.station !== 'telegram') return null;
41
+ if (p?.station !== 'telegram-bot') return null;
42
42
  const { accountId, rest } = splitTelegramAccount(p.path);
43
43
  const [chatId, topicId] = rest;
44
44
  if (rest.length < 1 || rest.length > 2 || chatId === undefined || !isSignedInt(chatId))
@@ -76,7 +76,7 @@ export const Line = {
76
76
  parseXmtp: (line: Line | string) => parseAccountScoped(line, 'xmtp'),
77
77
 
78
78
  parseDiscord: (line: Line | string) =>
79
- parseAccountScoped(line, 'discord', isSnowflake),
79
+ parseAccountScoped(line, 'discord-bot', isSnowflake),
80
80
 
81
81
  parseTelegram: (line: Line | string) => parseTelegramLine(line),
82
82
  };
@@ -1,17 +1,17 @@
1
1
  import type { Station } from './types.js';
2
2
  import { Line } from './lines.js';
3
3
  import { xmtpStation } from '@metro-labs/xmtp';
4
+ import { telegramBotStation } from '@metro-labs/telegram-bot';
4
5
  import { telegramStation } from '@metro-labs/telegram';
5
- import { telegramUserStation } from '@metro-labs/telegram-user';
6
- import { discordStation } from '@metro-labs/discord';
6
+ import { discordBotStation } from '@metro-labs/discord-bot';
7
7
  import { whatsappStation } from '@metro-labs/whatsapp';
8
8
  import { webhookStation } from '@metro-labs/webhook';
9
9
 
10
10
  export const STATIONS: readonly Station[] = [
11
11
  xmtpStation,
12
+ telegramBotStation,
12
13
  telegramStation,
13
- telegramUserStation,
14
- discordStation,
14
+ discordBotStation,
15
15
  whatsappStation,
16
16
  webhookStation,
17
17
  ];
@@ -6,20 +6,23 @@
6
6
  "exports": {
7
7
  ".": "./src/station.ts",
8
8
  "./train": "./src/index.ts",
9
- "./verify": "./src/verify.ts"
9
+ "./login": "./src/login.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:*"
16
+ "@metro-labs/mcp": "workspace:*",
17
+ "@mtcute/bun": "0.30.1"
17
18
  },
18
19
  "devDependencies": {
19
20
  "@stage-labs/config": "0.1.0-beta.2",
21
+ "@types/qrcode-terminal": "^0.12.2",
20
22
  "eslint": "^10.3.0",
21
23
  "knip": "^6.15.0",
22
24
  "madge": "^8.0.0",
25
+ "qrcode-terminal": "^0.12.0",
23
26
  "typescript": "^5"
24
27
  }
25
28
  }
@@ -3,102 +3,43 @@ import { join } from 'node:path';
3
3
  import {
4
4
  makeAccountStore,
5
5
  resolveAccountId,
6
+ type Die,
6
7
  } from '@metro-labs/mcp/stations/account-store';
7
- import { Line } from '@metro-labs/mcp/lines';
8
+ import type { UserAccount } from './types.js';
8
9
 
9
10
  const ACCOUNTS_FILE =
10
11
  process.env.TELEGRAM_ACCOUNTS_FILE ??
11
12
  join(homedir(), '.metro', 'telegram-accounts.json');
12
13
 
13
- export interface AccountConfig {
14
- id: string;
15
- token: string;
16
- owner?: string;
14
+ const isSignedInt = (s: string): boolean => /^-?\d+$/.test(s);
15
+ const isTopic = (s: string): boolean => /^\d+$/.test(s);
16
+
17
+ function validateAccount(a: UserAccount, seen: Set<string>, die: Die): void {
18
+ if (!a.id) die('account missing id');
19
+ if (!a.session || typeof a.session !== 'string')
20
+ die(`account '${a.id}' missing session`);
21
+ if (!Number.isInteger(a.apiId) || (a.apiId ?? 0) <= 0)
22
+ die(`account '${a.id}' missing apiId`);
23
+ if (!a.apiHash || typeof a.apiHash !== 'string')
24
+ die(`account '${a.id}' missing apiHash`);
25
+ if (seen.has(a.id)) die(`duplicate account id '${a.id}'`);
26
+ seen.add(a.id);
17
27
  }
18
28
 
19
- export const { loadAccounts } = makeAccountStore<AccountConfig>({
29
+ export const { loadAccounts } = makeAccountStore<UserAccount>({
20
30
  prefix: 'telegram',
21
31
  file: ACCOUNTS_FILE,
22
32
  allowlistEnv: ['TELEGRAM_ONLY_ACCOUNTS', 'TELEGRAM_ACCOUNTS'],
23
33
  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
- }
34
+ const seen = new Set<string>();
35
+ for (const a of raw) validateAccount(a, seen, die);
38
36
  },
39
37
  });
40
38
 
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 ${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 ${method}: ${json.description ?? 'unknown'}`);
93
- return json.result as T;
94
- }
39
+ export const accounts = new Map<string, UserAccount>();
95
40
 
96
41
  export function accountFor(args: { account?: string; line?: string }): string {
97
- return resolveAccountId(
98
- accounts,
99
- args,
100
- (line) => Line.parseTelegram(line)?.accountId,
101
- );
42
+ return resolveAccountId(accounts, args, (line) => targetOf(line)?.accountId);
102
43
  }
103
44
 
104
45
  export function lineOf(
@@ -110,11 +51,31 @@ export function lineOf(
110
51
  return `metro://telegram/${accountId}/${tail}`;
111
52
  }
112
53
 
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 line: ${line}`);
119
- return { ...parsed, accountId: accountOverride ?? parsed.accountId };
54
+ interface Target {
55
+ accountId: string;
56
+ chatId: number;
57
+ topicId?: number;
58
+ }
59
+
60
+ function splitScoped(path: string[]): { accountId: string; rest: string[] } {
61
+ const first = path[0];
62
+ if (path.length >= 2 && first !== undefined && !isSignedInt(first))
63
+ return { accountId: first, rest: path.slice(1) };
64
+ return { accountId: 'default', rest: path };
65
+ }
66
+
67
+ export function targetOf(line: string): Target | undefined {
68
+ const prefix = 'metro://telegram/';
69
+ if (!line.startsWith(prefix)) return undefined;
70
+ const path = line.slice(prefix.length).split('/').filter(Boolean);
71
+ const { accountId, rest } = splitScoped(path);
72
+ const [chatId, topicId] = rest;
73
+ if (rest.length < 1 || rest.length > 2 || chatId === undefined) return undefined;
74
+ if (!isSignedInt(chatId)) return undefined;
75
+ if (topicId !== undefined && !isTopic(topicId)) return undefined;
76
+ return {
77
+ accountId,
78
+ chatId: Number(chatId),
79
+ ...(topicId !== undefined ? { topicId: Number(topicId) } : {}),
80
+ };
120
81
  }