@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 +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/core/src/lines.ts +3 -3
- package/runtime/node_modules/@metro-labs/threema/src/accounts.ts +10 -3
- package/runtime/node_modules/@metro-labs/threema/src/actions.ts +35 -123
- package/runtime/node_modules/@metro-labs/threema/src/api.ts +2 -0
- package/runtime/node_modules/@metro-labs/threema/src/crypto.ts +0 -40
- package/runtime/node_modules/@metro-labs/threema/src/format.ts +72 -13
- package/runtime/node_modules/@metro-labs/threema/src/groups.ts +95 -0
- package/runtime/node_modules/@metro-labs/threema/src/inbound.ts +80 -0
- package/runtime/node_modules/@metro-labs/threema/src/messages.ts +187 -0
- package/runtime/node_modules/@metro-labs/threema/src/outbound.ts +118 -0
- package/runtime/node_modules/@metro-labs/threema/src/station.ts +1 -1
- package/runtime/node_modules/@metro-labs/xmtp/src/profile.ts +48 -7
- package/runtime/runtime.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stage-labs/metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.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.
|
|
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
|
|
29
|
-
/^(?:[A-Z0-9]{8}|\*[A-Z0-9]{7})
|
|
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',
|
|
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;
|
|
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
|
-
|
|
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 {
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
69
|
-
line:
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
if (
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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 {
|
|
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:
|
|
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
|
|
94
|
+
export function reactionEnvelope(
|
|
74
95
|
accountId: string,
|
|
75
96
|
m: InboundMeta,
|
|
76
|
-
|
|
97
|
+
emoji: string,
|
|
77
98
|
targetId: string,
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
99
|
+
removed: boolean,
|
|
100
|
+
room: Room = DIRECT,
|
|
101
|
+
type = 'reaction',
|
|
102
|
+
): Record<string, unknown> {
|
|
81
103
|
return {
|
|
82
|
-
...base(accountId, m,
|
|
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
|
+
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/runtime/runtime.json
CHANGED