@stage-labs/metro 0.1.0-beta.161 → 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 (34) 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/stations/sender-profile.ts +64 -0
  5. package/runtime/node_modules/@metro-labs/core/src/stations/types.ts +1 -0
  6. package/runtime/node_modules/@metro-labs/daemon/src/mcp/instructions.ts +21 -0
  7. package/runtime/node_modules/@metro-labs/daemon/src/mcp/profile-lookup.ts +58 -0
  8. package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +1 -1
  9. package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-dispatch.ts +8 -0
  10. package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-schemas.ts +0 -20
  11. package/runtime/node_modules/@metro-labs/discord-bot/src/actions.ts +2 -1
  12. package/runtime/node_modules/@metro-labs/discord-bot/src/format.ts +6 -2
  13. package/runtime/node_modules/@metro-labs/discord-bot/src/profile.ts +20 -0
  14. package/runtime/node_modules/@metro-labs/discord-bot/src/station.ts +1 -0
  15. package/runtime/node_modules/@metro-labs/telegram/src/actions.ts +2 -1
  16. package/runtime/node_modules/@metro-labs/telegram/src/format.ts +3 -2
  17. package/runtime/node_modules/@metro-labs/telegram/src/profile.ts +34 -0
  18. package/runtime/node_modules/@metro-labs/telegram/src/station.ts +1 -0
  19. package/runtime/node_modules/@metro-labs/telegram-bot/src/actions.ts +2 -1
  20. package/runtime/node_modules/@metro-labs/telegram-bot/src/format.ts +8 -3
  21. package/runtime/node_modules/@metro-labs/telegram-bot/src/profile.ts +32 -0
  22. package/runtime/node_modules/@metro-labs/telegram-bot/src/station.ts +1 -0
  23. package/runtime/node_modules/@metro-labs/telegram-bot/src/types.ts +10 -12
  24. package/runtime/node_modules/@metro-labs/whatsapp/src/actions.ts +11 -0
  25. package/runtime/node_modules/@metro-labs/whatsapp/src/client.ts +13 -0
  26. package/runtime/node_modules/@metro-labs/whatsapp/src/station.ts +1 -0
  27. package/runtime/node_modules/@metro-labs/xmtp/src/actions.ts +2 -0
  28. package/runtime/node_modules/@metro-labs/xmtp/src/conv-helpers.ts +1 -1
  29. package/runtime/node_modules/@metro-labs/xmtp/src/index.ts +2 -0
  30. package/runtime/node_modules/@metro-labs/xmtp/src/profile.ts +2 -2
  31. package/runtime/node_modules/@metro-labs/xmtp/src/sender.ts +108 -0
  32. package/runtime/node_modules/@metro-labs/xmtp/src/smart.ts +1 -1
  33. package/runtime/node_modules/@metro-labs/xmtp/src/station.ts +1 -0
  34. 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.161",
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.161",
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",
@@ -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",
@@ -0,0 +1,64 @@
1
+ export interface SenderProfile {
2
+ id: string;
3
+ name?: string;
4
+ display_name?: string;
5
+ about?: string;
6
+ avatar?: string;
7
+ address?: string;
8
+ }
9
+
10
+ export interface ProfileCacheOptions {
11
+ ttlMs?: number;
12
+ max?: number;
13
+ onError: (key: string, err: unknown) => void;
14
+ }
15
+
16
+ export interface ProfileCache<T> {
17
+ get(key: string): Promise<T | null>;
18
+ peek(key: string): T | null;
19
+ }
20
+
21
+ const TTL_MS = 10 * 60_000;
22
+ const MAX = 2000;
23
+
24
+ interface Held<T> {
25
+ at: number;
26
+ value: Promise<T | null>;
27
+ settled: T | null;
28
+ }
29
+
30
+ export const nonEmpty = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined);
31
+
32
+ export function makeProfileCache<T>(lookup: (key: string) => Promise<T | null>, opts: ProfileCacheOptions): ProfileCache<T> {
33
+ const ttl = opts.ttlMs ?? TTL_MS;
34
+ const max = opts.max ?? MAX;
35
+ const cache = new Map<string, Held<T>>();
36
+ const fresh = (key: string): Held<T> | undefined => {
37
+ const held = cache.get(key);
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);
53
+ while (cache.size > max) {
54
+ const oldest = cache.keys().next();
55
+ if (oldest.done) break;
56
+ cache.delete(oldest.value);
57
+ }
58
+ return held;
59
+ };
60
+ return {
61
+ get: (key) => (fresh(key) ?? start(key)).value,
62
+ peek: (key) => (fresh(key) ?? start(key)).settled,
63
+ };
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,
@@ -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
+ '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
+ }
@@ -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';
@@ -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);
@@ -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.';
@@ -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,
@@ -74,6 +74,11 @@ 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
+ });
81
+
77
82
  export function messageEnvelope(
78
83
  accountId: string,
79
84
  m: Message,
@@ -115,8 +120,7 @@ export function messageEnvelope(
115
120
  line,
116
121
  line_name: 'name' in m.channel ? m.channel.name : undefined,
117
122
  from: `metro://discord-bot/${accountId}/user/${m.author.id}`,
118
- from_name: m.author.username,
119
- from_display_name: m.author.globalName ?? undefined,
123
+ ...senderOf(m),
120
124
  message_id: m.id,
121
125
  text,
122
126
  is_private: m.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
  });
@@ -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 {
@@ -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) =>
@@ -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) },
@@ -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
  };
