@stage-labs/metro 0.1.0-beta.263 → 0.1.0-beta.264
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/runtime-install.js +22 -5
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/marketplace/plugin/bin/policy.mjs +1 -1
- package/runtime/node_modules/@metro-labs/core/src/stations/train-boot.ts +22 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/stations/runtime-deps.ts +23 -7
- package/runtime/node_modules/@metro-labs/whatsapp/src/actions.ts +32 -1
- package/runtime/node_modules/@metro-labs/whatsapp/src/app-state.ts +49 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/client-history.ts +31 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/client-types.ts +46 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/client.ts +75 -77
- package/runtime/node_modules/@metro-labs/whatsapp/src/history-events.ts +127 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/history-files.ts +39 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/history-identity.ts +58 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/history-message.ts +161 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/history-state.ts +344 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/history-types.ts +91 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/history.ts +195 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/index.ts +1 -1
- package/runtime/node_modules/@metro-labs/whatsapp/src/members.ts +94 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/messages.ts +70 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/send-expiry.ts +65 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/station.ts +4 -0
- package/runtime/patches/5bca2c07d5852d3f58453cff0448dd85da13d3665b61e3d758905b298d432d7c.patch +31 -0
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/train-boot.ts +22 -1
- package/runtime/sdk-runner/runner.json +1 -1
- package/runtime/stations.json +3 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { getBinaryNodeChild, type BinaryNode, type GroupParticipant } from 'baileys';
|
|
2
|
+
import { extractGroupMetadata } from 'baileys/lib/Socket/groups.js';
|
|
3
|
+
import { TrainError } from '@metro-labs/core/train-error';
|
|
4
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
5
|
+
import type { MemberCapability, MemberList, MetroMember } from '@metro-labs/core/stations/types';
|
|
6
|
+
import { isGroupJid } from './parse.js';
|
|
7
|
+
import { phoneOf, type NameBook } from './names.js';
|
|
8
|
+
|
|
9
|
+
interface WhatsAppMember extends MetroMember {
|
|
10
|
+
lid?: string;
|
|
11
|
+
phoneNumber?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function memberName(p: GroupParticipant, names: NameBook): string | undefined {
|
|
15
|
+
return names.get(p.id) ?? p.name ?? p.notify
|
|
16
|
+
?? (p.lid ? names.get(p.lid) : undefined)
|
|
17
|
+
?? (p.phoneNumber ? names.get(p.phoneNumber) : undefined);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function memberRole(p: GroupParticipant): string | undefined {
|
|
21
|
+
return p.admin ?? (p.isSuperAdmin ? 'superadmin' : p.isAdmin ? 'admin' : undefined);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function memberOf(p: GroupParticipant, names: NameBook): WhatsAppMember {
|
|
25
|
+
const lid = p.id.endsWith('@lid') ? p.id : p.lid;
|
|
26
|
+
const phoneNumber = p.id.endsWith('@s.whatsapp.net') ? p.id : p.phoneNumber;
|
|
27
|
+
const displayName = memberName(p, names);
|
|
28
|
+
const address = phoneOf(phoneNumber);
|
|
29
|
+
const role = memberRole(p);
|
|
30
|
+
return {
|
|
31
|
+
id: p.id,
|
|
32
|
+
...(displayName ? { display_name: displayName } : {}),
|
|
33
|
+
...(lid ? { lid } : {}),
|
|
34
|
+
...(phoneNumber ? { phoneNumber } : {}),
|
|
35
|
+
...(address ? { address } : {}),
|
|
36
|
+
roles: role ? [role] : [],
|
|
37
|
+
is_admin: role === 'admin' || role === 'superadmin',
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function memberLimit(limit: number | undefined): number {
|
|
42
|
+
if (limit === undefined) return 200;
|
|
43
|
+
if (!Number.isFinite(limit) || limit < 1 || !Number.isInteger(limit))
|
|
44
|
+
throw new TrainError('bad_request', 'limit must be a positive integer');
|
|
45
|
+
return Math.min(limit, 1000);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function binaryNode(value: unknown): value is BinaryNode {
|
|
49
|
+
if (!isRecord(value) || typeof value.tag !== 'string' || !isRecord(value.attrs)) return false;
|
|
50
|
+
if (!Object.values(value.attrs).every((attr) => typeof attr === 'string')) return false;
|
|
51
|
+
const content = value.content;
|
|
52
|
+
return content === undefined || typeof content === 'string' || content instanceof Uint8Array
|
|
53
|
+
|| (Array.isArray(content) && content.every(binaryNode));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function memberTotal(response: BinaryNode): number | undefined {
|
|
57
|
+
const value = getBinaryNodeChild(response, 'group')?.attrs.size;
|
|
58
|
+
if (!value || !/^\d+$/.test(value)) return undefined;
|
|
59
|
+
const size = Number(value);
|
|
60
|
+
return Number.isSafeInteger(size) ? size : undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function capability(total: number | undefined, fetched: number, returned: number): MemberCapability {
|
|
64
|
+
const complete = total !== undefined && total === fetched && returned === total;
|
|
65
|
+
const reason = returned < fetched ? 'Member list was limited; increase limit up to 1000'
|
|
66
|
+
: total === undefined ? 'WhatsApp did not report a total member count'
|
|
67
|
+
: total !== fetched ? 'WhatsApp returned a partial member list' : undefined;
|
|
68
|
+
return { supported: true, complete, ...(total === undefined ? {} : { total }), ...(reason ? { reason } : {}) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function listMembers(
|
|
72
|
+
jid: string,
|
|
73
|
+
query: (node: BinaryNode) => Promise<unknown>,
|
|
74
|
+
names: NameBook,
|
|
75
|
+
limit?: number,
|
|
76
|
+
): Promise<MemberList> {
|
|
77
|
+
const cap = memberLimit(limit);
|
|
78
|
+
if (!isGroupJid(jid)) return {
|
|
79
|
+
members: [],
|
|
80
|
+
capability: { supported: false, complete: false, reason: 'WhatsApp member lookup requires a group line (@g.us)' },
|
|
81
|
+
};
|
|
82
|
+
const response = await query({
|
|
83
|
+
tag: 'iq', attrs: { type: 'get', xmlns: 'w:g2', to: jid },
|
|
84
|
+
content: [{ tag: 'query', attrs: { request: 'interactive' } }],
|
|
85
|
+
});
|
|
86
|
+
if (!binaryNode(response)) throw new TrainError('whatsapp_call', 'WhatsApp returned invalid group metadata');
|
|
87
|
+
const group = extractGroupMetadata(response);
|
|
88
|
+
const participants = [...new Map(group.participants.map((p) => [p.id, p])).values()];
|
|
89
|
+
const members = participants.slice(0, cap).map((p) => memberOf(p, names));
|
|
90
|
+
return {
|
|
91
|
+
members,
|
|
92
|
+
capability: capability(memberTotal(response), participants.length, members.length),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { jidNormalizedUser, type Contact, type WASocket } from 'baileys';
|
|
2
|
+
import { bindAppState, noteHistoryContact } from './app-state.js';
|
|
3
|
+
import type { InboundHandlers } from './client-types.js';
|
|
4
|
+
import type { History } from './history.js';
|
|
5
|
+
import type { KeyCache } from './keys.js';
|
|
6
|
+
import { noteContact, type NameBook } from './names.js';
|
|
7
|
+
import { toInbound, toReaction, type SelfRef } from './parse.js';
|
|
8
|
+
|
|
9
|
+
interface MessageHooks {
|
|
10
|
+
accountId: string;
|
|
11
|
+
keys: KeyCache;
|
|
12
|
+
names: NameBook;
|
|
13
|
+
history: History;
|
|
14
|
+
current(): boolean;
|
|
15
|
+
handlers(): InboundHandlers | undefined;
|
|
16
|
+
self(): SelfRef;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function bindMessages(sock: Pick<WASocket, 'ev' | 'user'>, hooks: MessageHooks): void {
|
|
20
|
+
const selfJid = (): string | undefined => sock.user?.id ? jidNormalizedUser(sock.user.id) : undefined;
|
|
21
|
+
const noteContacts = (contacts: Partial<Contact>[]): void => {
|
|
22
|
+
if (!hooks.current()) return;
|
|
23
|
+
for (const contact of contacts) {
|
|
24
|
+
noteContact(hooks.names, contact);
|
|
25
|
+
noteHistoryContact(hooks.history, contact);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
bindAppState(sock.ev, hooks);
|
|
29
|
+
sock.ev.on('messages.upsert', ({ messages, type }) => {
|
|
30
|
+
if (!hooks.current()) return;
|
|
31
|
+
for (const m of messages) hooks.keys.remember(m.key);
|
|
32
|
+
hooks.history.ingest(messages, selfJid());
|
|
33
|
+
const handlers = hooks.handlers();
|
|
34
|
+
if (type !== 'notify' || !handlers) return;
|
|
35
|
+
for (const m of messages) {
|
|
36
|
+
if (m.key.fromMe) continue;
|
|
37
|
+
const inbound = toInbound(hooks.accountId, m, hooks.self());
|
|
38
|
+
if (!inbound) continue;
|
|
39
|
+
hooks.names.note(inbound.senderJid, inbound.pushName);
|
|
40
|
+
handlers.onMessage(inbound, m);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
sock.ev.on('contacts.upsert', noteContacts);
|
|
44
|
+
sock.ev.on('contacts.update', noteContacts);
|
|
45
|
+
sock.ev.on('messaging-history.set', ({ contacts, messages, lidPnMappings }) => {
|
|
46
|
+
if (!hooks.current()) return;
|
|
47
|
+
noteContacts(contacts);
|
|
48
|
+
for (const { pn, lid } of lidPnMappings ?? []) hooks.history.alias(pn, lid);
|
|
49
|
+
for (const m of messages) {
|
|
50
|
+
hooks.keys.remember(m.key);
|
|
51
|
+
if (m.key.fromMe !== true) hooks.names.note(m.key.participant ?? m.key.remoteJid, m.pushName);
|
|
52
|
+
}
|
|
53
|
+
hooks.history.ingest(messages, selfJid());
|
|
54
|
+
});
|
|
55
|
+
sock.ev.on('messages.update', (updates) => {
|
|
56
|
+
if (hooks.current()) hooks.history.update(updates, selfJid());
|
|
57
|
+
});
|
|
58
|
+
sock.ev.on('messages.delete', (event) => {
|
|
59
|
+
if (hooks.current()) hooks.history.deleteMessages(event);
|
|
60
|
+
});
|
|
61
|
+
sock.ev.on('messages.reaction', (events) => {
|
|
62
|
+
if (!hooks.current()) return;
|
|
63
|
+
const handlers = hooks.handlers();
|
|
64
|
+
if (!handlers) return;
|
|
65
|
+
for (const event of events) {
|
|
66
|
+
const reaction = toReaction(hooks.accountId, event);
|
|
67
|
+
if (reaction) handlers.onReaction(reaction);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ChatUpdate, WASocket } from 'baileys';
|
|
2
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
|
+
import { isGroupJid, isPrivateJid } from './parse.js';
|
|
4
|
+
|
|
5
|
+
const MAX_CHATS = 2000;
|
|
6
|
+
type Content = Parameters<WASocket['sendMessage']>[1];
|
|
7
|
+
interface Setting { duration: number; at: number }
|
|
8
|
+
|
|
9
|
+
function duration(value: number | undefined): number | undefined {
|
|
10
|
+
return value !== undefined && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function setting(chat: ChatUpdate): Setting | undefined {
|
|
14
|
+
if (!Object.hasOwn(chat, 'ephemeralExpiration')) return undefined;
|
|
15
|
+
const seconds = duration(chat.ephemeralExpiration === null ? 0 : chat.ephemeralExpiration);
|
|
16
|
+
const timestamp = chat.ephemeralSettingTimestamp;
|
|
17
|
+
const at = typeof timestamp === 'number' ? timestamp : timestamp?.toNumber();
|
|
18
|
+
if (seconds === undefined || at === undefined || !Number.isSafeInteger(at) || at <= 0) return undefined;
|
|
19
|
+
return { duration: seconds, at };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class SendExpiry {
|
|
23
|
+
private readonly chats = new Map<string, Setting>();
|
|
24
|
+
|
|
25
|
+
constructor(private readonly accountId: string) {}
|
|
26
|
+
|
|
27
|
+
private note(chat: ChatUpdate): void {
|
|
28
|
+
if (!chat.id || !isPrivateJid(chat.id)) return;
|
|
29
|
+
const next = setting(chat);
|
|
30
|
+
if (!next) return;
|
|
31
|
+
const known = this.chats.get(chat.id);
|
|
32
|
+
if (known && known.at > next.at) return;
|
|
33
|
+
this.chats.delete(chat.id);
|
|
34
|
+
this.chats.set(chat.id, next);
|
|
35
|
+
if (this.chats.size > MAX_CHATS) {
|
|
36
|
+
const oldest = this.chats.keys().next().value;
|
|
37
|
+
if (oldest !== undefined) this.chats.delete(oldest);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
bind(sock: WASocket, current: () => boolean): void {
|
|
42
|
+
this.chats.clear();
|
|
43
|
+
const note = (chats: readonly ChatUpdate[]): void => {
|
|
44
|
+
if (current()) for (const chat of chats) this.note(chat);
|
|
45
|
+
};
|
|
46
|
+
sock.ev.on('chats.upsert', note);
|
|
47
|
+
sock.ev.on('chats.update', note);
|
|
48
|
+
sock.ev.on('messaging-history.set', ({ chats }) => { note(chats); });
|
|
49
|
+
sock.ev.on('chats.delete', (jids) => {
|
|
50
|
+
if (current()) for (const jid of jids) this.chats.delete(jid);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async forSend(sock: WASocket, jid: string, content: Content): Promise<number | undefined> {
|
|
55
|
+
if ('edit' in content || 'delete' in content || 'react' in content) return undefined;
|
|
56
|
+
if (!isGroupJid(jid)) return this.chats.get(jid)?.duration;
|
|
57
|
+
try {
|
|
58
|
+
const group = await sock.groupMetadata(jid);
|
|
59
|
+
return duration(group.ephemeralDuration ?? 0);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
log.warn({ accountId: this.accountId, err: errMsg(err) }, 'whatsapp: send expiry unknown; local history may omit the message');
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -2,12 +2,14 @@ import type { Station, Verb } from '@metro-labs/core/stations/types';
|
|
|
2
2
|
import { PROFILE_FIELDS, type ProfileField } from '@metro-labs/core/stations/profile';
|
|
3
3
|
import { tokenFiles } from './token-store.js';
|
|
4
4
|
import { nameFiles } from './names.js';
|
|
5
|
+
import { historyFiles } from './history-state.js';
|
|
5
6
|
|
|
6
7
|
export const whatsappStation: Station = {
|
|
7
8
|
name: 'whatsapp',
|
|
8
9
|
hasAccounts: true,
|
|
9
10
|
hasTrain: true,
|
|
10
11
|
messageVerbs: new Set<Verb>([
|
|
12
|
+
'read',
|
|
11
13
|
'send',
|
|
12
14
|
'reply',
|
|
13
15
|
'react',
|
|
@@ -23,10 +25,12 @@ export const whatsappStation: Station = {
|
|
|
23
25
|
forget: (accountId) => {
|
|
24
26
|
tokenFiles.forget(accountId);
|
|
25
27
|
nameFiles.forget(accountId);
|
|
28
|
+
historyFiles.forget(accountId);
|
|
26
29
|
},
|
|
27
30
|
forgetExcept: (kept) => {
|
|
28
31
|
tokenFiles.forgetExcept(kept);
|
|
29
32
|
nameFiles.forgetExcept(kept);
|
|
33
|
+
historyFiles.forgetExcept(kept);
|
|
30
34
|
},
|
|
31
35
|
tools: [],
|
|
32
36
|
};
|
package/runtime/patches/5bca2c07d5852d3f58453cff0448dd85da13d3665b61e3d758905b298d432d7c.patch
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
diff --git a/lib/Types/Events.d.ts b/lib/Types/Events.d.ts
|
|
2
|
+
index f55c4c325b08b8c25dc9d17032514ce5c80ad3fd..930cb143e813b9a656533ae765f9db70f6eb7ccd 100644
|
|
3
|
+
--- a/lib/Types/Events.d.ts
|
|
4
|
+
+++ b/lib/Types/Events.d.ts
|
|
5
|
+
@@ -46,6 +46,10 @@ export type BaileysEventMap = {
|
|
6
|
+
'lid-mapping.update': LIDMapping;
|
|
7
|
+
/** delete chats with given ID */
|
|
8
|
+
'chats.delete': string[];
|
|
9
|
+
+ 'chats.clear': {
|
|
10
|
+
+ id: string;
|
|
11
|
+
+ messageRange?: proto.SyncActionValue.ISyncActionMessageRange | null;
|
|
12
|
+
+ };
|
|
13
|
+
/** presence of contact in a chat updated */
|
|
14
|
+
'presence.update': {
|
|
15
|
+
id: string;
|
|
16
|
+
diff --git a/lib/Utils/chat-utils.js b/lib/Utils/chat-utils.js
|
|
17
|
+
index c30265d36671bacd25a8c456cafedcd9fc2fdd9a..2be37df069eda49c546eac1eb3485e649432dbeb 100644
|
|
18
|
+
--- a/lib/Utils/chat-utils.js
|
|
19
|
+
+++ b/lib/Utils/chat-utils.js
|
|
20
|
+
@@ -763,7 +763,11 @@ export const processSyncAction = (syncAction, ev, me, initialSyncOpts, logger) =
|
|
21
|
+
}
|
|
22
|
+
]);
|
|
23
|
+
}
|
|
24
|
+
+ else if (action?.clearChatAction || type === 'clearChat') {
|
|
25
|
+
+ ev.emit('chats.clear', { id, messageRange: action?.clearChatAction?.messageRange });
|
|
26
|
+
+ }
|
|
27
|
+
else if (action?.deleteChatAction || type === 'deleteChat') {
|
|
28
|
+
+ ev.emit('chats.clear', { id, messageRange: action?.deleteChatAction?.messageRange });
|
|
29
|
+
if (!isInitialSync) {
|
|
30
|
+
ev.emit('chats.delete', [id]);
|
|
31
|
+
}
|
package/runtime/runtime.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { errMsg } from '../log.js';
|
|
1
|
+
import { errMsg, logFatalSync } from '../log.js';
|
|
2
2
|
import { readCalls } from '../protocol.js';
|
|
3
3
|
import { TrainError } from '../train-error.js';
|
|
4
4
|
import type { CallMsg } from './station-runtime.js';
|
|
@@ -18,12 +18,33 @@ export interface ClientTrain<A extends { id: string }, C> {
|
|
|
18
18
|
loadAccounts: () => A[];
|
|
19
19
|
createClient: (account: A) => C;
|
|
20
20
|
startInbound: (client: C) => Promise<void>;
|
|
21
|
+
disconnectClient?: (client: C) => Promise<void>;
|
|
21
22
|
makeHandleCall: (clientFor: (accountId: string) => C) => (msg: CallMsg) => Promise<void>;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
export function runClientTrain<A extends { id: string }, C>(train: ClientTrain<A, C>): void {
|
|
25
26
|
const clients = new Map<string, C>();
|
|
27
|
+
let stopping = false;
|
|
28
|
+
if (train.disconnectClient) {
|
|
29
|
+
const disconnect = train.disconnectClient;
|
|
30
|
+
const shutdown = (): void => {
|
|
31
|
+
if (stopping) return;
|
|
32
|
+
stopping = true;
|
|
33
|
+
process.stdin.pause();
|
|
34
|
+
Promise.allSettled([...clients.values()].map(async (client) => { await disconnect(client); })).then((results) => {
|
|
35
|
+
const errors = results.flatMap((result) => result.status === 'rejected' ? [errMsg(result.reason)] : []);
|
|
36
|
+
if (errors.length) logFatalSync({ station: train.station, errors }, 'train: shutdown failed');
|
|
37
|
+
process.exit(errors.length ? 1 : 0);
|
|
38
|
+
}).catch((err: unknown) => {
|
|
39
|
+
logFatalSync({ station: train.station, err: errMsg(err) }, 'train: shutdown failed');
|
|
40
|
+
process.exit(1);
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
process.on('SIGTERM', shutdown);
|
|
44
|
+
process.on('SIGINT', shutdown);
|
|
45
|
+
}
|
|
26
46
|
const clientFor = (accountId: string): C => {
|
|
47
|
+
if (stopping) throw new TrainError('train_stopping', `${train.station} is shutting down`);
|
|
27
48
|
const known = clients.get(accountId);
|
|
28
49
|
if (known !== undefined) return known;
|
|
29
50
|
const account = train.accounts.get(accountId);
|