pi-discord-bot 0.1.1
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 +352 -0
- package/discord-policy.example.json +10 -0
- package/dist/agent-models.js +81 -0
- package/dist/agent-models.test.js +40 -0
- package/dist/agent-prompt.js +68 -0
- package/dist/agent-runner.js +101 -0
- package/dist/agent-session-ops.js +157 -0
- package/dist/agent-tools.js +224 -0
- package/dist/agent-tree.js +52 -0
- package/dist/agent-tree.test.js +31 -0
- package/dist/agent-types.js +1 -0
- package/dist/agent.js +93 -0
- package/dist/context.js +58 -0
- package/dist/context.test.js +35 -0
- package/dist/discord-context.js +190 -0
- package/dist/discord-guild-tools.js +142 -0
- package/dist/discord-interactions.js +142 -0
- package/dist/discord-policy.js +90 -0
- package/dist/discord-registry.js +22 -0
- package/dist/discord-registry.test.js +17 -0
- package/dist/discord-types.js +1 -0
- package/dist/discord-ui.js +172 -0
- package/dist/discord-ui.test.js +37 -0
- package/dist/discord.js +389 -0
- package/dist/log.js +9 -0
- package/dist/main.js +362 -0
- package/dist/store.js +60 -0
- package/docs/github-release-flow.md +237 -0
- package/docs/operator-env-config.md +278 -0
- package/docs/publishing-checklist.md +95 -0
- package/docs/using-skill-in-pi.md +128 -0
- package/package.json +66 -0
- package/pi-discord-bot.env.example +12 -0
- package/pi-discord-bot.service +16 -0
- package/skills/pi-discord-bot/SKILL.md +237 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, StringSelectMenuBuilder, } from "discord.js";
|
|
2
|
+
export function formatWorkingText(text, working) {
|
|
3
|
+
return working ? `${text}\n\n...` : text;
|
|
4
|
+
}
|
|
5
|
+
export function chunkText(text, max = 1900) {
|
|
6
|
+
if (text.length <= max)
|
|
7
|
+
return [text];
|
|
8
|
+
const chunks = [];
|
|
9
|
+
let remaining = text;
|
|
10
|
+
while (remaining.length > max) {
|
|
11
|
+
let cut = remaining.lastIndexOf("\n", max);
|
|
12
|
+
if (cut < max * 0.5)
|
|
13
|
+
cut = remaining.lastIndexOf(" ", max);
|
|
14
|
+
if (cut < max * 0.5)
|
|
15
|
+
cut = max;
|
|
16
|
+
chunks.push(remaining.slice(0, cut).trimEnd());
|
|
17
|
+
remaining = remaining.slice(cut).trimStart();
|
|
18
|
+
}
|
|
19
|
+
if (remaining.length > 0)
|
|
20
|
+
chunks.push(remaining);
|
|
21
|
+
return chunks;
|
|
22
|
+
}
|
|
23
|
+
export function truncate(text, max = 180) {
|
|
24
|
+
return text.length <= max ? text : `${text.slice(0, max - 3)}...`;
|
|
25
|
+
}
|
|
26
|
+
export async function sendOrEditAnchor(params) {
|
|
27
|
+
const { slashInteraction, sendable, text } = params;
|
|
28
|
+
if (slashInteraction) {
|
|
29
|
+
if (params.replyMessageId) {
|
|
30
|
+
await slashInteraction.editReply(text);
|
|
31
|
+
return { replyMessageId: params.replyMessageId };
|
|
32
|
+
}
|
|
33
|
+
const response = await slashInteraction.editReply(text);
|
|
34
|
+
return { replyMessageId: response.id };
|
|
35
|
+
}
|
|
36
|
+
if (params.replyMessageId) {
|
|
37
|
+
const message = await sendable.messages.fetch(params.replyMessageId);
|
|
38
|
+
await message.edit(text);
|
|
39
|
+
return { replyMessageId: params.replyMessageId };
|
|
40
|
+
}
|
|
41
|
+
const sent = await sendable.send(text);
|
|
42
|
+
return { replyMessageId: sent.id };
|
|
43
|
+
}
|
|
44
|
+
export function buildModelSelectionCard(params) {
|
|
45
|
+
const page = params.page ?? 0;
|
|
46
|
+
const pageSize = 25;
|
|
47
|
+
const pageCount = Math.max(1, Math.ceil(params.models.length / pageSize));
|
|
48
|
+
const safePage = Math.min(Math.max(page, 0), pageCount - 1);
|
|
49
|
+
const start = safePage * pageSize;
|
|
50
|
+
const pageModels = params.models.slice(start, start + pageSize);
|
|
51
|
+
const customId = `model:${params.messageId}:${Date.now()}:select`;
|
|
52
|
+
const options = pageModels.map((value) => ({ label: value.slice(0, 100), value, default: value === params.currentModel }));
|
|
53
|
+
const embed = new EmbedBuilder()
|
|
54
|
+
.setTitle(`🤖 ${params.title ?? "Select a model"}`)
|
|
55
|
+
.setDescription([
|
|
56
|
+
`> **Current model**\n> \`${params.currentModel}\``,
|
|
57
|
+
pageCount > 1 ? `📄 Page **${safePage + 1}** of **${pageCount}** · ${params.models.length} models available` : `${params.models.length} models available`,
|
|
58
|
+
"Use the dropdown to pick a model" + (pageCount > 1 ? ", or **Prev / Next** to browse." : "."),
|
|
59
|
+
].join("\n\n"))
|
|
60
|
+
.setColor(0x5865F2)
|
|
61
|
+
.setFooter({ text: "Selection expires in 2 minutes" })
|
|
62
|
+
.setTimestamp();
|
|
63
|
+
const rows = [
|
|
64
|
+
new ActionRowBuilder().addComponents(new StringSelectMenuBuilder().setCustomId(customId).setPlaceholder("Choose a model").addOptions(options)),
|
|
65
|
+
];
|
|
66
|
+
const prevId = `model:${params.messageId}:${Date.now()}:prev`;
|
|
67
|
+
const nextId = `model:${params.messageId}:${Date.now()}:next`;
|
|
68
|
+
const closeId = `model:${params.messageId}:${Date.now()}:close`;
|
|
69
|
+
if (pageCount > 1) {
|
|
70
|
+
rows.push(new ActionRowBuilder().addComponents(new ButtonBuilder().setCustomId(prevId).setLabel("Prev").setStyle(ButtonStyle.Secondary).setDisabled(safePage === 0), new ButtonBuilder().setCustomId(`model:${params.messageId}:${Date.now()}:page`).setLabel(`Page ${safePage + 1}/${pageCount}`).setStyle(ButtonStyle.Secondary).setDisabled(true), new ButtonBuilder().setCustomId(nextId).setLabel("Next").setStyle(ButtonStyle.Secondary).setDisabled(safePage >= pageCount - 1), new ButtonBuilder().setCustomId(closeId).setLabel("Done").setStyle(ButtonStyle.Success)));
|
|
71
|
+
}
|
|
72
|
+
return { embed, rows, ids: { customId, prevId, nextId, closeId }, pageCount };
|
|
73
|
+
}
|
|
74
|
+
export function buildScopedModelSelectionCard(params) {
|
|
75
|
+
const page = params.page ?? 0;
|
|
76
|
+
const pageSize = 25;
|
|
77
|
+
const pageCount = Math.max(1, Math.ceil(params.models.length / pageSize));
|
|
78
|
+
const safePage = Math.min(Math.max(page, 0), pageCount - 1);
|
|
79
|
+
const start = safePage * pageSize;
|
|
80
|
+
const pageModels = params.models.slice(start, start + pageSize);
|
|
81
|
+
const customId = `scoped:${params.messageId}:${Date.now()}:select`;
|
|
82
|
+
const options = pageModels.map((value) => ({ label: value.slice(0, 100), value, default: params.currentModels.includes(value) }));
|
|
83
|
+
const embed = new EmbedBuilder()
|
|
84
|
+
.setTitle("🎯 Scoped models")
|
|
85
|
+
.setDescription([
|
|
86
|
+
params.currentModels.length > 0
|
|
87
|
+
? `> **Current scope**\n${params.currentModels.map((model) => `> • \`${model}\``).join("\n")}`
|
|
88
|
+
: "> **Current scope**\n> All available models",
|
|
89
|
+
pageCount > 1 ? `📄 Page **${safePage + 1}** of **${pageCount}** · ${params.models.length} models available` : `${params.models.length} models available`,
|
|
90
|
+
"Select zero or more models, or browse with **Prev / Next**.",
|
|
91
|
+
].join("\n\n"))
|
|
92
|
+
.setColor(0x57F287)
|
|
93
|
+
.setFooter({ text: "Selection expires in 2 minutes" })
|
|
94
|
+
.setTimestamp();
|
|
95
|
+
const rows = [
|
|
96
|
+
new ActionRowBuilder().addComponents(new StringSelectMenuBuilder().setCustomId(customId).setPlaceholder("Select scoped models").setMinValues(0).setMaxValues(options.length).addOptions(options)),
|
|
97
|
+
];
|
|
98
|
+
const prevId = `scoped:${params.messageId}:${Date.now()}:prev`;
|
|
99
|
+
const nextId = `scoped:${params.messageId}:${Date.now()}:next`;
|
|
100
|
+
const closeId = `scoped:${params.messageId}:${Date.now()}:close`;
|
|
101
|
+
if (pageCount > 1) {
|
|
102
|
+
rows.push(new ActionRowBuilder().addComponents(new ButtonBuilder().setCustomId(prevId).setLabel("Prev").setStyle(ButtonStyle.Secondary).setDisabled(safePage === 0), new ButtonBuilder().setCustomId(`scoped:${params.messageId}:${Date.now()}:page`).setLabel(`Page ${safePage + 1}/${pageCount}`).setStyle(ButtonStyle.Secondary).setDisabled(true), new ButtonBuilder().setCustomId(nextId).setLabel("Next").setStyle(ButtonStyle.Secondary).setDisabled(safePage >= pageCount - 1), new ButtonBuilder().setCustomId(closeId).setLabel("Done").setStyle(ButtonStyle.Success)));
|
|
103
|
+
}
|
|
104
|
+
return { embed, rows, ids: { customId, prevId, nextId, closeId }, pageCount };
|
|
105
|
+
}
|
|
106
|
+
export function buildSessionCard(params) {
|
|
107
|
+
const embed = new EmbedBuilder().setTitle(params.title);
|
|
108
|
+
for (const field of params.fields)
|
|
109
|
+
embed.addFields({ name: field.name, value: field.value.slice(0, 1024), inline: field.inline ?? false });
|
|
110
|
+
return embed;
|
|
111
|
+
}
|
|
112
|
+
export function buildTreeSelectionCard(params) {
|
|
113
|
+
const page = params.page ?? 0;
|
|
114
|
+
const pageSize = 20;
|
|
115
|
+
const pageCount = Math.max(1, Math.ceil(params.entries.length / pageSize));
|
|
116
|
+
const safePage = Math.min(Math.max(page, 0), pageCount - 1);
|
|
117
|
+
const start = safePage * pageSize;
|
|
118
|
+
const pageEntries = params.entries.slice(start, start + pageSize);
|
|
119
|
+
const customId = `tree:${params.messageId}:${Date.now()}:select`;
|
|
120
|
+
const options = pageEntries.map((entry) => ({
|
|
121
|
+
label: `${entry.isCurrent ? "● " : ""}${"· ".repeat(Math.min(entry.depth, 3))}${entry.preview}`.slice(0, 100),
|
|
122
|
+
description: `${entry.type} · ${entry.id}${entry.label ? ` · ${entry.label}` : ""}`.slice(0, 100),
|
|
123
|
+
value: entry.id,
|
|
124
|
+
default: entry.isCurrent,
|
|
125
|
+
}));
|
|
126
|
+
const descriptionLines = [params.description];
|
|
127
|
+
if (pageEntries.length > 0) {
|
|
128
|
+
descriptionLines.push("", ...pageEntries.map((entry, index) => {
|
|
129
|
+
const prefix = entry.isCurrent ? "**● Current**" : `**${start + index + 1}.**`;
|
|
130
|
+
const indent = "› ".repeat(Math.min(entry.depth, 4));
|
|
131
|
+
return `${prefix} ${indent}\`${entry.id}\`\n${truncate(entry.preview, 140)}${entry.label ? `\n_Label:_ ${entry.label}` : ""}`;
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
134
|
+
const embed = new EmbedBuilder()
|
|
135
|
+
.setTitle(`🌳 ${params.title}`)
|
|
136
|
+
.setDescription(descriptionLines.join("\n").slice(0, 4096))
|
|
137
|
+
.setColor(0xFEE75C)
|
|
138
|
+
.setFooter({ text: `Page ${safePage + 1}/${pageCount} · Selection expires in 2 minutes` })
|
|
139
|
+
.setTimestamp();
|
|
140
|
+
const rows = [];
|
|
141
|
+
if (options.length > 0) {
|
|
142
|
+
rows.push(new ActionRowBuilder().addComponents(new StringSelectMenuBuilder().setCustomId(customId).setPlaceholder("Select an entry to navigate to").addOptions(options)));
|
|
143
|
+
}
|
|
144
|
+
const prevId = `tree:${params.messageId}:${Date.now()}:prev`;
|
|
145
|
+
const nextId = `tree:${params.messageId}:${Date.now()}:next`;
|
|
146
|
+
const closeId = `tree:${params.messageId}:${Date.now()}:close`;
|
|
147
|
+
rows.push(new ActionRowBuilder().addComponents(new ButtonBuilder().setCustomId(prevId).setLabel("Prev").setStyle(ButtonStyle.Secondary).setDisabled(safePage === 0), new ButtonBuilder().setCustomId(`tree:${params.messageId}:${Date.now()}:page`).setLabel(`Page ${safePage + 1}/${pageCount}`).setStyle(ButtonStyle.Secondary).setDisabled(true), new ButtonBuilder().setCustomId(nextId).setLabel("Next").setStyle(ButtonStyle.Secondary).setDisabled(safePage >= pageCount - 1), new ButtonBuilder().setCustomId(closeId).setLabel("Done").setStyle(ButtonStyle.Success)));
|
|
148
|
+
return { embed, rows, options, ids: { customId, prevId, nextId, closeId } };
|
|
149
|
+
}
|
|
150
|
+
export function buildSettingsCard(summary, baseId) {
|
|
151
|
+
const embed = new EmbedBuilder()
|
|
152
|
+
.setTitle("Settings")
|
|
153
|
+
.setDescription("Use the buttons below to update Pi settings for this Discord harness.")
|
|
154
|
+
.addFields({ name: "Current", value: summary.slice(0, 1024) });
|
|
155
|
+
const row1 = new ActionRowBuilder().addComponents(new ButtonBuilder().setCustomId(`${baseId}:thinking`).setLabel("Cycle thinking").setStyle(ButtonStyle.Primary), new ButtonBuilder().setCustomId(`${baseId}:transport`).setLabel("Cycle transport").setStyle(ButtonStyle.Primary));
|
|
156
|
+
const row2 = new ActionRowBuilder().addComponents(new ButtonBuilder().setCustomId(`${baseId}:steering`).setLabel("Toggle steering").setStyle(ButtonStyle.Secondary), new ButtonBuilder().setCustomId(`${baseId}:followup`).setLabel("Toggle follow-up").setStyle(ButtonStyle.Secondary), new ButtonBuilder().setCustomId(`${baseId}:compact`).setLabel("Toggle auto compact").setStyle(ButtonStyle.Secondary));
|
|
157
|
+
const row3 = new ActionRowBuilder().addComponents(new ButtonBuilder().setCustomId(`${baseId}:done`).setLabel("Done").setStyle(ButtonStyle.Success));
|
|
158
|
+
return { embed, rows: [row1, row2, row3] };
|
|
159
|
+
}
|
|
160
|
+
export function buildApprovalCard(params) {
|
|
161
|
+
const embed = new EmbedBuilder()
|
|
162
|
+
.setTitle(params.title)
|
|
163
|
+
.setDescription(params.description);
|
|
164
|
+
if (params.bullets && params.bullets.length > 0) {
|
|
165
|
+
embed.addFields({ name: "Summary", value: params.bullets.map((bullet) => `• ${bullet}`).join("\n").slice(0, 1024) });
|
|
166
|
+
}
|
|
167
|
+
if (params.caution) {
|
|
168
|
+
embed.addFields({ name: "Caution", value: params.caution.slice(0, 1024) });
|
|
169
|
+
}
|
|
170
|
+
const row = new ActionRowBuilder().addComponents(new ButtonBuilder().setCustomId(params.approveId).setLabel(params.approveLabel ?? "Approve").setStyle(ButtonStyle.Success), new ButtonBuilder().setCustomId(params.rejectId).setLabel("Cancel").setStyle(ButtonStyle.Secondary));
|
|
171
|
+
return { embed, rows: [row] };
|
|
172
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { buildApprovalCard, buildTreeSelectionCard, chunkText, formatWorkingText, truncate } from "./discord-ui.js";
|
|
4
|
+
test("chunkText splits long text into safe chunks", () => {
|
|
5
|
+
const text = "a ".repeat(1500);
|
|
6
|
+
const chunks = chunkText(text, 100);
|
|
7
|
+
assert.ok(chunks.length > 1);
|
|
8
|
+
assert.ok(chunks.every((chunk) => chunk.length <= 100));
|
|
9
|
+
});
|
|
10
|
+
test("formatWorkingText and truncate are stable", () => {
|
|
11
|
+
assert.equal(formatWorkingText("hello", true), "hello\n\n...");
|
|
12
|
+
assert.equal(formatWorkingText("hello", false), "hello");
|
|
13
|
+
assert.equal(truncate("abcdef", 5), "ab...");
|
|
14
|
+
});
|
|
15
|
+
test("buildTreeSelectionCard creates options and rows", () => {
|
|
16
|
+
const card = buildTreeSelectionCard({
|
|
17
|
+
title: "Session tree",
|
|
18
|
+
description: "desc",
|
|
19
|
+
messageId: "123",
|
|
20
|
+
entries: [
|
|
21
|
+
{ id: "a", depth: 0, type: "message", preview: "root", isCurrent: true },
|
|
22
|
+
{ id: "b", depth: 1, type: "message", preview: "child", isCurrent: false },
|
|
23
|
+
],
|
|
24
|
+
});
|
|
25
|
+
assert.equal(card.options.length, 2);
|
|
26
|
+
assert.equal(card.rows.length, 2);
|
|
27
|
+
});
|
|
28
|
+
test("buildApprovalCard includes action row", () => {
|
|
29
|
+
const card = buildApprovalCard({
|
|
30
|
+
title: "Approve",
|
|
31
|
+
description: "Do it?",
|
|
32
|
+
approveId: "yes",
|
|
33
|
+
rejectId: "no",
|
|
34
|
+
bullets: ["one"],
|
|
35
|
+
});
|
|
36
|
+
assert.equal(card.rows.length, 1);
|
|
37
|
+
});
|
package/dist/discord.js
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import { Client, GatewayIntentBits, Partials, } from "discord.js";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { createDiscordContext } from "./discord-context.js";
|
|
5
|
+
import { buildSlashEvent, handleButtonInteraction, handleSelectMenuInteraction, slashCommandToText, } from "./discord-interactions.js";
|
|
6
|
+
import { buildSlashCommands, isAllowedDiscordMessage, loadDiscordPolicy } from "./discord-policy.js";
|
|
7
|
+
import * as log from "./log.js";
|
|
8
|
+
import { buildApprovalCard, buildModelSelectionCard, buildScopedModelSelectionCard, buildSessionCard, buildSettingsCard, buildTreeSelectionCard, } from "./discord-ui.js";
|
|
9
|
+
import { clearPending, registerManyPending, registerPending, resolveOnTimeout } from "./discord-registry.js";
|
|
10
|
+
class ChannelQueue {
|
|
11
|
+
queue = [];
|
|
12
|
+
processing = false;
|
|
13
|
+
enqueue(fn) {
|
|
14
|
+
this.queue.push(fn);
|
|
15
|
+
void this.processNext();
|
|
16
|
+
}
|
|
17
|
+
async processNext() {
|
|
18
|
+
if (this.processing || this.queue.length === 0)
|
|
19
|
+
return;
|
|
20
|
+
this.processing = true;
|
|
21
|
+
const fn = this.queue.shift();
|
|
22
|
+
try {
|
|
23
|
+
await fn();
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
log.warn("queue error", err instanceof Error ? err.message : String(err));
|
|
27
|
+
}
|
|
28
|
+
this.processing = false;
|
|
29
|
+
await this.processNext();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export class DiscordBot {
|
|
33
|
+
handler;
|
|
34
|
+
store;
|
|
35
|
+
workingDir;
|
|
36
|
+
client;
|
|
37
|
+
queues = new Map();
|
|
38
|
+
botUserIdPromise;
|
|
39
|
+
pendingSlashInteractions = new Map();
|
|
40
|
+
pendingModelSelections = new Map();
|
|
41
|
+
pendingModelPages = new Map();
|
|
42
|
+
pendingScopedSelections = new Map();
|
|
43
|
+
pendingScopedPages = new Map();
|
|
44
|
+
pendingApprovals = new Map();
|
|
45
|
+
pendingSettingsActions = new Map();
|
|
46
|
+
pendingTreeSelections = new Map();
|
|
47
|
+
pendingTreePages = new Map();
|
|
48
|
+
constructor(token, handler, store, workingDir) {
|
|
49
|
+
this.handler = handler;
|
|
50
|
+
this.store = store;
|
|
51
|
+
this.workingDir = workingDir;
|
|
52
|
+
this.client = new Client({
|
|
53
|
+
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.DirectMessages, GatewayIntentBits.MessageContent],
|
|
54
|
+
partials: [Partials.Channel],
|
|
55
|
+
});
|
|
56
|
+
this.botUserIdPromise = new Promise((resolve) => {
|
|
57
|
+
this.client.once("ready", () => resolve(this.client.user.id));
|
|
58
|
+
});
|
|
59
|
+
this.client.on("messageCreate", (message) => {
|
|
60
|
+
void this.onMessage(message);
|
|
61
|
+
});
|
|
62
|
+
this.client.on("interactionCreate", (interaction) => {
|
|
63
|
+
if (interaction.isChatInputCommand())
|
|
64
|
+
void this.onSlashCommand(interaction);
|
|
65
|
+
else if (interaction.isStringSelectMenu())
|
|
66
|
+
void this.onSelectMenu(interaction);
|
|
67
|
+
else if (interaction.isButton())
|
|
68
|
+
void this.onButton(interaction);
|
|
69
|
+
});
|
|
70
|
+
void this.client.login(token);
|
|
71
|
+
}
|
|
72
|
+
get policyPath() {
|
|
73
|
+
return join(this.workingDir, "discord-policy.json");
|
|
74
|
+
}
|
|
75
|
+
loadPolicy() {
|
|
76
|
+
return loadDiscordPolicy(this.policyPath);
|
|
77
|
+
}
|
|
78
|
+
get interactionState() {
|
|
79
|
+
return {
|
|
80
|
+
pendingSlashInteractions: this.pendingSlashInteractions,
|
|
81
|
+
pendingModelSelections: this.pendingModelSelections,
|
|
82
|
+
pendingModelPages: this.pendingModelPages,
|
|
83
|
+
pendingScopedSelections: this.pendingScopedSelections,
|
|
84
|
+
pendingScopedPages: this.pendingScopedPages,
|
|
85
|
+
pendingApprovals: this.pendingApprovals,
|
|
86
|
+
pendingSettingsActions: this.pendingSettingsActions,
|
|
87
|
+
pendingTreeSelections: this.pendingTreeSelections,
|
|
88
|
+
pendingTreePages: this.pendingTreePages,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
queueFor(key) {
|
|
92
|
+
let queue = this.queues.get(key);
|
|
93
|
+
if (!queue) {
|
|
94
|
+
queue = new ChannelQueue();
|
|
95
|
+
this.queues.set(key, queue);
|
|
96
|
+
}
|
|
97
|
+
return queue;
|
|
98
|
+
}
|
|
99
|
+
async start() {
|
|
100
|
+
await this.botUserIdPromise;
|
|
101
|
+
log.info(`connected to Discord as ${this.client.user?.tag ?? this.client.user?.id}`);
|
|
102
|
+
await this.registerSlashCommands();
|
|
103
|
+
await this.backfillKnownConversations();
|
|
104
|
+
}
|
|
105
|
+
async registerSlashCommands() {
|
|
106
|
+
const policy = this.loadPolicy();
|
|
107
|
+
if (policy.slashCommands?.enabled === false)
|
|
108
|
+
return;
|
|
109
|
+
if (!this.client.application)
|
|
110
|
+
return;
|
|
111
|
+
const commands = buildSlashCommands();
|
|
112
|
+
const guildId = policy.slashCommands?.guildId;
|
|
113
|
+
if (guildId) {
|
|
114
|
+
await this.client.application.commands.set(commands, guildId);
|
|
115
|
+
log.info(`registered slash commands in guild ${guildId}`);
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
await this.client.application.commands.set(commands);
|
|
119
|
+
log.info("registered global slash commands");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async onMessage(message) {
|
|
123
|
+
if (message.author.bot)
|
|
124
|
+
return;
|
|
125
|
+
const botUserId = await this.botUserIdPromise;
|
|
126
|
+
const gate = isAllowedDiscordMessage(message, botUserId, this.loadPolicy());
|
|
127
|
+
if (!gate.allowed) {
|
|
128
|
+
log.info(`skipping message ${message.id}: ${gate.reason}`);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const conversationKey = this.getConversationKey(message);
|
|
132
|
+
const isDm = message.channel.type === 1;
|
|
133
|
+
const cleanedText = isDm ? message.content.trim() : message.content.replace(new RegExp(`<@!?${botUserId}>`, "g"), "").trim();
|
|
134
|
+
if (cleanedText.toLowerCase() === "stop") {
|
|
135
|
+
if (this.handler.isRunning(conversationKey))
|
|
136
|
+
await this.handler.handleStop(conversationKey, this);
|
|
137
|
+
else
|
|
138
|
+
await this.postMessage(message.channel, "Nothing running.");
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const attachments = await this.store.storeAttachments(conversationKey, message.attachments.values());
|
|
142
|
+
this.store.appendLog(conversationKey, {
|
|
143
|
+
date: new Date(message.createdTimestamp).toISOString(),
|
|
144
|
+
messageId: message.id,
|
|
145
|
+
channelId: message.channel.id,
|
|
146
|
+
guildId: message.guildId ?? undefined,
|
|
147
|
+
threadId: message.channel.isThread() ? message.channel.id : undefined,
|
|
148
|
+
authorId: message.author.id,
|
|
149
|
+
authorName: message.author.username,
|
|
150
|
+
text: cleanedText,
|
|
151
|
+
attachments,
|
|
152
|
+
isBot: false,
|
|
153
|
+
});
|
|
154
|
+
const event = {
|
|
155
|
+
type: isDm ? "dm" : "mention",
|
|
156
|
+
source: "message",
|
|
157
|
+
channelId: message.channel.id,
|
|
158
|
+
guildId: message.guildId ?? undefined,
|
|
159
|
+
threadId: message.channel.isThread() ? message.channel.id : undefined,
|
|
160
|
+
messageId: message.id,
|
|
161
|
+
userId: message.author.id,
|
|
162
|
+
userName: message.author.username,
|
|
163
|
+
text: cleanedText,
|
|
164
|
+
attachments: attachments,
|
|
165
|
+
};
|
|
166
|
+
this.queueFor(conversationKey).enqueue(() => this.handler.handleEvent(event, this));
|
|
167
|
+
}
|
|
168
|
+
async onSelectMenu(interaction) {
|
|
169
|
+
await handleSelectMenuInteraction(interaction, this.interactionState);
|
|
170
|
+
}
|
|
171
|
+
async onButton(interaction) {
|
|
172
|
+
await handleButtonInteraction(interaction, this.interactionState);
|
|
173
|
+
}
|
|
174
|
+
async onSlashCommand(interaction) {
|
|
175
|
+
const conversationKey = this.getConversationKeyFromIds(interaction.guildId ?? undefined, interaction.channelId, interaction.channel?.isThread() ? interaction.channel.id : undefined, interaction.user.id);
|
|
176
|
+
if (interaction.commandName === "stop") {
|
|
177
|
+
await interaction.reply({ content: "Stopping current run if there is one...", ephemeral: true });
|
|
178
|
+
await this.handler.handleStop(conversationKey, this);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const text = slashCommandToText(interaction);
|
|
182
|
+
if (!text)
|
|
183
|
+
return;
|
|
184
|
+
log.info(`slash command ${interaction.commandName} from ${interaction.user.username} in ${conversationKey}`);
|
|
185
|
+
await interaction.deferReply();
|
|
186
|
+
this.pendingSlashInteractions.set(interaction.id, interaction);
|
|
187
|
+
const event = buildSlashEvent(interaction, text);
|
|
188
|
+
const key = this.getConversationKeyFromIds(event.guildId, event.channelId, event.threadId, event.userId);
|
|
189
|
+
this.queueFor(key).enqueue(() => this.handler.handleEvent(event, this));
|
|
190
|
+
}
|
|
191
|
+
getConversationKey(message) {
|
|
192
|
+
return this.getConversationKeyFromIds(message.guildId ?? undefined, message.channel.id, message.channel.isThread() ? message.channel.id : undefined, message.author.id);
|
|
193
|
+
}
|
|
194
|
+
getConversationKeyFromIds(guildId, channelId, threadId, userId) {
|
|
195
|
+
if (threadId && guildId)
|
|
196
|
+
return `guild:${guildId}:thread:${threadId}`;
|
|
197
|
+
if (guildId)
|
|
198
|
+
return `guild:${guildId}:channel:${channelId}`;
|
|
199
|
+
return `dm:${userId}`;
|
|
200
|
+
}
|
|
201
|
+
async getEventTarget(event) {
|
|
202
|
+
const channel = await this.client.channels.fetch(event.channelId);
|
|
203
|
+
if (!channel?.isTextBased())
|
|
204
|
+
throw new Error(`Channel ${event.channelId} is not text-based`);
|
|
205
|
+
const slashInteraction = event.source === "slash" ? this.pendingSlashInteractions.get(event.messageId) ?? null : null;
|
|
206
|
+
return { channel, sendable: channel, slashInteraction };
|
|
207
|
+
}
|
|
208
|
+
async promptModelSelection(event, params) {
|
|
209
|
+
const { sendable, slashInteraction } = await this.getEventTarget(event);
|
|
210
|
+
const card = buildModelSelectionCard({ ...params, messageId: event.messageId });
|
|
211
|
+
if (slashInteraction)
|
|
212
|
+
await slashInteraction.editReply({ content: "", embeds: [card.embed], components: card.rows });
|
|
213
|
+
else
|
|
214
|
+
await sendable.send({ embeds: [card.embed], components: card.rows });
|
|
215
|
+
log.info(`opened model selector for ${event.userName}`);
|
|
216
|
+
return await new Promise((resolve) => {
|
|
217
|
+
registerPending({ registry: this.pendingModelSelections, key: card.ids.customId, userId: event.userId, resolve: (value) => resolve(value) });
|
|
218
|
+
if (card.pageCount > 1) {
|
|
219
|
+
registerManyPending({ registry: this.pendingModelPages, keys: [card.ids.prevId, card.ids.nextId, card.ids.closeId], userId: event.userId, resolve: (value) => resolve(value) });
|
|
220
|
+
}
|
|
221
|
+
resolveOnTimeout({ run: () => {
|
|
222
|
+
log.info(`model selector timed out for ${event.userName}`);
|
|
223
|
+
clearPending(this.pendingModelSelections, [card.ids.customId]);
|
|
224
|
+
clearPending(this.pendingModelPages, [card.ids.prevId, card.ids.nextId, card.ids.closeId]);
|
|
225
|
+
resolve(null);
|
|
226
|
+
return true;
|
|
227
|
+
} });
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
async promptScopedModelSelection(event, params) {
|
|
231
|
+
const { sendable, slashInteraction } = await this.getEventTarget(event);
|
|
232
|
+
const card = buildScopedModelSelectionCard({ ...params, messageId: event.messageId });
|
|
233
|
+
if (slashInteraction)
|
|
234
|
+
await slashInteraction.editReply({ content: "", embeds: [card.embed], components: card.rows });
|
|
235
|
+
else
|
|
236
|
+
await sendable.send({ embeds: [card.embed], components: card.rows });
|
|
237
|
+
return await new Promise((resolve) => {
|
|
238
|
+
registerPending({ registry: this.pendingScopedSelections, key: card.ids.customId, userId: event.userId, resolve: (value) => resolve(value) });
|
|
239
|
+
if (card.pageCount > 1) {
|
|
240
|
+
registerManyPending({ registry: this.pendingScopedPages, keys: [card.ids.prevId, card.ids.nextId, card.ids.closeId], userId: event.userId, resolve: (value) => resolve(value) });
|
|
241
|
+
}
|
|
242
|
+
resolveOnTimeout({ run: () => {
|
|
243
|
+
clearPending(this.pendingScopedSelections, [card.ids.customId]);
|
|
244
|
+
clearPending(this.pendingScopedPages, [card.ids.prevId, card.ids.nextId, card.ids.closeId]);
|
|
245
|
+
resolve(null);
|
|
246
|
+
return true;
|
|
247
|
+
} });
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
async showSessionCard(event, params) {
|
|
251
|
+
const { sendable, slashInteraction } = await this.getEventTarget(event);
|
|
252
|
+
const embed = buildSessionCard(params);
|
|
253
|
+
if (slashInteraction)
|
|
254
|
+
await slashInteraction.editReply({ content: "", embeds: [embed], components: [] });
|
|
255
|
+
else
|
|
256
|
+
await sendable.send({ embeds: [embed] });
|
|
257
|
+
}
|
|
258
|
+
async promptTreeSelection(event, params) {
|
|
259
|
+
const { sendable, slashInteraction } = await this.getEventTarget(event);
|
|
260
|
+
const card = buildTreeSelectionCard({ ...params, messageId: event.messageId });
|
|
261
|
+
if (slashInteraction)
|
|
262
|
+
await slashInteraction.editReply({ content: "", embeds: [card.embed], components: card.rows });
|
|
263
|
+
else
|
|
264
|
+
await sendable.send({ embeds: [card.embed], components: card.rows });
|
|
265
|
+
log.info(`opened tree browser for ${event.userName}`);
|
|
266
|
+
return await new Promise((resolve) => {
|
|
267
|
+
if (card.options.length > 0)
|
|
268
|
+
registerPending({ registry: this.pendingTreeSelections, key: card.ids.customId, userId: event.userId, resolve: (value) => resolve(value) });
|
|
269
|
+
registerManyPending({ registry: this.pendingTreePages, keys: [card.ids.prevId, card.ids.nextId, card.ids.closeId], userId: event.userId, resolve: (value) => resolve(value) });
|
|
270
|
+
resolveOnTimeout({ run: () => {
|
|
271
|
+
log.info(`tree browser timed out for ${event.userName}`);
|
|
272
|
+
if (card.options.length > 0)
|
|
273
|
+
clearPending(this.pendingTreeSelections, [card.ids.customId]);
|
|
274
|
+
clearPending(this.pendingTreePages, [card.ids.prevId, card.ids.nextId, card.ids.closeId]);
|
|
275
|
+
resolve(null);
|
|
276
|
+
return true;
|
|
277
|
+
} });
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
async promptSettingsCard(event, summary) {
|
|
281
|
+
const { sendable, slashInteraction } = await this.getEventTarget(event);
|
|
282
|
+
const baseId = `settings:${event.messageId}:${Date.now()}`;
|
|
283
|
+
const card = buildSettingsCard(summary, baseId);
|
|
284
|
+
if (slashInteraction)
|
|
285
|
+
await slashInteraction.editReply({ content: "", embeds: [card.embed], components: card.rows });
|
|
286
|
+
else
|
|
287
|
+
await sendable.send({ embeds: [card.embed], components: card.rows });
|
|
288
|
+
return await new Promise((resolve) => {
|
|
289
|
+
const keys = ["thinking", "transport", "steering", "followup", "compact", "done"].map((suffix) => `${baseId}:${suffix}`);
|
|
290
|
+
registerManyPending({ registry: this.pendingSettingsActions, keys, userId: event.userId, resolve });
|
|
291
|
+
resolveOnTimeout({ run: () => {
|
|
292
|
+
if (clearPending(this.pendingSettingsActions, keys))
|
|
293
|
+
resolve(null);
|
|
294
|
+
return true;
|
|
295
|
+
} });
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
async requestApproval(event, params) {
|
|
299
|
+
const { sendable, slashInteraction } = await this.getEventTarget(event);
|
|
300
|
+
const baseId = `approve:${event.messageId}:${Date.now()}`;
|
|
301
|
+
const approveId = `${baseId}:approve`;
|
|
302
|
+
const rejectId = `${baseId}:reject`;
|
|
303
|
+
const card = buildApprovalCard({ ...params, approveId, rejectId });
|
|
304
|
+
if (slashInteraction)
|
|
305
|
+
await slashInteraction.editReply({ content: "", embeds: [card.embed], components: card.rows });
|
|
306
|
+
else
|
|
307
|
+
await sendable.send({ embeds: [card.embed], components: card.rows });
|
|
308
|
+
log.info(`opened approval card for ${event.userName}: ${params.title}`);
|
|
309
|
+
return await new Promise((resolve) => {
|
|
310
|
+
registerManyPending({ registry: this.pendingApprovals, keys: [approveId, rejectId], userId: event.userId, resolve });
|
|
311
|
+
resolveOnTimeout({ run: () => {
|
|
312
|
+
if (clearPending(this.pendingApprovals, [approveId, rejectId]))
|
|
313
|
+
resolve(false);
|
|
314
|
+
return true;
|
|
315
|
+
} });
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
async createContext(event) {
|
|
319
|
+
return createDiscordContext({
|
|
320
|
+
client: this.client,
|
|
321
|
+
store: this.store,
|
|
322
|
+
event,
|
|
323
|
+
pendingSlashInteractions: this.pendingSlashInteractions,
|
|
324
|
+
getConversationKeyFromIds: (guildId, channelId, threadId, userId) => this.getConversationKeyFromIds(guildId, channelId, threadId, userId),
|
|
325
|
+
requestApproval: (approvalEvent, approvalParams) => this.requestApproval(approvalEvent, approvalParams),
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
async postMessage(channel, text) {
|
|
329
|
+
const resolved = typeof channel === "string" ? await this.client.channels.fetch(channel) : channel;
|
|
330
|
+
if (!resolved?.isTextBased())
|
|
331
|
+
throw new Error("Channel is not text-based");
|
|
332
|
+
const msg = await resolved.send(text);
|
|
333
|
+
return msg.id;
|
|
334
|
+
}
|
|
335
|
+
async backfillKnownConversations() {
|
|
336
|
+
const entries = readdirSync(this.workingDir, { withFileTypes: true }).filter((entry) => entry.isDirectory());
|
|
337
|
+
for (const entry of entries) {
|
|
338
|
+
const key = entry.name;
|
|
339
|
+
const logPath = join(this.workingDir, key, "log.jsonl");
|
|
340
|
+
if (!existsSync(logPath))
|
|
341
|
+
continue;
|
|
342
|
+
const lines = readFileSync(logPath, "utf-8").split("\n").filter(Boolean);
|
|
343
|
+
if (lines.length === 0)
|
|
344
|
+
continue;
|
|
345
|
+
let last = null;
|
|
346
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
347
|
+
try {
|
|
348
|
+
const parsed = JSON.parse(lines[i]);
|
|
349
|
+
if (parsed.channelId && parsed.messageId) {
|
|
350
|
+
last = parsed;
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
catch { }
|
|
355
|
+
}
|
|
356
|
+
if (!last?.channelId)
|
|
357
|
+
continue;
|
|
358
|
+
try {
|
|
359
|
+
const channel = await this.client.channels.fetch(last.channelId);
|
|
360
|
+
if (!channel?.isTextBased())
|
|
361
|
+
continue;
|
|
362
|
+
const messages = await channel.messages.fetch({ limit: 100, after: last.messageId });
|
|
363
|
+
const sorted = [...messages.values()].sort((a, b) => a.createdTimestamp - b.createdTimestamp);
|
|
364
|
+
for (const message of sorted) {
|
|
365
|
+
if (!message.author)
|
|
366
|
+
continue;
|
|
367
|
+
const attachments = message.author.bot ? [] : await this.store.storeAttachments(key, message.attachments.values());
|
|
368
|
+
this.store.appendLog(key, {
|
|
369
|
+
date: new Date(message.createdTimestamp).toISOString(),
|
|
370
|
+
messageId: message.id,
|
|
371
|
+
channelId: message.channel.id,
|
|
372
|
+
guildId: message.guildId ?? undefined,
|
|
373
|
+
threadId: message.channel.isThread() ? message.channel.id : undefined,
|
|
374
|
+
authorId: message.author.id,
|
|
375
|
+
authorName: message.author.username,
|
|
376
|
+
text: message.author.bot ? message.content : message.content.trim(),
|
|
377
|
+
attachments,
|
|
378
|
+
isBot: message.author.bot,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
if (sorted.length > 0)
|
|
382
|
+
log.info(`backfilled ${sorted.length} messages for ${key}`);
|
|
383
|
+
}
|
|
384
|
+
catch (err) {
|
|
385
|
+
log.warn(`backfill failed for ${key}`, err instanceof Error ? err.message : String(err));
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
package/dist/log.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function info(message) {
|
|
2
|
+
console.log(`[info] ${message}`);
|
|
3
|
+
}
|
|
4
|
+
export function warn(message, error) {
|
|
5
|
+
console.warn(`[warn] ${message}${error ? `: ${error}` : ""}`);
|
|
6
|
+
}
|
|
7
|
+
export function error(message, err) {
|
|
8
|
+
console.error(`[error] ${message}`, err);
|
|
9
|
+
}
|