@stage-labs/metro 0.1.0-beta.162 → 0.1.0-beta.164
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/src/events.ts +0 -2
- package/runtime/node_modules/@metro-labs/core/src/protocol.ts +0 -2
- package/runtime/node_modules/@metro-labs/core/src/stations/sender-profile.ts +28 -24
- package/runtime/node_modules/@metro-labs/core/src/stations/types.ts +1 -0
- package/runtime/node_modules/@metro-labs/daemon/src/channels/inbound.ts +4 -6
- package/runtime/node_modules/@metro-labs/daemon/src/channels/pending.ts +4 -16
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/instructions.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/profile-lookup.ts +58 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-dispatch.ts +8 -0
- package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +0 -2
- package/runtime/node_modules/@metro-labs/discord-bot/src/actions.ts +2 -1
- package/runtime/node_modules/@metro-labs/discord-bot/src/format.ts +0 -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/inbound.ts +1 -31
- 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/index.ts +5 -23
- 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/whatsapp/src/actions.ts +11 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/client.ts +12 -9
- package/runtime/node_modules/@metro-labs/whatsapp/src/inbound.ts +8 -21
- 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/index.ts +2 -2
- package/runtime/node_modules/@metro-labs/xmtp/src/sender.ts +50 -33
- 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.164",
|
|
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.164",
|
|
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",
|
|
@@ -1,29 +1,30 @@
|
|
|
1
1
|
export interface SenderProfile {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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
|
|
36
|
+
const fresh = (key: string): Held<T> | undefined => {
|
|
36
37
|
const held = cache.get(key);
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
58
|
+
return held;
|
|
50
59
|
};
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
}
|
|
@@ -11,7 +11,7 @@ import { buildWebhookNote } from './webhook-note.js';
|
|
|
11
11
|
import { replyMeta } from './addressed.js';
|
|
12
12
|
import {
|
|
13
13
|
capSet,
|
|
14
|
-
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
|
53
|
-
const
|
|
54
|
-
return
|
|
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
|
-
...
|
|
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
|
-
'
|
|
21
|
-
'
|
|
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
|
+
}
|
|
@@ -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
|
|
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
|
+
}
|
|
@@ -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)
|
|
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
|
+
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
}
|
|
@@ -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 {
|
|
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,
|
|
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,11 +1,13 @@
|
|
|
1
|
-
import { namehash, type Hex } from 'viem';
|
|
1
|
+
import { createPublicClient, http, namehash, type Hex } from 'viem';
|
|
2
|
+
import { base } from 'viem/chains';
|
|
2
3
|
import { normalize } from 'viem/ens';
|
|
3
|
-
import { makeProfileCache, type SenderProfile as
|
|
4
|
-
import {
|
|
4
|
+
import { makeProfileCache, type SenderProfile as Profile } from '@metro-labs/core/stations/sender-profile';
|
|
5
|
+
import { respond } from './wire.js';
|
|
6
|
+
import { TrainError } from '@metro-labs/core/train-error';
|
|
7
|
+
import { accountForCall, accounts, type Account } from './accounts.js';
|
|
5
8
|
import { resolveAddresses } from './conv-helpers.js';
|
|
6
9
|
import { nameOf } from './names.js';
|
|
7
10
|
import { BASENAME_L2_RESOLVER, BASENAME_REGISTRY, NAME_ABI, REGISTRY_ABI, reverseNodeOf } from './profile.js';
|
|
8
|
-
import { makePublicClient, type BaseClient } from './smart.js';
|
|
9
11
|
|
|
10
12
|
export interface SenderProfile {
|
|
11
13
|
address: string;
|
|
@@ -15,8 +17,7 @@ export interface SenderProfile {
|
|
|
15
17
|
avatar: string | null;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
|
-
const BASE_RPC = 'https://
|
|
19
|
-
const LOOKUP_MS = 4000;
|
|
20
|
+
const BASE_RPC = 'https://rpc.brovider.xyz/8453';
|
|
20
21
|
const ZERO = '0x0000000000000000000000000000000000000000';
|
|
21
22
|
const AVATAR_RE = /^(https?:\/\/|ipfs:\/\/|data:image\/)/i;
|
|
22
23
|
|
|
@@ -33,28 +34,32 @@ const TEXT_ABI = [
|
|
|
33
34
|
|
|
34
35
|
export const baseRpc = (): string => process.env.METRO_BASE_RPC ?? BASE_RPC;
|
|
35
36
|
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
const makeReader = (rpc: string) => createPublicClient({ chain: base, transport: http(rpc), batch: { multicall: true } });
|
|
38
|
+
|
|
39
|
+
export type Reader = ReturnType<typeof makeReader>;
|
|
40
|
+
|
|
41
|
+
let reader: Reader | null = null;
|
|
42
|
+
const readerFor = (): Reader => (reader ??= makeReader(baseRpc()));
|
|
38
43
|
|
|
39
44
|
const trimmed = (v: string): string | null => (v.trim() === '' ? null : v.trim());
|
|
40
45
|
|
|
41
|
-
async function resolverOf(client:
|
|
42
|
-
const found: Hex = await client.readContract({ address: BASENAME_REGISTRY, abi: REGISTRY_ABI, functionName: 'resolver', args: [node] })
|
|
46
|
+
async function resolverOf(client: Reader, node: Hex): Promise<Hex> {
|
|
47
|
+
const found: Hex = await client.readContract({ address: BASENAME_REGISTRY, abi: REGISTRY_ABI, functionName: 'resolver', args: [node] });
|
|
43
48
|
return found === ZERO ? BASENAME_L2_RESOLVER : found;
|
|
44
49
|
}
|
|
45
50
|
|
|
46
|
-
async function primaryName(client:
|
|
51
|
+
async function primaryName(client: Reader, address: string): Promise<string | null> {
|
|
47
52
|
const node = reverseNodeOf(address);
|
|
48
|
-
const found = await client.readContract({ address: await resolverOf(client, node), abi: NAME_ABI, functionName: 'name', args: [node] })
|
|
53
|
+
const found = await client.readContract({ address: await resolverOf(client, node), abi: NAME_ABI, functionName: 'name', args: [node] });
|
|
49
54
|
return found === '' ? null : found;
|
|
50
55
|
}
|
|
51
56
|
|
|
52
|
-
async function recordsOf(client:
|
|
57
|
+
async function recordsOf(client: Reader, name: string, address: string): Promise<SenderProfile | null> {
|
|
53
58
|
const node = namehash(normalize(name));
|
|
54
59
|
const resolver = { address: await resolverOf(client, node), abi: TEXT_ABI } as const;
|
|
55
|
-
const text = (key: string): Promise<string> => client.readContract({ ...resolver, functionName: 'text', args: [node, key] })
|
|
60
|
+
const text = (key: string): Promise<string> => client.readContract({ ...resolver, functionName: 'text', args: [node, key] });
|
|
56
61
|
const [forward, displayName, about, avatar] = await Promise.all([
|
|
57
|
-
client.readContract({ ...resolver, functionName: 'addr', args: [node] })
|
|
62
|
+
client.readContract({ ...resolver, functionName: 'addr', args: [node] }),
|
|
58
63
|
text('name'),
|
|
59
64
|
text('description'),
|
|
60
65
|
text('avatar'),
|
|
@@ -63,33 +68,45 @@ async function recordsOf(client: BaseClient, name: string, address: string): Pro
|
|
|
63
68
|
return { address, name, displayName: trimmed(displayName), about: trimmed(about), avatar: AVATAR_RE.test(avatar.trim()) ? avatar.trim() : null };
|
|
64
69
|
}
|
|
65
70
|
|
|
66
|
-
export async function profileOf(address: string, client:
|
|
71
|
+
export async function profileOf(address: string, client: Reader = readerFor(), fetchImpl: typeof fetch = fetch): Promise<SenderProfile> {
|
|
67
72
|
const bare: SenderProfile = { address, name: null, displayName: null, about: null, avatar: null };
|
|
68
|
-
const name = (await primaryName(client, address)) ?? (await nameOf(address, fetchImpl)
|
|
73
|
+
const name = (await primaryName(client, address)) ?? (await nameOf(address, fetchImpl));
|
|
69
74
|
if (name === null) return bare;
|
|
70
|
-
return (await recordsOf(client, name, address)
|
|
75
|
+
return (await recordsOf(client, name, address)) ?? bare;
|
|
71
76
|
}
|
|
72
77
|
|
|
78
|
+
const key = (acct: Account, inboxId: string): string => `${acct.cfg.id}:${inboxId}`;
|
|
79
|
+
|
|
73
80
|
const senders = makeProfileCache<SenderProfile>(
|
|
74
|
-
async (
|
|
75
|
-
const [accountId, inboxId] =
|
|
81
|
+
async (k) => {
|
|
82
|
+
const [accountId, inboxId] = k.split(':', 2) as [string, string];
|
|
76
83
|
const acct = accounts.get(accountId);
|
|
77
84
|
if (acct === undefined) return null;
|
|
78
85
|
const address = (await resolveAddresses(acct, [inboxId]))[inboxId];
|
|
79
86
|
return address === undefined ? null : profileOf(address);
|
|
80
87
|
},
|
|
81
|
-
{
|
|
88
|
+
{ onError: (k, err) => process.stderr.write(`xmtp: could not resolve the sender ${k}: ${err instanceof Error ? err.message : String(err)}\n`) },
|
|
82
89
|
);
|
|
83
90
|
|
|
84
|
-
export const senderFields = (p: SenderProfile | null):
|
|
85
|
-
p === null
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
91
|
+
export const senderFields = (p: SenderProfile | null): Record<string, string> =>
|
|
92
|
+
p === null ? {} : { from_name: p.name ?? p.address, ...(p.displayName === null ? {} : { from_display_name: p.displayName }) };
|
|
93
|
+
|
|
94
|
+
export const senderFieldsNow = (acct: Account, inboxId: string): Record<string, string> => senderFields(senders.peek(key(acct, inboxId)));
|
|
95
|
+
|
|
96
|
+
export const senderProfile = (acct: Account, inboxId: string): Promise<SenderProfile | null> => senders.get(key(acct, inboxId));
|
|
97
|
+
|
|
98
|
+
export const profileView = (inboxId: string, p: SenderProfile | null): Profile => ({
|
|
99
|
+
id: inboxId,
|
|
100
|
+
...(p === null ? {} : { address: p.address }),
|
|
101
|
+
...(p?.name == null ? {} : { name: p.name }),
|
|
102
|
+
...(p?.displayName == null ? {} : { display_name: p.displayName }),
|
|
103
|
+
...(p?.about == null ? {} : { about: p.about }),
|
|
104
|
+
...(p?.avatar == null ? {} : { avatar: p.avatar }),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
export async function profileAction(id: string, args: Record<string, unknown>): Promise<void> {
|
|
108
|
+
const user = typeof args.user === 'string' ? args.user.trim() : '';
|
|
109
|
+
if (user === '') throw new TrainError('xmtp_user_required', 'profile needs the inbox id of the person', { retryable: false });
|
|
110
|
+
const acct = accountForCall({ account: typeof args.account === 'string' ? args.account : undefined });
|
|
111
|
+
respond(id, { result: profileView(user, await senderProfile(acct, user)) });
|
|
112
|
+
}
|
|
@@ -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