@stage-labs/metro 0.1.0-beta.97 → 0.1.0-beta.99

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.
Files changed (31) hide show
  1. package/package.json +1 -1
  2. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  3. package/runtime/node_modules/@metro-labs/core/src/lines.ts +5 -0
  4. package/runtime/node_modules/@metro-labs/core/src/station-names.ts +1 -0
  5. package/runtime/node_modules/@metro-labs/daemon/package.json +1 -0
  6. package/runtime/node_modules/@metro-labs/daemon/src/agents/accounts-api.ts +35 -6
  7. package/runtime/node_modules/@metro-labs/daemon/src/agents/bundle.ts +1 -1
  8. package/runtime/node_modules/@metro-labs/daemon/src/agents/file-admin.ts +17 -1
  9. package/runtime/node_modules/@metro-labs/daemon/src/agents/files.ts +1 -1
  10. package/runtime/node_modules/@metro-labs/daemon/src/agents/map.ts +7 -0
  11. package/runtime/node_modules/@metro-labs/daemon/src/mcp/accounts.ts +16 -4
  12. package/runtime/node_modules/@metro-labs/daemon/src/routes/body.ts +24 -0
  13. package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +5 -23
  14. package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +2 -0
  15. package/runtime/node_modules/@metro-labs/daemon/src/routes/threema-callback.ts +103 -0
  16. package/runtime/node_modules/@metro-labs/daemon/src/stations/attach.ts +64 -0
  17. package/runtime/node_modules/@metro-labs/daemon/src/stations/materialize.ts +14 -1
  18. package/runtime/node_modules/@metro-labs/daemon/src/stations/registry.ts +2 -0
  19. package/runtime/node_modules/@metro-labs/daemon/src/stations/threema-callbacks.ts +32 -0
  20. package/runtime/node_modules/@metro-labs/threema/package.json +26 -0
  21. package/runtime/node_modules/@metro-labs/threema/src/accounts.ts +100 -0
  22. package/runtime/node_modules/@metro-labs/threema/src/actions.ts +219 -0
  23. package/runtime/node_modules/@metro-labs/threema/src/api.ts +130 -0
  24. package/runtime/node_modules/@metro-labs/threema/src/crypto.ts +132 -0
  25. package/runtime/node_modules/@metro-labs/threema/src/format.ts +123 -0
  26. package/runtime/node_modules/@metro-labs/threema/src/ids.ts +21 -0
  27. package/runtime/node_modules/@metro-labs/threema/src/index.ts +29 -0
  28. package/runtime/node_modules/@metro-labs/threema/src/station.ts +10 -0
  29. package/runtime/node_modules/@metro-labs/threema/src/verify.ts +117 -0
  30. package/runtime/runtime.json +1 -1
  31. package/runtime/stations.json +3 -0
