singularity-forge/src/resources/extensions/remote-questions/discord-adapter.js
2026-05-04 23:27:20 +02:00

134 lines
5.3 KiB
JavaScript

/**
* Remote Questions — Discord adapter
*/
import { DISCORD_NUMBER_EMOJIS, formatForDiscord, parseDiscordResponse, } from "./format.js";
import { apiRequest } from "./http-client.js";
const DISCORD_API = "https://discord.com/api/v10";
export class DiscordAdapter {
name = "discord";
botUserId = null;
guildId = null;
token;
channelId;
constructor(token, channelId) {
this.token = token;
this.channelId = channelId;
}
async validate() {
const res = await this.discordApi("GET", "/users/@me");
if (!res.id)
throw new Error("Discord auth failed: invalid token");
this.botUserId = String(res.id);
// Resolve guild ID for message URL generation.
// The channel belongs to a guild — fetch channel info to discover it.
try {
const channelInfo = await this.discordApi("GET", `/channels/${this.channelId}`);
if (channelInfo.guild_id) {
this.guildId = String(channelInfo.guild_id);
}
}
catch {
// Non-fatal — message URLs will be omitted if guild ID can't be resolved
}
}
async sendPrompt(prompt) {
const { embeds, reactionEmojis } = formatForDiscord(prompt);
const res = await this.discordApi("POST", `/channels/${this.channelId}/messages`, {
content: "**SF needs your input** — reply to this message with your answer",
embeds,
});
if (!res.id)
throw new Error(`Discord send failed: ${JSON.stringify(res)}`);
const messageId = String(res.id);
if (prompt.questions.length === 1) {
for (const emoji of reactionEmojis) {
try {
await this.discordApi("PUT", `/channels/${this.channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}/@me`);
}
catch {
// Best-effort only
}
}
}
// Build message URL if guild ID is available
const messageUrl = this.guildId
? `https://discord.com/channels/${this.guildId}/${this.channelId}/${messageId}`
: undefined;
return {
ref: {
id: prompt.id,
channel: "discord",
messageId,
channelId: this.channelId,
threadUrl: messageUrl,
},
};
}
async pollAnswer(prompt, ref) {
if (!this.botUserId)
await this.validate();
if (prompt.questions.length === 1) {
const reactionAnswer = await this.checkReactions(prompt, ref);
if (reactionAnswer)
return reactionAnswer;
}
return this.checkReplies(prompt, ref);
}
/**
* Acknowledge that an answer was received by adding a ✅ reaction to the
* original prompt message. Best-effort — failures are silently ignored.
*/
async acknowledgeAnswer(ref) {
try {
await this.discordApi("PUT", `/channels/${ref.channelId}/messages/${ref.messageId}/reactions/${encodeURIComponent("✅")}/@me`);
}
catch {
// Best-effort — don't let acknowledgement failures affect the flow
}
}
async checkReactions(prompt, ref) {
const reactions = [];
for (const emoji of DISCORD_NUMBER_EMOJIS) {
try {
const users = await this.discordApi("GET", `/channels/${ref.channelId}/messages/${ref.messageId}/reactions/${encodeURIComponent(emoji)}`);
if (Array.isArray(users)) {
const humanUsers = users.filter((u) => u.id !== this.botUserId);
if (humanUsers.length > 0)
reactions.push({ emoji, count: humanUsers.length });
}
}
catch (err) {
const msg = String(err.message ?? "");
// 404 = no reactions for this emoji — expected, continue
if (msg.includes("HTTP 404"))
continue;
// 401/403 = auth failure — surface to caller so it can fail the poll
if (msg.includes("HTTP 401") || msg.includes("HTTP 403"))
throw err;
// Other errors (rate limit, network) — skip this emoji, best-effort
}
}
if (reactions.length === 0)
return null;
return parseDiscordResponse(reactions, null, prompt.questions);
}
async checkReplies(prompt, ref) {
const messages = await this.discordApi("GET", `/channels/${ref.channelId}/messages?after=${ref.messageId}&limit=10`);
if (!Array.isArray(messages))
return null;
const replies = messages.filter((m) => m.author?.id &&
m.author.id !== this.botUserId &&
m.message_reference?.message_id === ref.messageId &&
m.content);
if (replies.length === 0)
return null;
return parseDiscordResponse([], String(replies[0].content), prompt.questions);
}
async discordApi(method, path, body) {
return apiRequest(`${DISCORD_API}${path}`, method, body, {
authScheme: "Bot",
authToken: this.token,
errorLabel: "Discord API",
});
}
}