@openpalm/discord-portal 0.12.7

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 ADDED
@@ -0,0 +1,64 @@
1
+ # @openpalm/discord-portal
2
+
3
+ Discord bot adapter for OpenPalm.
4
+ It runs behind guardian and is normally enabled via the `addon.discord` Compose profile.
5
+
6
+ ## Features
7
+
8
+ - Gateway-based Discord bot connection
9
+ - Slash commands: `/ask`, `/queue`, `/health`, `/help`, `/clear`
10
+ - Mention-to-thread conversations
11
+ - Guild, role, and user allowlists plus user blocklist
12
+ - Deferred responses, typing indicators, queued follow-ups, and long-reply splitting
13
+
14
+ ## Deployment model
15
+
16
+ - Shipped service definition: `.openpalm/config/stack/portals.compose.yml`, profile `addon.discord`
17
+ - Non-secret values: `~/.openpalm/knowledge/env/stack.env`
18
+ - Secret values: files under `~/.openpalm/knowledge/secrets/`
19
+
20
+ Manual start example:
21
+
22
+ ```bash
23
+ cd "$HOME/.openpalm/config/stack"
24
+ docker compose \
25
+ --project-name openpalm \
26
+ --env-file ../../knowledge/env/stack.env \
27
+ -f core.compose.yml \
28
+ -f services.compose.yml \
29
+ -f portals.compose.yml \
30
+ -f custom.compose.yml \
31
+ --profile addon.discord \
32
+ up -d
33
+ ```
34
+
35
+ See `docs/portals/discord-setup.md` for the full walkthrough.
36
+
37
+ The service definition uses explicit non-secret environment entries and Docker secret grants. It does not use service-level `env_file`.
38
+
39
+ ## Environment variables
40
+
41
+ | Variable | Required | Purpose |
42
+ |---|---|---|
43
+ | `OPENCODE_BASE_URL` | no | OpenCode/guardian `/oc` base URL, default `http://guardian:8080/oc` |
44
+ | `PRINCIPAL_ID` | system-managed | Guardian principal id used for Basic auth |
45
+ | `PRINCIPAL_SECRET_FILE` | system-managed | Shared secret file path used for Basic auth |
46
+ | `DISCORD_APPLICATION_ID` | yes for command registration | Discord application ID |
47
+ | `DISCORD_BOT_TOKEN_FILE` | yes | Bot token file path |
48
+ | `DISCORD_REGISTER_COMMANDS` | no | Disable startup command registration when `false` |
49
+ | `DISCORD_ALLOWED_GUILDS` | no | Comma-separated guild allowlist |
50
+ | `DISCORD_ALLOWED_ROLES` | no | Comma-separated role allowlist |
51
+ | `DISCORD_ALLOWED_USERS` | no | Comma-separated user allowlist |
52
+ | `DISCORD_BLOCKED_USERS` | no | Comma-separated user blocklist |
53
+ | `DISCORD_CUSTOM_COMMANDS` | no | JSON array of custom command definitions |
54
+
55
+ Secret values are stored as files and exposed only through `*_FILE` variables. The schema may collect `DISCORD_BOT_TOKEN` for setup, but setup persists it under `knowledge/secrets/` and the runtime receives `DISCORD_BOT_TOKEN_FILE`, not the raw token.
56
+
57
+ The shipped Compose overlay exposes per-portal overrides through `DISCORD_OPENCODE_BASE_URL`, `DISCORD_PRINCIPAL_ID`, and `DISCORD_PRINCIPAL_SECRET_FILE`; each defaults to the guardian-backed first-party wiring.
58
+
59
+ ## Conversation behavior
60
+
61
+ - Mentioning the bot in a normal channel starts or reuses a Discord thread
62
+ - Replies inside that tracked thread keep the same backend session
63
+ - `/ask` replies inline and does not create a thread
64
+ - `/clear` clears the active conversation scope and drops queued follow-ups for that scope
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@openpalm/discord-portal",
3
+ "description": "Discord bot portal adapter for OpenPalm",
4
+ "version": "0.12.7",
5
+ "type": "module",
6
+ "license": "MPL-2.0",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/itlackey/openpalm.git",
10
+ "directory": "portals/discord"
11
+ },
12
+ "engines": {
13
+ "bun": ">=1.0.0"
14
+ },
15
+ "files": [
16
+ "src",
17
+ "README.md"
18
+ ],
19
+ "main": "src/index.ts",
20
+ "dependencies": {
21
+ "@opencode-ai/sdk": "1.17.7",
22
+ "discord.js": "^14.16.3"
23
+ }
24
+ }
@@ -0,0 +1,202 @@
1
+ import { createLogger } from './runtime.ts';
2
+ import { CommandOptionType, type CustomCommandDef, type CustomCommandOption } from "./types.ts";
3
+
4
+ const log = createLogger("channel-discord");
5
+
6
+ export const BUILTIN_COMMANDS: CustomCommandDef[] = [
7
+ {
8
+ name: "ask",
9
+ description: "Send a message to the assistant",
10
+ options: [
11
+ {
12
+ name: "message",
13
+ description: "Your message or question",
14
+ type: CommandOptionType.STRING,
15
+ required: true,
16
+ },
17
+ ],
18
+ },
19
+ {
20
+ name: "queue",
21
+ description: "Queue a follow-up for the current conversation",
22
+ options: [
23
+ {
24
+ name: "message",
25
+ description: "Your follow-up message or question",
26
+ type: CommandOptionType.STRING,
27
+ required: true,
28
+ },
29
+ ],
30
+ },
31
+ {
32
+ name: "health",
33
+ description: "Check the assistant's health status",
34
+ ephemeral: true,
35
+ },
36
+ {
37
+ name: "help",
38
+ description: "Show available commands and usage information",
39
+ ephemeral: true,
40
+ },
41
+ {
42
+ name: "clear",
43
+ description: "Start a fresh conversation (clears session context)",
44
+ ephemeral: true,
45
+ },
46
+ ];
47
+
48
+ const VALID_NAME = /^[a-z0-9_-]{1,32}$/;
49
+ const MAX_DESCRIPTION_LENGTH = 100;
50
+ const MAX_CUSTOM_COMMANDS = 20;
51
+
52
+ function validateCommandOption(opt: unknown, cmdName: string): CustomCommandOption | null {
53
+ if (!opt || typeof opt !== "object") return null;
54
+ const o = opt as Record<string, unknown>;
55
+
56
+ if (typeof o.name !== "string" || !VALID_NAME.test(o.name)) {
57
+ log.warn("invalid_custom_command_option", { command: cmdName, option: o.name, reason: "invalid_name" });
58
+ return null;
59
+ }
60
+ if (typeof o.description !== "string" || o.description.length > MAX_DESCRIPTION_LENGTH) {
61
+ log.warn("invalid_custom_command_option", {
62
+ command: cmdName,
63
+ option: o.name,
64
+ reason: "invalid_description",
65
+ });
66
+ return null;
67
+ }
68
+
69
+ const validTypes = new Set<number>(Object.values(CommandOptionType));
70
+ const type = typeof o.type === "number" && validTypes.has(o.type)
71
+ ? (o.type as typeof CommandOptionType[keyof typeof CommandOptionType])
72
+ : CommandOptionType.STRING;
73
+
74
+ let choices: Array<{ name: string; value: string }> | undefined;
75
+ if (Array.isArray(o.choices)) {
76
+ choices = o.choices
77
+ .filter(
78
+ (c): c is { name: string; value: string } =>
79
+ typeof c === "object" && c !== null && typeof c.name === "string" && typeof c.value === "string",
80
+ )
81
+ .slice(0, 25);
82
+ }
83
+
84
+ return {
85
+ name: o.name,
86
+ description: o.description,
87
+ type,
88
+ required: typeof o.required === "boolean" ? o.required : false,
89
+ choices,
90
+ };
91
+ }
92
+
93
+ export function parseCustomCommands(raw: string | undefined): CustomCommandDef[] {
94
+ if (!raw?.trim()) return [];
95
+
96
+ let parsed: unknown;
97
+ try {
98
+ parsed = JSON.parse(raw);
99
+ } catch (error) {
100
+ log.error("custom_commands_parse_error", {
101
+ error: error instanceof Error ? error.message : String(error),
102
+ });
103
+ return [];
104
+ }
105
+
106
+ if (!Array.isArray(parsed)) {
107
+ log.error("custom_commands_invalid_format", { reason: "expected_array" });
108
+ return [];
109
+ }
110
+
111
+ const builtinNames = new Set(BUILTIN_COMMANDS.map((c) => c.name));
112
+ const commands: CustomCommandDef[] = [];
113
+
114
+ for (const entry of parsed.slice(0, MAX_CUSTOM_COMMANDS)) {
115
+ if (!entry || typeof entry !== "object") continue;
116
+ const e = entry as Record<string, unknown>;
117
+
118
+ if (typeof e.name !== "string" || !VALID_NAME.test(e.name)) {
119
+ log.warn("invalid_custom_command", { name: e.name, reason: "invalid_name" });
120
+ continue;
121
+ }
122
+
123
+ if (builtinNames.has(e.name)) {
124
+ log.warn("invalid_custom_command", { name: e.name, reason: "conflicts_with_builtin" });
125
+ continue;
126
+ }
127
+
128
+ if (typeof e.description !== "string" || e.description.length === 0 || e.description.length > MAX_DESCRIPTION_LENGTH) {
129
+ log.warn("invalid_custom_command", { name: e.name, reason: "invalid_description" });
130
+ continue;
131
+ }
132
+
133
+ let options: CustomCommandOption[] | undefined;
134
+ if (Array.isArray(e.options)) {
135
+ options = e.options
136
+ .map((o) => validateCommandOption(o, e.name as string))
137
+ .filter((o): o is CustomCommandOption => o !== null);
138
+ }
139
+
140
+ commands.push({
141
+ name: e.name,
142
+ description: e.description,
143
+ options,
144
+ promptTemplate: typeof e.promptTemplate === "string" ? e.promptTemplate : undefined,
145
+ ephemeral: typeof e.ephemeral === "boolean" ? e.ephemeral : false,
146
+ });
147
+ }
148
+
149
+ if (commands.length > 0) {
150
+ log.info("custom_commands_loaded", {
151
+ count: commands.length,
152
+ commands: commands.map((c) => c.name),
153
+ });
154
+ }
155
+
156
+ return commands;
157
+ }
158
+
159
+ export function buildCommandRegistry(customCommands: CustomCommandDef[]): {
160
+ all: CustomCommandDef[];
161
+ registrationPayload: Array<{
162
+ name: string;
163
+ description: string;
164
+ type: number;
165
+ options?: Array<{
166
+ name: string;
167
+ description: string;
168
+ type: number;
169
+ required?: boolean;
170
+ choices?: Array<{ name: string; value: string }>;
171
+ }>;
172
+ }>;
173
+ } {
174
+ const all = [...BUILTIN_COMMANDS, ...customCommands];
175
+
176
+ const registrationPayload = all.map((cmd) => ({
177
+ name: cmd.name,
178
+ description: cmd.description,
179
+ type: 1,
180
+ ...(cmd.options?.length
181
+ ? {
182
+ options: cmd.options.map((opt) => ({
183
+ name: opt.name,
184
+ description: opt.description,
185
+ type: opt.type,
186
+ required: opt.required ?? false,
187
+ ...(opt.choices?.length ? { choices: opt.choices } : {}),
188
+ })),
189
+ }
190
+ : {}),
191
+ }));
192
+
193
+ return { all, registrationPayload };
194
+ }
195
+
196
+ export function resolvePromptTemplate(template: string, options: Record<string, string>): string {
197
+ return template.replace(/\{\{(\w+)\}\}/g, (_, key: string) => options[key] ?? "");
198
+ }
199
+
200
+ export function findCommand(commands: CustomCommandDef[], name: string): CustomCommandDef | undefined {
201
+ return commands.find((c) => c.name === name);
202
+ }