@stage-labs/metro 0.1.0-beta.276 → 0.1.0-beta.277

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 (78) hide show
  1. package/dist/claude.js +2 -1
  2. package/dist/native-recovery-scan.js +246 -0
  3. package/dist/native-recovery-state.js +154 -0
  4. package/dist/native-recovery.js +164 -0
  5. package/package.json +1 -1
  6. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  7. package/runtime/marketplace/plugin/bin/policy.mjs +4 -0
  8. package/runtime/node_modules/@metro-labs/core/package.json +2 -0
  9. package/runtime/node_modules/@metro-labs/core/src/runner-activity.ts +2 -0
  10. package/runtime/node_modules/@metro-labs/core/src/stations/channel-directory.ts +159 -0
  11. package/runtime/node_modules/@metro-labs/core/src/stations/channel-pages.ts +129 -0
  12. package/runtime/node_modules/@metro-labs/core/src/stations/types.ts +1 -0
  13. package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +1 -1
  14. package/runtime/node_modules/@metro-labs/daemon/src/claude/runner-process.ts +6 -4
  15. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +3 -3
  16. package/runtime/node_modules/@metro-labs/daemon/src/mcp/channel-tool.ts +80 -0
  17. package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +12 -1
  18. package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +6 -0
  19. package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-catalog.ts +4 -1
  20. package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-dispatch.ts +7 -0
  21. package/runtime/node_modules/@metro-labs/discord-bot/src/actions.ts +2 -0
  22. package/runtime/node_modules/@metro-labs/discord-bot/src/channel-source.ts +79 -0
  23. package/runtime/node_modules/@metro-labs/discord-bot/src/channels.ts +186 -0
  24. package/runtime/node_modules/@metro-labs/discord-bot/src/station.ts +1 -0
  25. package/runtime/node_modules/@metro-labs/gmail/src/actions.ts +2 -1
  26. package/runtime/node_modules/@metro-labs/gmail/src/channels.ts +66 -0
  27. package/runtime/node_modules/@metro-labs/gmail/src/station.ts +1 -0
  28. package/runtime/node_modules/@metro-labs/outlook/src/actions.ts +2 -1
  29. package/runtime/node_modules/@metro-labs/outlook/src/channels.ts +60 -0
  30. package/runtime/node_modules/@metro-labs/outlook/src/station.ts +1 -0
  31. package/runtime/node_modules/@metro-labs/telegram/src/actions.ts +19 -0
  32. package/runtime/node_modules/@metro-labs/telegram/src/channels.ts +58 -0
  33. package/runtime/node_modules/@metro-labs/telegram/src/station.ts +1 -0
  34. package/runtime/node_modules/@metro-labs/telegram-bot/src/actions.ts +2 -0
  35. package/runtime/node_modules/@metro-labs/telegram-bot/src/channel-files.ts +3 -0
  36. package/runtime/node_modules/@metro-labs/telegram-bot/src/channels.ts +43 -0
  37. package/runtime/node_modules/@metro-labs/telegram-bot/src/index.ts +5 -34
  38. package/runtime/node_modules/@metro-labs/telegram-bot/src/observed-chats.ts +172 -0
  39. package/runtime/node_modules/@metro-labs/telegram-bot/src/station.ts +4 -0
  40. package/runtime/node_modules/@metro-labs/telegram-bot/src/types.ts +30 -3
  41. package/runtime/node_modules/@metro-labs/telegram-bot/src/updates.ts +26 -0
  42. package/runtime/node_modules/@metro-labs/threema/src/accounts.ts +13 -1
  43. package/runtime/node_modules/@metro-labs/threema/src/actions.ts +7 -1
  44. package/runtime/node_modules/@metro-labs/threema/src/channels.ts +37 -0
  45. package/runtime/node_modules/@metro-labs/threema/src/chats.ts +88 -0
  46. package/runtime/node_modules/@metro-labs/threema/src/inbound.ts +5 -0
  47. package/runtime/node_modules/@metro-labs/threema/src/outbound.ts +1 -0
  48. package/runtime/node_modules/@metro-labs/threema/src/station.ts +10 -2
  49. package/runtime/node_modules/@metro-labs/whatsapp/src/actions.ts +11 -0
  50. package/runtime/node_modules/@metro-labs/whatsapp/src/channel-chats.ts +137 -0
  51. package/runtime/node_modules/@metro-labs/whatsapp/src/channels.ts +48 -0
  52. package/runtime/node_modules/@metro-labs/whatsapp/src/client-types.ts +2 -0
  53. package/runtime/node_modules/@metro-labs/whatsapp/src/client.ts +8 -6
  54. package/runtime/node_modules/@metro-labs/whatsapp/src/station.ts +4 -0
  55. package/runtime/node_modules/@metro-labs/xmtp/src/actions.ts +3 -1
  56. package/runtime/node_modules/@metro-labs/xmtp/src/channels.ts +56 -0
  57. package/runtime/node_modules/@metro-labs/xmtp/src/network.ts +4 -0
  58. package/runtime/node_modules/@metro-labs/xmtp/src/station.ts +1 -0
  59. package/runtime/runtime.json +1 -1
  60. package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +2 -0
  61. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/runner-activity.ts +2 -0
  62. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/channel-directory.ts +159 -0
  63. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/channel-pages.ts +129 -0
  64. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/types.ts +1 -0
  65. package/runtime/sdk-runner/runner.json +1 -1
  66. package/runtime/sdk-runner/src/activity-tasks.ts +7 -1
  67. package/runtime/sdk-runner/src/activity.ts +33 -3
  68. package/runtime/sdk-runner/src/app.ts +5 -2
  69. package/runtime/sdk-runner/src/inbox.ts +10 -1
  70. package/runtime/sdk-runner/src/input-queue.ts +6 -4
  71. package/runtime/sdk-runner/src/main.ts +21 -8
  72. package/runtime/sdk-runner/src/rules.ts +1 -0
  73. package/runtime/sdk-runner/src/runner.ts +18 -4
  74. package/runtime/sdk-runner/src/session-store.ts +19 -4
  75. package/runtime/sdk-runner/src/session-watch.ts +5 -1
  76. package/runtime/sdk-runner/src/task-ledger.ts +229 -0
  77. package/runtime/sdk-runner/src/task-recovery.ts +203 -0
  78. package/runtime/sdk-runner/src/task-state.ts +71 -0
