@stage-labs/metro 0.1.0-beta.156 → 0.1.0-beta.158

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.156",
3
+ "version": "0.1.0-beta.158",
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.156",
3
+ "version": "0.1.0-beta.158",
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
  });
@@ -17,8 +17,8 @@ async function call(
17
17
  ): Promise<Response> {
18
18
  try {
19
19
  return await fetch(`${GATEWAY_API}${path}`, {
20
- ...init,
21
20
  signal: AbortSignal.timeout(TIMEOUT_MS),
21
+ ...init,
22
22
  });
23
23
  } catch (err) {
24
24
  throw new TrainError(
@@ -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',
@@ -128,3 +130,23 @@ export async function sendE2E(
128
130
  );
129
131
  return messageId;
130
132
  }
133
+
134
+ const BLOB_ID_RE = /^[0-9a-f]{32}$/;
135
+ const BLOB_TIMEOUT_MS = 120_000;
136
+
137
+ export async function uploadBlob(c: GatewayCreds, data: Uint8Array): Promise<string> {
138
+ const form = new FormData();
139
+ form.append('blob', new Blob([Buffer.from(data)]), 'blob');
140
+ const res = await call(`/upload_blob?${authQuery(c)}`, { method: 'POST', body: form, signal: AbortSignal.timeout(BLOB_TIMEOUT_MS) }, 'upload_blob');
141
+ if (!res.ok) throw refusal(res.status, 'blob');
142
+ const blobId = (await res.text()).trim().toLowerCase();
143
+ if (!BLOB_ID_RE.test(blobId)) throw new TrainError('threema_gateway_error', 'upload_blob: the Threema Gateway answered without a blob id');
144
+ return blobId;
145
+ }
146
+
147
+ export async function downloadBlob(c: GatewayCreds, blobId: string): Promise<Uint8Array> {
148
+ if (!BLOB_ID_RE.test(blobId)) throw new TrainError('threema_bad_blob', `'${blobId}' is not a blob id`, { retryable: false });
149
+ const res = await call(`/blobs/${blobId}?${authQuery(c)}`, { signal: AbortSignal.timeout(BLOB_TIMEOUT_MS) }, 'blob download');
150
+ if (!res.ok) throw refusal(res.status, `blob ${blobId}`);
151
+ return new Uint8Array(await res.arrayBuffer());
152
+ }
@@ -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;
@@ -130,3 +90,11 @@ export function unquote(text: string): { replyTo?: string; text: string } {
130
90
  const body = m?.[2];
131
91
  return replyTo === undefined || body === undefined ? { text } : { replyTo, text: body };
132
92
  }
93
+
94
+ const FILE_NONCE = new Uint8Array([...new Array<number>(23).fill(0), 1]);
95
+
96
+ export const newBlobKey = (): Uint8Array => nacl.randomBytes(nacl.secretbox.keyLength);
97
+
98
+ export const sealBlob = (data: Uint8Array, key: Uint8Array): Uint8Array => nacl.secretbox(data, FILE_NONCE, key);
99
+
100
+ export const openBlob = (data: Uint8Array, key: Uint8Array): Uint8Array | null => nacl.secretbox.open(data, FILE_NONCE, key);
@@ -0,0 +1,93 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { emit, mintId } from '@metro-labs/core/stations/station-runtime';
3
+ import { assertAttachmentSize, isImageMime, kindOf, saveBufferToCache } from '@metro-labs/core/stations/attachments';
4
+ import { TrainError } from '@metro-labs/core/train-error';
5
+ import type { Account } from './accounts.js';
6
+ import { downloadBlob, uploadBlob } from './api.js';
7
+ import { bytesToHex, hexToBytes, newBlobKey, openBlob, sealBlob } from './crypto.js';
8
+ import { emitInbound, textEnvelope, type InboundMeta, type Room } from './format.js';
9
+ import { encodeFile, encodeGroupFile, type FileData, type GroupRef } from './messages.js';
10
+
11
+ export const MAX_BLOB_BYTES = 50 * 1024 * 1024;
12
+ const SELF_URI = process.env.METRO_SELF_URI ?? '';
13
+
14
+ export interface OutgoingFile {
15
+ path: string;
16
+ mime: string;
17
+ name: string;
18
+ }
19
+
20
+ export async function packFile(acct: Account, file: OutgoingFile, caption: string | null): Promise<FileData> {
21
+ const data = await readFile(file.path);
22
+ assertAttachmentSize(data.length);
23
+ if (data.length > MAX_BLOB_BYTES) throw new TrainError('threema_file_too_big', `Threema carries files up to 50 MB; ${file.name} is bigger`, { retryable: false });
24
+ const key = newBlobKey();
25
+ const blobId = await uploadBlob(acct.cfg, sealBlob(new Uint8Array(data), key));
26
+ return { blobId, key: bytesToHex(key), mime: file.mime, name: file.name, size: data.length, caption, media: isImageMime(file.mime) };
27
+ }
28
+
29
+ export const encodeFileFor = (group: GroupRef | null, file: FileData): Uint8Array => (group === null ? encodeFile(file) : encodeGroupFile(group, file));
30
+
31
+ export const fileLabel = (file: OutgoingFile): string => kindOf(file.mime, file.path);
32
+
33
+ function savedEnvelope(accountId: string, line: string, forId: string, file: FileData, path: string, bytes: number): Record<string, unknown> {
34
+ return {
35
+ kind: 'inbound',
36
+ id: mintId(),
37
+ ts: new Date().toISOString(),
38
+ station: 'threema',
39
+ line,
40
+ from: SELF_URI,
41
+ text: `📎 saved: ${path}`,
42
+ payload: {
43
+ account: accountId,
44
+ contentType: 'attachmentSaved',
45
+ attachmentFor: forId,
46
+ index: 0,
47
+ kind: kindOf(file.mime, file.name),
48
+ attachmentPath: path,
49
+ localPath: path,
50
+ mime: file.mime,
51
+ name: file.name,
52
+ size: bytes,
53
+ },
54
+ };
55
+ }
56
+
57
+ function failedEnvelope(accountId: string, line: string, forId: string, file: FileData, reason: string): Record<string, unknown> {
58
+ return {
59
+ kind: 'inbound',
60
+ id: mintId(),
61
+ ts: new Date().toISOString(),
62
+ station: 'threema',
63
+ line,
64
+ from: SELF_URI,
65
+ text: `📎 not fetched: ${reason}`,
66
+ payload: { account: accountId, contentType: 'attachmentFailed', attachmentFor: forId, index: 0, kind: kindOf(file.mime, file.name), reason },
67
+ };
68
+ }
69
+
70
+ async function fetchFile(acct: Account, m: InboundMeta, file: FileData): Promise<{ path: string; bytes: number }> {
71
+ const sealed = await downloadBlob(acct.cfg, file.blobId);
72
+ const plain = openBlob(sealed, hexToBytes(file.key, 'blob key'));
73
+ if (plain === null) throw new TrainError('threema_bad_blob', 'the file did not decrypt with the key in the message', { retryable: false });
74
+ const saved = await saveBufferToCache(plain, m.messageId, 0, { mime: file.mime, name: file.name });
75
+ return { path: saved.path, bytes: saved.bytes };
76
+ }
77
+
78
+ export function deliverFile(acct: Account, m: InboundMeta, file: FileData, room: Room, sentByUs: (id: string) => boolean): void {
79
+ const env = textEnvelope(acct.cfg.id, m, file.caption ?? '', sentByUs, room);
80
+ const line = String(env.line);
81
+ const forId = String(env.id);
82
+ const payload = { ...(env.payload as Record<string, unknown>), attachments: [{ kind: kindOf(file.mime, file.name), name: file.name, mime: file.mime, ...(file.size === null ? {} : { size: file.size }) }] };
83
+ emitInbound(acct.cfg.id, acct.cfg.owner, { ...env, payload });
84
+ fetchFile(acct, m, file)
85
+ .then(({ path, bytes }) => {
86
+ emit(savedEnvelope(acct.cfg.id, line, forId, file, path, bytes));
87
+ })
88
+ .catch((err: unknown) => {
89
+ const reason = err instanceof Error ? err.message : String(err);
90
+ process.stderr.write(`threema[${acct.cfg.id}]: could not fetch ${file.name} from ${m.from}: ${reason}\n`);
91
+ emit(failedEnvelope(acct.cfg.id, line, forId, file, reason));
92
+ });
93
+ }
@@ -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,90 @@
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 { deliverFile } from './files.js';
12
+ import type { Decoded, GroupRef } from './messages.js';
13
+ import { requestSync, sentByUs } from './outbound.js';
14
+
15
+ const synced = new Set<string>();
16
+
17
+ function roomOf(acct: Account, group: GroupRef | null): Room {
18
+ if (group === null) return DIRECT;
19
+ return { group, name: acct.groups.get(group)?.name ?? null };
20
+ }
21
+
22
+ function askForRoster(acct: Account, group: GroupRef): void {
23
+ const key = `${acct.cfg.id}:${group.creator}-${group.groupId}`;
24
+ if (acct.groups.get(group) !== undefined || synced.has(key)) return;
25
+ synced.add(key);
26
+ requestSync(acct, group).catch((err: unknown) => {
27
+ process.stderr.write(`threema[${acct.cfg.id}]: could not ask ${group.creator} for the group roster: ${err instanceof Error ? err.message : String(err)}\n`);
28
+ });
29
+ }
30
+
31
+ function reactions(acct: Account, m: InboundMeta, d: Extract<Decoded, { kind: 'receipt' }>): string {
32
+ let count = 0;
33
+ for (const target of d.messageIds) {
34
+ const env = receiptEnvelope(acct.cfg.id, m, d.status, target, roomOf(acct, d.group));
35
+ if (env === null) continue;
36
+ emitInbound(acct.cfg.id, acct.cfg.owner, env);
37
+ count += 1;
38
+ }
39
+ return count > 0 ? 'reaction' : `receipt:${String(d.status)}`;
40
+ }
41
+
42
+ function control(acct: Account, m: InboundMeta, d: Decoded): string | null {
43
+ if (d.kind === 'group-setup') {
44
+ acct.groups.setup(m.from, d.groupId, d.members);
45
+ return 'group-setup';
46
+ }
47
+ if (d.kind === 'group-rename') {
48
+ acct.groups.rename(m.from, d.groupId, d.name);
49
+ return 'group-rename';
50
+ }
51
+ if (d.kind === 'group-leave') {
52
+ acct.groups.leave(d.group, m.from);
53
+ return 'group-leave';
54
+ }
55
+ return null;
56
+ }
57
+
58
+ function chat(acct: Account, m: InboundMeta, d: Decoded): string | null {
59
+ const owner = acct.cfg.owner;
60
+ if (d.kind === 'text') {
61
+ emitInbound(acct.cfg.id, owner, textEnvelope(acct.cfg.id, m, d.text, sentByUs));
62
+ return 'text';
63
+ }
64
+ if (d.kind === 'group-text') {
65
+ askForRoster(acct, d.group);
66
+ emitInbound(acct.cfg.id, owner, textEnvelope(acct.cfg.id, m, d.text, sentByUs, roomOf(acct, d.group)));
67
+ return 'group-text';
68
+ }
69
+ if (d.kind === 'file') {
70
+ if (d.group !== null) askForRoster(acct, d.group);
71
+ deliverFile(acct, m, d.file, roomOf(acct, d.group), sentByUs);
72
+ return 'file';
73
+ }
74
+ if (d.kind === 'reaction') {
75
+ if (d.group !== null) askForRoster(acct, d.group);
76
+ emitInbound(acct.cfg.id, owner, reactionEnvelope(acct.cfg.id, m, d.emoji, d.messageId, d.removed, roomOf(acct, d.group)));
77
+ return 'reaction';
78
+ }
79
+ return null;
80
+ }
81
+
82
+ export function deliver(acct: Account, m: InboundMeta, d: Decoded): string {
83
+ const handled = control(acct, m, d) ?? chat(acct, m, d);
84
+ if (handled !== null) return handled;
85
+ if (d.kind === 'receipt') return reactions(acct, m, d);
86
+ if (d.kind === 'typing') return 'typing';
87
+ const type = d.kind === 'other' ? `0x${d.type.toString(16)}` : d.kind;
88
+ process.stderr.write(`threema[${acct.cfg.id}]: ignored a message of type ${type} from ${m.from}\n`);
89
+ return `ignored:${type}`;
90
+ }
@@ -0,0 +1,264 @@
1
+ import { bytesToHex, hexToBytes } from './crypto.js';
2
+
3
+ export const MSG_TEXT = 0x01;
4
+ export const MSG_FILE = 0x17;
5
+ export const MSG_GROUP_TEXT = 0x41;
6
+ export const MSG_GROUP_FILE = 0x46;
7
+ export const MSG_GROUP_SETUP = 0x4a;
8
+ export const MSG_GROUP_RENAME = 0x4b;
9
+ export const MSG_GROUP_LEAVE = 0x4c;
10
+ export const MSG_GROUP_REQUEST_SYNC = 0x51;
11
+ export const MSG_DELIVERY_RECEIPT = 0x80;
12
+ export const MSG_GROUP_DELIVERY_RECEIPT = 0x81;
13
+ export const MSG_REACTION = 0x82;
14
+ export const MSG_GROUP_REACTION = 0x83;
15
+ export const MSG_TYPING = 0x90;
16
+
17
+ export const RECEIPT_ACK = 0x03;
18
+ export const RECEIPT_DECLINE = 0x04;
19
+
20
+ export const ID_BYTES = 8;
21
+ const MESSAGE_ID_BYTES = 8;
22
+ const PROTO_MESSAGE_ID = 0x09;
23
+ const PROTO_APPLY = 0x12;
24
+ const PROTO_WITHDRAW = 0x1a;
25
+
26
+ export interface GroupRef {
27
+ creator: string;
28
+ groupId: string;
29
+ }
30
+
31
+ export interface FileData {
32
+ blobId: string;
33
+ key: string;
34
+ mime: string;
35
+ name: string;
36
+ size: number | null;
37
+ caption: string | null;
38
+ media: boolean;
39
+ }
40
+
41
+ export type Decoded =
42
+ | { kind: 'text'; text: string }
43
+ | { kind: 'file'; group: GroupRef | null; file: FileData }
44
+ | { kind: 'group-text'; group: GroupRef; text: string }
45
+ | { kind: 'group-setup'; groupId: string; members: string[] }
46
+ | { kind: 'group-rename'; groupId: string; name: string }
47
+ | { kind: 'group-leave'; group: GroupRef }
48
+ | { kind: 'receipt'; group: GroupRef | null; status: number; messageIds: string[] }
49
+ | { kind: 'reaction'; group: GroupRef | null; messageId: string; emoji: string; removed: boolean }
50
+ | { kind: 'typing' }
51
+ | { kind: 'other'; type: number };
52
+
53
+ const ascii = (bytes: Uint8Array): string => Buffer.from(bytes).toString('ascii');
54
+ const utf8 = (bytes: Uint8Array): string => Buffer.from(bytes).toString('utf8');
55
+
56
+ const groupHeader = (group: GroupRef): Uint8Array =>
57
+ new Uint8Array([...Buffer.from(group.creator, 'ascii'), ...hexToBytes(group.groupId, 'group id')]);
58
+
59
+ const readGroup = (body: Uint8Array): GroupRef | null =>
60
+ body.length < ID_BYTES * 2 ? null : { creator: ascii(body.subarray(0, ID_BYTES)), groupId: bytesToHex(body.subarray(ID_BYTES, ID_BYTES * 2)) };
61
+
62
+ function ids(body: Uint8Array, from: number): string[] {
63
+ const out: string[] = [];
64
+ for (let at = from; at + MESSAGE_ID_BYTES <= body.length; at += MESSAGE_ID_BYTES) out.push(bytesToHex(body.subarray(at, at + MESSAGE_ID_BYTES)));
65
+ return out;
66
+ }
67
+
68
+ const members = (body: Uint8Array, from: number): string[] => {
69
+ const out: string[] = [];
70
+ for (let at = from; at + ID_BYTES <= body.length; at += ID_BYTES) out.push(ascii(body.subarray(at, at + ID_BYTES)));
71
+ return out;
72
+ };
73
+
74
+ function concat(...parts: Uint8Array[]): Uint8Array {
75
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
76
+ let at = 0;
77
+ for (const p of parts) {
78
+ out.set(p, at);
79
+ at += p.length;
80
+ }
81
+ return out;
82
+ }
83
+
84
+ const typed = (type: number, ...parts: Uint8Array[]): Uint8Array => concat(new Uint8Array([type]), ...parts);
85
+
86
+ export const encodeText = (text: string): Uint8Array => typed(MSG_TEXT, Buffer.from(text, 'utf8'));
87
+
88
+ export const encodeGroupText = (group: GroupRef, text: string): Uint8Array => typed(MSG_GROUP_TEXT, groupHeader(group), Buffer.from(text, 'utf8'));
89
+
90
+ const fileJson = (f: FileData): Uint8Array =>
91
+ Buffer.from(
92
+ JSON.stringify({
93
+ b: f.blobId,
94
+ k: f.key,
95
+ m: f.mime,
96
+ n: f.name,
97
+ ...(f.size === null ? {} : { s: f.size }),
98
+ i: f.media ? 1 : 0,
99
+ j: f.media ? 1 : 0,
100
+ ...(f.caption === null ? {} : { d: f.caption }),
101
+ }),
102
+ 'utf8',
103
+ );
104
+
105
+ export const encodeFile = (f: FileData): Uint8Array => typed(MSG_FILE, fileJson(f));
106
+
107
+ export const encodeGroupFile = (group: GroupRef, f: FileData): Uint8Array => typed(MSG_GROUP_FILE, groupHeader(group), fileJson(f));
108
+
109
+ const HEX_RE = /^[0-9a-f]+$/i;
110
+
111
+ const str = (v: unknown, fallback: string): string => (typeof v === 'string' && v !== '' ? v : fallback);
112
+ const num = (v: unknown): number | null => (typeof v === 'number' ? v : null);
113
+ const hexOf = (v: unknown, length: number): string | null => {
114
+ const text = typeof v === 'string' ? v.toLowerCase() : '';
115
+ return text.length === length && HEX_RE.test(text) ? text : null;
116
+ };
117
+
118
+ function parseJson(raw: Uint8Array): Record<string, unknown> | null {
119
+ try {
120
+ const parsed: unknown = JSON.parse(utf8(raw));
121
+ return typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : null;
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+
127
+ export function decodeFileJson(raw: Uint8Array): FileData | null {
128
+ const o = parseJson(raw);
129
+ if (o === null) return null;
130
+ const blobId = hexOf(o.b, 32);
131
+ const key = hexOf(o.k, 64);
132
+ if (blobId === null || key === null) return null;
133
+ const rendering = num(o.j) ?? num(o.i) ?? 0;
134
+ return {
135
+ blobId,
136
+ key,
137
+ mime: str(o.m, 'application/octet-stream'),
138
+ name: str(o.n, 'file'),
139
+ size: num(o.s),
140
+ caption: typeof o.d === 'string' && o.d !== '' ? o.d : null,
141
+ media: rendering === 1,
142
+ };
143
+ }
144
+
145
+ export const encodeGroupSyncRequest = (groupId: string): Uint8Array => typed(MSG_GROUP_REQUEST_SYNC, hexToBytes(groupId, 'group id'));
146
+
147
+ function varint(n: number): Uint8Array {
148
+ const out: number[] = [];
149
+ let rest = n;
150
+ while (rest >= 0x80) {
151
+ out.push((rest & 0x7f) | 0x80);
152
+ rest = Math.floor(rest / 0x80);
153
+ }
154
+ out.push(rest);
155
+ return new Uint8Array(out);
156
+ }
157
+
158
+ export function encodeReactionProto(messageId: string, emoji: string, removed: boolean): Uint8Array {
159
+ const emojiBytes = Buffer.from(emoji, 'utf8');
160
+ return concat(
161
+ new Uint8Array([PROTO_MESSAGE_ID]),
162
+ hexToBytes(messageId, 'message id'),
163
+ new Uint8Array([removed ? PROTO_WITHDRAW : PROTO_APPLY]),
164
+ varint(emojiBytes.length),
165
+ emojiBytes,
166
+ );
167
+ }
168
+
169
+ export const encodeReaction = (messageId: string, emoji: string, removed: boolean): Uint8Array =>
170
+ typed(MSG_REACTION, encodeReactionProto(messageId, emoji, removed));
171
+
172
+ export const encodeGroupReaction = (group: GroupRef, messageId: string, emoji: string, removed: boolean): Uint8Array =>
173
+ typed(MSG_GROUP_REACTION, groupHeader(group), encodeReactionProto(messageId, emoji, removed));
174
+
175
+ function readVarint(body: Uint8Array, at: number): { value: number; next: number } | null {
176
+ let value = 0;
177
+ let shift = 1;
178
+ for (let i = at; i < body.length; i++) {
179
+ const byte = body[i] ?? 0;
180
+ value += (byte & 0x7f) * shift;
181
+ if (byte < 0x80) return { value, next: i + 1 };
182
+ shift *= 0x80;
183
+ }
184
+ return null;
185
+ }
186
+
187
+ interface ProtoField {
188
+ tag: number;
189
+ bytes: Uint8Array;
190
+ next: number;
191
+ }
192
+
193
+ function readField(body: Uint8Array, at: number): ProtoField | null {
194
+ const tag = body[at] ?? 0;
195
+ if (tag === PROTO_MESSAGE_ID)
196
+ 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 };
197
+ if (tag !== PROTO_APPLY && tag !== PROTO_WITHDRAW) return null;
198
+ const len = readVarint(body, at + 1);
199
+ if (len === null || len.next + len.value > body.length) return null;
200
+ return { tag, bytes: body.subarray(len.next, len.next + len.value), next: len.next + len.value };
201
+ }
202
+
203
+ export function decodeReactionProto(body: Uint8Array): { messageId: string; emoji: string; removed: boolean } | null {
204
+ let messageId: string | null = null;
205
+ let emoji = '';
206
+ let removed = false;
207
+ for (let at = 0; at < body.length; ) {
208
+ const field = readField(body, at);
209
+ if (field === null) return null;
210
+ if (field.tag === PROTO_MESSAGE_ID) messageId = bytesToHex(field.bytes);
211
+ else {
212
+ emoji = utf8(field.bytes);
213
+ removed = field.tag === PROTO_WITHDRAW;
214
+ }
215
+ at = field.next;
216
+ }
217
+ return messageId === null || emoji === '' ? null : { messageId, emoji, removed };
218
+ }
219
+
220
+ function decodeGroup(type: number, body: Uint8Array): Decoded {
221
+ const group = readGroup(body);
222
+ if (group === null) return { kind: 'other', type };
223
+ const rest = body.subarray(ID_BYTES * 2);
224
+ if (type === MSG_GROUP_TEXT) return { kind: 'group-text', group, text: utf8(rest) };
225
+ if (type === MSG_GROUP_FILE) {
226
+ const file = decodeFileJson(rest);
227
+ return file === null ? { kind: 'other', type } : { kind: 'file', group, file };
228
+ }
229
+ if (type === MSG_GROUP_LEAVE) return { kind: 'group-leave', group };
230
+ if (type === MSG_GROUP_DELIVERY_RECEIPT) return { kind: 'receipt', group, status: rest[0] ?? 0, messageIds: ids(rest, 1) };
231
+ const reaction = decodeReactionProto(rest);
232
+ return reaction === null ? { kind: 'other', type } : { kind: 'reaction', group, ...reaction };
233
+ }
234
+
235
+ const GROUP_TYPES = new Set([MSG_GROUP_TEXT, MSG_GROUP_FILE, MSG_GROUP_LEAVE, MSG_GROUP_DELIVERY_RECEIPT, MSG_GROUP_REACTION]);
236
+ const DIRECT_TYPES = new Set([MSG_TEXT, MSG_FILE, MSG_TYPING, MSG_DELIVERY_RECEIPT, MSG_REACTION]);
237
+
238
+ function decodeControl(type: number, body: Uint8Array): Decoded {
239
+ if (body.length < ID_BYTES) return { kind: 'other', type };
240
+ const groupId = bytesToHex(body.subarray(0, ID_BYTES));
241
+ if (type === MSG_GROUP_SETUP) return { kind: 'group-setup', groupId, members: members(body, ID_BYTES) };
242
+ return { kind: 'group-rename', groupId, name: utf8(body.subarray(ID_BYTES)) };
243
+ }
244
+
245
+ function decodeDirect(type: number, body: Uint8Array): Decoded {
246
+ if (type === MSG_TEXT) return { kind: 'text', text: utf8(body) };
247
+ if (type === MSG_FILE) {
248
+ const file = decodeFileJson(body);
249
+ return file === null ? { kind: 'other', type } : { kind: 'file', group: null, file };
250
+ }
251
+ if (type === MSG_TYPING) return { kind: 'typing' };
252
+ if (type === MSG_DELIVERY_RECEIPT) return { kind: 'receipt', group: null, status: body[0] ?? 0, messageIds: ids(body, 1) };
253
+ const reaction = decodeReactionProto(body);
254
+ return reaction === null ? { kind: 'other', type } : { kind: 'reaction', group: null, ...reaction };
255
+ }
256
+
257
+ export function decode(plain: Uint8Array): Decoded {
258
+ const type = plain[0] ?? -1;
259
+ const body = plain.subarray(1);
260
+ if (DIRECT_TYPES.has(type)) return decodeDirect(type, body);
261
+ if (type === MSG_GROUP_SETUP || type === MSG_GROUP_RENAME) return decodeControl(type, body);
262
+ if (GROUP_TYPES.has(type)) return decodeGroup(type, body);
263
+ return { kind: 'other', type };
264
+ }
@@ -0,0 +1,160 @@
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 { encodeFileFor, fileLabel, packFile, type OutgoingFile } from './files.js';
7
+ import { emitOutbound, emitOutboundReaction } from './format.js';
8
+ import { MESSAGE_ID_RE } from './ids.js';
9
+ import {
10
+ encodeGroupReaction,
11
+ encodeGroupSyncRequest,
12
+ encodeGroupText,
13
+ encodeReaction,
14
+ encodeText,
15
+ type GroupRef,
16
+ } from './messages.js';
17
+
18
+ export const MAX_TEXT_BYTES = 3500;
19
+ const SENT_MAX = 2000;
20
+
21
+ type Args = Record<string, unknown>;
22
+
23
+ const sentIds = new Set<string>();
24
+
25
+ export function noteSent(messageId: string): void {
26
+ sentIds.add(messageId);
27
+ while (sentIds.size > SENT_MAX) {
28
+ const oldest = sentIds.values().next();
29
+ if (oldest.done) break;
30
+ sentIds.delete(oldest.value);
31
+ }
32
+ }
33
+
34
+ export const sentByUs = (messageId: string): boolean => sentIds.has(messageId);
35
+
36
+ async function deliver(acct: Account, to: string, plain: Uint8Array, group: boolean): Promise<string> {
37
+ const { nonce, box } = seal(plain, await publicKeyFor(acct, to), acct.keys);
38
+ const messageId = await sendE2E(acct.cfg, to, bytesToHex(nonce), bytesToHex(box), group);
39
+ noteSent(messageId);
40
+ return messageId;
41
+ }
42
+
43
+ function recipientsOf(acct: Account, group: GroupRef): string[] {
44
+ const members = acct.groups.recipients(group);
45
+ if (members === null)
46
+ throw new TrainError(
47
+ 'threema_unknown_group',
48
+ 'metro has not received this group\'s member list yet; it asked the group creator for it, try again in a moment',
49
+ { retryable: true },
50
+ );
51
+ return members;
52
+ }
53
+
54
+ export async function requestSync(acct: Account, group: GroupRef): Promise<void> {
55
+ await deliver(acct, group.creator, encodeGroupSyncRequest(group.groupId), true);
56
+ }
57
+
58
+ async function fanOut(acct: Account, target: Target, direct: Uint8Array, grouped: (g: GroupRef) => Uint8Array): Promise<string[]> {
59
+ if (target.kind === 'user') return [await deliver(acct, target.id, direct, false)];
60
+ const ids: string[] = [];
61
+ for (const member of recipientsOf(acct, target.group)) ids.push(await deliver(acct, member, grouped(target.group), true));
62
+ if (ids.length === 0) throw new TrainError('threema_empty_group', 'nobody else is in this group', { retryable: false });
63
+ return ids;
64
+ }
65
+
66
+ interface SendArgs {
67
+ line: string;
68
+ text?: unknown;
69
+ replyTo?: unknown;
70
+ account?: string;
71
+ attachments?: unknown;
72
+ }
73
+
74
+ const filesOf = (raw: unknown): OutgoingFile[] =>
75
+ (Array.isArray(raw) ? raw : [])
76
+ .map((a) => (a ?? {}) as Record<string, unknown>)
77
+ .filter((a) => typeof a.path === 'string' && a.path !== '')
78
+ .map((a) => ({ path: String(a.path), mime: typeof a.mime === 'string' && a.mime !== '' ? a.mime : 'application/octet-stream', name: typeof a.name === 'string' && a.name !== '' ? a.name : 'file' }));
79
+
80
+ function textOf(a: SendArgs, files: OutgoingFile[]): string {
81
+ const text = typeof a.text === 'string' ? a.text : '';
82
+ if (text === '' && files.length === 0)
83
+ throw new TrainError('threema_text_required', 'give some text or a file to send', { retryable: false });
84
+ return text;
85
+ }
86
+
87
+ async function sendFiles(acct: Account, target: Target, files: OutgoingFile[], caption: string | null): Promise<{ ids: string[]; labels: string[] }> {
88
+ const ids: string[] = [];
89
+ const labels: string[] = [];
90
+ for (const [i, file] of files.entries()) {
91
+ const packed = await packFile(acct, file, i === 0 ? caption : null);
92
+ const sent = await fanOut(acct, target, encodeFileFor(null, packed), (g) => encodeFileFor(g, packed));
93
+ ids.push(...sent);
94
+ labels.push(fileLabel(file));
95
+ }
96
+ return { ids, labels };
97
+ }
98
+
99
+ function messageIdOf(raw: unknown, field: string): string {
100
+ const id = typeof raw === 'string' ? raw.toLowerCase() : '';
101
+ if (!MESSAGE_ID_RE.test(id))
102
+ throw new TrainError('threema_bad_message_id', `${field} must be a Threema message id, 16 hex characters`, { retryable: false });
103
+ return id;
104
+ }
105
+
106
+ const replyTargetOf = (raw: unknown): string | undefined => (raw === undefined || raw === null || raw === '' ? undefined : messageIdOf(raw, 'replyTo'));
107
+
108
+ function bodyOf(text: string, replyTo: string | undefined): string {
109
+ const body = replyTo === undefined ? text : quoted(replyTo, text);
110
+ if (Buffer.byteLength(body, 'utf8') > MAX_TEXT_BYTES)
111
+ throw new TrainError('threema_message_too_long', `Threema carries at most ${MAX_TEXT_BYTES} bytes of text per message; split it up`, { retryable: false });
112
+ return body;
113
+ }
114
+
115
+ interface Sent {
116
+ ids: string[];
117
+ labels: string[];
118
+ }
119
+
120
+ const resultOf = (acct: Account, sent: Sent): Record<string, unknown> => ({
121
+ messageId: sent.ids[0] ?? '',
122
+ account: acct.cfg.id,
123
+ ...(sent.ids.length > 1 ? { messageIds: sent.ids } : {}),
124
+ ...(sent.labels.length > 0 ? { attachments: sent.labels } : {}),
125
+ });
126
+
127
+ export async function send(id: string, args: Args): Promise<void> {
128
+ const a = args as unknown as SendArgs;
129
+ const files = filesOf(a.attachments);
130
+ const text = textOf(a, files);
131
+ const replyTo = replyTargetOf(a.replyTo);
132
+ const { acct, target } = targetOf(a.line, a.account);
133
+ const body = bodyOf(text, replyTo);
134
+ const sent: Sent =
135
+ files.length === 0
136
+ ? { ids: await fanOut(acct, target, encodeText(body), (g) => encodeGroupText(g, body)), labels: [] }
137
+ : await sendFiles(acct, target, files, body === '' ? null : body);
138
+ emitOutbound(acct.cfg.id, a.line, sent.ids[0] ?? '', text === '' ? `[${sent.labels.join(', ')}]` : text, replyTo);
139
+ respond(id, { result: resultOf(acct, sent) });
140
+ }
141
+
142
+ interface ReactArgs {
143
+ line: string;
144
+ messageId?: unknown;
145
+ emoji?: unknown;
146
+ account?: string;
147
+ action?: unknown;
148
+ }
149
+
150
+ export async function react(id: string, args: Args): Promise<void> {
151
+ const a = args as unknown as ReactArgs;
152
+ const emoji = typeof a.emoji === 'string' ? a.emoji.trim() : '';
153
+ if (emoji === '') throw new TrainError('threema_emoji_required', 'react needs an emoji', { retryable: false });
154
+ const messageId = messageIdOf(a.messageId, 'messageId');
155
+ const removed = a.action === 'removed';
156
+ const { acct, target } = targetOf(a.line, a.account);
157
+ const ids = await fanOut(acct, target, encodeReaction(messageId, emoji, removed), (g) => encodeGroupReaction(g, messageId, emoji, removed));
158
+ emitOutboundReaction(acct.cfg.id, a.line, ids[0] ?? '', emoji, messageId, removed);
159
+ respond(id, { result: { messageId: ids[0] ?? '', account: acct.cfg.id, emoji, removed } });
160
+ }
@@ -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']),
8
- attachmentMode: 'none',
7
+ messageVerbs: new Set<Verb>(['send', 'reply', 'react', 'unreact']),
8
+ attachmentMode: 'canonical',
9
9
  tools: [],
10
10
  };
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.156"
2
+ "version": "0.1.0-beta.158"
3
3
  }