@stage-labs/metro 0.1.0-beta.259 → 0.1.0-beta.260
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/dist/agent.js +128 -0
- package/dist/background.js +19 -1
- package/dist/claude.js +11 -7
- package/dist/cli.js +8 -0
- package/dist/route.js +17 -9
- package/dist/runtime-install.js +3 -2
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/core/package.json +1 -0
- package/runtime/node_modules/@metro-labs/core/src/ids.ts +5 -1
- package/runtime/node_modules/@metro-labs/core/src/runner-activity.ts +103 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/agent-fs.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +3 -2
- package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +17 -120
- package/runtime/node_modules/@metro-labs/daemon/src/claude/runner-activity.ts +17 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/runner-process.ts +50 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/runner.ts +45 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session-continuity.ts +48 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +90 -31
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup-api.ts +161 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup-state.ts +18 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +21 -32
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +1 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +12 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +43 -5
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +15 -1
- package/runtime/node_modules/@metro-labs/daemon/src/routes/bearer.ts +4 -1
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +2 -1
- package/runtime/node_modules/@metro-labs/daemon/src/voice/approvals.ts +1 -6
- package/runtime/node_modules/@metro-labs/http/src/api-http.ts +1 -0
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +52 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/endpoints.ts +7 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/events.ts +121 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/ids.ts +28 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/is-record.ts +2 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/lines.ts +78 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/log.ts +42 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/protocol.ts +227 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/runner-activity.ts +103 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/secure-fs.ts +74 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/station-names.ts +14 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/account-files.ts +41 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/account-store.ts +83 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/attachments.ts +195 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/mail-text.ts +72 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/mail-trust.ts +29 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/mail.ts +212 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/messaging-normalize.ts +16 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/oauth.ts +126 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/profile.ts +58 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/sender-profile.ts +64 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/station-runtime.ts +59 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/train-boot.ts +42 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/train-events.ts +84 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/types.ts +125 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/str.ts +5 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/tickets.ts +37 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/train-error.ts +53 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/version.ts +2 -0
- package/runtime/sdk-runner/runner.json +10 -0
- package/runtime/sdk-runner/src/activity-tasks.ts +142 -0
- package/runtime/sdk-runner/src/activity.ts +293 -0
- package/runtime/sdk-runner/src/app.ts +77 -0
- package/runtime/sdk-runner/src/approvals.ts +28 -0
- package/runtime/sdk-runner/src/channel-text.ts +26 -0
- package/runtime/sdk-runner/src/config.ts +50 -0
- package/runtime/sdk-runner/src/inbox.ts +94 -0
- package/runtime/sdk-runner/src/link.ts +130 -0
- package/runtime/sdk-runner/src/main.ts +44 -0
- package/runtime/sdk-runner/src/rules.ts +11 -0
- package/runtime/sdk-runner/src/runner.ts +251 -0
- package/runtime/sdk-runner/src/session-store.ts +71 -0
- package/runtime/sdk-runner/src/session-watch.ts +111 -0
- package/runtime/sdk-runner/src/singleton.ts +15 -0
- package/runtime/sdk-runner/src/speech.ts +79 -0
- package/runtime/sdk-runner/src/tool-proxy.ts +40 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { readdirSync, rmSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { log } from '../log.js';
|
|
5
|
+
|
|
6
|
+
export interface AccountFiles {
|
|
7
|
+
path: (accountId: string) => string;
|
|
8
|
+
forget: (accountId: string) => void;
|
|
9
|
+
forgetExcept: (keptAccountIds: readonly string[]) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const safeSegment = (s: string): string => s.replace(/[^A-Za-z0-9_-]/g, '_');
|
|
13
|
+
|
|
14
|
+
function listed(dir: string): string[] {
|
|
15
|
+
try {
|
|
16
|
+
return readdirSync(dir);
|
|
17
|
+
} catch {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function accountFiles(dirEnv: string, prefix: string): AccountFiles {
|
|
23
|
+
const dir = (): string => process.env[dirEnv] ?? join(homedir(), '.metro');
|
|
24
|
+
const name = (accountId: string): string => `${prefix}${safeSegment(accountId)}.json`;
|
|
25
|
+
const remove = (file: string, reason: string): void => {
|
|
26
|
+
rmSync(join(dir(), file), { force: true });
|
|
27
|
+
log.info({ file: join(dir(), file) }, reason);
|
|
28
|
+
};
|
|
29
|
+
return {
|
|
30
|
+
path: (accountId) => join(dir(), name(accountId)),
|
|
31
|
+
forget: (accountId) => {
|
|
32
|
+
remove(name(accountId), 'station: removed the files of a detached account');
|
|
33
|
+
},
|
|
34
|
+
forgetExcept: (keptAccountIds) => {
|
|
35
|
+
const kept = new Set(keptAccountIds.map(name));
|
|
36
|
+
for (const file of listed(dir()))
|
|
37
|
+
if (file.startsWith(prefix) && file.endsWith('.json') && !kept.has(file))
|
|
38
|
+
remove(file, 'station: removed the files of an account that is no longer attached');
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { errMsg } from '../log.js';
|
|
5
|
+
import { chmodIfExists } from '../secure-fs.js';
|
|
6
|
+
|
|
7
|
+
export type Die = (msg: string) => never;
|
|
8
|
+
|
|
9
|
+
export interface AccountStoreOpts<T> {
|
|
10
|
+
prefix: string;
|
|
11
|
+
validate?: (raw: T[], die: Die) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface Loader<T> {
|
|
15
|
+
die: Die;
|
|
16
|
+
loadAccounts: () => T[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const envName = (prefix: string): string =>
|
|
20
|
+
`${prefix.toUpperCase().replace(/-/g, '_')}_ACCOUNTS_FILE`;
|
|
21
|
+
|
|
22
|
+
const accountsFile = (prefix: string): string =>
|
|
23
|
+
process.env[envName(prefix)] ?? join(homedir(), '.metro', `${prefix}-accounts.json`);
|
|
24
|
+
|
|
25
|
+
function checkIds(raw: { id: string }[], die: Die): void {
|
|
26
|
+
const seen = new Set<string>();
|
|
27
|
+
for (const a of raw) {
|
|
28
|
+
if (!a.id) die('account missing id');
|
|
29
|
+
if (seen.has(a.id)) die(`duplicate account id '${a.id}'`);
|
|
30
|
+
seen.add(a.id);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readList<T>(file: string, die: Die): T[] {
|
|
35
|
+
if (!existsSync(file))
|
|
36
|
+
return die(
|
|
37
|
+
`no accounts file ${file} — the daemon writes it from ~/.metro/agents/agent.json at boot; attach an account to this station from the page first`,
|
|
38
|
+
);
|
|
39
|
+
chmodIfExists(file);
|
|
40
|
+
let raw: unknown;
|
|
41
|
+
try {
|
|
42
|
+
raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
43
|
+
} catch (e) {
|
|
44
|
+
return die(`bad ${file}: ${errMsg(e)}`);
|
|
45
|
+
}
|
|
46
|
+
if (!Array.isArray(raw) || raw.length === 0)
|
|
47
|
+
return die(`${file} must be a non-empty array`);
|
|
48
|
+
return raw as T[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function makeAccountStore<T extends { id: string }>(
|
|
52
|
+
opts: AccountStoreOpts<T>,
|
|
53
|
+
): Loader<T> {
|
|
54
|
+
const die: Die = (msg) => {
|
|
55
|
+
process.stderr.write(`${opts.prefix}: ${msg}\n`);
|
|
56
|
+
process.exit(2);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function loadAccounts(): T[] {
|
|
60
|
+
const raw = readList<T>(accountsFile(opts.prefix), die);
|
|
61
|
+
checkIds(raw, die);
|
|
62
|
+
opts.validate?.(raw, die);
|
|
63
|
+
return raw;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { die, loadAccounts };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function resolveAccountId(
|
|
70
|
+
accounts: Map<string, unknown>,
|
|
71
|
+
args: { account?: string; line?: string },
|
|
72
|
+
parseAccountId: (line: string) => string | undefined,
|
|
73
|
+
): string {
|
|
74
|
+
const have = [...accounts.keys()];
|
|
75
|
+
let id = args.account;
|
|
76
|
+
id ??= args.line ? parseAccountId(args.line) : undefined;
|
|
77
|
+
id ??= have.length === 1 ? have[0] : undefined;
|
|
78
|
+
if (id === undefined)
|
|
79
|
+
throw new Error(`name an account (have: ${have.join(', ')})`);
|
|
80
|
+
if (!accounts.has(id))
|
|
81
|
+
throw new Error(`unknown account '${id}' (have: ${have.join(', ')})`);
|
|
82
|
+
return id;
|
|
83
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
export const attachDir = (): string =>
|
|
2
|
+
process.env.METRO_XMTP_ATTACH_DIR ??
|
|
3
|
+
`${process.env.HOME}/.cache/metro/messenger-uploads`;
|
|
4
|
+
|
|
5
|
+
const CACHE_NAME_RE = /^msg_[A-Za-z0-9]{1,16}_\d+\.[A-Za-z0-9]{1,5}$/;
|
|
6
|
+
|
|
7
|
+
export const resolveCachedAttachment = (name: string): string | null =>
|
|
8
|
+
CACHE_NAME_RE.test(name) ? `${attachDir()}/${name}` : null;
|
|
9
|
+
|
|
10
|
+
const MIME_EXT: Record<string, string> = {
|
|
11
|
+
'image/jpeg': 'jpg',
|
|
12
|
+
'image/jpg': 'jpg',
|
|
13
|
+
'image/png': 'png',
|
|
14
|
+
'image/gif': 'gif',
|
|
15
|
+
'image/webp': 'webp',
|
|
16
|
+
'image/heic': 'heic',
|
|
17
|
+
'audio/mp4': 'm4a',
|
|
18
|
+
'audio/mpeg': 'mp3',
|
|
19
|
+
'audio/ogg': 'ogg',
|
|
20
|
+
'audio/wav': 'wav',
|
|
21
|
+
'video/mp4': 'mp4',
|
|
22
|
+
'video/quicktime': 'mov',
|
|
23
|
+
'video/webm': 'webm',
|
|
24
|
+
'application/pdf': 'pdf',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const extFromName = (filename: string | undefined): string | undefined => {
|
|
28
|
+
const ext = filename
|
|
29
|
+
?.split('?')[0]
|
|
30
|
+
?.split('#')[0]
|
|
31
|
+
?.split('.')
|
|
32
|
+
.pop()
|
|
33
|
+
?.toLowerCase();
|
|
34
|
+
return ext !== undefined && ext.length >= 1 && ext.length <= 5
|
|
35
|
+
? ext
|
|
36
|
+
: undefined;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const extFromMime = (mime: string | undefined): string | undefined => {
|
|
40
|
+
if (mime === undefined) return undefined;
|
|
41
|
+
const mapped = MIME_EXT[mime];
|
|
42
|
+
if (mapped) return mapped;
|
|
43
|
+
if (mime.startsWith('image/')) return mime.slice(6).replace('jpeg', 'jpg');
|
|
44
|
+
return undefined;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const extFor = (
|
|
48
|
+
filename: string | undefined,
|
|
49
|
+
mime: string | undefined,
|
|
50
|
+
): string => extFromName(filename) ?? extFromMime(mime) ?? 'bin';
|
|
51
|
+
|
|
52
|
+
const cacheFileName = (
|
|
53
|
+
messageId: string,
|
|
54
|
+
index: number,
|
|
55
|
+
filename: string | undefined,
|
|
56
|
+
mime: string | undefined,
|
|
57
|
+
): string => {
|
|
58
|
+
const safeId =
|
|
59
|
+
messageId.replace(/[^a-zA-Z0-9]/g, '').slice(0, 16) || 'unknown';
|
|
60
|
+
return `msg_${safeId}_${index}.${extFor(filename, mime)}`;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export interface SavedAttachment {
|
|
64
|
+
path: string;
|
|
65
|
+
mime?: string;
|
|
66
|
+
name?: string;
|
|
67
|
+
bytes: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const MAX_ATTACHMENT_BYTES = Number(
|
|
71
|
+
process.env.METRO_ATTACH_MAX_BYTES ?? 100 * 1024 * 1024,
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
export function assertAttachmentSize(bytes: number): void {
|
|
75
|
+
if (bytes > MAX_ATTACHMENT_BYTES)
|
|
76
|
+
throw new Error(
|
|
77
|
+
`attachment size ${bytes} exceeds limit of ${MAX_ATTACHMENT_BYTES} bytes`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function assertContentLength(header: string | null | undefined): void {
|
|
82
|
+
if (header == null) return;
|
|
83
|
+
const n = Number(header);
|
|
84
|
+
if (Number.isFinite(n)) assertAttachmentSize(n);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const cachePathFor = async (
|
|
88
|
+
messageId: string,
|
|
89
|
+
index: number,
|
|
90
|
+
meta: { mime?: string; name?: string },
|
|
91
|
+
): Promise<string> => {
|
|
92
|
+
const { mkdir } = await import('node:fs/promises');
|
|
93
|
+
const nodePath = await import('node:path');
|
|
94
|
+
const dir = attachDir();
|
|
95
|
+
await mkdir(dir, { recursive: true });
|
|
96
|
+
return nodePath.join(
|
|
97
|
+
dir,
|
|
98
|
+
cacheFileName(messageId, index, meta.name, meta.mime),
|
|
99
|
+
);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export const saveBufferToCache = async (
|
|
103
|
+
data: Uint8Array,
|
|
104
|
+
messageId: string,
|
|
105
|
+
index: number,
|
|
106
|
+
meta: { mime?: string; name?: string },
|
|
107
|
+
): Promise<SavedAttachment> => {
|
|
108
|
+
assertAttachmentSize(data.length);
|
|
109
|
+
const { writeFile } = await import('node:fs/promises');
|
|
110
|
+
const path = await cachePathFor(messageId, index, meta);
|
|
111
|
+
await writeFile(path, data);
|
|
112
|
+
return { path, mime: meta.mime, name: meta.name, bytes: data.length };
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export const saveStreamToCache = async (
|
|
116
|
+
chunks: AsyncIterable<Uint8Array>,
|
|
117
|
+
messageId: string,
|
|
118
|
+
index: number,
|
|
119
|
+
meta: { mime?: string; name?: string },
|
|
120
|
+
): Promise<SavedAttachment> => {
|
|
121
|
+
const { open, rename, rm } = await import('node:fs/promises');
|
|
122
|
+
const path = await cachePathFor(messageId, index, meta);
|
|
123
|
+
const part = `${path}.part`;
|
|
124
|
+
const handle = await open(part, 'w');
|
|
125
|
+
let bytes = 0;
|
|
126
|
+
try {
|
|
127
|
+
for await (const chunk of chunks) {
|
|
128
|
+
bytes += chunk.length;
|
|
129
|
+
assertAttachmentSize(bytes);
|
|
130
|
+
await handle.write(chunk);
|
|
131
|
+
}
|
|
132
|
+
} catch (err) {
|
|
133
|
+
await handle.close();
|
|
134
|
+
await rm(part, { force: true });
|
|
135
|
+
throw err;
|
|
136
|
+
}
|
|
137
|
+
await handle.close();
|
|
138
|
+
await rename(part, path);
|
|
139
|
+
return { path, mime: meta.mime, name: meta.name, bytes };
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export const guessMime = (path: string): string => {
|
|
143
|
+
const ext = (path.split('.').pop() ?? '').toLowerCase();
|
|
144
|
+
const map: Record<string, string> = {
|
|
145
|
+
png: 'image/png',
|
|
146
|
+
jpg: 'image/jpeg',
|
|
147
|
+
jpeg: 'image/jpeg',
|
|
148
|
+
gif: 'image/gif',
|
|
149
|
+
webp: 'image/webp',
|
|
150
|
+
heic: 'image/heic',
|
|
151
|
+
mp4: 'video/mp4',
|
|
152
|
+
mov: 'video/quicktime',
|
|
153
|
+
webm: 'video/webm',
|
|
154
|
+
m4v: 'video/x-m4v',
|
|
155
|
+
m4a: 'audio/mp4',
|
|
156
|
+
mp3: 'audio/mpeg',
|
|
157
|
+
ogg: 'audio/ogg',
|
|
158
|
+
wav: 'audio/wav',
|
|
159
|
+
pdf: 'application/pdf',
|
|
160
|
+
};
|
|
161
|
+
return map[ext] ?? 'application/octet-stream';
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
export const isImageMime = (mime: string): boolean =>
|
|
165
|
+
mime.toLowerCase().startsWith('image/');
|
|
166
|
+
export const isImageExt = (path: string): boolean =>
|
|
167
|
+
/\.(png|jpe?g|gif|webp|heic|bmp|svg)$/i.test(path);
|
|
168
|
+
|
|
169
|
+
export const appendFile = async (
|
|
170
|
+
form: FormData,
|
|
171
|
+
field: string,
|
|
172
|
+
path: string,
|
|
173
|
+
name: string,
|
|
174
|
+
): Promise<void> => {
|
|
175
|
+
const data = await Bun.file(path).arrayBuffer();
|
|
176
|
+
form.append(field, new Blob([data]), name);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
export const kindOf = (mime: string, path: string): string => {
|
|
180
|
+
if (isImageMime(mime) || isImageExt(path)) return 'image';
|
|
181
|
+
if (mime.startsWith('video/')) return 'video';
|
|
182
|
+
if (mime.startsWith('audio/')) return 'audio';
|
|
183
|
+
return 'file';
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
export const toCanonical = (a: {
|
|
187
|
+
path: string;
|
|
188
|
+
mime: string;
|
|
189
|
+
name: string;
|
|
190
|
+
}): Record<string, unknown> => ({
|
|
191
|
+
kind: kindOf(a.mime, a.path),
|
|
192
|
+
path: a.path,
|
|
193
|
+
name: a.name,
|
|
194
|
+
mime: a.mime,
|
|
195
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export const MAX_TEXT_CHARS = 20_000;
|
|
2
|
+
|
|
3
|
+
const ENTITIES: Record<string, string> = {
|
|
4
|
+
amp: '&',
|
|
5
|
+
lt: '<',
|
|
6
|
+
gt: '>',
|
|
7
|
+
quot: '"',
|
|
8
|
+
apos: "'",
|
|
9
|
+
nbsp: ' ',
|
|
10
|
+
ndash: '-',
|
|
11
|
+
mdash: '-',
|
|
12
|
+
hellip: '...',
|
|
13
|
+
rsquo: "'",
|
|
14
|
+
lsquo: "'",
|
|
15
|
+
rdquo: '"',
|
|
16
|
+
ldquo: '"',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function codePoint(n: number): string {
|
|
20
|
+
return Number.isInteger(n) && n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : '';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function decodeEntities(text: string): string {
|
|
24
|
+
return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, name: string) => {
|
|
25
|
+
if (name.startsWith('#x') || name.startsWith('#X')) return codePoint(parseInt(name.slice(2), 16));
|
|
26
|
+
if (name.startsWith('#')) return codePoint(Number(name.slice(1)));
|
|
27
|
+
return ENTITIES[name.toLowerCase()] ?? whole;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function htmlToText(html: string): string {
|
|
32
|
+
const text = html
|
|
33
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
34
|
+
.replace(/<(head|style|script|title)\b[\s\S]*?<\/\1\s*>/gi, '')
|
|
35
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
36
|
+
.replace(/<li\b[^>]*>/gi, '\n- ')
|
|
37
|
+
.replace(/<\/(p|div|tr|h[1-6]|blockquote|table|ul|ol)\s*>/gi, '\n')
|
|
38
|
+
.replace(/<(p|div|tr|h[1-6]|blockquote|table)\b[^>]*>/gi, '\n')
|
|
39
|
+
.replace(/<\/t[dh]\s*>/gi, ' ')
|
|
40
|
+
.replace(/<[^>]*>/g, '');
|
|
41
|
+
return tidy(decodeEntities(text));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function tidy(text: string): string {
|
|
45
|
+
return text
|
|
46
|
+
.replace(/\r\n?/g, '\n')
|
|
47
|
+
.replace(/[ \t ]+/g, ' ')
|
|
48
|
+
.split('\n')
|
|
49
|
+
.map((l) => l.trim())
|
|
50
|
+
.join('\n')
|
|
51
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
52
|
+
.trim();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function capText(text: string, max = MAX_TEXT_CHARS): string {
|
|
56
|
+
if (text.length <= max) return text;
|
|
57
|
+
return `${text.slice(0, max)}\n[... ${String(text.length - max)} more characters]`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function bodyText(body: { contentType?: unknown; content?: unknown } | undefined): string {
|
|
61
|
+
const content = typeof body?.content === 'string' ? body.content : '';
|
|
62
|
+
return String(body?.contentType).toLowerCase() === 'html' ? htmlToText(content) : tidy(content);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function escapeHtml(text: string): string {
|
|
66
|
+
return text
|
|
67
|
+
.replace(/&/g, '&')
|
|
68
|
+
.replace(/</g, '<')
|
|
69
|
+
.replace(/>/g, '>')
|
|
70
|
+
.replace(/"/g, '"')
|
|
71
|
+
.replace(/\r?\n/g, '<br>');
|
|
72
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface MailHeader {
|
|
2
|
+
name?: string;
|
|
3
|
+
value?: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export type Screened = { skip: string } | { verified: boolean };
|
|
7
|
+
|
|
8
|
+
const AUTOMATED_LOCAL = /^(no-?reply|do-?not-?reply|donotreply|mailer-daemon|notifications?)([+._-].*)?$/i;
|
|
9
|
+
const BULK = new Set(['bulk', 'list', 'junk']);
|
|
10
|
+
|
|
11
|
+
export const headerValues = (headers: MailHeader[], name: string): string[] =>
|
|
12
|
+
headers.filter((h) => (h.name ?? '').toLowerCase() === name).map((h) => (h.value ?? '').trim());
|
|
13
|
+
|
|
14
|
+
export const firstHeader = (headers: MailHeader[], name: string): string | undefined => headerValues(headers, name)[0];
|
|
15
|
+
|
|
16
|
+
export function automatedBySender(address: string): string | null {
|
|
17
|
+
const local = address.split('@')[0] ?? '';
|
|
18
|
+
return AUTOMATED_LOCAL.test(local) ? `sender ${address}` : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function automatedByHeaders(headers: MailHeader[]): string | null {
|
|
22
|
+
if (firstHeader(headers, 'list-unsubscribe') !== undefined) return 'List-Unsubscribe';
|
|
23
|
+
const auto = firstHeader(headers, 'auto-submitted');
|
|
24
|
+
if (auto !== undefined && auto.toLowerCase() !== 'no') return `Auto-Submitted: ${auto}`;
|
|
25
|
+
const precedence = (firstHeader(headers, 'precedence') ?? '').toLowerCase();
|
|
26
|
+
return BULK.has(precedence) ? `Precedence: ${precedence}` : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const domainOf = (address: string): string => (address.split('@')[1] ?? '').toLowerCase();
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { mintId } from '../ids.js';
|
|
2
|
+
import { Line } from '../lines.js';
|
|
3
|
+
import { TrainError } from '../train-error.js';
|
|
4
|
+
import { capText } from './mail-text.js';
|
|
5
|
+
|
|
6
|
+
export type MailStation = 'outlook' | 'gmail';
|
|
7
|
+
|
|
8
|
+
type Args = Record<string, unknown>;
|
|
9
|
+
|
|
10
|
+
export interface MailItem {
|
|
11
|
+
id: string;
|
|
12
|
+
threadId: string;
|
|
13
|
+
subject: string;
|
|
14
|
+
from: string;
|
|
15
|
+
fromName: string;
|
|
16
|
+
to: string[];
|
|
17
|
+
cc: string[];
|
|
18
|
+
date: string | null;
|
|
19
|
+
preview: string;
|
|
20
|
+
text: string;
|
|
21
|
+
hasAttachments: boolean;
|
|
22
|
+
isRead: boolean;
|
|
23
|
+
internetMessageId: string | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AttachmentMeta {
|
|
27
|
+
kind: string;
|
|
28
|
+
name: string;
|
|
29
|
+
mime: string;
|
|
30
|
+
size?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface OutgoingFile {
|
|
34
|
+
path: string;
|
|
35
|
+
mime: string;
|
|
36
|
+
name: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ReadQuery {
|
|
40
|
+
threadId: string | null;
|
|
41
|
+
query: string;
|
|
42
|
+
from: string;
|
|
43
|
+
since: string;
|
|
44
|
+
until: string;
|
|
45
|
+
before: string;
|
|
46
|
+
unreadOnly: boolean;
|
|
47
|
+
limit: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const DEFAULT_LIMIT = 20;
|
|
51
|
+
export const MAX_LIMIT = 50;
|
|
52
|
+
const SEEN_MAX = 500;
|
|
53
|
+
const SUBJECT_MAX = 255;
|
|
54
|
+
const ADDRESS_RE = /^[^@\s/]+@[^@\s/]+\.[^@\s/]+$/;
|
|
55
|
+
|
|
56
|
+
export interface MailService {
|
|
57
|
+
station: MailStation;
|
|
58
|
+
company: string;
|
|
59
|
+
product: string;
|
|
60
|
+
api: string;
|
|
61
|
+
apiCode: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function refusalOf(s: MailService, status: number, detail: string): TrainError {
|
|
65
|
+
const refuse = (code: string, text: string, retryable = false): TrainError => new TrainError(`${s.station}_${code}`, text, { retryable });
|
|
66
|
+
if (status === 401) return refuse('signed_out', `${s.company} refused this mailbox's sign-in (${detail}); connect ${s.product} again from the page`);
|
|
67
|
+
if (status === 403) return refuse('forbidden', `${s.company} does not let Metro do that on this mailbox (${detail})`);
|
|
68
|
+
if (status === 404) return refuse('not_found', `${s.product} has no such message or conversation (${detail})`);
|
|
69
|
+
if (status === 413) return refuse('too_large', `${s.product} refused the size of that request (${detail})`);
|
|
70
|
+
if (status === 429) return refuse('throttled', `${s.company} asked Metro to slow down (${detail}); try again in a minute`, true);
|
|
71
|
+
return refuse(s.apiCode, `${s.api} answered ${String(status)} (${detail})`, status >= 500);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : '');
|
|
75
|
+
|
|
76
|
+
const encodeResource = (id: string): string => id.replace(/%/g, '%25').replace(/\//g, '%2F').replace(/\+/g, '%2B');
|
|
77
|
+
|
|
78
|
+
export const mailLine = (station: MailStation, accountId: string, threadId: string): string =>
|
|
79
|
+
`metro://${station}/${accountId}/${encodeResource(threadId)}`;
|
|
80
|
+
|
|
81
|
+
const mailUser = (station: MailStation, accountId: string, address: string): string =>
|
|
82
|
+
`metro://${station}/${accountId}/user/${address.toLowerCase()}`;
|
|
83
|
+
|
|
84
|
+
export function threadOfLine(station: MailStation, line: string): { accountId: string; threadId: string } | null {
|
|
85
|
+
const parsed = Line.parseMail(line, station);
|
|
86
|
+
if (parsed === null) return null;
|
|
87
|
+
try {
|
|
88
|
+
return { accountId: parsed.accountId, threadId: decodeURIComponent(parsed.resource) };
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const isPrivateTo = (m: MailItem, self: string): boolean => m.to.length === 1 && m.to[0] === self && m.cc.length === 0;
|
|
95
|
+
|
|
96
|
+
export function mailEnvelope(station: MailStation, accountId: string, self: string, m: MailItem, attachments: AttachmentMeta[], verified: boolean): Record<string, unknown> {
|
|
97
|
+
const body = capText(m.text);
|
|
98
|
+
return {
|
|
99
|
+
id: mintId(),
|
|
100
|
+
ts: m.date ?? new Date().toISOString(),
|
|
101
|
+
station,
|
|
102
|
+
line: mailLine(station, accountId, m.threadId),
|
|
103
|
+
line_name: m.subject === '' ? '(no subject)' : m.subject,
|
|
104
|
+
from: mailUser(station, accountId, m.from === '' ? 'unknown' : m.from),
|
|
105
|
+
from_name: m.fromName || m.from,
|
|
106
|
+
from_display_name: m.fromName || undefined,
|
|
107
|
+
message_id: m.id,
|
|
108
|
+
text: m.subject === '' ? body : `Subject: ${m.subject}\n\n${body}`,
|
|
109
|
+
is_private: isPrivateTo(m, self),
|
|
110
|
+
sender_verified: verified,
|
|
111
|
+
payload: {
|
|
112
|
+
from: m.from,
|
|
113
|
+
to: m.to,
|
|
114
|
+
cc: m.cc,
|
|
115
|
+
subject: m.subject,
|
|
116
|
+
internetMessageId: m.internetMessageId,
|
|
117
|
+
...(attachments.length === 0 ? {} : { attachments }),
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const heading = (station: MailStation, accountId: string, m: MailItem): Record<string, unknown> => ({
|
|
123
|
+
message_id: m.id,
|
|
124
|
+
line: mailLine(station, accountId, m.threadId),
|
|
125
|
+
from: m.from,
|
|
126
|
+
from_name: m.fromName,
|
|
127
|
+
date: m.date,
|
|
128
|
+
title: m.subject,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
export const mailSummary = (station: MailStation, accountId: string, m: MailItem): Record<string, unknown> => ({
|
|
132
|
+
...heading(station, accountId, m),
|
|
133
|
+
text_preview: m.preview,
|
|
134
|
+
has_attachments: m.hasAttachments,
|
|
135
|
+
is_read: m.isRead,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
export const mailFull = (station: MailStation, accountId: string, m: MailItem): Record<string, unknown> => ({
|
|
139
|
+
...heading(station, accountId, m),
|
|
140
|
+
has_attachments: m.hasAttachments,
|
|
141
|
+
is_read: m.isRead,
|
|
142
|
+
to: m.to,
|
|
143
|
+
cc: m.cc,
|
|
144
|
+
text: capText(m.text),
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
export const isAddress = (target: string): boolean => target.includes('@');
|
|
148
|
+
|
|
149
|
+
export const isEmailAddress = (value: string): boolean => ADDRESS_RE.test(value);
|
|
150
|
+
|
|
151
|
+
export function checkAddress(station: MailStation, to: string): void {
|
|
152
|
+
if (!isEmailAddress(to)) throw new TrainError(`${station}_bad_address`, `not an email address: ${to}`, { retryable: false });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function subjectOf(station: MailStation, subject: string, text: string): string {
|
|
156
|
+
const chosen = subject !== '' ? subject : (text.split('\n').find((l) => l.trim() !== '') ?? '').trim();
|
|
157
|
+
if (chosen === '') throw new TrainError(`${station}_subject_required`, 'a new email needs a subject or some text', { retryable: false });
|
|
158
|
+
return chosen.length > SUBJECT_MAX ? `${chosen.slice(0, SUBJECT_MAX - 3)}...` : chosen;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export const filesOf = (raw: unknown): OutgoingFile[] =>
|
|
162
|
+
(Array.isArray(raw) ? raw : [])
|
|
163
|
+
.map((a) => (a ?? {}) as Record<string, unknown>)
|
|
164
|
+
.filter((a) => typeof a.path === 'string' && a.path !== '')
|
|
165
|
+
.map((a) => ({
|
|
166
|
+
path: String(a.path),
|
|
167
|
+
mime: typeof a.mime === 'string' && a.mime !== '' ? a.mime : 'application/octet-stream',
|
|
168
|
+
name: typeof a.name === 'string' && a.name !== '' ? a.name : 'file',
|
|
169
|
+
}));
|
|
170
|
+
|
|
171
|
+
function isoOf(station: MailStation, raw: string, field: string): string {
|
|
172
|
+
if (raw === '') return '';
|
|
173
|
+
const at = Date.parse(raw);
|
|
174
|
+
if (Number.isNaN(at)) throw new TrainError(`${station}_bad_date`, `${field} must be a date, for example 2026-09-24 or 2026-09-24T10:00:00Z`, { retryable: false });
|
|
175
|
+
return new Date(at).toISOString();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function readQueryOf(station: MailStation, args: Args): ReadQuery {
|
|
179
|
+
const line = str(args.line);
|
|
180
|
+
const thread = line === '' ? null : threadOfLine(station, line);
|
|
181
|
+
if (line !== '' && thread === null) throw new TrainError(`${station}_bad_line`, `not a valid ${station} line: ${line}`, { retryable: false });
|
|
182
|
+
return {
|
|
183
|
+
threadId: thread?.threadId ?? null,
|
|
184
|
+
query: str(args.query),
|
|
185
|
+
from: str(args.from).toLowerCase(),
|
|
186
|
+
since: isoOf(station, str(args.since), 'since'),
|
|
187
|
+
until: isoOf(station, str(args.until), 'until'),
|
|
188
|
+
before: str(args.before),
|
|
189
|
+
unreadOnly: args.unreadOnly === true,
|
|
190
|
+
limit: typeof args.limit === 'number' && args.limit > 0 ? Math.min(Math.floor(args.limit), MAX_LIMIT) : DEFAULT_LIMIT,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export const seenOf = (raw: unknown): string[] => (Array.isArray(raw) ? raw.filter((s): s is string => typeof s === 'string') : []);
|
|
195
|
+
|
|
196
|
+
export function noteSeen(seen: string[], messageId: string): void {
|
|
197
|
+
seen.push(messageId);
|
|
198
|
+
if (seen.length > SEEN_MAX) seen.splice(0, seen.length - SEEN_MAX);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function pollForever(tag: string, once: () => Promise<unknown>, intervalMs: number): void {
|
|
202
|
+
const tick = (): void => {
|
|
203
|
+
once()
|
|
204
|
+
.catch((err: unknown) => {
|
|
205
|
+
process.stderr.write(`${tag} mail check failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
206
|
+
})
|
|
207
|
+
.finally(() => {
|
|
208
|
+
setTimeout(tick, intervalMs);
|
|
209
|
+
});
|
|
210
|
+
};
|
|
211
|
+
tick();
|
|
212
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
type Args = Record<string, unknown>;
|
|
2
|
+
|
|
3
|
+
export interface Normalized {
|
|
4
|
+
action: string;
|
|
5
|
+
args: Args;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type Normalize = (action: string, args: Args) => Normalized;
|
|
9
|
+
|
|
10
|
+
export const messagingAliases =
|
|
11
|
+
(removal: Args = { emoji: '' }): Normalize =>
|
|
12
|
+
(action, args) => {
|
|
13
|
+
if (action === 'reply') return { action: 'send', args };
|
|
14
|
+
if (action === 'unreact') return { action: 'react', args: { ...args, ...removal } };
|
|
15
|
+
return { action, args };
|
|
16
|
+
};
|