@@ -0,0 +1,186 @@
1
+ import {
2
+ ChannelDirectory,
3
+ DIRECTORY_LIMIT,
4
+ type ChannelEntry,
5
+ type ChannelSnapshot,
6
+ } from '@metro-labs/core/stations/channel-directory';
7
+ import { respond } from '@metro-labs/core/stations/station-runtime';
8
+ import { TrainError } from '@metro-labs/core/train-error';
9
+ import { ChannelType, PermissionFlagsBits } from 'discord.js';
10
+ import { accounts, lineOf, type Account } from './accounts.js';
11
+ import {
12
+ discordChannelSource,
13
+ type DiscordChannelMetadata,
14
+ type DiscordChannelSource,
15
+ type DiscordGuildSource,
16
+ } from './channel-source.js';
17
+
18
+ const directories = new WeakMap<Account, ChannelDirectory>();
19
+ const GUILD_PAGE = 20;
20
+ const REQUEST_LIMIT = 100;
21
+ const SCAN_MS = 30_000;
22
+ const LIMIT_REASON = `Discovery stopped at the ${DIRECTORY_LIMIT} metadata scan limit; results are partial.`;
23
+ const THREAD_REASON = 'Archived and inaccessible threads are omitted; only accessible active threads are listed.';
24
+ const DM_REASON = 'DMs are limited to channels already cached by this bot; Discord has no complete bot DM directory.';
25
+
26
+ function kindOf(type: ChannelType): ChannelEntry['kind'] | undefined {
27
+ switch (type) {
28
+ case ChannelType.DM: return 'direct';
29
+ case ChannelType.GroupDM: return 'group';
30
+ case ChannelType.AnnouncementThread:
31
+ case ChannelType.PublicThread:
32
+ case ChannelType.PrivateThread: return 'thread';
33
+ case ChannelType.GuildCategory: return undefined;
34
+ default: return 'channel';
35
+ }
36
+ }
37
+
38
+ function visible(channel: DiscordChannelMetadata): boolean {
39
+ if (!channel.permissions?.has(PermissionFlagsBits.ViewChannel)) return false;
40
+ return channel.type !== ChannelType.PrivateThread || channel.joined === true ||
41
+ channel.permissions.has(PermissionFlagsBits.ManageThreads);
42
+ }
43
+
44
+ function inaccessible(error: unknown): boolean {
45
+ return typeof error === 'object' && error !== null && 'status' in error &&
46
+ (error.status === 403 || error.status === 404);
47
+ }
48
+
49
+ class Snapshot {
50
+ readonly channels = new Map<string, ChannelEntry>();
51
+ readonly reasons = new Set([THREAD_REASON, DM_REASON]);
52
+ private scanned = 0;
53
+ private requests = 0;
54
+ private readonly started: number;
55
+ remote = false;
56
+
57
+ constructor(private readonly account: string, private readonly now: () => number) {
58
+ this.started = now();
59
+ }
60
+
61
+ canFetch(cost = 1): boolean {
62
+ if (this.requests + cost > REQUEST_LIMIT || this.now() - this.started >= SCAN_MS) {
63
+ this.reasons.add('Discovery request or time budget reached; additional guilds or threads may be missing.');
64
+ return false;
65
+ }
66
+ this.requests += cost;
67
+ return true;
68
+ }
69
+
70
+ collect(channels: Iterable<DiscordChannelMetadata>, direct = false): boolean {
71
+ for (const channel of channels) {
72
+ if (++this.scanned > DIRECTORY_LIMIT) {
73
+ this.reasons.add(LIMIT_REASON);
74
+ return false;
75
+ }
76
+ this.add(channel, direct);
77
+ }
78
+ return true;
79
+ }
80
+
81
+ private add(channel: DiscordChannelMetadata, direct: boolean): void {
82
+ const kind = kindOf(channel.type);
83
+ if (!kind || channel.archived) return;
84
+ if (direct ? kind !== 'direct' && kind !== 'group' : !visible(channel)) return;
85
+ const line = lineOf(this.account, channel.id);
86
+ if (!this.channels.has(line)) {
87
+ this.channels.set(line, {
88
+ id: channel.id,
89
+ line,
90
+ kind,
91
+ ...(channel.name ? { name: channel.name } : {}),
92
+ });
93
+ }
94
+ }
95
+
96
+ result(): ChannelSnapshot {
97
+ return {
98
+ channels: [...this.channels.values()],
99
+ capability: {
100
+ supported: true,
101
+ complete: false,
102
+ source: this.remote ? 'mixed' : 'local',
103
+ reason: [...this.reasons].join(' '),
104
+ },
105
+ };
106
+ }
107
+ }
108
+
109
+ async function collectGuild(guild: DiscordGuildSource, snapshot: Snapshot): Promise<boolean> {
110
+ if (!snapshot.canFetch() || !snapshot.collect(await guild.channels())) return false;
111
+ if (!snapshot.canFetch()) return false;
112
+ try {
113
+ return snapshot.collect(await guild.threads());
114
+ } catch (error) {
115
+ if (!inaccessible(error)) throw error;
116
+ snapshot.reasons.add('Some active threads could not be accessed and were omitted.');
117
+ return true;
118
+ }
119
+ }
120
+
121
+ async function visitGuild(id: string, source: DiscordChannelSource, snapshot: Snapshot): Promise<boolean> {
122
+ try {
123
+ return await collectGuild(await source.guild(id), snapshot);
124
+ } catch (error) {
125
+ if (!inaccessible(error)) throw error;
126
+ snapshot.reasons.add('Some guilds or their channel permissions could not be accessed and were omitted.');
127
+ return true;
128
+ }
129
+ }
130
+
131
+ async function collectGuilds(source: DiscordChannelSource, snapshot: Snapshot): Promise<void> {
132
+ const visited = new Set<string>();
133
+ let after: string | undefined;
134
+ for (;;) {
135
+ if (!snapshot.canFetch()) return;
136
+ const page = await source.guilds(after, GUILD_PAGE);
137
+ snapshot.remote = true;
138
+ const previousSize = visited.size;
139
+ for (const id of page) {
140
+ if (visited.has(id)) continue;
141
+ if (!snapshot.canFetch(2)) return;
142
+ visited.add(id);
143
+ if (!await visitGuild(id, source, snapshot)) return;
144
+ }
145
+ if (page.length < GUILD_PAGE) return;
146
+ if (visited.size === previousSize || page.at(-1) === after) {
147
+ snapshot.reasons.add('Discord guild pagination did not advance; results are partial.');
148
+ return;
149
+ }
150
+ after = page.at(-1);
151
+ }
152
+ }
153
+
154
+ export async function loadDiscordChannels(
155
+ account: string,
156
+ source: DiscordChannelSource,
157
+ now: () => number = Date.now,
158
+ ): Promise<ChannelSnapshot> {
159
+ const snapshot = new Snapshot(account, now);
160
+ if (snapshot.collect(source.directs(), true)) {
161
+ try {
162
+ await collectGuilds(source, snapshot);
163
+ } catch (error) {
164
+ if (!inaccessible(error)) throw error;
165
+ snapshot.reasons.add(snapshot.remote
166
+ ? 'Discord guild pagination became inaccessible; channels from remaining guilds are omitted.'
167
+ : 'The bot guild directory could not be accessed; only cached DMs are listed.');
168
+ }
169
+ }
170
+ return snapshot.result();
171
+ }
172
+
173
+ export async function listChannels(id: string, args: Record<string, unknown>): Promise<void> {
174
+ const { account } = args;
175
+ if (typeof account !== 'string' || !account.trim())
176
+ throw new TrainError('bad_request', 'account is required to list Discord channels');
177
+ const bot = accounts.get(account);
178
+ if (!bot) throw new TrainError('bad_request', `Unknown Discord account '${account}'`);
179
+ const directory = directories.get(bot) ?? new ChannelDirectory();
180
+ directories.set(bot, directory);
181
+ const result = await directory.list(account, args, () => {
182
+ if (!bot.client.isReady()) throw new TrainError('unavailable', 'Discord bot is not ready');
183
+ return loadDiscordChannels(account, discordChannelSource(bot.client));
184
+ });
185
+ respond(id, { result });
186
+ }
@@ -19,5 +19,6 @@ export const discordBotStation: Station = {
19
19
  attachmentMode: 'canonical',
20
20
  profileFields: new Set<ProfileField>(PROFILE_FIELDS),
21
21
  readsProfiles: true,
22
+ discoversChannels: true,
22
23
  tools: [],
23
24
  };
