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

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 (33) 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/src/events.ts +0 -2
  4. package/runtime/node_modules/@metro-labs/core/src/protocol.ts +0 -2
  5. package/runtime/node_modules/@metro-labs/core/src/stations/sender-profile.ts +28 -24
  6. package/runtime/node_modules/@metro-labs/core/src/stations/types.ts +1 -0
  7. package/runtime/node_modules/@metro-labs/daemon/src/channels/inbound.ts +4 -6
  8. package/runtime/node_modules/@metro-labs/daemon/src/channels/pending.ts +4 -16
  9. package/runtime/node_modules/@metro-labs/daemon/src/mcp/instructions.ts +2 -2
  10. package/runtime/node_modules/@metro-labs/daemon/src/mcp/profile-lookup.ts +58 -0
  11. package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-dispatch.ts +8 -0
  12. package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +0 -2
  13. package/runtime/node_modules/@metro-labs/discord-bot/src/actions.ts +2 -1
  14. package/runtime/node_modules/@metro-labs/discord-bot/src/format.ts +0 -2
  15. package/runtime/node_modules/@metro-labs/discord-bot/src/profile.ts +20 -0
  16. package/runtime/node_modules/@metro-labs/discord-bot/src/station.ts +1 -0
  17. package/runtime/node_modules/@metro-labs/telegram/src/actions.ts +2 -1
  18. package/runtime/node_modules/@metro-labs/telegram/src/inbound.ts +1 -31
  19. package/runtime/node_modules/@metro-labs/telegram/src/profile.ts +34 -0
  20. package/runtime/node_modules/@metro-labs/telegram/src/station.ts +1 -0
  21. package/runtime/node_modules/@metro-labs/telegram-bot/src/actions.ts +2 -1
  22. package/runtime/node_modules/@metro-labs/telegram-bot/src/index.ts +5 -23
  23. package/runtime/node_modules/@metro-labs/telegram-bot/src/profile.ts +32 -0
  24. package/runtime/node_modules/@metro-labs/telegram-bot/src/station.ts +1 -0
  25. package/runtime/node_modules/@metro-labs/whatsapp/src/actions.ts +11 -0
  26. package/runtime/node_modules/@metro-labs/whatsapp/src/client.ts +12 -9
  27. package/runtime/node_modules/@metro-labs/whatsapp/src/inbound.ts +8 -21
  28. package/runtime/node_modules/@metro-labs/whatsapp/src/station.ts +1 -0
  29. package/runtime/node_modules/@metro-labs/xmtp/src/actions.ts +2 -0
  30. package/runtime/node_modules/@metro-labs/xmtp/src/index.ts +2 -2
  31. package/runtime/node_modules/@metro-labs/xmtp/src/sender.ts +31 -18
  32. package/runtime/node_modules/@metro-labs/xmtp/src/station.ts +1 -0
  33. 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.162",
3
+ "version": "0.1.0-beta.163",
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.162",
3
+ "version": "0.1.0-beta.163",
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",
@@ -20,8 +20,6 @@ export interface MetroEvent {
20
20
  from: Line;
21
21
  fromName?: string;
22
22
  fromDisplayName?: string;
23
- fromAvatar?: string;
24
- fromAbout?: string;
25
23
  to: Line;
26
24
  text?: string;
27
25
  messageId?: string;
@@ -33,8 +33,6 @@ 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;
38
36
  to?: string;
39
37
  message_id?: string;
40
38
  reply_to?: string;
@@ -1,29 +1,30 @@
1
1
  export interface SenderProfile {
2
- from_name?: string;
3
- from_display_name?: string;
4
- from_avatar?: string;
5
- from_about?: string;
2
+ id: string;
3
+ name?: string;
4
+ display_name?: string;
5
+ about?: string;
6
+ avatar?: string;
7
+ address?: string;
6
8
  }
7
9
 
8
10
  export interface ProfileCacheOptions {
9
11
  ttlMs?: number;
10
12
  max?: number;
11
- timeoutMs?: number;
12
13
  onError: (key: string, err: unknown) => void;
13
14
  }
14
15
 
15
16
  export interface ProfileCache<T> {
16
17
  get(key: string): Promise<T | null>;
17
- within(key: string, ms?: number): Promise<T | null>;
18
+ peek(key: string): T | null;
18
19
  }
19
20
 
20
21
  const TTL_MS = 10 * 60_000;
21
22
  const MAX = 2000;
22
- const TIMEOUT_MS = 3000;
23
23
 
24
24
  interface Held<T> {
25
25
  at: number;
26
26
  value: Promise<T | null>;
27
+ settled: T | null;
27
28
  }
28
29
 
29
30
  export const nonEmpty = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined);
