@loomcycle/bridge-discord 0.0.0-stage → 0.1.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/README.md CHANGED
@@ -1,3 +1,148 @@
1
- # Temporary Holding Version
1
+ # @loomcycle/bridge-discord
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A Discord bot for a [loomcycle](https://github.com/denn-gubsky/loomcycle) interactive agent. People talk to the agent with slash commands. In a DM each person is their own loomcycle user, with their own runs, memory, history, budget and rate limit; a server channel is a loomcycle user of its own.
4
+
5
+ - **Slash commands:** `/ask prompt:<text>` talks to the agent, `/new` starts over, `/stop` stops the current answer, `/help` lists them.
6
+ - **Steering:** an `/ask` sent while the agent is working reaches it mid-turn.
7
+ - **Questions and reviews** from the agent arrive as messages with buttons.
8
+ - **Restarts lose nothing:** the next `/ask` continues the conversation, with the agent's full history.
9
+
10
+ It is built on `@loomcycle/bridge-core`, which holds the per-user tokens, conversations and cards every bridge shares. Design: RFC EQ (Bridges) in the loomcycle document store.
11
+
12
+ ## What v1 does not do
13
+
14
+ The bridge takes Discord **interactions over HTTP** only. It does not connect to the Gateway, so it never sees ordinary messages: people use `/ask`, in a server or in a DM. Free-text messages need the Gateway and are a later phase.
15
+
16
+ ## How it works
17
+
18
+ ```
19
+ Discord ──HTTPS POST /discord/interactions──▶ bridge-discord ──private network──▶ loomcycle
20
+ ◀──── interaction webhooks, REST ─────┘
21
+ ```
22
+
23
+ - **Only the bridge is public.** loomcycle stays on your private network.
24
+ - **Every command is answered within Discord's 3 seconds** with a deferred response ("thinking…"). The agent's reply replaces it. A long reply continues in follow-up messages, 2,000 characters each.
25
+ - **Replies travel back through the interaction.** Its token lasts 15 minutes. A turn that ends later is posted with the bot token as an ordinary channel message, which needs the bot in that channel.
26
+ - **One interactive run per conversation:** per DM, and per server channel or thread.
27
+
28
+ ## Setup
29
+
30
+ ### 1. loomcycle
31
+
32
+ Run loomcycle with authentication on and a persistent store. Then:
33
+
34
+ 1. Pick or define the agent the bot runs, for example the `chat` agent from `LOOMCYCLE_PRESETS=base,chat`.
35
+ 2. Mint the bridge's token, a tenant operator in the tenant the Discord users will live in:
36
+
37
+ ```bash
38
+ loomcycle operator-token create --name discord-bridge --tenant acme --subject discord-bridge --scopes substrate:tenant
39
+ ```
40
+
41
+ Or declare it in YAML as a principal (`principals:` with `scopes: [substrate:tenant]` and a `token_env`).
42
+
43
+ ### 2. The Discord application
44
+
45
+ 1. In the [Developer Portal](https://discord.com/developers/applications), create an application.
46
+ 2. **General Information:** copy the **Application ID** (`DISCORD_APPLICATION_ID`) and the **Public Key** (`DISCORD_PUBLIC_KEY`).
47
+ 3. **Bot:** reset and copy the token (`DISCORD_BOT_TOKEN`). The bot needs no privileged intents.
48
+ 4. **Installation:** keep **Guild Install** only. For the install link, add the scopes `bot` and `applications.commands`, and the bot permissions *Send Messages* and *View Channels*. Install it in your server.
49
+ 5. Start the bridge (below), then set **Interactions Endpoint URL** to `https://<bridge-host>/discord/interactions`. Discord checks the URL with a PING when you save it, so the bridge must be running and reachable; it also probes the endpoint with bad signatures from time to time.
50
+
51
+ ### 3. Run the bridge
52
+
53
+ ```bash
54
+ npx @loomcycle/bridge-discord
55
+ ```
56
+
57
+ With `DISCORD_BRIDGE_REGISTER_COMMANDS=1` the bridge registers its four commands when it starts:
58
+
59
+ - **in each allowed server, as that server's commands.** They take effect at once and show only where the bot answers.
60
+ - **globally, limited to the bot's DMs,** when DMs are on. Commands in DMs can only be global, and limiting them to DMs keeps a server from listing them twice.
61
+
62
+ Registration replaces the application's commands in those places. The bridge owns its application's commands; use a separate application for anything else. It does not remove global commands when you turn DMs off later.
63
+
64
+ ### Configuration
65
+
66
+ The shared settings, as every bridge reads them:
67
+
68
+ | Variable | Meaning | Default |
69
+ |---|---|---|
70
+ | `LOOMCYCLE_BASE_URL` | loomcycle's private URL | required |
71
+ | `LOOMCYCLE_TOKEN` | The bridge's `substrate:tenant` token | required |
72
+ | `BRIDGE_AGENT` | The loomcycle agent every conversation runs | required |
73
+ | `BRIDGE_TOKEN_TTL_SECONDS` | Lifetime of a user's token | `43200` |
74
+ | `BRIDGE_RATE_PER_MINUTE`, `BRIDGE_RATE_BURST` | Runs a user may start per minute, and the burst | `6`, `10` |
75
+ | `BRIDGE_USER_ACCESS_MODE` | `isolated` (a user sees only their own data) or `tenant` | `isolated` |
76
+ | `BRIDGE_IDLE_MINUTES` | End a conversation's run after this long without a message; the next message continues it | `1440` |
77
+ | `BRIDGE_STORE` | `memory`, or `file:<path>` to survive restarts on one instance | `memory` |
78
+ | `BRIDGE_DEV` | `1` turns signature verification off, for local testing only | unset |
79
+ | `PORT` | Listen port | `8080` |
80
+
81
+ Discord's:
82
+
83
+ | Variable | Meaning | Default |
84
+ |---|---|---|
85
+ | `DISCORD_APPLICATION_ID` | The application's id | required |
86
+ | `DISCORD_PUBLIC_KEY` | The application's public key (64 hex characters) | required |
87
+ | `DISCORD_BOT_TOKEN` | The bot's token | required |
88
+ | `DISCORD_BRIDGE_ALLOWED_GUILDS` | Comma list of server ids the bot answers in | none |
89
+ | `DISCORD_BRIDGE_ALLOW_DMS` | `1` lets anyone who can reach the bot use it in a DM | off |
90
+ | `DISCORD_BRIDGE_TRUSTED_GUILDS` | Allowed servers whose members are trusted (see below) | none |
91
+ | `DISCORD_BRIDGE_REGISTER_COMMANDS` | `1` registers the commands at start | off |
92
+ | `DISCORD_BRIDGE_API_BASE_URL` | Discord's REST API (`https`, or `http` to localhost for a test fake) | `https://discord.com/api/v10` |
93
+
94
+ **The bridge is closed by default.** It refuses to start unless `DISCORD_BRIDGE_ALLOWED_GUILDS` or `DISCORD_BRIDGE_ALLOW_DMS=1` opens something: anyone on the internet can reach a Discord bot, and every message it answers runs on your model keys. `DISCORD_BRIDGE_ALLOW_DMS=1` means exactly that, so pair it with the rate limit and loomcycle's token budgets.
95
+
96
+ ## Servers, channels and trust
97
+
98
+ - **A server channel runs as itself, never as one of its members.** It is its own loomcycle user (`discord-conv-<hash>`), with its own memory and history. A thread is its own conversation and shares its parent channel's memory.
99
+ - Every message reaches the agent labelled with the speaker's name.
100
+ - The person who started the conversation answers the agent's question and review cards, and only they can `/new` or `/stop`.
101
+ - **Trust.** A DM is trusted. In a server, what members write is **untrusted** unless the server is in `DISCORD_BRIDGE_TRUSTED_GUILDS`. The agent reads untrusted text as data, never as instructions, and it never reaches memory. An untrusted member can pick one of the agent's options, decline, approve or send back, but cannot type an answer to a question.
102
+ - A server not on the list, a DM when DMs are off, and a group DM each get a private reply saying the bot isn't available there, and never reach loomcycle.
103
+
104
+ ## Questions and reviews
105
+
106
+ - A question with options shows one button per option (up to six) and **Decline**.
107
+ - A question without options asks to be answered with `/ask <your answer>`: a Discord message has no text field. The conversation owner's next `/ask` is taken as the answer.
108
+ - A review shows **Approve** and **Send back**. To send it back with notes, press **Send back**, then `/ask` the notes. A feedback form on **Send back** is planned for v1.1.
109
+ - A pressed card is replaced by its result, so it cannot be answered twice. A card from before a restart says it is no longer open.
110
+
111
+ ## Limits
112
+
113
+ - **2,000 characters a message.** A longer answer arrives as several.
114
+ - **A turn longer than 15 minutes** finishes with the bot token. In a DM, or in a channel the bot cannot post in, that last message can be lost: the bridge logs the refusal. Another `/ask` gives the bridge a fresh interaction to reply through.
115
+ - **No streaming.** Without the Gateway there is no live message to grow; each turn arrives when it ends, with Discord's "thinking…" until then.
116
+ - A `/stop` sent while the agent waits for its next message has nothing to stop; its reply settles after ten seconds.
117
+
118
+ ## Security
119
+
120
+ - **Inbound:** every request's `X-Signature-Ed25519` is checked over the timestamp and the raw body with the application's public key, before the body is parsed. A timestamp more than five minutes off, and an interaction id already seen, are refused. A failed check is a bare `401`.
121
+ - **Bots are dropped,** the bot itself included, before anything reaches loomcycle.
122
+ - **Least privilege:** the bridge's own token never runs agents. A user's token is limited to one agent, every per-run override except `interactive` is locked, it is rate-limited, and it expires.
123
+ - **Credentials stay in memory.** Interaction tokens are never written to the conversation store, so `BRIDGE_STORE=file:` holds no credential; after a restart, replies use the bot token until the next command. The bot token is sent only to Discord's REST API, never to an interaction webhook. Logs carry ids, never a token, message text or agent output.
124
+ - **Output:** agent text can be steered by whoever writes in a channel, so nothing the bridge posts can ping anyone (`allowed_mentions` parses nothing, so `@everyone` and role mentions are inert), shows a link preview (`SUPPRESS_EMBEDS` on every message: Discord would fetch the link to build one, with no click, and hand its query string to whoever runs that server), or hides a link's target (a masked link `[label](url)` is rewritten as `label (<url>)`). Tool inputs and outputs are never sent.
125
+
126
+ ## As a library
127
+
128
+ ```ts
129
+ import { createDiscordBridge, loadConfig } from "@loomcycle/bridge-discord";
130
+
131
+ const bridge = createDiscordBridge(loadConfig(), { store: myRedisStore });
132
+ await bridge.start();
133
+ ```
134
+
135
+ `ConversationStore` is four methods (`get`, `put`, `delete`, `list`); implement it over Redis or Postgres to run several instances. Interaction tokens are per process, so with several instances a reply sent by an instance other than the one that took the command goes by the bot token: it arrives as a new channel message rather than an edit of the "thinking…" reply. v1 is meant to run as one instance per bot.
136
+
137
+ ## Development
138
+
139
+ ```bash
140
+ npm ci
141
+ npm run typecheck
142
+ npm test # unit and HTTP tests
143
+ ./test/integration/run.sh # builds loomcycle from this repo and runs the CLI against it (Go required)
144
+ ```
145
+
146
+ The integration run boots loomcycle on the mock provider, starts the built CLI against it with a fake Discord API on localhost, and posts signed interactions.
147
+
148
+ For a real Discord test, expose the bridge with a tunnel and set it as the application's Interactions Endpoint URL.
package/dist/api.d.ts ADDED
@@ -0,0 +1,51 @@
1
+ import type { Logger } from "@loomcycle/bridge-core";
2
+ import type { MessageBody } from "./cards.js";
3
+ export interface DiscordApiOptions {
4
+ baseUrl: string;
5
+ applicationId: string;
6
+ botToken: string;
7
+ log: Logger;
8
+ fetch?: typeof fetch;
9
+ sleep?: (ms: number) => Promise<void>;
10
+ }
11
+ export declare class DiscordApiError extends Error {
12
+ readonly status: number;
13
+ readonly route: string;
14
+ /** Discord's JSON error code (10062 "Unknown interaction", say), when sent. */
15
+ readonly code?: number | undefined;
16
+ constructor(status: number, route: string,
17
+ /** Discord's JSON error code (10062 "Unknown interaction", say), when sent. */
18
+ code?: number | undefined);
19
+ }
20
+ /** Discord asks bot clients to identify themselves this way. */
21
+ export declare const USER_AGENT = "DiscordBot (https://github.com/denn-gubsky/loomcycle, 0.1.0)";
22
+ export interface CommandSpec {
23
+ name: string;
24
+ description: string;
25
+ type: 1;
26
+ contexts: number[];
27
+ integration_types: number[];
28
+ options?: Array<{
29
+ type: 3;
30
+ name: string;
31
+ description: string;
32
+ required: boolean;
33
+ max_length?: number;
34
+ }>;
35
+ }
36
+ export declare class DiscordApi {
37
+ private readonly opts;
38
+ private readonly fetch;
39
+ private readonly sleep;
40
+ constructor(opts: DiscordApiOptions);
41
+ editOriginal(interactionToken: string, body: MessageBody): Promise<unknown>;
42
+ followUp(interactionToken: string, body: MessageBody): Promise<unknown>;
43
+ createMessage(channelId: string, body: MessageBody): Promise<unknown>;
44
+ typing(channelId: string): Promise<unknown>;
45
+ /** putCommands replaces the application's commands: globally, or in one server. */
46
+ putCommands(commands: CommandSpec[], guildId?: string): Promise<unknown>;
47
+ private call;
48
+ }
49
+ /** retryAfterMs reads how long a 429 asks to wait: the body's retry_after
50
+ * (seconds, fractional), else the Retry-After header (seconds). */
51
+ export declare function retryAfterMs(body: unknown, header: string | null): number | undefined;
package/dist/api.js ADDED
@@ -0,0 +1,101 @@
1
+ // Discord's REST API, the few calls the bridge makes, over fetch.
2
+ //
3
+ // Two kinds of credential: the interaction webhook routes are authorised by
4
+ // the interaction token in their path and get no Authorization header; the
5
+ // channel routes and command registration use the bot token. Neither is ever
6
+ // put in a log line or an error: an error names the route's shape only.
7
+ export class DiscordApiError extends Error {
8
+ status;
9
+ route;
10
+ code;
11
+ constructor(status, route,
12
+ /** Discord's JSON error code (10062 "Unknown interaction", say), when sent. */
13
+ code) {
14
+ super(`discord ${route}: http ${status}`);
15
+ this.status = status;
16
+ this.route = route;
17
+ this.code = code;
18
+ this.name = "DiscordApiError";
19
+ }
20
+ }
21
+ /** Discord asks bot clients to identify themselves this way. */
22
+ export const USER_AGENT = "DiscordBot (https://github.com/denn-gubsky/loomcycle, 0.1.0)";
23
+ /** A 429 is waited out at most this many times, each wait at most this long. */
24
+ const MAX_RETRIES = 3;
25
+ const MAX_WAIT_MS = 30_000;
26
+ export class DiscordApi {
27
+ opts;
28
+ fetch;
29
+ sleep;
30
+ constructor(opts) {
31
+ this.opts = opts;
32
+ this.fetch = opts.fetch ?? globalThis.fetch;
33
+ this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
34
+ }
35
+ editOriginal(interactionToken, body) {
36
+ return this.call("PATCH", `/webhooks/${this.opts.applicationId}/${interactionToken}/messages/@original`, "PATCH webhooks/@original", body, false);
37
+ }
38
+ followUp(interactionToken, body) {
39
+ return this.call("POST", `/webhooks/${this.opts.applicationId}/${interactionToken}`, "POST webhooks/followup", body, false);
40
+ }
41
+ createMessage(channelId, body) {
42
+ return this.call("POST", `/channels/${channelId}/messages`, "POST channels/messages", body, true);
43
+ }
44
+ typing(channelId) {
45
+ return this.call("POST", `/channels/${channelId}/typing`, "POST channels/typing", undefined, true);
46
+ }
47
+ /** putCommands replaces the application's commands: globally, or in one server. */
48
+ putCommands(commands, guildId) {
49
+ const path = guildId
50
+ ? `/applications/${this.opts.applicationId}/guilds/${guildId}/commands`
51
+ : `/applications/${this.opts.applicationId}/commands`;
52
+ return this.call("PUT", path, guildId ? "PUT guild commands" : "PUT global commands", commands, true);
53
+ }
54
+ async call(method, path, route, body, bot) {
55
+ const headers = { "user-agent": USER_AGENT };
56
+ if (body !== undefined)
57
+ headers["content-type"] = "application/json";
58
+ if (bot)
59
+ headers.authorization = `Bot ${this.opts.botToken}`;
60
+ for (let attempt = 0;; attempt++) {
61
+ const res = await this.fetch(`${this.opts.baseUrl}${path}`, {
62
+ method,
63
+ headers,
64
+ body: body === undefined ? undefined : JSON.stringify(body),
65
+ });
66
+ const text = await res.text();
67
+ const json = parseJson(text);
68
+ if (res.status === 429 && attempt < MAX_RETRIES) {
69
+ const wait = retryAfterMs(json, res.headers.get("retry-after"));
70
+ if (wait !== undefined && wait <= MAX_WAIT_MS) {
71
+ this.opts.log.warn("discord rate limited, waiting", { route, wait_ms: wait });
72
+ await this.sleep(wait);
73
+ continue;
74
+ }
75
+ }
76
+ if (!res.ok) {
77
+ const code = typeof json?.code === "number" ? json.code : undefined;
78
+ throw new DiscordApiError(res.status, route, code);
79
+ }
80
+ return json;
81
+ }
82
+ }
83
+ }
84
+ function parseJson(text) {
85
+ if (!text)
86
+ return undefined;
87
+ try {
88
+ return JSON.parse(text);
89
+ }
90
+ catch {
91
+ return undefined;
92
+ }
93
+ }
94
+ /** retryAfterMs reads how long a 429 asks to wait: the body's retry_after
95
+ * (seconds, fractional), else the Retry-After header (seconds). */
96
+ export function retryAfterMs(body, header) {
97
+ const fromBody = body?.retry_after;
98
+ const seconds = typeof fromBody === "number" ? fromBody : header !== null ? Number(header) : NaN;
99
+ return Number.isFinite(seconds) && seconds >= 0 ? Math.ceil(seconds * 1000) : undefined;
100
+ }
101
+ //# sourceMappingURL=api.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"api.js","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,12 @@
1
+ import { type Bridge, type BridgeOptions } from "@loomcycle/bridge-core";
2
+ import type { DiscordConfig } from "./config.js";
3
+ import { type DiscordRef } from "./port.js";
4
+ export declare const MAX_MESSAGE_CHARS = 2000;
5
+ export interface DiscordBridgeOptions extends BridgeOptions<DiscordRef> {
6
+ /** Override how Discord's API is called (tests, a proxy). */
7
+ fetch?: typeof fetch;
8
+ sleep?: (ms: number) => Promise<void>;
9
+ /** How long a button press may take before it is acked and finished later. */
10
+ inlineCardMs?: number;
11
+ }
12
+ export declare function createDiscordBridge(cfg: DiscordConfig, opts?: DiscordBridgeOptions): Bridge<DiscordRef>;
package/dist/bridge.js ADDED
@@ -0,0 +1,41 @@
1
+ // The Discord adapter on the shared core: interactions over HTTP, no Gateway.
2
+ import { consoleLogger, createBridge } from "@loomcycle/bridge-core";
3
+ import { DiscordApi } from "./api.js";
4
+ import { registerCommands } from "./commands.js";
5
+ import { interactionRoute } from "./interactions.js";
6
+ import { DiscordPort } from "./port.js";
7
+ import { publicKeyFromHex } from "./verify.js";
8
+ export const MAX_MESSAGE_CHARS = 2000;
9
+ export function createDiscordBridge(cfg, opts = {}) {
10
+ const log = opts.logger ?? consoleLogger;
11
+ const now = opts.now ?? Date.now;
12
+ const api = new DiscordApi({
13
+ baseUrl: cfg.apiBaseUrl,
14
+ applicationId: cfg.applicationId,
15
+ botToken: cfg.botToken,
16
+ log,
17
+ fetch: opts.fetch,
18
+ sleep: opts.sleep,
19
+ });
20
+ const port = new DiscordPort(api, log, { now });
21
+ const key = publicKeyFromHex(cfg.publicKey);
22
+ const adapter = {
23
+ platform: { name: "discord", label: "Discord" },
24
+ // Without the Gateway there is no message to edit as it grows, and each
25
+ // turn is one message per 2,000 characters.
26
+ capabilities: { streaming: "never", maxMessageChars: MAX_MESSAGE_CHARS },
27
+ port,
28
+ async start(ctx) {
29
+ await ctx.serve({
30
+ "POST /discord/interactions": interactionRoute({ cfg, key, port, api, router: ctx.router, log: ctx.log, now, inlineCardMs: opts.inlineCardMs }),
31
+ });
32
+ if (cfg.registerCommands)
33
+ await registerCommands(api, cfg, ctx.log);
34
+ },
35
+ async stop() {
36
+ port.stop();
37
+ },
38
+ };
39
+ return createBridge(cfg, adapter, { ...opts, logger: log });
40
+ }
41
+ //# sourceMappingURL=bridge.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bridge.js","sourceRoot":"","sources":["../src/bridge.ts"],"names":[],"mappings":"AAAA,8EAA8E;AAE9E,OAAO,EAAE,aAAa,EAAE,YAAY,EAAuD,MAAM,wBAAwB,CAAC;AAE1H,OAAO,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AACtC,OAAO,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAEjD,OAAO,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AACrD,OAAO,EAAE,WAAW,EAAmB,MAAM,WAAW,CAAC;AACzD,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAE/C,MAAM,CAAC,MAAM,iBAAiB,GAAG,IAAI,CAAC;AAUtC,MAAM,UAAU,mBAAmB,CAAC,GAAkB,EAAE,OAA6B,EAAE;IACrF,MAAM,GAAG,GAAG,IAAI,CAAC,MAAM,IAAI,aAAa,CAAC;IACzC,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,IAAI,IAAI,CAAC,GAAG,CAAC;IACjC,MAAM,GAAG,GAAG,IAAI,UAAU,CAAC;QACzB,OAAO,EAAE,GAAG,CAAC,UAAU;QACvB,aAAa,EAAE,GAAG,CAAC,aAAa;QAChC,QAAQ,EAAE,GAAG,CAAC,QAAQ;QACtB,GAAG;QACH,KAAK,EAAE,IAAI,CAAC,KAAK;QACjB,KAAK,EAAE,IAAI,CAAC,KAAK;KAClB,CAAC,CAAC;IACH,MAAM,IAAI,GAAG,IAAI,WAAW,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE,GAAG,EAAE,CAAC,CAAC;IAChD,MAAM,GAAG,GAAG,gBAAgB,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;IAE5C,MAAM,OAAO,GAA8B;QACzC,QAAQ,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,SAAS,EAAE;QAC/C,wEAAwE;QACxE,4CAA4C;QAC5C,YAAY,EAAE,EAAE,SAAS,EAAE,OAAO,EAAE,eAAe,EAAE,iBAAiB,EAAE;QACxE,IAAI;QACJ,KAAK,CAAC,KAAK,CAAC,GAAG;YACb,MAAM,GAAG,CAAC,KAAK,CAAC;gBACd,4BAA4B,EAAE,gBAAgB,CAAC,EAAE,GAAG,EAAE,GAAG,EAAE,IAAI,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,GAAG,EAAE,GAAG,CAAC,GAAG,EAAE,GAAG,EAAE,YAAY,EAAE,IAAI,CAAC,YAAY,EAAE,CAAC;aAChJ,CAAC,CAAC;YACH,IAAI,GAAG,CAAC,gBAAgB;gBAAE,MAAM,gBAAgB,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,GAAG,CAAC,CAAC;QACtE,CAAC;QACD,KAAK,CAAC,IAAI;YACR,IAAI,CAAC,IAAI,EAAE,CAAC;QACd,CAAC;KACF,CAAC;IACF,OAAO,YAAY,CAAC,GAAG,EAAE,OAAO,EAAE,EAAE,GAAG,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC;AAC9D,CAAC"}
@@ -0,0 +1,42 @@
1
+ import { type CanonicalCard } from "@loomcycle/bridge-core";
2
+ export interface MessageBody {
3
+ content?: string;
4
+ components?: Array<{
5
+ type: 1;
6
+ components: Button[];
7
+ }>;
8
+ allowed_mentions: {
9
+ parse: string[];
10
+ };
11
+ flags: number;
12
+ }
13
+ interface Button {
14
+ type: 2;
15
+ style: number;
16
+ label: string;
17
+ custom_id: string;
18
+ }
19
+ export declare const CUSTOM_ID_PREFIX = "lc:";
20
+ export declare const MAX_CUSTOM_ID = 100;
21
+ export declare const MAX_CONTENT = 2000;
22
+ export declare const NO_MENTIONS: {
23
+ parse: string[];
24
+ };
25
+ export declare const SUPPRESS_EMBEDS: number;
26
+ export declare const FREE_TEXT_HINT = "Answer with `/ask` and your answer, or decline.";
27
+ export declare function customIdOf(buttonId: string): string;
28
+ /** parseCustomId returns the core's button id, or undefined when the button
29
+ * is not one this bridge drew. */
30
+ export declare function parseCustomId(customId: unknown): {
31
+ buttonId: string;
32
+ } | undefined;
33
+ /** neutraliseLinks rewrites each masked link as `label (<url>)`, so the shown
34
+ * text holds the real target (the angle brackets also keep it from
35
+ * unfurling). A `](` it cannot parse (a URL with parentheses, nested
36
+ * brackets) gets a zero-width space, which breaks the link and shows it raw. */
37
+ export declare function neutraliseLinks(text: string): string;
38
+ /** textMessage is one short message: a line from the bridge, or a piece of
39
+ * a turn already cut to size (neutralising may lengthen it, hence the clip). */
40
+ export declare function textMessage(text: string): MessageBody;
41
+ export declare function renderCard(card: CanonicalCard): MessageBody;
42
+ export {};
package/dist/cards.js ADDED
@@ -0,0 +1,96 @@
1
+ // The core's question and review cards as Discord messages: the text, and
2
+ // rows of buttons whose custom_id is the core's short button id. Discord keeps
3
+ // a button's custom_id to 100 characters, and the core looks the action up by
4
+ // id, so the id is all a button carries.
5
+ //
6
+ // Every message the bridge posts carries agent-written text, which an
7
+ // untrusted speaker can steer. So none of them pings anyone (allowed_mentions
8
+ // parses nothing) and none shows a link preview: Discord fetches a link to
9
+ // build its preview without anyone clicking, which would send the URL's query
10
+ // string to whoever runs the server. SUPPRESS_EMBEDS also hides embeds the
11
+ // bridge would build, so cards are plain content. And no masked link
12
+ // (`[label](url)`) survives: Discord renders one in a bot's message, so the
13
+ // agent could show one address and open another.
14
+ import { VERB } from "@loomcycle/bridge-core";
15
+ export const CUSTOM_ID_PREFIX = "lc:";
16
+ export const MAX_CUSTOM_ID = 100;
17
+ const MAX_LABEL = 80;
18
+ export const MAX_CONTENT = 2000;
19
+ const BUTTONS_PER_ROW = 5;
20
+ const Style = { PRIMARY: 1, SECONDARY: 2, SUCCESS: 3, DANGER: 4 };
21
+ export const NO_MENTIONS = { parse: [] };
22
+ export const SUPPRESS_EMBEDS = 1 << 2;
23
+ export const FREE_TEXT_HINT = "Answer with `/ask` and your answer, or decline.";
24
+ export function customIdOf(buttonId) {
25
+ const id = `${CUSTOM_ID_PREFIX}${buttonId}`;
26
+ if (id.length > MAX_CUSTOM_ID)
27
+ throw new Error("button id too long for Discord");
28
+ return id;
29
+ }
30
+ /** parseCustomId returns the core's button id, or undefined when the button
31
+ * is not one this bridge drew. */
32
+ export function parseCustomId(customId) {
33
+ if (typeof customId !== "string")
34
+ return undefined;
35
+ const m = /^lc:([A-Za-z0-9_-]{1,64})$/.exec(customId);
36
+ return m ? { buttonId: m[1] } : undefined;
37
+ }
38
+ function clip(s, max) {
39
+ return s.length <= max ? s : `${s.slice(0, max - 1)}…`;
40
+ }
41
+ function rows(buttons) {
42
+ const out = [];
43
+ for (let i = 0; i < buttons.length; i += BUTTONS_PER_ROW)
44
+ out.push({ type: 1, components: buttons.slice(i, i + BUTTONS_PER_ROW) });
45
+ return out;
46
+ }
47
+ function button(b, style) {
48
+ return { type: 2, style, label: clip(b.label || "…", MAX_LABEL), custom_id: customIdOf(b.id) };
49
+ }
50
+ const MASKED_LINK = /\[([^[\]\n]*)\]\(\s*<?([^\s()<>]+)>?(?:\s+"[^"\n]*")?\s*\)/g;
51
+ /** neutraliseLinks rewrites each masked link as `label (<url>)`, so the shown
52
+ * text holds the real target (the angle brackets also keep it from
53
+ * unfurling). A `](` it cannot parse (a URL with parentheses, nested
54
+ * brackets) gets a zero-width space, which breaks the link and shows it raw. */
55
+ export function neutraliseLinks(text) {
56
+ return text.replace(MASKED_LINK, (_, label, url) => `${label} (<${url}>)`).replace(/\]\(/g, "]\u200b(");
57
+ }
58
+ /** textMessage is one short message: a line from the bridge, or a piece of
59
+ * a turn already cut to size (neutralising may lengthen it, hence the clip). */
60
+ export function textMessage(text) {
61
+ return { content: clip(neutraliseLinks(text), MAX_CONTENT), allowed_mentions: NO_MENTIONS, flags: SUPPRESS_EMBEDS };
62
+ }
63
+ export function renderCard(card) {
64
+ switch (card.kind) {
65
+ case "result":
66
+ // Replaces a used card: the buttons go, so it cannot be answered twice.
67
+ return { ...textMessage(card.message), components: [] };
68
+ case "question": {
69
+ // A Discord message has no text field, so a free-text question is
70
+ // answered with the owner's next /ask; the core's "Send" button would
71
+ // only ever say "type an answer first".
72
+ const freeText = card.options.length === 0;
73
+ const buttons = card.buttons
74
+ .filter((b) => !(freeText && b.action.verb === VERB.answer))
75
+ .map((b) => button(b, b.action.verb === VERB.decline ? Style.SECONDARY : Style.PRIMARY));
76
+ const hint = freeText ? `\n\n${FREE_TEXT_HINT}` : "";
77
+ const text = neutraliseLinks([card.question || "The agent has a question.", card.context].filter(Boolean).join("\n\n"));
78
+ return {
79
+ content: `**Question**\n${clip(text, MAX_CONTENT - hint.length - 13)}${hint}`,
80
+ components: rows(buttons),
81
+ allowed_mentions: NO_MENTIONS,
82
+ flags: SUPPRESS_EMBEDS,
83
+ };
84
+ }
85
+ case "review": {
86
+ const buttons = card.buttons.map((b) => button(b, b.action.verb === VERB.approve ? Style.SUCCESS : Style.DANGER));
87
+ return {
88
+ content: `**Review**\nThe agent's answer is waiting for your review.${card.round ? ` Round ${card.round}.` : ""}`,
89
+ components: rows(buttons),
90
+ allowed_mentions: NO_MENTIONS,
91
+ flags: SUPPRESS_EMBEDS,
92
+ };
93
+ }
94
+ }
95
+ }
96
+ //# sourceMappingURL=cards.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cards.js","sourceRoot":"","sources":["../src/cards.ts"],"names":[],"mappings":"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"}
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from "@loomcycle/bridge-core";
3
+ import { createDiscordBridge } from "./bridge.js";
4
+ import { loadConfig } from "./config.js";
5
+ void runCli("discord-bridge", () => createDiscordBridge(loadConfig()));
6
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,MAAM,EAAE,MAAM,wBAAwB,CAAC;AAEhD,OAAO,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAClD,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEzC,KAAK,MAAM,CAAC,gBAAgB,EAAE,GAAG,EAAE,CAAC,mBAAmB,CAAC,UAAU,EAAE,CAAC,CAAC,CAAC"}
@@ -0,0 +1,10 @@
1
+ import type { Logger } from "@loomcycle/bridge-core";
2
+ import type { CommandSpec, DiscordApi } from "./api.js";
3
+ import type { DiscordConfig } from "./config.js";
4
+ export declare const MAX_PROMPT_CHARS = 4000;
5
+ export declare const HELP_TEXT: string;
6
+ export declare function commandSpecs(contexts: number[]): CommandSpec[];
7
+ /** registerCommands replaces the application's commands in every allowed
8
+ * server, and globally (DMs only) when DMs are on. The bridge owns the
9
+ * application's commands: anything else registered there is replaced. */
10
+ export declare function registerCommands(api: DiscordApi, cfg: Pick<DiscordConfig, "allowedGuilds" | "allowDms">, log: Logger): Promise<void>;
@@ -0,0 +1,46 @@
1
+ // The slash commands, and registering them.
2
+ //
3
+ // In each allowed server they are registered as that server's commands: those
4
+ // take effect at once and show only where the bot answers. Commands in DMs
5
+ // can only be global, so with DMs on they are also registered globally,
6
+ // limited to the bot's DMs, so a server never lists them twice.
7
+ import { Context } from "./interaction.js";
8
+ const GUILD_INSTALL = 0;
9
+ export const MAX_PROMPT_CHARS = 4000;
10
+ export const HELP_TEXT = [
11
+ "Commands:",
12
+ "- **/ask** `prompt`: talk to the agent. When it asks you a question without options, answer with /ask too.",
13
+ "- **/new**: start a new conversation (the agent forgets this one)",
14
+ "- **/stop**: stop the current answer",
15
+ "- **/help**: show this list",
16
+ "",
17
+ "In a server, everyone in a channel shares one conversation with the agent. The person who started it answers the agent's questions and can /new or /stop it.",
18
+ ].join("\n");
19
+ export function commandSpecs(contexts) {
20
+ const base = { type: 1, contexts, integration_types: [GUILD_INSTALL] };
21
+ return [
22
+ {
23
+ ...base,
24
+ name: "ask",
25
+ description: "Talk to the agent",
26
+ options: [{ type: 3, name: "prompt", description: "What to say to the agent", required: true, max_length: MAX_PROMPT_CHARS }],
27
+ },
28
+ { ...base, name: "new", description: "Start a new conversation with the agent" },
29
+ { ...base, name: "stop", description: "Stop the agent's current answer" },
30
+ { ...base, name: "help", description: "Show what the bot can do" },
31
+ ];
32
+ }
33
+ /** registerCommands replaces the application's commands in every allowed
34
+ * server, and globally (DMs only) when DMs are on. The bridge owns the
35
+ * application's commands: anything else registered there is replaced. */
36
+ export async function registerCommands(api, cfg, log) {
37
+ for (const guild of cfg.allowedGuilds) {
38
+ await api.putCommands(commandSpecs([Context.GUILD]), guild);
39
+ log.info("registered commands", { guild });
40
+ }
41
+ if (cfg.allowDms) {
42
+ await api.putCommands(commandSpecs([Context.BOT_DM]));
43
+ log.info("registered commands", { scope: "dm" });
44
+ }
45
+ }
46
+ //# sourceMappingURL=commands.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"commands.js","sourceRoot":"","sources":["../src/commands.ts"],"names":[],"mappings":"AAAA,4CAA4C;AAC5C,EAAE;AACF,8EAA8E;AAC9E,2EAA2E;AAC3E,wEAAwE;AACxE,gEAAgE;AAMhE,OAAO,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAE3C,MAAM,aAAa,GAAG,CAAC,CAAC;AACxB,MAAM,CAAC,MAAM,gBAAgB,GAAG,IAAI,CAAC;AAErC,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,WAAW;IACX,4GAA4G;IAC5G,mEAAmE;IACnE,sCAAsC;IACtC,6BAA6B;IAC7B,EAAE;IACF,8JAA8J;CAC/J,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAEb,MAAM,UAAU,YAAY,CAAC,QAAkB;IAC7C,MAAM,IAAI,GAAG,EAAE,IAAI,EAAE,CAAU,EAAE,QAAQ,EAAE,iBAAiB,EAAE,CAAC,aAAa,CAAC,EAAE,CAAC;IAChF,OAAO;QACL;YACE,GAAG,IAAI;YACP,IAAI,EAAE,KAAK;YACX,WAAW,EAAE,mBAAmB;YAChC,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,WAAW,EAAE,0BAA0B,EAAE,QAAQ,EAAE,IAAI,EAAE,UAAU,EAAE,gBAAgB,EAAE,CAAC;SAC9H;QACD,EAAE,GAAG,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,WAAW,EAAE,yCAAyC,EAAE;QAChF,EAAE,GAAG,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,iCAAiC,EAAE;QACzE,EAAE,GAAG,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,0BAA0B,EAAE;KACnE,CAAC;AACJ,CAAC;AAED;;0EAE0E;AAC1E,MAAM,CAAC,KAAK,UAAU,gBAAgB,CAAC,GAAe,EAAE,GAAsD,EAAE,GAAW;IACzH,KAAK,MAAM,KAAK,IAAI,GAAG,CAAC,aAAa,EAAE,CAAC;QACtC,MAAM,GAAG,CAAC,WAAW,CAAC,YAAY,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC;QAC5D,GAAG,CAAC,IAAI,CAAC,qBAAqB,EAAE,EAAE,KAAK,EAAE,CAAC,CAAC;IAC7C,CAAC;IACD,IAAI,GAAG,CAAC,QAAQ,EAAE,CAAC;QACjB,MAAM,GAAG,CAAC,WAAW,CAAC,YAAY,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;QACtD,GAAG,CAAC,IAAI,CAAC,qBAAqB,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;IACnD,CAAC;AACH,CAAC"}
@@ -0,0 +1,22 @@
1
+ import { type Env, type SharedConfig } from "@loomcycle/bridge-core";
2
+ export interface DiscordConfig extends SharedConfig {
3
+ applicationId: string;
4
+ /** The application's Ed25519 public key, 32 bytes as hex. */
5
+ publicKey: string;
6
+ /** Sent only to Discord's REST API, never logged. */
7
+ botToken: string;
8
+ /** Servers the bot answers in. Every other server is refused. */
9
+ allowedGuilds: Set<string>;
10
+ /** Anyone who can reach the app may use it in a DM with the bot. */
11
+ allowDms: boolean;
12
+ /** Allowed servers whose speakers are trusted. Elsewhere in a server they are not. */
13
+ trustedGuilds: Set<string>;
14
+ /** Register the slash commands when the bridge starts. */
15
+ registerCommands: boolean;
16
+ /** Discord's REST base, without a trailing slash. */
17
+ apiBaseUrl: string;
18
+ }
19
+ export declare const DEFAULT_PORT = 8080;
20
+ export declare const DEFAULT_API_BASE_URL = "https://discord.com/api/v10";
21
+ export declare function isSnowflake(v: unknown): v is string;
22
+ export declare function loadConfig(env?: Env): DiscordConfig;