@@ -0,0 +1,123 @@
1
+ import { emit, mintId } from '@metro-labs/core/stations/station-runtime';
2
+ import { RECEIPT_ACK, RECEIPT_DECLINE, unquote } from './crypto.js';
3
+
4
+ const SELF_URI = process.env.METRO_SELF_URI ?? '';
5
+
6
+ const REACTION_OF: Record<number, string> = {
7
+ [RECEIPT_ACK]: '👍',
8
+ [RECEIPT_DECLINE]: '👎',
9
+ };
10
+
11
+ export const lineOf = (accountId: string, threemaId: string): string =>
12
+ `metro://threema/${accountId}/${threemaId}`;
13
+
14
+ const userOf = (accountId: string, threemaId: string): string =>
15
+ `metro://threema/${accountId}/user/${threemaId}`;
16
+
17
+ export interface InboundMeta {
18
+ from: string;
19
+ to: string;
20
+ messageId: string;
21
+ date: string;
22
+ nickname?: string;
23
+ }
24
+
25
+ function tsOf(date: string): string {
26
+ const seconds = Number(date);
27
+ return Number.isFinite(seconds) && seconds > 0
28
+ ? new Date(seconds * 1000).toISOString()
29
+ : new Date().toISOString();
30
+ }
31
+
32
+ function base(
33
+ accountId: string,
34
+ m: InboundMeta,
35
+ type: string,
36
+ ): Record<string, unknown> {
37
+ return {
38
+ kind: 'inbound',
39
+ id: mintId(),
40
+ ts: tsOf(m.date),
41
+ station: 'threema',
42
+ line: lineOf(accountId, m.from),
43
+ line_name: m.nickname ?? m.from,
44
+ from: userOf(accountId, m.from),
45
+ from_name: m.nickname,
46
+ from_display_name: m.nickname,
47
+ is_private: true,
48
+ payload: { from: m.from, to: m.to, nickname: m.nickname ?? null, type },
49
+ };
50
+ }
51
+
52
+ export function textEnvelope(
53
+ accountId: string,
54
+ m: InboundMeta,
55
+ raw: string,
56
+ sentByUs: (messageId: string) => boolean,
57
+ ): Record<string, unknown> {
58
+ const { replyTo, text } = unquote(raw);
59
+ return {
60
+ ...base(accountId, m, 'text'),
61
+ message_id: m.messageId,
62
+ text,
63
+ ...(replyTo === undefined
64
+ ? {}
65
+ : {
66
+ reply_to: replyTo,
67
+ event: { type: 'reply', replyTo },
68
+ reply_to_self: sentByUs(replyTo),
69
+ }),
70
+ };
71
+ }
72
+
73
+ export function receiptEnvelope(
74
+ accountId: string,
75
+ m: InboundMeta,
76
+ status: number,
77
+ targetId: string,
78
+ ): Record<string, unknown> | null {
79
+ const emoji = REACTION_OF[status];
80
+ if (emoji === undefined) return null;
81
+ return {
82
+ ...base(accountId, m, 'receipt'),
83
+ message_id: `${m.messageId}:${targetId}`,
84
+ emoji,
85
+ event: { type: 'react', emoji, targetId },
86
+ };
87
+ }
88
+
89
+ export function emitInbound(
90
+ accountId: string,
91
+ owner: string | undefined,
92
+ env: Record<string, unknown>,
93
+ ): void {
94
+ const payload = {
95
+ ...(env.payload as Record<string, unknown> | undefined),
96
+ account: accountId,
97
+ };
98
+ emit({ ...env, ...(owner ? { to: owner } : {}), account: accountId, payload });
99
+ }
100
+
101
+ export function emitOutbound(
102
+ accountId: string,
103
+ line: string,
104
+ messageId: string,
105
+ text: string,
106
+ replyTo?: string,
107
+ ): void {
108
+ emit({
109
+ kind: 'outbound',
110
+ id: mintId(),
111
+ ts: new Date().toISOString(),
112
+ station: 'threema',
113
+ line,
114
+ from: SELF_URI,
115
+ to: line,
116
+ message_id: messageId,
117
+ text,
118
+ reply_to: replyTo,
119
+ ...(replyTo ? { event: { type: 'reply', replyTo } } : {}),
120
+ account: accountId,
121
+ payload: { account: accountId },
122
+ });
123
+ }
@@ -0,0 +1,21 @@
1
+ export const GATEWAY_API = (
2
+ process.env.THREEMA_GATEWAY_URL ?? 'https://msgapi.threema.ch'
3
+ ).replace(/\/+$/, '');
4
+
5
+ const THREEMA_ID_RE = /^(?:[A-Z0-9]{8}|\*[A-Z0-9]{7})$/;
6
+ const KEY_HEX_RE = /^[0-9a-f]{64}$/;
7
+
8
+ export const MESSAGE_ID_RE = /^[0-9a-f]{16}$/;
9
+
10
+ export const isThreemaId = (raw: string): boolean => THREEMA_ID_RE.test(raw);
11
+
12
+ export const isGatewayId = (raw: string): boolean =>
13
+ raw.startsWith('*') && isThreemaId(raw);
14
+
15
+ export const normalizeThreemaId = (raw: string): string =>
16
+ raw.trim().toUpperCase();
17
+
18
+ export function parsePrivateKey(raw: string): string | null {
19
+ const text = raw.trim().replace(/^private:/i, '').trim().toLowerCase();
20
+ return KEY_HEX_RE.test(text) ? text : null;
21
+ }
@@ -0,0 +1,29 @@
1
+ import { errMsg } from '@metro-labs/core/log';
2
+ import { readCalls } from '@metro-labs/core/trains/protocol';
3
+ import { accounts, bootAccount, loadAccounts } from './accounts.js';
4
+ import { handleCall } from './actions.js';
5
+ import { fetchCredits } from './api.js';
6
+
7
+ readCalls('threema', handleCall);
8
+
9
+ for (const cfg of loadAccounts()) accounts.set(cfg.id, bootAccount(cfg));
10
+ if (accounts.size === 0) {
11
+ process.stderr.write('threema: no accounts booted, exiting\n');
12
+ process.exit(2);
13
+ }
14
+ process.stderr.write(
15
+ `threema train ready — ${accounts.size} account(s): ${[...accounts.keys()].join(', ')}\n`,
16
+ );
17
+
18
+ for (const acct of accounts.values())
19
+ fetchCredits(acct.cfg)
20
+ .then((credits) => {
21
+ process.stderr.write(
22
+ `threema[${acct.cfg.id}] ${acct.cfg.gatewayId}: ${credits} credits left\n`,
23
+ );
24
+ })
25
+ .catch((err: unknown) => {
26
+ process.stderr.write(
27
+ `threema[${acct.cfg.id}] credits check failed: ${errMsg(err)}\n`,
28
+ );
29
+ });
@@ -0,0 +1,10 @@
1
+ import type { Station, Verb } from '@metro-labs/core/stations/types';
2
+
3
+ export const threemaStation: Station = {
4
+ name: 'threema',
5
+ hasAccounts: true,
6
+ hasTrain: true,
7
+ messageVerbs: new Set<Verb>(['send', 'reply']),
8
+ attachmentMode: 'none',
9
+ tools: [],
10
+ };
@@ -0,0 +1,117 @@
1
+ import { createPrivateKey, createPublicKey } from 'node:crypto';
2
+ import {
3
+ GATEWAY_API,
4
+ isGatewayId,
5
+ normalizeThreemaId,
6
+ parsePrivateKey,
7
+ } from './ids.js';
8
+
9
+ export { parsePrivateKey } from './ids.js';
10
+
11
+ export class ThreemaGatewayError extends Error {}
12
+
13
+ export interface ThreemaCredentials {
14
+ gatewayId: string;
15
+ secret: string;
16
+ privateKey: string;
17
+ }
18
+
19
+ export interface ThreemaGatewayIdentity {
20
+ gatewayId: string;
21
+ publicKey: string;
22
+ credits: number;
23
+ }
24
+
25
+ const PKCS8_X25519_PREFIX = Buffer.from(
26
+ '302e020100300506032b656e04220420',
27
+ 'hex',
28
+ );
29
+ const TIMEOUT_MS = 10_000;
30
+ const PUBLIC_KEY_RE = /^[0-9a-f]{64}$/;
31
+
32
+ export function publicKeyOf(privateKeyHex: string): string {
33
+ const der = Buffer.concat([
34
+ PKCS8_X25519_PREFIX,
35
+ Buffer.from(privateKeyHex, 'hex'),
36
+ ]);
37
+ const key = createPrivateKey({ key: der, format: 'der', type: 'pkcs8' });
38
+ const jwk = createPublicKey(key).export({ format: 'jwk' });
39
+ return Buffer.from(String(jwk.x), 'base64url').toString('hex');
40
+ }
41
+
42
+ async function gatewayGet(
43
+ path: string,
44
+ gatewayId: string,
45
+ secret: string,
46
+ ): Promise<Response> {
47
+ const query = new URLSearchParams({ from: gatewayId, secret });
48
+ try {
49
+ return await fetch(`${GATEWAY_API}${path}?${query.toString()}`, {
50
+ signal: AbortSignal.timeout(TIMEOUT_MS),
51
+ });
52
+ } catch {
53
+ throw new ThreemaGatewayError(
54
+ 'could not reach the Threema Gateway to check those credentials',
55
+ );
56
+ }
57
+ }
58
+
59
+ function refused(res: Response, what: string): ThreemaGatewayError {
60
+ if (res.status === 401)
61
+ return new ThreemaGatewayError(
62
+ 'Threema rejected that Gateway ID or API secret',
63
+ );
64
+ if (res.status === 404)
65
+ return new ThreemaGatewayError(`Threema does not know ${what}`);
66
+ return new ThreemaGatewayError(
67
+ `the Threema Gateway answered ${res.status} while checking ${what}`,
68
+ );
69
+ }
70
+
71
+ async function fetchCredits(gatewayId: string, secret: string): Promise<number> {
72
+ const res = await gatewayGet('/credits', gatewayId, secret);
73
+ if (!res.ok) throw refused(res, 'the credit balance');
74
+ const credits = Number((await res.text()).trim());
75
+ if (!Number.isInteger(credits))
76
+ throw new ThreemaGatewayError(
77
+ 'the Threema Gateway gave an unexpected answer for the credit balance',
78
+ );
79
+ return credits;
80
+ }
81
+
82
+ async function fetchPublicKey(gatewayId: string, secret: string): Promise<string> {
83
+ const res = await gatewayGet(
84
+ `/pubkeys/${encodeURIComponent(gatewayId)}`,
85
+ gatewayId,
86
+ secret,
87
+ );
88
+ if (!res.ok) throw refused(res, `the public key of ${gatewayId}`);
89
+ const hex = (await res.text()).trim().toLowerCase();
90
+ if (!PUBLIC_KEY_RE.test(hex))
91
+ throw new ThreemaGatewayError(
92
+ 'the Threema Gateway gave an unexpected answer for the public key',
93
+ );
94
+ return hex;
95
+ }
96
+
97
+ export async function verifyThreemaGateway(
98
+ input: ThreemaCredentials,
99
+ ): Promise<ThreemaGatewayIdentity> {
100
+ const gatewayId = normalizeThreemaId(input.gatewayId);
101
+ if (!isGatewayId(gatewayId))
102
+ throw new ThreemaGatewayError(
103
+ 'a Threema Gateway ID is a * followed by 7 letters or digits, as in *ABCDEFG',
104
+ );
105
+ const privateKey = parsePrivateKey(input.privateKey);
106
+ if (privateKey === null)
107
+ throw new ThreemaGatewayError(
108
+ 'the private key is the 64 hex characters from the key file Threema gave you, with or without its private: prefix',
109
+ );
110
+ const credits = await fetchCredits(gatewayId, input.secret);
111
+ const registered = await fetchPublicKey(gatewayId, input.secret);
112
+ if (registered !== publicKeyOf(privateKey))
113
+ throw new ThreemaGatewayError(
114
+ `that private key does not belong to ${gatewayId}: the Gateway holds a different public key for it`,
115
+ );
116
+ return { gatewayId, publicKey: registered, credits };
117
+ }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.97"
2
+ "version": "0.1.0-beta.99"
3
3
  }
@@ -31,6 +31,9 @@
31
31
  "whatsapp": {
32
32
  "baileys": "7.0.0-rc14"
33
33
  },
34
+ "threema": {
35
+ "tweetnacl": "^1.0.3"
36
+ },
34
37
  "webhook": {}
35
38
  }
36
39
  }