@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.
- package/dist/claude.js +2 -1
- package/dist/native-recovery-scan.js +246 -0
- package/dist/native-recovery-state.js +154 -0
- package/dist/native-recovery.js +164 -0
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/marketplace/plugin/bin/policy.mjs +4 -0
- package/runtime/node_modules/@metro-labs/core/package.json +2 -0
- package/runtime/node_modules/@metro-labs/core/src/runner-activity.ts +2 -0
- package/runtime/node_modules/@metro-labs/core/src/stations/channel-directory.ts +159 -0
- package/runtime/node_modules/@metro-labs/core/src/stations/channel-pages.ts +129 -0
- package/runtime/node_modules/@metro-labs/core/src/stations/types.ts +1 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/claude/runner-process.ts +6 -4
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/channel-tool.ts +80 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +12 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +6 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-catalog.ts +4 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-dispatch.ts +7 -0
- package/runtime/node_modules/@metro-labs/discord-bot/src/actions.ts +2 -0
- package/runtime/node_modules/@metro-labs/discord-bot/src/channel-source.ts +79 -0
- package/runtime/node_modules/@metro-labs/discord-bot/src/channels.ts +186 -0
- package/runtime/node_modules/@metro-labs/discord-bot/src/station.ts +1 -0
- package/runtime/node_modules/@metro-labs/gmail/src/actions.ts +2 -1
- package/runtime/node_modules/@metro-labs/gmail/src/channels.ts +66 -0
- package/runtime/node_modules/@metro-labs/gmail/src/station.ts +1 -0
- package/runtime/node_modules/@metro-labs/outlook/src/actions.ts +2 -1
- package/runtime/node_modules/@metro-labs/outlook/src/channels.ts +60 -0
- package/runtime/node_modules/@metro-labs/outlook/src/station.ts +1 -0
- package/runtime/node_modules/@metro-labs/telegram/src/actions.ts +19 -0
- package/runtime/node_modules/@metro-labs/telegram/src/channels.ts +58 -0
- package/runtime/node_modules/@metro-labs/telegram/src/station.ts +1 -0
- package/runtime/node_modules/@metro-labs/telegram-bot/src/actions.ts +2 -0
- package/runtime/node_modules/@metro-labs/telegram-bot/src/channel-files.ts +3 -0
- package/runtime/node_modules/@metro-labs/telegram-bot/src/channels.ts +43 -0
- package/runtime/node_modules/@metro-labs/telegram-bot/src/index.ts +5 -34
- package/runtime/node_modules/@metro-labs/telegram-bot/src/observed-chats.ts +172 -0
- package/runtime/node_modules/@metro-labs/telegram-bot/src/station.ts +4 -0
- package/runtime/node_modules/@metro-labs/telegram-bot/src/types.ts +30 -3
- package/runtime/node_modules/@metro-labs/telegram-bot/src/updates.ts +26 -0
- package/runtime/node_modules/@metro-labs/threema/src/accounts.ts +13 -1
- package/runtime/node_modules/@metro-labs/threema/src/actions.ts +7 -1
- package/runtime/node_modules/@metro-labs/threema/src/channels.ts +37 -0
- package/runtime/node_modules/@metro-labs/threema/src/chats.ts +88 -0
- package/runtime/node_modules/@metro-labs/threema/src/inbound.ts +5 -0
- package/runtime/node_modules/@metro-labs/threema/src/outbound.ts +1 -0
- package/runtime/node_modules/@metro-labs/threema/src/station.ts +10 -2
- package/runtime/node_modules/@metro-labs/whatsapp/src/actions.ts +11 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/channel-chats.ts +137 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/channels.ts +48 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/client-types.ts +2 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/client.ts +8 -6
- package/runtime/node_modules/@metro-labs/whatsapp/src/station.ts +4 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/actions.ts +3 -1
- package/runtime/node_modules/@metro-labs/xmtp/src/channels.ts +56 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/network.ts +4 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/station.ts +1 -0
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +2 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/runner-activity.ts +2 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/channel-directory.ts +159 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/channel-pages.ts +129 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/types.ts +1 -0
- package/runtime/sdk-runner/runner.json +1 -1
- package/runtime/sdk-runner/src/activity-tasks.ts +7 -1
- package/runtime/sdk-runner/src/activity.ts +33 -3
- package/runtime/sdk-runner/src/app.ts +5 -2
- package/runtime/sdk-runner/src/inbox.ts +10 -1
- package/runtime/sdk-runner/src/input-queue.ts +6 -4
- package/runtime/sdk-runner/src/main.ts +21 -8
- package/runtime/sdk-runner/src/rules.ts +1 -0
- package/runtime/sdk-runner/src/runner.ts +18 -4
- package/runtime/sdk-runner/src/session-store.ts +19 -4
- package/runtime/sdk-runner/src/session-watch.ts +5 -1
- package/runtime/sdk-runner/src/task-ledger.ts +229 -0
- package/runtime/sdk-runner/src/task-recovery.ts +203 -0
- package/runtime/sdk-runner/src/task-state.ts +71 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
3
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
4
|
+
import { writeSecure } from '@metro-labs/core/secure-fs';
|
|
5
|
+
import {
|
|
6
|
+
DIRECTORY_LIMIT,
|
|
7
|
+
type ChannelEntry,
|
|
8
|
+
type ChannelSnapshot,
|
|
9
|
+
} from '@metro-labs/core/stations/channel-directory';
|
|
10
|
+
import { lineOf } from './accounts.js';
|
|
11
|
+
import type { TgChat, TgMsg, TgMyChatMember, TgUpdate } from './types.js';
|
|
12
|
+
|
|
13
|
+
type Metadata = Omit<ChannelEntry, 'line'>;
|
|
14
|
+
|
|
15
|
+
const kinds = new Set(['direct', 'group', 'channel', 'thread']);
|
|
16
|
+
const chatId = (id: number): boolean => Number.isSafeInteger(id) && id !== 0;
|
|
17
|
+
const cleanName = (name: string | undefined): string | undefined =>
|
|
18
|
+
name?.trim() ? Array.from(name.trim()).slice(0, 256).join('') : undefined;
|
|
19
|
+
|
|
20
|
+
function kindOf(chat: TgChat): Metadata['kind'] | undefined {
|
|
21
|
+
if (chat.type === 'private') return 'direct';
|
|
22
|
+
if (chat.type === 'group' || chat.type === 'supergroup') return 'group';
|
|
23
|
+
if (chat.type === 'channel') return 'channel';
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function metadataOf(raw: unknown): Metadata | undefined {
|
|
28
|
+
if (!isRecord(raw) || typeof raw.id !== 'string' || typeof raw.kind !== 'string') return undefined;
|
|
29
|
+
if (!/^-?[1-9]\d*(\/[1-9]\d*)?$/.test(raw.id) || !kinds.has(raw.kind)) return undefined;
|
|
30
|
+
const parts = raw.id.split('/').map(Number);
|
|
31
|
+
if (!parts.every(chatId) || (raw.kind === 'thread') !== (parts.length === 2)) return undefined;
|
|
32
|
+
const name = typeof raw.name === 'string' ? cleanName(raw.name) : undefined;
|
|
33
|
+
return { id: raw.id, kind: raw.kind as Metadata['kind'], ...(name ? { name } : {}) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function savedMetadata(account: string, file: string): unknown[] {
|
|
37
|
+
const raw: unknown = JSON.parse(readFileSync(file, 'utf8'));
|
|
38
|
+
if (!isRecord(raw) || raw.version !== 1 || raw.account !== account || !Array.isArray(raw.channels))
|
|
39
|
+
throw new Error('invalid observed chat metadata');
|
|
40
|
+
return raw.channels.slice(-DIRECTORY_LIMIT);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function topicIdOf(message: TgMsg): number | undefined {
|
|
44
|
+
if (!message.is_topic_message || message.chat.type !== 'supergroup' || !chatId(message.chat.id)) return undefined;
|
|
45
|
+
const id = message.message_thread_id;
|
|
46
|
+
return id !== undefined && Number.isSafeInteger(id) && id > 0 ? id : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function loaded(account: string, file: string | undefined): Map<string, Metadata> {
|
|
50
|
+
const entries = new Map<string, Metadata>();
|
|
51
|
+
if (file === undefined) return entries;
|
|
52
|
+
try {
|
|
53
|
+
for (const value of savedMetadata(account, file)) {
|
|
54
|
+
const entry = metadataOf(value);
|
|
55
|
+
if (entry) entries.set(entry.id, entry);
|
|
56
|
+
}
|
|
57
|
+
} catch (err) {
|
|
58
|
+
if (!isRecord(err) || err.code !== 'ENOENT')
|
|
59
|
+
log.warn({ account, err: errMsg(err) }, 'telegram-bot: could not load observed chats');
|
|
60
|
+
}
|
|
61
|
+
return entries;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class ObservedChats {
|
|
65
|
+
private readonly entries: Map<string, Metadata>;
|
|
66
|
+
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
67
|
+
|
|
68
|
+
constructor(private readonly account: string, private readonly file?: string) {
|
|
69
|
+
this.entries = loaded(account, file);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
snapshot(): ChannelSnapshot {
|
|
73
|
+
const channels = [...this.entries.values()].map((entry) => {
|
|
74
|
+
const [chat, topic] = entry.id.split('/');
|
|
75
|
+
return { ...entry, line: lineOf(this.account, chat ?? '', topic === undefined ? undefined : Number(topic)) };
|
|
76
|
+
});
|
|
77
|
+
return {
|
|
78
|
+
channels,
|
|
79
|
+
capability: {
|
|
80
|
+
supported: true,
|
|
81
|
+
complete: false,
|
|
82
|
+
source: 'local',
|
|
83
|
+
reason: `Telegram Bot API has no all-chats API. Only chat and topic metadata observed in updates on this box is available, up to ${DIRECTORY_LIMIT} entries; unobserved chats and missed updates are not included.`,
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
observe(update: TgUpdate): void {
|
|
89
|
+
for (const message of [update.message, update.channel_post])
|
|
90
|
+
if (message) this.message(message);
|
|
91
|
+
for (const event of [update.message_reaction, update.message_reaction_count])
|
|
92
|
+
if (event) this.chat(event.chat);
|
|
93
|
+
if (update.my_chat_member) this.membership(update.my_chat_member);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
flush(): void {
|
|
97
|
+
if (this.timer !== undefined) clearTimeout(this.timer);
|
|
98
|
+
this.timer = undefined;
|
|
99
|
+
if (this.file === undefined) return;
|
|
100
|
+
writeSecure(this.file, JSON.stringify({ version: 1, account: this.account, channels: [...this.entries.values()] }));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private changed(): void {
|
|
104
|
+
if (this.file === undefined || this.timer !== undefined) return;
|
|
105
|
+
this.timer = setTimeout(() => {
|
|
106
|
+
try {
|
|
107
|
+
this.flush();
|
|
108
|
+
} catch (err) {
|
|
109
|
+
log.warn({ account: this.account, err: errMsg(err) }, 'telegram-bot: could not save observed chats');
|
|
110
|
+
}
|
|
111
|
+
}, 2000);
|
|
112
|
+
this.timer.unref();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private note(entry: Metadata): void {
|
|
116
|
+
const previous = this.entries.get(entry.id);
|
|
117
|
+
const name = entry.name ?? previous?.name;
|
|
118
|
+
this.entries.delete(entry.id);
|
|
119
|
+
this.entries.set(entry.id, { id: entry.id, kind: entry.kind, ...(name ? { name } : {}) });
|
|
120
|
+
if (this.entries.size > DIRECTORY_LIMIT) {
|
|
121
|
+
const oldest = this.entries.keys().next().value;
|
|
122
|
+
if (oldest !== undefined) this.entries.delete(oldest);
|
|
123
|
+
}
|
|
124
|
+
if (!previous || previous.name !== name || previous.kind !== entry.kind) this.changed();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private remove(id: number): void {
|
|
128
|
+
for (const key of this.entries.keys()) {
|
|
129
|
+
if (key === String(id) || key.startsWith(`${id}/`)) {
|
|
130
|
+
this.entries.delete(key);
|
|
131
|
+
this.changed();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private chat(chat: TgChat): void {
|
|
137
|
+
const kind = kindOf(chat);
|
|
138
|
+
if (!chatId(chat.id) || kind === undefined) return;
|
|
139
|
+
const name = cleanName(kind === 'direct'
|
|
140
|
+
? [chat.first_name, chat.last_name].map((part) => part?.trim()).filter(Boolean).join(' ') || (chat.username ? `@${chat.username}` : undefined)
|
|
141
|
+
: chat.title);
|
|
142
|
+
this.note({ id: String(chat.id), kind, ...(name ? { name } : {}) });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private message(message: TgMsg): void {
|
|
146
|
+
if (message.migrate_to_chat_id !== undefined && chatId(message.migrate_to_chat_id)) {
|
|
147
|
+
this.remove(message.chat.id);
|
|
148
|
+
this.chat({ ...message.chat, id: message.migrate_to_chat_id, type: 'supergroup' });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (message.migrate_from_chat_id !== undefined && chatId(message.migrate_from_chat_id))
|
|
152
|
+
this.remove(message.migrate_from_chat_id);
|
|
153
|
+
this.chat(message.chat);
|
|
154
|
+
this.topic(message);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private topic(message: TgMsg): void {
|
|
158
|
+
const topic = topicIdOf(message);
|
|
159
|
+
if (topic === undefined) return;
|
|
160
|
+
const name = cleanName(message.forum_topic_edited?.name ?? message.forum_topic_created?.name);
|
|
161
|
+
this.note({ id: `${message.chat.id}/${topic}`, kind: 'thread', ...(name ? { name } : {}) });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private membership(update: TgMyChatMember): void {
|
|
165
|
+
const { status, is_member: isMember } = update.new_chat_member;
|
|
166
|
+
if (status === 'left' || status === 'kicked' || (status === 'restricted' && isMember === false))
|
|
167
|
+
this.remove(update.chat.id);
|
|
168
|
+
else if (status === 'creator' || status === 'administrator' || status === 'member'
|
|
169
|
+
|| (status === 'restricted' && isMember === true))
|
|
170
|
+
this.chat(update.chat);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import type { Station, Verb } from '@metro-labs/core/stations/types';
|
|
2
2
|
import { PROFILE_FIELDS, type ProfileField } from '@metro-labs/core/stations/profile';
|
|
3
|
+
import { channelFiles } from './channel-files.js';
|
|
3
4
|
|
|
4
5
|
export const telegramBotStation: Station = {
|
|
5
6
|
name: 'telegram-bot',
|
|
6
7
|
hasAccounts: true,
|
|
7
8
|
hasTrain: true,
|
|
9
|
+
discoversChannels: true,
|
|
10
|
+
forget: channelFiles.forget,
|
|
11
|
+
forgetExcept: channelFiles.forgetExcept,
|
|
8
12
|
messageVerbs: new Set<Verb>([
|
|
9
13
|
'send',
|
|
10
14
|
'reply',
|
|
@@ -13,18 +13,45 @@ export interface TgUser {
|
|
|
13
13
|
is_bot?: boolean;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
export interface TgChat {
|
|
17
|
+
id: number;
|
|
18
|
+
type: string;
|
|
19
|
+
title?: string;
|
|
20
|
+
first_name?: string;
|
|
21
|
+
last_name?: string;
|
|
22
|
+
username?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TgMyChatMember {
|
|
26
|
+
chat: TgChat;
|
|
27
|
+
new_chat_member: { status: string; is_member?: boolean };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface TgUpdate {
|
|
31
|
+
update_id: number;
|
|
32
|
+
message?: TgMsg;
|
|
33
|
+
channel_post?: TgMsg;
|
|
34
|
+
my_chat_member?: TgMyChatMember;
|
|
35
|
+
message_reaction?: TgReaction;
|
|
36
|
+
message_reaction_count?: TgReactionCount;
|
|
37
|
+
}
|
|
38
|
+
|
|
16
39
|
export interface TgMsg {
|
|
17
40
|
entities?: TgEntity[];
|
|
18
41
|
caption_entities?: TgEntity[];
|
|
19
42
|
reply_to_message?: { message_id: number; from?: { id: number; username?: string; is_bot?: boolean } };
|
|
20
43
|
message_id: number;
|
|
21
44
|
date: number;
|
|
22
|
-
chat:
|
|
45
|
+
chat: TgChat;
|
|
23
46
|
from?: TgUser;
|
|
24
47
|
text?: string;
|
|
25
48
|
caption?: string;
|
|
26
49
|
message_thread_id?: number;
|
|
27
50
|
is_topic_message?: boolean;
|
|
51
|
+
forum_topic_created?: { name: string };
|
|
52
|
+
forum_topic_edited?: { name?: string };
|
|
53
|
+
migrate_to_chat_id?: number;
|
|
54
|
+
migrate_from_chat_id?: number;
|
|
28
55
|
photo?: { file_id: string; file_size?: number }[];
|
|
29
56
|
document?: { file_name?: string; file_id?: string; mime_type?: string };
|
|
30
57
|
voice?: { file_id?: string; duration?: number; mime_type?: string };
|
|
@@ -37,7 +64,7 @@ export interface TgMsg {
|
|
|
37
64
|
}
|
|
38
65
|
|
|
39
66
|
export interface TgReaction {
|
|
40
|
-
chat:
|
|
67
|
+
chat: TgChat;
|
|
41
68
|
message_id: number;
|
|
42
69
|
user?: TgUser;
|
|
43
70
|
date: number;
|
|
@@ -46,7 +73,7 @@ export interface TgReaction {
|
|
|
46
73
|
}
|
|
47
74
|
|
|
48
75
|
export interface TgReactionCount {
|
|
49
|
-
chat:
|
|
76
|
+
chat: TgChat;
|
|
50
77
|
message_id: number;
|
|
51
78
|
date: number;
|
|
52
79
|
reactions: {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { emitInbound } from '@metro-labs/core/stations/train-events';
|
|
2
|
+
import { observeChannels } from './channels.js';
|
|
3
|
+
import {
|
|
4
|
+
envelope,
|
|
5
|
+
reactionCountEnvelope,
|
|
6
|
+
reactionEnvelope,
|
|
7
|
+
saveMediaAndEmit,
|
|
8
|
+
} from './format.js';
|
|
9
|
+
import type { TgUpdate } from './types.js';
|
|
10
|
+
|
|
11
|
+
export function handleUpdate(id: string, update: TgUpdate): void {
|
|
12
|
+
observeChannels(id, update);
|
|
13
|
+
if (update.message && !update.message.from?.is_bot) {
|
|
14
|
+
const env = envelope(id, update.message);
|
|
15
|
+
emitInbound(id, env);
|
|
16
|
+
saveMediaAndEmit(id, update.message, env.id as string);
|
|
17
|
+
}
|
|
18
|
+
if (update.message_reaction) {
|
|
19
|
+
const env = reactionEnvelope(id, update.message_reaction);
|
|
20
|
+
if (env) emitInbound(id, env);
|
|
21
|
+
}
|
|
22
|
+
if (update.message_reaction_count) {
|
|
23
|
+
const env = reactionCountEnvelope(id, update.message_reaction_count);
|
|
24
|
+
if (env) emitInbound(id, env);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
type Die,
|
|
5
5
|
} from '@metro-labs/core/stations/account-store';
|
|
6
6
|
import { Line } from '@metro-labs/core/lines';
|
|
7
|
+
import { ChannelDirectory } from '@metro-labs/core/stations/channel-directory';
|
|
8
|
+
import { DirectStore } from './chats.js';
|
|
7
9
|
import { fetchPublicKey } from './api.js';
|
|
8
10
|
import { hexToBytes, keyPairFrom, type KeyPair } from './crypto.js';
|
|
9
11
|
import { isGatewayId, normalizeThreemaId, parsePrivateKey } from './ids.js';
|
|
@@ -47,6 +49,8 @@ export interface Account {
|
|
|
47
49
|
publicKeys: Map<string, Uint8Array>;
|
|
48
50
|
requestedRosters: Set<string>;
|
|
49
51
|
groups: GroupStore;
|
|
52
|
+
chats: DirectStore;
|
|
53
|
+
directory: ChannelDirectory;
|
|
50
54
|
}
|
|
51
55
|
|
|
52
56
|
export type Target = { kind: 'user'; id: string } | { kind: 'group'; group: GroupRef };
|
|
@@ -56,7 +60,15 @@ export const accounts = new Map<string, Account>();
|
|
|
56
60
|
export function bootAccount(cfg: AccountConfig): Account {
|
|
57
61
|
const key = parsePrivateKey(cfg.privateKey);
|
|
58
62
|
if (key === null) throw new Error(`account '${cfg.id}' has no usable private key`);
|
|
59
|
-
return {
|
|
63
|
+
return {
|
|
64
|
+
cfg,
|
|
65
|
+
keys: keyPairFrom(key),
|
|
66
|
+
publicKeys: new Map(),
|
|
67
|
+
requestedRosters: new Set(),
|
|
68
|
+
groups: new GroupStore(cfg.id, cfg.gatewayId),
|
|
69
|
+
chats: new DirectStore(cfg.id),
|
|
70
|
+
directory: new ChannelDirectory(),
|
|
71
|
+
};
|
|
60
72
|
}
|
|
61
73
|
|
|
62
74
|
export function accountFor(id: string): Account {
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
import { messagingAliases } from '@metro-labs/core/stations/messaging-normalize';
|
|
8
8
|
import { accountFor, accounts, publicKeyFor, targetOf, type Account } from './accounts.js';
|
|
9
9
|
import { hexToBytes, macMatches, open } from './crypto.js';
|
|
10
|
+
import { listChannels } from './channels.js';
|
|
10
11
|
import { groupLineOf, type InboundMeta } from './format.js';
|
|
11
12
|
import { groupKey } from './groups.js';
|
|
12
13
|
import { deliver } from './inbound.js';
|
|
@@ -119,6 +120,11 @@ async function callback(id: string, args: Args): Promise<void> {
|
|
|
119
120
|
}
|
|
120
121
|
|
|
121
122
|
export const handleCall = makeStation({
|
|
122
|
-
handlers: {
|
|
123
|
+
handlers: {
|
|
124
|
+
accounts: listAccounts, send, react, callback, listMembers,
|
|
125
|
+
async listChannels(id, args) {
|
|
126
|
+
respond(id, { result: await listChannels(args) });
|
|
127
|
+
},
|
|
128
|
+
},
|
|
123
129
|
normalize: messagingAliases({ action: 'removed' }),
|
|
124
130
|
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { TrainError } from '@metro-labs/core/train-error';
|
|
2
|
+
import { DIRECTORY_LIMIT, type ChannelList, type ChannelSnapshot } from '@metro-labs/core/stations/channel-directory';
|
|
3
|
+
import { accountFor, type Account } from './accounts.js';
|
|
4
|
+
import { groupLineOf, lineOf } from './format.js';
|
|
5
|
+
import { groupKey, parseGroupKey } from './groups.js';
|
|
6
|
+
|
|
7
|
+
function snapshot(acct: Account): ChannelSnapshot {
|
|
8
|
+
return {
|
|
9
|
+
channels: [
|
|
10
|
+
...acct.groups.list().slice(0, DIRECTORY_LIMIT + 1).filter((group) => parseGroupKey(groupKey(group)) !== null).map((group) => ({
|
|
11
|
+
id: groupKey(group),
|
|
12
|
+
line: groupLineOf(acct.cfg.id, group),
|
|
13
|
+
kind: 'group' as const,
|
|
14
|
+
...(group.name ? { name: Array.from(group.name).slice(0, 256).join('') } : {}),
|
|
15
|
+
})),
|
|
16
|
+
...acct.chats.list().map((chat) => ({
|
|
17
|
+
id: chat.id,
|
|
18
|
+
line: lineOf(acct.cfg.id, chat.id),
|
|
19
|
+
kind: 'direct' as const,
|
|
20
|
+
...(chat.name ? { name: chat.name } : {}),
|
|
21
|
+
})),
|
|
22
|
+
],
|
|
23
|
+
capability: {
|
|
24
|
+
supported: true,
|
|
25
|
+
complete: false,
|
|
26
|
+
source: 'local',
|
|
27
|
+
reason: `Threema Gateway has no remote conversation-list or history endpoint. Only locally stored groups and up to ${DIRECTORY_LIMIT} observed direct chats are listed; conversations not observed by this Metro are unavailable.`,
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function listChannels(args: Record<string, unknown>): Promise<ChannelList> {
|
|
33
|
+
if (typeof args.account !== 'string' || !args.account.trim())
|
|
34
|
+
throw new TrainError('bad_request', 'listChannels requires an explicit Threema account');
|
|
35
|
+
const acct = accountFor(args.account);
|
|
36
|
+
return acct.directory.list(acct.cfg.id, args, () => Promise.resolve(snapshot(acct)));
|
|
37
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { log } from '@metro-labs/core/log';
|
|
5
|
+
import { accountFiles } from '@metro-labs/core/stations/account-files';
|
|
6
|
+
import { DIRECTORY_LIMIT } from '@metro-labs/core/stations/channel-directory';
|
|
7
|
+
import { isThreemaId, normalizeThreemaId } from './ids.js';
|
|
8
|
+
|
|
9
|
+
interface DirectChat {
|
|
10
|
+
id: string;
|
|
11
|
+
name?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const chatFiles = accountFiles('THREEMA_GROUPS_DIR', 'threema-chats-');
|
|
15
|
+
|
|
16
|
+
const nameOf = (name: unknown): string | undefined =>
|
|
17
|
+
typeof name === 'string' ? Array.from(name.trim()).slice(0, 256).join('') || undefined : undefined;
|
|
18
|
+
|
|
19
|
+
function directChat(entry: unknown): DirectChat | undefined {
|
|
20
|
+
if (!entry || typeof entry !== 'object' || !('id' in entry) || typeof entry.id !== 'string') return undefined;
|
|
21
|
+
const id = normalizeThreemaId(entry.id);
|
|
22
|
+
if (!isThreemaId(id)) return undefined;
|
|
23
|
+
const name = nameOf('name' in entry ? entry.name : undefined);
|
|
24
|
+
return { id, ...(name ? { name } : {}) };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readChats(path: string): Map<string, DirectChat> {
|
|
28
|
+
try {
|
|
29
|
+
if (statSync(path).size > DIRECTORY_LIMIT * 2048) throw new Error('Threema chat metadata is too large');
|
|
30
|
+
const raw: unknown = JSON.parse(readFileSync(path, 'utf8'));
|
|
31
|
+
const chats = new Map<string, DirectChat>();
|
|
32
|
+
if (!Array.isArray(raw)) throw new Error('Threema chat metadata must be an array');
|
|
33
|
+
for (const entry of raw.slice(-DIRECTORY_LIMIT)) {
|
|
34
|
+
const chat = directChat(entry);
|
|
35
|
+
if (chat) chats.set(chat.id, chat);
|
|
36
|
+
}
|
|
37
|
+
return chats;
|
|
38
|
+
} catch (err) {
|
|
39
|
+
if (!(err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT'))
|
|
40
|
+
log.warn({ err, path }, 'threema: could not load chat metadata');
|
|
41
|
+
return new Map();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class DirectStore {
|
|
46
|
+
private readonly path: string;
|
|
47
|
+
private readonly chats: Map<string, DirectChat>;
|
|
48
|
+
|
|
49
|
+
constructor(accountId: string) {
|
|
50
|
+
this.path = chatFiles.path(accountId);
|
|
51
|
+
this.chats = readChats(this.path);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
list(): DirectChat[] {
|
|
55
|
+
return [...this.chats.values()].map((chat) => ({ ...chat }));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
note(rawId: string, rawName?: string): void {
|
|
59
|
+
const id = normalizeThreemaId(rawId);
|
|
60
|
+
if (!isThreemaId(id)) return;
|
|
61
|
+
const previous = this.chats.get(id);
|
|
62
|
+
const name = nameOf(rawName) ?? previous?.name;
|
|
63
|
+
if (previous && name === previous.name) return;
|
|
64
|
+
this.chats.delete(id);
|
|
65
|
+
this.chats.set(id, { id, ...(name ? { name } : {}) });
|
|
66
|
+
while (this.chats.size > DIRECTORY_LIMIT) {
|
|
67
|
+
const oldest = this.chats.keys().next();
|
|
68
|
+
if (oldest.done) break;
|
|
69
|
+
this.chats.delete(oldest.value);
|
|
70
|
+
}
|
|
71
|
+
this.save();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private save(): void {
|
|
75
|
+
try {
|
|
76
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
77
|
+
const tmp = `${this.path}.${randomUUID()}.tmp`;
|
|
78
|
+
try {
|
|
79
|
+
writeFileSync(tmp, JSON.stringify(this.list()), { mode: 0o600, flag: 'wx' });
|
|
80
|
+
renameSync(tmp, this.path);
|
|
81
|
+
} finally {
|
|
82
|
+
rmSync(tmp, { force: true });
|
|
83
|
+
}
|
|
84
|
+
} catch (err) {
|
|
85
|
+
log.warn({ err, path: this.path }, 'threema: could not save chat metadata');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -76,7 +76,12 @@ function chat(acct: Account, m: InboundMeta, d: Decoded): string | null {
|
|
|
76
76
|
return null;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
function isDirectChat(d: Decoded): boolean {
|
|
80
|
+
return d.kind === 'text' || ((d.kind === 'file' || d.kind === 'reaction' || d.kind === 'receipt') && d.group === null);
|
|
81
|
+
}
|
|
82
|
+
|
|
79
83
|
export function deliver(acct: Account, m: InboundMeta, d: Decoded): string {
|
|
84
|
+
if (isDirectChat(d)) acct.chats.note(m.from, m.nickname);
|
|
80
85
|
const handled = control(acct, m, d) ?? chat(acct, m, d);
|
|
81
86
|
if (handled !== null) return handled;
|
|
82
87
|
if (d.kind === 'receipt') return reactions(acct, m, d);
|
|
@@ -37,6 +37,7 @@ async function deliver(acct: Account, to: string, plain: Uint8Array, group: bool
|
|
|
37
37
|
const { nonce, box } = seal(plain, await publicKeyFor(acct, to), acct.keys);
|
|
38
38
|
const messageId = await sendE2E(acct.cfg, to, bytesToHex(nonce), bytesToHex(box), group);
|
|
39
39
|
noteSent(messageId);
|
|
40
|
+
if (!group) acct.chats.note(to);
|
|
40
41
|
return messageId;
|
|
41
42
|
}
|
|
42
43
|
|
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
import type { Station, Verb } from '@metro-labs/core/stations/types';
|
|
2
2
|
import { groupFiles } from './groups.js';
|
|
3
|
+
import { chatFiles } from './chats.js';
|
|
3
4
|
|
|
4
5
|
export const threemaStation: Station = {
|
|
5
6
|
name: 'threema',
|
|
6
7
|
hasAccounts: true,
|
|
7
8
|
hasTrain: true,
|
|
9
|
+
discoversChannels: true,
|
|
8
10
|
messageVerbs: new Set<Verb>(['send', 'reply', 'react', 'unreact']),
|
|
9
11
|
attachmentMode: 'canonical',
|
|
10
|
-
forget
|
|
11
|
-
|
|
12
|
+
forget(accountId) {
|
|
13
|
+
groupFiles.forget(accountId);
|
|
14
|
+
chatFiles.forget(accountId);
|
|
15
|
+
},
|
|
16
|
+
forgetExcept(accountIds) {
|
|
17
|
+
groupFiles.forgetExcept(accountIds);
|
|
18
|
+
chatFiles.forgetExcept(accountIds);
|
|
19
|
+
},
|
|
12
20
|
tools: [],
|
|
13
21
|
};
|
|
@@ -138,6 +138,16 @@ function makeRead(clientFor: ClientFor): StationHandler {
|
|
|
138
138
|
};
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
function makeListChannels(clientFor: ClientFor): StationHandler {
|
|
142
|
+
return async (id, args) => {
|
|
143
|
+
const account = str(args.account);
|
|
144
|
+
if (!account) throw new TrainError('bad_request', 'listChannels requires an account');
|
|
145
|
+
const accountId = accountFor({ account });
|
|
146
|
+
const result = await guard(() => clientFor(accountId).listChannels(args));
|
|
147
|
+
respond(id, { result });
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
141
151
|
function makeListMembers(clientFor: ClientFor): StationHandler {
|
|
142
152
|
return async (id, args) => {
|
|
143
153
|
const { client, jid } = resolve({ line: args.line }, clientFor);
|
|
@@ -239,6 +249,7 @@ export function makeHandleCall(
|
|
|
239
249
|
send: makeSend(clientFor),
|
|
240
250
|
read: makeRead(clientFor),
|
|
241
251
|
listMembers: makeListMembers(clientFor),
|
|
252
|
+
listChannels: makeListChannels(clientFor),
|
|
242
253
|
react: makeReact(clientFor),
|
|
243
254
|
typing: makeTyping(clientFor),
|
|
244
255
|
edit: makeEdit(clientFor),
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
3
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
4
|
+
import { writeSecure } from '@metro-labs/core/secure-fs';
|
|
5
|
+
import { accountFiles } from '@metro-labs/core/stations/account-files';
|
|
6
|
+
import { DIRECTORY_LIMIT, type ChannelEntry } from '@metro-labs/core/stations/channel-directory';
|
|
7
|
+
import { lineOf } from './accounts.js';
|
|
8
|
+
|
|
9
|
+
export const channelFiles = accountFiles('WHATSAPP_TOKEN_DIR', 'whatsapp-channels-');
|
|
10
|
+
const FILE_BYTES = 16 * 1024 * 1024;
|
|
11
|
+
const NAME_LIMIT = 256;
|
|
12
|
+
|
|
13
|
+
interface ChatMetadata {
|
|
14
|
+
id?: string | null;
|
|
15
|
+
name?: string | null;
|
|
16
|
+
conversationTimestamp?: number | { toNumber(): number } | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface StoredChat {
|
|
20
|
+
id: string;
|
|
21
|
+
name?: string;
|
|
22
|
+
deleted?: true;
|
|
23
|
+
deletedAt?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function directId(value: unknown): string | undefined {
|
|
27
|
+
if (typeof value !== 'string' || value.length > 128 || !/^\d+(?::\d+)?@(s\.whatsapp\.net|lid)$/.test(value)) return undefined;
|
|
28
|
+
return value.replace(/:\d+@/, '@');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function channelName(value: unknown): string | undefined {
|
|
32
|
+
return typeof value === 'string' ? value.trim().slice(0, NAME_LIMIT) || undefined : undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function deletionTime(value: unknown): number {
|
|
36
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : Date.now();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function newerActivity(chat: ChatMetadata, deletedAt: number): boolean {
|
|
40
|
+
const value = chat.conversationTimestamp;
|
|
41
|
+
const seconds = typeof value === 'number' ? value : value?.toNumber();
|
|
42
|
+
if (seconds === undefined || !Number.isSafeInteger(seconds)) return false;
|
|
43
|
+
const at = seconds * 1000;
|
|
44
|
+
return at > deletedAt && at <= Date.now();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function blocked(chat: ChatMetadata, prior: StoredChat | undefined, fresh: boolean): boolean {
|
|
48
|
+
return prior?.deleted === true && (!fresh || !newerActivity(chat, deletionTime(prior.deletedAt)));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function storedChat(row: unknown): StoredChat | undefined {
|
|
52
|
+
if (!isRecord(row)) return undefined;
|
|
53
|
+
const id = directId(row.id);
|
|
54
|
+
if (!id) return undefined;
|
|
55
|
+
return row.deleted === true
|
|
56
|
+
? { id, deleted: true, deletedAt: deletionTime(row.deletedAt) }
|
|
57
|
+
: { id, name: channelName(row.name) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class KnownChats {
|
|
61
|
+
private readonly chats = new Map<string, StoredChat>();
|
|
62
|
+
private readonly file: string;
|
|
63
|
+
|
|
64
|
+
constructor(private readonly accountId: string) {
|
|
65
|
+
this.file = channelFiles.path(accountId);
|
|
66
|
+
this.load();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private load(): void {
|
|
70
|
+
try {
|
|
71
|
+
if (statSync(this.file).size > FILE_BYTES) throw new Error('channel metadata file is too large');
|
|
72
|
+
const raw: unknown = JSON.parse(readFileSync(this.file, 'utf8'));
|
|
73
|
+
if (!Array.isArray(raw)) throw new Error('invalid channel metadata');
|
|
74
|
+
for (const row of raw.slice(-DIRECTORY_LIMIT)) {
|
|
75
|
+
const chat = storedChat(row);
|
|
76
|
+
if (chat) this.put(chat);
|
|
77
|
+
}
|
|
78
|
+
} catch (err) {
|
|
79
|
+
if (isRecord(err) && err.code === 'ENOENT') return;
|
|
80
|
+
log.warn({ accountId: this.accountId, err: errMsg(err) }, 'whatsapp: could not load channel metadata');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private put(row: StoredChat): void {
|
|
85
|
+
this.chats.delete(row.id);
|
|
86
|
+
this.chats.set(row.id, row);
|
|
87
|
+
if (this.chats.size > DIRECTORY_LIMIT) {
|
|
88
|
+
const oldest = this.chats.keys().next().value;
|
|
89
|
+
if (oldest !== undefined) this.chats.delete(oldest);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private save(): void {
|
|
94
|
+
try {
|
|
95
|
+
writeSecure(this.file, JSON.stringify([...this.chats.values()]));
|
|
96
|
+
} catch (err) {
|
|
97
|
+
log.warn({ accountId: this.accountId, err: errMsg(err) }, 'whatsapp: could not save channel metadata');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private noteChat(chat: ChatMetadata, fresh: boolean): boolean {
|
|
102
|
+
const id = directId(chat.id);
|
|
103
|
+
if (!id) return false;
|
|
104
|
+
const prior = this.chats.get(id);
|
|
105
|
+
if (blocked(chat, prior, fresh)) return false;
|
|
106
|
+
const name = channelName(chat.name) ?? prior?.name;
|
|
107
|
+
if (prior && !prior.deleted && prior.name === name) return false;
|
|
108
|
+
this.put({ id, name });
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
note(chats: readonly ChatMetadata[], fresh = false): void {
|
|
113
|
+
let changed = false;
|
|
114
|
+
for (const chat of chats) {
|
|
115
|
+
if (this.noteChat(chat, fresh)) changed = true;
|
|
116
|
+
}
|
|
117
|
+
if (changed) this.save();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
remove(ids: readonly string[]): void {
|
|
121
|
+
let changed = false;
|
|
122
|
+
for (const value of ids) {
|
|
123
|
+
const id = directId(value);
|
|
124
|
+
if (!id) continue;
|
|
125
|
+
const deletedAt = Math.max(Date.now(), this.chats.get(id)?.deletedAt ?? 0);
|
|
126
|
+
this.put({ id, deleted: true, deletedAt });
|
|
127
|
+
changed = true;
|
|
128
|
+
}
|
|
129
|
+
if (changed) this.save();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
list(): ChannelEntry[] {
|
|
133
|
+
return [...this.chats.values()].filter((chat) => !chat.deleted).map(({ id, name }) => ({
|
|
134
|
+
id, line: lineOf(this.accountId, id), kind: 'direct', ...(name ? { name } : {}),
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
}
|