@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 +64 -0
- package/package.json +24 -0
- package/src/commands.ts +202 -0
- package/src/index.test.ts +1154 -0
- package/src/index.ts +814 -0
- package/src/oc-event-hub.test.ts +100 -0
- package/src/oc-event-hub.ts +161 -0
- package/src/oc-events.ts +157 -0
- package/src/opencode.test.ts +78 -0
- package/src/opencode.ts +130 -0
- package/src/permissions.ts +55 -0
- package/src/runtime.ts +211 -0
- package/src/session.test.ts +74 -0
- package/src/stream-render.test.ts +127 -0
- package/src/stream-render.ts +482 -0
- package/src/types.ts +49 -0
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
|
+
}
|
package/src/commands.ts
ADDED
|
@@ -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
|
+
}
|