@stage-labs/metro 0.1.0-beta.160 → 0.1.0-beta.162

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.
Files changed (27) hide show
  1. package/package.json +1 -1
  2. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  3. package/runtime/node_modules/@metro-labs/core/package.json +2 -1
  4. package/runtime/node_modules/@metro-labs/core/src/events.ts +2 -0
  5. package/runtime/node_modules/@metro-labs/core/src/protocol.ts +2 -0
  6. package/runtime/node_modules/@metro-labs/core/src/stations/sender-profile.ts +60 -0
  7. package/runtime/node_modules/@metro-labs/daemon/src/channels/inbound.ts +6 -4
  8. package/runtime/node_modules/@metro-labs/daemon/src/channels/pending.ts +16 -4
  9. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini.ts +29 -3
  10. package/runtime/node_modules/@metro-labs/daemon/src/mcp/instructions.ts +21 -0
  11. package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +1 -1
  12. package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-schemas.ts +0 -20
  13. package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +2 -0
  14. package/runtime/node_modules/@metro-labs/discord-bot/src/format.ts +8 -2
  15. package/runtime/node_modules/@metro-labs/telegram/src/format.ts +3 -2
  16. package/runtime/node_modules/@metro-labs/telegram/src/inbound.ts +31 -1
  17. package/runtime/node_modules/@metro-labs/telegram-bot/src/format.ts +8 -3
  18. package/runtime/node_modules/@metro-labs/telegram-bot/src/index.ts +23 -5
  19. package/runtime/node_modules/@metro-labs/telegram-bot/src/types.ts +10 -12
  20. package/runtime/node_modules/@metro-labs/whatsapp/src/client.ts +10 -0
  21. package/runtime/node_modules/@metro-labs/whatsapp/src/inbound.ts +21 -8
  22. package/runtime/node_modules/@metro-labs/xmtp/src/conv-helpers.ts +1 -1
  23. package/runtime/node_modules/@metro-labs/xmtp/src/index.ts +2 -0
  24. package/runtime/node_modules/@metro-labs/xmtp/src/profile.ts +2 -2
  25. package/runtime/node_modules/@metro-labs/xmtp/src/sender.ts +95 -0
  26. package/runtime/node_modules/@metro-labs/xmtp/src/smart.ts +1 -1
  27. package/runtime/runtime.json +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.160",
3
+ "version": "0.1.0-beta.162",
4
4
  "description": "The metro command line. Sign in once per machine, then hand your MCP connector list to Claude Code without the credentials touching disk, argv or shell history.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.160",
3
+ "version": "0.1.0-beta.162",
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",
@@ -24,7 +24,8 @@
24
24
  "./stations/account-store": "./src/stations/account-store.ts",
25
25
  "./stations/attachments": "./src/stations/attachments.ts",
26
26
  "./stations/messaging-normalize": "./src/stations/messaging-normalize.ts",
27
- "./stations/profile": "./src/stations/profile.ts"
27
+ "./stations/profile": "./src/stations/profile.ts",
28
+ "./stations/sender-profile": "./src/stations/sender-profile.ts"
28
29
  },
29
30
  "scripts": {
30
31
  "build": "tsc",
@@ -20,6 +20,8 @@ export interface MetroEvent {
20
20
  from: Line;
21
21
  fromName?: string;
22
22
  fromDisplayName?: string;
23
+ fromAvatar?: string;
24
+ fromAbout?: string;
23
25
  to: Line;
24
26
  text?: string;
25
27
  messageId?: string;
@@ -33,6 +33,8 @@ export type TrainEvent = {
33
33
  from?: string;
34
34
  from_name?: string;
35
35
  from_display_name?: string;
36
+ from_avatar?: string;
37
+ from_about?: string;
36
38
  to?: string;
37
39
  message_id?: string;
38
40
  reply_to?: string;
@@ -0,0 +1,60 @@
1
+ export interface SenderProfile {
2
+ from_name?: string;
3
+ from_display_name?: string;
4
+ from_avatar?: string;
5
+ from_about?: string;
6
+ }
7
+
8
+ export interface ProfileCacheOptions {
9
+ ttlMs?: number;
10
+ max?: number;
11
+ timeoutMs?: number;
12
+ onError: (key: string, err: unknown) => void;
13
+ }
14
+
15
+ export interface ProfileCache<T> {
16
+ get(key: string): Promise<T | null>;
17
+ within(key: string, ms?: number): Promise<T | null>;
18
+ }
19
+
20
+ const TTL_MS = 10 * 60_000;
21
+ const MAX = 2000;
22
+ const TIMEOUT_MS = 3000;
23
+
24
+ interface Held<T> {
25
+ at: number;
26
+ value: Promise<T | null>;
27
+ }
28
+
29
+ export const nonEmpty = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined);
30
+
31
+ export function makeProfileCache<T>(lookup: (key: string) => Promise<T | null>, opts: ProfileCacheOptions): ProfileCache<T> {
32
+ const ttl = opts.ttlMs ?? TTL_MS;
33
+ const max = opts.max ?? MAX;
34
+ const cache = new Map<string, Held<T>>();
35
+ const get = (key: string): Promise<T | null> => {
36
+ const held = cache.get(key);
37
+ if (held !== undefined && Date.now() - held.at < ttl) return held.value;
38
+ const value = lookup(key).catch((err: unknown) => {
39
+ opts.onError(key, err);
40
+ cache.delete(key);
41
+ return null;
42
+ });
43
+ cache.set(key, { at: Date.now(), value });
44
+ while (cache.size > max) {
45
+ const oldest = cache.keys().next();
46
+ if (oldest.done) break;
47
+ cache.delete(oldest.value);
48
+ }
49
+ return value;
50
+ };
51
+ const within = (key: string, ms = opts.timeoutMs ?? TIMEOUT_MS): Promise<T | null> => {
52
+ const wait = new Promise<null>((resolve) => {
53
+ setTimeout(() => {
54
+ resolve(null);
55
+ }, ms).unref();
56
+ });
57
+ return Promise.race([get(key), wait]);
58
+ };
59
+ return { get, within };
60
+ }
@@ -11,7 +11,7 @@ import { buildWebhookNote } from './webhook-note.js';
11
11
  import { replyMeta } from './addressed.js';
12
12
  import {
13
13
  capSet,
14
- displayNameMeta,
14
+ profileMeta,
15
15
  senderMeta,
16
16
  tsMeta,
17
17
  takeMediaCtx,
@@ -148,7 +148,7 @@ export class InboundRelay {
148
148
  message_id: e.messageId,
149
149
  line_name: e.lineName,
150
150
  from_name: e.fromName,
151
- ...displayNameMeta(e.fromDisplayName),
151
+ ...profileMeta(e),
152
152
  },
153
153
  });
154
154
  }