@@ -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 }[];
@@ -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,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 { makeProfileCache, 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
 
@@ -278,6 +280,16 @@ export function createClient(account: WhatsAppAccount): WAClient {
278
280
  acks: makeAckWatch(),
279
281
  };
280
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
+ );
281
293
  return {
282
294
  account,
283
295
  self() {
@@ -347,6 +359,7 @@ export function createClient(account: WhatsAppAccount): WAClient {
347
359
  await sock.updateProfilePicture(me, { url: change.avatar.path });
348
360
  }
349
361
  },
362
+ senderProfile: (jid) => senders.get(jid),
350
363
  async disconnect() {
351
364
  st.closed = true;
352
365
  try {
@@ -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,
@@ -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 { senderFieldsNow } 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, senderFieldsNow(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,108 @@
1
+ import { namehash, type Hex } from 'viem';
2
+ import { normalize } from 'viem/ens';
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';
7
+ import { resolveAddresses } from './conv-helpers.js';
8
+ import { nameOf } from './names.js';
9
+ import { BASENAME_L2_RESOLVER, BASENAME_REGISTRY, NAME_ABI, REGISTRY_ABI, reverseNodeOf } from './profile.js';
10
+ import { makePublicClient, type BaseClient } from './smart.js';
11
+
12
+ export interface SenderProfile {
13
+ address: string;
14
+ name: string | null;
15
+ displayName: string | null;
16
+ about: string | null;
17
+ avatar: string | null;
18
+ }
19
+
20
+ const BASE_RPC = 'https://mainnet.base.org';
21
+ const ZERO = '0x0000000000000000000000000000000000000000';
22
+ const AVATAR_RE = /^(https?:\/\/|ipfs:\/\/|data:image\/)/i;
23
+
24
+ const TEXT_ABI = [
25
+ { name: 'addr', type: 'function', stateMutability: 'view', inputs: [{ name: 'node', type: 'bytes32' }], outputs: [{ name: '', type: 'address' }] },
26
+ {
27
+ name: 'text',
28
+ type: 'function',
29
+ stateMutability: 'view',
30
+ inputs: [{ name: 'node', type: 'bytes32' }, { name: 'key', type: 'string' }],
31
+ outputs: [{ name: '', type: 'string' }],
32
+ },
33
+ ] as const;
34
+
35
+ export const baseRpc = (): string => process.env.METRO_BASE_RPC ?? BASE_RPC;
36
+
37
+ let reader: BaseClient | null = null;
38
+ const readerFor = (): BaseClient => (reader ??= makePublicClient(baseRpc()));
39
+
40
+ const trimmed = (v: string): string | null => (v.trim() === '' ? null : v.trim());
41
+
42
+ async function resolverOf(client: BaseClient, node: Hex): Promise<Hex> {
43
+ const found: Hex = await client.readContract({ address: BASENAME_REGISTRY, abi: REGISTRY_ABI, functionName: 'resolver', args: [node] }).catch(() => ZERO);
44
+ return found === ZERO ? BASENAME_L2_RESOLVER : found;
45
+ }
46
+
47
+ async function primaryName(client: BaseClient, address: string): Promise<string | null> {
48
+ const node = reverseNodeOf(address);
49
+ const found = await client.readContract({ address: await resolverOf(client, node), abi: NAME_ABI, functionName: 'name', args: [node] }).catch(() => '');
50
+ return found === '' ? null : found;
51
+ }
52
+
53
+ async function recordsOf(client: BaseClient, name: string, address: string): Promise<SenderProfile | null> {
54
+ const node = namehash(normalize(name));
55
+ const resolver = { address: await resolverOf(client, node), abi: TEXT_ABI } as const;
56
+ const text = (key: string): Promise<string> => client.readContract({ ...resolver, functionName: 'text', args: [node, key] }).catch(() => '');
57
+ const [forward, displayName, about, avatar] = await Promise.all([
58
+ client.readContract({ ...resolver, functionName: 'addr', args: [node] }).catch(() => ZERO),
59
+ text('name'),
60
+ text('description'),
61
+ text('avatar'),
62
+ ]);
63
+ if (forward.toLowerCase() !== address.toLowerCase()) return null;
64
+ return { address, name, displayName: trimmed(displayName), about: trimmed(about), avatar: AVATAR_RE.test(avatar.trim()) ? avatar.trim() : null };
65
+ }
66
+
67
+ export async function profileOf(address: string, client: BaseClient = readerFor(), fetchImpl: typeof fetch = fetch): Promise<SenderProfile> {
68
+ const bare: SenderProfile = { address, name: null, displayName: null, about: null, avatar: null };
69
+ const name = (await primaryName(client, address)) ?? (await nameOf(address, fetchImpl).catch(() => null));
70
+ if (name === null) return bare;
71
+ return (await recordsOf(client, name, address).catch(() => null)) ?? bare;
72
+ }
73
+
74
+ const key = (acct: Account, inboxId: string): string => `${acct.cfg.id}:${inboxId}`;
75
+
76
+ const senders = makeProfileCache<SenderProfile>(
77
+ async (k) => {
78
+ const [accountId, inboxId] = k.split(':', 2) as [string, string];
79
+ const acct = accounts.get(accountId);
80
+ if (acct === undefined) return null;
81
+ const address = (await resolveAddresses(acct, [inboxId]))[inboxId];
82
+ return address === undefined ? null : profileOf(address);
83
+ },
84
+ { onError: (k, err) => process.stderr.write(`xmtp: could not resolve the sender ${k}: ${err instanceof Error ? err.message : String(err)}\n`) },
85
+ );
86
+
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
+ }
@@ -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
 
@@ -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.161"
2
+ "version": "0.1.0-beta.163"
3
3
  }