6f12c9a · Accept mapping URLs · 5 days ago

typescript107 lines · 3.5 KB
1import { readFileSync } from "node:fs";
2
3export type Role = "management" | "room" | "reports";
4export type Direction = "in" | "out" | "both";
5export type Platform = "slack" | "discord";
6
7export type Channel = {
8 id: string;
9 name: string;
10 role: Role;
11 direction: Direction;
12};
13
14export type Mapping = {
15 workspace: string;
16 platform: Platform;
17 scope: string;
18 agent: string;
19 collectSeconds: number;
20 channels: Channel[];
21};
22
23export class MappingError extends Error {}
24
25export async function readSource(source: string): Promise<string> {
26 if (source.startsWith("http://") || source.startsWith("https://")) {
27 const res = await fetch(source, { headers: { "User-Agent": "axiom-relay/0.2" } });
28 if (!res.ok) throw new MappingError(`mapping fetch ${res.status}`);
29 return await res.text();
30 }
31 return readFileSync(source, "utf8");
32}
33
34export async function loadMappingFrom(source: string): Promise<Mapping> {
35 return loadMapping(await readSource(source));
36}
37
38export function loadMapping(text: string): Mapping {
39 const { meta, body } = splitFrontMatter(text);
40 const channels = parseTable(body);
41 validate(meta, channels);
42 return {
43 workspace: meta.workspace,
44 platform: meta.platform as Platform,
45 scope: meta.scope,
46 agent: meta.agent,
47 collectSeconds: Number(meta.collect_seconds ?? 45),
48 channels,
49 };
50}
51
52function splitFrontMatter(text: string): { meta: Record<string, string>; body: string } {
53 if (!text.startsWith("---")) throw new MappingError("mapping must start with YAML front matter");
54 const end = text.indexOf("\n---", 3);
55 if (end < 0) throw new MappingError("unterminated front matter");
56 const raw = text.slice(3, end);
57 const body = text.slice(end + 4);
58 const meta: Record<string, string> = {};
59 for (const line of raw.split("\n")) {
60 if (!line.trim() || !line.includes(":")) continue;
61 const i = line.indexOf(":");
62 meta[line.slice(0, i).trim()] = line.slice(i + 1).trim();
63 }
64 return { meta, body };
65}
66
67function parseTable(body: string): Channel[] {
68 let header: string[] | null = null;
69 const rows: Channel[] = [];
70 for (const raw of body.split("\n")) {
71 const line = raw.trim();
72 if (!line.startsWith("|")) continue;
73 const cells = line.replace(/^\||\|$/g, "").split("|").map((c) => c.trim());
74 if (!header) {
75 header = cells.map((c) => c.toLowerCase());
76 continue;
77 }
78 if (/^[-:\s|]+$/.test(line)) continue;
79 const data: Record<string, string> = {};
80 header.forEach((key, i) => {
81 data[key] = cells[i] ?? "";
82 });
83 rows.push({
84 id: data.id,
85 name: data.name ?? "",
86 role: data.role as Role,
87 direction: data.direction as Direction,
88 });
89 }
90 return rows;
91}
92
93function validate(meta: Record<string, string>, channels: Channel[]) {
94 for (const key of ["workspace", "platform", "scope", "agent"]) {
95 if (!meta[key]) throw new MappingError(`missing ${key}`);
96 }
97 if (meta.platform !== "slack" && meta.platform !== "discord") {
98 throw new MappingError(`unknown platform ${meta.platform}`);
99 }
100 const ids = channels.map((c) => c.id);
101 if (new Set(ids).size !== ids.length) throw new MappingError("duplicate channel id");
102 const hasMgmt = channels.some((c) => c.role === "management" && c.direction !== "out");
103 const hasReport = channels.some((c) => c.role === "reports" && c.direction !== "in");
104 if (!hasMgmt) throw new MappingError("need a management channel with direction in/both");
105 if (!hasReport) throw new MappingError("need a reports channel with direction out/both");
106}
107