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