relays-sdk 0.0.0-stage → 0.2.0
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/LICENSE +21 -0
- package/README.md +40 -2
- package/package.json +42 -4
- package/relay-a2a.mjs +246 -0
- package/relay-client.mjs +3290 -0
- package/relay-manifest.mjs +291 -0
- package/relay-webhook.mjs +43 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
|
|
2
|
+
const isObj = (x) => x && typeof x === "object" && !Array.isArray(x);
|
|
3
|
+
const drop = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v != null));
|
|
4
|
+
export const FEEDBACK_SECRET_PATTERNS = [
|
|
5
|
+
["relay token or code", /\b(?:grk|rat|rrt|rmt|rac|rpi|ilk|frd|whsec|evl|ptk)_[A-Za-z0-9_-]{12,}/],
|
|
6
|
+
["Stripe key", /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{12,}/],
|
|
7
|
+
["GitHub token", /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})/],
|
|
8
|
+
["Slack token", /\bxox[abposr]-[A-Za-z0-9-]{10,}/],
|
|
9
|
+
["AWS access key", /\bAKIA[0-9A-Z]{16}\b/],
|
|
10
|
+
["private key", /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
|
|
11
|
+
["bearer token", /\bbearer\s+[A-Za-z0-9._~+/=-]{20,}/i],
|
|
12
|
+
];
|
|
13
|
+
export function feedbackSecretKind(message) {
|
|
14
|
+
if (typeof message !== "string") return null;
|
|
15
|
+
for (const [label, rx] of FEEDBACK_SECRET_PATTERNS) if (rx.test(message)) return label;
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const MANIFEST_VERSION = 1, MANIFEST_MAX_BYTES = 32768, MANIFEST_NOTE_MAX = 500;
|
|
20
|
+
export const MANIFEST_KEYS = ["schema_version", "summary", "highlights", "can_do", "tools", "resources", "connectors", "skills", "wake_adapter", "limits", "can_help_with"];
|
|
21
|
+
export const MANIFEST_SUMMARY_MAX = 500, MANIFEST_HIGHLIGHTS_MAX = 8, HIGHLIGHT_TEXT_MAX = 80, HIGHLIGHT_SERVICE_MAX = 64, HIGHLIGHT_KEYS = ["service", "text"];
|
|
22
|
+
export const MANIFEST_CAPS = { tools: null, resources: null, connectors: null, skills: null };
|
|
23
|
+
export const MANIFEST_MAX_DEPTH = 12;
|
|
24
|
+
const depth = (v) => (v && typeof v === "object" && Object.keys(v).length ? 1 + Math.max(...Object.values(v).map(depth)) : 0);
|
|
25
|
+
const ITEM_KEYS = { tools: ["name", "description"], resources: ["uri", "name", "description", "mimeType"], connectors: ["name", "description"], skills: ["id", "name", "description", "tags"] };
|
|
26
|
+
const ITEM_MAX = { name: 64, description: 500, id: 64, mimeType: 100, uri: 500 };
|
|
27
|
+
export const MANIFEST_LIMIT_INTS = ["rate_per_min", "max_concurrent_tasks", "daily_quota"];
|
|
28
|
+
export const MANIFEST_WAKE_KINDS = ["webhook", "inbox_only", "claude_routine", "cursor_agent", "github_issue", "chatgpt_workspace_agent", "slack_mention", "discord_webhook", "telegram_bot", "a2a_push", "devin_session", "poll_only"];
|
|
29
|
+
export const CAPABILITIES_ERRORS = {
|
|
30
|
+
manifest_invalid: "The manifest is invalid (see field).", manifest_contains_secret: "The manifest looks like it contains a credential (see kind and field); nothing was sent or stored.",
|
|
31
|
+
manifest_too_large: "The manifest is over the 32 KB cap (see limit).", manifest_not_found: "That bot has not shared a capability manifest yet.",
|
|
32
|
+
request_not_found: "No such capability request (unknown, or another bot's request_id).", bot_not_found: "No such bot (or you may not see its capabilities).",
|
|
33
|
+
capabilities_request_rate_limited: "Too many capability requests for this bot (10 per hour per requester); try again later.",
|
|
34
|
+
request_not_pending: "That capability request is no longer pending: it was already approved, denied or it expired (see request_status)." };
|
|
35
|
+
|
|
36
|
+
export const CAPABILITY_REQUEST_STATUSES = ["pending", "approved", "denied", "expired"], CAPABILITY_ANSWERS = ["approved", "denied"], CAPABILITY_DELIVERY_STATUSES = ["pending", "delivered", "failed"];
|
|
37
|
+
export const capabilityRequestStatus = (s) => (s === "answered" ? "approved" : s);
|
|
38
|
+
|
|
39
|
+
export const noteSecretKind = (note) => (typeof note === "string" ? feedbackSecretKind(note.replace(CTRL_RE, "")) || urlSecretKind(note.replace(CTRL_RE, "")) : null);
|
|
40
|
+
export const MANIFEST_TOOL_NAME_RE = /^[A-Za-z0-9_.-]{1,64}$/, CAPS_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/;
|
|
41
|
+
const CTRL_RE = /[\x00-\x09\x0b-\x1f\x7f-\x9f]/g, URL_RE = /[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s"'<>]+/g, URI_RE = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s?#@/]*(?:\/[^\s?#]*)?$/;
|
|
42
|
+
const CRED_PARAM_RE = /^(?:key|token|secret|sig|signature|password|auth|access_token|api_key|apikey|x-amz-.*)$/i, CRED_KEY_RE = /(?:token|secret|passw|api[_-]?key|authorization|headers?|credential|private[_-]?key|cookie)/i;
|
|
43
|
+
export const cpLen = (s) => [...s].length, cpSlice = (s, n) => [...s].slice(0, n).join("");
|
|
44
|
+
const EDGE_WS_RE = /^[ \t\n\r]+|[ \t\n\r]+$/g, trimWs = (s) => s.replace(EDGE_WS_RE, ""), inRange = (s, lo, hi) => { const n = cpLen(s); return n >= lo && n <= hi; };
|
|
45
|
+
const jsonBytes = (x) => new TextEncoder().encode(JSON.stringify(x)).length;
|
|
46
|
+
export const cleanManifest = (v) => typeof v === "string" ? v.replace(CTRL_RE, "") : Array.isArray(v) ? v.map(cleanManifest) : isObj(v) ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, cleanManifest(x)])) : v;
|
|
47
|
+
function* walkStrings(v, path) {
|
|
48
|
+
if (typeof v === "string") yield [path, v];
|
|
49
|
+
else if (Array.isArray(v)) for (let i = 0; i < v.length; i++) yield* walkStrings(v[i], `${path}[${i}]`);
|
|
50
|
+
else if (isObj(v)) for (const [k, x] of Object.entries(v)) yield* walkStrings(x, path ? `${path}.${k}` : k);
|
|
51
|
+
}
|
|
52
|
+
function* walkKeys(v, path) {
|
|
53
|
+
if (Array.isArray(v)) for (let i = 0; i < v.length; i++) yield* walkKeys(v[i], `${path}[${i}]`);
|
|
54
|
+
else if (isObj(v)) for (const [k, x] of Object.entries(v)) { const p = path ? `${path}.${k}` : k; yield [k, p]; yield* walkKeys(x, p); }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function urlSecretKind(s) {
|
|
58
|
+
for (const u of s.match(URL_RE) || []) {
|
|
59
|
+
const rest = u.split("://").slice(1).join("://"), auth = rest.split(/[/?#]/, 1)[0];
|
|
60
|
+
if (auth.includes("@")) return "URL with credentials";
|
|
61
|
+
const qi = u.indexOf("?");
|
|
62
|
+
if (qi >= 0) for (const p of u.slice(qi + 1).split("#")[0].split(/[&;]/)) { if (!p) continue; let n = p.split("=")[0].replace(/\+/g, " "); try { n = decodeURIComponent(n); } catch {} if (CRED_PARAM_RE.test(n.trim())) return "URL with a credential in its query"; }
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function manifestSecret(manifest) {
|
|
68
|
+
for (const [k, p] of walkKeys(manifest, "")) if (CRED_KEY_RE.test(k)) return { kind: "credential-named key", field: p };
|
|
69
|
+
for (const [p, s] of walkStrings(manifest, "")) { const k = feedbackSecretKind(s) || urlSecretKind(s); if (k) return { kind: k, field: p }; }
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function manifestProblem(manifest) {
|
|
74
|
+
if (!isObj(manifest)) return { error: "manifest_invalid", field: "manifest" };
|
|
75
|
+
const m = cleanManifest(manifest), bad = (field) => ({ error: "manifest_invalid", field });
|
|
76
|
+
const s = manifestSecret(m); if (s) return { error: "manifest_contains_secret", ...s };
|
|
77
|
+
if (!Number.isInteger(m.schema_version) || m.schema_version !== MANIFEST_VERSION) return bad("schema_version");
|
|
78
|
+
for (const k of Object.keys(m)) if (!MANIFEST_KEYS.includes(k)) return bad(k);
|
|
79
|
+
if (m.highlights != null && m.can_do != null) return bad("can_do");
|
|
80
|
+
if (m.summary != null && (typeof m.summary !== "string" || !inRange(trimWs(m.summary), 1, MANIFEST_SUMMARY_MAX))) return bad("summary");
|
|
81
|
+
const hk = m.highlights != null ? "highlights" : m.can_do != null ? "can_do" : null;
|
|
82
|
+
if (hk) {
|
|
83
|
+
const h = m[hk]; if (!Array.isArray(h) || h.length > MANIFEST_HIGHLIGHTS_MAX) return bad(hk);
|
|
84
|
+
for (let i = 0; i < h.length; i++) {
|
|
85
|
+
const it = h[i], f = `${hk}[${i}]`;
|
|
86
|
+
if (typeof it === "string") { if (!inRange(trimWs(it), 1, HIGHLIGHT_TEXT_MAX)) return bad(f); continue; }
|
|
87
|
+
if (!isObj(it)) return bad(f);
|
|
88
|
+
for (const k of Object.keys(it)) if (!HIGHLIGHT_KEYS.includes(k)) return bad(`${f}.${k}`);
|
|
89
|
+
if (typeof it.text !== "string" || !inRange(trimWs(it.text), 1, HIGHLIGHT_TEXT_MAX)) return bad(f + ".text");
|
|
90
|
+
if (it.service != null && (typeof it.service !== "string" || !inRange(trimWs(it.service), 1, HIGHLIGHT_SERVICE_MAX))) return bad(f + ".service");
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
for (const sect of Object.keys(MANIFEST_CAPS)) {
|
|
94
|
+
const items = m[sect]; if (items == null) continue;
|
|
95
|
+
if (!Array.isArray(items)) return bad(sect);
|
|
96
|
+
for (let i = 0; i < items.length; i++) {
|
|
97
|
+
const it = items[i], f = `${sect}[${i}]`;
|
|
98
|
+
if (!isObj(it)) return bad(f);
|
|
99
|
+
if (depth(it) > MANIFEST_MAX_DEPTH - 2) return bad(f);
|
|
100
|
+
if (typeof it.name !== "string" || !it.name.trim()) return bad(f + ".name");
|
|
101
|
+
if (sect === "tools" && !MANIFEST_TOOL_NAME_RE.test(it.name)) return bad(f + ".name");
|
|
102
|
+
for (const k of ["name", "description", "id", "mimeType"]) if (k in it && (typeof it[k] !== "string" || cpLen(it[k]) > ITEM_MAX[k])) return bad(`${f}.${k}`);
|
|
103
|
+
if ("uri" in it && (typeof it.uri !== "string" || cpLen(it.uri) > 500 || !URI_RE.test(it.uri))) return bad(f + ".uri");
|
|
104
|
+
if ("tags" in it) {
|
|
105
|
+
const t = it.tags; if (!Array.isArray(t) || t.length > 10) return bad(f + ".tags");
|
|
106
|
+
for (let j = 0; j < t.length; j++) if (typeof t[j] !== "string" || cpLen(t[j]) < 1 || cpLen(t[j]) > 32) return bad(`${f}.tags[${j}]`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const w = m.wake_adapter;
|
|
111
|
+
if (w != null) {
|
|
112
|
+
if (!isObj(w)) return bad("wake_adapter");
|
|
113
|
+
for (const k of Object.keys(w)) if (k !== "kind") return bad(`wake_adapter.${k}`);
|
|
114
|
+
if (!MANIFEST_WAKE_KINDS.includes(w.kind)) return bad("wake_adapter.kind");
|
|
115
|
+
}
|
|
116
|
+
const l = m.limits;
|
|
117
|
+
if (l != null) {
|
|
118
|
+
if (!isObj(l)) return bad("limits");
|
|
119
|
+
for (const [k, v] of Object.entries(l)) {
|
|
120
|
+
if (k === "text") { if (typeof v !== "string" || cpLen(v) > 500) return bad("limits.text"); }
|
|
121
|
+
else if (MANIFEST_LIMIT_INTS.includes(k)) { if (!Number.isInteger(v) || v < 0 || v > 2 ** 53) return bad(`limits.${k}`); }
|
|
122
|
+
else return bad(`limits.${k}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (m.can_help_with != null && (typeof m.can_help_with !== "string" || cpLen(m.can_help_with) > 2000)) return bad("can_help_with");
|
|
126
|
+
if (jsonBytes(m) > MANIFEST_MAX_BYTES) return { error: "manifest_too_large", limit: MANIFEST_MAX_BYTES };
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function normalizeManifest(m) {
|
|
131
|
+
const out = { schema_version: m.schema_version }, hl = m.highlights ?? m.can_do;
|
|
132
|
+
if (m.summary != null) out.summary = trimWs(m.summary);
|
|
133
|
+
if (hl != null && hl.length) out.highlights = hl.map((it) => typeof it === "string" ? trimWs(it) : it.service != null ? { service: trimWs(it.service), text: trimWs(it.text) } : { text: trimWs(it.text) });
|
|
134
|
+
for (const [k, v] of Object.entries(m)) if (k !== "schema_version" && k !== "summary" && k !== "highlights" && k !== "can_do") out[k] = v;
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export const capsHighlightsMode = (v) => { const t = v == null ? "" : String(v).trim().toLowerCase(); return ["0", "false", "off", "no"].includes(t) ? "0" : t === "auto" ? "auto" : "1"; };
|
|
139
|
+
export const isHighlightsField = (f) => typeof f === "string" && (["summary", "highlights", "can_do"].includes(f) || f.startsWith("highlights[") || f.startsWith("can_do["));
|
|
140
|
+
|
|
141
|
+
export function foldHighlights(m) {
|
|
142
|
+
const { summary, highlights, can_do, ...out } = m;
|
|
143
|
+
if (out.can_help_with == null) { const lines = [...(summary ? [summary] : []), ...(highlights || []).map((it) => "- " + (typeof it === "string" ? it : it.text))]; if (lines.length) out.can_help_with = cpSlice(lines.join("\n"), 2000); }
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
const unwrapList = (v, key) => { if (isObj(v)) { v = isObj(v.result) ? v.result : v; if (isObj(v)) v = v[key]; } return Array.isArray(v) ? v : null; };
|
|
147
|
+
const trimStr = (s, mx, field, dropped) => { s = s.replace(CTRL_RE, ""); if (cpLen(s) > mx) { dropped.push({ field, reason: `trimmed to ${mx} chars` }); s = cpSlice(s, mx); } return s; };
|
|
148
|
+
export const MANIFEST_FIT_STEPS = [160, 80, 40, 0];
|
|
149
|
+
const SENT_END_RE = /[.!?](?=[ \t\n\r]|$)/, TAIL_RE = /[ \t\n\r,;:-]+$/;
|
|
150
|
+
|
|
151
|
+
export function shortDescription(s, mx = 160) {
|
|
152
|
+
s = s.replace(EDGE_WS_RE, "").replace(/[ \t\n\r]+/g, " "); const m = SENT_END_RE.exec(s); if (m) s = s.slice(0, m.index + 1);
|
|
153
|
+
const a = [...s]; if (a.length <= mx) return s;
|
|
154
|
+
const c = a.slice(0, mx - 1), i = c.lastIndexOf(" "); if (i >= Math.floor(mx / 2)) c.length = i;
|
|
155
|
+
return c.join("").replace(TAIL_RE, "") + "…";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const SERVICE_TOKENS = [["GitHub", "github"], ["GitLab", "gitlab"], ["Cloudflare", "cloudflare wrangler"], ["Gmail", "gmail"], ["Google Calendar", "gcal calendar"], ["Google Drive", "gdrive drive"], ["Google Sheets", "gsheets sheets"], ["Notion", "notion"], ["Slack", "slack"], ["Discord", "discord"], ["Telegram", "telegram"],
|
|
159
|
+
["Stripe", "stripe"], ["Linear", "linear"], ["Jira", "jira"], ["X", "x twitter tweet tweets"], ["Figma", "figma"], ["Vercel", "vercel"], ["AWS", "aws s3 ec2 lambda dynamodb"], ["ElevenLabs", "elevenlabs"], ["Grafana", "grafana"], ["Sentry", "sentry"], ["Airtable", "airtable"], ["HubSpot", "hubspot"],
|
|
160
|
+
["Shopify", "shopify"], ["Asana", "asana"], ["Trello", "trello"], ["Dropbox", "dropbox"], ["Zoom", "zoom"], ["Twilio", "twilio"], ["Supabase", "supabase"], ["Docker", "docker"], ["Postgres", "postgres postgresql"]];
|
|
161
|
+
const TOKEN_SERVICE = Object.fromEntries(SERVICE_TOKENS.flatMap(([n, t]) => t.split(" ").map((k) => [k, n])));
|
|
162
|
+
export const RELAYS_SERVICE = "relays.bot", RELAYS_LINE = "Messages bots and works on projects in relays.bot", HIGHLIGHT_LINES_MAX = 8;
|
|
163
|
+
const VERBS_READ = new Set("get list search read fetch query find view show lookup check describe browse".split(" ")), VERBS_SEND = new Set("send post reply comment".split(" ")),
|
|
164
|
+
VERBS_WRITE = new Set("create update send post add edit delete set write remove reply comment upload merge push close patch put submit move rename".split(" ")), VERBS_DEPLOY = new Set(["deploy", "deploys"]);
|
|
165
|
+
const nameTokens = (n) => n.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
|
166
|
+
function serviceLine(name, v) {
|
|
167
|
+
if (name === RELAYS_SERVICE) return RELAYS_LINE;
|
|
168
|
+
const parts = [v.read && "reads", v.write && "writes", v.deploy && "deploys"].filter(Boolean), joined = parts.length > 1 ? parts.slice(0, -1).join(", ") + " and " + parts[parts.length - 1] : parts[0];
|
|
169
|
+
const line = !parts.length ? `uses ${name}` : v.deploy ? `${joined} to ${name}` : v.write && !v.read ? `${v.send ? "sends" : "writes"} to ${name}` : `${joined} ${name}`;
|
|
170
|
+
return line[0].toUpperCase() + line.slice(1);
|
|
171
|
+
}
|
|
172
|
+
const joinNames = (a) => a.length > 1 ? a.slice(0, -1).join(", ") + " and " + a[a.length - 1] : a[0];
|
|
173
|
+
|
|
174
|
+
export function summarizeTools(tools) {
|
|
175
|
+
const lst = Array.isArray(tools) ? tools : unwrapList(tools, "tools") || [], groups = new Map();
|
|
176
|
+
for (const t of lst) {
|
|
177
|
+
const name = typeof t === "string" ? t : isObj(t) && typeof t.name === "string" ? t.name : null; if (!name) continue;
|
|
178
|
+
const toks = nameTokens(name), svc = toks.map((k) => TOKEN_SERVICE[k]).find(Boolean) || RELAYS_SERVICE;
|
|
179
|
+
if (!groups.has(svc)) groups.set(svc, { service: svc, n: 0, read: false, write: false, send: false, deploy: false });
|
|
180
|
+
const g = groups.get(svc); g.n++;
|
|
181
|
+
for (const k of toks) { if (VERBS_READ.has(k)) g.read = true; if (VERBS_WRITE.has(k)) g.write = true; if (VERBS_SEND.has(k)) g.send = true; if (VERBS_DEPLOY.has(k)) g.deploy = true; }
|
|
182
|
+
}
|
|
183
|
+
let gs = [...groups.values()]; if (!gs.length) return { summary: "", highlights: [] };
|
|
184
|
+
gs = [...gs.filter((g) => g.service !== RELAYS_SERVICE).map((g, i) => [g, i]).sort((a, b) => b[0].n - a[0].n || a[1] - b[1]).map(([g]) => g), ...gs.filter((g) => g.service === RELAYS_SERVICE)];
|
|
185
|
+
let other = 0; if (gs.length > HIGHLIGHT_LINES_MAX) { other = gs.slice(HIGHLIGHT_LINES_MAX - 1).reduce((a, g) => a + g.n, 0); gs = gs.slice(0, HIGHLIGHT_LINES_MAX - 1); }
|
|
186
|
+
const highlights = gs.map((g) => ({ service: g.service, text: serviceLine(g.service, g) }));
|
|
187
|
+
if (other) highlights.push({ text: `Other tools (${other})` });
|
|
188
|
+
return { summary: `This bot works with ${joinNames(gs.map((g) => g.service))}.` + (other ? " It has other tools too." : ""), highlights };
|
|
189
|
+
}
|
|
190
|
+
const cleanStr = (v) => trimWs(v.replace(CTRL_RE, ""));
|
|
191
|
+
function buildSummary(v, dr) {
|
|
192
|
+
if (v == null) return null; if (typeof v !== "string") { dr.push({ field: "summary", reason: "not a string" }); return null; }
|
|
193
|
+
let s = cleanStr(v); if (!s) return null;
|
|
194
|
+
if (cpLen(s) > MANIFEST_SUMMARY_MAX) { dr.push({ field: "summary", reason: `trimmed to ${MANIFEST_SUMMARY_MAX} chars` }); s = trimWs(cpSlice(s, MANIFEST_SUMMARY_MAX)); }
|
|
195
|
+
return s;
|
|
196
|
+
}
|
|
197
|
+
function buildHighlights(v, dr) {
|
|
198
|
+
if (v == null) return null; if (!Array.isArray(v)) { dr.push({ field: "highlights", reason: "not a list" }); return null; }
|
|
199
|
+
const out = [];
|
|
200
|
+
v.forEach((it, i) => {
|
|
201
|
+
const f = `highlights[${i}]`; let text, svc;
|
|
202
|
+
if (typeof it === "string") text = it; else if (isObj(it)) { text = it.text; svc = it.service; } else { dr.push({ field: f, reason: "not a string or object" }); return; }
|
|
203
|
+
text = typeof text === "string" ? cleanStr(text) : ""; if (!text) { dr.push({ field: f, reason: "no text" }); return; }
|
|
204
|
+
if (out.length >= MANIFEST_HIGHLIGHTS_MAX) { dr.push({ field: f, reason: `over the ${MANIFEST_HIGHLIGHTS_MAX} item cap` }); return; }
|
|
205
|
+
if (cpLen(text) > HIGHLIGHT_TEXT_MAX) { dr.push({ field: isObj(it) ? f + ".text" : f, reason: `trimmed to ${HIGHLIGHT_TEXT_MAX} chars` }); text = trimWs(cpSlice(text, HIGHLIGHT_TEXT_MAX)); }
|
|
206
|
+
svc = typeof svc === "string" ? cleanStr(svc) : "";
|
|
207
|
+
if (cpLen(svc) > HIGHLIGHT_SERVICE_MAX) { dr.push({ field: f + ".service", reason: `trimmed to ${HIGHLIGHT_SERVICE_MAX} chars` }); svc = trimWs(cpSlice(svc, HIGHLIGHT_SERVICE_MAX)); }
|
|
208
|
+
out.push(svc ? { service: svc, text } : typeof it === "string" ? text : { text });
|
|
209
|
+
});
|
|
210
|
+
return out.length ? out : null;
|
|
211
|
+
}
|
|
212
|
+
export const MANIFEST_TOOLS_DETAIL = ["auto", "full", "compact", "names", "none"], TOOLS_COMPACT_DESC = 80;
|
|
213
|
+
|
|
214
|
+
export function buildManifest({ tools, resources, connectors, skills, wakeKind, wake_kind, limits, canHelpWith, can_help_with, summary, highlights, can_do, canDo, toolsDetail, tools_detail, autoSummary, auto_summary } = {}) {
|
|
215
|
+
const dr = [], gen = [], m = { schema_version: MANIFEST_VERSION }, td = toolsDetail ?? tools_detail ?? "auto", auto = autoSummary ?? auto_summary ?? true;
|
|
216
|
+
if (!MANIFEST_TOOLS_DETAIL.includes(td)) throw new Error(`tools_detail must be one of ${MANIFEST_TOOLS_DETAIL.join(", ")}`);
|
|
217
|
+
for (const [sect, src] of [["tools", tools], ["resources", resources], ["connectors", connectors], ["skills", skills]]) {
|
|
218
|
+
if (src == null) continue;
|
|
219
|
+
const lst = unwrapList(src, sect);
|
|
220
|
+
if (!lst) { dr.push({ field: sect, reason: "not a list" }); continue; }
|
|
221
|
+
const out = [];
|
|
222
|
+
lst.forEach((it0, i) => {
|
|
223
|
+
const f = `${sect}[${i}]`; let it = it0;
|
|
224
|
+
if (typeof it === "string" && sect === "connectors") it = { name: it };
|
|
225
|
+
if (!isObj(it)) { dr.push({ field: f, reason: "not an object" }); return; }
|
|
226
|
+
const o = {};
|
|
227
|
+
for (const k of ITEM_KEYS[sect]) {
|
|
228
|
+
let v = it[k]; if (v == null) continue;
|
|
229
|
+
if (k === "tags") {
|
|
230
|
+
if (!Array.isArray(v)) continue;
|
|
231
|
+
let tags = v.map((x, j) => [x, j]).filter(([x]) => typeof x === "string" && x.trim()).map(([x, j]) => trimStr(x, 32, `${f}.tags[${j}]`, dr));
|
|
232
|
+
if (tags.length > 10) { dr.push({ field: f + ".tags", reason: "over the 10 tag cap" }); tags = tags.slice(0, 10); }
|
|
233
|
+
if (tags.length) o[k] = tags;
|
|
234
|
+
} else if (typeof v === "string") {
|
|
235
|
+
if (k === "uri" && /[?#]/.test(v)) { dr.push({ field: f + ".uri", reason: "query string / fragment removed" }); v = v.split(/[?#]/, 1)[0]; }
|
|
236
|
+
o[k] = trimStr(v, ITEM_MAX[k], `${f}.${k}`, dr);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (sect === "skills" && !o.name && o.id) o.name = o.id;
|
|
240
|
+
if (!o.name && !(sect === "resources" && o.uri)) { dr.push({ field: f, reason: "no name" }); return; }
|
|
241
|
+
if (sect === "resources" && !o.name) o.name = cpSlice(o.uri, 64);
|
|
242
|
+
if (sect === "tools" && !MANIFEST_TOOL_NAME_RE.test(o.name)) { dr.push({ field: f, reason: "tool name is not [A-Za-z0-9_.-]{1,64}" }); return; }
|
|
243
|
+
out.push(o);
|
|
244
|
+
});
|
|
245
|
+
m[sect] = out;
|
|
246
|
+
}
|
|
247
|
+
const wk = wakeKind ?? wake_kind; if (wk != null) m.wake_adapter = { kind: wk };
|
|
248
|
+
if (limits != null) {
|
|
249
|
+
const lim = typeof limits === "string" ? { text: limits } : isObj(limits) ? limits : {}, lo = {};
|
|
250
|
+
for (const [k, v] of Object.entries(lim)) {
|
|
251
|
+
if (k === "text" && typeof v === "string") lo[k] = trimStr(v, 500, "limits.text", dr);
|
|
252
|
+
else if (MANIFEST_LIMIT_INTS.includes(k) && Number.isInteger(v)) lo[k] = v;
|
|
253
|
+
else dr.push({ field: `limits.${k}`, reason: "not a supported limit" });
|
|
254
|
+
}
|
|
255
|
+
m.limits = lo;
|
|
256
|
+
}
|
|
257
|
+
const ch = canHelpWith ?? can_help_with; if (ch != null) m.can_help_with = trimStr(String(ch), 2000, "can_help_with", dr);
|
|
258
|
+
const hl0 = highlights ?? can_do ?? canDo; if (highlights != null && (can_do ?? canDo) != null) dr.push({ field: "can_do", reason: "ignored: highlights was given" });
|
|
259
|
+
let sum = buildSummary(summary, dr), hl = buildHighlights(hl0, dr);
|
|
260
|
+
if (auto && (sum == null || hl == null) && m.tools?.length) {
|
|
261
|
+
const g = summarizeTools(m.tools);
|
|
262
|
+
if (sum == null && g.summary) { sum = g.summary; gen.push("summary"); }
|
|
263
|
+
if (hl == null && g.highlights.length) { hl = g.highlights; gen.push("highlights"); }
|
|
264
|
+
}
|
|
265
|
+
if (sum != null) m.summary = sum; if (hl != null) m.highlights = hl;
|
|
266
|
+
const detail = td === "auto" ? (sum != null || hl != null ? "compact" : "full") : td;
|
|
267
|
+
if (m.tools) {
|
|
268
|
+
if (detail === "none") delete m.tools;
|
|
269
|
+
else if (detail === "names") for (const o of m.tools) delete o.description;
|
|
270
|
+
else if (detail === "compact") for (const o of m.tools) if (o.description != null) o.description = shortDescription(o.description, TOOLS_COMPACT_DESC);
|
|
271
|
+
}
|
|
272
|
+
for (const mx of MANIFEST_FIT_STEPS) {
|
|
273
|
+
if (jsonBytes(m) <= MANIFEST_MAX_BYTES) break;
|
|
274
|
+
for (const sect of Object.keys(MANIFEST_CAPS)) {
|
|
275
|
+
let n = 0;
|
|
276
|
+
for (const o of m[sect] || []) {
|
|
277
|
+
const d = o.description; if (d == null) continue;
|
|
278
|
+
const nd = mx ? shortDescription(d, mx) : "";
|
|
279
|
+
if (nd !== d) { n++; if (nd) o.description = nd; else delete o.description; }
|
|
280
|
+
}
|
|
281
|
+
if (n) dr.push({ field: `${sect}[].description`, reason: mx ? `${n} shortened to the first sentence, at most ${mx} chars, to fit the 32 KB cap` : `${n} removed to fit the 32 KB cap` });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
while (jsonBytes(m) > MANIFEST_MAX_BYTES) {
|
|
285
|
+
const lists = Object.keys(MANIFEST_CAPS).filter((s) => m[s]?.length); if (!lists.length) break;
|
|
286
|
+
const s = lists.reduce((a, b) => (jsonBytes(m[b]) > jsonBytes(m[a]) ? b : a)); m[s].pop(); dr.push({ field: `${s}[${m[s].length}]`, reason: "dropped to fit the 32 KB cap" });
|
|
287
|
+
}
|
|
288
|
+
const { schema_version, summary: s0, highlights: h0, ...rest } = m, out = { schema_version, ...(s0 != null ? { summary: s0 } : {}), ...(h0 != null ? { highlights: h0 } : {}), ...rest };
|
|
289
|
+
Object.defineProperty(out, "dropped", { value: dr, enumerable: false }); Object.defineProperty(out, "generated", { value: gen, enumerable: false });
|
|
290
|
+
return out;
|
|
291
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
export const JOIN_RATE_LIMIT = 5;
|
|
4
|
+
export const JOIN_WEBHOOK_HELP = "see /protocol: 'How a Grok Bot gets a webhook' (docs/connect-any-bot.md): a public https DNS name on port 443, 8443, 10000, 2053, 2083, 2087 or 2096 that answers the relays.webhook.verify ping";
|
|
5
|
+
export const WEBHOOK_VERIFY_TYPE = "relays.webhook.verify";
|
|
6
|
+
export const WEBHOOK_TEST_TYPE = "relays.webhook.test";
|
|
7
|
+
export const WEBHOOK_VERIFY_HINT = "Your endpoint must answer the relay's relays.webhook.verify POST with 2xx within 5 s and no redirect, echoing {\"challenge\": <the challenge in the body>} (the join-time signing secret is not known to you yet, so answer before checking any signature); see isWebhookVerify / verifyChallengeResponse.";
|
|
8
|
+
export const WEBHOOK_PORTS = ["", "443", "8443", "10000", "2053", "2083", "2087", "2096"];
|
|
9
|
+
const WH_BAD_HOST = /(^|\.)(localhost|local|internal|intranet|lan|home|corp|arpa|test|example|invalid|onion)$|^example\.(com|net|org)$|\.example\.(com|net|org)$/;
|
|
10
|
+
|
|
11
|
+
export function joinWebhookUrlProblem(url) {
|
|
12
|
+
if (typeof url !== "string") return "webhook_url must be a string";
|
|
13
|
+
url = url.trim(); if (url.length > 500) return "webhook_url too long";
|
|
14
|
+
let u; try { u = new URL(url); } catch { return "webhook_url is not a valid URL"; }
|
|
15
|
+
if (u.protocol !== "https:") return "webhook_url must be https";
|
|
16
|
+
if (u.username || u.password) return "webhook_url must not contain credentials";
|
|
17
|
+
if (!WEBHOOK_PORTS.includes(u.port)) return "webhook_url port must be 443, 8443, 10000, 2053, 2083, 2087 or 2096";
|
|
18
|
+
const h = u.hostname.toLowerCase();
|
|
19
|
+
if (h.startsWith("[")) return "webhook_url must not be an IP address";
|
|
20
|
+
const ls = h.split(".");
|
|
21
|
+
if (h.length > 253 || ls.length < 2 || !ls.every((l) => /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(l))) return "webhook_url host must be a public DNS name";
|
|
22
|
+
const tld = ls[ls.length - 1];
|
|
23
|
+
if (!(/^[a-z]{2,63}$/.test(tld) || /^xn--[a-z0-9-]{1,59}$/.test(tld))) return "webhook_url host must be a public DNS name";
|
|
24
|
+
if (WH_BAD_HOST.test(h)) return "webhook_url host is not publicly routable";
|
|
25
|
+
if (/(^|\.)(relays\.bot|cloudflareaccess\.com)$/.test(h)) return "webhook_url must not point at the relay itself";
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const joinWebhookProblem = (url) => (typeof url !== "string" || !url.trim()) ? "webhook_url is required (https URL that wakes your bot, e.g. a Grok Bot webhook routine)" : joinWebhookUrlProblem(url);
|
|
30
|
+
const parseBody = (body) => { if (body && typeof body === "object" && !(body instanceof Uint8Array)) return body; try { const d = JSON.parse(typeof body === "string" ? body : new TextDecoder().decode(body)); return d && typeof d === "object" && !Array.isArray(d) ? d : null; } catch { return null; } };
|
|
31
|
+
|
|
32
|
+
export const isWebhookVerify = (body) => parseBody(body)?.type === WEBHOOK_VERIFY_TYPE;
|
|
33
|
+
|
|
34
|
+
export const isWebhookTest = (body) => { const d = parseBody(body); return !!d && (d.type === WEBHOOK_TEST_TYPE || (d.type === undefined && d.event === "webhook.test")); };
|
|
35
|
+
|
|
36
|
+
export const isWebhookPing = (body) => isWebhookVerify(body) || isWebhookTest(body);
|
|
37
|
+
const hdr = (headers, name) => { if (!headers) return undefined; if (typeof headers.get === "function") return headers.get(name) ?? undefined; const k = Object.keys(headers).find((x) => x.toLowerCase() === name.toLowerCase()); const v = k === undefined ? undefined : headers[k]; return Array.isArray(v) ? v[0] : v; };
|
|
38
|
+
export const headerOf = hdr;
|
|
39
|
+
|
|
40
|
+
export function pickChallenge(body, headers) {
|
|
41
|
+
const bc = parseBody(body)?.challenge, hc = hdr(headers, "X-Relay-Challenge"), c = bc ?? hc;
|
|
42
|
+
return typeof c === "string" && c.length > 0 && c.length <= 256 && !(bc != null && hc != null && bc !== hc) ? c : null;
|
|
43
|
+
}
|