@stage-labs/metro 0.1.0-beta.157 → 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 +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/threema/src/api.ts +21 -1
- package/runtime/node_modules/@metro-labs/threema/src/crypto.ts +8 -0
- package/runtime/node_modules/@metro-labs/threema/src/files.ts +93 -0
- package/runtime/node_modules/@metro-labs/threema/src/inbound.ts +13 -3
- package/runtime/node_modules/@metro-labs/threema/src/messages.ts +79 -2
- package/runtime/node_modules/@metro-labs/threema/src/outbound.ts +54 -12
- package/runtime/node_modules/@metro-labs/threema/src/station.ts +1 -1
- 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.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.
|
|
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",
|
|
@@ -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(
|
|
@@ -130,3 +130,23 @@ export async function sendE2E(
|
|
|
130
130
|
);
|
|
131
131
|
return messageId;
|
|
132
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
|
+
}
|
|
@@ -90,3 +90,11 @@ export function unquote(text: string): { replyTo?: string; text: string } {
|
|
|
90
90
|
const body = m?.[2];
|
|
91
91
|
return replyTo === undefined || body === undefined ? { text } : { replyTo, text: body };
|
|
92
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
|
+
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type InboundMeta,
|
|
9
9
|
type Room,
|
|
10
10
|
} from './format.js';
|
|
11
|
+
import { deliverFile } from './files.js';
|
|
11
12
|
import type { Decoded, GroupRef } from './messages.js';
|
|
12
13
|
import { requestSync, sentByUs } from './outbound.js';
|
|
13
14
|
|
|
@@ -54,10 +55,8 @@ function control(acct: Account, m: InboundMeta, d: Decoded): string | null {
|
|
|
54
55
|
return null;
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
|
|
58
|
+
function chat(acct: Account, m: InboundMeta, d: Decoded): string | null {
|
|
58
59
|
const owner = acct.cfg.owner;
|
|
59
|
-
const handled = control(acct, m, d);
|
|
60
|
-
if (handled !== null) return handled;
|
|
61
60
|
if (d.kind === 'text') {
|
|
62
61
|
emitInbound(acct.cfg.id, owner, textEnvelope(acct.cfg.id, m, d.text, sentByUs));
|
|
63
62
|
return 'text';
|
|
@@ -67,11 +66,22 @@ export function deliver(acct: Account, m: InboundMeta, d: Decoded): string {
|
|
|
67
66
|
emitInbound(acct.cfg.id, owner, textEnvelope(acct.cfg.id, m, d.text, sentByUs, roomOf(acct, d.group)));
|
|
68
67
|
return 'group-text';
|
|
69
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
|
+
}
|
|
70
74
|
if (d.kind === 'reaction') {
|
|
71
75
|
if (d.group !== null) askForRoster(acct, d.group);
|
|
72
76
|
emitInbound(acct.cfg.id, owner, reactionEnvelope(acct.cfg.id, m, d.emoji, d.messageId, d.removed, roomOf(acct, d.group)));
|
|
73
77
|
return 'reaction';
|
|
74
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;
|
|
75
85
|
if (d.kind === 'receipt') return reactions(acct, m, d);
|
|
76
86
|
if (d.kind === 'typing') return 'typing';
|
|
77
87
|
const type = d.kind === 'other' ? `0x${d.type.toString(16)}` : d.kind;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { bytesToHex, hexToBytes } from './crypto.js';
|
|
2
2
|
|
|
3
3
|
export const MSG_TEXT = 0x01;
|
|
4
|
+
export const MSG_FILE = 0x17;
|
|
4
5
|
export const MSG_GROUP_TEXT = 0x41;
|
|
6
|
+
export const MSG_GROUP_FILE = 0x46;
|
|
5
7
|
export const MSG_GROUP_SETUP = 0x4a;
|
|
6
8
|
export const MSG_GROUP_RENAME = 0x4b;
|
|
7
9
|
export const MSG_GROUP_LEAVE = 0x4c;
|
|
@@ -26,8 +28,19 @@ export interface GroupRef {
|
|
|
26
28
|
groupId: string;
|
|
27
29
|
}
|
|
28
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
|
+
|
|
29
41
|
export type Decoded =
|
|
30
42
|
| { kind: 'text'; text: string }
|
|
43
|
+
| { kind: 'file'; group: GroupRef | null; file: FileData }
|
|
31
44
|
| { kind: 'group-text'; group: GroupRef; text: string }
|
|
32
45
|
| { kind: 'group-setup'; groupId: string; members: string[] }
|
|
33
46
|
| { kind: 'group-rename'; groupId: string; name: string }
|
|
@@ -74,6 +87,61 @@ export const encodeText = (text: string): Uint8Array => typed(MSG_TEXT, Buffer.f
|
|
|
74
87
|
|
|
75
88
|
export const encodeGroupText = (group: GroupRef, text: string): Uint8Array => typed(MSG_GROUP_TEXT, groupHeader(group), Buffer.from(text, 'utf8'));
|
|
76
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
|
+
|
|
77
145
|
export const encodeGroupSyncRequest = (groupId: string): Uint8Array => typed(MSG_GROUP_REQUEST_SYNC, hexToBytes(groupId, 'group id'));
|
|
78
146
|
|
|
79
147
|
function varint(n: number): Uint8Array {
|
|
@@ -154,13 +222,18 @@ function decodeGroup(type: number, body: Uint8Array): Decoded {
|
|
|
154
222
|
if (group === null) return { kind: 'other', type };
|
|
155
223
|
const rest = body.subarray(ID_BYTES * 2);
|
|
156
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
|
+
}
|
|
157
229
|
if (type === MSG_GROUP_LEAVE) return { kind: 'group-leave', group };
|
|
158
230
|
if (type === MSG_GROUP_DELIVERY_RECEIPT) return { kind: 'receipt', group, status: rest[0] ?? 0, messageIds: ids(rest, 1) };
|
|
159
231
|
const reaction = decodeReactionProto(rest);
|
|
160
232
|
return reaction === null ? { kind: 'other', type } : { kind: 'reaction', group, ...reaction };
|
|
161
233
|
}
|
|
162
234
|
|
|
163
|
-
const GROUP_TYPES = new Set([MSG_GROUP_TEXT, MSG_GROUP_LEAVE, MSG_GROUP_DELIVERY_RECEIPT, MSG_GROUP_REACTION]);
|
|
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]);
|
|
164
237
|
|
|
165
238
|
function decodeControl(type: number, body: Uint8Array): Decoded {
|
|
166
239
|
if (body.length < ID_BYTES) return { kind: 'other', type };
|
|
@@ -171,6 +244,10 @@ function decodeControl(type: number, body: Uint8Array): Decoded {
|
|
|
171
244
|
|
|
172
245
|
function decodeDirect(type: number, body: Uint8Array): Decoded {
|
|
173
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
|
+
}
|
|
174
251
|
if (type === MSG_TYPING) return { kind: 'typing' };
|
|
175
252
|
if (type === MSG_DELIVERY_RECEIPT) return { kind: 'receipt', group: null, status: body[0] ?? 0, messageIds: ids(body, 1) };
|
|
176
253
|
const reaction = decodeReactionProto(body);
|
|
@@ -180,7 +257,7 @@ function decodeDirect(type: number, body: Uint8Array): Decoded {
|
|
|
180
257
|
export function decode(plain: Uint8Array): Decoded {
|
|
181
258
|
const type = plain[0] ?? -1;
|
|
182
259
|
const body = plain.subarray(1);
|
|
183
|
-
if (type
|
|
260
|
+
if (DIRECT_TYPES.has(type)) return decodeDirect(type, body);
|
|
184
261
|
if (type === MSG_GROUP_SETUP || type === MSG_GROUP_RENAME) return decodeControl(type, body);
|
|
185
262
|
if (GROUP_TYPES.has(type)) return decodeGroup(type, body);
|
|
186
263
|
return { kind: 'other', type };
|
|
@@ -3,6 +3,7 @@ import { respond } from '@metro-labs/core/stations/station-runtime';
|
|
|
3
3
|
import { publicKeyFor, targetOf, type Account, type Target } from './accounts.js';
|
|
4
4
|
import { sendE2E } from './api.js';
|
|
5
5
|
import { bytesToHex, quoted, seal } from './crypto.js';
|
|
6
|
+
import { encodeFileFor, fileLabel, packFile, type OutgoingFile } from './files.js';
|
|
6
7
|
import { emitOutbound, emitOutboundReaction } from './format.js';
|
|
7
8
|
import { MESSAGE_ID_RE } from './ids.js';
|
|
8
9
|
import {
|
|
@@ -67,15 +68,34 @@ interface SendArgs {
|
|
|
67
68
|
text?: unknown;
|
|
68
69
|
replyTo?: unknown;
|
|
69
70
|
account?: string;
|
|
71
|
+
attachments?: unknown;
|
|
70
72
|
}
|
|
71
73
|
|
|
72
|
-
|
|
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 {
|
|
73
81
|
const text = typeof a.text === 'string' ? a.text : '';
|
|
74
|
-
if (text === '')
|
|
75
|
-
throw new TrainError('threema_text_required', '
|
|
82
|
+
if (text === '' && files.length === 0)
|
|
83
|
+
throw new TrainError('threema_text_required', 'give some text or a file to send', { retryable: false });
|
|
76
84
|
return text;
|
|
77
85
|
}
|
|
78
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
|
+
|
|
79
99
|
function messageIdOf(raw: unknown, field: string): string {
|
|
80
100
|
const id = typeof raw === 'string' ? raw.toLowerCase() : '';
|
|
81
101
|
if (!MESSAGE_ID_RE.test(id))
|
|
@@ -83,18 +103,40 @@ function messageIdOf(raw: unknown, field: string): string {
|
|
|
83
103
|
return id;
|
|
84
104
|
}
|
|
85
105
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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);
|
|
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 {
|
|
91
109
|
const body = replyTo === undefined ? text : quoted(replyTo, text);
|
|
92
110
|
if (Buffer.byteLength(body, 'utf8') > MAX_TEXT_BYTES)
|
|
93
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 });
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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) });
|
|
98
140
|
}
|
|
99
141
|
|
|
100
142
|
interface ReactArgs {
|
package/runtime/runtime.json
CHANGED