@stage-labs/metro 0.1.0-beta.205 → 0.1.0-beta.207
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/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/sender-cards.ts +8 -0
- package/runtime/node_modules/@metro-labs/telegram-bot/src/profile.ts +21 -1
- package/runtime/node_modules/@metro-labs/whatsapp/src/client.ts +10 -3
- package/runtime/node_modules/@metro-labs/whatsapp/src/names.ts +50 -3
- package/runtime/node_modules/@metro-labs/whatsapp/src/station.ts +9 -2
- package/runtime/runtime.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.207",
|
|
4
4
|
"description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Bonustrack",
|
|
@@ -11,6 +11,7 @@ export interface SenderCard {
|
|
|
11
11
|
id: string;
|
|
12
12
|
name?: string;
|
|
13
13
|
handle?: string;
|
|
14
|
+
avatar?: string;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export interface SenderCardDeps {
|
|
@@ -23,15 +24,22 @@ const LOOKUP_MS = 8_000;
|
|
|
23
24
|
|
|
24
25
|
const filled = (value: unknown): string | undefined => (typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined);
|
|
25
26
|
|
|
27
|
+
const AVATAR_DATA_MAX = 100_000;
|
|
28
|
+
|
|
29
|
+
const shownAvatar = (avatar: string): boolean =>
|
|
30
|
+
avatar.startsWith('https://') || (avatar.startsWith('data:image/') && avatar.length <= AVATAR_DATA_MAX);
|
|
31
|
+
|
|
26
32
|
function cardOf(id: string, profile: unknown, seenName: string | undefined): SenderCard {
|
|
27
33
|
const p = isRecord(profile) ? profile : {};
|
|
28
34
|
const display = filled(p.display_name);
|
|
29
35
|
const handle = filled(p.name);
|
|
30
36
|
const name = display ?? seenName ?? handle;
|
|
37
|
+
const avatar = filled(p.avatar);
|
|
31
38
|
return {
|
|
32
39
|
id,
|
|
33
40
|
...(name === undefined ? {} : { name }),
|
|
34
41
|
...(handle === undefined || handle === name ? {} : { handle }),
|
|
42
|
+
...(avatar !== undefined && shownAvatar(avatar) ? { avatar } : {}),
|
|
35
43
|
};
|
|
36
44
|
}
|
|
37
45
|
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
} from '@metro-labs/core/stations/profile';
|
|
10
10
|
import { makeProfileCache, nonEmpty, type SenderProfile } from '@metro-labs/core/stations/sender-profile';
|
|
11
11
|
import { TrainError } from '@metro-labs/core/train-error';
|
|
12
|
-
import { accountFor, tg } from './accounts.js';
|
|
12
|
+
import { accountFor, accounts, tg } from './accounts.js';
|
|
13
13
|
import { respond } from '@metro-labs/core/stations/station-runtime';
|
|
14
14
|
|
|
15
15
|
async function setPhoto(accountId: string, avatar: ProfileAvatar): Promise<void> {
|
|
@@ -39,6 +39,24 @@ interface TgChat {
|
|
|
39
39
|
first_name?: unknown;
|
|
40
40
|
last_name?: unknown;
|
|
41
41
|
bio?: unknown;
|
|
42
|
+
photo?: { small_file_id?: unknown };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const PHOTO_MAX = 64 * 1024;
|
|
46
|
+
|
|
47
|
+
async function photoData(accountId: string, fileId: unknown): Promise<string | undefined> {
|
|
48
|
+
if (typeof fileId !== 'string' || fileId === '') return undefined;
|
|
49
|
+
try {
|
|
50
|
+
const file = await tg<{ file_path?: string }>(accountId, 'getFile', { file_id: fileId }, 10_000);
|
|
51
|
+
const acct = accounts.get(accountId);
|
|
52
|
+
if (acct === undefined || typeof file.file_path !== 'string') return undefined;
|
|
53
|
+
const res = await fetch(`${acct.fileApi}/${file.file_path}`, { signal: AbortSignal.timeout(10_000) });
|
|
54
|
+
if (!res.ok) return undefined;
|
|
55
|
+
const bytes = Buffer.from(await res.arrayBuffer());
|
|
56
|
+
return bytes.length === 0 || bytes.length > PHOTO_MAX ? undefined : `data:image/jpeg;base64,${bytes.toString('base64')}`;
|
|
57
|
+
} catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
42
60
|
}
|
|
43
61
|
|
|
44
62
|
const senders = makeProfileCache<SenderProfile>(
|
|
@@ -47,11 +65,13 @@ const senders = makeProfileCache<SenderProfile>(
|
|
|
47
65
|
const chat = await tg<TgChat>(accountId, 'getChat', { chat_id: Number(user) }, 10_000);
|
|
48
66
|
const handle = nonEmpty(chat.username);
|
|
49
67
|
const display = nonEmpty([chat.first_name, chat.last_name].filter((part) => typeof part === 'string').join(' '));
|
|
68
|
+
const avatar = await photoData(accountId, chat.photo?.small_file_id);
|
|
50
69
|
return {
|
|
51
70
|
id: user,
|
|
52
71
|
...(handle === undefined ? {} : { name: `@${handle}` }),
|
|
53
72
|
...(display === undefined ? {} : { display_name: display }),
|
|
54
73
|
...(nonEmpty(chat.bio) === undefined ? {} : { about: nonEmpty(chat.bio) }),
|
|
74
|
+
...(avatar === undefined ? {} : { avatar }),
|
|
55
75
|
};
|
|
56
76
|
},
|
|
57
77
|
{ onError: (key, err) => process.stderr.write(`telegram-bot: could not read the profile of ${key}: ${err instanceof Error ? err.message : String(err)}\n`) },
|
|
@@ -17,7 +17,7 @@ import type { ProfileChange } from '@metro-labs/core/stations/profile';
|
|
|
17
17
|
import { makeProfileCache, nonEmpty, type SenderProfile } from '@metro-labs/core/stations/sender-profile';
|
|
18
18
|
import { toInbound, toReaction, type ReactionEvent, type SelfRef } from './parse.js';
|
|
19
19
|
import { baileysLogger } from './logger.js';
|
|
20
|
-
import { makeNameBook, phoneOf, type NameBook } from './names.js';
|
|
20
|
+
import { makeNameBook, nameFiles, noteContact, phoneOf, type NameBook } from './names.js';
|
|
21
21
|
import { useAccountAuthState } from './auth-state.js';
|
|
22
22
|
import { knownKey, makeKeyCache, targetKey, type KeyCache } from './keys.js';
|
|
23
23
|
import { makeOutbox, type Outbox } from './outbox.js';
|
|
@@ -102,7 +102,14 @@ function bindInbound(st: State, sock: WASocket): void {
|
|
|
102
102
|
}
|
|
103
103
|
});
|
|
104
104
|
sock.ev.on('contacts.upsert', (contacts) => {
|
|
105
|
-
for (const c of contacts) st.names
|
|
105
|
+
for (const c of contacts) noteContact(st.names, c);
|
|
106
|
+
});
|
|
107
|
+
sock.ev.on('contacts.update', (contacts) => {
|
|
108
|
+
for (const c of contacts) noteContact(st.names, c);
|
|
109
|
+
});
|
|
110
|
+
sock.ev.on('messaging-history.set', ({ contacts, messages }) => {
|
|
111
|
+
for (const c of contacts) noteContact(st.names, c);
|
|
112
|
+
for (const m of messages) if (m.key.fromMe !== true) st.names.note(m.key.participant ?? m.key.remoteJid, m.pushName);
|
|
106
113
|
});
|
|
107
114
|
sock.ev.on('messages.reaction', (events: ReactionEvent[]) => {
|
|
108
115
|
if (!st.handlers) return;
|
|
@@ -301,7 +308,7 @@ export function createClient(account: WhatsAppAccount): WAClient {
|
|
|
301
308
|
keys: makeKeyCache(),
|
|
302
309
|
outbox: makeOutbox(),
|
|
303
310
|
acks: makeAckWatch(),
|
|
304
|
-
names: makeNameBook(),
|
|
311
|
+
names: makeNameBook(nameFiles.path(account.id)),
|
|
305
312
|
};
|
|
306
313
|
resetGate(st);
|
|
307
314
|
const senders = makeProfileCache<SenderProfile>((jid) => readProfile(st, jid), {
|
|
@@ -1,27 +1,74 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { accountFiles } from '@metro-labs/core/stations/account-files';
|
|
3
|
+
import { writeSecure } from '@metro-labs/core/secure-fs';
|
|
4
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
5
|
+
import { errMsg } from '@metro-labs/core/log';
|
|
6
|
+
|
|
1
7
|
const MAX_NAMES = 2000;
|
|
8
|
+
const SAVE_DELAY_MS = 2000;
|
|
9
|
+
|
|
10
|
+
export const nameFiles = accountFiles('WHATSAPP_TOKEN_DIR', 'whatsapp-names-');
|
|
2
11
|
|
|
3
12
|
export interface NameBook {
|
|
4
13
|
note(jid: string | null | undefined, name: string | null | undefined): void;
|
|
5
14
|
get(jid: string): string | undefined;
|
|
6
15
|
}
|
|
7
16
|
|
|
8
|
-
|
|
9
|
-
|
|
17
|
+
function loaded(file: string | undefined): Map<string, string> {
|
|
18
|
+
if (file === undefined) return new Map();
|
|
19
|
+
try {
|
|
20
|
+
const raw: unknown = JSON.parse(readFileSync(file, 'utf8'));
|
|
21
|
+
if (!isRecord(raw)) return new Map();
|
|
22
|
+
return new Map(Object.entries(raw).filter((e): e is [string, string] => typeof e[1] === 'string' && e[1] !== ''));
|
|
23
|
+
} catch {
|
|
24
|
+
return new Map();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function makeNameBook(file?: string): NameBook {
|
|
29
|
+
const names = loaded(file);
|
|
30
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
31
|
+
const save = (): void => {
|
|
32
|
+
timer = null;
|
|
33
|
+
if (file === undefined) return;
|
|
34
|
+
try {
|
|
35
|
+
writeSecure(file, JSON.stringify(Object.fromEntries(names)));
|
|
36
|
+
} catch (err) {
|
|
37
|
+
process.stderr.write(`whatsapp: could not save the names: ${errMsg(err)}\n`);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
10
40
|
return {
|
|
11
41
|
note(jid, name) {
|
|
12
42
|
const clean = name?.trim() ?? '';
|
|
13
|
-
if (!jid || clean === '') return;
|
|
43
|
+
if (!jid || clean === '' || names.get(jid) === clean) return;
|
|
14
44
|
names.delete(jid);
|
|
15
45
|
names.set(jid, clean);
|
|
16
46
|
if (names.size > MAX_NAMES) {
|
|
17
47
|
const oldest = names.keys().next().value;
|
|
18
48
|
if (oldest !== undefined) names.delete(oldest);
|
|
19
49
|
}
|
|
50
|
+
if (file !== undefined && timer === null) {
|
|
51
|
+
timer = setTimeout(save, SAVE_DELAY_MS);
|
|
52
|
+
timer.unref();
|
|
53
|
+
}
|
|
20
54
|
},
|
|
21
55
|
get: (jid) => names.get(jid),
|
|
22
56
|
};
|
|
23
57
|
}
|
|
24
58
|
|
|
59
|
+
export interface ContactNames {
|
|
60
|
+
id?: string | null;
|
|
61
|
+
lid?: string | null;
|
|
62
|
+
phoneNumber?: string | null;
|
|
63
|
+
name?: string | null;
|
|
64
|
+
notify?: string | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function noteContact(book: NameBook, c: ContactNames): void {
|
|
68
|
+
const name = c.name ?? c.notify;
|
|
69
|
+
for (const jid of [c.id, c.lid, c.phoneNumber]) book.note(jid, name);
|
|
70
|
+
}
|
|
71
|
+
|
|
25
72
|
export const phoneOf = (jid: string | null | undefined): string | undefined => {
|
|
26
73
|
const digits = /^(\d{6,})(?::\d+)?@s\.whatsapp\.net$/.exec(jid ?? '')?.[1];
|
|
27
74
|
return digits === undefined ? undefined : `+${digits}`;
|
|
@@ -1,6 +1,7 @@
|
|
|
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
3
|
import { tokenFiles } from './token-store.js';
|
|
4
|
+
import { nameFiles } from './names.js';
|
|
4
5
|
|
|
5
6
|
export const whatsappStation: Station = {
|
|
6
7
|
name: 'whatsapp',
|
|
@@ -19,7 +20,13 @@ export const whatsappStation: Station = {
|
|
|
19
20
|
profileFields: new Set<ProfileField>(PROFILE_FIELDS),
|
|
20
21
|
readsProfiles: true,
|
|
21
22
|
resolvesSenders: true,
|
|
22
|
-
forget:
|
|
23
|
-
|
|
23
|
+
forget: (accountId) => {
|
|
24
|
+
tokenFiles.forget(accountId);
|
|
25
|
+
nameFiles.forget(accountId);
|
|
26
|
+
},
|
|
27
|
+
forgetExcept: (kept) => {
|
|
28
|
+
tokenFiles.forgetExcept(kept);
|
|
29
|
+
nameFiles.forgetExcept(kept);
|
|
30
|
+
},
|
|
24
31
|
tools: [],
|
|
25
32
|
};
|
package/runtime/runtime.json
CHANGED