discord-agent-bridge 0.1.0
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 +195 -0
- package/dist/app.js +210 -0
- package/dist/app.js.map +1 -0
- package/dist/app.test.js +117 -0
- package/dist/app.test.js.map +1 -0
- package/dist/cli.js +63 -0
- package/dist/cli.js.map +1 -0
- package/dist/cli.test.js +91 -0
- package/dist/cli.test.js.map +1 -0
- package/dist/core/auditLog.js +65 -0
- package/dist/core/auditLog.js.map +1 -0
- package/dist/core/auditLog.test.js +124 -0
- package/dist/core/auditLog.test.js.map +1 -0
- package/dist/core/auth.js +106 -0
- package/dist/core/auth.js.map +1 -0
- package/dist/core/auth.test.js +216 -0
- package/dist/core/auth.test.js.map +1 -0
- package/dist/core/channelRegistry.js +119 -0
- package/dist/core/channelRegistry.js.map +1 -0
- package/dist/core/channelRegistry.test.js +114 -0
- package/dist/core/channelRegistry.test.js.map +1 -0
- package/dist/core/commandPolicy.js +6 -0
- package/dist/core/commandPolicy.js.map +1 -0
- package/dist/core/commandRouter.js +9 -0
- package/dist/core/commandRouter.js.map +1 -0
- package/dist/core/config.js +141 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/config.test.js +149 -0
- package/dist/core/config.test.js.map +1 -0
- package/dist/core/configResolver.js +90 -0
- package/dist/core/configResolver.js.map +1 -0
- package/dist/core/configResolver.test.js +138 -0
- package/dist/core/configResolver.test.js.map +1 -0
- package/dist/core/configSchema.js +164 -0
- package/dist/core/configSchema.js.map +1 -0
- package/dist/core/contracts.js +5 -0
- package/dist/core/contracts.js.map +1 -0
- package/dist/core/eventBus.js +42 -0
- package/dist/core/eventBus.js.map +1 -0
- package/dist/core/eventBus.test.js +72 -0
- package/dist/core/eventBus.test.js.map +1 -0
- package/dist/core/hookBridge.js +11 -0
- package/dist/core/hookBridge.js.map +1 -0
- package/dist/core/logger.js +116 -0
- package/dist/core/logger.js.map +1 -0
- package/dist/core/logger.test.js +88 -0
- package/dist/core/logger.test.js.map +1 -0
- package/dist/core/modeRegistry.js +33 -0
- package/dist/core/modeRegistry.js.map +1 -0
- package/dist/core/permissionResolver.js +43 -0
- package/dist/core/permissionResolver.js.map +1 -0
- package/dist/core/permissionResolver.test.js +136 -0
- package/dist/core/permissionResolver.test.js.map +1 -0
- package/dist/core/providerCatalog.js +240 -0
- package/dist/core/providerCatalog.js.map +1 -0
- package/dist/core/providerCatalog.test.js +230 -0
- package/dist/core/providerCatalog.test.js.map +1 -0
- package/dist/core/sessionOrchestrator.js +463 -0
- package/dist/core/sessionOrchestrator.js.map +1 -0
- package/dist/core/sessionOrchestrator.test.js +344 -0
- package/dist/core/sessionOrchestrator.test.js.map +1 -0
- package/dist/core/state/schema.js +49 -0
- package/dist/core/state/schema.js.map +1 -0
- package/dist/core/state/store.js +86 -0
- package/dist/core/state/store.js.map +1 -0
- package/dist/core/state/store.test.js +99 -0
- package/dist/core/state/store.test.js.map +1 -0
- package/dist/core/usageService.js +310 -0
- package/dist/core/usageService.js.map +1 -0
- package/dist/core/usageService.test.js +234 -0
- package/dist/core/usageService.test.js.map +1 -0
- package/dist/discord/alwaysAllow.test.js +181 -0
- package/dist/discord/alwaysAllow.test.js.map +1 -0
- package/dist/discord/client.js +666 -0
- package/dist/discord/client.js.map +1 -0
- package/dist/discord/client.test.js +256 -0
- package/dist/discord/client.test.js.map +1 -0
- package/dist/discord/configPanel.js +270 -0
- package/dist/discord/configPanel.js.map +1 -0
- package/dist/discord/configPanel.test.js +332 -0
- package/dist/discord/configPanel.test.js.map +1 -0
- package/dist/discord/directoryBrowser.js +151 -0
- package/dist/discord/directoryBrowser.js.map +1 -0
- package/dist/discord/directoryBrowser.test.js +163 -0
- package/dist/discord/directoryBrowser.test.js.map +1 -0
- package/dist/discord/favorites.js +10 -0
- package/dist/discord/favorites.js.map +1 -0
- package/dist/discord/fileDownload.js +94 -0
- package/dist/discord/fileDownload.js.map +1 -0
- package/dist/discord/fileDownload.test.js +59 -0
- package/dist/discord/fileDownload.test.js.map +1 -0
- package/dist/discord/format.js +104 -0
- package/dist/discord/format.js.map +1 -0
- package/dist/discord/format.test.js +40 -0
- package/dist/discord/format.test.js.map +1 -0
- package/dist/discord/guildChannels.js +97 -0
- package/dist/discord/guildChannels.js.map +1 -0
- package/dist/discord/guildChannels.test.js +231 -0
- package/dist/discord/guildChannels.test.js.map +1 -0
- package/dist/discord/i18n.js +207 -0
- package/dist/discord/i18n.js.map +1 -0
- package/dist/discord/i18n.test.js +31 -0
- package/dist/discord/i18n.test.js.map +1 -0
- package/dist/discord/interactionRouter.js +954 -0
- package/dist/discord/interactionRouter.js.map +1 -0
- package/dist/discord/interactionRouter.test.js +1245 -0
- package/dist/discord/interactionRouter.test.js.map +1 -0
- package/dist/discord/messageRouter.js +221 -0
- package/dist/discord/messageRouter.js.map +1 -0
- package/dist/discord/messageRouter.test.js +360 -0
- package/dist/discord/messageRouter.test.js.map +1 -0
- package/dist/discord/ports.js +12 -0
- package/dist/discord/ports.js.map +1 -0
- package/dist/discord/renderers/defaultSet.test.js +97 -0
- package/dist/discord/renderers/defaultSet.test.js.map +1 -0
- package/dist/discord/renderers/diffView.js +73 -0
- package/dist/discord/renderers/diffView.js.map +1 -0
- package/dist/discord/renderers/diffView.test.js +54 -0
- package/dist/discord/renderers/diffView.test.js.map +1 -0
- package/dist/discord/renderers/index.js +143 -0
- package/dist/discord/renderers/index.js.map +1 -0
- package/dist/discord/renderers/index.test.js +124 -0
- package/dist/discord/renderers/index.test.js.map +1 -0
- package/dist/discord/renderers/mentionOnComplete.js +19 -0
- package/dist/discord/renderers/mentionOnComplete.js.map +1 -0
- package/dist/discord/renderers/mentionOnComplete.test.js +35 -0
- package/dist/discord/renderers/mentionOnComplete.test.js.map +1 -0
- package/dist/discord/renderers/permissionButtons.js +150 -0
- package/dist/discord/renderers/permissionButtons.js.map +1 -0
- package/dist/discord/renderers/permissionButtons.test.js +108 -0
- package/dist/discord/renderers/permissionButtons.test.js.map +1 -0
- package/dist/discord/renderers/resultLine.js +25 -0
- package/dist/discord/renderers/resultLine.js.map +1 -0
- package/dist/discord/renderers/statusEmbed.js +20 -0
- package/dist/discord/renderers/statusEmbed.js.map +1 -0
- package/dist/discord/renderers/statusEmbed.test.js +32 -0
- package/dist/discord/renderers/statusEmbed.test.js.map +1 -0
- package/dist/discord/renderers/streamEmbed.js +134 -0
- package/dist/discord/renderers/streamEmbed.js.map +1 -0
- package/dist/discord/renderers/streamEmbed.test.js +97 -0
- package/dist/discord/renderers/streamEmbed.test.js.map +1 -0
- package/dist/discord/renderers/toolThread.js +65 -0
- package/dist/discord/renderers/toolThread.js.map +1 -0
- package/dist/discord/renderers/toolThread.test.js +54 -0
- package/dist/discord/renderers/toolThread.test.js.map +1 -0
- package/dist/discord/renderers/transcriptFeed.js +33 -0
- package/dist/discord/renderers/transcriptFeed.js.map +1 -0
- package/dist/discord/renderers/usageEmbed.js +78 -0
- package/dist/discord/renderers/usageEmbed.js.map +1 -0
- package/dist/discord/renderers/usageEmbed.test.js +42 -0
- package/dist/discord/renderers/usageEmbed.test.js.map +1 -0
- package/dist/discord/wiring.js +214 -0
- package/dist/discord/wiring.js.map +1 -0
- package/dist/discord/wiring.test.js +252 -0
- package/dist/discord/wiring.test.js.map +1 -0
- package/dist/discord/wizard/channelWizard.js +305 -0
- package/dist/discord/wizard/channelWizard.js.map +1 -0
- package/dist/discord/wizard/channelWizard.test.js +298 -0
- package/dist/discord/wizard/channelWizard.test.js.map +1 -0
- package/dist/discord/wizard/resumeWizard.js +123 -0
- package/dist/discord/wizard/resumeWizard.js.map +1 -0
- package/dist/modes/claude/index.js +80 -0
- package/dist/modes/claude/index.js.map +1 -0
- package/dist/modes/claude/listResumable.test.js +72 -0
- package/dist/modes/claude/listResumable.test.js.map +1 -0
- package/dist/modes/claude/mcpFileTool.js +82 -0
- package/dist/modes/claude/mcpFileTool.js.map +1 -0
- package/dist/modes/claude/permissions.js +35 -0
- package/dist/modes/claude/permissions.js.map +1 -0
- package/dist/modes/claude/plugins.js +56 -0
- package/dist/modes/claude/plugins.js.map +1 -0
- package/dist/modes/claude/session.js +324 -0
- package/dist/modes/claude/session.js.map +1 -0
- package/dist/modes/claude/session.test.js +415 -0
- package/dist/modes/claude/session.test.js.map +1 -0
- package/dist/modes/codex/discovery.js +221 -0
- package/dist/modes/codex/discovery.js.map +1 -0
- package/dist/modes/codex/discovery.test.js +213 -0
- package/dist/modes/codex/discovery.test.js.map +1 -0
- package/dist/modes/codex/eventMapper.js +262 -0
- package/dist/modes/codex/eventMapper.js.map +1 -0
- package/dist/modes/codex/index.js +132 -0
- package/dist/modes/codex/index.js.map +1 -0
- package/dist/modes/codex/index.test.js +170 -0
- package/dist/modes/codex/index.test.js.map +1 -0
- package/dist/modes/codex/runner.js +207 -0
- package/dist/modes/codex/runner.js.map +1 -0
- package/dist/modes/codex/runner.test.js +587 -0
- package/dist/modes/codex/runner.test.js.map +1 -0
- package/dist/modes/codex/sqliteReader.js +171 -0
- package/dist/modes/codex/sqliteReader.js.map +1 -0
- package/dist/modes/codex/sqliteReader.test.js +137 -0
- package/dist/modes/codex/sqliteReader.test.js.map +1 -0
- package/dist/setup/wizard.js +115 -0
- package/dist/setup/wizard.js.map +1 -0
- package/dist/setup/wizard.test.js +216 -0
- package/dist/setup/wizard.test.js.map +1 -0
- package/dist/startBot.test.js +78 -0
- package/dist/startBot.test.js.map +1 -0
- package/package.json +58 -0
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
import { ActionRowBuilder, AttachmentBuilder, ButtonBuilder, ButtonStyle, ChannelType, Client, EmbedBuilder, Events, GatewayIntentBits, MessageFlags, ModalBuilder, PermissionFlagsBits, REST, RoleSelectMenuBuilder, Routes, SlashCommandBuilder, StringSelectMenuBuilder, TextInputBuilder, TextInputStyle, } from 'discord.js';
|
|
2
|
+
import { t } from './i18n.js';
|
|
3
|
+
// The gateway client (§2/§4). The ONLY place (with the two routers) that imports
|
|
4
|
+
// discord.js: it constructs the Client with the required intents, registers the
|
|
5
|
+
// slash commands on ready, resumes persisted sessions on ready, and adapts real
|
|
6
|
+
// discord.js channels/messages/threads onto the narrow ports.ts interfaces the
|
|
7
|
+
// renderers/wizard consume. Everything downstream is discord.js-free.
|
|
8
|
+
// Required intents (README): MessageContent + GuildMembers are the privileged
|
|
9
|
+
// intents that must also be enabled in the Discord developer portal.
|
|
10
|
+
const INTENTS = [
|
|
11
|
+
GatewayIntentBits.Guilds,
|
|
12
|
+
GatewayIntentBits.GuildMessages,
|
|
13
|
+
GatewayIntentBits.MessageContent,
|
|
14
|
+
GatewayIntentBits.GuildMembers,
|
|
15
|
+
];
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Slash command definitions (§9 lifecycle). Adapted from A4D commands/index.ts,
|
|
18
|
+
// re-shaped to this project's /agent start|resume|close, /mode, /stop, /stop-all.
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Human-readable choice labels for known backends; an unknown backend falls back to
|
|
21
|
+
// its raw id, so a newly registered backend still appears (just un-prettified).
|
|
22
|
+
const BACKEND_LABELS = {
|
|
23
|
+
claude: 'Claude Code',
|
|
24
|
+
codex: 'Codex',
|
|
25
|
+
};
|
|
26
|
+
// Build the slash commands. `backends` is the list of REGISTERED backend ids
|
|
27
|
+
// (modeRegistry.list()): only these appear as `/mode backend` choices, so a backend
|
|
28
|
+
// that is not yet registered (e.g. Codex before Phase 2) is not offered. Generic —
|
|
29
|
+
// a backend registered later automatically becomes a choice.
|
|
30
|
+
export function buildSlashCommands(backends) {
|
|
31
|
+
const agent = new SlashCommandBuilder()
|
|
32
|
+
.setName('agent')
|
|
33
|
+
.setDescription('Manage the agent session in this channel')
|
|
34
|
+
.addSubcommand((s) => s.setName('start').setDescription('Start a new agent session (wizard)'))
|
|
35
|
+
.addSubcommand((s) => s.setName('resume').setDescription('Resume a prior session in this channel'))
|
|
36
|
+
.addSubcommand((s) => s.setName('close').setDescription('Stop and archive this channel’s session'));
|
|
37
|
+
const backendChoices = backends.map((b) => ({ name: BACKEND_LABELS[b] ?? b, value: b }));
|
|
38
|
+
const mode = new SlashCommandBuilder()
|
|
39
|
+
.setName('mode')
|
|
40
|
+
.setDescription('Switch the backend or permission mode')
|
|
41
|
+
.addSubcommand((s) => s
|
|
42
|
+
.setName('backend')
|
|
43
|
+
.setDescription('Switch the agent backend (starts a fresh context)')
|
|
44
|
+
.addStringOption((o) => o
|
|
45
|
+
.setName('backend')
|
|
46
|
+
.setDescription('Backend to switch to')
|
|
47
|
+
.setRequired(true)
|
|
48
|
+
.addChoices(...backendChoices)))
|
|
49
|
+
.addSubcommand((s) => s
|
|
50
|
+
.setName('perm')
|
|
51
|
+
.setDescription('Switch the permission mode or profile (session kept)')
|
|
52
|
+
.addStringOption((o) => o.setName('value').setDescription('Permission mode or profile name').setRequired(true)));
|
|
53
|
+
const stop = new SlashCommandBuilder()
|
|
54
|
+
.setName('stop')
|
|
55
|
+
.setDescription('Stop this channel’s session');
|
|
56
|
+
const stopAll = new SlashCommandBuilder()
|
|
57
|
+
.setName('stop-all')
|
|
58
|
+
.setDescription('Stop every active session (admin)');
|
|
59
|
+
// /config opens the role-tier + defaults panel. Bootstrap gate: only members with
|
|
60
|
+
// the Discord Administrator permission see/use it by default, since the role
|
|
61
|
+
// allowlist may still be empty on first run (the interaction router additionally
|
|
62
|
+
// allows the admin tier once it is configured — see interactionRouter.authorize).
|
|
63
|
+
const config = new SlashCommandBuilder()
|
|
64
|
+
.setName('config')
|
|
65
|
+
.setDescription('Configure role tiers and defaults for this server')
|
|
66
|
+
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator);
|
|
67
|
+
// /init creates the A4D-style channel structure (control channel + sessions
|
|
68
|
+
// category). Administrator-gated: it creates channels, so only server admins run it.
|
|
69
|
+
const init = new SlashCommandBuilder()
|
|
70
|
+
.setName('init')
|
|
71
|
+
.setDescription('Create the agent control channel and sessions category')
|
|
72
|
+
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator);
|
|
73
|
+
return [agent.toJSON(), mode.toJSON(), stop.toJSON(), stopAll.toJSON(), config.toJSON(), init.toJSON()];
|
|
74
|
+
}
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// discord.js → ports.ts adapters. These are the thin adapter the renderers,
|
|
77
|
+
// wizard, and permission buttons consume — no discord.js leaks past this file.
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Build the discord.js MessageCreateOptions from a plain OutgoingMessage. Embeds,
|
|
80
|
+
// component rows, files, and allowed-mentions are mapped here; `ephemeral` is a
|
|
81
|
+
// no-op on a channel send (it only applies to interaction replies — see the
|
|
82
|
+
// routers) and is ignored, exactly as ports.ts documents.
|
|
83
|
+
export function toMessageOptions(msg) {
|
|
84
|
+
const options = {};
|
|
85
|
+
if (msg.content !== undefined)
|
|
86
|
+
options.content = msg.content;
|
|
87
|
+
if (msg.embeds && msg.embeds.length > 0)
|
|
88
|
+
options.embeds = msg.embeds.map(toEmbed);
|
|
89
|
+
if (msg.components && msg.components.length > 0)
|
|
90
|
+
options.components = msg.components.map(toRow);
|
|
91
|
+
if (msg.files && msg.files.length > 0) {
|
|
92
|
+
options.files = msg.files.map((f) => new AttachmentBuilder(f.path, f.name ? { name: f.name } : {}));
|
|
93
|
+
}
|
|
94
|
+
if (msg.mentionUserIds && msg.mentionUserIds.length > 0) {
|
|
95
|
+
options.allowedMentions = { users: msg.mentionUserIds };
|
|
96
|
+
}
|
|
97
|
+
else if (msg.content !== undefined) {
|
|
98
|
+
// No explicit pings requested → suppress accidental @mentions from content.
|
|
99
|
+
options.allowedMentions = { parse: [] };
|
|
100
|
+
}
|
|
101
|
+
return options;
|
|
102
|
+
}
|
|
103
|
+
function toEmbed(spec) {
|
|
104
|
+
const embed = new EmbedBuilder();
|
|
105
|
+
if (spec.title !== undefined)
|
|
106
|
+
embed.setTitle(spec.title);
|
|
107
|
+
if (spec.description !== undefined)
|
|
108
|
+
embed.setDescription(spec.description);
|
|
109
|
+
if (spec.color !== undefined)
|
|
110
|
+
embed.setColor(spec.color);
|
|
111
|
+
if (spec.fields && spec.fields.length > 0) {
|
|
112
|
+
embed.addFields(spec.fields.map((f) => ({ name: f.name, value: f.value, inline: f.inline ?? false })));
|
|
113
|
+
}
|
|
114
|
+
if (spec.footer !== undefined)
|
|
115
|
+
embed.setFooter({ text: spec.footer });
|
|
116
|
+
return embed;
|
|
117
|
+
}
|
|
118
|
+
function toRow(row) {
|
|
119
|
+
const builder = new ActionRowBuilder();
|
|
120
|
+
for (const c of row.components) {
|
|
121
|
+
if (c.type === 'button')
|
|
122
|
+
builder.addComponents(toButton(c));
|
|
123
|
+
else if (c.type === 'roleSelect')
|
|
124
|
+
builder.addComponents(toRoleSelect(c));
|
|
125
|
+
else
|
|
126
|
+
builder.addComponents(toSelect(c));
|
|
127
|
+
}
|
|
128
|
+
return builder;
|
|
129
|
+
}
|
|
130
|
+
const BUTTON_STYLE = {
|
|
131
|
+
primary: ButtonStyle.Primary,
|
|
132
|
+
secondary: ButtonStyle.Secondary,
|
|
133
|
+
success: ButtonStyle.Success,
|
|
134
|
+
danger: ButtonStyle.Danger,
|
|
135
|
+
};
|
|
136
|
+
function toButton(spec) {
|
|
137
|
+
const button = new ButtonBuilder()
|
|
138
|
+
.setCustomId(spec.customId)
|
|
139
|
+
.setLabel(spec.label)
|
|
140
|
+
.setStyle(BUTTON_STYLE[spec.style]);
|
|
141
|
+
if (spec.disabled)
|
|
142
|
+
button.setDisabled(true);
|
|
143
|
+
return button;
|
|
144
|
+
}
|
|
145
|
+
function toSelect(spec) {
|
|
146
|
+
const select = new StringSelectMenuBuilder().setCustomId(spec.customId).addOptions(spec.options.map((o) => ({
|
|
147
|
+
label: o.label,
|
|
148
|
+
value: o.value,
|
|
149
|
+
...(o.description !== undefined ? { description: o.description } : {}),
|
|
150
|
+
...(o.default !== undefined ? { default: o.default } : {}),
|
|
151
|
+
})));
|
|
152
|
+
if (spec.placeholder !== undefined)
|
|
153
|
+
select.setPlaceholder(spec.placeholder);
|
|
154
|
+
return select;
|
|
155
|
+
}
|
|
156
|
+
// A Discord Role Select menu (the /config role-tier pickers). min/maxValues bound
|
|
157
|
+
// the multi-select; defaultRoleIds prefill the tier's current roles so the panel
|
|
158
|
+
// shows the effective values. The user picks role NAMES; the values are role IDs.
|
|
159
|
+
function toRoleSelect(spec) {
|
|
160
|
+
const select = new RoleSelectMenuBuilder().setCustomId(spec.customId);
|
|
161
|
+
if (spec.placeholder !== undefined)
|
|
162
|
+
select.setPlaceholder(spec.placeholder);
|
|
163
|
+
if (spec.minValues !== undefined)
|
|
164
|
+
select.setMinValues(spec.minValues);
|
|
165
|
+
if (spec.maxValues !== undefined)
|
|
166
|
+
select.setMaxValues(spec.maxValues);
|
|
167
|
+
if (spec.defaultRoleIds && spec.defaultRoleIds.length > 0) {
|
|
168
|
+
select.setDefaultRoles(spec.defaultRoleIds);
|
|
169
|
+
}
|
|
170
|
+
return select;
|
|
171
|
+
}
|
|
172
|
+
// A Discord modal (the /config Codex-path input). Each field becomes a single-line
|
|
173
|
+
// TextInput on its own ModalActionRow; a prefilled value is set as the input's value.
|
|
174
|
+
// discord.js requires TextInput on a ModalActionRowComponentBuilder row, distinct from
|
|
175
|
+
// the message-component row builder used elsewhere.
|
|
176
|
+
function toModal(spec) {
|
|
177
|
+
const modal = new ModalBuilder().setCustomId(spec.customId).setTitle(spec.title);
|
|
178
|
+
const rows = spec.fields.map((field) => {
|
|
179
|
+
const input = new TextInputBuilder()
|
|
180
|
+
.setCustomId(field.customId)
|
|
181
|
+
.setLabel(field.label)
|
|
182
|
+
.setStyle(TextInputStyle.Short)
|
|
183
|
+
.setRequired(field.required ?? false);
|
|
184
|
+
if (field.value !== undefined)
|
|
185
|
+
input.setValue(field.value);
|
|
186
|
+
if (field.placeholder !== undefined)
|
|
187
|
+
input.setPlaceholder(field.placeholder);
|
|
188
|
+
return new ActionRowBuilder().addComponents(input);
|
|
189
|
+
});
|
|
190
|
+
modal.addComponents(...rows);
|
|
191
|
+
return modal;
|
|
192
|
+
}
|
|
193
|
+
// A posted discord.js Message adapted onto EditableMessage.
|
|
194
|
+
class MessageAdapter {
|
|
195
|
+
message;
|
|
196
|
+
constructor(message) {
|
|
197
|
+
this.message = message;
|
|
198
|
+
}
|
|
199
|
+
get id() {
|
|
200
|
+
return this.message.id;
|
|
201
|
+
}
|
|
202
|
+
async edit(message) {
|
|
203
|
+
// discord.js edit accepts the same option shape as send for these fields.
|
|
204
|
+
await this.message.edit(toMessageOptions(message));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// A discord.js thread adapted onto MessageThread.
|
|
208
|
+
class ThreadAdapter {
|
|
209
|
+
thread;
|
|
210
|
+
constructor(thread) {
|
|
211
|
+
this.thread = thread;
|
|
212
|
+
}
|
|
213
|
+
get id() {
|
|
214
|
+
return this.thread.id;
|
|
215
|
+
}
|
|
216
|
+
async send(message) {
|
|
217
|
+
const sent = await this.thread.send(toMessageOptions(message));
|
|
218
|
+
return new MessageAdapter(sent);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
// A discord.js text channel adapted onto MessageChannel — the single sink every
|
|
222
|
+
// renderer receives. Threads are opened either off a message (message.startThread)
|
|
223
|
+
// or standalone on the channel (channel.threads.create), matching ports.ts.
|
|
224
|
+
export class ChannelAdapter {
|
|
225
|
+
channel;
|
|
226
|
+
constructor(channel) {
|
|
227
|
+
this.channel = channel;
|
|
228
|
+
}
|
|
229
|
+
async send(message) {
|
|
230
|
+
const sent = await this.channel.send(toMessageOptions(message));
|
|
231
|
+
return new MessageAdapter(sent);
|
|
232
|
+
}
|
|
233
|
+
async startThread(name, startFromMessageId) {
|
|
234
|
+
// Thread off a specific message when we have its id and the channel supports it.
|
|
235
|
+
if (startFromMessageId && 'messages' in this.channel) {
|
|
236
|
+
const parent = await this.channel.messages.fetch(startFromMessageId);
|
|
237
|
+
const thread = await parent.startThread({ name });
|
|
238
|
+
return new ThreadAdapter(thread);
|
|
239
|
+
}
|
|
240
|
+
if ('threads' in this.channel) {
|
|
241
|
+
const thread = await this.channel.threads.create({ name });
|
|
242
|
+
return new ThreadAdapter(thread);
|
|
243
|
+
}
|
|
244
|
+
throw new Error('Channel does not support threads.');
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// True when a fetched channel can be sent to (text-based, not a category/voice).
|
|
248
|
+
function isSendable(channel) {
|
|
249
|
+
return channel !== null && 'send' in channel && typeof channel.send === 'function';
|
|
250
|
+
}
|
|
251
|
+
// Resolve a channel id to a ChannelAdapter, or null if it is missing/not sendable.
|
|
252
|
+
// Used by the wiring layer to obtain the sink for a session's renderers.
|
|
253
|
+
export async function resolveChannelAdapter(client, channelId) {
|
|
254
|
+
const channel = await client.channels.fetch(channelId).catch(() => null);
|
|
255
|
+
if (channel && isSendable(channel)) {
|
|
256
|
+
return new ChannelAdapter(channel);
|
|
257
|
+
}
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
// A discord.js Guild adapted onto the GuildChannelProvisioner port — the single place
|
|
261
|
+
// that touches guild.channels.create/delete for the /init + session-channel flows.
|
|
262
|
+
// Every ensure* checks the guild's channel cache for the given id first (idempotency)
|
|
263
|
+
// and only creates when absent, mirroring A4D's init.
|
|
264
|
+
class GuildProvisionerAdapter {
|
|
265
|
+
guild;
|
|
266
|
+
constructor(guild) {
|
|
267
|
+
this.guild = guild;
|
|
268
|
+
}
|
|
269
|
+
get guildId() {
|
|
270
|
+
return this.guild.id;
|
|
271
|
+
}
|
|
272
|
+
// The bot has Manage Channels in this guild. Reads the bot member's resolved
|
|
273
|
+
// permissions (guild.members.me). Absent member (not yet cached) → false, so
|
|
274
|
+
// auto-provision skips with a warning rather than attempting a create that would fail.
|
|
275
|
+
canManageChannels() {
|
|
276
|
+
const me = this.guild.members.me;
|
|
277
|
+
return me?.permissions.has(PermissionFlagsBits.ManageChannels) ?? false;
|
|
278
|
+
}
|
|
279
|
+
channelExists(id) {
|
|
280
|
+
return this.guild.channels.cache.has(id);
|
|
281
|
+
}
|
|
282
|
+
async ensureCategory(name, existingId) {
|
|
283
|
+
if (existingId) {
|
|
284
|
+
const cached = this.guild.channels.cache.get(existingId);
|
|
285
|
+
if (cached)
|
|
286
|
+
return { id: cached.id, name: cached.name };
|
|
287
|
+
}
|
|
288
|
+
const created = await this.guild.channels.create({ name, type: ChannelType.GuildCategory });
|
|
289
|
+
return { id: created.id, name: created.name };
|
|
290
|
+
}
|
|
291
|
+
async ensureTextChannel(name, parentId, existingId) {
|
|
292
|
+
if (existingId) {
|
|
293
|
+
const cached = this.guild.channels.cache.get(existingId);
|
|
294
|
+
if (cached)
|
|
295
|
+
return { id: cached.id, name: cached.name };
|
|
296
|
+
}
|
|
297
|
+
const created = await this.guild.channels.create({ name, type: ChannelType.GuildText, parent: parentId });
|
|
298
|
+
return { id: created.id, name: created.name };
|
|
299
|
+
}
|
|
300
|
+
async createTextChannel(name, parentId) {
|
|
301
|
+
const created = await this.guild.channels.create({
|
|
302
|
+
name,
|
|
303
|
+
type: ChannelType.GuildText,
|
|
304
|
+
...(parentId ? { parent: parentId } : {}),
|
|
305
|
+
});
|
|
306
|
+
return { id: created.id, name: created.name };
|
|
307
|
+
}
|
|
308
|
+
async deleteChannel(id) {
|
|
309
|
+
const channel = this.guild.channels.cache.get(id) ?? (await this.guild.channels.fetch(id).catch(() => null));
|
|
310
|
+
if (channel)
|
|
311
|
+
await channel.delete().catch(() => { });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
// Resolve a guildId to a GuildChannelProvisioner over the live client, or null when
|
|
315
|
+
// the guild is unknown (not cached / bot no longer a member). Used by the interaction
|
|
316
|
+
// router for /init and /agent start's session-channel creation.
|
|
317
|
+
export async function resolveGuildProvisioner(client, guildId) {
|
|
318
|
+
const guild = client.guilds.cache.get(guildId) ?? (await client.guilds.fetch(guildId).catch(() => null));
|
|
319
|
+
if (!guild)
|
|
320
|
+
return null;
|
|
321
|
+
return new GuildProvisionerAdapter(guild);
|
|
322
|
+
}
|
|
323
|
+
export class DiscordClient {
|
|
324
|
+
client;
|
|
325
|
+
clientId;
|
|
326
|
+
logger;
|
|
327
|
+
messageRouter;
|
|
328
|
+
interactionRouter;
|
|
329
|
+
backends;
|
|
330
|
+
onReady;
|
|
331
|
+
autoProvisionGuild;
|
|
332
|
+
constructor(deps) {
|
|
333
|
+
this.client = deps.client ?? new Client({ intents: INTENTS });
|
|
334
|
+
this.clientId = deps.clientId;
|
|
335
|
+
this.logger = deps.logger;
|
|
336
|
+
this.messageRouter = deps.messageRouter;
|
|
337
|
+
this.interactionRouter = deps.interactionRouter;
|
|
338
|
+
this.backends = deps.backends;
|
|
339
|
+
this.onReady = deps.onReady;
|
|
340
|
+
if (deps.autoProvisionGuild)
|
|
341
|
+
this.autoProvisionGuild = deps.autoProvisionGuild;
|
|
342
|
+
this.registerHandlers();
|
|
343
|
+
}
|
|
344
|
+
// Expose the underlying client so the wiring layer can resolve channels.
|
|
345
|
+
get raw() {
|
|
346
|
+
return this.client;
|
|
347
|
+
}
|
|
348
|
+
registerHandlers() {
|
|
349
|
+
this.client.once(Events.ClientReady, (ready) => {
|
|
350
|
+
void this.handleReady(ready).catch((err) => {
|
|
351
|
+
this.logger.error('ready handler failed', { err: String(err) });
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
// Register commands for a guild the bot joins after startup, too, and
|
|
355
|
+
// auto-provision its channel structure so /init is optional (§ auto-provision).
|
|
356
|
+
// The two are INDEPENDENT: a command-registration hiccup must not prevent channel
|
|
357
|
+
// creation, so each is guarded separately (mirrors handleReady's per-guild loop).
|
|
358
|
+
this.client.on(Events.GuildCreate, (guild) => {
|
|
359
|
+
void this.registerCommands(guild.id).catch((err) => {
|
|
360
|
+
this.logger.error('guild-join command registration failed', { guildId: guild.id, err: String(err) });
|
|
361
|
+
});
|
|
362
|
+
if (this.autoProvisionGuild) {
|
|
363
|
+
void this.autoProvisionGuild(guild.id).catch((err) => {
|
|
364
|
+
this.logger.error('guild-join auto-provision failed', { guildId: guild.id, err: String(err) });
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
this.client.on(Events.MessageCreate, (message) => {
|
|
369
|
+
if (message.author.bot)
|
|
370
|
+
return; // never react to our own / other bots' messages
|
|
371
|
+
void this.messageRouter.handle(adaptMessage(message, this.client)).catch((err) => {
|
|
372
|
+
this.logger.error('message router failed', { err: String(err) });
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
this.client.on(Events.InteractionCreate, (interaction) => {
|
|
376
|
+
// Earliest-possible operator log: record that the gateway delivered SOMETHING,
|
|
377
|
+
// even if adaptation/routing below fails. This is the line that was missing when
|
|
378
|
+
// /config showed "application did not respond" with a silent terminal.
|
|
379
|
+
this.logInteractionArrival(interaction);
|
|
380
|
+
let adapted;
|
|
381
|
+
try {
|
|
382
|
+
adapted = adaptInteraction(interaction);
|
|
383
|
+
}
|
|
384
|
+
catch (err) {
|
|
385
|
+
// Adaptation itself threw (unexpected member/permissions shape). Never let this
|
|
386
|
+
// leave the interaction unacknowledged — best-effort ack, then log with stack.
|
|
387
|
+
this.logger.error('interaction adaptation failed', { err: errWithStack(err) });
|
|
388
|
+
void ackFailedInteraction(interaction);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (!adapted)
|
|
392
|
+
return; // autocomplete / unsupported: ignored (modals are adapted)
|
|
393
|
+
void this.interactionRouter.handle(adapted).catch((err) => {
|
|
394
|
+
this.logger.error('interaction router failed', { err: errWithStack(err) });
|
|
395
|
+
void ackFailedInteraction(interaction);
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
// Log the raw arrival of an interaction at the gateway (before adaptation). The
|
|
400
|
+
// router logs a richer receipt line too; this one guarantees a trace even if
|
|
401
|
+
// adaptation throws. discord.js type guards read command/customId safely.
|
|
402
|
+
logInteractionArrival(interaction) {
|
|
403
|
+
if (interaction.isChatInputCommand()) {
|
|
404
|
+
this.logger.info('interaction arrived', {
|
|
405
|
+
type: 'slash',
|
|
406
|
+
command: interaction.commandName,
|
|
407
|
+
guildId: interaction.guildId,
|
|
408
|
+
userId: interaction.user.id,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
else if (interaction.isMessageComponent()) {
|
|
412
|
+
this.logger.info('interaction arrived', {
|
|
413
|
+
type: 'component',
|
|
414
|
+
customId: interaction.customId,
|
|
415
|
+
guildId: interaction.guildId,
|
|
416
|
+
userId: interaction.user.id,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
else if (interaction.isModalSubmit()) {
|
|
420
|
+
this.logger.info('interaction arrived', {
|
|
421
|
+
type: 'modalSubmit',
|
|
422
|
+
customId: interaction.customId,
|
|
423
|
+
guildId: interaction.guildId,
|
|
424
|
+
userId: interaction.user.id,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
async handleReady(ready) {
|
|
429
|
+
this.logger.info('gateway ready', { tag: ready.user.tag, guilds: ready.guilds.cache.size });
|
|
430
|
+
for (const [guildId] of ready.guilds.cache) {
|
|
431
|
+
await this.registerCommands(guildId).catch((err) => {
|
|
432
|
+
this.logger.error('command registration failed', { guildId, err: String(err) });
|
|
433
|
+
});
|
|
434
|
+
// Auto-provision each existing guild so the control/session channels appear
|
|
435
|
+
// without a manual /init (idempotent + Manage-Channels-guarded + non-throwing).
|
|
436
|
+
if (this.autoProvisionGuild) {
|
|
437
|
+
await this.autoProvisionGuild(guildId).catch((err) => {
|
|
438
|
+
this.logger.error('auto-provision failed', { guildId, err: String(err) });
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
await this.onReady(this.client);
|
|
443
|
+
}
|
|
444
|
+
// PUT the slash-command JSON for one guild (fast to propagate vs global).
|
|
445
|
+
async registerCommands(guildId) {
|
|
446
|
+
const token = this.client.token;
|
|
447
|
+
if (!token)
|
|
448
|
+
throw new Error('Cannot register commands before login (no token).');
|
|
449
|
+
const rest = new REST({ version: '10' }).setToken(token);
|
|
450
|
+
const body = buildSlashCommands(this.backends());
|
|
451
|
+
await rest.put(Routes.applicationGuildCommands(this.clientId, guildId), { body });
|
|
452
|
+
this.logger.info('slash commands registered', { guildId, count: body.length });
|
|
453
|
+
}
|
|
454
|
+
async login(token) {
|
|
455
|
+
await this.client.login(token);
|
|
456
|
+
}
|
|
457
|
+
async destroy() {
|
|
458
|
+
await this.client.destroy();
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
// Serialize an error to a redaction-friendly shape that KEEPS the stack, so the
|
|
462
|
+
// operator terminal shows where a failure originated (a bare String(err) drops it).
|
|
463
|
+
function errWithStack(err) {
|
|
464
|
+
return err instanceof Error ? { message: err.message, stack: err.stack } : { message: String(err) };
|
|
465
|
+
}
|
|
466
|
+
// Last-resort acknowledgment for a raw interaction whose adaptation or routing threw
|
|
467
|
+
// BEFORE the router could ack it. Discord must never show "application did not
|
|
468
|
+
// respond": if the interaction is repliable and not yet acked, send an ephemeral
|
|
469
|
+
// error; if already deferred/replied, edit it. Best-effort — swallow any failure (the
|
|
470
|
+
// interaction may already be expired). No secrets are surfaced (a generic message).
|
|
471
|
+
async function ackFailedInteraction(interaction) {
|
|
472
|
+
try {
|
|
473
|
+
if (!interaction.isRepliable())
|
|
474
|
+
return;
|
|
475
|
+
const content = t('cmd.error.generic');
|
|
476
|
+
if (interaction.deferred || interaction.replied) {
|
|
477
|
+
await interaction.editReply({ content });
|
|
478
|
+
}
|
|
479
|
+
else {
|
|
480
|
+
await interaction.reply({ content, flags: MessageFlags.Ephemeral });
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
catch {
|
|
484
|
+
// best-effort: nothing more we can do for an already-expired interaction.
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
// Adapt a raw discord.js Message onto the router's narrow IncomingMessage. The real
|
|
488
|
+
// Message already satisfies most of the shape structurally (content/author/react/
|
|
489
|
+
// reply/…); this adds `removeReaction`, which discord.js does not expose directly —
|
|
490
|
+
// it maps onto message.reactions to remove the BOT's own reaction (used to clear the
|
|
491
|
+
// ⏳ working indicator on completion). Best-effort: a missing/uncached reaction or a
|
|
492
|
+
// permission error resolves quietly so a clear never breaks a turn.
|
|
493
|
+
export function adaptMessage(message, client) {
|
|
494
|
+
const removeReaction = async (emoji) => {
|
|
495
|
+
const botId = client.user?.id;
|
|
496
|
+
if (!botId)
|
|
497
|
+
return;
|
|
498
|
+
const reaction = message.reactions.cache.get(emoji);
|
|
499
|
+
if (!reaction)
|
|
500
|
+
return;
|
|
501
|
+
await reaction.users.remove(botId).catch(() => { });
|
|
502
|
+
};
|
|
503
|
+
// The Message structurally satisfies IncomingMessage; attach removeReaction without
|
|
504
|
+
// mutating the discord.js object (a fresh spread would drop its methods, so extend
|
|
505
|
+
// via a prototype-preserving wrapper object that delegates the fields we read).
|
|
506
|
+
return Object.assign(message, { removeReaction });
|
|
507
|
+
}
|
|
508
|
+
// Adapt a raw discord.js Interaction onto the router's narrow RouterInteraction, so
|
|
509
|
+
// the router never imports discord.js as a value. Returns null for interactions the
|
|
510
|
+
// router does not handle (modals, autocomplete). The `ephemeral` flag on reply is
|
|
511
|
+
// translated to MessageFlags.Ephemeral here — the single discord.js-aware boundary.
|
|
512
|
+
export function adaptInteraction(interaction) {
|
|
513
|
+
// Build the shared discord.js message-body (content + embeds + component rows) from
|
|
514
|
+
// a plain AckPayload. Embeds and component rows are mapped through the same adapters
|
|
515
|
+
// as channel sends, so a malformed payload cannot slip past this seam. The Ephemeral
|
|
516
|
+
// flag is applied ONLY by reply/followUp (deferReply sets visibility separately);
|
|
517
|
+
// editReply cannot carry it (the deferReply already fixed the reply's visibility).
|
|
518
|
+
const buildBody = (options) => ({
|
|
519
|
+
...(options.content !== undefined ? { content: options.content } : {}),
|
|
520
|
+
...(options.embeds && options.embeds.length > 0 ? { embeds: options.embeds.map(toEmbed) } : {}),
|
|
521
|
+
...(options.components && options.components.length > 0 ? { components: options.components.map(toRow) } : {}),
|
|
522
|
+
});
|
|
523
|
+
const withEphemeral = (options) => ({
|
|
524
|
+
...buildBody(options),
|
|
525
|
+
...(options.ephemeral ? { flags: MessageFlags.Ephemeral } : {}),
|
|
526
|
+
});
|
|
527
|
+
const repliable = () => interaction;
|
|
528
|
+
// Shared actor/context + ack methods. `acknowledged` is a GETTER (reads discord.js's
|
|
529
|
+
// live deferred/replied flags) — it must be attached with defineProperty on the final
|
|
530
|
+
// object rather than spread, since spreading an object evaluates a getter ONCE and
|
|
531
|
+
// freezes its value (which would wrongly report the interaction as never acked and
|
|
532
|
+
// send reply() after a defer → discord.js throws). base() returns fresh fields; the
|
|
533
|
+
// getter is defined on each concrete object below.
|
|
534
|
+
const base = () => ({
|
|
535
|
+
guildId: interaction.guildId,
|
|
536
|
+
channelId: interaction.channelId ?? '',
|
|
537
|
+
user: { id: interaction.user.id },
|
|
538
|
+
member: adaptMember(interaction),
|
|
539
|
+
hasAdminPermission: hasAdminPermission(interaction),
|
|
540
|
+
reply: (options) => repliable().reply(withEphemeral(options)),
|
|
541
|
+
deferReply: (options) => repliable().deferReply(options?.ephemeral ? { flags: MessageFlags.Ephemeral } : {}),
|
|
542
|
+
editReply: (options) => repliable().editReply(buildBody(options)),
|
|
543
|
+
followUp: (options) => repliable().followUp(withEphemeral(options)),
|
|
544
|
+
});
|
|
545
|
+
// Define the live `acknowledged` getter on the concrete object (not via spread).
|
|
546
|
+
const withAcknowledged = (obj) => Object.defineProperty(obj, 'acknowledged', {
|
|
547
|
+
get: () => {
|
|
548
|
+
const r = repliable();
|
|
549
|
+
return r.deferred || r.replied;
|
|
550
|
+
},
|
|
551
|
+
enumerable: true,
|
|
552
|
+
});
|
|
553
|
+
if (interaction.isChatInputCommand()) {
|
|
554
|
+
const slash = {
|
|
555
|
+
...base(),
|
|
556
|
+
kind: 'slash',
|
|
557
|
+
commandName: interaction.commandName,
|
|
558
|
+
subcommand: safeSubcommand(interaction),
|
|
559
|
+
getString: (name) => interaction.options.getString(name),
|
|
560
|
+
};
|
|
561
|
+
return withAcknowledged(slash);
|
|
562
|
+
}
|
|
563
|
+
if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isRoleSelectMenu()) {
|
|
564
|
+
// String-select: single `value` (first) for legacy callers; also expose all
|
|
565
|
+
// `values`. Role-select: `values` are the picked role IDs (no single `value`).
|
|
566
|
+
const values = interaction.isStringSelectMenu() || interaction.isRoleSelectMenu()
|
|
567
|
+
? interaction.values
|
|
568
|
+
: undefined;
|
|
569
|
+
const value = interaction.isStringSelectMenu() ? interaction.values[0] : undefined;
|
|
570
|
+
const component = {
|
|
571
|
+
...base(),
|
|
572
|
+
kind: 'component',
|
|
573
|
+
customId: interaction.customId,
|
|
574
|
+
...(value !== undefined ? { value } : {}),
|
|
575
|
+
...(values !== undefined ? { values } : {}),
|
|
576
|
+
deferUpdate: () => interaction.deferUpdate(),
|
|
577
|
+
// showModal is the ack for this interaction — the router calls it INSTEAD of
|
|
578
|
+
// deferring (a deferred component can no longer show a modal). Only a button
|
|
579
|
+
// triggers it in practice; the cast is safe (all three are MessageComponent).
|
|
580
|
+
showModal: (modal) => interaction.showModal(toModal(modal)),
|
|
581
|
+
};
|
|
582
|
+
return withAcknowledged(component);
|
|
583
|
+
}
|
|
584
|
+
if (interaction.isModalSubmit()) {
|
|
585
|
+
// A submitted modal (the /config Codex-path input). It replies/defers like any
|
|
586
|
+
// interaction; the field values are read by custom id off the submission.
|
|
587
|
+
const modal = {
|
|
588
|
+
...base(),
|
|
589
|
+
kind: 'modalSubmit',
|
|
590
|
+
customId: interaction.customId,
|
|
591
|
+
getField: (fieldId) => {
|
|
592
|
+
try {
|
|
593
|
+
return interaction.fields.getTextInputValue(fieldId);
|
|
594
|
+
}
|
|
595
|
+
catch {
|
|
596
|
+
return '';
|
|
597
|
+
}
|
|
598
|
+
},
|
|
599
|
+
};
|
|
600
|
+
return withAcknowledged(modal);
|
|
601
|
+
}
|
|
602
|
+
return null;
|
|
603
|
+
}
|
|
604
|
+
// True when the acting member has the Discord Administrator permission. Used as the
|
|
605
|
+
// /config bootstrap gate (server admins can open /config before the role allowlist
|
|
606
|
+
// is set). PREFER interaction.memberPermissions — discord.js resolves it to a
|
|
607
|
+
// PermissionsBitField on EVERY guild interaction, cached or not, so it avoids the
|
|
608
|
+
// brittle raw-permissions branches below. Falls back to member.permissions (a
|
|
609
|
+
// PermissionsBitField on a cached GuildMember, or a string bitfield on an uncached
|
|
610
|
+
// APIInteractionGuildMember). Any read error → false (never throw before the ack, or
|
|
611
|
+
// the interaction dies unacknowledged → "application did not respond"). DM → false.
|
|
612
|
+
function hasAdminPermission(interaction) {
|
|
613
|
+
try {
|
|
614
|
+
// memberPermissions is present on guild interactions (null in DMs); it is already
|
|
615
|
+
// a resolved PermissionsBitField for both cached and uncached members.
|
|
616
|
+
const memberPerms = interaction.memberPermissions;
|
|
617
|
+
if (memberPerms && typeof memberPerms.has === 'function') {
|
|
618
|
+
return memberPerms.has(PermissionFlagsBits.Administrator);
|
|
619
|
+
}
|
|
620
|
+
const member = interaction.member;
|
|
621
|
+
if (!member)
|
|
622
|
+
return false;
|
|
623
|
+
const perms = member.permissions;
|
|
624
|
+
if (perms && typeof perms === 'object' && 'has' in perms) {
|
|
625
|
+
return perms.has(PermissionFlagsBits.Administrator);
|
|
626
|
+
}
|
|
627
|
+
if (typeof perms === 'string') {
|
|
628
|
+
return (BigInt(perms) & PermissionFlagsBits.Administrator) === PermissionFlagsBits.Administrator;
|
|
629
|
+
}
|
|
630
|
+
return false;
|
|
631
|
+
}
|
|
632
|
+
catch {
|
|
633
|
+
return false;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
// The acting member's role ids, or null when unavailable (DMs). Discord may deliver
|
|
637
|
+
// member.roles as a GuildMemberRoleManager (has .cache) — normalize to the router's
|
|
638
|
+
// tiny { roles: { cache: { map } } } shape.
|
|
639
|
+
function adaptMember(interaction) {
|
|
640
|
+
const member = interaction.member;
|
|
641
|
+
if (!member)
|
|
642
|
+
return null;
|
|
643
|
+
const roles = member.roles;
|
|
644
|
+
// In a cached guild, roles is a GuildMemberRoleManager with a .cache Collection.
|
|
645
|
+
if (roles && 'cache' in roles) {
|
|
646
|
+
const cache = roles.cache;
|
|
647
|
+
return { roles: { cache } };
|
|
648
|
+
}
|
|
649
|
+
// Uncached (APIInteractionGuildMember): roles is a string[] of ids.
|
|
650
|
+
const ids = Array.isArray(roles) ? roles : [];
|
|
651
|
+
return { roles: { cache: { map: (fn) => ids.map((id) => fn({ id })) } } };
|
|
652
|
+
}
|
|
653
|
+
// interaction.options.getSubcommand() throws when the command has no subcommands;
|
|
654
|
+
// return null in that case so a flat command (/stop, /stop-all) is handled cleanly.
|
|
655
|
+
function safeSubcommand(interaction) {
|
|
656
|
+
try {
|
|
657
|
+
return interaction.options.getSubcommand(false);
|
|
658
|
+
}
|
|
659
|
+
catch {
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
// Re-export a small helper for ephemeral flags so the routers stay discord.js-free
|
|
664
|
+
// where possible. (The routers still import interaction types; this is a convenience.)
|
|
665
|
+
export const EPHEMERAL = MessageFlags.Ephemeral;
|
|
666
|
+
//# sourceMappingURL=client.js.map
|