@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.
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/core/package.json +2 -1
- package/runtime/node_modules/@metro-labs/core/src/stations/sender-profile.ts +64 -0
- package/runtime/node_modules/@metro-labs/core/src/stations/types.ts +1 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/instructions.ts +21 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/profile-lookup.ts +58 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-dispatch.ts +8 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-schemas.ts +0 -20
- package/runtime/node_modules/@metro-labs/discord-bot/src/actions.ts +2 -1
- package/runtime/node_modules/@metro-labs/discord-bot/src/format.ts +6 -2
- package/runtime/node_modules/@metro-labs/discord-bot/src/profile.ts +20 -0
- package/runtime/node_modules/@metro-labs/discord-bot/src/station.ts +1 -0
- package/runtime/node_modules/@metro-labs/telegram/src/actions.ts +2 -1
- package/runtime/node_modules/@metro-labs/telegram/src/format.ts +3 -2
- package/runtime/node_modules/@metro-labs/telegram/src/profile.ts +34 -0
- package/runtime/node_modules/@metro-labs/telegram/src/station.ts +1 -0
- package/runtime/node_modules/@metro-labs/telegram-bot/src/actions.ts +2 -1
- package/runtime/node_modules/@metro-labs/telegram-bot/src/format.ts +8 -3
- package/runtime/node_modules/@metro-labs/telegram-bot/src/profile.ts +32 -0
- package/runtime/node_modules/@metro-labs/telegram-bot/src/station.ts +1 -0
- package/runtime/node_modules/@metro-labs/telegram-bot/src/types.ts +10 -12
- package/runtime/node_modules/@metro-labs/whatsapp/src/actions.ts +11 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/client.ts +13 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/station.ts +1 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/actions.ts +2 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/conv-helpers.ts +1 -1
- package/runtime/node_modules/@metro-labs/xmtp/src/index.ts +2 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/profile.ts +2 -2
- package/runtime/node_modules/@metro-labs/xmtp/src/sender.ts +108 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/smart.ts +1 -1
- package/runtime/node_modules/@metro-labs/xmtp/src/station.ts +1 -0
- 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.
|
|
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.
|
|
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
|
+
}
|
|
@@ -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 './
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -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.
|
|
17
|
-
|
|
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
|
+
}
|
|
@@ -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
|
|
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
|
|
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
|
+
}
|
|
@@ -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 {
|
|
@@ -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,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,
|
package/runtime/runtime.json
CHANGED