@@ -206,6 +206,8 @@ export class InboundRelay {
206
206
  lineName: str(ev.lineName),
207
207
  fromName: str(ev.fromName),
208
208
  fromDisplayName: str(ev.fromDisplayName),
209
+ fromAvatar: str(ev.fromAvatar),
210
+ fromAbout: str(ev.fromAbout),
209
211
  attachments: atts.map((a) => ({ kind: a.kind, name: a.name })),
210
212
  saved: new Set<number>(),
211
213
  timer: setTimeout(() => {
@@ -246,7 +248,7 @@ export class InboundRelay {
246
248
  message_id: str(ev.messageId),
247
249
  line_name: str(ev.lineName),
248
250
  from_name: str(ev.fromName),
249
- ...displayNameMeta(ev.fromDisplayName),
251
+ ...profileMeta(ev),
250
252
  reaction: emoji,
251
253
  target_id: target,
252
254
  },
@@ -330,7 +332,7 @@ export class InboundRelay {
330
332
  message_id: str(ev.messageId),
331
333
  line_name: str(ev.lineName),
332
334
  from_name: str(ev.fromName),
333
- ...displayNameMeta(ev.fromDisplayName),
335
+ ...profileMeta(ev),
334
336
  ...replyMeta(ev, this.sentIds),
335
337
  },
336
338
  });
@@ -15,6 +15,8 @@ export interface PendingMsg {
15
15
  lineName: string;
16
16
  fromName: string;
17
17
  fromDisplayName: string;
18
+ fromAvatar: string;
19
+ fromAbout: string;
18
20
  attachments: PendingAtt[];
19
21
  saved: Set<number>;
20
22
  timer: ReturnType<typeof setTimeout>;
@@ -30,6 +32,8 @@ export interface MediaCtx {
30
32
  lineName?: string;
31
33
  fromName?: string;
32
34
  fromDisplayName?: string;
35
+ fromAvatar?: string;
36
+ fromAbout?: string;
33
37
  }
34
38
 
35
39
  export function capSet(set: Set<string>, max: number): void {
@@ -45,17 +49,23 @@ export const tsMeta = (v: unknown): Record<string, string> => {
45
49
  return ts ? { ts } : {};
46
50
  };
47
51
 
48
- export const displayNameMeta = (v: unknown): Record<string, string> => {
49
- const name = str(v);
50
- return name ? { from_display_name: name } : {};
52
+ const optional = (key: string, v: unknown): Record<string, string> => {
53
+ const text = str(v);
54
+ return text ? { [key]: text } : {};
51
55
  };
52
56
 
57
+ export const profileMeta = (v: { fromDisplayName?: unknown; fromAvatar?: unknown; fromAbout?: unknown }): Record<string, string> => ({
58
+ ...optional('from_display_name', v.fromDisplayName),
59
+ ...optional('from_avatar', v.fromAvatar),
60
+ ...optional('from_about', v.fromAbout),
61
+ });
62
+
53
63
  export const senderMeta = (c: MediaCtx): Record<string, string> => ({
54
64
  ...tsMeta(c.ts),
55
65
  ...(c.messageId ? { message_id: c.messageId } : {}),
56
66
  ...(c.lineName ? { line_name: c.lineName } : {}),
57
67
  ...(c.fromName ? { from_name: c.fromName } : {}),
58
- ...displayNameMeta(c.fromDisplayName),
68
+ ...profileMeta(c),
59
69
  });
60
70
 
61
71
  export function takeMediaCtx(buf: PendingMsg): MediaCtx {
@@ -69,6 +79,8 @@ export function takeMediaCtx(buf: PendingMsg): MediaCtx {
69
79
  lineName: buf.lineName,
70
80
  fromName: buf.fromName,
71
81
  fromDisplayName: buf.fromDisplayName,
82
+ fromAvatar: buf.fromAvatar,
83
+ fromAbout: buf.fromAbout,
72
84
  };
73
85
  buf.text = '';
74
86
  return ctx;
@@ -1,6 +1,6 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import { isRecord } from '@metro-labs/core/is-record';
3
- import { errMsg } from '@metro-labs/core/log';
3
+ import { errMsg, log } from '@metro-labs/core/log';
4
4
  import { refreshTokens, tokensStale, type GeminiTokens } from './gemini-auth.js';
5
5
  import { API, clientHeaders, generateBases, machineSessionId, type Bases } from './gemini-client.js';
6
6
  import { GeminiStreamTranslator } from './gemini-stream.js';
@@ -109,12 +109,37 @@ async function send(call: Call, tokens: GeminiTokens, stream: boolean): Promise<
109
109
  if (base === bases.at(-1) || call.watch.signal.aborted) throw err;
110
110
  continue;
111
111
  }
112
- if (last.status < 500) return last;
112
+ if (last.status < 500 && last.status !== 429) return last;
113
113
  }
114
114
  if (last === null) throw new GatewayError(502, 'api_error', 'no Code Assist endpoint to call');
115
115
  return last;
116
116
  }
117
117
 
118
+ const REFUSAL_LOG_MAX = 2000;
119
+
120
+ const detailText = (detail: Record<string, unknown>): string[] => {
121
+ const out: string[] = [];
122
+ if (typeof detail.reason === 'string') out.push(detail.reason);
123
+ if (isRecord(detail.metadata)) out.push(...Object.entries(detail.metadata).map(([k, v]) => `${k}=${String(v)}`));
124
+ if (typeof detail.retryDelay === 'string') out.push(`retry after ${detail.retryDelay}`);
125
+ if (Array.isArray(detail.violations))
126
+ out.push(...detail.violations.filter(isRecord).map((v) => [v.subject, v.description].filter((x) => typeof x === 'string').join(': ')));
127
+ return out.filter((x) => x !== '');
128
+ };
129
+
130
+ export function refusalMessage(text: string, status: number): string {
131
+ const message = upstreamMessage(text, `Gemini answered ${String(status)}`);
132
+ let details: unknown = null;
133
+ try {
134
+ const parsed: unknown = JSON.parse(text);
135
+ details = isRecord(parsed) && isRecord(parsed.error) ? parsed.error.details : null;
136
+ } catch {
137
+ return message;
138
+ }
139
+ const notes = Array.isArray(details) ? details.filter(isRecord).flatMap(detailText) : [];
140
+ return notes.length === 0 ? message : `${message} (${notes.join('; ')})`;
141
+ }
142
+
118
143
  async function reach(call: Call, cfg: ModelConfig, state: GeminiState, stream: boolean): Promise<Response> {
119
144
  let tokens = await currentGeminiTokens(cfg, call.deps, state);
120
145
  let upstream = await send(call, tokens, stream);
@@ -244,7 +269,8 @@ export async function geminiMessages(
244
269
  const upstream = await reach(call, cfg, state, stream);
245
270
  if (!upstream.ok) {
246
271
  const text = await upstream.text();
247
- sendError(res, providerStatus(upstream.status), errorKind(upstream.status), upstreamMessage(text, `Gemini answered ${String(upstream.status)}`));
272
+ log.warn({ provider: 'gemini', model, status: upstream.status, body: text.slice(0, REFUSAL_LOG_MAX) }, 'gateway: the provider refused the request');
273
+ sendError(res, providerStatus(upstream.status), errorKind(upstream.status), refusalMessage(text, upstream.status));
248
274
  return;
249
275
  }
250
276
  if (stream) await relayStream(upstream, res, call);
@@ -0,0 +1,21 @@
1
+ export const MCP_INSTRUCTIONS =
2
+ 'Messages from Metro chat arrive as <channel source="metro" line="..." from="..." ' +
3
+ 'station="..." message_id="...">. To respond, use the messaging tools, always passing the ' +
4
+ '`line` attribute verbatim (the station is derived from it): `send` (text and/or media via ' +
5
+ '`attachments`, optional `reply_to`), `reply` (quote a `message_id` with `text`), `react`/' +
6
+ '`unreact` (emoji on a `message_id`), `edit`/`delete` (a `message_id`), and `read` (recent ' +
7
+ "history). Station support varies - the tool returns the daemon's reason if a verb is " +
8
+ 'unsupported on that line. An inbound attachment is surfaced as a note carrying the ' +
9
+ "sender's own text alongside a `Public URL` - fetch that url to read the file; the note's " +
10
+ '`local_path` resolves on the DAEMON host, not on yours, so the Read tool works on it only ' +
11
+ 'for an agent running there. To send a file OUT, call `create_upload`, ' +
12
+ 'run the one curl line it hands back (that step needs a shell; the bytes go from your disk ' +
13
+ 'straight to metro and never through this conversation), then `send` with ' +
14
+ 'attachments:[{upload:"<upload_id>"}]. Inline `data` is for tiny files only, `url` only for ' +
15
+ 'an already-public file, and `path` is read on the DAEMON host, not yours. Tool-approval ' +
16
+ 'prompts are relayed to the same chat - answer "yes <id>"/"no <id>". A message meant for you ' +
17
+ 'carries addressed="direct" (a private chat), "mention" (you were named) or "reply" (it ' +
18
+ 'answers one of your own messages); one without `addressed` merely happened in a room you ' +
19
+ 'watch, so treat it as context and do not answer unless the person plainly wants you. The ' +
20
+ 'sender rides in the same meta: `from_name` (their handle or name), `from_display_name`, and, ' +
21
+ 'when the station knows them, `from_avatar` (a picture url) and `from_about` (their bio).';
@@ -8,7 +8,7 @@ import {
8
8
  senderMatchesAllowlist,
9
9
  } from '../agents/map.js';
10
10
  import { accountStationNames } from '../stations/registry.js';
11
- import { MCP_INSTRUCTIONS } from './tool-schemas.js';
11
+ import { MCP_INSTRUCTIONS } from './instructions.js';
12
12
  import { ChannelOwner } from './channel-owner.js';
13
13
  import { BoundedEventStore } from './event-store.js';
14
14
  import { registerPermissionRelay } from './permission-relay.js';
@@ -378,23 +378,3 @@ export const LIST_ACCOUNTS_TOOL = {
378
378
  'verb need not be discovered by trial and error.',
379
379
  inputSchema: { type: 'object', properties: {} },
380
380
  };
381
-
382
- export const MCP_INSTRUCTIONS =
383
- 'Messages from Metro chat arrive as <channel source="metro" line="..." from="..." ' +
384
- 'station="..." message_id="...">. To respond, use the messaging tools, always passing the ' +
385
- '`line` attribute verbatim (the station is derived from it): `send` (text and/or media via ' +
386
- '`attachments`, optional `reply_to`), `reply` (quote a `message_id` with `text`), `react`/' +
387
- '`unreact` (emoji on a `message_id`), `edit`/`delete` (a `message_id`), and `read` (recent ' +
388
- "history). Station support varies - the tool returns the daemon's reason if a verb is " +
389
- 'unsupported on that line. An inbound attachment is surfaced as a note carrying the ' +
390
- "sender's own text alongside a `Public URL` - fetch that url to read the file; the note's " +
391
- '`local_path` resolves on the DAEMON host, not on yours, so the Read tool works on it only ' +
392
- 'for an agent running there. To send a file OUT, call `create_upload`, ' +
393
- 'run the one curl line it hands back (that step needs a shell; the bytes go from your disk ' +
394
- 'straight to metro and never through this conversation), then `send` with ' +
395
- 'attachments:[{upload:"<upload_id>"}]. Inline `data` is for tiny files only, `url` only for ' +
396
- 'an already-public file, and `path` is read on the DAEMON host, not yours. Tool-approval ' +
397
- 'prompts are relayed to the same chat - answer "yes <id>"/"no <id>". A message meant for you ' +
398
- 'carries addressed="direct" (a private chat), "mention" (you were named) or "reply" (it ' +
399
- 'answers one of your own messages); one without `addressed` merely happened in a room you ' +
400
- 'watch, so treat it as context and do not answer unless the person plainly wants you.';
@@ -166,6 +166,8 @@ export function trainEventToMetroEvent(
166
166
  from: (env.from ?? `metro://${station}`) as MetroEvent['from'],
167
167
  fromName: env.from_name,
168
168
  fromDisplayName: env.from_display_name,
169
+ fromAvatar: env.from_avatar,
170
+ fromAbout: env.from_about,
169
171
  to: (env.to ?? (isPrivate ? userSelf() : line)) as MetroEvent['to'],
170
172
  text,
171
173
  messageId: env.message_id,
@@ -74,6 +74,12 @@ function addressing(accountId: string, m: Message): { mentions_self: boolean; re
74
74
  };
75
75
  }
76
76
 
77
+ const senderOf = (m: Message): Record<string, string | undefined> => ({
78
+ from_name: m.author.username,
79
+ from_display_name: m.member?.displayName ?? m.author.globalName ?? undefined,
80
+ from_avatar: (m.member ?? m.author).displayAvatarURL({ size: 256 }),
81
+ });
82
+
77
83
  export function messageEnvelope(
78
84
  accountId: string,
79
85
  m: Message,
@@ -115,8 +121,7 @@ export function messageEnvelope(
115
121
  line,
116
122
  line_name: 'name' in m.channel ? m.channel.name : undefined,
117
123
  from: `metro://discord-bot/${accountId}/user/${m.author.id}`,
118
- from_name: m.author.username,
119
- from_display_name: m.author.globalName ?? undefined,
124
+ ...senderOf(m),
120
125
  message_id: m.id,
121
126
  text,
122
127
  is_private: m.guildId == null,
@@ -144,6 +149,7 @@ export function reactionEnvelope(
144
149
  from: `metro://discord-bot/${accountId}/user/${u.id}`,
145
150
  from_name: u.username,
146
151
  from_display_name: u.globalName ?? undefined,
152
+ from_avatar: u.displayAvatarURL({ size: 256 }),
147
153
  message_id: r.message.id,
148
154
  emoji: r.emoji.name ?? r.emoji.id ?? '?',
149
155
  is_private: r.message.guildId == null,
@@ -13,8 +13,9 @@ export function senderName(peer: Peer): string | undefined {
13
13
  }
14
14
 
15
15
  export function displayName(peer: Peer): string | undefined {
16
- if (isUser(peer)) return peer.firstName;
17
- return peer.title;
16
+ if (!isUser(peer)) return peer.title;
17
+ const name = [peer.firstName, peer.lastName].filter((part) => typeof part === 'string' && part !== '').join(' ');
18
+ return name === '' ? undefined : name;
18
19
  }
19
20
 
20
21
  function chatName(peer: Peer): string | undefined {
@@ -1,5 +1,6 @@
1
1
  import type { Message } from '@mtcute/bun';
2
2
  import { errMsg, log } from '@metro-labs/core/log';
3
+ import { makeProfileCache, nonEmpty, type SenderProfile } from '@metro-labs/core/stations/sender-profile';
3
4
  import { emit } from './wire.js';
4
5
  import { envelope, isOwnEcho, attachmentSavedEnvelope } from './format.js';
5
6
  import { downloadMedia, isDownloadable } from './attachments.js';
@@ -31,13 +32,42 @@ async function saveMediaAndEmit(
31
32
  }
32
33
  }
33
34
 
35
+ const senders = new Map<string, ReturnType<typeof makeProfileCache<SenderProfile>>>();
36
+
37
+ function sendersOf(client: UserClient): ReturnType<typeof makeProfileCache<SenderProfile>> {
38
+ const held = senders.get(client.account.id);
39
+ if (held !== undefined) return held;
40
+ const made = makeProfileCache<SenderProfile>(
41
+ async (userId) => {
42
+ const full = await client.tg.getFullUser(Number(userId));
43
+ const about = nonEmpty(full.bio);
44
+ return about === undefined ? null : { from_about: about };
45
+ },
46
+ {
47
+ onError: (userId, err) => {
48
+ process.stderr.write(`telegram[${client.account.id}] could not read the profile of ${userId}: ${errMsg(err)}\n`);
49
+ },
50
+ },
51
+ );
52
+ senders.set(client.account.id, made);
53
+ return made;
54
+ }
55
+
34
56
  function emitMessage(client: UserClient, m: Message): void {
35
57
  log.debug(
36
58
  { train: 'telegram', account: client.account.id, messageId: m.id },
37
59
  'inbound message received',
38
60
  );
39
61
  if (isOwnEcho(m)) return;
40
- const env = envelope(client.account.id, m);
62
+ const lookup = m.sender.type === 'user' && !m.sender.isBot ? sendersOf(client).within(String(m.sender.id)) : Promise.resolve(null);
63
+ lookup
64
+ .then((profile) => {
65
+ deliver(client, m, { ...envelope(client.account.id, m), ...profile });
66
+ })
67
+ .catch((err: unknown) => process.stderr.write(`telegram[${client.account.id}] inbound not emitted: ${errMsg(err)}\n`));
68
+ }
69
+
70
+ function deliver(client: UserClient, m: Message, env: Record<string, unknown>): void {
41
71
  emit(env);
42
72
  if (m.media !== null)
43
73
  saveMediaAndEmit(client, m, env).catch((err: unknown) => {
@@ -2,7 +2,7 @@ import { errMsg } from '@metro-labs/core/log';
2
2
  import { accounts, lineOf } from './accounts.js';
3
3
  import { mintId, SELF_URI } from './wire.js';
4
4
  import { mediaRefOf, saveTelegramMedia } from './attachments.js';
5
- import type { TgMsg, TgReaction, TgReactionCount } from './types.js';
5
+ import type { TgMsg, TgReaction, TgReactionCount, TgUser } from './types.js';
6
6
 
7
7
  export type { TgMsg, TgReaction, TgReactionCount };
8
8
 
@@ -72,6 +72,11 @@ function addressing(accountId: string, m: TgMsg): Record<string, unknown> {
72
72
  };
73
73
  }
74
74
 
75
+ export const fullName = (u: TgUser | undefined): string | undefined => {
76
+ const name = [u?.first_name, u?.last_name].filter((part) => typeof part === 'string' && part !== '').join(' ');
77
+ return name === '' ? undefined : name;
78
+ };
79
+
75
80
  export function envelope(accountId: string, m: TgMsg): Record<string, unknown> {
76
81
  const { line } = lineForMsg(accountId, m);
77
82
  return {
@@ -83,7 +88,7 @@ export function envelope(accountId: string, m: TgMsg): Record<string, unknown> {
83
88
  line_name: m.chat.title ?? m.chat.first_name ?? undefined,
84
89
  from: `metro://telegram-bot/${accountId}/user/${m.from?.id ?? 'unknown'}`,
85
90
  from_name: m.from?.username ? `@${m.from.username}` : m.from?.first_name,
86
- from_display_name: m.from?.first_name,
91
+ from_display_name: fullName(m.from),
87
92
  message_id: String(m.message_id),
88
93
  text: projectText(m),
89
94
  payload: m,
@@ -113,7 +118,7 @@ export function reactionEnvelope(
113
118
  line: lineOf(accountId, r.chat.id),
114
119
  from: `metro://telegram-bot/${accountId}/user/${r.user?.id ?? 'unknown'}`,
115
120
  from_name: r.user?.username ? `@${r.user.username}` : r.user?.first_name,
116
- from_display_name: r.user?.first_name,
121
+ from_display_name: fullName(r.user),
117
122
  message_id: String(r.message_id),
118
123
  emoji: added[0],
119
124
  event: { type: 'react', emoji: added[0], targetId: String(r.message_id) },
@@ -1,4 +1,5 @@
1
1
  import { errMsg } from '@metro-labs/core/log';
2
+ import { makeProfileCache, nonEmpty, type SenderProfile } from '@metro-labs/core/stations/sender-profile';
2
3
  import { accounts, loadAccounts, tg, type Account } from './accounts.js';
3
4
  import { emit } from './wire.js';
4
5
  import {
@@ -23,12 +24,29 @@ interface Update {
23
24
  message_reaction_count?: TgReactionCount;
24
25
  }
25
26
 
27
+ const senders = makeProfileCache<SenderProfile>(
28
+ async (key) => {
29
+ const [id, userId] = key.split(':', 2) as [string, string];
30
+ const chat = await tg<{ bio?: unknown }>(id, 'getChat', { chat_id: Number(userId) }, 5000);
31
+ const about = nonEmpty(chat.bio);
32
+ return about === undefined ? null : { from_about: about };
33
+ },
34
+ { onError: (key, err) => process.stderr.write(`telegram-bot: could not read the profile of ${key}: ${errMsg(err)}\n`) },
35
+ );
36
+
37
+ function handleMessage(id: string, m: TgMsg): void {
38
+ const lookup = m.from === undefined || m.chat.type !== 'private' ? Promise.resolve(null) : senders.within(`${id}:${String(m.from.id)}`);
39
+ lookup
40
+ .then((profile) => {
41
+ const env: Record<string, unknown> = { ...envelope(id, m), ...profile };
42
+ emitInbound(emit, id, env);
43
+ saveMediaAndEmit(emit, id, m, env.id as string);
44
+ })
45
+ .catch((err: unknown) => process.stderr.write(`telegram-bot[${id}] inbound not emitted: ${errMsg(err)}\n`));
46
+ }
47
+
26
48
  function handleUpdate(id: string, u: Update): void {
27
- if (u.message && !u.message.from?.is_bot) {
28
- const env = envelope(id, u.message);
29
- emitInbound(emit, id, env);
30
- saveMediaAndEmit(emit, id, u.message, env.id as string);
31
- }
49
+ if (u.message && !u.message.from?.is_bot) handleMessage(id, u.message);
32
50
  if (u.message_reaction) {
33
51
  const env = reactionEnvelope(id, u.message_reaction);
34
52
  if (env) emitInbound(emit, id, env);
@@ -5,6 +5,14 @@ export interface TgEntity {
5
5
  user?: { id: number };
6
6
  }
7
7
 
8
+ export interface TgUser {
9
+ id: number;
10
+ username?: string;
11
+ first_name?: string;
12
+ last_name?: string;
13
+ is_bot?: boolean;
14
+ }
15
+
8
16
  export interface TgMsg {
9
17
  entities?: TgEntity[];
10
18
  caption_entities?: TgEntity[];
@@ -12,12 +20,7 @@ export interface TgMsg {
12
20
  message_id: number;
13
21
  date: number;
14
22
  chat: { id: number; type: string; title?: string; first_name?: string };
15
- from?: {
16
- id: number;
17
- username?: string;
18
- first_name?: string;
19
- is_bot?: boolean;
20
- };
23
+ from?: TgUser;
21
24
  text?: string;
22
25
  caption?: string;
23
26
  message_thread_id?: number;
@@ -36,12 +39,7 @@ export interface TgMsg {
36
39
  export interface TgReaction {
37
40
  chat: { id: number; type: string };
38
41
  message_id: number;
39
- user?: {
40
- id: number;
41
- username?: string;
42
- first_name?: string;
43
- is_bot?: boolean;
44
- };
42
+ user?: TgUser;
45
43
  date: number;
46
44
  old_reaction: { type: string; emoji?: string }[];
47
45
  new_reaction: { type: string; emoji?: string }[];
@@ -14,6 +14,7 @@ import type { WhatsAppAccount } from './types.js';
14
14
  import type { InboundMessage, ReactionInput } from './format.js';
15
15
  import type { SenderFound } from './resolve.js';
16
16
  import type { ProfileChange } from '@metro-labs/core/stations/profile';
17
+ import { nonEmpty, type SenderProfile } from '@metro-labs/core/stations/sender-profile';
17
18
  import { toInbound, toReaction, type ReactionEvent, type SelfRef } from './parse.js';
18
19
  import { baileysLogger } from './logger.js';
19
20
  import { useAccountAuthState } from './auth-state.js';
@@ -53,6 +54,7 @@ export interface WAClient {
53
54
  reuploadMedia(m: WAMessage): Promise<WAMessage>;
54
55
  lookupSender(number: string): Promise<SenderFound>;
55
56
  setProfile(change: ProfileChange): Promise<void>;
57
+ senderProfile(jid: string): Promise<SenderProfile | null>;
56
58
  disconnect(): Promise<void>;
57
59
  }
58
60
 
@@ -347,6 +349,14 @@ export function createClient(account: WhatsAppAccount): WAClient {
347
349
  await sock.updateProfilePicture(me, { url: change.avatar.path });
348
350
  }
349
351
  },
352
+ async senderProfile(jid) {
353
+ const sock = await ready(st);
354
+ const [statuses, avatar] = await Promise.all([sock.fetchStatus(jid).catch(() => undefined), sock.profilePictureUrl(jid, 'image').catch(() => undefined)]);
355
+ const about = nonEmpty((statuses?.[0]?.status as { status?: unknown } | undefined)?.status);
356
+ const picture = nonEmpty(avatar);
357
+ if (about === undefined && picture === undefined) return null;
358
+ return { ...(about === undefined ? {} : { from_about: about }), ...(picture === undefined ? {} : { from_avatar: picture }) };
359
+ },
350
360
  async disconnect() {
351
361
  st.closed = true;
352
362
  try {
@@ -1,5 +1,6 @@
1
1
  import type { WAMessage } from 'baileys';
2
2
  import { errMsg } from '@metro-labs/core/log';
3
+ import { makeProfileCache } from '@metro-labs/core/stations/sender-profile';
3
4
  import { emit } from './wire.js';
4
5
  import {
5
6
  attachmentFailedEnvelope,
@@ -39,19 +40,31 @@ async function saveMedia(
39
40
  }
40
41
 
41
42
  export async function startInbound(client: WAClient): Promise<void> {
43
+ const senders = makeProfileCache((jid) => client.senderProfile(jid), {
44
+ onError: (jid, err) => process.stderr.write(`whatsapp[${client.account.id}] could not read the profile of ${jid}: ${errMsg(err)}\n`),
45
+ });
42
46
  await client.start({
43
47
  onMessage: (m, raw) => {
44
- const env = envelope(m);
45
- emit(env);
46
- if (!m.media) return;
47
- saveMedia(client, m, raw, String(env.id)).catch((err: unknown) => {
48
- process.stderr.write(
49
- `whatsapp[${m.accountId}] media event not emitted: ${errMsg(err)}\n`,
50
- );
51
- });
48
+ senders
49
+ .within(m.senderJid)
50
+ .then((profile) => {
51
+ const env = { ...envelope(m), ...profile };
52
+ emit(env);
53
+ deliverMedia(client, m, raw, env);
54
+ })
55
+ .catch((err: unknown) => {
56
+ process.stderr.write(`whatsapp[${m.accountId}] inbound not emitted: ${errMsg(err)}\n`);
57
+ });
52
58
  },
53
59
  onReaction: (r) => {
54
60
  emit(reactionEnvelope(r));
55
61
  },
56
62
  });
57
63
  }
64
+
65
+ function deliverMedia(client: WAClient, m: InboundMessage, raw: WAMessage, env: Record<string, unknown>): void {
66
+ if (!m.media) return;
67
+ saveMedia(client, m, raw, String(env.id)).catch((err: unknown) => {
68
+ process.stderr.write(`whatsapp[${m.accountId}] media event not emitted: ${errMsg(err)}\n`);
69
+ });
70
+ }
@@ -39,7 +39,7 @@ export async function createGroupWithMembers(
39
39
  return created;
40
40
  }
41
41
 
42
- async function resolveAddresses(
42
+ export async function resolveAddresses(
43
43
  acct: Account,
44
44
  inboxIds: string[],
45
45
  ): Promise<Record<string, string>> {
@@ -8,6 +8,7 @@ import {
8
8
  } from './accounts.js';
9
9
  import { emitInbound, envelope } from './emit.js';
10
10
  import { groupNameFor } from './conv-helpers.js';
11
+ import { senderFieldsWithin } from './sender.js';
11
12
  import { handleControlDm, pushInbound } from './push.js';
12
13
  import { readCalls } from '@metro-labs/core/trains/protocol';
13
14
  import { handleCall } from './actions.js';
@@ -65,6 +66,7 @@ async function handleStreamMessage(
65
66
  );
66
67
  if (!conv) return;
67
68
  const env = envelope(id, msg, conv);
69
+ Object.assign(env, await senderFieldsWithin(acct, msg.senderInboxId));
68
70
  const name = await groupNameFor(msg.conversationId, conv);
69
71
  if (name) {
70
72
  env.line_name = name;
@@ -23,7 +23,7 @@ const STAMP_CLEAR_URL = 'https://stamp.fyi/clear/';
23
23
  const ZERO = '0x0000000000000000000000000000000000000000';
24
24
  const TEXT_KEYS = { name: 'name', bio: 'description', avatar: 'avatar' } as const;
25
25
 
26
- const REGISTRY_ABI = [
26
+ export const REGISTRY_ABI = [
27
27
  { name: 'resolver', type: 'function', stateMutability: 'view', inputs: [{ name: 'node', type: 'bytes32' }], outputs: [{ name: '', type: 'address' }] },
28
28
  ] as const;
29
29
 
@@ -42,7 +42,7 @@ const REVERSE_ABI = [
42
42
  { name: 'setName', type: 'function', stateMutability: 'nonpayable', inputs: [{ name: 'name', type: 'string' }], outputs: [{ name: '', type: 'bytes32' }] },
43
43
  ] as const;
44
44
 
45
- const NAME_ABI = [
45
+ export const NAME_ABI = [
46
46
  { name: 'name', type: 'function', stateMutability: 'view', inputs: [{ name: 'node', type: 'bytes32' }], outputs: [{ name: '', type: 'string' }] },
47
47
  ] as const;
48
48
 
@@ -0,0 +1,95 @@
1
+ import { namehash, type Hex } from 'viem';
2
+ import { normalize } from 'viem/ens';
3
+ import { makeProfileCache, type SenderProfile as SenderFields } from '@metro-labs/core/stations/sender-profile';
4
+ import { accounts, type Account } from './accounts.js';
5
+ import { resolveAddresses } from './conv-helpers.js';
6
+ import { nameOf } from './names.js';
7
+ import { BASENAME_L2_RESOLVER, BASENAME_REGISTRY, NAME_ABI, REGISTRY_ABI, reverseNodeOf } from './profile.js';
8
+ import { makePublicClient, type BaseClient } from './smart.js';
9
+
10
+ export interface SenderProfile {
11
+ address: string;
12
+ name: string | null;
13
+ displayName: string | null;
14
+ about: string | null;
15
+ avatar: string | null;
16
+ }
17
+
18
+ const BASE_RPC = 'https://mainnet.base.org';
19
+ const LOOKUP_MS = 4000;
20
+ const ZERO = '0x0000000000000000000000000000000000000000';
21
+ const AVATAR_RE = /^(https?:\/\/|ipfs:\/\/|data:image\/)/i;
22
+
23
+ const TEXT_ABI = [
24
+ { name: 'addr', type: 'function', stateMutability: 'view', inputs: [{ name: 'node', type: 'bytes32' }], outputs: [{ name: '', type: 'address' }] },
25
+ {
26
+ name: 'text',
27
+ type: 'function',
28
+ stateMutability: 'view',
29
+ inputs: [{ name: 'node', type: 'bytes32' }, { name: 'key', type: 'string' }],
30
+ outputs: [{ name: '', type: 'string' }],
31
+ },
32
+ ] as const;
33
+
34
+ export const baseRpc = (): string => process.env.METRO_BASE_RPC ?? BASE_RPC;
35
+
36
+ let reader: BaseClient | null = null;
37
+ const readerFor = (): BaseClient => (reader ??= makePublicClient(baseRpc()));
38
+
39
+ const trimmed = (v: string): string | null => (v.trim() === '' ? null : v.trim());
40
+
41
+ async function resolverOf(client: BaseClient, node: Hex): Promise<Hex> {
42
+ const found: Hex = await client.readContract({ address: BASENAME_REGISTRY, abi: REGISTRY_ABI, functionName: 'resolver', args: [node] }).catch(() => ZERO);
43
+ return found === ZERO ? BASENAME_L2_RESOLVER : found;
44
+ }
45
+
46
+ async function primaryName(client: BaseClient, address: string): Promise<string | null> {
47
+ const node = reverseNodeOf(address);
48
+ const found = await client.readContract({ address: await resolverOf(client, node), abi: NAME_ABI, functionName: 'name', args: [node] }).catch(() => '');
49
+ return found === '' ? null : found;
50
+ }
51
+
52
+ async function recordsOf(client: BaseClient, name: string, address: string): Promise<SenderProfile | null> {
53
+ const node = namehash(normalize(name));
54
+ const resolver = { address: await resolverOf(client, node), abi: TEXT_ABI } as const;
55
+ const text = (key: string): Promise<string> => client.readContract({ ...resolver, functionName: 'text', args: [node, key] }).catch(() => '');
56
+ const [forward, displayName, about, avatar] = await Promise.all([
57
+ client.readContract({ ...resolver, functionName: 'addr', args: [node] }).catch(() => ZERO),
58
+ text('name'),
59
+ text('description'),
60
+ text('avatar'),
61
+ ]);
62
+ if (forward.toLowerCase() !== address.toLowerCase()) return null;
63
+ return { address, name, displayName: trimmed(displayName), about: trimmed(about), avatar: AVATAR_RE.test(avatar.trim()) ? avatar.trim() : null };
64
+ }
65
+
66
+ export async function profileOf(address: string, client: BaseClient = readerFor(), fetchImpl: typeof fetch = fetch): Promise<SenderProfile> {
67
+ const bare: SenderProfile = { address, name: null, displayName: null, about: null, avatar: null };
68
+ const name = (await primaryName(client, address)) ?? (await nameOf(address, fetchImpl).catch(() => null));
69
+ if (name === null) return bare;
70
+ return (await recordsOf(client, name, address).catch(() => null)) ?? bare;
71
+ }
72
+
73
+ const senders = makeProfileCache<SenderProfile>(
74
+ async (key) => {
75
+ const [accountId, inboxId] = key.split(':', 2) as [string, string];
76
+ const acct = accounts.get(accountId);
77
+ if (acct === undefined) return null;
78
+ const address = (await resolveAddresses(acct, [inboxId]))[inboxId];
79
+ return address === undefined ? null : profileOf(address);
80
+ },
81
+ { timeoutMs: LOOKUP_MS, onError: (key, err) => process.stderr.write(`xmtp: could not resolve the sender ${key}: ${err instanceof Error ? err.message : String(err)}\n`) },
82
+ );
83
+
84
+ export const senderFields = (p: SenderProfile | null): SenderFields | null =>
85
+ p === null
86
+ ? null
87
+ : {
88
+ from_name: p.name ?? p.address,
89
+ ...(p.displayName === null ? {} : { from_display_name: p.displayName }),
90
+ ...(p.avatar === null ? {} : { from_avatar: p.avatar }),
91
+ ...(p.about === null ? {} : { from_about: p.about }),
92
+ };
93
+
94
+ export const senderFieldsWithin = (acct: Account, inboxId: string): Promise<SenderFields | null> =>
95
+ senders.within(`${acct.cfg.id}:${inboxId}`).then(senderFields);
@@ -18,7 +18,7 @@ const ENTRY_POINT = getEntryPoint('0.7');
18
18
  export const zerodevRpc = (): string =>
19
19
  `https://rpc.zerodev.app/api/v3/${process.env.METRO_ZERODEV_PROJECT ?? STAGE_ZERODEV_PROJECT}/chain/${String(SMART_CHAIN_ID)}`;
20
20
 
21
- const makePublicClient = (rpc: string) => createPublicClient({ chain: base, transport: http(rpc) });
21
+ export const makePublicClient = (rpc: string) => createPublicClient({ chain: base, transport: http(rpc) });
22
22
 
23
23
  export type BaseClient = ReturnType<typeof makePublicClient>;
24
24
 
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.160"
2
+ "version": "0.1.0-beta.162"
3
3
  }