@stage-labs/metro 0.1.0-beta.152 → 0.1.0-beta.154
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/profile.ts +58 -0
- package/runtime/node_modules/@metro-labs/core/src/stations/types.ts +4 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/account-routes.ts +4 -2
- package/runtime/node_modules/@metro-labs/daemon/src/agents/accounts-api.ts +23 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/profile-tool.ts +94 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-dispatch.ts +5 -1
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +6 -8
- package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-xmtp.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/stations/attach.ts +1 -1
- package/runtime/node_modules/@metro-labs/discord-bot/src/actions.ts +2 -0
- package/runtime/node_modules/@metro-labs/discord-bot/src/profile.ts +48 -0
- package/runtime/node_modules/@metro-labs/discord-bot/src/station.ts +2 -0
- package/runtime/node_modules/@metro-labs/telegram/src/actions.ts +2 -0
- package/runtime/node_modules/@metro-labs/telegram/src/profile.ts +36 -0
- package/runtime/node_modules/@metro-labs/telegram/src/station.ts +2 -0
- package/runtime/node_modules/@metro-labs/telegram-bot/src/actions.ts +2 -0
- package/runtime/node_modules/@metro-labs/telegram-bot/src/profile.ts +33 -0
- package/runtime/node_modules/@metro-labs/telegram-bot/src/station.ts +2 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/actions.ts +13 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/client.ts +12 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/station.ts +2 -0
- package/runtime/node_modules/@metro-labs/xmtp/package.json +2 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/accounts.ts +6 -3
- package/runtime/node_modules/@metro-labs/xmtp/src/actions.ts +5 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/identity.ts +30 -16
- package/runtime/node_modules/@metro-labs/xmtp/src/names.ts +61 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/profile.ts +112 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/smart.ts +68 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/station.ts +3 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/verify.ts +19 -5
- package/runtime/runtime.json +1 -1
- package/runtime/stations.json +2 -0
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.154",
|
|
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.154",
|
|
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",
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
"./stations/station-runtime": "./src/stations/station-runtime.ts",
|
|
24
24
|
"./stations/account-store": "./src/stations/account-store.ts",
|
|
25
25
|
"./stations/attachments": "./src/stations/attachments.ts",
|
|
26
|
-
"./stations/messaging-normalize": "./src/stations/messaging-normalize.ts"
|
|
26
|
+
"./stations/messaging-normalize": "./src/stations/messaging-normalize.ts",
|
|
27
|
+
"./stations/profile": "./src/stations/profile.ts"
|
|
27
28
|
},
|
|
28
29
|
"scripts": {
|
|
29
30
|
"build": "tsc",
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { TrainError } from '../train-error.js';
|
|
2
|
+
|
|
3
|
+
export const PROFILE_FIELDS = ['name', 'bio', 'avatar'] as const;
|
|
4
|
+
export type ProfileField = (typeof PROFILE_FIELDS)[number];
|
|
5
|
+
|
|
6
|
+
export const NAME_MAX = 64;
|
|
7
|
+
export const BIO_MAX = 512;
|
|
8
|
+
|
|
9
|
+
export interface ProfileAvatar {
|
|
10
|
+
path: string;
|
|
11
|
+
mime: string;
|
|
12
|
+
name: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ProfileChange {
|
|
16
|
+
name?: string;
|
|
17
|
+
bio?: string;
|
|
18
|
+
avatar?: ProfileAvatar;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ProfileApplied {
|
|
22
|
+
account: string;
|
|
23
|
+
applied: ProfileField[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const text = (value: unknown, field: string, max: number): string | undefined => {
|
|
27
|
+
if (value === undefined) return undefined;
|
|
28
|
+
if (typeof value !== 'string') throw new TrainError('bad_request', `${field} must be a string`);
|
|
29
|
+
const trimmed = value.trim();
|
|
30
|
+
if (trimmed === '') throw new TrainError('bad_request', `${field} cannot be empty`);
|
|
31
|
+
if (trimmed.length > max) throw new TrainError('bad_request', `${field} is over ${String(max)} characters`);
|
|
32
|
+
return trimmed;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function avatarOf(value: unknown): ProfileAvatar | undefined {
|
|
36
|
+
if (value === undefined) return undefined;
|
|
37
|
+
const raw = (value ?? {}) as Record<string, unknown>;
|
|
38
|
+
if (typeof raw.path !== 'string' || raw.path === '') throw new TrainError('bad_request', 'avatar needs a local file');
|
|
39
|
+
return { path: raw.path, mime: typeof raw.mime === 'string' ? raw.mime : 'application/octet-stream', name: typeof raw.name === 'string' ? raw.name : 'avatar' };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function parseProfileChange(args: Record<string, unknown>): ProfileChange {
|
|
43
|
+
const change: ProfileChange = {};
|
|
44
|
+
const name = text(args.name, 'name', NAME_MAX);
|
|
45
|
+
const bio = text(args.bio, 'bio', BIO_MAX);
|
|
46
|
+
const avatar = avatarOf(args.avatar);
|
|
47
|
+
if (name !== undefined) change.name = name;
|
|
48
|
+
if (bio !== undefined) change.bio = bio;
|
|
49
|
+
if (avatar !== undefined) change.avatar = avatar;
|
|
50
|
+
if (Object.keys(change).length === 0) throw new TrainError('bad_request', 'set_profile needs at least one of name, bio, avatar');
|
|
51
|
+
return change;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const fieldsOf = (change: ProfileChange): ProfileField[] => PROFILE_FIELDS.filter((f) => change[f] !== undefined);
|
|
55
|
+
|
|
56
|
+
export const assertImage = (avatar: ProfileAvatar): void => {
|
|
57
|
+
if (!avatar.mime.startsWith('image/')) throw new TrainError('bad_request', `avatar must be an image, not ${avatar.mime}`);
|
|
58
|
+
};
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ProfileField } from './profile.js';
|
|
2
|
+
|
|
1
3
|
export type Verb =
|
|
2
4
|
| 'send'
|
|
3
5
|
| 'reply'
|
|
@@ -98,6 +100,8 @@ export interface Station {
|
|
|
98
100
|
messageVerbs: ReadonlySet<Verb>;
|
|
99
101
|
groupOps?: ReadonlySet<GroupOp>;
|
|
100
102
|
resolvesSenders?: boolean;
|
|
103
|
+
profileFields?: ReadonlySet<ProfileField>;
|
|
104
|
+
claimsName?: boolean;
|
|
101
105
|
attachmentMode: AttachmentMode;
|
|
102
106
|
sendAttachments?(
|
|
103
107
|
line: string,
|
|
@@ -10,7 +10,8 @@ export type AccountRoute =
|
|
|
10
10
|
| { kind: 'allowlist'; station: StationName; accountId: string }
|
|
11
11
|
| { kind: 'senders'; station: StationName; accountId: string }
|
|
12
12
|
| { kind: 'resolve'; station: StationName; accountId: string }
|
|
13
|
-
| { kind: 'enabled'; station: StationName; accountId: string }
|
|
13
|
+
| { kind: 'enabled'; station: StationName; accountId: string }
|
|
14
|
+
| { kind: 'name'; station: StationName; accountId: string };
|
|
14
15
|
|
|
15
16
|
const ROUTE_METHODS: Record<AccountRoute['kind'], string[]> = {
|
|
16
17
|
start: ['POST'],
|
|
@@ -21,9 +22,10 @@ const ROUTE_METHODS: Record<AccountRoute['kind'], string[]> = {
|
|
|
21
22
|
senders: ['GET'],
|
|
22
23
|
resolve: ['GET'],
|
|
23
24
|
enabled: ['PUT'],
|
|
25
|
+
name: ['GET', 'POST'],
|
|
24
26
|
};
|
|
25
27
|
|
|
26
|
-
const SUB_ROUTES = ['allowlist', 'senders', 'resolve', 'enabled'] as const;
|
|
28
|
+
const SUB_ROUTES = ['allowlist', 'senders', 'resolve', 'enabled', 'name'] as const;
|
|
27
29
|
|
|
28
30
|
type SubRoute = (typeof SUB_ROUTES)[number];
|
|
29
31
|
|
|
@@ -77,6 +77,11 @@ export interface AccountApiDeps {
|
|
|
77
77
|
accountId: string,
|
|
78
78
|
query: string,
|
|
79
79
|
) => Promise<unknown>;
|
|
80
|
+
accountCall: (
|
|
81
|
+
station: StationName,
|
|
82
|
+
action: string,
|
|
83
|
+
args: Record<string, unknown>,
|
|
84
|
+
) => Promise<unknown>;
|
|
80
85
|
setAccountEnabled: (
|
|
81
86
|
subject: string,
|
|
82
87
|
agentId: string,
|
|
@@ -110,6 +115,23 @@ async function handleResolve(
|
|
|
110
115
|
sendJson(req, res, 200, found);
|
|
111
116
|
}
|
|
112
117
|
|
|
118
|
+
async function handleName(
|
|
119
|
+
req: IncomingMessage,
|
|
120
|
+
res: ServerResponse,
|
|
121
|
+
deps: AccountApiDeps,
|
|
122
|
+
target: { station: StationName; accountId: string },
|
|
123
|
+
): Promise<void> {
|
|
124
|
+
if (stationByName(target.station)?.claimsName !== true)
|
|
125
|
+
throw new ApiError(`${target.station} accounts have no name to claim`, 400);
|
|
126
|
+
if (req.method === 'GET') {
|
|
127
|
+
sendJson(req, res, 200, await deps.accountCall(target.station, 'name', { account: target.accountId }));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const label = bodyField(await readJsonBody(req), 'label');
|
|
131
|
+
if (typeof label !== 'string' || label.trim() === '') throw new ApiError('a label is required', 400);
|
|
132
|
+
sendJson(req, res, 200, await deps.accountCall(target.station, 'claim_name', { account: target.accountId, label }));
|
|
133
|
+
}
|
|
134
|
+
|
|
113
135
|
async function activate(
|
|
114
136
|
deps: AccountApiDeps,
|
|
115
137
|
station: StationName,
|
|
@@ -342,6 +364,7 @@ async function dispatchRoute(
|
|
|
342
364
|
if (route.kind === 'allowlist') return handleAllowlist(req, res, deps, session, agentId, route);
|
|
343
365
|
if (route.kind === 'enabled') return handleEnabled(req, res, deps, session, agentId, route);
|
|
344
366
|
if (route.kind === 'resolve') return handleResolve(req, res, deps, route);
|
|
367
|
+
if (route.kind === 'name') return handleName(req, res, deps, route);
|
|
345
368
|
if (route.kind === 'senders') {
|
|
346
369
|
sendJson(req, res, 200, { senders: deps.recentSenders(route.station, route.accountId) });
|
|
347
370
|
return;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { CanonicalAttachment, Station, ToolResult } from '@metro-labs/core/stations/types';
|
|
2
|
+
import { PROFILE_FIELDS, type ProfileField } from '@metro-labs/core/stations/profile';
|
|
3
|
+
import { str } from '@metro-labs/core/str';
|
|
4
|
+
import { STATIONS, stationByName } from '../stations/registry.js';
|
|
5
|
+
import { cleanupAttachments, resolveAttachments, type ResolvedAttachment } from '../stations/attach-resolve.js';
|
|
6
|
+
import { errResult, makeCtx, okJson, toErr } from './ctx.js';
|
|
7
|
+
import { allowedAgents, currentIdentity } from './request-identity.js';
|
|
8
|
+
|
|
9
|
+
const SOURCE = { type: 'string' };
|
|
10
|
+
|
|
11
|
+
export const SET_PROFILE_TOOL = {
|
|
12
|
+
name: 'set_profile',
|
|
13
|
+
description:
|
|
14
|
+
'Change how one of your accounts presents itself on its network: the display name, the bio ' +
|
|
15
|
+
'(about line) and the avatar. Args: station (required), account? (which account of that station; ' +
|
|
16
|
+
'needed only when you have several), name?, bio?, avatar? (an image given like an attachment: ' +
|
|
17
|
+
'exactly one of upload, url, path, data, plus mime and name). At least one of name, bio, avatar. ' +
|
|
18
|
+
'Each station takes what its network allows (`list_accounts` reports the fields under ' +
|
|
19
|
+
'`profiles`); a field the station cannot set is refused by name, never dropped. Discord changes ' +
|
|
20
|
+
'the bot username and app description (Discord allows two username changes an hour); Telegram ' +
|
|
21
|
+
'bots set their name, description and photo; a Telegram user account sets its first name, bio ' +
|
|
22
|
+
'(70 chars) and photo; WhatsApp sets the profile name, about and picture; XMTP writes the ' +
|
|
23
|
+
'name, description and avatar records of the account\'s <label>.stage.base.eth name on Base, ' +
|
|
24
|
+
'so it needs a name first (claimed on the channel page, never by you). Visible to everyone ' +
|
|
25
|
+
'who talks to the account, so do it when asked, not on your own. Returns {account, applied}.',
|
|
26
|
+
inputSchema: {
|
|
27
|
+
type: 'object',
|
|
28
|
+
properties: {
|
|
29
|
+
station: { type: 'string', description: 'discord-bot | telegram-bot | telegram | whatsapp | xmtp' },
|
|
30
|
+
account: { type: 'string', description: 'The account id on that station, from list_accounts.' },
|
|
31
|
+
name: { type: 'string', description: 'The display name.' },
|
|
32
|
+
bio: { type: 'string', description: 'The bio or about line.' },
|
|
33
|
+
avatar: {
|
|
34
|
+
type: 'object',
|
|
35
|
+
description: 'The new picture, as an attachment source.',
|
|
36
|
+
properties: { upload: SOURCE, url: SOURCE, path: SOURCE, data: SOURCE, mime: SOURCE, name: SOURCE },
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
required: ['station'],
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const profileCapabilities = (): Record<string, ProfileField[]> => {
|
|
44
|
+
const out: Record<string, ProfileField[]> = {};
|
|
45
|
+
for (const s of STATIONS) if (s.profileFields !== undefined) out[s.name] = [...s.profileFields];
|
|
46
|
+
return out;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const asked = (a: Record<string, unknown>): ProfileField[] => PROFILE_FIELDS.filter((f) => a[f] !== undefined);
|
|
50
|
+
|
|
51
|
+
type Target = { station: Station } | { refused: string };
|
|
52
|
+
|
|
53
|
+
function targetOf(a: Record<string, unknown>): Target {
|
|
54
|
+
const name = str(a.station);
|
|
55
|
+
if (!name) return { refused: 'set_profile requires `station`' };
|
|
56
|
+
const station = stationByName(name);
|
|
57
|
+
if (!station) return { refused: `no station named ${name}` };
|
|
58
|
+
const fields = station.profileFields;
|
|
59
|
+
if (fields === undefined) return { refused: `${name} accounts have no profile metro can set` };
|
|
60
|
+
const wanted = asked(a);
|
|
61
|
+
if (wanted.length === 0) return { refused: 'set_profile needs at least one of name, bio, avatar' };
|
|
62
|
+
const refused = wanted.filter((f) => !fields.has(f));
|
|
63
|
+
if (refused.length > 0) return { refused: `${name} cannot set ${refused.join(', ')}; it takes ${[...fields].join(', ')}` };
|
|
64
|
+
return { station };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function trainArgs(a: Record<string, unknown>, picture: ResolvedAttachment | undefined): Record<string, unknown> {
|
|
68
|
+
const args: Record<string, unknown> = {};
|
|
69
|
+
if (typeof a.account === 'string') args.account = a.account;
|
|
70
|
+
if (typeof a.name === 'string') args.name = a.name;
|
|
71
|
+
if (typeof a.bio === 'string') args.bio = a.bio;
|
|
72
|
+
if (picture !== undefined) args.avatar = { path: picture.path, mime: picture.mime, name: picture.name };
|
|
73
|
+
return args;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function dispatchSetProfile(a: Record<string, unknown>): Promise<ToolResult> {
|
|
77
|
+
const target = targetOf(a);
|
|
78
|
+
if ('refused' in target) return errResult(target.refused);
|
|
79
|
+
const avatar = a.avatar === undefined ? [] : [a.avatar as CanonicalAttachment];
|
|
80
|
+
let resolved: ResolvedAttachment[];
|
|
81
|
+
try {
|
|
82
|
+
resolved = await resolveAttachments(avatar, { allowed: allowedAgents(currentIdentity()) });
|
|
83
|
+
} catch (e) {
|
|
84
|
+
return toErr('set_profile', e);
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const { result } = await makeCtx(target.station.name).call('set_profile', trainArgs(a, resolved[0]));
|
|
88
|
+
return okJson(result);
|
|
89
|
+
} catch (e) {
|
|
90
|
+
return toErr('set_profile', e);
|
|
91
|
+
} finally {
|
|
92
|
+
await cleanupAttachments(resolved);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
dispatchRemoveMembers,
|
|
22
22
|
} from './group-tools.js';
|
|
23
23
|
import { dispatchCreateUpload } from './upload-tool.js';
|
|
24
|
+
import { dispatchSetProfile, profileCapabilities, SET_PROFILE_TOOL } from './profile-tool.js';
|
|
24
25
|
import { callTargetDenied, lineTargetDenied } from '../agents/scope.js';
|
|
25
26
|
import { SOURCE_KEYS } from '../stations/attach-resolve.js';
|
|
26
27
|
import { decodedLengthOf } from '../stations/attach-inline.js';
|
|
@@ -49,6 +50,7 @@ const CORE_DISPATCH: Record<
|
|
|
49
50
|
remove_members: dispatchRemoveMembers,
|
|
50
51
|
export_invite: dispatchInviteLink,
|
|
51
52
|
create_upload: dispatchCreateUpload,
|
|
53
|
+
set_profile: dispatchSetProfile,
|
|
52
54
|
};
|
|
53
55
|
|
|
54
56
|
const toolList = (): { tools: unknown[] } => ({
|
|
@@ -62,6 +64,7 @@ const toolList = (): { tools: unknown[] } => ({
|
|
|
62
64
|
})),
|
|
63
65
|
),
|
|
64
66
|
LIST_ACCOUNTS_TOOL,
|
|
67
|
+
SET_PROFILE_TOOL,
|
|
65
68
|
],
|
|
66
69
|
});
|
|
67
70
|
|
|
@@ -82,6 +85,7 @@ async function handleListAccounts(
|
|
|
82
85
|
return okJson({
|
|
83
86
|
accounts: await gatherAccounts(allowedAgents(identity)),
|
|
84
87
|
capabilities: accountStationCapabilities(),
|
|
88
|
+
profiles: profileCapabilities(),
|
|
85
89
|
});
|
|
86
90
|
} catch (e) {
|
|
87
91
|
return errResult(`metro list_accounts failed: ${String(e)}`);
|
|
@@ -92,7 +96,7 @@ function stationForTool(
|
|
|
92
96
|
name: string,
|
|
93
97
|
args: Record<string, unknown>,
|
|
94
98
|
): string | undefined {
|
|
95
|
-
if (name === 'create_group') return str(args.station) || undefined;
|
|
99
|
+
if (name === 'create_group' || name === 'set_profile') return str(args.station) || undefined;
|
|
96
100
|
return STATION_TOOLS.get(name)?.station.name;
|
|
97
101
|
}
|
|
98
102
|
|
|
@@ -98,7 +98,8 @@ function agentApi(deps: LocalModeDeps): AgentApiDeps {
|
|
|
98
98
|
setAllowlist: localSetAllowlist,
|
|
99
99
|
setAccountEnabled: localSetAccountEnabled,
|
|
100
100
|
recentSenders,
|
|
101
|
-
resolveSender,
|
|
101
|
+
resolveSender: (station, accountId, query) => accountCall(station, 'resolve_sender', { account: accountId, query }),
|
|
102
|
+
accountCall,
|
|
102
103
|
reloadAgents: deps.reloadAgents,
|
|
103
104
|
};
|
|
104
105
|
}
|
|
@@ -169,17 +170,14 @@ function localModeInfo(): ModeInfo {
|
|
|
169
170
|
return { mode: 'local', owner: localOwner(), project: LOCAL_PROJECT_ID, version: METRO_VERSION };
|
|
170
171
|
}
|
|
171
172
|
|
|
172
|
-
async function
|
|
173
|
+
async function accountCall(
|
|
173
174
|
station: StationName,
|
|
174
|
-
|
|
175
|
-
|
|
175
|
+
action: string,
|
|
176
|
+
args: Record<string, unknown>,
|
|
176
177
|
): Promise<unknown> {
|
|
177
178
|
let response;
|
|
178
179
|
try {
|
|
179
|
-
response = await forwardTrainCall(station,
|
|
180
|
-
account: accountId,
|
|
181
|
-
query,
|
|
182
|
-
});
|
|
180
|
+
response = await forwardTrainCall(station, action, args);
|
|
183
181
|
} catch (err) {
|
|
184
182
|
throw new ApiError(
|
|
185
183
|
`metro could not reach the ${station} train: ${errMsg(err)}`,
|
|
@@ -89,7 +89,7 @@ export async function verifyXmtpKeyOutOfProcess(
|
|
|
89
89
|
dbPath: string,
|
|
90
90
|
): Promise<XmtpIdentity> {
|
|
91
91
|
const raw = await runVerifier(
|
|
92
|
-
JSON.stringify({ privateKey, dbPath }),
|
|
92
|
+
JSON.stringify({ privateKey, dbPath, smart: true }),
|
|
93
93
|
).catch((err: unknown) => {
|
|
94
94
|
log.warn({ err: errMsg(err) }, 'attach-xmtp: could not run the inbox check');
|
|
95
95
|
throw new XmtpAttachError(UNREACHABLE);
|
|
@@ -158,7 +158,7 @@ async function prepareXmtp(verify: VerifyXmtpKey): Promise<PreparedAccount> {
|
|
|
158
158
|
),
|
|
159
159
|
);
|
|
160
160
|
return {
|
|
161
|
-
config: { privateKey, dbPath },
|
|
161
|
+
config: { privateKey, dbPath, smart: true },
|
|
162
162
|
identity: { inboxId: identity.inboxId, address: identity.address },
|
|
163
163
|
secret: {
|
|
164
164
|
label: 'xmtp private key',
|
|
@@ -27,6 +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
31
|
import { discordMembers } from './members.js';
|
|
31
32
|
import {
|
|
32
33
|
groupAddHandler,
|
|
@@ -375,6 +376,7 @@ const HANDLERS: Record<string, StationHandler> = {
|
|
|
375
376
|
channel,
|
|
376
377
|
listMembers,
|
|
377
378
|
set_presence: presence,
|
|
379
|
+
set_profile: setProfile,
|
|
378
380
|
joinVoice,
|
|
379
381
|
leaveVoice,
|
|
380
382
|
speak,
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import type { Client } from 'discord.js';
|
|
3
|
+
import { TrainError } from '@metro-labs/core/train-error';
|
|
4
|
+
import {
|
|
5
|
+
assertImage,
|
|
6
|
+
fieldsOf,
|
|
7
|
+
parseProfileChange,
|
|
8
|
+
type ProfileApplied,
|
|
9
|
+
type ProfileChange,
|
|
10
|
+
} from '@metro-labs/core/stations/profile';
|
|
11
|
+
import { accountFor, accounts } from './accounts.js';
|
|
12
|
+
import { respond } from './wire.js';
|
|
13
|
+
|
|
14
|
+
export async function applyProfile(
|
|
15
|
+
client: Client,
|
|
16
|
+
change: ProfileChange,
|
|
17
|
+
): Promise<void> {
|
|
18
|
+
const user = client.user;
|
|
19
|
+
if (!user) throw new TrainError('discord_not_ready', 'the gateway is not ready yet');
|
|
20
|
+
if (change.avatar !== undefined) assertImage(change.avatar);
|
|
21
|
+
const avatar = change.avatar === undefined ? undefined : await readFile(change.avatar.path);
|
|
22
|
+
if (change.name !== undefined || avatar !== undefined)
|
|
23
|
+
await user.edit({
|
|
24
|
+
...(change.name === undefined ? {} : { username: change.name }),
|
|
25
|
+
...(avatar === undefined ? {} : { avatar }),
|
|
26
|
+
});
|
|
27
|
+
if (change.bio !== undefined) {
|
|
28
|
+
const app = client.application;
|
|
29
|
+
if (!app) throw new TrainError('discord_not_ready', 'the application is not loaded yet');
|
|
30
|
+
await app.edit({ description: change.bio });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function setProfile(
|
|
35
|
+
id: string,
|
|
36
|
+
args: Record<string, unknown>,
|
|
37
|
+
): Promise<void> {
|
|
38
|
+
const accountId = accountFor({ account: args.account as string | undefined });
|
|
39
|
+
const acct = accounts.get(accountId);
|
|
40
|
+
if (!acct) {
|
|
41
|
+
respond(id, { error: `unknown account '${accountId}'` });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const change = parseProfileChange(args);
|
|
45
|
+
await applyProfile(acct.client, change);
|
|
46
|
+
const result: ProfileApplied = { account: accountId, applied: fieldsOf(change) };
|
|
47
|
+
respond(id, { result });
|
|
48
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { GroupOp, Station, Verb } from '@metro-labs/core/stations/types';
|
|
2
|
+
import { PROFILE_FIELDS, type ProfileField } from '@metro-labs/core/stations/profile';
|
|
2
3
|
|
|
3
4
|
export const discordBotStation: Station = {
|
|
4
5
|
name: 'discord-bot',
|
|
@@ -15,5 +16,6 @@ export const discordBotStation: Station = {
|
|
|
15
16
|
]),
|
|
16
17
|
groupOps: new Set<GroupOp>(['create_group', 'add_members', 'remove_members']),
|
|
17
18
|
attachmentMode: 'canonical',
|
|
19
|
+
profileFields: new Set<ProfileField>(PROFILE_FIELDS),
|
|
18
20
|
tools: [],
|
|
19
21
|
};
|
|
@@ -14,6 +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
18
|
import { fetchMembers, isRestricted, restrictedMemberList } from './members.js';
|
|
18
19
|
import {
|
|
19
20
|
groupAddMembers,
|
|
@@ -248,6 +249,7 @@ export function makeHandleCall(
|
|
|
248
249
|
groupAddMembers: makeGroupAdd(clientFor),
|
|
249
250
|
groupRemoveMembers: makeGroupRemove(clientFor),
|
|
250
251
|
groupInviteLink: makeGroupInvite(clientFor),
|
|
252
|
+
set_profile: makeSetProfile(clientFor),
|
|
251
253
|
},
|
|
252
254
|
normalize: normalizeTelegramUser,
|
|
253
255
|
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { TelegramClient } from '@mtcute/bun';
|
|
2
|
+
import {
|
|
3
|
+
assertImage,
|
|
4
|
+
fieldsOf,
|
|
5
|
+
parseProfileChange,
|
|
6
|
+
type ProfileApplied,
|
|
7
|
+
type ProfileChange,
|
|
8
|
+
} from '@metro-labs/core/stations/profile';
|
|
9
|
+
import { respond, type StationHandler } from '@metro-labs/core/stations/station-runtime';
|
|
10
|
+
import { str } from '@metro-labs/core/str';
|
|
11
|
+
import { accountFor } from './accounts.js';
|
|
12
|
+
import type { UserClient } from './client.js';
|
|
13
|
+
|
|
14
|
+
const BIO_MAX = 70;
|
|
15
|
+
|
|
16
|
+
export async function applyProfile(tg: TelegramClient, change: ProfileChange): Promise<void> {
|
|
17
|
+
if (change.name !== undefined || change.bio !== undefined)
|
|
18
|
+
await tg.updateProfile({
|
|
19
|
+
...(change.name === undefined ? {} : { firstName: change.name }),
|
|
20
|
+
...(change.bio === undefined ? {} : { bio: change.bio.slice(0, BIO_MAX) }),
|
|
21
|
+
});
|
|
22
|
+
if (change.avatar !== undefined) {
|
|
23
|
+
assertImage(change.avatar);
|
|
24
|
+
await tg.setMyProfilePhoto({ type: 'photo', media: `file:${change.avatar.path}` });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function makeSetProfile(clientFor: (accountId: string) => UserClient): StationHandler {
|
|
29
|
+
return async (id, args) => {
|
|
30
|
+
const accountId = accountFor({ account: str(args.account) });
|
|
31
|
+
const change = parseProfileChange(args);
|
|
32
|
+
await applyProfile(clientFor(accountId).tg, change);
|
|
33
|
+
const result: ProfileApplied = { account: accountId, applied: fieldsOf(change) };
|
|
34
|
+
respond(id, { result });
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { GroupOp, Station, Verb } from '@metro-labs/core/stations/types';
|
|
2
|
+
import { PROFILE_FIELDS, type ProfileField } from '@metro-labs/core/stations/profile';
|
|
2
3
|
|
|
3
4
|
export const telegramStation: Station = {
|
|
4
5
|
name: 'telegram',
|
|
@@ -20,5 +21,6 @@ export const telegramStation: Station = {
|
|
|
20
21
|
'invite_link',
|
|
21
22
|
]),
|
|
22
23
|
attachmentMode: 'canonical',
|
|
24
|
+
profileFields: new Set<ProfileField>(PROFILE_FIELDS),
|
|
23
25
|
tools: [],
|
|
24
26
|
};
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
sendLocation,
|
|
24
24
|
sendMedia,
|
|
25
25
|
} from './media-actions.js';
|
|
26
|
+
import { setProfile } from './profile.js';
|
|
26
27
|
|
|
27
28
|
|
|
28
29
|
|
|
@@ -271,6 +272,7 @@ const HANDLERS: Record<string, StationHandler> = {
|
|
|
271
272
|
edit,
|
|
272
273
|
delete: remove,
|
|
273
274
|
listMembers,
|
|
275
|
+
set_profile: setProfile,
|
|
274
276
|
send_photo: (id, args) =>
|
|
275
277
|
media(id, 'sendPhoto', 'photo', ((args.caption as string) ?? '') + ' [image]', args),
|
|
276
278
|
send_document: (id, args) =>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { appendFile } from '@metro-labs/core/stations/attachments';
|
|
2
|
+
import {
|
|
3
|
+
assertImage,
|
|
4
|
+
fieldsOf,
|
|
5
|
+
parseProfileChange,
|
|
6
|
+
type ProfileApplied,
|
|
7
|
+
type ProfileAvatar,
|
|
8
|
+
type ProfileChange,
|
|
9
|
+
} from '@metro-labs/core/stations/profile';
|
|
10
|
+
import { accountFor, tg, tgForm } from './accounts.js';
|
|
11
|
+
import { respond } from './wire.js';
|
|
12
|
+
|
|
13
|
+
async function setPhoto(accountId: string, avatar: ProfileAvatar): Promise<void> {
|
|
14
|
+
assertImage(avatar);
|
|
15
|
+
const form = new FormData();
|
|
16
|
+
form.append('photo', JSON.stringify({ type: 'static', photo: 'attach://avatar' }));
|
|
17
|
+
await appendFile(form, 'avatar', avatar.path, avatar.name);
|
|
18
|
+
await tgForm(accountId, 'setMyProfilePhoto', form);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function applyProfile(accountId: string, change: ProfileChange): Promise<void> {
|
|
22
|
+
if (change.name !== undefined) await tg(accountId, 'setMyName', { name: change.name });
|
|
23
|
+
if (change.bio !== undefined) await tg(accountId, 'setMyDescription', { description: change.bio });
|
|
24
|
+
if (change.avatar !== undefined) await setPhoto(accountId, change.avatar);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function setProfile(id: string, args: Record<string, unknown>): Promise<void> {
|
|
28
|
+
const accountId = accountFor({ account: args.account as string | undefined });
|
|
29
|
+
const change = parseProfileChange(args);
|
|
30
|
+
await applyProfile(accountId, change);
|
|
31
|
+
const result: ProfileApplied = { account: accountId, applied: fieldsOf(change) };
|
|
32
|
+
respond(id, { result });
|
|
33
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Station, Verb } from '@metro-labs/core/stations/types';
|
|
2
|
+
import { PROFILE_FIELDS, type ProfileField } from '@metro-labs/core/stations/profile';
|
|
2
3
|
|
|
3
4
|
export const telegramBotStation: Station = {
|
|
4
5
|
name: 'telegram-bot',
|
|
@@ -13,5 +14,6 @@ export const telegramBotStation: Station = {
|
|
|
13
14
|
'delete',
|
|
14
15
|
]),
|
|
15
16
|
attachmentMode: 'canonical',
|
|
17
|
+
profileFields: new Set<ProfileField>(PROFILE_FIELDS),
|
|
16
18
|
tools: [],
|
|
17
19
|
};
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
import { accountFor, accounts, targetOf } from './accounts.js';
|
|
11
11
|
import { normalizeWhatsApp } from './normalize.js';
|
|
12
12
|
import { phoneNumberOf, senderLookup } from './resolve.js';
|
|
13
|
+
import { assertImage, fieldsOf, parseProfileChange, type ProfileApplied } from '@metro-labs/core/stations/profile';
|
|
13
14
|
import type { WAClient } from './client.js';
|
|
14
15
|
|
|
15
16
|
type Args = Record<string, unknown>;
|
|
@@ -173,6 +174,17 @@ function makeResolveSender(clientFor: ClientFor): StationHandler {
|
|
|
173
174
|
};
|
|
174
175
|
}
|
|
175
176
|
|
|
177
|
+
function makeSetProfile(clientFor: ClientFor): StationHandler {
|
|
178
|
+
return async (id, args) => {
|
|
179
|
+
const accountId = accountFor({ account: str(args.account) });
|
|
180
|
+
const change = parseProfileChange(args);
|
|
181
|
+
if (change.avatar !== undefined) assertImage(change.avatar);
|
|
182
|
+
await guard(() => clientFor(accountId).setProfile(change));
|
|
183
|
+
const result: ProfileApplied = { account: accountId, applied: fieldsOf(change) };
|
|
184
|
+
respond(id, { result });
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
176
188
|
export function makeHandleCall(
|
|
177
189
|
clientFor: ClientFor,
|
|
178
190
|
): (msg: CallMsg) => Promise<void> {
|
|
@@ -184,6 +196,7 @@ export function makeHandleCall(
|
|
|
184
196
|
edit: makeEdit(clientFor),
|
|
185
197
|
delete: makeDelete(clientFor),
|
|
186
198
|
resolve_sender: makeResolveSender(clientFor),
|
|
199
|
+
set_profile: makeSetProfile(clientFor),
|
|
187
200
|
},
|
|
188
201
|
normalize: normalizeWhatsApp,
|
|
189
202
|
});
|
|
@@ -13,6 +13,7 @@ import { errMsg } from '@metro-labs/core/log';
|
|
|
13
13
|
import type { WhatsAppAccount } from './types.js';
|
|
14
14
|
import type { InboundMessage, ReactionInput } from './format.js';
|
|
15
15
|
import type { SenderFound } from './resolve.js';
|
|
16
|
+
import type { ProfileChange } from '@metro-labs/core/stations/profile';
|
|
16
17
|
import { toInbound, toReaction, type ReactionEvent, type SelfRef } from './parse.js';
|
|
17
18
|
import { baileysLogger } from './logger.js';
|
|
18
19
|
import { useAccountAuthState } from './auth-state.js';
|
|
@@ -51,6 +52,7 @@ export interface WAClient {
|
|
|
51
52
|
deleteMessage(jid: string, messageId: string): Promise<void>;
|
|
52
53
|
reuploadMedia(m: WAMessage): Promise<WAMessage>;
|
|
53
54
|
lookupSender(number: string): Promise<SenderFound>;
|
|
55
|
+
setProfile(change: ProfileChange): Promise<void>;
|
|
54
56
|
disconnect(): Promise<void>;
|
|
55
57
|
}
|
|
56
58
|
|
|
@@ -335,6 +337,16 @@ export function createClient(account: WhatsAppAccount): WAClient {
|
|
|
335
337
|
const jid = jidNormalizedUser(found.jid);
|
|
336
338
|
return { exists: true, jid, lid: await lidFor(st, sock, jid) };
|
|
337
339
|
},
|
|
340
|
+
async setProfile(change) {
|
|
341
|
+
const sock = await ready(st);
|
|
342
|
+
if (change.name !== undefined) await sock.updateProfileName(change.name);
|
|
343
|
+
if (change.bio !== undefined) await sock.updateProfileStatus(change.bio);
|
|
344
|
+
if (change.avatar !== undefined) {
|
|
345
|
+
const me = sock.user?.id;
|
|
346
|
+
if (me === undefined) throw new TrainError('whatsapp_call', 'own jid unknown');
|
|
347
|
+
await sock.updateProfilePicture(me, { url: change.avatar.path });
|
|
348
|
+
}
|
|
349
|
+
},
|
|
338
350
|
async disconnect() {
|
|
339
351
|
st.closed = true;
|
|
340
352
|
try {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Station, Verb } from '@metro-labs/core/stations/types';
|
|
2
|
+
import { PROFILE_FIELDS, type ProfileField } from '@metro-labs/core/stations/profile';
|
|
2
3
|
|
|
3
4
|
export const whatsappStation: Station = {
|
|
4
5
|
name: 'whatsapp',
|
|
@@ -13,6 +14,7 @@ export const whatsappStation: Station = {
|
|
|
13
14
|
'delete',
|
|
14
15
|
]),
|
|
15
16
|
attachmentMode: 'canonical',
|
|
17
|
+
profileFields: new Set<ProfileField>(PROFILE_FIELDS),
|
|
16
18
|
resolvesSenders: true,
|
|
17
19
|
tools: [],
|
|
18
20
|
};
|
|
@@ -7,7 +7,8 @@ import { homedir } from 'node:os';
|
|
|
7
7
|
import { existsSync } from 'node:fs';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
import { CODECS } from './codecs.js';
|
|
10
|
-
import { expandHome,
|
|
10
|
+
import { expandHome, identityFor, XMTP_ENV } from './identity.js';
|
|
11
|
+
import type { SmartAccount } from './smart.js';
|
|
11
12
|
import {
|
|
12
13
|
makeAccountStore,
|
|
13
14
|
resolveAccountId,
|
|
@@ -23,6 +24,7 @@ export interface AccountConfig {
|
|
|
23
24
|
privateKey?: string;
|
|
24
25
|
owner?: string;
|
|
25
26
|
dbPath?: string;
|
|
27
|
+
smart?: boolean;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export const { loadAccounts } = makeAccountStore<AccountConfig>({
|
|
@@ -50,18 +52,19 @@ export interface Account {
|
|
|
50
52
|
client: Client<unknown>;
|
|
51
53
|
inboxId: string;
|
|
52
54
|
address: string;
|
|
55
|
+
smart: SmartAccount | null;
|
|
53
56
|
}
|
|
54
57
|
export const accounts = new Map<string, Account>();
|
|
55
58
|
|
|
56
59
|
export async function bootAccount(cfg: AccountConfig): Promise<void> {
|
|
57
|
-
const { signer, address } =
|
|
60
|
+
const { signer, address, smart } = await identityFor(resolvePrivateKey(cfg), cfg.smart === true);
|
|
58
61
|
const dbPath = expandHome(
|
|
59
62
|
cfg.dbPath ?? join(homedir(), '.metro', `xmtp-${XMTP_ENV}-${cfg.id}.db3`),
|
|
60
63
|
);
|
|
61
64
|
const fresh = !existsSync(dbPath);
|
|
62
65
|
const options: ClientOptions = { env: XMTP_ENV, codecs: CODECS(), dbPath };
|
|
63
66
|
const client: Client<unknown> = await Client.create(signer, options);
|
|
64
|
-
accounts.set(cfg.id, { cfg, client, inboxId: client.inboxId, address });
|
|
67
|
+
accounts.set(cfg.id, { cfg, client, inboxId: client.inboxId, address, smart });
|
|
65
68
|
if (fresh)
|
|
66
69
|
process.stderr.write(
|
|
67
70
|
`xmtp[${cfg.id}] registered a NEW installation for this machine — ` +
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
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
|
+
import { claimNameAction, nameAction, setProfile } from './profile.js';
|
|
24
25
|
import { makeStation, type CallMsg } from '@metro-labs/core/stations/station-runtime';
|
|
25
26
|
|
|
26
27
|
type Args = Record<string, unknown>;
|
|
@@ -338,6 +339,7 @@ async function accountsAction(id: string): Promise<void> {
|
|
|
338
339
|
url: `https://stage.box/#/${a.address}`,
|
|
339
340
|
address: a.address,
|
|
340
341
|
inboxId: a.inboxId,
|
|
342
|
+
smart: a.smart !== null,
|
|
341
343
|
env: 'production',
|
|
342
344
|
owner: a.cfg.owner ?? null,
|
|
343
345
|
keySource: 'privateKey',
|
|
@@ -353,6 +355,9 @@ function unsupportedVerb(id: string, verb: string): Promise<void> {
|
|
|
353
355
|
|
|
354
356
|
const handlers: Record<string, (id: string, args: Args) => Promise<void>> = {
|
|
355
357
|
accounts: (id) => accountsAction(id),
|
|
358
|
+
set_profile: setProfile,
|
|
359
|
+
claim_name: claimNameAction,
|
|
360
|
+
name: nameAction,
|
|
356
361
|
send,
|
|
357
362
|
ask,
|
|
358
363
|
sendPoll: ask,
|
|
@@ -2,32 +2,46 @@ import { IdentifierKind, type Signer } from '@xmtp/node-sdk';
|
|
|
2
2
|
import { privateKeyToAccount } from 'viem/accounts';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
+
import { SMART_CHAIN_ID, smartAccountFor, type SmartAccount } from './smart.js';
|
|
5
6
|
|
|
6
7
|
export const XMTP_ENV = 'production' as const;
|
|
7
8
|
|
|
8
9
|
export const expandHome = (p: string): string =>
|
|
9
10
|
p.startsWith('~') ? join(homedir(), p.slice(1)) : p;
|
|
10
11
|
|
|
11
|
-
export
|
|
12
|
+
export interface Identity {
|
|
12
13
|
signer: Signer;
|
|
13
14
|
address: string;
|
|
14
|
-
|
|
15
|
+
smart: SmartAccount | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const bytesOf = (sig: string): Uint8Array => {
|
|
19
|
+
const hex = sig.slice(2);
|
|
20
|
+
const out = new Uint8Array(hex.length / 2);
|
|
21
|
+
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
22
|
+
return out;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function signerFor(privateKey: string): Identity {
|
|
15
26
|
const acct = privateKeyToAccount(privateKey as `0x${string}`);
|
|
16
27
|
const signer: Signer = {
|
|
17
28
|
type: 'EOA',
|
|
18
|
-
getIdentifier: () =>
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
getIdentifier: () => Promise.resolve({ identifier: acct.address, identifierKind: IdentifierKind.Ethereum }),
|
|
30
|
+
signMessage: async (msg: string) => bytesOf(await acct.signMessage({ message: msg })),
|
|
31
|
+
};
|
|
32
|
+
return { signer, address: acct.address, smart: null };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function smartSignerFor(privateKey: string, rpc?: string): Promise<Identity> {
|
|
36
|
+
const smart = await smartAccountFor(privateKey, rpc);
|
|
37
|
+
const signer: Signer = {
|
|
38
|
+
type: 'SCW',
|
|
39
|
+
getIdentifier: () => Promise.resolve({ identifier: smart.address, identifierKind: IdentifierKind.Ethereum }),
|
|
40
|
+
signMessage: async (msg: string) => bytesOf(await smart.signMessage(msg)),
|
|
41
|
+
getChainId: () => BigInt(SMART_CHAIN_ID),
|
|
31
42
|
};
|
|
32
|
-
return { signer, address:
|
|
43
|
+
return { signer, address: smart.address, smart };
|
|
33
44
|
}
|
|
45
|
+
|
|
46
|
+
export const identityFor = (privateKey: string, smart: boolean): Promise<Identity> =>
|
|
47
|
+
smart ? smartSignerFor(privateKey) : Promise.resolve(signerFor(privateKey));
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { TrainError } from '@metro-labs/core/train-error';
|
|
2
|
+
|
|
3
|
+
export const STAGE_NAMES_PARENT = 'stage.base.eth';
|
|
4
|
+
export const STAGE_PROXY = 'https://proxy.stage.box';
|
|
5
|
+
const LABEL_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
6
|
+
const MIN = 6;
|
|
7
|
+
const MAX = 32;
|
|
8
|
+
const HEADERS = { 'content-type': 'application/json', 'x-stage-client': '1' };
|
|
9
|
+
|
|
10
|
+
export const proxyBase = (): string => process.env.METRO_STAGE_PROXY ?? STAGE_PROXY;
|
|
11
|
+
|
|
12
|
+
export const stageNameOf = (label: string): string => `${label}.${STAGE_NAMES_PARENT}`;
|
|
13
|
+
|
|
14
|
+
export function parseLabel(raw: unknown): string {
|
|
15
|
+
const label = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
|
|
16
|
+
if (label.length < MIN) throw new TrainError('bad_request', `a name needs at least ${String(MIN)} characters`);
|
|
17
|
+
if (label.length > MAX) throw new TrainError('bad_request', `a name can have at most ${String(MAX)} characters`);
|
|
18
|
+
if (!LABEL_RE.test(label)) throw new TrainError('bad_request', 'a name is lowercase letters, digits and single inner hyphens');
|
|
19
|
+
return label;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const claimMessage = (label: string, address: string, issuedAt: number): string =>
|
|
23
|
+
`Claim ${stageNameOf(label)} for ${address.toLowerCase()} at ${String(issuedAt)}`;
|
|
24
|
+
|
|
25
|
+
async function ask(path: string, init: RequestInit, fetchImpl: typeof fetch): Promise<Record<string, unknown>> {
|
|
26
|
+
let res: Response;
|
|
27
|
+
try {
|
|
28
|
+
res = await fetchImpl(`${proxyBase()}/names/${path}`, { ...init, headers: HEADERS, signal: AbortSignal.timeout(30_000) });
|
|
29
|
+
} catch (err) {
|
|
30
|
+
throw new TrainError('names_unreachable', `could not reach the stage name service: ${err instanceof Error ? err.message : String(err)}`);
|
|
31
|
+
}
|
|
32
|
+
const body: unknown = await res.json().catch(() => null);
|
|
33
|
+
const record = typeof body === 'object' && body !== null ? (body as Record<string, unknown>) : {};
|
|
34
|
+
if (!res.ok) throw new TrainError('names_refused', typeof record.error === 'string' ? record.error : `the stage name service answered ${String(res.status)}`);
|
|
35
|
+
return record;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function nameOf(address: string, fetchImpl: typeof fetch = fetch): Promise<string | null> {
|
|
39
|
+
const body = await ask(`status?address=${encodeURIComponent(address)}`, {}, fetchImpl);
|
|
40
|
+
return typeof body.name === 'string' && body.name !== '' ? body.name : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function checkLabel(label: string, fetchImpl: typeof fetch = fetch): Promise<void> {
|
|
44
|
+
const body = await ask(`check?label=${encodeURIComponent(label)}`, {}, fetchImpl);
|
|
45
|
+
if (body.valid !== true) throw new TrainError('bad_request', typeof body.reason === 'string' ? body.reason : `${stageNameOf(label)} is not a valid name`);
|
|
46
|
+
if (body.available !== true) throw new TrainError('name_taken', `${stageNameOf(label)} is already taken`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function claimName(
|
|
50
|
+
label: string,
|
|
51
|
+
address: string,
|
|
52
|
+
sign: (message: string) => Promise<string>,
|
|
53
|
+
fetchImpl: typeof fetch = fetch,
|
|
54
|
+
now = Date.now(),
|
|
55
|
+
): Promise<string> {
|
|
56
|
+
await checkLabel(label, fetchImpl);
|
|
57
|
+
const signature = await sign(claimMessage(label, address, now));
|
|
58
|
+
const body = await ask('claim', { method: 'POST', body: JSON.stringify({ label, address, issuedAt: now, signature }) }, fetchImpl);
|
|
59
|
+
if (typeof body.name !== 'string' || body.name === '') throw new TrainError('names_refused', 'the stage name service issued no name');
|
|
60
|
+
return body.name;
|
|
61
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { encodeFunctionData, namehash, type Hex } from 'viem';
|
|
3
|
+
import { normalize } from 'viem/ens';
|
|
4
|
+
import { TrainError } from '@metro-labs/core/train-error';
|
|
5
|
+
import {
|
|
6
|
+
assertImage,
|
|
7
|
+
fieldsOf,
|
|
8
|
+
parseProfileChange,
|
|
9
|
+
type ProfileApplied,
|
|
10
|
+
type ProfileAvatar,
|
|
11
|
+
type ProfileChange,
|
|
12
|
+
} from '@metro-labs/core/stations/profile';
|
|
13
|
+
import { accountForCall } from './accounts.js';
|
|
14
|
+
import { claimName, nameOf, parseLabel } from './names.js';
|
|
15
|
+
import { sendSponsored, type SmartAccount } from './smart.js';
|
|
16
|
+
import { respond } from './wire.js';
|
|
17
|
+
|
|
18
|
+
export const BASENAME_REGISTRY = '0xB94704422c2a1E396835A571837Aa5AE53285a95' as const;
|
|
19
|
+
export const BASENAME_L2_RESOLVER = '0xC6d566A56A1aFf6508b41f6c90ff131615583BCD' as const;
|
|
20
|
+
export const PINEAPPLE_UPLOAD_URL = 'https://pineapple.fyi/upload';
|
|
21
|
+
const STAMP_CLEAR_URL = 'https://stamp.fyi/clear/';
|
|
22
|
+
const ZERO = '0x0000000000000000000000000000000000000000';
|
|
23
|
+
const TEXT_KEYS = { name: 'name', bio: 'description', avatar: 'avatar' } as const;
|
|
24
|
+
|
|
25
|
+
const REGISTRY_ABI = [
|
|
26
|
+
{ name: 'resolver', type: 'function', stateMutability: 'view', inputs: [{ name: 'node', type: 'bytes32' }], outputs: [{ name: '', type: 'address' }] },
|
|
27
|
+
] as const;
|
|
28
|
+
|
|
29
|
+
const RESOLVER_ABI = [
|
|
30
|
+
{
|
|
31
|
+
name: 'setText',
|
|
32
|
+
type: 'function',
|
|
33
|
+
stateMutability: 'nonpayable',
|
|
34
|
+
inputs: [{ name: 'node', type: 'bytes32' }, { name: 'key', type: 'string' }, { name: 'value', type: 'string' }],
|
|
35
|
+
outputs: [],
|
|
36
|
+
},
|
|
37
|
+
{ name: 'multicall', type: 'function', stateMutability: 'nonpayable', inputs: [{ name: 'data', type: 'bytes[]' }], outputs: [{ name: 'results', type: 'bytes[]' }] },
|
|
38
|
+
] as const;
|
|
39
|
+
|
|
40
|
+
type Args = Record<string, unknown>;
|
|
41
|
+
|
|
42
|
+
const NO_NAME = 'this XMTP account has no name yet, so it has no profile: claim a <label>.stage.base.eth name on its channel page first';
|
|
43
|
+
const NOT_SMART = 'this XMTP account was attached before names existed and cannot hold one; attach XMTP again to get an account that can';
|
|
44
|
+
|
|
45
|
+
export async function pinAvatar(avatar: ProfileAvatar, fetchImpl: typeof fetch = fetch): Promise<string> {
|
|
46
|
+
assertImage(avatar);
|
|
47
|
+
const form = new FormData();
|
|
48
|
+
form.append('file', new Blob([await readFile(avatar.path)], { type: avatar.mime }), avatar.name);
|
|
49
|
+
const res = await fetchImpl(PINEAPPLE_UPLOAD_URL, { method: 'POST', body: form, signal: AbortSignal.timeout(60_000) });
|
|
50
|
+
const body = (await res.json().catch(() => ({}))) as { result?: { cid?: string }; error?: { message?: string } };
|
|
51
|
+
if (!res.ok) throw new TrainError('avatar_upload', `the image upload answered ${String(res.status)}`);
|
|
52
|
+
if (typeof body.error?.message === 'string') throw new TrainError('avatar_upload', body.error.message);
|
|
53
|
+
if (typeof body.result?.cid !== 'string' || body.result.cid === '') throw new TrainError('avatar_upload', 'the image upload returned no CID');
|
|
54
|
+
return `ipfs://${body.result.cid}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function encodeTextRecords(name: string, records: Record<string, string>): Hex {
|
|
58
|
+
const node = namehash(normalize(name));
|
|
59
|
+
const calls = Object.entries(records).map(([key, value]) => encodeFunctionData({ abi: RESOLVER_ABI, functionName: 'setText', args: [node, key, value] }));
|
|
60
|
+
return encodeFunctionData({ abi: RESOLVER_ABI, functionName: 'multicall', args: [calls] });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function resolverFor(smart: SmartAccount, name: string): Promise<Hex> {
|
|
64
|
+
const found: Hex = await smart.publicClient.readContract({ address: BASENAME_REGISTRY, abi: REGISTRY_ABI, functionName: 'resolver', args: [namehash(normalize(name))] });
|
|
65
|
+
return found === ZERO ? BASENAME_L2_RESOLVER : found;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function recordsFor(change: ProfileChange, fetchImpl: typeof fetch): Promise<Record<string, string>> {
|
|
69
|
+
const records: Record<string, string> = {};
|
|
70
|
+
if (change.name !== undefined) records[TEXT_KEYS.name] = change.name;
|
|
71
|
+
if (change.bio !== undefined) records[TEXT_KEYS.bio] = change.bio;
|
|
72
|
+
if (change.avatar !== undefined) records[TEXT_KEYS.avatar] = await pinAvatar(change.avatar, fetchImpl);
|
|
73
|
+
return records;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function writeProfile(smart: SmartAccount, name: string, change: ProfileChange, fetchImpl: typeof fetch = fetch): Promise<Hex> {
|
|
77
|
+
const records = await recordsFor(change, fetchImpl);
|
|
78
|
+
const hash = await sendSponsored(smart, await resolverFor(smart, name), encodeTextRecords(name, records));
|
|
79
|
+
const id = smart.address.toLowerCase();
|
|
80
|
+
await Promise.all([fetchImpl(`${STAMP_CLEAR_URL}address/${id}`), fetchImpl(`${STAMP_CLEAR_URL}avatar/eth:${id}`)]).catch(() => undefined);
|
|
81
|
+
return hash;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const smartOf = (args: Args): { accountId: string; address: string; smart: SmartAccount } => {
|
|
85
|
+
const acct = accountForCall(args);
|
|
86
|
+
if (acct.smart === null) throw new TrainError('no_name', NOT_SMART);
|
|
87
|
+
return { accountId: acct.cfg.id, address: acct.address, smart: acct.smart };
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export async function setProfile(id: string, args: Args): Promise<void> {
|
|
91
|
+
const { accountId, address, smart } = smartOf(args);
|
|
92
|
+
const change = parseProfileChange(args);
|
|
93
|
+
const name = await nameOf(address);
|
|
94
|
+
if (name === null) throw new TrainError('no_name', NO_NAME);
|
|
95
|
+
const tx = await writeProfile(smart, name, change);
|
|
96
|
+
const result: ProfileApplied & { name: string; tx: string } = { account: accountId, applied: fieldsOf(change), name, tx };
|
|
97
|
+
respond(id, { result });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function claimNameAction(id: string, args: Args): Promise<void> {
|
|
101
|
+
const { accountId, address, smart } = smartOf(args);
|
|
102
|
+
const held = await nameOf(address);
|
|
103
|
+
if (held !== null) throw new TrainError('name_held', `this account already holds ${held}`);
|
|
104
|
+
const name = await claimName(parseLabel(args.label), address, (message) => smart.signMessage(message));
|
|
105
|
+
respond(id, { result: { account: accountId, name } });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function nameAction(id: string, args: Args): Promise<void> {
|
|
109
|
+
const acct = accountForCall(args);
|
|
110
|
+
const name = acct.smart === null ? null : await nameOf(acct.address);
|
|
111
|
+
respond(id, { result: { account: acct.cfg.id, name, canClaim: acct.smart !== null && name === null } });
|
|
112
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createPublicClient, http, type Hex, type PublicClient } from 'viem';
|
|
2
|
+
import { privateKeyToAccount } from 'viem/accounts';
|
|
3
|
+
import { base } from 'viem/chains';
|
|
4
|
+
import {
|
|
5
|
+
createKernelAccount,
|
|
6
|
+
createKernelAccountClient,
|
|
7
|
+
createZeroDevPaymasterClient,
|
|
8
|
+
getUserOperationGasPrice,
|
|
9
|
+
type KernelAccountClient,
|
|
10
|
+
} from '@zerodev/sdk';
|
|
11
|
+
import { getEntryPoint, KERNEL_V3_1 } from '@zerodev/sdk/constants';
|
|
12
|
+
import { signerToEcdsaValidator } from '@zerodev/ecdsa-validator';
|
|
13
|
+
|
|
14
|
+
export const SMART_CHAIN_ID = 8453;
|
|
15
|
+
const STAGE_ZERODEV_PROJECT = 'c107f734-f7bb-48c1-a5d2-ceb3f70a42f6';
|
|
16
|
+
const ENTRY_POINT = getEntryPoint('0.7');
|
|
17
|
+
|
|
18
|
+
export const zerodevRpc = (): string =>
|
|
19
|
+
`https://rpc.zerodev.app/api/v3/${process.env.METRO_ZERODEV_PROJECT ?? STAGE_ZERODEV_PROJECT}/chain/${String(SMART_CHAIN_ID)}`;
|
|
20
|
+
|
|
21
|
+
export interface SmartAccount {
|
|
22
|
+
address: Hex;
|
|
23
|
+
publicClient: PublicClient;
|
|
24
|
+
client: () => KernelAccountClient;
|
|
25
|
+
signMessage: (message: string) => Promise<Hex>;
|
|
26
|
+
deployed: () => Promise<boolean>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function smartAccountFor(privateKey: string, rpc = zerodevRpc()): Promise<SmartAccount> {
|
|
30
|
+
const owner = privateKeyToAccount(privateKey as Hex);
|
|
31
|
+
const publicClient = createPublicClient({ transport: http(rpc) });
|
|
32
|
+
const validator = await signerToEcdsaValidator(publicClient, { signer: owner, entryPoint: ENTRY_POINT, kernelVersion: KERNEL_V3_1 });
|
|
33
|
+
const account = await createKernelAccount(publicClient, { plugins: { sudo: validator }, entryPoint: ENTRY_POINT, kernelVersion: KERNEL_V3_1, index: 0n });
|
|
34
|
+
const paymaster = createZeroDevPaymasterClient({ chain: base, transport: http(rpc) });
|
|
35
|
+
const client = (): KernelAccountClient =>
|
|
36
|
+
createKernelAccountClient({
|
|
37
|
+
account,
|
|
38
|
+
chain: base,
|
|
39
|
+
bundlerTransport: http(rpc),
|
|
40
|
+
client: publicClient,
|
|
41
|
+
paymaster: { getPaymasterData: (userOperation) => paymaster.sponsorUserOperation({ userOperation }) },
|
|
42
|
+
userOperation: { estimateFeesPerGas: ({ bundlerClient }) => getUserOperationGasPrice(bundlerClient) },
|
|
43
|
+
});
|
|
44
|
+
return {
|
|
45
|
+
address: account.address,
|
|
46
|
+
publicClient,
|
|
47
|
+
client,
|
|
48
|
+
signMessage: (message) => account.signMessage({ message }),
|
|
49
|
+
deployed: async () => {
|
|
50
|
+
const code = await publicClient.getCode({ address: account.address });
|
|
51
|
+
return code !== undefined && code !== '0x';
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function sendSponsored(smart: SmartAccount, to: Hex, data: Hex): Promise<Hex> {
|
|
57
|
+
const client = smart.client();
|
|
58
|
+
const hash = await client.sendTransaction({ to, data, value: 0n } as Parameters<typeof client.sendTransaction>[0]);
|
|
59
|
+
const receipt = await smart.publicClient.waitForTransactionReceipt({ hash });
|
|
60
|
+
if (receipt.status !== 'success') throw new Error(`the Base transaction ${hash} reverted`);
|
|
61
|
+
return hash;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function ensureDeployed(smart: SmartAccount): Promise<boolean> {
|
|
65
|
+
if (await smart.deployed()) return false;
|
|
66
|
+
await sendSponsored(smart, smart.address, '0x');
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { GroupOp, Station, Verb } from '@metro-labs/core/stations/types';
|
|
2
|
+
import { PROFILE_FIELDS, type ProfileField } from '@metro-labs/core/stations/profile';
|
|
2
3
|
import { XMTP_TOOLS, xmtpSendAttachments } from './tools.js';
|
|
3
4
|
|
|
4
5
|
export const xmtpStation: Station = {
|
|
@@ -8,6 +9,8 @@ export const xmtpStation: Station = {
|
|
|
8
9
|
messageVerbs: new Set<Verb>(['send', 'reply', 'react', 'unreact', 'read']),
|
|
9
10
|
groupOps: new Set<GroupOp>(['create_group', 'add_members', 'remove_members']),
|
|
10
11
|
attachmentMode: 'native',
|
|
12
|
+
profileFields: new Set<ProfileField>(PROFILE_FIELDS),
|
|
13
|
+
claimsName: true,
|
|
11
14
|
sendAttachments: xmtpSendAttachments,
|
|
12
15
|
tools: XMTP_TOOLS,
|
|
13
16
|
};
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname } from 'node:path';
|
|
3
3
|
import { Client, type ClientOptions, type Signer } from '@xmtp/node-sdk';
|
|
4
|
-
import { expandHome, signerFor, XMTP_ENV } from './identity.js';
|
|
4
|
+
import { expandHome, identityFor, signerFor, XMTP_ENV } from './identity.js';
|
|
5
|
+
import { ensureDeployed } from './smart.js';
|
|
5
6
|
|
|
6
7
|
export class XmtpVerifyError extends Error {}
|
|
7
8
|
|
|
@@ -10,6 +11,7 @@ export interface XmtpVerified {
|
|
|
10
11
|
address: string;
|
|
11
12
|
installationId: string;
|
|
12
13
|
dbPath: string;
|
|
14
|
+
smart: boolean;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
interface RegisteredClient {
|
|
@@ -34,15 +36,25 @@ const reason = (err: unknown): string =>
|
|
|
34
36
|
export async function verifyXmtpKey(
|
|
35
37
|
privateKey: string,
|
|
36
38
|
dbPath: string,
|
|
39
|
+
smart = false,
|
|
37
40
|
create: CreateXmtpClient = openInbox,
|
|
38
41
|
): Promise<XmtpVerified> {
|
|
39
|
-
let
|
|
40
|
-
let address: string;
|
|
42
|
+
let identity: Awaited<ReturnType<typeof identityFor>>;
|
|
41
43
|
try {
|
|
42
|
-
|
|
44
|
+
signerFor(privateKey);
|
|
43
45
|
} catch {
|
|
44
46
|
throw new XmtpVerifyError('that is not a usable XMTP private key');
|
|
45
47
|
}
|
|
48
|
+
try {
|
|
49
|
+
identity = await identityFor(privateKey, smart);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
throw new XmtpVerifyError(`could not build the smart account on Base: ${reason(err)}`);
|
|
52
|
+
}
|
|
53
|
+
const { signer, address } = identity;
|
|
54
|
+
if (identity.smart !== null)
|
|
55
|
+
await ensureDeployed(identity.smart).catch((err: unknown) => {
|
|
56
|
+
throw new XmtpVerifyError(`could not deploy the smart account on Base (is the ZeroDev sponsorship still on?): ${reason(err)}`);
|
|
57
|
+
});
|
|
46
58
|
const resolved = expandHome(dbPath);
|
|
47
59
|
mkdirSync(dirname(resolved), { recursive: true });
|
|
48
60
|
const client = await create(signer, resolved).catch((err: unknown) => {
|
|
@@ -59,6 +71,7 @@ export async function verifyXmtpKey(
|
|
|
59
71
|
address,
|
|
60
72
|
installationId: client.installationId,
|
|
61
73
|
dbPath: resolved,
|
|
74
|
+
smart,
|
|
62
75
|
};
|
|
63
76
|
}
|
|
64
77
|
|
|
@@ -68,10 +81,11 @@ async function runCli(): Promise<void> {
|
|
|
68
81
|
const input = JSON.parse(readFileSync(0, 'utf8')) as {
|
|
69
82
|
privateKey?: unknown;
|
|
70
83
|
dbPath?: unknown;
|
|
84
|
+
smart?: unknown;
|
|
71
85
|
};
|
|
72
86
|
if (typeof input.privateKey !== 'string' || typeof input.dbPath !== 'string')
|
|
73
87
|
throw new XmtpVerifyError('xmtp verify needs a privateKey and a dbPath');
|
|
74
|
-
out = { ok: true, ...(await verifyXmtpKey(input.privateKey, input.dbPath)) };
|
|
88
|
+
out = { ok: true, ...(await verifyXmtpKey(input.privateKey, input.dbPath, input.smart === true)) };
|
|
75
89
|
} catch (err) {
|
|
76
90
|
out = { ok: false, error: reason(err) };
|
|
77
91
|
}
|
package/runtime/runtime.json
CHANGED
package/runtime/stations.json
CHANGED