@@ -32,29 +33,32 @@ export function makeProfileCache<T>(lookup: (key: string) => Promise<T | null>,
32
33
  const ttl = opts.ttlMs ?? TTL_MS;
33
34
  const max = opts.max ?? MAX;
34
35
  const cache = new Map<string, Held<T>>();
35
- const get = (key: string): Promise<T | null> => {
36
+ const fresh = (key: string): Held<T> | undefined => {
36
37
  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 });
38
+ return held !== undefined && Date.now() - held.at < ttl ? held : undefined;
39
+ };
40
+ const start = (key: string): Held<T> => {
41
+ const held: Held<T> = { at: Date.now(), settled: null, value: Promise.resolve(null) };
42
+ held.value = lookup(key)
43
+ .then((value) => {
44
+ held.settled = value;
45
+ return value;
46
+ })
47
+ .catch((err: unknown) => {
48
+ opts.onError(key, err);
49
+ cache.delete(key);
50
+ return null;
51
+ });
52
+ cache.set(key, held);
44
53
  while (cache.size > max) {
45
54
  const oldest = cache.keys().next();
46
55
  if (oldest.done) break;
47
56
  cache.delete(oldest.value);
48
57
  }
49
- return value;
58
+ return held;
50
59
  };
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]);
60
+ return {
61
+ get: (key) => (fresh(key) ?? start(key)).value,
62
+ peek: (key) => (fresh(key) ?? start(key)).settled,
58
63
  };
59
- return { get, within };
60
64
  }
@@ -102,6 +102,7 @@ export interface Station {
102
102
  resolvesSenders?: boolean;
103
103
  profileFields?: ReadonlySet<ProfileField>;
104
104
  claimsName?: boolean;
105
+ readsProfiles?: boolean;
105
106
  attachmentMode: AttachmentMode;
106
107
  sendAttachments?(
107
108
  line: string,
@@ -11,7 +11,7 @@ import { buildWebhookNote } from './webhook-note.js';
11
11
  import { replyMeta } from './addressed.js';
12
12
  import {
13
13
  capSet,
14
- profileMeta,
14
+ displayNameMeta,
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
- ...profileMeta(e),
151
+ ...displayNameMeta(e.fromDisplayName),
152
152
  },
153
153
  });
154
154
  }
@@ -206,8 +206,6 @@ 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),
211
209
  attachments: atts.map((a) => ({ kind: a.kind, name: a.name })),
212
210
  saved: new Set<number>(),
213
211
  timer: setTimeout(() => {
@@ -248,7 +246,7 @@ export class InboundRelay {
248
246
  message_id: str(ev.messageId),
249
247
  line_name: str(ev.lineName),
250
248
  from_name: str(ev.fromName),
251
- ...profileMeta(ev),
249
+ ...displayNameMeta(ev.fromDisplayName),
252
250
  reaction: emoji,
253
251
  target_id: target,
254
252
  },
@@ -332,7 +330,7 @@ export class InboundRelay {
332
330
  message_id: str(ev.messageId),
333
331
  line_name: str(ev.lineName),
334
332
  from_name: str(ev.fromName),
335
- ...profileMeta(ev),
333
+ ...displayNameMeta(ev.fromDisplayName),
336
334
  ...replyMeta(ev, this.sentIds),
337
335
  },
338
336
  });
