@stage-labs/metro 0.1.0-beta.155 → 0.1.0-beta.157

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.155",
3
+ "version": "0.1.0-beta.157",
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.155",
3
+ "version": "0.1.0-beta.157",
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",
@@ -25,8 +25,8 @@ function parseAccountScoped(
25
25
  }
26
26
 
27
27
  const isSnowflake = (s: string): boolean => /^\d+$/.test(s);
28
- const isThreemaId = (s: string): boolean =>
29
- /^(?:[A-Z0-9]{8}|\*[A-Z0-9]{7})$/i.test(s);
28
+ const isThreemaResource = (s: string): boolean =>
29
+ /^(?:[A-Z0-9]{8}|\*[A-Z0-9]{7})(?:-[0-9a-f]{16})?$/i.test(s);
30
30
  const isSignedInt = (s: string): boolean => /^-?\d+$/.test(s);
31
31
 
32
32
  function splitTelegramAccount(path: string[]): { accountId: string; rest: string[] } {
@@ -83,5 +83,5 @@ export const Line = {
83
83
  parseTelegram: (line: Line | string) => parseTelegramLine(line),
84
84
 
85
85
  parseThreema: (line: Line | string) =>
86
- parseAccountScoped(line, 'threema', isThreemaId),
86
+ parseAccountScoped(line, 'threema', isThreemaResource),
87
87
  };
@@ -9,6 +9,8 @@ import { Line } from '@metro-labs/core/lines';
9
9
  import { fetchPublicKey } from './api.js';
10
10
  import { hexToBytes, keyPairFrom, type KeyPair } from './crypto.js';
11
11
  import { isGatewayId, normalizeThreemaId, parsePrivateKey } from './ids.js';
12
+ import { GroupStore, parseGroupKey } from './groups.js';
13
+ import type { GroupRef } from './messages.js';
12
14
 
13
15
  const ACCOUNTS_FILE =
14
16
  process.env.THREEMA_ACCOUNTS_FILE ??
@@ -55,14 +57,17 @@ export interface Account {
55
57
  cfg: AccountConfig;
56
58
  keys: KeyPair;
57
59
  publicKeys: Map<string, Uint8Array>;
60
+ groups: GroupStore;
58
61
  }
59
62
 
63
+ export type Target = { kind: 'user'; id: string } | { kind: 'group'; group: GroupRef };
64
+
60
65
  export const accounts = new Map<string, Account>();
61
66
 
62
67
  export function bootAccount(cfg: AccountConfig): Account {
63
68
  const key = parsePrivateKey(cfg.privateKey);
64
69
  if (key === null) throw new Error(`account '${cfg.id}' has no usable private key`);
65
- return { cfg, keys: keyPairFrom(key), publicKeys: new Map() };
70
+ return { cfg, keys: keyPairFrom(key), publicKeys: new Map(), groups: new GroupStore(cfg.id, cfg.gatewayId) };
66
71
  }
67
72
 
68
73
  export function accountFor(id: string): Account {
@@ -77,7 +82,7 @@ export function accountFor(id: string): Account {
77
82
  export function targetOf(
78
83
  line: string,
79
84
  account?: string,
80
- ): { acct: Account; to: string } {
85
+ ): { acct: Account; target: Target } {
81
86
  const parsed = Line.parseThreema(line);
82
87
  if (parsed === null) throw new Error(`not a threema line: ${line}`);
83
88
  const id = resolveAccountId(
@@ -85,7 +90,9 @@ export function targetOf(
85
90
  { account, line },
86
91
  (l) => Line.parseThreema(l)?.accountId,
87
92
  );
88
- return { acct: accountFor(id), to: normalizeThreemaId(parsed.resource) };
93
+ const group = parseGroupKey(parsed.resource);
94
+ const target: Target = group === null ? { kind: 'user', id: normalizeThreemaId(parsed.resource) } : { kind: 'group', group };
95
+ return { acct: accountFor(id), target };
89
96
  }
90
97
 
91
98
  export async function publicKeyFor(
@@ -6,47 +6,19 @@ import {
6
6
  } from '@metro-labs/core/stations/station-runtime';
7
7
  import type { Normalized } from '@metro-labs/core/stations/messaging-normalize';
8
8
  import { accountFor, accounts, publicKeyFor, targetOf, type Account } from './accounts.js';
9
- import { sendE2E } from './api.js';
10
- import {
11
- bytesToHex,
12
- decode,
13
- encodeText,
14
- hexToBytes,
15
- macMatches,
16
- open,
17
- quoted,
18
- seal,
19
- type Decoded,
20
- } from './crypto.js';
21
- import {
22
- emitInbound,
23
- emitOutbound,
24
- receiptEnvelope,
25
- textEnvelope,
26
- type InboundMeta,
27
- } from './format.js';
28
- import { isThreemaId, MESSAGE_ID_RE, normalizeThreemaId } from './ids.js';
9
+ import { hexToBytes, macMatches, open } from './crypto.js';
10
+ import { groupLineOf, type InboundMeta } from './format.js';
11
+ import { groupKey } from './groups.js';
12
+ import { deliver } from './inbound.js';
13
+ import { isThreemaId, normalizeThreemaId } from './ids.js';
14
+ import { decode } from './messages.js';
15
+ import { react, send } from './outbound.js';
29
16
 
30
17
  export type { CallMsg };
31
-
32
- export const MAX_TEXT_BYTES = 3500;
33
- const SENT_MAX = 2000;
18
+ export { MAX_TEXT_BYTES } from './outbound.js';
34
19
 
35
20
  type Args = Record<string, unknown>;
36
21
 
37
- const sentIds = new Set<string>();
38
-
39
- function noteSent(messageId: string): void {
40
- sentIds.add(messageId);
41
- while (sentIds.size > SENT_MAX) {
42
- const oldest = sentIds.values().next();
43
- if (oldest.done) break;
44
- sentIds.delete(oldest.value);
45
- }
46
- }
47
-
48
- const sentByUs = (messageId: string): boolean => sentIds.has(messageId);
49
-
50
22
  const WEB_CHAT = 'https://web.threema.com/#!/messenger/conversation/contact/';
51
23
 
52
24
  export const chatUrl = (gatewayId: string): string => `${WEB_CHAT}${encodeURIComponent(gatewayId)}`;
@@ -58,6 +30,7 @@ function listAccounts(id: string): void {
58
30
  url: chatUrl(a.cfg.gatewayId),
59
31
  owner: a.cfg.owner ?? null,
60
32
  gatewayId: a.cfg.gatewayId,
33
+ groups: a.groups.list().map((g) => ({ line: groupLineOf(a.cfg.id, g), name: g.name, members: g.members.length })),
61
34
  ...(a.cfg.callbackId && a.cfg.callbackToken
62
35
  ? { callbackId: a.cfg.callbackId, callbackToken: a.cfg.callbackToken }
63
36
  : {}),
@@ -65,53 +38,20 @@ function listAccounts(id: string): void {
65
38
  respond(id, { result: { accounts: list } });
66
39
  }
67
40
 
68
- interface SendArgs {
69
- line: string;
70
- text?: unknown;
71
- replyTo?: unknown;
72
- account?: string;
73
- }
74
-
75
- function requireText(a: SendArgs): string {
76
- const text = typeof a.text === 'string' ? a.text : '';
77
- if (text === '')
78
- throw new TrainError(
79
- 'threema_text_required',
80
- 'threema carries text only; give some text to send',
81
- { retryable: false },
82
- );
83
- return text;
84
- }
85
-
86
- function replyTargetOf(a: SendArgs): string | undefined {
87
- if (a.replyTo === undefined || a.replyTo === null || a.replyTo === '') return undefined;
88
- const target = typeof a.replyTo === 'string' ? a.replyTo.toLowerCase() : '';
89
- if (!MESSAGE_ID_RE.test(target))
90
- throw new TrainError(
91
- 'threema_bad_reply_target',
92
- 'replyTo must be a Threema message id, 16 hex characters',
93
- { retryable: false },
94
- );
95
- return target;
96
- }
97
-
98
- async function send(id: string, args: Args): Promise<void> {
99
- const a = args as unknown as SendArgs;
100
- const text = requireText(a);
101
- const replyTo = replyTargetOf(a);
102
- const { acct, to } = targetOf(a.line, a.account);
103
- const plain = encodeText(replyTo === undefined ? text : quoted(replyTo, text));
104
- if (plain.length - 1 > MAX_TEXT_BYTES)
105
- throw new TrainError(
106
- 'threema_message_too_long',
107
- `Threema carries at most ${MAX_TEXT_BYTES} bytes of text per message; split it up`,
108
- { retryable: false },
109
- );
110
- const { nonce, box } = seal(plain, await publicKeyFor(acct, to), acct.keys);
111
- const messageId = await sendE2E(acct.cfg, to, bytesToHex(nonce), bytesToHex(box));
112
- noteSent(messageId);
113
- emitOutbound(acct.cfg.id, a.line, messageId, text, replyTo);
114
- respond(id, { result: { messageId, account: acct.cfg.id } });
41
+ function listMembers(id: string, args: Args): void {
42
+ const line = typeof args.line === 'string' ? args.line : '';
43
+ const { acct, target } = targetOf(line, typeof args.account === 'string' ? args.account : undefined);
44
+ if (target.kind !== 'group') {
45
+ respond(id, { result: { members: [], capability: { supported: false, complete: false, reason: 'a Threema 1:1 chat has no roster' } } });
46
+ return;
47
+ }
48
+ const roster = acct.groups.get(target.group);
49
+ if (roster === undefined) {
50
+ respond(id, { result: { members: [], capability: { supported: true, complete: false, reason: `no member list yet for ${groupKey(target.group)}` } } });
51
+ return;
52
+ }
53
+ const members = roster.members.map((m) => ({ id: m, name: m, is_admin: m === roster.creator, is_bot: m === acct.cfg.gatewayId }));
54
+ respond(id, { result: { members, capability: { supported: true, complete: true, total: members.length } } });
115
55
  }
116
56
 
117
57
  interface CallbackArgs {
@@ -148,50 +88,21 @@ function parseCallback(args: Args): CallbackArgs {
148
88
  return nickname === '' ? fields : { ...fields, nickname };
149
89
  }
150
90
 
151
- function deliver(acct: Account, m: InboundMeta, decoded: Decoded): string {
152
- const owner = acct.cfg.owner;
153
- if (decoded.kind === 'text') {
154
- emitInbound(acct.cfg.id, owner, textEnvelope(acct.cfg.id, m, decoded.text, sentByUs));
155
- return 'text';
156
- }
157
- if (decoded.kind === 'receipt') {
158
- let reactions = 0;
159
- for (const target of decoded.messageIds) {
160
- const env = receiptEnvelope(acct.cfg.id, m, decoded.status, target);
161
- if (env === null) continue;
162
- emitInbound(acct.cfg.id, owner, env);
163
- reactions += 1;
164
- }
165
- return reactions > 0 ? 'reaction' : `receipt:${decoded.status}`;
166
- }
167
- if (decoded.kind === 'typing') return 'typing';
168
- const type = `0x${decoded.type.toString(16)}`;
169
- process.stderr.write(
170
- `threema[${acct.cfg.id}]: ignored a message of type ${type} from ${m.from}\n`,
171
- );
172
- return `ignored:${type}`;
91
+ function checkedSender(acct: Account, cb: CallbackArgs): string {
92
+ if (!macMatches(acct.cfg.secret, cb, cb.mac))
93
+ throw new TrainError('threema_bad_mac', 'the callback MAC does not match this account API secret', { retryable: false });
94
+ const from = normalizeThreemaId(cb.from);
95
+ if (!isThreemaId(from))
96
+ throw new TrainError('threema_bad_callback', `'${cb.from}' is not a Threema ID`, { retryable: false });
97
+ if (normalizeThreemaId(cb.to) !== acct.cfg.gatewayId)
98
+ throw new TrainError('threema_wrong_recipient', `callback addressed to ${cb.to}, not to ${acct.cfg.gatewayId}`, { retryable: false });
99
+ return from;
173
100
  }
174
101
 
175
102
  async function callback(id: string, args: Args): Promise<void> {
176
103
  const cb = parseCallback(args);
177
104
  const acct = accountFor(cb.account);
178
- if (!macMatches(acct.cfg.secret, cb, cb.mac))
179
- throw new TrainError(
180
- 'threema_bad_mac',
181
- 'the callback MAC does not match this account API secret',
182
- { retryable: false },
183
- );
184
- const from = normalizeThreemaId(cb.from);
185
- if (!isThreemaId(from))
186
- throw new TrainError('threema_bad_callback', `'${cb.from}' is not a Threema ID`, {
187
- retryable: false,
188
- });
189
- if (normalizeThreemaId(cb.to) !== acct.cfg.gatewayId)
190
- throw new TrainError(
191
- 'threema_wrong_recipient',
192
- `callback addressed to ${cb.to}, not to ${acct.cfg.gatewayId}`,
193
- { retryable: false },
194
- );
105
+ const from = checkedSender(acct, cb);
195
106
  const plain = open(
196
107
  hexToBytes(cb.box, 'box'),
197
108
  hexToBytes(cb.nonce, 'nonce'),
@@ -214,10 +125,11 @@ export function normalizeThreema(action: string, env: Args): Normalized {
214
125
  action: 'send',
215
126
  args: { line: env.line, text: env.text, replyTo: env.replyTo, account: env.account },
216
127
  };
128
+ if (action === 'unreact') return { action: 'react', args: { ...env, action: 'removed' } };
217
129
  return { action, args: env };
218
130
  }
219
131
 
220
132
  export const handleCall = makeStation({
221
- handlers: { accounts: listAccounts, send, callback },
133
+ handlers: { accounts: listAccounts, send, react, callback, listMembers },
222
134
  normalize: normalizeThreema,
223
135
  });
@@ -102,6 +102,7 @@ export async function sendE2E(
102
102
  to: string,
103
103
  nonceHex: string,
104
104
  boxHex: string,
105
+ group = false,
105
106
  ): Promise<string> {
106
107
  const body = new URLSearchParams({
107
108
  from: c.gatewayId,
@@ -109,6 +110,7 @@ export async function sendE2E(
109
110
  nonce: nonceHex,
110
111
  box: boxHex,
111
112
  secret: c.secret,
113
+ ...(group ? { group: '1' } : {}),
112
114
  });
113
115
  const res = await call(
114
116
  '/send_e2e',
@@ -1,14 +1,6 @@
1
1
  import { createHmac, randomInt, timingSafeEqual } from 'node:crypto';
2
2
  import nacl from 'tweetnacl';
3
3
 
4
- export const MSG_TEXT = 0x01;
5
- export const MSG_DELIVERY_RECEIPT = 0x80;
6
- export const MSG_TYPING = 0x90;
7
-
8
- export const RECEIPT_ACK = 0x03;
9
- export const RECEIPT_DECLINE = 0x04;
10
-
11
- const MESSAGE_ID_BYTES = 8;
12
4
  const HEX_RE = /^(?:[0-9a-fA-F]{2})*$/;
13
5
 
14
6
  export interface KeyPair {
@@ -63,38 +55,6 @@ export function open(
63
55
  return padded === null ? null : unpad(padded);
64
56
  }
65
57
 
66
- export function encodeText(text: string): Uint8Array {
67
- const utf8 = Buffer.from(text, 'utf8');
68
- const out = new Uint8Array(1 + utf8.length);
69
- out[0] = MSG_TEXT;
70
- out.set(utf8, 1);
71
- return out;
72
- }
73
-
74
- export type Decoded =
75
- | { kind: 'text'; text: string }
76
- | { kind: 'receipt'; status: number; messageIds: string[] }
77
- | { kind: 'typing' }
78
- | { kind: 'other'; type: number };
79
-
80
- function receiptIds(body: Uint8Array): string[] {
81
- const ids: string[] = [];
82
- for (let at = 1; at + MESSAGE_ID_BYTES <= body.length; at += MESSAGE_ID_BYTES)
83
- ids.push(bytesToHex(body.subarray(at, at + MESSAGE_ID_BYTES)));
84
- return ids;
85
- }
86
-
87
- export function decode(plain: Uint8Array): Decoded {
88
- const type = plain[0];
89
- const body = plain.subarray(1);
90
- if (type === MSG_TEXT)
91
- return { kind: 'text', text: Buffer.from(body).toString('utf8') };
92
- if (type === MSG_DELIVERY_RECEIPT)
93
- return { kind: 'receipt', status: body[0] ?? 0, messageIds: receiptIds(body) };
94
- if (type === MSG_TYPING) return { kind: 'typing' };
95
- return { kind: 'other', type: type ?? -1 };
96
- }
97
-
98
58
  export interface CallbackFields {
99
59
  from: string;
100
60
  to: string;
@@ -1,5 +1,7 @@
1
1
  import { emit, mintId } from '@metro-labs/core/stations/station-runtime';
2
- import { RECEIPT_ACK, RECEIPT_DECLINE, unquote } from './crypto.js';
2
+ import { unquote } from './crypto.js';
3
+ import { groupKey } from './groups.js';
4
+ import { RECEIPT_ACK, RECEIPT_DECLINE, type GroupRef } from './messages.js';
3
5
 
4
6
  const SELF_URI = process.env.METRO_SELF_URI ?? '';
5
7
 
@@ -11,6 +13,9 @@ const REACTION_OF: Record<number, string> = {
11
13
  export const lineOf = (accountId: string, threemaId: string): string =>
12
14
  `metro://threema/${accountId}/${threemaId}`;
13
15
 
16
+ export const groupLineOf = (accountId: string, group: GroupRef): string =>
17
+ `metro://threema/${accountId}/${groupKey(group)}`;
18
+
14
19
  const userOf = (accountId: string, threemaId: string): string =>
15
20
  `metro://threema/${accountId}/user/${threemaId}`;
16
21
 
@@ -22,6 +27,18 @@ export interface InboundMeta {
22
27
  nickname?: string;
23
28
  }
24
29
 
30
+ export interface Room {
31
+ group: GroupRef | null;
32
+ name: string | null;
33
+ }
34
+
35
+ export const DIRECT: Room = { group: null, name: null };
36
+
37
+ const MENTION_ALL = '@@@@@@@@';
38
+
39
+ export const mentionsSelf = (text: string, self: string): boolean =>
40
+ text.includes(MENTION_ALL) || text.toUpperCase().includes(`@@${self.toUpperCase()}`);
41
+
25
42
  function tsOf(date: string): string {
26
43
  const seconds = Number(date);
27
44
  return Number.isFinite(seconds) && seconds > 0
@@ -33,19 +50,21 @@ function base(
33
50
  accountId: string,
34
51
  m: InboundMeta,
35
52
  type: string,
53
+ room: Room,
36
54
  ): Record<string, unknown> {
55
+ const group = room.group;
37
56
  return {
38
57
  kind: 'inbound',
39
58
  id: mintId(),
40
59
  ts: tsOf(m.date),
41
60
  station: 'threema',
42
- line: lineOf(accountId, m.from),
43
- line_name: m.nickname ?? m.from,
61
+ line: group === null ? lineOf(accountId, m.from) : groupLineOf(accountId, group),
62
+ line_name: group === null ? (m.nickname ?? m.from) : (room.name ?? groupKey(group)),
44
63
  from: userOf(accountId, m.from),
45
64
  from_name: m.nickname,
46
65
  from_display_name: m.nickname,
47
- is_private: true,
48
- payload: { from: m.from, to: m.to, nickname: m.nickname ?? null, type },
66
+ is_private: group === null,
67
+ payload: { from: m.from, to: m.to, nickname: m.nickname ?? null, type, ...(group === null ? {} : { group: groupKey(group) }) },
49
68
  };
50
69
  }
51
70
 
@@ -54,12 +73,14 @@ export function textEnvelope(
54
73
  m: InboundMeta,
55
74
  raw: string,
56
75
  sentByUs: (messageId: string) => boolean,
76
+ room: Room = DIRECT,
57
77
  ): Record<string, unknown> {
58
78
  const { replyTo, text } = unquote(raw);
59
79
  return {
60
- ...base(accountId, m, 'text'),
80
+ ...base(accountId, m, 'text', room),
61
81
  message_id: m.messageId,
62
82
  text,
83
+ ...(room.group !== null && mentionsSelf(text, m.to) ? { mentions_self: true } : {}),
63
84
  ...(replyTo === undefined
64
85
  ? {}
65
86
  : {
@@ -70,22 +91,35 @@ export function textEnvelope(
70
91
  };
71
92
  }
72
93
 
73
- export function receiptEnvelope(
94
+ export function reactionEnvelope(
74
95
  accountId: string,
75
96
  m: InboundMeta,
76
- status: number,
97
+ emoji: string,
77
98
  targetId: string,
78
- ): Record<string, unknown> | null {
79
- const emoji = REACTION_OF[status];
80
- if (emoji === undefined) return null;
99
+ removed: boolean,
100
+ room: Room = DIRECT,
101
+ type = 'reaction',
102
+ ): Record<string, unknown> {
81
103
  return {
82
- ...base(accountId, m, 'receipt'),
104
+ ...base(accountId, m, type, room),
83
105
  message_id: `${m.messageId}:${targetId}`,
106
+ text: `[react ${emoji}${removed ? ' (removed)' : ''}]`,
84
107
  emoji,
85
- event: { type: 'react', emoji, targetId },
108
+ event: { type: 'react', emoji, targetId, ...(removed ? { removed: true } : {}) },
86
109
  };
87
110
  }
88
111
 
112
+ export function receiptEnvelope(
113
+ accountId: string,
114
+ m: InboundMeta,
115
+ status: number,
116
+ targetId: string,
117
+ room: Room = DIRECT,
118
+ ): Record<string, unknown> | null {
119
+ const emoji = REACTION_OF[status];
120
+ return emoji === undefined ? null : reactionEnvelope(accountId, m, emoji, targetId, false, room, 'receipt');
121
+ }
122
+
89
123
  export function emitInbound(
90
124
  accountId: string,
91
125
  owner: string | undefined,
@@ -121,3 +155,28 @@ export function emitOutbound(
121
155
  payload: { account: accountId },
122
156
  });
123
157
  }
158
+
159
+ export function emitOutboundReaction(
160
+ accountId: string,
161
+ line: string,
162
+ messageId: string,
163
+ emoji: string,
164
+ targetId: string,
165
+ removed: boolean,
166
+ ): void {
167
+ emit({
168
+ kind: 'outbound',
169
+ id: mintId(),
170
+ ts: new Date().toISOString(),
171
+ station: 'threema',
172
+ line,
173
+ from: SELF_URI,
174
+ to: line,
175
+ message_id: messageId,
176
+ text: `[react ${emoji}${removed ? ' (removed)' : ''}]`,
177
+ emoji,
178
+ event: { type: 'react', emoji, targetId, ...(removed ? { removed: true } : {}) },
179
+ account: accountId,
180
+ payload: { account: accountId },
181
+ });
182
+ }
@@ -0,0 +1,95 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import type { GroupRef } from './messages.js';
5
+
6
+ export const GROUP_RE = /^([A-Z0-9]{8}|\*[A-Z0-9]{7})-([0-9a-f]{16})$/i;
7
+
8
+ export interface GroupRoster {
9
+ creator: string;
10
+ groupId: string;
11
+ members: string[];
12
+ name: string | null;
13
+ }
14
+
15
+ export const groupKey = (group: GroupRef): string => `${group.creator.toUpperCase()}-${group.groupId.toLowerCase()}`;
16
+
17
+ export function parseGroupKey(resource: string): GroupRef | null {
18
+ const m = GROUP_RE.exec(resource);
19
+ return m === null ? null : { creator: (m[1] ?? '').toUpperCase(), groupId: (m[2] ?? '').toLowerCase() };
20
+ }
21
+
22
+ const groupsFile = (accountId: string): string =>
23
+ join(process.env.THREEMA_GROUPS_DIR ?? join(homedir(), '.metro'), `threema-groups-${accountId}.json`);
24
+
25
+ function readRosters(accountId: string): Map<string, GroupRoster> {
26
+ try {
27
+ const raw: unknown = JSON.parse(readFileSync(groupsFile(accountId), 'utf8'));
28
+ const out = new Map<string, GroupRoster>();
29
+ if (Array.isArray(raw))
30
+ for (const entry of raw) {
31
+ const g = (entry ?? {}) as Partial<GroupRoster>;
32
+ if (typeof g.creator === 'string' && typeof g.groupId === 'string' && Array.isArray(g.members))
33
+ out.set(groupKey(g as GroupRef), { creator: g.creator, groupId: g.groupId, members: g.members.filter((m): m is string => typeof m === 'string'), name: typeof g.name === 'string' ? g.name : null });
34
+ }
35
+ return out;
36
+ } catch {
37
+ return new Map();
38
+ }
39
+ }
40
+
41
+ export class GroupStore {
42
+ private readonly rosters: Map<string, GroupRoster>;
43
+
44
+ constructor(
45
+ private readonly accountId: string,
46
+ private readonly self: string,
47
+ ) {
48
+ this.rosters = readRosters(accountId);
49
+ }
50
+
51
+ get(group: GroupRef): GroupRoster | undefined {
52
+ return this.rosters.get(groupKey(group));
53
+ }
54
+
55
+ list(): GroupRoster[] {
56
+ return [...this.rosters.values()];
57
+ }
58
+
59
+ setup(creator: string, groupId: string, members: string[]): GroupRoster {
60
+ const key = groupKey({ creator, groupId });
61
+ const held = this.rosters.get(key);
62
+ const roster: GroupRoster = { creator, groupId, members: [...new Set([creator, ...members])], name: held?.name ?? null };
63
+ this.rosters.set(key, roster);
64
+ this.save();
65
+ return roster;
66
+ }
67
+
68
+ rename(creator: string, groupId: string, name: string): void {
69
+ const held = this.rosters.get(groupKey({ creator, groupId }));
70
+ if (held === undefined) return;
71
+ held.name = name;
72
+ this.save();
73
+ }
74
+
75
+ leave(group: GroupRef, member: string): void {
76
+ const held = this.rosters.get(groupKey(group));
77
+ if (held === undefined) return;
78
+ if (member === this.self) this.rosters.delete(groupKey(group));
79
+ else held.members = held.members.filter((m) => m !== member);
80
+ this.save();
81
+ }
82
+
83
+ recipients(group: GroupRef): string[] | null {
84
+ const held = this.get(group);
85
+ return held === undefined ? null : held.members.filter((m) => m !== this.self);
86
+ }
87
+
88
+ private save(): void {
89
+ const path = groupsFile(this.accountId);
90
+ mkdirSync(dirname(path), { recursive: true });
91
+ const tmp = `${path}.${String(process.pid)}.tmp`;
92
+ writeFileSync(tmp, JSON.stringify(this.list()), { mode: 0o600 });
93
+ renameSync(tmp, path);
94
+ }
95
+ }
@@ -0,0 +1,80 @@
1
+ import type { Account } from './accounts.js';
2
+ import {
3
+ DIRECT,
4
+ emitInbound,
5
+ reactionEnvelope,
6
+ receiptEnvelope,
7
+ textEnvelope,
8
+ type InboundMeta,
9
+ type Room,
10
+ } from './format.js';
11
+ import type { Decoded, GroupRef } from './messages.js';
12
+ import { requestSync, sentByUs } from './outbound.js';
13
+
14
+ const synced = new Set<string>();
15
+
16
+ function roomOf(acct: Account, group: GroupRef | null): Room {
17
+ if (group === null) return DIRECT;
18
+ return { group, name: acct.groups.get(group)?.name ?? null };
19
+ }
20
+
21
+ function askForRoster(acct: Account, group: GroupRef): void {
22
+ const key = `${acct.cfg.id}:${group.creator}-${group.groupId}`;
23
+ if (acct.groups.get(group) !== undefined || synced.has(key)) return;
24
+ synced.add(key);
25
+ requestSync(acct, group).catch((err: unknown) => {
26
+ process.stderr.write(`threema[${acct.cfg.id}]: could not ask ${group.creator} for the group roster: ${err instanceof Error ? err.message : String(err)}\n`);
27
+ });
28
+ }
29
+
30
+ function reactions(acct: Account, m: InboundMeta, d: Extract<Decoded, { kind: 'receipt' }>): string {
31
+ let count = 0;
32
+ for (const target of d.messageIds) {
33
+ const env = receiptEnvelope(acct.cfg.id, m, d.status, target, roomOf(acct, d.group));
34
+ if (env === null) continue;
35
+ emitInbound(acct.cfg.id, acct.cfg.owner, env);
36
+ count += 1;
37
+ }
38
+ return count > 0 ? 'reaction' : `receipt:${String(d.status)}`;
39
+ }
40
+
41
+ function control(acct: Account, m: InboundMeta, d: Decoded): string | null {
42
+ if (d.kind === 'group-setup') {
43
+ acct.groups.setup(m.from, d.groupId, d.members);
44
+ return 'group-setup';
45
+ }
46
+ if (d.kind === 'group-rename') {
47
+ acct.groups.rename(m.from, d.groupId, d.name);
48
+ return 'group-rename';
49
+ }
50
+ if (d.kind === 'group-leave') {
51
+ acct.groups.leave(d.group, m.from);
52
+ return 'group-leave';
53
+ }
54
+ return null;
55
+ }
56
+
57
+ export function deliver(acct: Account, m: InboundMeta, d: Decoded): string {
58
+ const owner = acct.cfg.owner;
59
+ const handled = control(acct, m, d);
60
+ if (handled !== null) return handled;
61
+ if (d.kind === 'text') {
62
+ emitInbound(acct.cfg.id, owner, textEnvelope(acct.cfg.id, m, d.text, sentByUs));
63
+ return 'text';
64
+ }
65
+ if (d.kind === 'group-text') {
66
+ askForRoster(acct, d.group);
67
+ emitInbound(acct.cfg.id, owner, textEnvelope(acct.cfg.id, m, d.text, sentByUs, roomOf(acct, d.group)));
68
+ return 'group-text';
69
+ }
70
+ if (d.kind === 'reaction') {
71
+ if (d.group !== null) askForRoster(acct, d.group);
72
+ emitInbound(acct.cfg.id, owner, reactionEnvelope(acct.cfg.id, m, d.emoji, d.messageId, d.removed, roomOf(acct, d.group)));
73
+ return 'reaction';
74
+ }
75
+ if (d.kind === 'receipt') return reactions(acct, m, d);
76
+ if (d.kind === 'typing') return 'typing';
77
+ const type = d.kind === 'other' ? `0x${d.type.toString(16)}` : d.kind;
78
+ process.stderr.write(`threema[${acct.cfg.id}]: ignored a message of type ${type} from ${m.from}\n`);
79
+ return `ignored:${type}`;
80
+ }
@@ -0,0 +1,187 @@
1
+ import { bytesToHex, hexToBytes } from './crypto.js';
2
+
3
+ export const MSG_TEXT = 0x01;
4
+ export const MSG_GROUP_TEXT = 0x41;
5
+ export const MSG_GROUP_SETUP = 0x4a;
6
+ export const MSG_GROUP_RENAME = 0x4b;
7
+ export const MSG_GROUP_LEAVE = 0x4c;
8
+ export const MSG_GROUP_REQUEST_SYNC = 0x51;
9
+ export const MSG_DELIVERY_RECEIPT = 0x80;
10
+ export const MSG_GROUP_DELIVERY_RECEIPT = 0x81;
11
+ export const MSG_REACTION = 0x82;
12
+ export const MSG_GROUP_REACTION = 0x83;
13
+ export const MSG_TYPING = 0x90;
14
+
15
+ export const RECEIPT_ACK = 0x03;
16
+ export const RECEIPT_DECLINE = 0x04;
17
+
18
+ export const ID_BYTES = 8;
19
+ const MESSAGE_ID_BYTES = 8;
20
+ const PROTO_MESSAGE_ID = 0x09;
21
+ const PROTO_APPLY = 0x12;
22
+ const PROTO_WITHDRAW = 0x1a;
23
+
24
+ export interface GroupRef {
25
+ creator: string;
26
+ groupId: string;
27
+ }
28
+
29
+ export type Decoded =
30
+ | { kind: 'text'; text: string }
31
+ | { kind: 'group-text'; group: GroupRef; text: string }
32
+ | { kind: 'group-setup'; groupId: string; members: string[] }
33
+ | { kind: 'group-rename'; groupId: string; name: string }
34
+ | { kind: 'group-leave'; group: GroupRef }
35
+ | { kind: 'receipt'; group: GroupRef | null; status: number; messageIds: string[] }
36
+ | { kind: 'reaction'; group: GroupRef | null; messageId: string; emoji: string; removed: boolean }
37
+ | { kind: 'typing' }
38
+ | { kind: 'other'; type: number };
39
+
40
+ const ascii = (bytes: Uint8Array): string => Buffer.from(bytes).toString('ascii');
41
+ const utf8 = (bytes: Uint8Array): string => Buffer.from(bytes).toString('utf8');
42
+
43
+ const groupHeader = (group: GroupRef): Uint8Array =>
44
+ new Uint8Array([...Buffer.from(group.creator, 'ascii'), ...hexToBytes(group.groupId, 'group id')]);
45
+
46
+ const readGroup = (body: Uint8Array): GroupRef | null =>
47
+ body.length < ID_BYTES * 2 ? null : { creator: ascii(body.subarray(0, ID_BYTES)), groupId: bytesToHex(body.subarray(ID_BYTES, ID_BYTES * 2)) };
48
+
49
+ function ids(body: Uint8Array, from: number): string[] {
50
+ const out: string[] = [];
51
+ for (let at = from; at + MESSAGE_ID_BYTES <= body.length; at += MESSAGE_ID_BYTES) out.push(bytesToHex(body.subarray(at, at + MESSAGE_ID_BYTES)));
52
+ return out;
53
+ }
54
+
55
+ const members = (body: Uint8Array, from: number): string[] => {
56
+ const out: string[] = [];
57
+ for (let at = from; at + ID_BYTES <= body.length; at += ID_BYTES) out.push(ascii(body.subarray(at, at + ID_BYTES)));
58
+ return out;
59
+ };
60
+
61
+ function concat(...parts: Uint8Array[]): Uint8Array {
62
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
63
+ let at = 0;
64
+ for (const p of parts) {
65
+ out.set(p, at);
66
+ at += p.length;
67
+ }
68
+ return out;
69
+ }
70
+
71
+ const typed = (type: number, ...parts: Uint8Array[]): Uint8Array => concat(new Uint8Array([type]), ...parts);
72
+
73
+ export const encodeText = (text: string): Uint8Array => typed(MSG_TEXT, Buffer.from(text, 'utf8'));
74
+
75
+ export const encodeGroupText = (group: GroupRef, text: string): Uint8Array => typed(MSG_GROUP_TEXT, groupHeader(group), Buffer.from(text, 'utf8'));
76
+
77
+ export const encodeGroupSyncRequest = (groupId: string): Uint8Array => typed(MSG_GROUP_REQUEST_SYNC, hexToBytes(groupId, 'group id'));
78
+
79
+ function varint(n: number): Uint8Array {
80
+ const out: number[] = [];
81
+ let rest = n;
82
+ while (rest >= 0x80) {
83
+ out.push((rest & 0x7f) | 0x80);
84
+ rest = Math.floor(rest / 0x80);
85
+ }
86
+ out.push(rest);
87
+ return new Uint8Array(out);
88
+ }
89
+
90
+ export function encodeReactionProto(messageId: string, emoji: string, removed: boolean): Uint8Array {
91
+ const emojiBytes = Buffer.from(emoji, 'utf8');
92
+ return concat(
93
+ new Uint8Array([PROTO_MESSAGE_ID]),
94
+ hexToBytes(messageId, 'message id'),
95
+ new Uint8Array([removed ? PROTO_WITHDRAW : PROTO_APPLY]),
96
+ varint(emojiBytes.length),
97
+ emojiBytes,
98
+ );
99
+ }
100
+
101
+ export const encodeReaction = (messageId: string, emoji: string, removed: boolean): Uint8Array =>
102
+ typed(MSG_REACTION, encodeReactionProto(messageId, emoji, removed));
103
+
104
+ export const encodeGroupReaction = (group: GroupRef, messageId: string, emoji: string, removed: boolean): Uint8Array =>
105
+ typed(MSG_GROUP_REACTION, groupHeader(group), encodeReactionProto(messageId, emoji, removed));
106
+
107
+ function readVarint(body: Uint8Array, at: number): { value: number; next: number } | null {
108
+ let value = 0;
109
+ let shift = 1;
110
+ for (let i = at; i < body.length; i++) {
111
+ const byte = body[i] ?? 0;
112
+ value += (byte & 0x7f) * shift;
113
+ if (byte < 0x80) return { value, next: i + 1 };
114
+ shift *= 0x80;
115
+ }
116
+ return null;
117
+ }
118
+
119
+ interface ProtoField {
120
+ tag: number;
121
+ bytes: Uint8Array;
122
+ next: number;
123
+ }
124
+
125
+ function readField(body: Uint8Array, at: number): ProtoField | null {
126
+ const tag = body[at] ?? 0;
127
+ if (tag === PROTO_MESSAGE_ID)
128
+ return at + 1 + MESSAGE_ID_BYTES > body.length ? null : { tag, bytes: body.subarray(at + 1, at + 1 + MESSAGE_ID_BYTES), next: at + 1 + MESSAGE_ID_BYTES };
129
+ if (tag !== PROTO_APPLY && tag !== PROTO_WITHDRAW) return null;
130
+ const len = readVarint(body, at + 1);
131
+ if (len === null || len.next + len.value > body.length) return null;
132
+ return { tag, bytes: body.subarray(len.next, len.next + len.value), next: len.next + len.value };
133
+ }
134
+
135
+ export function decodeReactionProto(body: Uint8Array): { messageId: string; emoji: string; removed: boolean } | null {
136
+ let messageId: string | null = null;
137
+ let emoji = '';
138
+ let removed = false;
139
+ for (let at = 0; at < body.length; ) {
140
+ const field = readField(body, at);
141
+ if (field === null) return null;
142
+ if (field.tag === PROTO_MESSAGE_ID) messageId = bytesToHex(field.bytes);
143
+ else {
144
+ emoji = utf8(field.bytes);
145
+ removed = field.tag === PROTO_WITHDRAW;
146
+ }
147
+ at = field.next;
148
+ }
149
+ return messageId === null || emoji === '' ? null : { messageId, emoji, removed };
150
+ }
151
+
152
+ function decodeGroup(type: number, body: Uint8Array): Decoded {
153
+ const group = readGroup(body);
154
+ if (group === null) return { kind: 'other', type };
155
+ const rest = body.subarray(ID_BYTES * 2);
156
+ if (type === MSG_GROUP_TEXT) return { kind: 'group-text', group, text: utf8(rest) };
157
+ if (type === MSG_GROUP_LEAVE) return { kind: 'group-leave', group };
158
+ if (type === MSG_GROUP_DELIVERY_RECEIPT) return { kind: 'receipt', group, status: rest[0] ?? 0, messageIds: ids(rest, 1) };
159
+ const reaction = decodeReactionProto(rest);
160
+ return reaction === null ? { kind: 'other', type } : { kind: 'reaction', group, ...reaction };
161
+ }
162
+
163
+ const GROUP_TYPES = new Set([MSG_GROUP_TEXT, MSG_GROUP_LEAVE, MSG_GROUP_DELIVERY_RECEIPT, MSG_GROUP_REACTION]);
164
+
165
+ function decodeControl(type: number, body: Uint8Array): Decoded {
166
+ if (body.length < ID_BYTES) return { kind: 'other', type };
167
+ const groupId = bytesToHex(body.subarray(0, ID_BYTES));
168
+ if (type === MSG_GROUP_SETUP) return { kind: 'group-setup', groupId, members: members(body, ID_BYTES) };
169
+ return { kind: 'group-rename', groupId, name: utf8(body.subarray(ID_BYTES)) };
170
+ }
171
+
172
+ function decodeDirect(type: number, body: Uint8Array): Decoded {
173
+ if (type === MSG_TEXT) return { kind: 'text', text: utf8(body) };
174
+ if (type === MSG_TYPING) return { kind: 'typing' };
175
+ if (type === MSG_DELIVERY_RECEIPT) return { kind: 'receipt', group: null, status: body[0] ?? 0, messageIds: ids(body, 1) };
176
+ const reaction = decodeReactionProto(body);
177
+ return reaction === null ? { kind: 'other', type } : { kind: 'reaction', group: null, ...reaction };
178
+ }
179
+
180
+ export function decode(plain: Uint8Array): Decoded {
181
+ const type = plain[0] ?? -1;
182
+ const body = plain.subarray(1);
183
+ if (type === MSG_TEXT || type === MSG_TYPING || type === MSG_DELIVERY_RECEIPT || type === MSG_REACTION) return decodeDirect(type, body);
184
+ if (type === MSG_GROUP_SETUP || type === MSG_GROUP_RENAME) return decodeControl(type, body);
185
+ if (GROUP_TYPES.has(type)) return decodeGroup(type, body);
186
+ return { kind: 'other', type };
187
+ }
@@ -0,0 +1,118 @@
1
+ import { TrainError } from '@metro-labs/core/train-error';
2
+ import { respond } from '@metro-labs/core/stations/station-runtime';
3
+ import { publicKeyFor, targetOf, type Account, type Target } from './accounts.js';
4
+ import { sendE2E } from './api.js';
5
+ import { bytesToHex, quoted, seal } from './crypto.js';
6
+ import { emitOutbound, emitOutboundReaction } from './format.js';
7
+ import { MESSAGE_ID_RE } from './ids.js';
8
+ import {
9
+ encodeGroupReaction,
10
+ encodeGroupSyncRequest,
11
+ encodeGroupText,
12
+ encodeReaction,
13
+ encodeText,
14
+ type GroupRef,
15
+ } from './messages.js';
16
+
17
+ export const MAX_TEXT_BYTES = 3500;
18
+ const SENT_MAX = 2000;
19
+
20
+ type Args = Record<string, unknown>;
21
+
22
+ const sentIds = new Set<string>();
23
+
24
+ export function noteSent(messageId: string): void {
25
+ sentIds.add(messageId);
26
+ while (sentIds.size > SENT_MAX) {
27
+ const oldest = sentIds.values().next();
28
+ if (oldest.done) break;
29
+ sentIds.delete(oldest.value);
30
+ }
31
+ }
32
+
33
+ export const sentByUs = (messageId: string): boolean => sentIds.has(messageId);
34
+
35
+ async function deliver(acct: Account, to: string, plain: Uint8Array, group: boolean): Promise<string> {
36
+ const { nonce, box } = seal(plain, await publicKeyFor(acct, to), acct.keys);
37
+ const messageId = await sendE2E(acct.cfg, to, bytesToHex(nonce), bytesToHex(box), group);
38
+ noteSent(messageId);
39
+ return messageId;
40
+ }
41
+
42
+ function recipientsOf(acct: Account, group: GroupRef): string[] {
43
+ const members = acct.groups.recipients(group);
44
+ if (members === null)
45
+ throw new TrainError(
46
+ 'threema_unknown_group',
47
+ 'metro has not received this group\'s member list yet; it asked the group creator for it, try again in a moment',
48
+ { retryable: true },
49
+ );
50
+ return members;
51
+ }
52
+
53
+ export async function requestSync(acct: Account, group: GroupRef): Promise<void> {
54
+ await deliver(acct, group.creator, encodeGroupSyncRequest(group.groupId), true);
55
+ }
56
+
57
+ async function fanOut(acct: Account, target: Target, direct: Uint8Array, grouped: (g: GroupRef) => Uint8Array): Promise<string[]> {
58
+ if (target.kind === 'user') return [await deliver(acct, target.id, direct, false)];
59
+ const ids: string[] = [];
60
+ for (const member of recipientsOf(acct, target.group)) ids.push(await deliver(acct, member, grouped(target.group), true));
61
+ if (ids.length === 0) throw new TrainError('threema_empty_group', 'nobody else is in this group', { retryable: false });
62
+ return ids;
63
+ }
64
+
65
+ interface SendArgs {
66
+ line: string;
67
+ text?: unknown;
68
+ replyTo?: unknown;
69
+ account?: string;
70
+ }
71
+
72
+ function requireText(a: SendArgs): string {
73
+ const text = typeof a.text === 'string' ? a.text : '';
74
+ if (text === '')
75
+ throw new TrainError('threema_text_required', 'threema carries text only; give some text to send', { retryable: false });
76
+ return text;
77
+ }
78
+
79
+ function messageIdOf(raw: unknown, field: string): string {
80
+ const id = typeof raw === 'string' ? raw.toLowerCase() : '';
81
+ if (!MESSAGE_ID_RE.test(id))
82
+ throw new TrainError('threema_bad_message_id', `${field} must be a Threema message id, 16 hex characters`, { retryable: false });
83
+ return id;
84
+ }
85
+
86
+ export async function send(id: string, args: Args): Promise<void> {
87
+ const a = args as unknown as SendArgs;
88
+ const text = requireText(a);
89
+ const replyTo = a.replyTo === undefined || a.replyTo === null || a.replyTo === '' ? undefined : messageIdOf(a.replyTo, 'replyTo');
90
+ const { acct, target } = targetOf(a.line, a.account);
91
+ const body = replyTo === undefined ? text : quoted(replyTo, text);
92
+ if (Buffer.byteLength(body, 'utf8') > MAX_TEXT_BYTES)
93
+ throw new TrainError('threema_message_too_long', `Threema carries at most ${MAX_TEXT_BYTES} bytes of text per message; split it up`, { retryable: false });
94
+ const ids = await fanOut(acct, target, encodeText(body), (g) => encodeGroupText(g, body));
95
+ const messageId = ids[0] ?? '';
96
+ emitOutbound(acct.cfg.id, a.line, messageId, text, replyTo);
97
+ respond(id, { result: { messageId, account: acct.cfg.id, ...(ids.length > 1 ? { messageIds: ids } : {}) } });
98
+ }
99
+
100
+ interface ReactArgs {
101
+ line: string;
102
+ messageId?: unknown;
103
+ emoji?: unknown;
104
+ account?: string;
105
+ action?: unknown;
106
+ }
107
+
108
+ export async function react(id: string, args: Args): Promise<void> {
109
+ const a = args as unknown as ReactArgs;
110
+ const emoji = typeof a.emoji === 'string' ? a.emoji.trim() : '';
111
+ if (emoji === '') throw new TrainError('threema_emoji_required', 'react needs an emoji', { retryable: false });
112
+ const messageId = messageIdOf(a.messageId, 'messageId');
113
+ const removed = a.action === 'removed';
114
+ const { acct, target } = targetOf(a.line, a.account);
115
+ const ids = await fanOut(acct, target, encodeReaction(messageId, emoji, removed), (g) => encodeGroupReaction(g, messageId, emoji, removed));
116
+ emitOutboundReaction(acct.cfg.id, a.line, ids[0] ?? '', emoji, messageId, removed);
117
+ respond(id, { result: { messageId: ids[0] ?? '', account: acct.cfg.id, emoji, removed } });
118
+ }
@@ -4,7 +4,7 @@ export const threemaStation: Station = {
4
4
  name: 'threema',
5
5
  hasAccounts: true,
6
6
  hasTrain: true,
7
- messageVerbs: new Set<Verb>(['send', 'reply']),
7
+ messageVerbs: new Set<Verb>(['send', 'reply', 'react', 'unreact']),
8
8
  attachmentMode: 'none',
9
9
  tools: [],
10
10
  };
@@ -1,5 +1,5 @@
1
1
  import { readFile } from 'node:fs/promises';
2
- import { encodeFunctionData, namehash, type Hex } from 'viem';
2
+ import { encodeFunctionData, encodePacked, keccak256, namehash, stringToBytes, type Hex } from 'viem';
3
3
  import { normalize } from 'viem/ens';
4
4
  import { TrainError } from '@metro-labs/core/train-error';
5
5
  import {
@@ -17,6 +17,7 @@ import { respond } from './wire.js';
17
17
 
18
18
  export const BASENAME_REGISTRY = '0xB94704422c2a1E396835A571837Aa5AE53285a95' as const;
19
19
  export const BASENAME_L2_RESOLVER = '0xC6d566A56A1aFf6508b41f6c90ff131615583BCD' as const;
20
+ export const BASENAME_REVERSE_REGISTRAR = '0x79EA96012eEa67A83431F1701B3dFf7e37F9E282' as const;
20
21
  export const PINEAPPLE_UPLOAD_URL = 'https://pineapple.fyi/upload';
21
22
  const STAMP_CLEAR_URL = 'https://stamp.fyi/clear/';
22
23
  const ZERO = '0x0000000000000000000000000000000000000000';
@@ -37,6 +38,16 @@ const RESOLVER_ABI = [
37
38
  { name: 'multicall', type: 'function', stateMutability: 'nonpayable', inputs: [{ name: 'data', type: 'bytes[]' }], outputs: [{ name: 'results', type: 'bytes[]' }] },
38
39
  ] as const;
39
40
 
41
+ const REVERSE_ABI = [
42
+ { name: 'setName', type: 'function', stateMutability: 'nonpayable', inputs: [{ name: 'name', type: 'string' }], outputs: [{ name: '', type: 'bytes32' }] },
43
+ ] as const;
44
+
45
+ const NAME_ABI = [
46
+ { name: 'name', type: 'function', stateMutability: 'view', inputs: [{ name: 'node', type: 'bytes32' }], outputs: [{ name: '', type: 'string' }] },
47
+ ] as const;
48
+
49
+ const BASE_REVERSE_ROOT = namehash('80002105.reverse');
50
+
40
51
  type Args = Record<string, unknown>;
41
52
 
42
53
  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';
@@ -54,16 +65,39 @@ export async function pinAvatar(avatar: ProfileAvatar, fetchImpl: typeof fetch =
54
65
  return `ipfs://${body.result.cid}`;
55
66
  }
56
67
 
68
+ export const encodePrimaryName = (name: string): Hex => encodeFunctionData({ abi: REVERSE_ABI, functionName: 'setName', args: [name] });
69
+
70
+ export const reverseNodeOf = (address: string): Hex =>
71
+ keccak256(encodePacked(['bytes32', 'bytes32'], [BASE_REVERSE_ROOT, keccak256(stringToBytes(address.slice(2).toLowerCase()))]));
72
+
73
+ async function resolverOf(smart: SmartAccount, node: Hex): Promise<Hex> {
74
+ const found: Hex = await smart.publicClient.readContract({ address: BASENAME_REGISTRY, abi: REGISTRY_ABI, functionName: 'resolver', args: [node] });
75
+ return found === ZERO ? BASENAME_L2_RESOLVER : found;
76
+ }
77
+
78
+ export async function primaryNameOf(smart: SmartAccount): Promise<string | null> {
79
+ const node = reverseNodeOf(smart.address);
80
+ const found = await smart.publicClient.readContract({ address: await resolverOf(smart, node), abi: NAME_ABI, functionName: 'name', args: [node] }).catch(() => '');
81
+ return found === '' ? null : found;
82
+ }
83
+
84
+ export async function setPrimaryName(smart: SmartAccount, name: string): Promise<boolean> {
85
+ try {
86
+ await sendSponsored(smart, BASENAME_REVERSE_REGISTRAR, encodePrimaryName(name));
87
+ return true;
88
+ } catch (err) {
89
+ process.stderr.write(`xmtp: the name ${name} is claimed but not set as the primary name of ${smart.address}: ${err instanceof Error ? err.message : String(err)}\n`);
90
+ return false;
91
+ }
92
+ }
93
+
57
94
  export function encodeTextRecords(name: string, records: Record<string, string>): Hex {
58
95
  const node = namehash(normalize(name));
59
96
  const calls = Object.entries(records).map(([key, value]) => encodeFunctionData({ abi: RESOLVER_ABI, functionName: 'setText', args: [node, key, value] }));
60
97
  return encodeFunctionData({ abi: RESOLVER_ABI, functionName: 'multicall', args: [calls] });
61
98
  }
62
99
 
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
- }
100
+ const resolverFor = (smart: SmartAccount, name: string): Promise<Hex> => resolverOf(smart, namehash(normalize(name)));
67
101
 
68
102
  async function recordsFor(change: ProfileChange, fetchImpl: typeof fetch): Promise<Record<string, string>> {
69
103
  const records: Record<string, string> = {};
@@ -102,11 +136,18 @@ export async function claimNameAction(id: string, args: Args): Promise<void> {
102
136
  const held = await nameOf(address);
103
137
  if (held !== null) throw new TrainError('name_held', `this account already holds ${held}`);
104
138
  const name = await claimName(parseLabel(args.label), address, (message) => smart.signMessage(message));
105
- respond(id, { result: { account: accountId, name } });
139
+ const primary = await setPrimaryName(smart, name);
140
+ respond(id, { result: { account: accountId, name, primary } });
141
+ }
142
+
143
+ export async function ensurePrimaryName(smart: SmartAccount, name: string): Promise<boolean> {
144
+ if ((await primaryNameOf(smart)) === name) return true;
145
+ return setPrimaryName(smart, name);
106
146
  }
107
147
 
108
148
  export async function nameAction(id: string, args: Args): Promise<void> {
109
149
  const acct = accountForCall(args);
110
150
  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 } });
151
+ const primary = acct.smart !== null && name !== null ? await ensurePrimaryName(acct.smart, name) : false;
152
+ respond(id, { result: { account: acct.cfg.id, name, primary, canClaim: acct.smart !== null && name === null } });
112
153
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.155"
2
+ "version": "0.1.0-beta.157"
3
3
  }