@stage-labs/metro 0.1.0-beta.262 → 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/events.ts +45 -1
- package/runtime/node_modules/@metro-labs/core/src/stations/train-boot.ts +22 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/map.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/monitor/run-event.ts +139 -0
- package/runtime/node_modules/@metro-labs/daemon/src/monitor/run-events.ts +101 -0
- package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +2 -0
- 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/events.ts +45 -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,195 @@
|
|
|
1
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
2
|
+
import { TrainError } from '@metro-labs/core/train-error';
|
|
3
|
+
import type { proto, WAMessage, WAMessageUpdate } from 'baileys';
|
|
4
|
+
import { HistoryEvents } from './history-events.js';
|
|
5
|
+
import { HistoryState } from './history-state.js';
|
|
6
|
+
import {
|
|
7
|
+
HISTORY_LIMITS, HISTORY_SAVE_MS, HISTORY_SWEEP_MS, newestFirst,
|
|
8
|
+
type History, type HistoryDelete, type HistoryOptions, type HistoryPage, type HistoryRow, type StoredRow,
|
|
9
|
+
} from './history-types.js';
|
|
10
|
+
|
|
11
|
+
export { historyFiles } from './history-state.js';
|
|
12
|
+
export type { History, HistoryOptions, HistoryPage, HistoryRow } from './history-types.js';
|
|
13
|
+
|
|
14
|
+
function shown(row: StoredRow): HistoryRow {
|
|
15
|
+
return {
|
|
16
|
+
key: { ...row.key }, messageId: row.key.id ?? '', chatJid: row.key.remoteJid ?? '',
|
|
17
|
+
fromMe: row.key.fromMe === true, timestamp: new Date(row.at).toISOString(), text: row.text,
|
|
18
|
+
...(row.senderJid ? { senderJid: row.senderJid } : {}),
|
|
19
|
+
...(row.truncated ? { truncated: true } : {}),
|
|
20
|
+
...(row.attachments ? { attachments: row.attachments.map((a) => ({ ...a })) } : {}),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function readBounds(options: HistoryOptions): { limit: number; since: number } {
|
|
25
|
+
const limit = options.limit ?? 50;
|
|
26
|
+
const since = options.since === undefined ? 0 : Date.parse(options.since);
|
|
27
|
+
if (!Number.isInteger(limit) || limit < 1 || !Number.isFinite(since))
|
|
28
|
+
throw new TrainError('bad_request', 'WhatsApp history needs a positive integer limit and a valid since timestamp');
|
|
29
|
+
return { limit: Math.min(limit, HISTORY_LIMITS.pageSize), since };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const rowTime = (row: StoredRow | undefined): string | null => row === undefined ? null : new Date(row.at).toISOString();
|
|
33
|
+
|
|
34
|
+
function boundedPage(result: HistoryPage, rows: StoredRow[], limit: number): HistoryPage {
|
|
35
|
+
let bytes = Buffer.byteLength(JSON.stringify(result));
|
|
36
|
+
for (const row of rows.slice(0, limit)) {
|
|
37
|
+
const message = shown(row);
|
|
38
|
+
const added = Buffer.byteLength(JSON.stringify(message)) + 1;
|
|
39
|
+
const cursor = Buffer.byteLength(JSON.stringify({ count: result.messages.length + 1, hasMore: true, nextBefore: message.messageId }));
|
|
40
|
+
if (bytes + added + cursor > HISTORY_LIMITS.pageBytes - 128 * 1024) break;
|
|
41
|
+
result.messages.push(message);
|
|
42
|
+
bytes += added;
|
|
43
|
+
}
|
|
44
|
+
result.count = result.messages.length;
|
|
45
|
+
result.hasMore = rows.length > result.count;
|
|
46
|
+
if (result.hasMore) result.nextBefore = result.messages.at(-1)?.messageId;
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function page(state: HistoryState, jid: string, options: HistoryOptions): HistoryPage {
|
|
51
|
+
const { limit, since } = readBounds(options);
|
|
52
|
+
const rows = [...state.rows.values()].filter((row) => state.same(row.key.remoteJid ?? '', jid)).sort(newestFirst);
|
|
53
|
+
const before = options.before === undefined ? -1 : rows.findIndex((row) => row.key.id === options.before);
|
|
54
|
+
if (options.before !== undefined && before < 0)
|
|
55
|
+
throw new TrainError('whatsapp_history_cursor', 'The before message is not retained in this chat. Read without before to restart local pagination.');
|
|
56
|
+
const matching = rows.slice(before + 1).filter((row) => row.at >= since);
|
|
57
|
+
return boundedPage({
|
|
58
|
+
messages: [], count: 0, hasMore: false,
|
|
59
|
+
coverage: {
|
|
60
|
+
partial: true, source: 'local',
|
|
61
|
+
description: 'Only locally observed or synced messages and successful sends with known expiry state. Own sends with unknown expiry are omitted until observed or synced. Gaps remain; no older backfill or media downloads. View-once and unknown-expiry content is omitted. before is exclusive; since is inclusive. Pages reserve output-formatting overhead within the serialized-byte limit. hasMore describes retained local matches only.',
|
|
62
|
+
oldest: rowTime(rows.at(-1)), newest: rowTime(rows[0]),
|
|
63
|
+
retained: rows.length, retainedAfter: new Date(state.after(jid)).toISOString(), limits: HISTORY_LIMITS,
|
|
64
|
+
},
|
|
65
|
+
}, matching, limit);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
class LocalHistory implements History {
|
|
69
|
+
private readonly state: HistoryState;
|
|
70
|
+
private readonly events: HistoryEvents;
|
|
71
|
+
private readonly sweep: ReturnType<typeof setInterval>;
|
|
72
|
+
private timer?: ReturnType<typeof setTimeout>;
|
|
73
|
+
private closed = false;
|
|
74
|
+
|
|
75
|
+
constructor(private readonly accountId: string) {
|
|
76
|
+
this.state = new HistoryState(accountId);
|
|
77
|
+
this.events = new HistoryEvents(this.state);
|
|
78
|
+
this.state.prune(Date.now());
|
|
79
|
+
this.sweep = setInterval(() => { this.saveSafely(); }, HISTORY_SWEEP_MS);
|
|
80
|
+
this.sweep.unref();
|
|
81
|
+
this.schedule();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private assertOpen(): void {
|
|
85
|
+
if (this.closed) throw new TrainError('whatsapp_history_closed', 'WhatsApp local history is closed');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private saveSafely(): void {
|
|
89
|
+
try {
|
|
90
|
+
this.flush();
|
|
91
|
+
} catch (err) {
|
|
92
|
+
log.error({ accountId: this.accountId, err: errMsg(err) }, 'whatsapp: could not save local history');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private schedule(): void {
|
|
97
|
+
if (this.timer) return;
|
|
98
|
+
this.timer = setTimeout(() => { this.saveSafely(); }, HISTORY_SAVE_MS);
|
|
99
|
+
this.timer.unref();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private changed(now: number): void {
|
|
103
|
+
this.state.prune(now);
|
|
104
|
+
this.schedule();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
read(jid: string, options: HistoryOptions = {}): HistoryPage {
|
|
108
|
+
this.assertOpen();
|
|
109
|
+
this.changed(Date.now());
|
|
110
|
+
this.state.assertReadable(jid);
|
|
111
|
+
return page(this.state, jid, options);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
ingest(messages: readonly WAMessage[], selfJid?: string): void {
|
|
115
|
+
this.ingestAll(messages, selfJid);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
ingestSent(messages: readonly WAMessage[], selfJid?: string, ephemeralExpiration?: number): void {
|
|
119
|
+
this.ingestAll(messages, selfJid, { expiration: ephemeralExpiration });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private ingestAll(messages: readonly WAMessage[], selfJid?: string, sent?: { expiration?: number }): void {
|
|
123
|
+
this.assertOpen();
|
|
124
|
+
const now = Date.now();
|
|
125
|
+
for (const [index, message] of messages.entries()) {
|
|
126
|
+
this.events.ingest(message, now, selfJid, sent);
|
|
127
|
+
if (index % 128 === 127) this.state.prune(now);
|
|
128
|
+
}
|
|
129
|
+
this.changed(now);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
alias(pn: string, lid: string): void {
|
|
133
|
+
this.assertOpen();
|
|
134
|
+
const now = Date.now();
|
|
135
|
+
this.state.alias(pn, lid, now);
|
|
136
|
+
this.changed(now);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
clearRange(jid: string, range?: proto.SyncActionValue.ISyncActionMessageRange | null): void {
|
|
140
|
+
this.assertOpen();
|
|
141
|
+
const now = Date.now();
|
|
142
|
+
this.state.clearRange(jid, range ?? {}, now);
|
|
143
|
+
this.changed(now);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
invalidate(): void {
|
|
147
|
+
this.assertOpen();
|
|
148
|
+
this.state.invalidate(Date.now());
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
update(updates: readonly WAMessageUpdate[], selfJid?: string): void {
|
|
152
|
+
this.assertOpen();
|
|
153
|
+
const now = Date.now();
|
|
154
|
+
for (const [index, update] of updates.entries()) {
|
|
155
|
+
this.events.update(update, now, selfJid);
|
|
156
|
+
if (index % 128 === 127) this.state.prune(now);
|
|
157
|
+
}
|
|
158
|
+
this.changed(now);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
deleteMessages(event: HistoryDelete): void {
|
|
162
|
+
this.assertOpen();
|
|
163
|
+
const now = Date.now();
|
|
164
|
+
if ('keys' in event) {
|
|
165
|
+
for (const [index, key] of event.keys.entries()) {
|
|
166
|
+
this.state.remove(key, now);
|
|
167
|
+
if (index % 128 === 127) this.state.prune(now);
|
|
168
|
+
}
|
|
169
|
+
} else this.state.clear(event.jid, now);
|
|
170
|
+
this.changed(now);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
edit(jid: string, messageId: string, text: string): void {
|
|
174
|
+
this.assertOpen();
|
|
175
|
+
const now = Date.now();
|
|
176
|
+
this.state.prune(now);
|
|
177
|
+
this.events.edit(jid, messageId, text, now);
|
|
178
|
+
this.changed(now);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
flush(): void {
|
|
182
|
+
if (this.timer) clearTimeout(this.timer);
|
|
183
|
+
this.timer = undefined;
|
|
184
|
+
this.state.flush();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
close(): void {
|
|
188
|
+
if (this.closed) return;
|
|
189
|
+
clearInterval(this.sweep);
|
|
190
|
+
this.closed = true;
|
|
191
|
+
this.flush();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export const createHistory = (accountId: string): History => new LocalHistory(accountId);
|
|
@@ -4,4 +4,4 @@ import { createClient } from './client.js';
|
|
|
4
4
|
import { startInbound } from './inbound.js';
|
|
5
5
|
import { makeHandleCall } from './actions.js';
|
|
6
6
|
|
|
7
|
-
runClientTrain({ station: 'whatsapp', accounts, loadAccounts, createClient, startInbound, makeHandleCall });
|
|
7
|
+
runClientTrain({ station: 'whatsapp', accounts, loadAccounts, createClient, startInbound, makeHandleCall, disconnectClient: (client) => client.disconnect() });
|
|
@@ -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
|
@@ -85,7 +85,51 @@ export interface BufferedEvent {
|
|
|
85
85
|
event: MetroEvent;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
const BUS_BUFFER_MAX = 500;
|
|
88
|
+
export const BUS_BUFFER_MAX = 500;
|
|
89
|
+
|
|
90
|
+
export interface RunEventMetadata {
|
|
91
|
+
isPrivate?: boolean;
|
|
92
|
+
senderVerified?: boolean;
|
|
93
|
+
mentionsSelf?: boolean;
|
|
94
|
+
replyToSelf?: boolean;
|
|
95
|
+
emoji?: string;
|
|
96
|
+
removed?: boolean;
|
|
97
|
+
targetId?: string;
|
|
98
|
+
attachmentCount?: number;
|
|
99
|
+
attachmentTypes?: ('image' | 'video' | 'audio' | 'file')[];
|
|
100
|
+
attachmentStatus?: 'saved' | 'failed';
|
|
101
|
+
attachmentFor?: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface RunEvent {
|
|
105
|
+
id: string;
|
|
106
|
+
seq: number;
|
|
107
|
+
ts: string;
|
|
108
|
+
kind: StructuredEvent['type'];
|
|
109
|
+
direction: 'inbound' | 'outbound' | 'system';
|
|
110
|
+
agentId: string;
|
|
111
|
+
station: string;
|
|
112
|
+
accountId: string | null;
|
|
113
|
+
line: string;
|
|
114
|
+
lineName?: string;
|
|
115
|
+
from?: string;
|
|
116
|
+
fromName?: string;
|
|
117
|
+
fromDisplayName?: string;
|
|
118
|
+
to?: string;
|
|
119
|
+
text?: string;
|
|
120
|
+
truncated: boolean;
|
|
121
|
+
messageId?: string;
|
|
122
|
+
replyTo?: string;
|
|
123
|
+
metadata: RunEventMetadata;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface RunEventsPage {
|
|
127
|
+
events: RunEvent[];
|
|
128
|
+
cursor: string;
|
|
129
|
+
hasMore: boolean;
|
|
130
|
+
reset: boolean;
|
|
131
|
+
retention: { capacity: number; oldestAt: string | null; oldestSeq: number | null };
|
|
132
|
+
}
|
|
89
133
|
|
|
90
134
|
const listeners = new Set<BusListener>();
|
|
91
135
|
const buffer: BufferedEvent[] = [];
|
|
@@ -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);
|