@@ -15,8 +15,6 @@ export interface PendingMsg {
15
15
  lineName: string;
16
16
  fromName: string;
17
17
  fromDisplayName: string;
18
- fromAvatar: string;
19
- fromAbout: string;
20
18
  attachments: PendingAtt[];
21
19
  saved: Set<number>;
22
20
  timer: ReturnType<typeof setTimeout>;
@@ -32,8 +30,6 @@ export interface MediaCtx {
32
30
  lineName?: string;
33
31
  fromName?: string;
34
32
  fromDisplayName?: string;
35
- fromAvatar?: string;
36
- fromAbout?: string;
37
33
  }
38
34
 
39
35
  export function capSet(set: Set<string>, max: number): void {
@@ -49,23 +45,17 @@ export const tsMeta = (v: unknown): Record<string, string> => {
49
45
  return ts ? { ts } : {};
50
46
  };
51
47
 
52
- const optional = (key: string, v: unknown): Record<string, string> => {
53
- const text = str(v);
54
- return text ? { [key]: text } : {};
48
+ export const displayNameMeta = (v: unknown): Record<string, string> => {
49
+ const name = str(v);
50
+ return name ? { from_display_name: name } : {};
55
51
  };
56
52
 
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
-
63
53
  export const senderMeta = (c: MediaCtx): Record<string, string> => ({
64
54
  ...tsMeta(c.ts),
65
55
  ...(c.messageId ? { message_id: c.messageId } : {}),
66
56
  ...(c.lineName ? { line_name: c.lineName } : {}),
67
57
  ...(c.fromName ? { from_name: c.fromName } : {}),
68
- ...profileMeta(c),
58
+ ...displayNameMeta(c.fromDisplayName),
69
59
  });
70
60
 
71
61
  export function takeMediaCtx(buf: PendingMsg): MediaCtx {
@@ -79,8 +69,6 @@ export function takeMediaCtx(buf: PendingMsg): MediaCtx {
79
69
  lineName: buf.lineName,
80
70
  fromName: buf.fromName,
81
71
  fromDisplayName: buf.fromDisplayName,
82
- fromAvatar: buf.fromAvatar,
83
- fromAbout: buf.fromAbout,
84
72
  };
85
73
  buf.text = '';
86
74
  return ctx;
@@ -17,5 +17,5 @@ export const MCP_INSTRUCTIONS =
17
17
  'carries addressed="direct" (a private chat), "mention" (you were named) or "reply" (it ' +
18
18
  'answers one of your own messages); one without `addressed` merely happened in a room you ' +
19
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).';
20
+ 'meta names the sender (`from_name`, `from_display_name`) when the station knows them; ' +
21
+ '`get_profile` with the `from` tells you more about a person (name, bio, avatar, address).';
@@ -0,0 +1,58 @@
1
+ import { Line } from '@metro-labs/core/lines';
2
+ import type { Station, ToolResult } from '@metro-labs/core/stations/types';
3
+ import { str } from '@metro-labs/core/str';
4
+ import { stationByName } from '../stations/registry.js';
5
+ import { errResult, makeCtx, okJson, toErr } from './ctx.js';
6
+
7
+ export const GET_PROFILE_TOOL = {
8
+ name: 'get_profile',
9
+ description:
10
+ 'Who is this person? Give the `from` attribute of one of their messages verbatim ' +
11
+ '(`metro://<station>/<account>/user/<id>`) and get what their network says about them: ' +
12
+ '{id, name, display_name, about, avatar, address}. XMTP answers the Basename ' +
13
+ '(<label>.stage.base.eth), its name, description and avatar records, and the wallet address; ' +
14
+ 'WhatsApp the about line and the picture; Telegram the handle, the name and the bio; Discord ' +
15
+ 'the username, the global name and the avatar. Fields the network does not have are absent. ' +
16
+ 'The answer is cached for ten minutes, so ask when you want to know someone, not on every message.',
17
+ inputSchema: {
18
+ type: 'object',
19
+ properties: {
20
+ from: { type: 'string', description: 'The `from` of a message, exactly as received.' },
21
+ },
22
+ required: ['from'],
23
+ },
24
+ };
25
+
26
+ export interface ProfileTarget {
27
+ station: Station;
28
+ account: string;
29
+ user: string;
30
+ }
31
+
32
+ export function profileTarget(a: Record<string, unknown>): ProfileTarget | { refused: string } {
33
+ const from = str(a.from);
34
+ if (!from) return { refused: 'get_profile requires `from`' };
35
+ const parsed = Line.parse(from);
36
+ const at = parsed?.path.indexOf('user') ?? -1;
37
+ if (parsed === null || at < 0 || at + 1 >= parsed.path.length) return { refused: `${from} is not a person: expected metro://<station>/<account>/user/<id>` };
38
+ const station = stationByName(parsed.station);
39
+ if (!station) return { refused: `no station named ${parsed.station}` };
40
+ if (station.readsProfiles !== true) return { refused: `${station.name} has no profile metro can read` };
41
+ return { station, account: at === 0 ? 'default' : parsed.path.slice(0, at).join('/'), user: parsed.path.slice(at + 1).join('/') };
42
+ }
43
+
44
+ export const profileScopeLine = (a: Record<string, unknown>): string | undefined => {
45
+ const target = profileTarget(a);
46
+ return 'refused' in target ? undefined : `metro://${target.station.name}/${target.account}/user/${target.user}`;
47
+ };
48
+
49
+ export async function dispatchGetProfile(a: Record<string, unknown>): Promise<ToolResult> {
50
+ const target = profileTarget(a);
51
+ if ('refused' in target) return errResult(target.refused);
52
+ try {
53
+ const { result } = await makeCtx(target.station.name).call('profile', { account: target.account, user: target.user });
54
+ return okJson(result);
55
+ } catch (e) {
56
+ return toErr('get_profile', e);
57
+ }
58
+ }
@@ -1,4 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { Line } from '@metro-labs/core/lines';
2
3
  import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
4
  import {
4
5
  ListToolsRequestSchema,
@@ -22,6 +23,7 @@ import {
22
23
  } from './group-tools.js';
23
24
  import { dispatchCreateUpload } from './upload-tool.js';
24
25
  import { dispatchSetProfile, profileCapabilities, SET_PROFILE_TOOL } from './profile-tool.js';
26
+ import { dispatchGetProfile, GET_PROFILE_TOOL, profileScopeLine } from './profile-lookup.js';
25
27
  import { callTargetDenied, lineTargetDenied } from '../agents/scope.js';
26
28
  import { SOURCE_KEYS } from '../stations/attach-resolve.js';
27
29
  import { decodedLengthOf } from '../stations/attach-inline.js';
@@ -51,6 +53,7 @@ const CORE_DISPATCH: Record<
51
53
  export_invite: dispatchInviteLink,
52
54
  create_upload: dispatchCreateUpload,
53
55
  set_profile: dispatchSetProfile,
56
+ get_profile: dispatchGetProfile,
54
57
  };
55
58
 
56
59
  const toolList = (): { tools: unknown[] } => ({
@@ -65,6 +68,7 @@ const toolList = (): { tools: unknown[] } => ({
65
68
  ),
66
69
  LIST_ACCOUNTS_TOOL,
67
70
  SET_PROFILE_TOOL,
71
+ GET_PROFILE_TOOL,
68
72
  ],
69
73
  });
70
74
 
@@ -106,6 +110,10 @@ export function scopeDenied(
106
110
  args: Record<string, unknown>,
107
111
  ): boolean {
108
112
  const allowed = allowedAgents(identity);
113
+ if (name === 'get_profile') {
114
+ const line = profileScopeLine(args);
115
+ return line === undefined || callTargetDenied(allowed, Line.station(line) ?? '', { line });
116
+ }
109
117
  const station = stationForTool(name, args);
110
118
  if (station !== undefined) return callTargetDenied(allowed, station, args);
111
119
  return lineTargetDenied(allowed, args);
@@ -166,8 +166,6 @@ 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,
171
169
  to: (env.to ?? (isPrivate ? userSelf() : line)) as MetroEvent['to'],
172
170
  text,
173
171
  messageId: env.message_id,
@@ -27,7 +27,7 @@ import {
27
27
  } from '@metro-labs/core/stations/station-runtime';
28
28
  import { joinVoice, leaveVoice, voiceDebug, voiceTranscribe } from './voice.js';
29
29
  import { speak } from './voice-speak.js';
30
- import { setProfile } from './profile.js';
30
+ import { readProfile, setProfile } from './profile.js';
31
31
  import { discordMembers } from './members.js';
32
32
  import {
33
33
  groupAddHandler,
@@ -377,6 +377,7 @@ const HANDLERS: Record<string, StationHandler> = {
377
377
  listMembers,
378
378
  set_presence: presence,
379
379
  set_profile: setProfile,
380
+ profile: readProfile,
380
381
  joinVoice,
381
382
  leaveVoice,
382
383
  speak,
@@ -77,7 +77,6 @@ function addressing(accountId: string, m: Message): { mentions_self: boolean; re
77
77
  const senderOf = (m: Message): Record<string, string | undefined> => ({
78
78
  from_name: m.author.username,
79
79
  from_display_name: m.member?.displayName ?? m.author.globalName ?? undefined,
80
- from_avatar: (m.member ?? m.author).displayAvatarURL({ size: 256 }),
81
80
  });
82
81
 
83
82
  export function messageEnvelope(
@@ -149,7 +148,6 @@ export function reactionEnvelope(
149
148
  from: `metro://discord-bot/${accountId}/user/${u.id}`,
150
149
  from_name: u.username,
151
150
  from_display_name: u.globalName ?? undefined,
152
- from_avatar: u.displayAvatarURL({ size: 256 }),
153
151
  message_id: r.message.id,
154
152
  emoji: r.emoji.name ?? r.emoji.id ?? '?',
155
153
  is_private: r.message.guildId == null,
@@ -8,6 +8,7 @@ import {
8
8
  type ProfileApplied,
9
9
  type ProfileChange,
10
10
  } from '@metro-labs/core/stations/profile';
11
+ import type { SenderProfile } from '@metro-labs/core/stations/sender-profile';
11
12
  import { accountFor, accounts } from './accounts.js';
12
13
  import { respond } from './wire.js';
13
14
 
@@ -46,3 +47,22 @@ export async function setProfile(
46
47
  const result: ProfileApplied = { account: accountId, applied: fieldsOf(change) };
47
48
  respond(id, { result });
48
49
  }
50
+
51
+ export async function readProfile(id: string, args: Record<string, unknown>): Promise<void> {
52
+ const accountId = accountFor({ account: args.account as string | undefined });
53
+ const acct = accounts.get(accountId);
54
+ if (!acct) {
55
+ respond(id, { error: `unknown account '${accountId}'` });
56
+ return;
57
+ }
58
+ const user = typeof args.user === 'string' ? args.user.trim() : '';
59
+ if (!/^\d+$/.test(user)) throw new TrainError('discord_user_required', 'profile needs the Discord id of the person', { retryable: false });
60
+ const found = await acct.client.users.fetch(user);
61
+ const result: SenderProfile = {
62
+ id: user,
63
+ name: found.username,
64
+ ...(found.globalName === null ? {} : { display_name: found.globalName }),
65
+ avatar: found.displayAvatarURL({ size: 256 }),
66
+ };
67
+ respond(id, { result });
68
+ }
@@ -17,5 +17,6 @@ export const discordBotStation: Station = {
17
17
  groupOps: new Set<GroupOp>(['create_group', 'add_members', 'remove_members']),
18
18
  attachmentMode: 'canonical',
19
19
  profileFields: new Set<ProfileField>(PROFILE_FIELDS),
20
+ readsProfiles: true,
20
21
  tools: [],
21
22
  };
@@ -14,7 +14,7 @@ import {
14
14
  type CanonicalAttachment,
15
15
  } from './media-actions.js';
16
16
  import { clampLimit, shapeHistory } from './history.js';
17
- import { makeSetProfile } from './profile.js';
17
+ import { makeReadProfile, makeSetProfile } from './profile.js';
18
18
  import { fetchMembers, isRestricted, restrictedMemberList } from './members.js';
19
19
  import {
20
20
  groupAddMembers,
@@ -250,6 +250,7 @@ export function makeHandleCall(
250
250
  groupRemoveMembers: makeGroupRemove(clientFor),
251
251
  groupInviteLink: makeGroupInvite(clientFor),
252
252
  set_profile: makeSetProfile(clientFor),
253
+ profile: makeReadProfile(clientFor),
253
254
  },
254
255
  normalize: normalizeTelegramUser,
255
256
  });
@@ -1,6 +1,5 @@
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';
4
3
  import { emit } from './wire.js';
5
4
  import { envelope, isOwnEcho, attachmentSavedEnvelope } from './format.js';
6
5
  import { downloadMedia, isDownloadable } from './attachments.js';
@@ -32,42 +31,13 @@ async function saveMediaAndEmit(
32
31
  }
33
32
  }
34
33
 
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
-
56
34
  function emitMessage(client: UserClient, m: Message): void {
57
35
  log.debug(
58
36
  { train: 'telegram', account: client.account.id, messageId: m.id },
59
37
  'inbound message received',
60
38
  );
61
39
  if (isOwnEcho(m)) return;
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 {
40
+ const env = envelope(client.account.id, m);
71
41
  emit(env);
72
42
  if (m.media !== null)
73
43
  saveMediaAndEmit(client, m, env).catch((err: unknown) => {
@@ -8,6 +8,8 @@ import {
8
8
  } from '@metro-labs/core/stations/profile';
9
9
  import { respond, type StationHandler } from '@metro-labs/core/stations/station-runtime';
10
10
  import { str } from '@metro-labs/core/str';
11
+ import { makeProfileCache, nonEmpty, type ProfileCache, type SenderProfile } from '@metro-labs/core/stations/sender-profile';
12
+ import { TrainError } from '@metro-labs/core/train-error';
11
13
  import { accountFor } from './accounts.js';
12
14
  import type { UserClient } from './client.js';
13
15
 
@@ -34,3 +36,35 @@ export function makeSetProfile(clientFor: (accountId: string) => UserClient): St
34
36
  respond(id, { result });
35
37
  };
36
38
  }
39
+
40
+ const senders = new Map<string, ProfileCache<SenderProfile>>();
41
+
42
+ function sendersOf(client: UserClient): ProfileCache<SenderProfile> {
43
+ const held = senders.get(client.account.id);
44
+ if (held !== undefined) return held;
45
+ const made = makeProfileCache<SenderProfile>(
46
+ async (user) => {
47
+ const full = await client.tg.getFullUser(Number(user));
48
+ const handle = nonEmpty(full.username);
49
+ const display = nonEmpty([full.firstName, full.lastName].filter((part) => typeof part === 'string').join(' '));
50
+ return {
51
+ id: user,
52
+ ...(handle === undefined ? {} : { name: `@${handle}` }),
53
+ ...(display === undefined ? {} : { display_name: display }),
54
+ ...(nonEmpty(full.bio) === undefined ? {} : { about: nonEmpty(full.bio) }),
55
+ };
56
+ },
57
+ { onError: (user, err) => process.stderr.write(`telegram[${client.account.id}] could not read the profile of ${user}: ${err instanceof Error ? err.message : String(err)}\n`) },
58
+ );
59
+ senders.set(client.account.id, made);
60
+ return made;
61
+ }
62
+
63
+ export function makeReadProfile(clientFor: (accountId: string) => UserClient): StationHandler {
64
+ return async (id, args) => {
65
+ const accountId = accountFor({ account: str(args.account) });
66
+ const user = str(args.user) ?? '';
67
+ if (!/^\d+$/.test(user)) throw new TrainError('telegram_user_required', 'profile needs the numeric Telegram id of the person', { retryable: false });
68
+ respond(id, { result: (await sendersOf(clientFor(accountId)).get(user)) ?? { id: user } });
69
+ };
70
+ }
@@ -22,5 +22,6 @@ export const telegramStation: Station = {
22
22
  ]),
23
23
  attachmentMode: 'canonical',
24
24
  profileFields: new Set<ProfileField>(PROFILE_FIELDS),
25
+ readsProfiles: true,
25
26
  tools: [],
26
27
  };
@@ -23,7 +23,7 @@ import {
23
23
  sendLocation,
24
24
  sendMedia,
25
25
  } from './media-actions.js';
26
- import { setProfile } from './profile.js';
26
+ import { readProfile, setProfile } from './profile.js';
27
27
 
28
28
 
29
29
 
@@ -273,6 +273,7 @@ const HANDLERS: Record<string, StationHandler> = {
273
273
  delete: remove,
274
274
  listMembers,
275
275
  set_profile: setProfile,
276
+ profile: readProfile,
276
277
  send_photo: (id, args) =>
277
278
  media(id, 'sendPhoto', 'photo', ((args.caption as string) ?? '') + ' [image]', args),
278
279
  send_document: (id, args) =>
@@ -1,5 +1,4 @@
1
1
  import { errMsg } from '@metro-labs/core/log';
2
- import { makeProfileCache, nonEmpty, type SenderProfile } from '@metro-labs/core/stations/sender-profile';
3
2
  import { accounts, loadAccounts, tg, type Account } from './accounts.js';
4
3
  import { emit } from './wire.js';
5
4
  import {
@@ -24,29 +23,12 @@ interface Update {
24
23
  message_reaction_count?: TgReactionCount;
25
24
  }
26
25
 
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
-
48
26
  function handleUpdate(id: string, u: Update): void {
49
- if (u.message && !u.message.from?.is_bot) handleMessage(id, u.message);
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
+ }
50
32
  if (u.message_reaction) {
51
33
  const env = reactionEnvelope(id, u.message_reaction);
52
34
  if (env) emitInbound(emit, id, env);
@@ -7,6 +7,8 @@ import {
7
7
  type ProfileAvatar,
8
8
  type ProfileChange,
9
9
  } from '@metro-labs/core/stations/profile';
10
+ import { makeProfileCache, nonEmpty, type SenderProfile } from '@metro-labs/core/stations/sender-profile';
11
+ import { TrainError } from '@metro-labs/core/train-error';
10
12
  import { accountFor, tg, tgForm } from './accounts.js';
11
13
  import { respond } from './wire.js';
12
14
 
@@ -31,3 +33,33 @@ export async function setProfile(id: string, args: Record<string, unknown>): Pro
31
33
  const result: ProfileApplied = { account: accountId, applied: fieldsOf(change) };
32
34
  respond(id, { result });
33
35
  }
36
+
37
+ interface TgChat {
38
+ username?: unknown;
39
+ first_name?: unknown;
40
+ last_name?: unknown;
41
+ bio?: unknown;
42
+ }
43
+
44
+ const senders = makeProfileCache<SenderProfile>(
45
+ async (key) => {
46
+ const [accountId, user] = key.split(':', 2) as [string, string];
47
+ const chat = await tg<TgChat>(accountId, 'getChat', { chat_id: Number(user) }, 10_000);
48
+ const handle = nonEmpty(chat.username);
49
+ const display = nonEmpty([chat.first_name, chat.last_name].filter((part) => typeof part === 'string').join(' '));
50
+ return {
51
+ id: user,
52
+ ...(handle === undefined ? {} : { name: `@${handle}` }),
53
+ ...(display === undefined ? {} : { display_name: display }),
54
+ ...(nonEmpty(chat.bio) === undefined ? {} : { about: nonEmpty(chat.bio) }),
55
+ };
56
+ },
57
+ { onError: (key, err) => process.stderr.write(`telegram-bot: could not read the profile of ${key}: ${err instanceof Error ? err.message : String(err)}\n`) },
58
+ );
59
+
60
+ export async function readProfile(id: string, args: Record<string, unknown>): Promise<void> {
61
+ const accountId = accountFor({ account: args.account as string | undefined });
62
+ const user = typeof args.user === 'string' ? args.user.trim() : '';
63
+ if (!/^\d+$/.test(user)) throw new TrainError('telegram_user_required', 'profile needs the numeric Telegram id of the person', { retryable: false });
64
+ respond(id, { result: (await senders.get(`${accountId}:${user}`)) ?? { id: user } });
65
+ }
@@ -15,5 +15,6 @@ export const telegramBotStation: Station = {
15
15
  ]),
16
16
  attachmentMode: 'canonical',
17
17
  profileFields: new Set<ProfileField>(PROFILE_FIELDS),
18
+ readsProfiles: true,
18
19
  tools: [],
19
20
  };
@@ -185,6 +185,16 @@ function makeSetProfile(clientFor: ClientFor): StationHandler {
185
185
  };
186
186
  }
187
187
 
188
+ function makeProfile(clientFor: ClientFor): StationHandler {
189
+ return async (id, args) => {
190
+ const accountId = accountFor({ account: str(args.account) });
191
+ const user = str(args.user) ?? '';
192
+ if (user === '') throw new TrainError('whatsapp_user_required', 'profile needs the jid of the person', { retryable: false });
193
+ const profile = await guard(() => clientFor(accountId).senderProfile(user));
194
+ respond(id, { result: profile ?? { id: user } });
195
+ };
196
+ }
197
+
188
198
  export function makeHandleCall(
189
199
  clientFor: ClientFor,
190
200
  ): (msg: CallMsg) => Promise<void> {
@@ -197,6 +207,7 @@ export function makeHandleCall(
197
207
  delete: makeDelete(clientFor),
198
208
  resolve_sender: makeResolveSender(clientFor),
199
209
  set_profile: makeSetProfile(clientFor),
210
+ profile: makeProfile(clientFor),
200
211
  },
201
212
  normalize: normalizeWhatsApp,
202
213
  });
@@ -14,7 +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
+ 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
20
  import { useAccountAuthState } from './auth-state.js';
@@ -280,6 +280,16 @@ export function createClient(account: WhatsAppAccount): WAClient {
280
280
  acks: makeAckWatch(),
281
281
  };
282
282
  resetGate(st);
283
+ const senders = makeProfileCache<SenderProfile>(
284
+ async (jid) => {
285
+ const sock = await ready(st);
286
+ const [statuses, avatar] = await Promise.all([sock.fetchStatus(jid).catch(() => undefined), sock.profilePictureUrl(jid, 'image').catch(() => undefined)]);
287
+ const about = nonEmpty((statuses?.[0]?.status as { status?: unknown } | undefined)?.status);
288
+ const picture = nonEmpty(avatar);
289
+ return { id: jid, ...(about === undefined ? {} : { about }), ...(picture === undefined ? {} : { avatar: picture }) };
290
+ },
291
+ { onError: (jid, err) => process.stderr.write(`whatsapp[${st.account.id}] could not read the profile of ${jid}: ${errMsg(err)}\n`) },
292
+ );
283
293
  return {
284
294
  account,
285
295
  self() {
@@ -349,14 +359,7 @@ export function createClient(account: WhatsAppAccount): WAClient {
349
359
  await sock.updateProfilePicture(me, { url: change.avatar.path });
350
360
  }
351
361
  },
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
- },
362
+ senderProfile: (jid) => senders.get(jid),
360
363
  async disconnect() {
361
364
  st.closed = true;
362
365
  try {
@@ -1,6 +1,5 @@
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';
4
3
  import { emit } from './wire.js';
5
4
  import {
6
5
  attachmentFailedEnvelope,
@@ -40,31 +39,19 @@ async function saveMedia(
40
39
  }
41
40
 
42
41
  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
- });
46
42
  await client.start({
47
43
  onMessage: (m, raw) => {
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
- });
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
+ });
58
52
  },
59
53
  onReaction: (r) => {
60
54
  emit(reactionEnvelope(r));
61
55
  },
62
56
  });
63
57
  }
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
- }
@@ -15,6 +15,7 @@ export const whatsappStation: Station = {
15
15
  ]),
