6f12c9a · Accept mapping URLs · 5 days ago
1
import { readFileSync } from "node:fs";2
3
export type Role = "management" | "room" | "reports";4
export type Direction = "in" | "out" | "both";5
export type Platform = "slack" | "discord";6
7
export type Channel = {8
id: string;9
name: string;10
role: Role;11
direction: Direction;12
};13
14
export type Mapping = {15
workspace: string;16
platform: Platform;17
scope: string;18
agent: string;19
collectSeconds: number;20
channels: Channel[];21
};22
23
export class MappingError extends Error {}24
25
export 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
34
export async function loadMappingFrom(source: string): Promise<Mapping> {35
return loadMapping(await readSource(source));36
}37
38
export 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
52
function 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
67
function 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
93
function 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