@@ -1,5 +1,6 @@
1
1
  import { makeStation, respond } from '@metro-labs/core/stations/station-runtime';
2
2
  import { accountOf, accounts } from './accounts.js';
3
+ import { listChannels } from './channels.js';
3
4
  import { MAILBOX_URL } from './config.js';
4
5
  import { reply, send } from './outbound.js';
5
6
  import { read } from './query.js';
@@ -15,5 +16,5 @@ async function disconnect(id: string, args: Record<string, unknown>): Promise<vo
15
16
  }
16
17
 
17
18
  export const handleCall = makeStation({
18
- handlers: { accounts: listAccounts, send, reply, read, disconnect },
19
+ handlers: { accounts: listAccounts, listChannels, send, reply, read, disconnect },
19
20
  });
@@ -0,0 +1,66 @@
1
+ import type { ChannelEntry } from '@metro-labs/core/stations/channel-directory';
2
+ import { ChannelPages, type ChannelPage } from '@metro-labs/core/stations/channel-pages';
3
+ import { mailLine } from '@metro-labs/core/stations/mail';
4
+ import { firstHeader, type MailHeader } from '@metro-labs/core/stations/mail-trust';
5
+ import { respond } from '@metro-labs/core/stations/station-runtime';
6
+ import { TrainError } from '@metro-labs/core/train-error';
7
+ import { accountOf, type Account } from './accounts.js';
8
+ import { gmailJson, queryOf, USER } from './api.js';
9
+
10
+ const BATCH = 10;
11
+ const SCOPE = 'Matches thread ids, lines and subjects in Gmail mailbox order, excluding Spam and Trash.';
12
+ const directories = new WeakMap<Account, ChannelPages>();
13
+
14
+ interface ThreadPage {
15
+ threads?: { id?: string }[];
16
+ nextPageToken?: string;
17
+ }
18
+
19
+ interface ThreadSubject {
20
+ messages?: { payload?: { headers?: MailHeader[] } }[];
21
+ }
22
+
23
+ async function threadEntry(acct: Account, id: string, signal: AbortSignal): Promise<ChannelEntry> {
24
+ const params: [string, string][] = [
25
+ ['format', 'metadata'],
26
+ ['metadataHeaders', 'Subject'],
27
+ ['fields', 'messages(payload(headers(name,value)))'],
28
+ ];
29
+ const thread = await gmailJson<ThreadSubject>(acct, `${USER}/threads/${encodeURIComponent(id)}?${queryOf(params)}`, { redirect: 'error', signal });
30
+ const subject = firstHeader(thread.messages?.[0]?.payload?.headers ?? [], 'subject') ?? '';
31
+ return { id, line: mailLine('gmail', acct.id, id), kind: 'thread', ...(subject ? { name: subject } : {}) };
32
+ }
33
+
34
+ async function loadPage(account: Account, token: string | undefined, limit: number, seen: ReadonlySet<string>): Promise<ChannelPage> {
35
+ const signal = AbortSignal.timeout(45_000);
36
+ const params: [string, string][] = [
37
+ ['maxResults', String(limit)],
38
+ ['fields', 'threads(id),nextPageToken'],
39
+ ...(token ? [['pageToken', token] as [string, string]] : []),
40
+ ];
41
+ const page = await gmailJson<ThreadPage>(account, `${USER}/threads?${queryOf(params)}`, { redirect: 'error', signal });
42
+ const rows = page.threads ?? [];
43
+ if (rows.length > limit) throw new TrainError('gmail_channel_page', 'Gmail returned more thread metadata than requested.');
44
+ const ids = new Set<string>();
45
+ for (const row of rows) {
46
+ if (!row.id || row.id.length > 1024) throw new TrainError('gmail_channel_page', 'Gmail returned invalid thread metadata.');
47
+ if (!seen.has(row.id)) ids.add(row.id);
48
+ }
49
+ const channels: ChannelEntry[] = [];
50
+ const unique = [...ids];
51
+ for (let i = 0; i < unique.length; i += BATCH) {
52
+ channels.push(...await Promise.all(unique.slice(i, i + BATCH).map((id) => threadEntry(account, id, signal))));
53
+ }
54
+ return { channels, scanned: rows.length, ...(page.nextPageToken ? { next: page.nextPageToken } : {}) };
55
+ }
56
+
57
+ export async function listChannels(id: string, args: Record<string, unknown>): Promise<void> {
58
+ if (typeof args.account !== 'string' || !args.account.trim()) throw new TrainError('bad_request', 'account is required to list channels.');
59
+ const account = accountOf({ account: args.account });
60
+ account.check();
61
+ const directory = directories.get(account) ?? new ChannelPages();
62
+ directories.set(account, directory);
63
+ const result = await directory.list(account.id, args, (token, limit, seen) => loadPage(account, token, limit, seen), SCOPE);
64
+ account.check();
65
+ respond(id, { result });
66
+ }
@@ -5,6 +5,7 @@ export const gmailStation: Station = {
5
5
  name: 'gmail',
6
6
  hasAccounts: true,
7
7
  hasTrain: true,
8
+ discoversChannels: true,
8
9
  messageVerbs: new Set<Verb>(['send', 'reply', 'read']),
9
10
  readFilters: new Set<ReadFilter>(['account', 'query', 'from', 'until', 'unread_only', 'message_id']),
10
11
  approvals: false,
@@ -1,5 +1,6 @@
1
1
  import { makeStation, respond } from '@metro-labs/core/stations/station-runtime';
2
2
  import { accounts } from './accounts.js';
3
+ import { listChannels } from './channels.js';
3
4
  import { MAILBOX_URL } from './config.js';
4
5
  import { reply, send } from './outbound.js';
5
6
  import { read } from './query.js';
@@ -10,5 +11,5 @@ function listAccounts(id: string): void {
10
11
  }
11
12
 
12
13
  export const handleCall = makeStation({
13
- handlers: { accounts: listAccounts, send, reply, read },
14
+ handlers: { accounts: listAccounts, listChannels, send, reply, read },
14
15
  });
@@ -0,0 +1,60 @@
1
+ import { ChannelPages, type ChannelPage } from '@metro-labs/core/stations/channel-pages';
2
+ import { mailLine } from '@metro-labs/core/stations/mail';
3
+ import { respond } from '@metro-labs/core/stations/station-runtime';
4
+ import { TrainError } from '@metro-labs/core/train-error';
5
+ import { accountOf, type Account } from './accounts.js';
6
+ import { graphBase } from './config.js';
7
+ import { graphJson } from './graph.js';
8
+
9
+ const FIELDS = 'conversationId,subject';
10
+ const ORDER = 'receivedDateTime desc';
11
+ const PARAMS = new Set(['$select', '$orderby', '$top', '$skip', '$skiptoken']);
12
+ const SCOPE = 'Matches thread ids, lines and the newest encountered subject across up to 5000 Outlook message records, newest first; repeated conversations are returned once.';
13
+ const directories = new WeakMap<Account, ChannelPages>();
14
+
15
+ interface MessagePage {
16
+ value?: { conversationId?: string; subject?: string | null }[];
17
+ '@odata.nextLink'?: string;
18
+ }
19
+
20
+ const refused = (): never => { throw new TrainError('outlook_channel_page', 'Outlook returned an unsafe metadata page link.'); };
21
+
22
+ function checkParams(params: URLSearchParams): void {
23
+ for (const key of params.keys()) {
24
+ if (!PARAMS.has(key) || params.getAll(key).length !== 1) refused();
25
+ }
26
+ const select = params.get('$select');
27
+ const order = params.get('$orderby');
28
+ if ((select !== null && select !== FIELDS) || (order !== null && order !== ORDER)) refused();
29
+ }
30
+
31
+ function pageUrl(next: string | undefined, limit: number): string {
32
+ const base = new URL(`${graphBase()}/me/messages`);
33
+ const url = next === undefined ? base : new URL(next);
34
+ if (url.origin !== base.origin || url.pathname !== base.pathname || url.username || url.password || url.hash) refused();
35
+ checkParams(url.searchParams);
36
+ url.searchParams.set('$select', FIELDS);
37
+ url.searchParams.set('$orderby', ORDER);
38
+ url.searchParams.set('$top', String(limit));
39
+ return url.toString();
40
+ }
41
+
42
+ async function loadPage(account: Account, next: string | undefined, limit: number): Promise<ChannelPage> {
43
+ const page = await graphJson<MessagePage>(account, pageUrl(next, limit), { redirect: 'error', signal: AbortSignal.timeout(45_000) });
44
+ const rows = page.value ?? [];
45
+ const channels = rows.map((row) => {
46
+ if (!row.conversationId || row.conversationId.length > 1024) throw new TrainError('outlook_channel_page', 'Outlook returned invalid conversation metadata.');
47
+ return { id: row.conversationId, line: mailLine('outlook', account.id, row.conversationId), kind: 'thread' as const, ...(row.subject ? { name: row.subject } : {}) };
48
+ });
49
+ const token = page['@odata.nextLink'];
50
+ return { channels, scanned: rows.length, ...(token ? { next: pageUrl(token, limit) } : {}) };
51
+ }
52
+
53
+ export async function listChannels(id: string, args: Record<string, unknown>): Promise<void> {
54
+ if (typeof args.account !== 'string' || !args.account.trim()) throw new TrainError('bad_request', 'account is required to list channels.');
55
+ const account = accountOf({ account: args.account });
56
+ const directory = directories.get(account) ?? new ChannelPages();
57
+ directories.set(account, directory);
58
+ const result = await directory.list(account.id, args, (token, limit) => loadPage(account, token, limit), SCOPE);
59
+ respond(id, { result });
60
+ }
@@ -5,6 +5,7 @@ export const outlookStation: Station = {
5
5
  name: 'outlook',
6
6
  hasAccounts: true,
7
7
  hasTrain: true,
8
+ discoversChannels: true,
8
9
  messageVerbs: new Set<Verb>(['send', 'reply', 'read']),
9
10
  readFilters: new Set<ReadFilter>(['account', 'query', 'from', 'until', 'unread_only', 'message_id']),
10
11
  approvals: false,
@@ -13,6 +13,8 @@ import {
13
13
  sendAttachments,
14
14
  type CanonicalAttachment,
15
15
  } from './media-actions.js';
16
+ import { ChannelDirectory } from '@metro-labs/core/stations/channel-directory';
17
+ import { fetchChannels } from './channels.js';
16
18
  import { clampLimit, shapeHistory } from './history.js';
17
19
  import { makeReadProfile, makeSetProfile } from './profile.js';
18
20
  import { fetchMembers, isRestricted, restrictedMemberList } from './members.js';
@@ -159,6 +161,22 @@ function makeRead(clientFor: ClientFor): StationHandler {
159
161
  };
160
162
  }
161
163
 
164
+ function makeListChannels(clientFor: ClientFor): StationHandler {
165
+ const directories = new WeakMap<UserClient, ChannelDirectory>();
166
+ return async (id, args) => {
167
+ const account = str(args.account);
168
+ if (!account) throw new TrainError('bad_request', 'missing account');
169
+ const accountId = accountFor({ account });
170
+ const client = clientFor(accountId);
171
+ const directory = directories.get(client) ?? new ChannelDirectory();
172
+ directories.set(client, directory);
173
+ const result = await directory.list(accountId, args, () =>
174
+ guard(() => fetchChannels(accountId, client.tg)),
175
+ );
176
+ respond(id, { result });
177
+ };
178
+ }
179
+
162
180
  function makeListMembers(clientFor: ClientFor): StationHandler {
163
181
  return async (id, args) => {
164
182
  const { client, chatId } = resolve(args, clientFor);
@@ -243,6 +261,7 @@ export function makeHandleCall(
243
261
  edit: makeEdit(clientFor),
244
262
  delete: makeDelete(clientFor),
245
263
  read: makeRead(clientFor),
264
+ listChannels: makeListChannels(clientFor),
246
265
  listMembers: makeListMembers(clientFor),
247
266
  groupCreate: makeGroupCreate(clientFor),
248
267
  groupAddMembers: makeGroupAdd(clientFor),
@@ -0,0 +1,58 @@
1
+ import type { Chat, Dialog, TelegramClient } from '@mtcute/bun';
2
+ import {
3
+ DIRECTORY_LIMIT,
4
+ type ChannelEntry,
5
+ type ChannelSnapshot,
6
+ } from '@metro-labs/core/stations/channel-directory';
7
+ import { lineOf } from './accounts.js';
8
+
9
+ type DialogOptions = NonNullable<Parameters<TelegramClient['iterDialogs']>[0]>;
10
+ type DialogPeer = Pick<Dialog['peer'], 'id' | 'type' | 'displayName'> &
11
+ Partial<Pick<Chat, 'chatType'>>;
12
+
13
+ export interface DialogSource {
14
+ iterDialogs(params: DialogOptions): AsyncIterable<{ peer: DialogPeer }>;
15
+ }
16
+
17
+ const SCAN_TIMEOUT_MS = 30_000;
18
+ const TIME_LIMIT_REASON = 'Telegram dialog scan reached its time limit; results are partial.';
19
+
20
+ const SCANS: DialogOptions[] = [
21
+ { pinned: 'only', archived: 'exclude' },
22
+ { pinned: 'only', archived: 'only' },
23
+ { pinned: 'exclude', archived: 'keep' },
24
+ ];
25
+
26
+ function channelOf(account: string, peer: DialogPeer): ChannelEntry {
27
+ return {
28
+ id: String(peer.id),
29
+ line: lineOf(account, peer.id),
30
+ ...(peer.displayName ? { name: peer.displayName } : {}),
31
+ kind: peer.type === 'user' ? 'direct' : peer.chatType === 'channel' ? 'channel' : 'group',
32
+ };
33
+ }
34
+
35
+ function partial(channels: ChannelEntry[], reason: string): ChannelSnapshot {
36
+ return { channels, capability: { supported: true, complete: false, source: 'remote', reason } };
37
+ }
38
+
39
+ export async function fetchChannels(account: string, source: DialogSource): Promise<ChannelSnapshot> {
40
+ const channels: ChannelEntry[] = [];
41
+ const deadline = Date.now() + SCAN_TIMEOUT_MS;
42
+ for (const scan of SCANS) {
43
+ if (Date.now() >= deadline) return partial(channels, TIME_LIMIT_REASON);
44
+ const dialogs = source.iterDialogs({
45
+ ...scan,
46
+ limit: DIRECTORY_LIMIT + 1 - channels.length,
47
+ chunkSize: 100,
48
+ });
49
+ for await (const dialog of dialogs) {
50
+ channels.push(channelOf(account, dialog.peer));
51
+ if (channels.length > DIRECTORY_LIMIT) {
52
+ return partial(channels, 'Telegram dialog scan reached the snapshot limit; results are partial.');
53
+ }
54
+ if (Date.now() >= deadline) return partial(channels, TIME_LIMIT_REASON);
55
+ }
56
+ }
57
+ return { channels, capability: { supported: true, complete: true, source: 'remote' } };
58
+ }
@@ -23,5 +23,6 @@ export const telegramStation: Station = {
23
23
  attachmentMode: 'canonical',
24
24
  profileFields: new Set<ProfileField>(PROFILE_FIELDS),
25
25
  readsProfiles: true,
26
+ discoversChannels: true,
26
27
  tools: [],
27
28
  };
@@ -15,6 +15,7 @@ import {
15
15
  sendMedia,
16
16
  } from './media-actions.js';
17
17
  import { readProfile, setProfile } from './profile.js';
18
+ import { listChannels } from './channels.js';
18
19
 
19
20
  export type { CallMsg };
20
21
 
@@ -230,6 +231,7 @@ const HANDLERS: Record<string, StationHandler> = {
230
231
  edit,
231
232
  delete: remove,
232
233
  listMembers,
234
+ listChannels,
233
235
  set_profile: setProfile,
234
236
  profile: readProfile,
235
237
  };
@@ -0,0 +1,3 @@
1
+ import { accountFiles } from '@metro-labs/core/stations/account-files';
2
+
3
+ export const channelFiles = accountFiles('TELEGRAM_BOT_STATE_DIR', 'telegram-bot-channels-');
@@ -0,0 +1,43 @@
1
+ import { ChannelDirectory } from '@metro-labs/core/stations/channel-directory';
2
+ import { respond } from '@metro-labs/core/stations/station-runtime';
3
+ import { TrainError } from '@metro-labs/core/train-error';
4
+ import { accounts, type Account } from './accounts.js';
5
+ import { channelFiles } from './channel-files.js';
6
+ import { ObservedChats } from './observed-chats.js';
7
+ import type { TgUpdate } from './types.js';
8
+
9
+ interface DiscoveryState {
10
+ book: ObservedChats;
11
+ directory: ChannelDirectory;
12
+ }
13
+
14
+ const states = new WeakMap<Account, DiscoveryState>();
15
+
16
+ function stateFor(account: Account): DiscoveryState {
17
+ let state = states.get(account);
18
+ if (state === undefined) {
19
+ state = {
20
+ book: new ObservedChats(account.cfg.id, channelFiles.path(account.cfg.id)),
21
+ directory: new ChannelDirectory(),
22
+ };
23
+ states.set(account, state);
24
+ }
25
+ return state;
26
+ }
27
+
28
+ export const observedChatsFor = (account: Account): ObservedChats => stateFor(account).book;
29
+
30
+ export function observeChannels(account: string, update: TgUpdate): void {
31
+ const known = accounts.get(account);
32
+ if (known !== undefined) observedChatsFor(known).observe(update);
33
+ }
34
+
35
+ export async function listChannels(id: string, args: Record<string, unknown>): Promise<void> {
36
+ if (typeof args.account !== 'string' || args.account.trim() === '')
37
+ throw new TrainError('bad_request', 'listChannels requires an explicit account');
38
+ const account = accounts.get(args.account);
39
+ if (account === undefined) throw new TrainError('bad_request', `unknown account '${args.account}'`);
40
+ const { directory, book } = stateFor(account);
41
+ const result = await directory.list(account.cfg.id, args, () => Promise.resolve(book.snapshot()));
42
+ respond(id, { result });
43
+ }
@@ -1,44 +1,13 @@
1
1
  import { errMsg } from '@metro-labs/core/log';
2
2
  import { accounts, loadAccounts, tg, type Account } from './accounts.js';
3
- import { emitInbound } from '@metro-labs/core/stations/train-events';
4
3
  import { announceAccounts } from '@metro-labs/core/stations/train-boot';
5
- import {
6
- envelope,
7
- reactionCountEnvelope,
8
- reactionEnvelope,
9
- saveMediaAndEmit,
10
- type TgMsg,
11
- type TgReaction,
12
- type TgReactionCount,
13
- } from './format.js';
14
4
  import { readCalls } from '@metro-labs/core/trains/protocol';
15
5
  import { handleCall } from './actions.js';
6
+ import { handleUpdate } from './updates.js';
7
+ import type { TgUpdate } from './types.js';
16
8
 
17
9
  readCalls('telegram-bot', handleCall);
18
10
 
19
- interface Update {
20
- update_id: number;
21
- message?: TgMsg;
22
- message_reaction?: TgReaction;
23
- message_reaction_count?: TgReactionCount;
24
- }
25
-
26
- function handleUpdate(id: string, u: Update): void {
27
- if (u.message && !u.message.from?.is_bot) {
28
- const env = envelope(id, u.message);
29
- emitInbound(id, env);
30
- saveMediaAndEmit(id, u.message, env.id as string);
31
- }
32
- if (u.message_reaction) {
33
- const env = reactionEnvelope(id, u.message_reaction);
34
- if (env) emitInbound(id, env);
35
- }
36
- if (u.message_reaction_count) {
37
- const env = reactionCountEnvelope(id, u.message_reaction_count);
38
- if (env) emitInbound(id, env);
39
- }
40
- }
41
-
42
11
  async function runAccount(acct: Account): Promise<void> {
43
12
  const { id } = acct.cfg;
44
13
  try {
@@ -57,7 +26,7 @@ async function runAccount(acct: Account): Promise<void> {
57
26
 
58
27
  for (;;) {
59
28
  try {
60
- const updates = await tg<Update[]>(
29
+ const updates = await tg<TgUpdate[]>(
61
30
  id,
62
31
  'getUpdates',
63
32
  {
@@ -65,6 +34,8 @@ async function runAccount(acct: Account): Promise<void> {
65
34
  timeout: 25,
66
35
  allowed_updates: [
67
36
  'message',
37
+ 'channel_post',
38
+ 'my_chat_member',
68
39
  'message_reaction',
69
40
  'message_reaction_count',
70
41
  ],