16
16
  attachmentMode: 'canonical',
17
17
  profileFields: new Set<ProfileField>(PROFILE_FIELDS),
18
+ readsProfiles: true,
18
19
  resolvesSenders: true,
19
20
  tools: [],
20
21
  };
@@ -22,6 +22,7 @@ import { convHandlers } from './actions-conv.js';
22
22
  import { normalizeXmtp } from '@metro-labs/core/stations/messaging-normalize';
23
23
  import { TrainError } from '@metro-labs/core/train-error';
24
24
  import { claimNameAction, nameAction, setProfile } from './profile.js';
25
+ import { profileAction } from './sender.js';
25
26
  import { makeStation, type CallMsg } from '@metro-labs/core/stations/station-runtime';
26
27
 
27
28
  type Args = Record<string, unknown>;
@@ -358,6 +359,7 @@ const handlers: Record<string, (id: string, args: Args) => Promise<void>> = {
358
359
  set_profile: setProfile,
359
360
  claim_name: claimNameAction,
360
361
  name: nameAction,
362
+ profile: profileAction,
361
363
  send,
362
364
  ask,
363
365
  sendPoll: ask,
@@ -8,7 +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
+ import { senderFieldsNow } from './sender.js';
12
12
  import { handleControlDm, pushInbound } from './push.js';
13
13
  import { readCalls } from '@metro-labs/core/trains/protocol';
14
14
  import { handleCall } from './actions.js';
@@ -66,7 +66,7 @@ async function handleStreamMessage(
66
66
  );
67
67
  if (!conv) return;
68
68
  const env = envelope(id, msg, conv);
69
- Object.assign(env, await senderFieldsWithin(acct, msg.senderInboxId));
69
+ Object.assign(env, senderFieldsNow(acct, msg.senderInboxId));
70
70
  const name = await groupNameFor(msg.conversationId, conv);
71
71
  if (name) {
72
72
  env.line_name = name;
@@ -1,7 +1,9 @@
1
1
  import { namehash, type Hex } from 'viem';
2
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';
3
+ import { makeProfileCache, type SenderProfile as Profile } from '@metro-labs/core/stations/sender-profile';
4
+ import { respond } from './wire.js';
5
+ import { TrainError } from '@metro-labs/core/train-error';
6
+ import { accountForCall, accounts, type Account } from './accounts.js';
5
7
  import { resolveAddresses } from './conv-helpers.js';
6
8
  import { nameOf } from './names.js';
7
9
  import { BASENAME_L2_RESOLVER, BASENAME_REGISTRY, NAME_ABI, REGISTRY_ABI, reverseNodeOf } from './profile.js';
@@ -16,7 +18,6 @@ export interface SenderProfile {
16
18
  }
17
19
 
18
20
  const BASE_RPC = 'https://mainnet.base.org';
19
- const LOOKUP_MS = 4000;
20
21
  const ZERO = '0x0000000000000000000000000000000000000000';
21
22
  const AVATAR_RE = /^(https?:\/\/|ipfs:\/\/|data:image\/)/i;
22
23
 
@@ -70,26 +71,38 @@ export async function profileOf(address: string, client: BaseClient = readerFor(
70
71
  return (await recordsOf(client, name, address).catch(() => null)) ?? bare;
71
72
  }
72
73
 
74
+ const key = (acct: Account, inboxId: string): string => `${acct.cfg.id}:${inboxId}`;
75
+
73
76
  const senders = makeProfileCache<SenderProfile>(
74
- async (key) => {
75
- const [accountId, inboxId] = key.split(':', 2) as [string, string];
77
+ async (k) => {
78
+ const [accountId, inboxId] = k.split(':', 2) as [string, string];
76
79
  const acct = accounts.get(accountId);
77
80
  if (acct === undefined) return null;
78
81
  const address = (await resolveAddresses(acct, [inboxId]))[inboxId];
79
82
  return address === undefined ? null : profileOf(address);
80
83
  },
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`) },
84
+ { onError: (k, err) => process.stderr.write(`xmtp: could not resolve the sender ${k}: ${err instanceof Error ? err.message : String(err)}\n`) },
82
85
  );
83
86
 
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);
87
+ export const senderFields = (p: SenderProfile | null): Record<string, string> =>
88
+ p === null ? {} : { from_name: p.name ?? p.address, ...(p.displayName === null ? {} : { from_display_name: p.displayName }) };
89
+
90
+ export const senderFieldsNow = (acct: Account, inboxId: string): Record<string, string> => senderFields(senders.peek(key(acct, inboxId)));
91
+
92
+ export const senderProfile = (acct: Account, inboxId: string): Promise<SenderProfile | null> => senders.get(key(acct, inboxId));
93
+
94
+ export const profileView = (inboxId: string, p: SenderProfile | null): Profile => ({
95
+ id: inboxId,
96
+ ...(p === null ? {} : { address: p.address }),
97
+ ...(p?.name == null ? {} : { name: p.name }),
98
+ ...(p?.displayName == null ? {} : { display_name: p.displayName }),
99
+ ...(p?.about == null ? {} : { about: p.about }),
100
+ ...(p?.avatar == null ? {} : { avatar: p.avatar }),
101
+ });
102
+
103
+ export async function profileAction(id: string, args: Record<string, unknown>): Promise<void> {
104
+ const user = typeof args.user === 'string' ? args.user.trim() : '';
105
+ if (user === '') throw new TrainError('xmtp_user_required', 'profile needs the inbox id of the person', { retryable: false });
106
+ const acct = accountForCall({ account: typeof args.account === 'string' ? args.account : undefined });
107
+ respond(id, { result: profileView(user, await senderProfile(acct, user)) });
108
+ }
@@ -10,6 +10,7 @@ export const xmtpStation: Station = {
10
10
  groupOps: new Set<GroupOp>(['create_group', 'add_members', 'remove_members']),
11
11
  attachmentMode: 'native',
12
12
  profileFields: new Set<ProfileField>(PROFILE_FIELDS),
13
+ readsProfiles: true,
13
14
  claimsName: true,
14
15
  sendAttachments: xmtpSendAttachments,
15
16
  tools: XMTP_TOOLS,
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.162"
2
+ "version": "0.1.0-beta.163"
3
3
  }