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,190 @@
|
|
|
1
|
+
import { chunkText, formatWorkingText, sendOrEditAnchor } from "./discord-ui.js";
|
|
2
|
+
import { createGuildToolApi } from "./discord-guild-tools.js";
|
|
3
|
+
export async function createDiscordContext(params) {
|
|
4
|
+
const { client, store, event, pendingSlashInteractions, getConversationKeyFromIds, requestApproval } = params;
|
|
5
|
+
const channel = await client.channels.fetch(event.channelId);
|
|
6
|
+
if (!channel?.isTextBased())
|
|
7
|
+
throw new Error(`Channel ${event.channelId} is not text-based`);
|
|
8
|
+
const slashInteraction = event.source === "slash" ? pendingSlashInteractions.get(event.messageId) ?? null : null;
|
|
9
|
+
const channels = [...client.channels.cache.values()]
|
|
10
|
+
.filter((c) => c.isTextBased())
|
|
11
|
+
.map((c) => ({ id: c.id, name: "name" in c ? (c.name ?? c.id) : c.id }));
|
|
12
|
+
const users = [...client.users.cache.values()].map((u) => ({ id: u.id, userName: u.username, displayName: u.displayName ?? u.username }));
|
|
13
|
+
let replyMessageId = null;
|
|
14
|
+
let currentText = "";
|
|
15
|
+
let working = true;
|
|
16
|
+
let detailThreadId = null;
|
|
17
|
+
let typingInterval = null;
|
|
18
|
+
const sendable = channel;
|
|
19
|
+
const fetchAnchorMessage = async () => {
|
|
20
|
+
if (!replyMessageId)
|
|
21
|
+
return null;
|
|
22
|
+
if (slashInteraction) {
|
|
23
|
+
try {
|
|
24
|
+
return await slashInteraction.fetchReply();
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
return await sendable.messages.fetch(replyMessageId);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
const sourceMessage = event.source === "message" ? await sendable.messages.fetch(event.messageId).catch(() => null) : null;
|
|
38
|
+
const syncReaction = async (emoji, enabled) => {
|
|
39
|
+
if (!sourceMessage)
|
|
40
|
+
return;
|
|
41
|
+
try {
|
|
42
|
+
const existing = sourceMessage.reactions.cache.find((reaction) => reaction.emoji?.name === emoji);
|
|
43
|
+
if (enabled) {
|
|
44
|
+
if (!existing)
|
|
45
|
+
await sourceMessage.react(emoji);
|
|
46
|
+
}
|
|
47
|
+
else if (existing) {
|
|
48
|
+
await existing.users.remove(client.user?.id);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch { }
|
|
52
|
+
};
|
|
53
|
+
const sendOrEdit = async (text, shouldLog = true) => {
|
|
54
|
+
currentText = text;
|
|
55
|
+
const display = formatWorkingText(text, working);
|
|
56
|
+
const anchor = await sendOrEditAnchor({ slashInteraction, sendable, replyMessageId, text: display });
|
|
57
|
+
replyMessageId = anchor.replyMessageId;
|
|
58
|
+
if (shouldLog && replyMessageId) {
|
|
59
|
+
const key = getConversationKeyFromIds(event.guildId, event.channelId, event.threadId, event.userId);
|
|
60
|
+
const entry = {
|
|
61
|
+
date: new Date().toISOString(),
|
|
62
|
+
messageId: replyMessageId,
|
|
63
|
+
channelId: event.channelId,
|
|
64
|
+
guildId: event.guildId,
|
|
65
|
+
threadId: event.threadId,
|
|
66
|
+
authorId: client.user?.id,
|
|
67
|
+
authorName: client.user?.username,
|
|
68
|
+
text,
|
|
69
|
+
isBot: true,
|
|
70
|
+
};
|
|
71
|
+
store.appendLog(key, entry);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
const getDetailSendable = async () => {
|
|
75
|
+
if (channel.isDMBased())
|
|
76
|
+
return null;
|
|
77
|
+
if (channel.isThread())
|
|
78
|
+
return sendable;
|
|
79
|
+
if (detailThreadId) {
|
|
80
|
+
const existing = await client.channels.fetch(detailThreadId).catch(() => null);
|
|
81
|
+
if (existing?.isTextBased())
|
|
82
|
+
return existing;
|
|
83
|
+
}
|
|
84
|
+
const base = await fetchAnchorMessage();
|
|
85
|
+
if (base && typeof base.startThread === "function") {
|
|
86
|
+
const thread = await base.startThread({ name: `Details · ${event.userName}`.slice(0, 100), autoArchiveDuration: 1440 }).catch(() => null);
|
|
87
|
+
if (thread) {
|
|
88
|
+
detailThreadId = thread.id;
|
|
89
|
+
return thread;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return sendable;
|
|
93
|
+
};
|
|
94
|
+
const guildApi = createGuildToolApi({ client, event, channel, sendable, fetchAnchorMessage });
|
|
95
|
+
return {
|
|
96
|
+
message: event,
|
|
97
|
+
channels,
|
|
98
|
+
users,
|
|
99
|
+
respond: async (text, shouldLog = true) => sendOrEdit(currentText ? `${currentText}\n${text}` : text, shouldLog),
|
|
100
|
+
replaceMessage: async (text) => {
|
|
101
|
+
const parts = chunkText(text);
|
|
102
|
+
await sendOrEdit(parts[0] ?? "", false);
|
|
103
|
+
for (const part of parts.slice(1))
|
|
104
|
+
await sendable.send(part);
|
|
105
|
+
},
|
|
106
|
+
respondInThread: async (text) => {
|
|
107
|
+
if (channel.isDMBased())
|
|
108
|
+
return;
|
|
109
|
+
const detailSendable = await getDetailSendable();
|
|
110
|
+
if (!detailSendable)
|
|
111
|
+
return;
|
|
112
|
+
for (const part of chunkText(text))
|
|
113
|
+
await detailSendable.send(part);
|
|
114
|
+
},
|
|
115
|
+
setTyping: async (isTyping) => {
|
|
116
|
+
if (typingInterval) {
|
|
117
|
+
clearInterval(typingInterval);
|
|
118
|
+
typingInterval = null;
|
|
119
|
+
}
|
|
120
|
+
if (!isTyping)
|
|
121
|
+
return;
|
|
122
|
+
if (slashInteraction) {
|
|
123
|
+
if (!replyMessageId) {
|
|
124
|
+
const response = await slashInteraction.editReply("Thinking...");
|
|
125
|
+
replyMessageId = response.id;
|
|
126
|
+
currentText = "Thinking...";
|
|
127
|
+
}
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (typeof sendable.sendTyping === "function") {
|
|
131
|
+
await sendable.sendTyping();
|
|
132
|
+
typingInterval = setInterval(() => {
|
|
133
|
+
void sendable.sendTyping().catch(() => { });
|
|
134
|
+
}, 7000);
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
uploadFile: async (filePath, title) => {
|
|
138
|
+
if (channel.isDMBased()) {
|
|
139
|
+
await sendable.send({ files: [{ attachment: filePath, name: title }] });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const base = await fetchAnchorMessage();
|
|
143
|
+
if (base && typeof base.reply === "function") {
|
|
144
|
+
await base.reply({ files: [{ attachment: filePath, name: title }] });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (slashInteraction) {
|
|
148
|
+
const response = await slashInteraction.editReply(currentText || "Working...");
|
|
149
|
+
replyMessageId = response.id;
|
|
150
|
+
if (typeof response.reply === "function") {
|
|
151
|
+
await response.reply({ files: [{ attachment: filePath, name: title }] });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
await sendable.send({ files: [{ attachment: filePath, name: title }] });
|
|
156
|
+
},
|
|
157
|
+
setWorking: async (isWorking) => {
|
|
158
|
+
working = isWorking;
|
|
159
|
+
await syncReaction("🤔", isWorking);
|
|
160
|
+
if (!isWorking)
|
|
161
|
+
await syncReaction("🧑💻", false);
|
|
162
|
+
if (!replyMessageId)
|
|
163
|
+
return;
|
|
164
|
+
const anchor = await sendOrEditAnchor({ slashInteraction, sendable, replyMessageId, text: formatWorkingText(currentText, working) });
|
|
165
|
+
replyMessageId = anchor.replyMessageId;
|
|
166
|
+
},
|
|
167
|
+
setToolActive: async (active) => {
|
|
168
|
+
await syncReaction("🧑💻", active);
|
|
169
|
+
},
|
|
170
|
+
deleteMessage: async () => {
|
|
171
|
+
if (typingInterval) {
|
|
172
|
+
clearInterval(typingInterval);
|
|
173
|
+
typingInterval = null;
|
|
174
|
+
}
|
|
175
|
+
if (!replyMessageId)
|
|
176
|
+
return;
|
|
177
|
+
if (slashInteraction) {
|
|
178
|
+
await slashInteraction.deleteReply();
|
|
179
|
+
pendingSlashInteractions.delete(event.messageId);
|
|
180
|
+
replyMessageId = null;
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const msg = await sendable.messages.fetch(replyMessageId);
|
|
184
|
+
await msg.delete();
|
|
185
|
+
replyMessageId = null;
|
|
186
|
+
},
|
|
187
|
+
confirmAction: async (approvalParams) => requestApproval(event, approvalParams),
|
|
188
|
+
...guildApi,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { ChannelType, PermissionFlagsBits } from "discord.js";
|
|
2
|
+
export function createGuildToolApi(params) {
|
|
3
|
+
const { client, event, channel, sendable, fetchAnchorMessage } = params;
|
|
4
|
+
return {
|
|
5
|
+
listGuildChannels: async () => {
|
|
6
|
+
if (!event.guildId)
|
|
7
|
+
throw new Error("Channel management is only available in guilds.");
|
|
8
|
+
const guild = await client.guilds.fetch(event.guildId);
|
|
9
|
+
await guild.channels.fetch();
|
|
10
|
+
return Array.from(guild.channels.cache.values())
|
|
11
|
+
.map((c) => ({ id: c.id, name: c.name ?? c.id, type: String(c.type), parentId: c.parentId ?? undefined }))
|
|
12
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
13
|
+
},
|
|
14
|
+
resolveGuildChannel: async (query) => {
|
|
15
|
+
if (!event.guildId)
|
|
16
|
+
throw new Error("Channel lookup is only available in guilds.");
|
|
17
|
+
const guild = await client.guilds.fetch(event.guildId);
|
|
18
|
+
await guild.channels.fetch();
|
|
19
|
+
const channels = Array.from(guild.channels.cache.values())
|
|
20
|
+
.map((c) => ({ id: c.id, name: c.name ?? c.id, type: String(c.type), parentId: c.parentId ?? undefined }));
|
|
21
|
+
const needle = query.trim().toLowerCase();
|
|
22
|
+
const exact = channels.find((c) => c.id === query || c.name.toLowerCase() === needle);
|
|
23
|
+
if (exact)
|
|
24
|
+
return exact;
|
|
25
|
+
const partial = channels.filter((c) => c.name.toLowerCase().includes(needle));
|
|
26
|
+
if (partial.length === 1)
|
|
27
|
+
return partial[0];
|
|
28
|
+
if (partial.length === 0)
|
|
29
|
+
throw new Error(`No channel or category matches: ${query}`);
|
|
30
|
+
throw new Error(`Channel query is ambiguous: ${partial.slice(0, 8).map((c) => `${c.name} (${c.id})`).join(", ")}`);
|
|
31
|
+
},
|
|
32
|
+
resolveGuildMember: async (query) => {
|
|
33
|
+
if (!event.guildId)
|
|
34
|
+
throw new Error("Member lookup is only available in guilds.");
|
|
35
|
+
const guild = await client.guilds.fetch(event.guildId);
|
|
36
|
+
await guild.members.fetch();
|
|
37
|
+
const members = Array.from(guild.members.cache.values()).map((m) => ({ id: m.id, userName: m.user?.username ?? m.id, displayName: m.displayName ?? m.user?.username ?? m.id }));
|
|
38
|
+
const needle = query.trim().toLowerCase();
|
|
39
|
+
const exact = members.find((m) => m.id === query || m.userName.toLowerCase() === needle || m.displayName.toLowerCase() === needle);
|
|
40
|
+
if (exact)
|
|
41
|
+
return exact;
|
|
42
|
+
const partial = members.filter((m) => m.userName.toLowerCase().includes(needle) || m.displayName.toLowerCase().includes(needle));
|
|
43
|
+
if (partial.length === 1)
|
|
44
|
+
return partial[0];
|
|
45
|
+
if (partial.length === 0)
|
|
46
|
+
throw new Error(`No guild member matches: ${query}`);
|
|
47
|
+
throw new Error(`Member query is ambiguous: ${partial.slice(0, 8).map((m) => `${m.displayName} (${m.id})`).join(", ")}`);
|
|
48
|
+
},
|
|
49
|
+
resolveGuildRole: async (query) => {
|
|
50
|
+
if (!event.guildId)
|
|
51
|
+
throw new Error("Role lookup is only available in guilds.");
|
|
52
|
+
const guild = await client.guilds.fetch(event.guildId);
|
|
53
|
+
await guild.roles.fetch();
|
|
54
|
+
const roles = Array.from(guild.roles.cache.values()).map((r) => ({ id: r.id, name: r.name ?? r.id }));
|
|
55
|
+
const needle = query.trim().toLowerCase();
|
|
56
|
+
const exact = roles.find((r) => r.id === query || r.name.toLowerCase() === needle);
|
|
57
|
+
if (exact)
|
|
58
|
+
return exact;
|
|
59
|
+
const partial = roles.filter((r) => r.name.toLowerCase().includes(needle));
|
|
60
|
+
if (partial.length === 1)
|
|
61
|
+
return partial[0];
|
|
62
|
+
if (partial.length === 0)
|
|
63
|
+
throw new Error(`No role matches: ${query}`);
|
|
64
|
+
throw new Error(`Role query is ambiguous: ${partial.slice(0, 8).map((r) => `${r.name} (${r.id})`).join(", ")}`);
|
|
65
|
+
},
|
|
66
|
+
createGuildTextChannel: async ({ name, parentId, topic, private: isPrivate, memberIds, roleIds }) => {
|
|
67
|
+
if (!event.guildId)
|
|
68
|
+
throw new Error("Channel management is only available in guilds.");
|
|
69
|
+
const guild = await client.guilds.fetch(event.guildId);
|
|
70
|
+
const overwrites = isPrivate
|
|
71
|
+
? [
|
|
72
|
+
{ id: guild.roles.everyone.id, deny: [PermissionFlagsBits.ViewChannel] },
|
|
73
|
+
{ id: event.userId, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory] },
|
|
74
|
+
...(memberIds ?? []).filter((id) => id !== event.userId).map((id) => ({ id, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory] })),
|
|
75
|
+
...(roleIds ?? []).map((id) => ({ id, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory] })),
|
|
76
|
+
{ id: client.user.id, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory, PermissionFlagsBits.ManageChannels, PermissionFlagsBits.CreatePublicThreads, PermissionFlagsBits.CreatePrivateThreads] },
|
|
77
|
+
]
|
|
78
|
+
: undefined;
|
|
79
|
+
const created = await guild.channels.create({ name, type: ChannelType.GuildText, parent: parentId, topic, permissionOverwrites: overwrites });
|
|
80
|
+
return { id: created.id, name: "name" in created ? (created.name ?? name) : name };
|
|
81
|
+
},
|
|
82
|
+
createGuildCategory: async ({ name }) => {
|
|
83
|
+
if (!event.guildId)
|
|
84
|
+
throw new Error("Channel management is only available in guilds.");
|
|
85
|
+
const guild = await client.guilds.fetch(event.guildId);
|
|
86
|
+
const created = await guild.channels.create({ name, type: ChannelType.GuildCategory });
|
|
87
|
+
return { id: created.id, name: "name" in created ? (created.name ?? name) : name };
|
|
88
|
+
},
|
|
89
|
+
renameGuildChannel: async ({ channelId, name }) => {
|
|
90
|
+
const target = await client.channels.fetch(channelId);
|
|
91
|
+
if (!target || !("setName" in target) || typeof target.setName !== "function")
|
|
92
|
+
throw new Error(`Channel ${channelId} cannot be renamed.`);
|
|
93
|
+
const updated = await target.setName(name);
|
|
94
|
+
return { id: updated.id, name: updated.name ?? name };
|
|
95
|
+
},
|
|
96
|
+
moveGuildChannel: async ({ channelId, parentId }) => {
|
|
97
|
+
const target = await client.channels.fetch(channelId);
|
|
98
|
+
if (!target || !("setParent" in target) || typeof target.setParent !== "function")
|
|
99
|
+
throw new Error(`Channel ${channelId} cannot be moved.`);
|
|
100
|
+
const updated = await target.setParent(parentId ?? null);
|
|
101
|
+
return { id: updated.id, name: updated.name ?? channelId, parentId: updated.parentId ?? null };
|
|
102
|
+
},
|
|
103
|
+
deleteGuildChannel: async ({ channelId }) => {
|
|
104
|
+
const target = await client.channels.fetch(channelId);
|
|
105
|
+
if (!target || !("delete" in target) || typeof target.delete !== "function")
|
|
106
|
+
throw new Error(`Channel ${channelId} cannot be deleted.`);
|
|
107
|
+
await target.delete();
|
|
108
|
+
return { id: channelId };
|
|
109
|
+
},
|
|
110
|
+
createThreadFromCurrentChannel: async ({ name, autoArchiveDuration }) => {
|
|
111
|
+
if (channel.isThread())
|
|
112
|
+
throw new Error("Already in a thread.");
|
|
113
|
+
const base = await fetchAnchorMessage();
|
|
114
|
+
if (base && typeof base.startThread === "function") {
|
|
115
|
+
const thread = await base.startThread({ name, autoArchiveDuration: autoArchiveDuration ?? 1440 });
|
|
116
|
+
return { id: thread.id, name: thread.name ?? name };
|
|
117
|
+
}
|
|
118
|
+
if ("threads" in sendable && sendable.threads?.create) {
|
|
119
|
+
const thread = await sendable.threads.create({ name, autoArchiveDuration: autoArchiveDuration ?? 1440 });
|
|
120
|
+
return { id: thread.id, name: thread.name ?? name };
|
|
121
|
+
}
|
|
122
|
+
throw new Error("This channel does not support thread creation.");
|
|
123
|
+
},
|
|
124
|
+
renameThread: async ({ threadId, name }) => {
|
|
125
|
+
const target = await client.channels.fetch(threadId);
|
|
126
|
+
if (!target?.isThread())
|
|
127
|
+
throw new Error(`Thread ${threadId} not found.`);
|
|
128
|
+
const updated = await target.setName(name);
|
|
129
|
+
return { id: updated.id, name: updated.name ?? name };
|
|
130
|
+
},
|
|
131
|
+
archiveThread: async ({ threadId, archived, locked }) => {
|
|
132
|
+
const target = await client.channels.fetch(threadId);
|
|
133
|
+
if (!target?.isThread())
|
|
134
|
+
throw new Error(`Thread ${threadId} not found.`);
|
|
135
|
+
const updated = await target.setArchived(archived ?? true);
|
|
136
|
+
if (typeof locked === "boolean" && "setLocked" in updated && typeof updated.setLocked === "function") {
|
|
137
|
+
await updated.setLocked(locked);
|
|
138
|
+
}
|
|
139
|
+
return { id: updated.id, archived: archived ?? true, locked };
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import * as log from "./log.js";
|
|
2
|
+
async function rejectForeignInteraction(interaction, message) {
|
|
3
|
+
await interaction.reply({ content: message, ephemeral: true });
|
|
4
|
+
return true;
|
|
5
|
+
}
|
|
6
|
+
export async function handleSelectMenuInteraction(interaction, state) {
|
|
7
|
+
const modelRequest = state.pendingModelSelections.get(interaction.customId);
|
|
8
|
+
if (modelRequest) {
|
|
9
|
+
if (interaction.user.id !== modelRequest.userId)
|
|
10
|
+
return rejectForeignInteraction(interaction, "This selector belongs to another user.");
|
|
11
|
+
state.pendingModelSelections.delete(interaction.customId);
|
|
12
|
+
await interaction.update({ content: `Selected model: ${interaction.values[0]}`, embeds: [], components: [] });
|
|
13
|
+
modelRequest.resolve(interaction.values[0] ?? null);
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
const scopedRequest = state.pendingScopedSelections.get(interaction.customId);
|
|
17
|
+
if (scopedRequest) {
|
|
18
|
+
if (interaction.user.id !== scopedRequest.userId)
|
|
19
|
+
return rejectForeignInteraction(interaction, "This selector belongs to another user.");
|
|
20
|
+
state.pendingScopedSelections.delete(interaction.customId);
|
|
21
|
+
await interaction.update({ content: interaction.values.length > 0 ? `Scoped models updated (${interaction.values.length} selected).` : "Scoped models cleared.", embeds: [], components: [] });
|
|
22
|
+
scopedRequest.resolve(interaction.values);
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
const treeRequest = state.pendingTreeSelections.get(interaction.customId);
|
|
26
|
+
if (treeRequest) {
|
|
27
|
+
if (interaction.user.id !== treeRequest.userId)
|
|
28
|
+
return rejectForeignInteraction(interaction, "This tree browser belongs to another user.");
|
|
29
|
+
state.pendingTreeSelections.delete(interaction.customId);
|
|
30
|
+
await interaction.update({ content: `Navigating to ${interaction.values[0]}...`, embeds: [], components: [] });
|
|
31
|
+
log.info(`tree navigate selected by ${interaction.user.username}: ${interaction.values[0]}`);
|
|
32
|
+
treeRequest.resolve(interaction.values[0] ?? null);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
export async function handleButtonInteraction(interaction, state) {
|
|
38
|
+
const pageAction = state.pendingModelPages.get(interaction.customId);
|
|
39
|
+
if (pageAction) {
|
|
40
|
+
if (interaction.user.id !== pageAction.userId)
|
|
41
|
+
return rejectForeignInteraction(interaction, "This selector belongs to another user.");
|
|
42
|
+
state.pendingModelPages.delete(interaction.customId);
|
|
43
|
+
await interaction.update({ content: interaction.customId.endsWith(":close") ? "Closed." : "Loading models...", embeds: [], components: [] });
|
|
44
|
+
pageAction.resolve(interaction.customId.endsWith(":prev") ? "prev" : interaction.customId.endsWith(":next") ? "next" : "close");
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
const scopedPageAction = state.pendingScopedPages.get(interaction.customId);
|
|
48
|
+
if (scopedPageAction) {
|
|
49
|
+
if (interaction.user.id !== scopedPageAction.userId)
|
|
50
|
+
return rejectForeignInteraction(interaction, "This selector belongs to another user.");
|
|
51
|
+
state.pendingScopedPages.delete(interaction.customId);
|
|
52
|
+
await interaction.update({ content: interaction.customId.endsWith(":close") ? "Closed." : "Loading scoped models...", embeds: [], components: [] });
|
|
53
|
+
scopedPageAction.resolve(interaction.customId.endsWith(":prev") ? "prev" : interaction.customId.endsWith(":next") ? "next" : "close");
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
const settingsAction = state.pendingSettingsActions.get(interaction.customId);
|
|
57
|
+
if (settingsAction) {
|
|
58
|
+
if (interaction.user.id !== settingsAction.userId)
|
|
59
|
+
return rejectForeignInteraction(interaction, "This settings card belongs to another user.");
|
|
60
|
+
state.pendingSettingsActions.delete(interaction.customId);
|
|
61
|
+
const action = interaction.customId.split(":").pop();
|
|
62
|
+
await interaction.update({ content: action === "done" ? "Done." : `Applying settings action: ${action}`, embeds: [], components: [] });
|
|
63
|
+
settingsAction.resolve(action);
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
const treePageAction = state.pendingTreePages.get(interaction.customId);
|
|
67
|
+
if (treePageAction) {
|
|
68
|
+
if (interaction.user.id !== treePageAction.userId)
|
|
69
|
+
return rejectForeignInteraction(interaction, "This tree browser belongs to another user.");
|
|
70
|
+
state.pendingTreePages.delete(interaction.customId);
|
|
71
|
+
await interaction.update({ content: interaction.customId.endsWith(":close") ? "Closed." : "Loading session tree...", embeds: [], components: [] });
|
|
72
|
+
treePageAction.resolve(interaction.customId.endsWith(":prev") ? "prev" : interaction.customId.endsWith(":next") ? "next" : "close");
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
const approval = state.pendingApprovals.get(interaction.customId);
|
|
76
|
+
if (approval) {
|
|
77
|
+
if (interaction.user.id !== approval.userId)
|
|
78
|
+
return rejectForeignInteraction(interaction, "This approval belongs to another user.");
|
|
79
|
+
const baseId = interaction.customId.replace(/:(approve|reject)$/, "");
|
|
80
|
+
state.pendingApprovals.delete(`${baseId}:approve`);
|
|
81
|
+
state.pendingApprovals.delete(`${baseId}:reject`);
|
|
82
|
+
const approved = interaction.customId.endsWith(":approve");
|
|
83
|
+
await interaction.update({ content: approved ? "Approved." : "Cancelled.", embeds: [], components: [] });
|
|
84
|
+
log.info(`approval ${approved ? "approved" : "cancelled"} by ${interaction.user.username}`);
|
|
85
|
+
approval.resolve(approved);
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
export function slashCommandToText(interaction) {
|
|
91
|
+
if (interaction.commandName === "pi")
|
|
92
|
+
return interaction.options.getString("prompt", true).trim();
|
|
93
|
+
if (interaction.commandName === "new")
|
|
94
|
+
return "/new";
|
|
95
|
+
if (interaction.commandName === "name")
|
|
96
|
+
return `/name ${interaction.options.getString("name", true).trim()}`;
|
|
97
|
+
if (interaction.commandName === "session")
|
|
98
|
+
return "/session";
|
|
99
|
+
if (interaction.commandName === "tree") {
|
|
100
|
+
const entryId = interaction.options.getString("entry_id")?.trim();
|
|
101
|
+
return entryId ? `/tree ${entryId}` : "/tree";
|
|
102
|
+
}
|
|
103
|
+
if (interaction.commandName === "model") {
|
|
104
|
+
const reference = interaction.options.getString("reference")?.trim();
|
|
105
|
+
return reference ? `/model ${reference}` : "/model";
|
|
106
|
+
}
|
|
107
|
+
if (interaction.commandName === "scoped-models") {
|
|
108
|
+
const patterns = interaction.options.getString("patterns")?.trim();
|
|
109
|
+
return patterns ? `/scoped-models ${patterns}` : "/scoped-models";
|
|
110
|
+
}
|
|
111
|
+
if (interaction.commandName === "settings")
|
|
112
|
+
return "/settings";
|
|
113
|
+
if (interaction.commandName === "compact") {
|
|
114
|
+
const instructions = interaction.options.getString("instructions")?.trim();
|
|
115
|
+
return instructions ? `/compact ${instructions}` : "/compact";
|
|
116
|
+
}
|
|
117
|
+
if (interaction.commandName === "reload")
|
|
118
|
+
return "/reload";
|
|
119
|
+
if (interaction.commandName === "login") {
|
|
120
|
+
const provider = interaction.options.getString("provider")?.trim();
|
|
121
|
+
return provider ? `/login ${provider}` : "/login";
|
|
122
|
+
}
|
|
123
|
+
if (interaction.commandName === "logout") {
|
|
124
|
+
const provider = interaction.options.getString("provider")?.trim();
|
|
125
|
+
return provider ? `/logout ${provider}` : "/logout";
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
export function buildSlashEvent(interaction, text) {
|
|
130
|
+
return {
|
|
131
|
+
type: "slash",
|
|
132
|
+
source: "slash",
|
|
133
|
+
channelId: interaction.channelId,
|
|
134
|
+
guildId: interaction.guildId ?? undefined,
|
|
135
|
+
threadId: interaction.channel?.isThread() ? interaction.channel.id : undefined,
|
|
136
|
+
messageId: interaction.id,
|
|
137
|
+
userId: interaction.user.id,
|
|
138
|
+
userName: interaction.user.username,
|
|
139
|
+
text,
|
|
140
|
+
attachments: [],
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { ApplicationCommandOptionType, ChannelType } from "discord.js";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import * as log from "./log.js";
|
|
4
|
+
export function loadDiscordPolicy(policyPath) {
|
|
5
|
+
if (!existsSync(policyPath)) {
|
|
6
|
+
return {
|
|
7
|
+
allowDMs: true,
|
|
8
|
+
mentionMode: "mention-only",
|
|
9
|
+
slashCommands: { enabled: true, guildId: process.env.DISCORD_GUILD_ID },
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(readFileSync(policyPath, "utf-8"));
|
|
14
|
+
}
|
|
15
|
+
catch (err) {
|
|
16
|
+
log.warn("failed to parse discord-policy.json", err instanceof Error ? err.message : String(err));
|
|
17
|
+
return { allowDMs: true, mentionMode: "mention-only", slashCommands: { enabled: true } };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function buildSlashCommands() {
|
|
21
|
+
return [
|
|
22
|
+
{
|
|
23
|
+
name: "pi",
|
|
24
|
+
description: "Ask the Pi-powered Discord bot to do something",
|
|
25
|
+
options: [{ name: "prompt", description: "What should the bot do?", type: ApplicationCommandOptionType.String, required: true }],
|
|
26
|
+
},
|
|
27
|
+
{ name: "stop", description: "Abort the current run for this conversation" },
|
|
28
|
+
{ name: "new", description: "Start a new Pi session for this conversation" },
|
|
29
|
+
{
|
|
30
|
+
name: "name",
|
|
31
|
+
description: "Set the current Pi session name",
|
|
32
|
+
options: [{ name: "name", description: "New session name", type: ApplicationCommandOptionType.String, required: true }],
|
|
33
|
+
},
|
|
34
|
+
{ name: "session", description: "Show current Pi session information" },
|
|
35
|
+
{
|
|
36
|
+
name: "tree",
|
|
37
|
+
description: "Show or navigate the current Pi session tree",
|
|
38
|
+
options: [{ name: "entry_id", description: "Optional session entry id to navigate to", type: ApplicationCommandOptionType.String, required: false }],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: "model",
|
|
42
|
+
description: "Show or change the current Pi model",
|
|
43
|
+
options: [{ name: "reference", description: "provider/model or search string", type: ApplicationCommandOptionType.String, required: false }],
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: "scoped-models",
|
|
47
|
+
description: "Show or set Pi scoped models",
|
|
48
|
+
options: [{ name: "patterns", description: "Comma-separated model patterns, or clear", type: ApplicationCommandOptionType.String, required: false }],
|
|
49
|
+
},
|
|
50
|
+
{ name: "settings", description: "Show current Pi settings summary" },
|
|
51
|
+
{
|
|
52
|
+
name: "compact",
|
|
53
|
+
description: "Compact the current Pi session",
|
|
54
|
+
options: [{ name: "instructions", description: "Optional custom compaction instructions", type: ApplicationCommandOptionType.String, required: false }],
|
|
55
|
+
},
|
|
56
|
+
{ name: "reload", description: "Reload Pi resources" },
|
|
57
|
+
{
|
|
58
|
+
name: "login",
|
|
59
|
+
description: "Show how to log in with shared Pi auth",
|
|
60
|
+
options: [{ name: "provider", description: "Optional provider name", type: ApplicationCommandOptionType.String, required: false }],
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: "logout",
|
|
64
|
+
description: "Show how to log out from shared Pi auth",
|
|
65
|
+
options: [{ name: "provider", description: "Optional provider name", type: ApplicationCommandOptionType.String, required: false }],
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
export function isAllowedDiscordMessage(message, botUserId, policy) {
|
|
70
|
+
const isDm = message.channel.type === ChannelType.DM;
|
|
71
|
+
const trimmed = message.content.trim();
|
|
72
|
+
const isTextCommand = trimmed.startsWith("/");
|
|
73
|
+
if (isDm) {
|
|
74
|
+
if (policy.allowDMs === false)
|
|
75
|
+
return { allowed: false, reason: "DMs disabled by policy" };
|
|
76
|
+
return { allowed: true };
|
|
77
|
+
}
|
|
78
|
+
if (policy.guildIds?.length && (!message.guildId || !policy.guildIds.includes(message.guildId))) {
|
|
79
|
+
return { allowed: false, reason: "guild not in allowlist" };
|
|
80
|
+
}
|
|
81
|
+
const effectiveChannelId = message.channel.isThread() ? message.channel.parentId ?? message.channel.id : message.channel.id;
|
|
82
|
+
if (policy.channelIds?.length && !policy.channelIds.includes(effectiveChannelId) && !policy.channelIds.includes(message.channel.id)) {
|
|
83
|
+
return { allowed: false, reason: "channel not in allowlist" };
|
|
84
|
+
}
|
|
85
|
+
const mentionMode = policy.mentionMode ?? "mention-only";
|
|
86
|
+
if (mentionMode === "mention-only" && !message.mentions.users.has(botUserId) && !isTextCommand) {
|
|
87
|
+
return { allowed: false, reason: "no mention" };
|
|
88
|
+
}
|
|
89
|
+
return { allowed: true };
|
|
90
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function registerPending(params) {
|
|
2
|
+
params.registry.set(params.key, { userId: params.userId, resolve: params.resolve });
|
|
3
|
+
}
|
|
4
|
+
export function registerManyPending(params) {
|
|
5
|
+
for (const key of params.keys) {
|
|
6
|
+
params.registry.set(key, { userId: params.userId, resolve: params.resolve });
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function clearPending(registry, keys) {
|
|
10
|
+
let hadAny = false;
|
|
11
|
+
for (const key of keys) {
|
|
12
|
+
if (!key)
|
|
13
|
+
continue;
|
|
14
|
+
hadAny = registry.delete(key) || hadAny;
|
|
15
|
+
}
|
|
16
|
+
return hadAny;
|
|
17
|
+
}
|
|
18
|
+
export function resolveOnTimeout(params) {
|
|
19
|
+
setTimeout(() => {
|
|
20
|
+
params.run();
|
|
21
|
+
}, params.timeoutMs ?? 120000);
|
|
22
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { clearPending, registerManyPending, registerPending } from "./discord-registry.js";
|
|
4
|
+
test("registerPending stores a single entry", () => {
|
|
5
|
+
const registry = new Map();
|
|
6
|
+
const resolve = () => { };
|
|
7
|
+
registerPending({ registry, key: "a", userId: "u1", resolve });
|
|
8
|
+
assert.equal(registry.get("a")?.userId, "u1");
|
|
9
|
+
});
|
|
10
|
+
test("registerManyPending and clearPending work together", () => {
|
|
11
|
+
const registry = new Map();
|
|
12
|
+
const resolve = () => { };
|
|
13
|
+
registerManyPending({ registry, keys: ["a", "b", "c"], userId: "u1", resolve });
|
|
14
|
+
assert.equal(registry.size, 3);
|
|
15
|
+
assert.equal(clearPending(registry, ["a", "c", "missing"]), true);
|
|
16
|
+
assert.deepEqual([...registry.keys()], ["b"]);
|
|
17
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|