@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.205",
3
+ "version": "0.1.0-beta.207",
4
4
  "description": "The metro command line: run your agent on this machine, relaying its chat channels and connectors to Claude Code.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.205",
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.note(c.id, c.name ?? c.notify);
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
- export function makeNameBook(): NameBook {
9
- const names = new Map<string, string>();
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: tokenFiles.forget,
23
- forgetExcept: tokenFiles.forgetExcept,
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
  };
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.205"
2
+ "version": "0.1.0-beta.207"
3
3
  }