From 1511a1a505e1805b4e0b12a0e8cec057d8897ce2 Mon Sep 17 00:00:00 2001 From: zocomputer Date: Wed, 23 Sep 2026 23:56:42 +0000 Subject: [PATCH 1/2] feat: give Data a Discord channel Data's Discord channel is the thin socket that lets people in Discord talk to Data. It holds the Gateway connection as the Data application, and forwards each admitted mention to Data's own HTTP ingress (`POST http://127.0.0.1:8788/ask`) instead of calling a model itself, so Data's brain and session memory stay in one place and this process stays a transport. Admission is fail-closed and mirrors Computer's policy: mention the bot, in an allowlisted guild, from an author holding an allowlisted role or on the owner list. It answers nothing else, and never DMs. Also: - `scripts/zo-deploy.ts` gains `--expect-ready`, so one deploy script can wait for each service's own readiness line, and knows both Data lines. - `.github/workflows/deploy.yml` deploys both services in sequence inside one job, because both fast-forward the same live checkout. - `services/discord.md` records the service and the two prerequisites that live outside this repository. --- .github/workflows/deploy.yml | 26 +- channels/discord/README.md | 59 +++++ channels/discord/index.ts | 478 +++++++++++++++++++++++++++++++++++ scripts/zo-deploy.ts | 8 +- services/discord.md | 56 ++++ 5 files changed, 620 insertions(+), 7 deletions(-) create mode 100644 channels/discord/README.md create mode 100644 channels/discord/index.ts create mode 100644 services/discord.md diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e46d5df..fd73f4f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,10 +1,13 @@ name: deploy # Data deploys itself. This repository is the live checkout on the Zo host, so a -# push to main only has to fast-forward that checkout and restart the http -# service. scripts/zo-deploy.ts does both through Zo's MCP endpoint, speaking -# JSON-RPC directly, then proves the restart landed by waiting for the service's -# own readiness line in the service log. +# push to main only has to fast-forward that checkout and restart each of Data's +# services. scripts/zo-deploy.ts does both through Zo's MCP endpoint, speaking +# JSON-RPC directly, then proves each restart landed by waiting for that +# service's own readiness line in its log. +# +# The two services deploy in sequence, inside one job, on purpose: both fast-forward +# the same live checkout, so they must never overlap. # # Needs the repository secret ZO_API_KEY: a Zo access token from # Settings > Advanced > Access Tokens. Without it the job warns and stops; @@ -15,6 +18,7 @@ on: branches: [main] paths: - "channels/http/**" + - "channels/discord/**" - "lib/**" - "scripts/**" - "package.json" @@ -49,12 +53,24 @@ jobs: with: node-version: 24 - - name: Deploy the live Zo service + - name: Deploy data-http if: steps.credential.outputs.configured == 'true' env: ZO_API_KEY: ${{ secrets.ZO_API_KEY }} run: | node --experimental-strip-types scripts/zo-deploy.ts \ --service data-http \ + --expect-ready "ready: data-http" \ + --dir /home/workspace/users/etok/workspaces/wazootech/repos/data \ + --expect-sha "${GITHUB_SHA}" + + - name: Deploy data-discord + if: steps.credential.outputs.configured == 'true' + env: + ZO_API_KEY: ${{ secrets.ZO_API_KEY }} + run: | + node --experimental-strip-types scripts/zo-deploy.ts \ + --service data-discord \ + --expect-ready "ready: data-discord" \ --dir /home/workspace/users/etok/workspaces/wazootech/repos/data \ --expect-sha "${GITHUB_SHA}" diff --git a/channels/discord/README.md b/channels/discord/README.md new file mode 100644 index 0000000..3610cf5 --- /dev/null +++ b/channels/discord/README.md @@ -0,0 +1,59 @@ +# data-discord + +Data's Discord channel. One process, no dependencies: it holds the Gateway socket as the +Data application, and routes an admitted `@Data` mention into Data's own HTTP ingress on +the same host. + +The bridge is a transport adapter, not a second brain. It normalizes the Discord event and +hands it to `data-http` (`POST /ask`), which is where question routing, the persona, and +session memory already live. That keeps one ingress shape and one place where Data's +identity is resolved, and it means the bridge needs no model credential of its own. + +## What it admits + +Fail-closed, and every gate must pass: + +| Gate | Rule | +| --- | --- | +| Transport | a guild event with a `guild_id` | +| Guild | the guild is in `DATA_DISCORD_GUILD_IDS` | +| Channel | `DATA_DISCORD_CHANNEL_IDS` is empty (any channel in the guild) or contains the channel | +| Author | the author is in `DATA_DISCORD_OWNER_IDS`, or holds a role in `DATA_DISCORD_ROLE_IDS` | +| Content | the message mentions the bot and has text after the mention is stripped | + +Message-author bots, webhook messages, and channel DMs are ignored. An admitted message is +answered once: the message id is remembered so a Gateway `RESUME` replaying events cannot +produce a second reply. + +Data is read-only by design, so this channel only answers questions. It does not moderate, +file, edit, or merge anything. + +## Environment + +| Variable | Purpose | +| --- | --- | +| `DATA_DISCORD_BOT_TOKEN` | The Data application's bot token. Required. | +| `DATA_DISCORD_APPLICATION_ID` | The Data application id. One of this or the bot token must be set. | +| `DATA_DISCORD_GUILD_IDS` | Comma-separated guild allowlist. Required; empty admits nothing. | +| `DATA_DISCORD_CHANNEL_IDS` | Optional channel allowlist. Empty means every channel in an admitted guild. | +| `DATA_DISCORD_ROLE_IDS` | Comma-separated roles allowed to reach Data. | +| `DATA_DISCORD_OWNER_IDS` | Comma-separated user ids always admitted. | +| `DATA_HTTP_URL` | Data's ingress. Defaults to `http://127.0.0.1:8788`. | +| `DATA_HTTP_TOKEN` | Shared secret sent as `x-data-token`. Set it here when the ingress has one. | +| `DATA_DISCORD_GATEWAY_URL` | Gateway URL override. For tests, not for production. | +| `DATA_DISCORD_API_BASE` | REST base override. For tests, not for production. | + +No Discord identifier is committed to this repository. They arrive through the service +definition, and `DATA_DISCORD_BOT_TOKEN` additionally loads from `/root/.zo_secrets` when +the process starts without it, because managed services do not inherit the host shell. + +The application must have the **Message Content Intent** enabled, or Discord closes the +connection with code 4014 and no mention text ever arrives. The bridge detects that close +code, says so once, and retries on a slow backoff instead of exiting, so the service stays +up while the intent is being enabled. + +## Running it + +```sh +DATA_DISCORD_GUILD_IDS= DATA_DISCORD_ROLE_IDS= bun run ./index.ts +``` diff --git a/channels/discord/index.ts b/channels/discord/index.ts new file mode 100644 index 0000000..868fcca --- /dev/null +++ b/channels/discord/index.ts @@ -0,0 +1,478 @@ +#!/usr/bin/env bun +/** + * Data's Discord channel. + * + * Holds one Gateway socket as the Data application and turns an admitted + * mention into a question for Data's own HTTP surface, then posts the answer + * back into the channel. The bridge owns transport only: admission, the reply + * limit, and reconnects live here, while the persona, the conversation memory, + * and the answer itself stay behind `POST /ask` on `channels/http`. + * + * That division keeps Data's brain in one place. A second channel adds a way to + * reach Data; it does not add a second way to think. + * + * Admission is default-deny: the guild must be allowlisted, the author must not + * be a bot, the message must mention the bot, and the author must hold an + * allowlisted role. Nothing else dispatches. + * + * Requires the application's Message Content intent, which is what makes a + * mention's text readable at all. Discord closes an unsupported IDENTIFY with + * 4014; the bridge reports the exact fix instead of hot-looping. + */ +import { existsSync, readFileSync } from "node:fs"; + +// Managed services start from a bare environment; Zo secrets live in +// /root/.zo_secrets (sourced by interactive shells). Load it when the bot token +// is absent so the bridge behaves the same under supervisord and from a shell. +function loadZoSecrets(): void { + if (process.env.DATA_DISCORD_BOT_TOKEN) return; + const file = "/root/.zo_secrets"; + if (!existsSync(file)) return; + let loaded = 0; + for (const raw of readFileSync(file, "utf8").split("\n")) { + const line = raw.trim(); + if (!line.startsWith("export ")) continue; + const eq = line.indexOf("="); + if (eq < 0) continue; + const key = line.slice("export ".length, eq).trim(); + let value = line.slice(eq + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + if (!key || process.env[key]) continue; + process.env[key] = value; + loaded += 1; + } + if (loaded > 0) log(`secrets: loaded ${String(loaded)} var(s) from ${file}`); +} + +function log(...parts: unknown[]): void { + console.log(new Date().toISOString(), ...parts); +} + +function readEnv(name: string, fallback = ""): string { + return (process.env[name] ?? fallback).trim(); +} + +function readIdList(name: string, fallback = ""): readonly string[] { + return readEnv(name, fallback) + .split(",") + .map((value) => value.trim()) + .filter((value) => value.length > 0); +} + +loadZoSecrets(); + +const BOT_TOKEN = readEnv("DATA_DISCORD_BOT_TOKEN"); +const HTTP_URL = readEnv("DATA_HTTP_URL", "http://127.0.0.1:8788").replace(/\/+$/u, ""); +const HTTP_TOKEN = readEnv("DATA_HTTP_TOKEN"); +const GUILD_IDS = readIdList("DATA_DISCORD_GUILD_IDS", ""); +const ROLE_IDS = readIdList("DATA_DISCORD_ROLE_IDS", ""); +const OWNER_IDS = readIdList("DATA_DISCORD_OWNER_IDS"); +const CHANNEL_IDS = readIdList("DATA_DISCORD_CHANNEL_IDS"); + +const GATEWAY_URL = (process.env.DATA_DISCORD_GATEWAY_URL ?? "wss://gateway.discord.gg/?v=10&encoding=json").trim(); +const REST = (process.env.DATA_DISCORD_API_BASE ?? "https://discord.com/api/v10").replace(/\/$/u, ""); +const API_VERSION = 10; + +// GUILDS | GUILD_MESSAGES | MESSAGE_CONTENT. MESSAGE_CONTENT is privileged and +// must be enabled on the application, or Discord refuses the connection. +const INTENTS = (1 << 0) | (1 << 9) | (1 << 15); + +const MAX_REPLY_CHARS = 1900; +const MAX_REPLY_PARTS = 8; +const MIN_BACKOFF_MS = 1_000; +const MAX_BACKOFF_MS = 5 * 60_000; +const DISALLOWED_INTENTS_DELAY_MS = 5 * 60_000; +const TYPING_REFRESH_MS = 8_000; + +const OP = { + dispatch: 0, + heartbeat: 1, + identify: 2, + resume: 6, + reconnect: 7, + invalidSession: 9, + hello: 10, + heartbeatAck: 11, +} as const; + +interface GatewaySocket { + onopen: ((event: unknown) => void) | null; + onmessage: ((event: { data: unknown }) => void) | null; + onclose: ((event: { code: number; reason: string }) => void) | null; + onerror: ((event: unknown) => void) | null; + readyState: number; + send(data: string): void; + close(code?: number, reason?: string): void; +} + +type SocketConstructor = new (url: string) => GatewaySocket; + +interface GatewayFrame { + readonly op: number; + readonly t?: string | null; + readonly s?: number | null; + readonly d?: unknown; +} + +interface DiscordUser { + readonly id: string; + readonly username?: string; + readonly global_name?: string | null; + readonly bot?: boolean; + readonly discriminator?: string; +} + +interface DiscordMessage { + readonly id: string; + readonly channel_id: string; + readonly guild_id?: string; + readonly content?: string; + readonly author?: DiscordUser; + readonly member?: { readonly roles?: readonly string[] }; + readonly webhook_id?: string; +} + +interface ReadyPayload { + readonly session_id?: string; + readonly resume_gateway_url?: string; + readonly user?: DiscordUser; + readonly guilds?: readonly { readonly id: string }[]; +} + +const answered = new Set(); +const ANSWERED_LIMIT = 500; + +function rememberAnswered(id: string): boolean { + if (answered.has(id)) return false; + answered.add(id); + if (answered.size > ANSWERED_LIMIT) { + const oldest = answered.values().next().value; + if (oldest !== undefined) answered.delete(oldest); + } + return true; +} + +function chunkReply(text: string, size = MAX_REPLY_CHARS): readonly string[] { + const clean = text.trim(); + if (clean.length === 0) return []; + const parts: string[] = []; + let rest = clean; + while (rest.length > size) { + let cut = rest.lastIndexOf("\n", size); + if (cut < size * 0.5) cut = rest.lastIndexOf(" ", size); + if (cut < size * 0.5) cut = size; + parts.push(rest.slice(0, cut).trimEnd()); + rest = rest.slice(cut).trimStart(); + } + if (rest.length > 0) parts.push(rest); + return parts.slice(0, MAX_REPLY_PARTS); +} + +async function discordRequest( + method: "POST" | "GET", + path: string, + body?: unknown, +): Promise { + const response = await fetch(`${REST}${path}`, { + method, + headers: { + authorization: `Bot ${BOT_TOKEN}`, + "content-type": "application/json", + "user-agent": "DataBridge (https://github.com/wazootech/data, 1.0.0)", + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + if (!response.ok && response.status !== 204) { + const text = await response.text(); + throw new Error(`discord ${method} ${path} -> ${String(response.status)} ${text.slice(0, 200)}`); + } + if (response.status === 204) return null; + return (await response.json()) as unknown; +} + +async function askData(question: string, session: string): Promise { + const response = await fetch(`${HTTP_URL}/ask`, { + method: "POST", + headers: { + "content-type": "application/json", + ...(HTTP_TOKEN.length === 0 ? {} : { "x-data-token": HTTP_TOKEN }), + }, + body: JSON.stringify({ question, session }), + }); + const text = await response.text(); + if (!response.ok) { + throw new Error(`data http /ask -> ${String(response.status)} ${text.slice(0, 200)}`); + } + const parsed = JSON.parse(text) as { answer?: string }; + return parsed.answer ?? ""; +} + +function keepTyping(channelId: string): () => void { + const beat = (): void => { + void discordRequest("POST", `/channels/${channelId}/typing`).catch(() => undefined); + }; + beat(); + const timer = setInterval(beat, TYPING_REFRESH_MS); + return () => { + clearInterval(timer); + }; +} + +/** True when the message is an admitted mention that should reach Data. */ +function admission(message: DiscordMessage): { readonly question: string } | null { + const author = message.author; + if (author === undefined) return null; + if (author.bot === true) return null; + if (message.webhook_id !== undefined) return null; + + const guildId = message.guild_id ?? ""; + if (guildId.length === 0) return null; + if (!GUILD_IDS.includes(guildId)) return null; + if (CHANNEL_IDS.length > 0 && !CHANNEL_IDS.includes(message.channel_id)) return null; + + const isOwner = OWNER_IDS.includes(author.id); + const roles = message.member?.roles ?? []; + const holdsRole = roles.some((role) => ROLE_IDS.includes(role)); + if (!isOwner && !holdsRole) return null; + + const content = message.content ?? ""; + const mention = new RegExp(`<@!?${BOT_USER_ID}>`, "gu"); + if (!mention.test(content)) return null; + + const question = content.replace(mention, " ").replace(/\s+/gu, " ").trim(); + if (question.length === 0) return null; + return { question }; +} + +let BOT_USER_ID = ""; + +async function handleMention(message: DiscordMessage): Promise { + const admitted = admission(message); + if (admitted === null) return; + if (!rememberAnswered(message.id)) return; + + const stopTyping = keepTyping(message.channel_id); + try { + const answer = await askData(admitted.question, `discord:${message.channel_id}`); + const parts = chunkReply(answer.length === 0 ? "(Data returned an empty answer.)" : answer); + for (const [index, part] of parts.entries()) { + await discordRequest("POST", `/channels/${message.channel_id}/messages`, { + content: part, + ...(index === 0 + ? { + message_reference: { + message_id: message.id, + channel_id: message.channel_id, + ...(message.guild_id === undefined ? {} : { guild_id: message.guild_id }), + fail_if_not_exists: false, + }, + } + : {}), + }); + } + log(`answered ${message.id} in ${message.channel_id} (${String(answer.length)} chars, ${String(parts.length)} part(s))`); + } catch (error) { + log(`failed to answer ${message.id}: ${error instanceof Error ? error.message : String(error)}`); + } finally { + stopTyping(); + } +} + +export function run(): void { + const discovered = (globalThis as { WebSocket?: SocketConstructor }).WebSocket; + if (discovered === undefined) { + log("no global WebSocket in this runtime; Data's Discord channel needs Bun 1.1+ or Node 22+"); + process.exit(1); + } + const Socket: SocketConstructor = discovered; + if (BOT_TOKEN.length === 0) { + log("DATA_DISCORD_BOT_TOKEN is unset; Data's Discord channel cannot start"); + process.exit(1); + } + + let sessionId: string | null = null; + let sequence: number | null = null; + let gatewayUrl = GATEWAY_URL; + let heartbeat: ReturnType | null = null; + let acked = true; + let attempts = 0; + let lastErrorAt = 0; + + const stopHeartbeat = (): void => { + if (heartbeat !== null) clearInterval(heartbeat); + heartbeat = null; + }; + + const send = (socket: GatewaySocket, op: number, d: unknown): void => { + socket.send(JSON.stringify({ op, d })); + }; + + const reconnect = (delayMs: number, why: string): void => { + stopHeartbeat(); + log(`reconnecting in ${String(Math.round(delayMs / 1000))}s: ${why}`); + setTimeout(connect, delayMs); + }; + + const identify = (socket: GatewaySocket): void => { + send(socket, OP.identify, { + token: BOT_TOKEN, + intents: INTENTS, + properties: { os: process.platform, browser: "data-bridge", device: "data-bridge" }, + }); + }; + + const startHeartbeat = (socket: GatewaySocket, intervalMs: number): void => { + stopHeartbeat(); + acked = true; + heartbeat = setInterval(() => { + if (!acked) { + stopHeartbeat(); + socket.close(4000, "heartbeat not acknowledged"); + return; + } + acked = false; + send(socket, OP.heartbeat, sequence); + }, intervalMs); + }; + + function connect(): void { + const socket = new Socket(gatewayUrl); + + socket.onopen = () => { + log(`gateway socket open (${gatewayUrl === GATEWAY_URL ? "fresh" : "resume"})`); + }; + + socket.onmessage = (event) => { + let frame: GatewayFrame; + try { + frame = JSON.parse(String(event.data)) as GatewayFrame; + } catch { + return; + } + if (typeof frame.s === "number") sequence = frame.s; + + if (frame.op === OP.hello) { + const payload = frame.d as { heartbeat_interval?: number } | null; + const interval = payload?.heartbeat_interval ?? 41_250; + if (sessionId === null) identify(socket); + else send(socket, OP.resume, { token: BOT_TOKEN, session_id: sessionId, seq: sequence }); + startHeartbeat(socket, interval); + return; + } + if (frame.op === OP.heartbeatAck) { + acked = true; + return; + } + if (frame.op === OP.heartbeat) { + send(socket, OP.heartbeat, sequence); + return; + } + if (frame.op === OP.reconnect) { + stopHeartbeat(); + socket.close(4001, "gateway asked for a reconnect"); + return; + } + if (frame.op === OP.invalidSession) { + const resumable = frame.d === true; + if (!resumable) { + sessionId = null; + sequence = null; + gatewayUrl = GATEWAY_URL; + } + stopHeartbeat(); + socket.close(4002, "invalid session"); + return; + } + if (frame.op !== OP.dispatch) return; + + if (frame.t === "READY") { + const payload = frame.d as ReadyPayload | null; + sessionId = payload?.session_id ?? null; + if (typeof payload?.resume_gateway_url === "string") gatewayUrl = payload.resume_gateway_url; + BOT_USER_ID = payload?.user?.id ?? BOT_USER_ID; + attempts = 0; + const tag = payload?.user?.username ?? "(unknown)"; + const guilds = payload?.guilds?.map((guild) => guild.id) ?? []; + log(`ready: data-discord ${tag}#${BOT_USER_ID} guilds=[${guilds.join(",")}]`); + if (guilds.length === 0) { + log( + "READY reports no guilds: invite the Data application to the server with the bot scope; mentions cannot arrive until it is a member", + ); + } + return; + } + if (frame.t === "RESUMED") { + attempts = 0; + log("session resumed"); + return; + } + if (frame.t === "MESSAGE_CREATE") { + const message = frame.d as DiscordMessage | null; + if (message !== null && typeof message.id === "string") void handleMention(message); + } + }; + + socket.onerror = (event) => { + const now = Date.now(); + if (now - lastErrorAt > 30_000) { + lastErrorAt = now; + log(`gateway socket error: ${String(event)}`); + } + }; + + socket.onclose = (event) => { + stopHeartbeat(); + const code = event.code; + const reason = event.reason.length > 0 ? ` (${event.reason})` : ""; + if (code === 4014) { + log( + "gateway refused the connection with 4014 (disallowed intents): enable Message Content Intent for the Data application (Discord Developer Portal -> Data -> Bot -> Privileged Gateway Intents), then this process connects on its next attempt.", + ); + reconnect(DISALLOWED_INTENTS_DELAY_MS, `4014${reason}`); + return; + } + if (code === 4004) { + log("gateway refused the connection with 4004 (authentication failed): DATA_DISCORD_BOT_TOKEN is wrong or rotated"); + reconnect(MAX_BACKOFF_MS, `4004${reason}`); + return; + } + if (code === 4013) { + log("gateway refused the connection with 4013 (invalid intents): the requested intent bits are not valid for this application"); + reconnect(MAX_BACKOFF_MS, `4013${reason}`); + return; + } + if (code === 4010 || code === 4011) { + log(`gateway closed with ${String(code)}${reason}: sharding must not be changed while resuming`); + sessionId = null; + sequence = null; + gatewayUrl = GATEWAY_URL; + reconnect(MIN_BACKOFF_MS, `close ${String(code)}`); + return; + } + if (code === 4007 || code === 4008 || code === 4009) { + sessionId = null; + sequence = null; + gatewayUrl = GATEWAY_URL; + } + attempts += 1; + const backoff = Math.min(MAX_BACKOFF_MS, MIN_BACKOFF_MS * 2 ** (attempts - 1)); + const jittered = backoff / 2 + Math.random() * (backoff / 2); + reconnect(jittered, `close ${String(code)}${reason}`); + }; + } + + for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.on(signal, () => { + log(`stopping on ${signal}`); + stopHeartbeat(); + process.exit(0); + }); + } + + log(`bridge starting: route=${HTTP_URL}/ask guilds=[${GUILD_IDS.join(",")}] roles=[${ROLE_IDS.join(",")}]`); + connect(); +} + +if (import.meta.main) run(); diff --git a/scripts/zo-deploy.ts b/scripts/zo-deploy.ts index 00fca72..8d03d67 100644 --- a/scripts/zo-deploy.ts +++ b/scripts/zo-deploy.ts @@ -32,7 +32,7 @@ const DEFAULT_MCP_URL = "https://api.zo.computer/mcp"; const DEFAULT_BRIDGE_DIRECTORY = "/home/workspace/users/etok/workspaces/wazootech/repos/data"; const DEFAULT_BRANCH = "main"; const DEFAULT_TIMEOUT_SECONDS = 90; -const READY_MARKERS = ["ready: data-http"] as const; +const READY_MARKERS = ["ready: data-http", "ready: data-discord"] as const; const POLL_INTERVAL_MS = 3_000; interface Options { @@ -40,6 +40,7 @@ interface Options { readonly directory: string; readonly branch: string; readonly expectSha: string | null; + readonly expectReady: string | null; readonly timeoutSeconds: number; readonly dryRun: boolean; readonly apiKey: string; @@ -61,6 +62,7 @@ function readOptions(): Options { ` --dir live checkout on the Zo host (default ${DEFAULT_BRIDGE_DIRECTORY})`, ` --branch branch to deploy (default ${DEFAULT_BRANCH})`, " --expect-sha revision this deploy must land on (usually the pushed commit)", + " --expect-ready readiness line the service must log (default: any known channel)", ` --timeout readiness deadline (default ${DEFAULT_TIMEOUT_SECONDS})`, " --dry-run resolve the service and report, restart nothing", "", @@ -81,6 +83,7 @@ function readOptions(): Options { directory: option("dir") ?? DEFAULT_BRIDGE_DIRECTORY, branch: option("branch") ?? DEFAULT_BRANCH, expectSha: option("expect-sha") ?? null, + expectReady: option("expect-ready") ?? null, timeoutSeconds: Number.isFinite(timeout) && timeout > 0 ? timeout : DEFAULT_TIMEOUT_SECONDS, dryRun: process.argv.includes("--dry-run"), apiKey, @@ -245,7 +248,8 @@ async function awaitReadiness( const status = parseZoServiceStatus(report.text, service.label); if (status !== null) { last = status; - const ready = READY_MARKERS.some((marker) => status.logs.includes(marker)); + const markers = options.expectReady === null ? READY_MARKERS : [options.expectReady]; + const ready = markers.some((marker) => status.logs.includes(marker)); const restarted = status.uptimeSeconds === null || status.uptimeSeconds < options.timeoutSeconds; if (status.state === "RUNNING" && ready && restarted) return status; } diff --git a/services/discord.md b/services/discord.md new file mode 100644 index 0000000..9d15f50 --- /dev/null +++ b/services/discord.md @@ -0,0 +1,56 @@ +# Service: data-discord + +Data's Discord channel: the thin socket that lets people in Discord talk to Data. It +holds the Gateway connection as the Data application and forwards each admitted mention +to Data's own HTTP ingress, so the brain stays in one place. + +| Field | Value | +| --- | --- | +| Zo service ID | `svc_X1tR9cq2PLm` | +| Label | `data-discord` | +| Mode | `process` (no network endpoint) | +| Entrypoint | `bun run ./index.ts` | +| Working directory | `/home/workspace/users/etok/workspaces/wazootech/repos/data/channels/discord` | +| Forwards to | `http://127.0.0.1:8788/ask` (the `data-http` service) | +| Logs | `/dev/shm/data-discord.log`, `/dev/shm/data-discord_err.log` | +| Source | `channels/discord/index.ts` in this repository | + +## Environment variable names + +`DATA_DISCORD_BOT_TOKEN`, `DATA_DISCORD_APPLICATION_ID`, `DATA_DISCORD_GUILD_IDS`, +`DATA_DISCORD_ROLE_IDS`; optionally `DATA_DISCORD_OWNER_IDS`, `DATA_DISCORD_CHANNEL_IDS`, +`DATA_DISCORD_HTTP_URL`, `DATA_HTTP_TOKEN`, `DATA_DISCORD_BOT_USER_ID`. Values are never +recorded here, and the identifiers (guild, channel, role, owner, application, bot user) +are deliberately absent from this public repository — they live in the service +definition and in `/root/.zo_secrets`. + +## Two prerequisites that live outside this repository + +1. **Message Content** must be enabled for the Data application + (Developer Portal → Bot → Privileged Gateway Intents). Without it Discord rejects the + gateway connection with close code `4014`, and no mention carries readable text. +2. The application must be installed in the server with the `bot` and + `applications.commands` scopes. + +The bridge logs a specific line for each: on a `4014` close it prints +"enable Message Content Intent for the Data application ... then this process connects on +its next attempt" and retries every five minutes instead of hot-looping; when READY +reports `guilds=[]` it prints "READY reports no guilds: invite the Data application to the +server with the bot scope". + +## Recreation + +```sh +# register once, then let .github/workflows/deploy.yml keep it current +# mode process, workdir as above +DATA_DISCORD_BOT_TOKEN= DATA_DISCORD_APPLICATION_ID= \ +DATA_DISCORD_GUILD_IDS= DATA_DISCORD_ROLE_IDS= \ +bun run ./index.ts +``` + +## Verification + +- 2026-09-23 — the mention → `data-http` → reply loop was proven against a stub Gateway, + a stub Discord REST API, and a stub `data-http`: the bridge identified, read a mention + from a role-holding author, asked the ingress with `session=discord:`, posted + the answer back as a reply, and showed a typing indicator while it worked. From cb86125b2f649ba81296cffd9b0d6328e52543aa Mon Sep 17 00:00:00 2001 From: zocomputer Date: Wed, 23 Sep 2026 23:57:05 +0000 Subject: [PATCH 2/2] docs: name both of Data's channels in the guide and the README --- AGENTS.md | 3 +++ README.md | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 9db327a..52c80ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,9 @@ the runtime, not in this checkout. - `agent/instructions.md` — Data's prompt. The source of truth; the live Zo persona is built from it. Change it here first. - `channels/http/` — the `data-http` service: `GET /health`, `POST /ask`. +- `channels/discord/` — the `data-discord` service: a thin Gateway socket that forwards + admitted mentions into `channels/http/`. It carries no prompt and calls no model; it is + transport, so Data keeps one brain. - `services/`, `automations/` — durable records of the processes and schedules that make Data operational. Records name environment variables, never their values. - `knowledge/`, `skills/` — durable knowledge and procedures. diff --git a/README.md b/README.md index fc1c607..cd9dff8 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ here is Data's conversations and memory: those belong to the runtime. | --- | --- | | `agent/instructions.md` | Data's prompt. The source of truth for who Data is; the live Zo persona is built from it. | | `channels/http/` | Data's live surface: `GET /health` and `POST /ask`, deployed as the `data-http` Zo service. | +| `channels/discord/` | Data's Discord channel: a thin Gateway socket that forwards admitted mentions into `channels/http/`, deployed as the `data-discord` Zo service. | | `services/` | Durable records of the long-running processes that make Data reachable. | | `knowledge/` | Durable, verified knowledge: how a subsystem behaves, what a reproduction showed. | | `skills/` | Procedures Data follows for a recurring kind of investigation. | @@ -37,7 +38,10 @@ A category directory is created when the first piece in that category lands. boundary is structural rather than a matter of instruction. - **Surface.** The `data-http` service in `channels/http/` routes questions into that persona and returns the answer. `GET /health` reports readiness; `POST /ask` takes - `{ question, session? }`. + `{ question, session? }`. The `data-discord` service in `channels/discord/` is the human + front door: it holds the Gateway socket, and forwards each admitted mention to + `POST /ask` with `session=discord:`, so both channels share one brain and one + thread of memory. - **Deploy.** Pushing to `main` runs `.github/workflows/deploy.yml`, which fast-forwards the live checkout on the Zo host and restarts the service through Zo's MCP endpoint, then waits for the service's own readiness line. This is the same shape as Goop's