@developer.krd/discord-dashboard 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/README.md ADDED
@@ -0,0 +1,673 @@
1
+ # @developer.krd/discord-dashboard
2
+
3
+ Advanced plug-and-play Discord dashboard package for bot developers.
4
+
5
+ - No frontend coding required.
6
+ - Built-in Discord OAuth2 login flow.
7
+ - Ready-made dashboard UI.
8
+ - Guild access control.
9
+ - Discord-like server rail with user/guild avatars.
10
+ - Invite-on-click flow for guilds where bot is not installed.
11
+ - Extensible plugin panels + server actions.
12
+ - Flexible Home Builder with editable sections and save actions.
13
+ - Fluent designer API for setup/user/guild dashboard separation.
14
+ - Fluent `.setupDesign(...)` API for dashboard main colors.
15
+ - Home section width presets: `100` (default), `50`, `33`, `20`.
16
+ - Built-in Discord utility helpers in actions (`getChannel`, `getRole`, `getGuildRoles`, etc.).
17
+ - Overview-first tabs with separate module categories (like pets, permissions, etc.).
18
+ - Website autocomplete field types for role/channel selection with filtering.
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ npm install @developer.krd/discord-dashboard
24
+ ```
25
+
26
+ ## Ways to Create a Dashboard
27
+
28
+ You can build your dashboard in multiple styles depending on your project:
29
+
30
+ 1. **Direct Config (`createDashboard`)**
31
+ - Fastest way for simple dashboards.
32
+ - Use `home`, `plugins`, and `getOverviewCards` directly.
33
+
34
+ 2. **Fluent Designer (`createDashboardDesigner`)**
35
+ - Best for larger bots and modular structure.
36
+ - Use `setupCategory`, `userCategory`, `guildCategory`, and `onHomeAction`.
37
+
38
+ 3. **Discord-like Lifecycle Pages**
39
+ - Use `setupPage + onLoad/onSave` (also `onload/onsave`).
40
+ - Great when pages need dynamic loading and per-page save handlers.
41
+
42
+ 4. **Template-based UI/UX**
43
+ - Choose built-in template (`default`, `compact`) with `uiTemplate`.
44
+ - Register custom full HTML templates with `uiTemplates` or Designer `addTemplate`.
45
+
46
+ ## Language Support
47
+
48
+ This package supports TypeScript, JavaScript (`.js`), and ESM JavaScript (`.mjs`).
49
+
50
+ TypeScript / ESM:
51
+
52
+ ```ts
53
+ import { createDashboard } from "@developer.krd/discord-dashboard";
54
+ ```
55
+
56
+ JavaScript ESM (`.mjs`):
57
+
58
+ ```js
59
+ import { createDashboard } from "@developer.krd/discord-dashboard";
60
+ ```
61
+
62
+ JavaScript CommonJS (`.js` with `require`):
63
+
64
+ ```js
65
+ const { createDashboard } = require("@developer.krd/discord-dashboard");
66
+ ```
67
+
68
+ ## Quick Start
69
+
70
+ ```ts
71
+ import express from "express";
72
+ import { createDashboard } from "@developer.krd/discord-dashboard";
73
+
74
+ const app = express();
75
+
76
+ const dashboard = createDashboard({
77
+ app,
78
+ basePath: "/dashboard",
79
+ dashboardName: "KRD Bot Control",
80
+ botToken: process.env.DISCORD_BOT_TOKEN!,
81
+ clientId: process.env.DISCORD_CLIENT_ID!,
82
+ clientSecret: process.env.DISCORD_CLIENT_SECRET!,
83
+ redirectUri: "http://localhost:3000/dashboard/callback",
84
+ sessionSecret: process.env.DASHBOARD_SESSION_SECRET!,
85
+ ownerIds: ["123456789012345678"],
86
+ getOverviewCards: async (context) => [
87
+ {
88
+ id: "uptime",
89
+ title: "Bot Uptime",
90
+ value: `${Math.floor(process.uptime() / 60)} min`,
91
+ subtitle: `Logged in as ${context.user.username}`
92
+ },
93
+ {
94
+ id: "guilds",
95
+ title: "Guilds",
96
+ value: context.guilds.length
97
+ }
98
+ ],
99
+ home: {
100
+ async getSections(context) {
101
+ return [
102
+ {
103
+ id: "welcome",
104
+ title: "Welcome Settings",
105
+ description: context.selectedGuildId ? "Guild-specific setup" : "User-level setup",
106
+ fields: [
107
+ { id: "enabled", label: "Enable Welcome", type: "boolean", value: true },
108
+ { id: "channel", label: "Channel ID", type: "text", value: "" },
109
+ { id: "message", label: "Message", type: "textarea", value: "Welcome to the server!" }
110
+ ],
111
+ actions: [{ id: "saveWelcome", label: "Save", variant: "primary" }]
112
+ }
113
+ ];
114
+ },
115
+ actions: {
116
+ async saveWelcome(context, payload) {
117
+ console.log("Saving", context.selectedGuildId, payload.values);
118
+ return { ok: true, message: "Welcome settings saved", refresh: false };
119
+ }
120
+ }
121
+ },
122
+ plugins: [
123
+ {
124
+ id: "moderation",
125
+ name: "Moderation",
126
+ description: "Live moderation controls",
127
+ async getPanels(context) {
128
+ return [
129
+ {
130
+ id: "status",
131
+ title: "Status",
132
+ fields: [
133
+ { label: "Selected Guild", value: context.selectedGuildId ?? "None" },
134
+ { label: "Access", value: "Ready" }
135
+ ],
136
+ actions: [
137
+ { id: "sync", label: "Sync Rules", variant: "primary" }
138
+ ]
139
+ }
140
+ ];
141
+ },
142
+ actions: {
143
+ async sync() {
144
+ return { ok: true, message: "Moderation rules synced.", refresh: true };
145
+ }
146
+ }
147
+ }
148
+ ]
149
+ });
150
+
151
+ app.listen(3000, () => {
152
+ console.log("Dashboard: http://localhost:3000/dashboard");
153
+ });
154
+ ```
155
+
156
+ ## Fluent Dashboard Designer (Recommended)
157
+
158
+ Use a clean modular structure instead of putting all configuration in one place:
159
+
160
+ - `setup(...)` for important defaults
161
+ - `setupDesign(...)` for dashboard colors (`primary`, `bg`, `rail`, `panel`, etc.)
162
+ - `setupCategory(...)` for one-time setup UI
163
+ - `userCategory(...)` for user dashboard sections
164
+ - `guildCategory(...)` for server dashboard sections
165
+ - `onHomeAction(...)` for save handlers
166
+
167
+ ```ts
168
+ import { createDashboard, createDashboardDesigner } from "@developer.krd/discord-dashboard";
169
+
170
+ const designer = createDashboardDesigner({
171
+ app,
172
+ botToken,
173
+ clientId,
174
+ clientSecret,
175
+ redirectUri,
176
+ sessionSecret
177
+ })
178
+ .setup({ ownerIds: ["123"], botInvitePermissions: "8" })
179
+ .setupDesign({ primary: "#4f46e5", rail: "#181a20", panel: "#2f3136" })
180
+ .userCategory("pets", "Pets", (category) => {
181
+ category.section({
182
+ id: "pets-user",
183
+ title: "User Pets",
184
+ width: 50,
185
+ fields: [{ id: "petName", label: "Pet Name", type: "text", value: "Luna" }],
186
+ actions: [{ id: "saveUserPets", label: "Save", variant: "primary" }]
187
+ });
188
+ })
189
+ .guildCategory("pets", "Pets", (category) => {
190
+ category.section({
191
+ id: "pets-guild",
192
+ title: "Guild Pets",
193
+ fields: [{ id: "petsChannelId", label: "Pets Channel", type: "text", value: "" }],
194
+ actions: [{ id: "saveGuildPets", label: "Save", variant: "primary" }]
195
+ });
196
+ })
197
+ .onHomeAction("saveGuildPets", async (context, payload) => {
198
+ const channelId = String(payload.values.petsChannelId ?? "");
199
+ const channel = await context.helpers.getChannel(channelId);
200
+ if (!channel) return { ok: false, message: "Channel not found" };
201
+ return { ok: true, message: "Saved", refresh: true };
202
+ });
203
+
204
+ createDashboard({ ...designer.build() });
205
+ ```
206
+
207
+ ## Discord-Style Flexible Flow (`onLoad` / `onSave`)
208
+
209
+ You can also build pages in a Discord-client-like style:
210
+
211
+ ```ts
212
+ import { createDashboard, createDashboardDesigner } from "@developer.krd/discord-dashboard";
213
+
214
+ const dashboard = createDashboardDesigner({
215
+ app,
216
+ botToken,
217
+ clientId,
218
+ clientSecret,
219
+ redirectUri,
220
+ sessionSecret
221
+ })
222
+ .setupPage({
223
+ id: "profile",
224
+ title: "Profile",
225
+ scope: "user",
226
+ width: 50,
227
+ fields: [
228
+ { id: "bio", label: "Bio", type: "textarea", value: "" },
229
+ { id: "notifications", label: "Notifications", type: "boolean", value: true }
230
+ ]
231
+ })
232
+ .onLoad("profile", async (ctx, section) => ({
233
+ ...section,
234
+ description: `Loaded for ${ctx.user.username}`
235
+ }))
236
+ .onSave("profile", async (ctx, payload) => {
237
+ console.log("save profile", ctx.user.id, payload.values);
238
+ return { ok: true, message: "Profile saved", refresh: true };
239
+ });
240
+
241
+ createDashboard({ ...dashboard.build() });
242
+ ```
243
+
244
+ Notes:
245
+
246
+ - `setupPage(...)` creates a page/section with optional fields/actions.
247
+ - `onLoad(pageId, handler)` (or lowercase `onload`) runs when that page is resolved.
248
+ - `onSave(pageId, handler)` (or lowercase `onsave`) auto-adds a default **Save** action if missing.
249
+
250
+ ## Designer API Reference
251
+
252
+ `createDashboardDesigner(baseOptions)` supports:
253
+
254
+ - `setup({...})`: core setup (`ownerIds`, invite permissions/scopes, name/path, `uiTemplate`).
255
+ - `setupDesign({...})`: color/theme tokens for built-in templates.
256
+ - `setupCategory(id, label, build)`
257
+ - `userCategory(id, label, build)`
258
+ - `guildCategory(id, label, build)`
259
+ - `onHomeAction(actionId, handler)`
260
+ - `setupPage({...})`: page-style section definition.
261
+ - `onLoad(pageId, handler)` + alias `onload(...)`.
262
+ - `onSave(pageId, handler)` + alias `onsave(...)`.
263
+ - `addTemplate(templateId, renderer)` + `useTemplate(templateId)`.
264
+ - `build()`: returns full `DashboardOptions` for `createDashboard(...)`.
265
+
266
+ ## UI Template System (for package developers)
267
+
268
+ You can ship multiple UI/UX templates and choose one by id.
269
+
270
+ Built-in templates:
271
+
272
+ - `default`
273
+ - `compact`
274
+
275
+ Use built-in compact mode:
276
+
277
+ ```ts
278
+ createDashboard({
279
+ app,
280
+ botToken,
281
+ clientId,
282
+ clientSecret,
283
+ redirectUri,
284
+ sessionSecret,
285
+ uiTemplate: "compact"
286
+ });
287
+ ```
288
+
289
+ Direct `createDashboard(...)` usage:
290
+
291
+ ```ts
292
+ import { createDashboard } from "@developer.krd/discord-dashboard";
293
+
294
+ createDashboard({
295
+ app,
296
+ botToken,
297
+ clientId,
298
+ clientSecret,
299
+ redirectUri,
300
+ sessionSecret,
301
+ uiTemplate: "glass",
302
+ uiTemplates: {
303
+ glass: ({ dashboardName, basePath }) => `
304
+ <!doctype html>
305
+ <html>
306
+ <head><title>${dashboardName}</title></head>
307
+ <body>
308
+ <h1>${dashboardName}</h1>
309
+ <p>Custom template active. Base path: ${basePath}</p>
310
+ </body>
311
+ </html>
312
+ `
313
+ }
314
+ });
315
+ ```
316
+
317
+ Designer usage:
318
+
319
+ ```ts
320
+ const designer = createDashboardDesigner({
321
+ app,
322
+ botToken,
323
+ clientId,
324
+ clientSecret,
325
+ redirectUri,
326
+ sessionSecret
327
+ })
328
+ .addTemplate("glass", ({ dashboardName }) => `<html><body><h1>${dashboardName}</h1></body></html>`)
329
+ .useTemplate("glass");
330
+
331
+ createDashboard({ ...designer.build() });
332
+ ```
333
+
334
+ Template renderer signature:
335
+
336
+ - Input: `{ dashboardName, basePath, setupDesign }`
337
+ - Output: full HTML string (complete document/template, not only CSS overrides)
338
+
339
+ Template files in this package are organized under [src/templates](src/templates).
340
+
341
+ ## Built-in Helper Functions
342
+
343
+ Available in `context.helpers` inside home/plugin actions:
344
+
345
+ - `getChannel(channelId)`
346
+ - `getGuildChannels(guildId)`
347
+ - `getRole(guildId, roleId)`
348
+ - `getGuildRoles(guildId)`
349
+ - `getGuildMember(guildId, userId)`
350
+
351
+ ## Plugin Scopes and Form Actions
352
+
353
+ Plugins can now be separated by target dashboard scope:
354
+
355
+ - `scope: "user"` → shown only on user dashboard
356
+ - `scope: "guild"` → shown only on guild dashboard
357
+ - `scope: "both"` (default) → shown on both
358
+
359
+ Plugin action forms:
360
+
361
+ - Mark a panel field with `editable: true` and set `type` (text, textarea, select, boolean, role-search, channel-search, member-search, url).
362
+ - Use `type: "string-list"` for drag-and-drop ordered labels (great for poll buttons).
363
+ - Set action `collectFields: true` to send panel values in the action payload.
364
+ - Action body format:
365
+ - `panelId`
366
+ - `values` (contains selected lookup objects and typed field values)
367
+
368
+ ## Lookup Field Types (Website UI)
369
+
370
+ Use these in home sections to let users type and select Discord objects:
371
+
372
+ - `role-search`: type role name, get matching roles, select one, submit role object data.
373
+ - `channel-search`: type channel name, get matching channels with filters, select one, submit channel object data.
374
+
375
+ Lookup filters:
376
+
377
+ - `limit`
378
+ - `minQueryLength`
379
+ - `includeManaged` (roles)
380
+ - `nsfw` (channels)
381
+ - `channelTypes` (channels)
382
+
383
+ ## Required Discord OAuth2 Setup
384
+
385
+ 1. Open the Discord Developer Portal for your application.
386
+ 2. In OAuth2 settings, add your redirect URI (example: `http://localhost:3000/dashboard/callback`).
387
+ 3. Make sure scopes include at least `identify guilds`.
388
+ 4. Use your app `CLIENT_ID` and `CLIENT_SECRET` in `createDashboard()`.
389
+
390
+ ## API Reference
391
+
392
+ ### `createDashboard(options)`
393
+
394
+ Creates and mounts a complete dashboard with OAuth2 + UI.
395
+
396
+ Key `options`:
397
+
398
+ - `app?`: existing Express app instance.
399
+ - `basePath?`: dashboard route prefix. Default: `/dashboard`.
400
+ - `dashboardName?`: topbar/dashboard title.
401
+ - `setupDesign?`: override CSS theme variables (`primary`, `bg`, `rail`, `panel`, etc.).
402
+ - `uiTemplate?`: template id to render (`default`, `compact`, or your custom id).
403
+ - `uiTemplates?`: custom full-HTML template renderers.
404
+ - `botToken`: bot token (for your own plugin logic).
405
+ - `clientId`, `clientSecret`, `redirectUri`: Discord OAuth2 credentials.
406
+ - `sessionSecret`: secret for encrypted session cookies.
407
+ - `sessionName?`, `sessionMaxAgeMs?`: cookie/session configuration.
408
+ - `scopes?`: OAuth scopes for login (default includes `identify guilds`).
409
+ - `botInvitePermissions?`, `botInviteScopes?`: controls invite link generation.
410
+ - `ownerIds?`: optional allow-list of Discord user IDs.
411
+ - `guildFilter?`: async filter for guild visibility.
412
+ - `getOverviewCards?`: dynamic card resolver.
413
+ - `home?`: configurable homepage sections + save handlers.
414
+ - `home.getOverviewSections?`: add overview-scoped sections.
415
+ - `home.getCategories?`: define category tabs (setup/user/guild).
416
+ - `home.getSections?`: define sections for active scope.
417
+ - `home.actions?`: action handlers for section actions.
418
+ - `plugins?`: plugin definitions with panels/actions.
419
+ - `trustProxy?`: proxy configuration when running behind reverse proxies.
420
+ - `host?`, `port?`: only used when you call `dashboard.start()` without passing `app`.
421
+
422
+ Return value:
423
+
424
+ - `app`: Express app instance.
425
+ - `start()`: starts internal server only if you didn’t pass `app`.
426
+ - `stop()`: stops internal server.
427
+
428
+ ## Security Notes
429
+
430
+ - Uses secure HTTP-only session cookies.
431
+ - Uses OAuth2 `state` validation for callback integrity.
432
+ - Use HTTPS in production and strong `sessionSecret`.
433
+ - Optionally set trusted proxy with `trustProxy`.
434
+
435
+ ## Local Development
436
+
437
+ ```bash
438
+ npm install
439
+ npm run typecheck
440
+ npm run build
441
+ ```
442
+
443
+ Run the example app:
444
+
445
+ ```bash
446
+ cp .env.example .env
447
+ # Fill .env with your Discord app credentials first
448
+ npm run example
449
+ ```
450
+
451
+ Run the real-bot entry directly:
452
+
453
+ ```bash
454
+ npm run real-bot
455
+ ```
456
+
457
+ ## Real Bot Code Examples
458
+
459
+ The real-bot sample is in:
460
+
461
+ - [examples/real-bot/main.ts](examples/real-bot/main.ts)
462
+ - [examples/real-bot/dashboard.ts](examples/real-bot/dashboard.ts)
463
+ - [examples/real-bot/commands](examples/real-bot/commands)
464
+ - [examples/real-bot/events](examples/real-bot/events)
465
+
466
+ ### Slash command example (`/ping`)
467
+
468
+ ```ts
469
+ import { SlashCommandBuilder } from "discord.js";
470
+ import type { BotCommand } from "../types";
471
+
472
+ const command: BotCommand = {
473
+ data: new SlashCommandBuilder().setName("ping").setDescription("Check latency"),
474
+ async execute(context, interaction) {
475
+ const wsPing = context.client.ws.ping;
476
+ const start = interaction.createdTimestamp;
477
+ const response = await interaction.reply({ content: "Pinging...", fetchReply: true });
478
+ const apiPing = response.createdTimestamp - start;
479
+ await interaction.editReply(`🏓 Pong! WS: ${wsPing}ms | API: ${apiPing}ms`);
480
+ }
481
+ };
482
+
483
+ export default command;
484
+ ```
485
+
486
+ ### Event example (`interactionCreate`)
487
+
488
+ ```ts
489
+ import type { BotEvent } from "../types";
490
+
491
+ const event: BotEvent<"interactionCreate"> = {
492
+ name: "interactionCreate",
493
+ async execute(context, interaction) {
494
+ if (!interaction.isChatInputCommand()) return;
495
+
496
+ const command = context.commands.get(interaction.commandName);
497
+ if (!command) {
498
+ await interaction.reply({ content: "Command not found.", ephemeral: true });
499
+ return;
500
+ }
501
+
502
+ await command.execute(context, interaction);
503
+ }
504
+ };
505
+
506
+ export default event;
507
+ ```
508
+
509
+ ### Dashboard creation example (classic + flexible pages)
510
+
511
+ ```ts
512
+ createDashboard({
513
+ app,
514
+ botToken,
515
+ clientId,
516
+ clientSecret,
517
+ redirectUri,
518
+ sessionSecret,
519
+ uiTemplate: "compact",
520
+ home: {
521
+ async getSections(ctx) {
522
+ return [{
523
+ id: "classic",
524
+ title: "Classic Home",
525
+ scope: ctx.selectedGuildId ? "guild" : "user",
526
+ fields: [{ id: "mode", label: "Mode", type: "text", value: ctx.selectedGuildId ? "Guild" : "User", readOnly: true }]
527
+ }];
528
+ }
529
+ }
530
+ });
531
+
532
+ // Flexible style:
533
+ createDashboard({
534
+ ...createDashboardDesigner({ app, botToken, clientId, clientSecret, redirectUri, sessionSecret })
535
+ .setupPage({ id: "profile", title: "Profile", scope: "user", fields: [{ id: "bio", label: "Bio", type: "textarea" }] })
536
+ .onLoad("profile", async (ctx, section) => ({ ...section, description: `Loaded for ${ctx.user.username}` }))
537
+ .onSave("profile", async () => ({ ok: true, message: "Saved", refresh: true }))
538
+ .build()
539
+ });
540
+ ```
541
+
542
+ While using the real-bot example, live edits and bot activity are persisted to [examples/dashboard-demo-state.json](examples/dashboard-demo-state.json).
543
+
544
+ ## Common Recipes
545
+
546
+ ### Welcome System (Guild Home Section)
547
+
548
+ ```ts
549
+ import { createDashboard, createDashboardDesigner } from "@developer.krd/discord-dashboard";
550
+
551
+ const designer = createDashboardDesigner({ app, botToken, clientId, clientSecret, redirectUri, sessionSecret })
552
+ .guildCategory("welcome", "Welcome", (category) => {
553
+ category.section({
554
+ id: "welcome-settings",
555
+ title: "Welcome Settings",
556
+ fields: [
557
+ { id: "enabled", label: "Enabled", type: "boolean", value: true },
558
+ { id: "channel", label: "Welcome Channel", type: "channel-search", placeholder: "Search channel..." },
559
+ { id: "message", label: "Message", type: "textarea", value: "Welcome to the server, {user}!" }
560
+ ],
561
+ actions: [{ id: "saveWelcome", label: "Save Welcome", variant: "primary" }]
562
+ });
563
+ })
564
+ .onHomeAction("saveWelcome", async (context, payload) => {
565
+ if (!context.selectedGuildId) return { ok: false, message: "Select a guild first" };
566
+ const channel = payload.values.channel as { id?: string; name?: string } | null;
567
+ return { ok: true, message: `Welcome config saved for ${context.selectedGuildId} in #${channel?.name ?? "unknown"}` };
568
+ });
569
+
570
+ createDashboard({ ...designer.build() });
571
+ ```
572
+
573
+ ### Moderation Toggles + Limits
574
+
575
+ ```ts
576
+ home: {
577
+ getSections: async (context) => [
578
+ {
579
+ id: "moderation-core",
580
+ title: "Moderation",
581
+ scope: "guild",
582
+ fields: [
583
+ { id: "antiSpam", label: "Anti-Spam", type: "boolean", value: true },
584
+ { id: "antiLinks", label: "Block Suspicious Links", type: "boolean", value: true },
585
+ { id: "maxMentions", label: "Max Mentions", type: "number", value: 5 }
586
+ ],
587
+ actions: [{ id: "saveModeration", label: "Save Moderation", variant: "primary" }]
588
+ }
589
+ ],
590
+ actions: {
591
+ saveModeration: async (_context, payload) => ({
592
+ ok: true,
593
+ message: `Saved moderation: antiSpam=${Boolean(payload.values.antiSpam)}, maxMentions=${Number(payload.values.maxMentions ?? 0)}`,
594
+ refresh: true
595
+ })
596
+ }
597
+ }
598
+ ```
599
+
600
+ ### Ticket Panel Builder (Plugin)
601
+
602
+ ```ts
603
+ plugins: [
604
+ {
605
+ id: "tickets",
606
+ name: "Tickets",
607
+ scope: "guild",
608
+ getPanels: async () => [
609
+ {
610
+ id: "ticket-panel",
611
+ title: "Ticket Panel",
612
+ fields: [
613
+ { id: "targetChannel", label: "Target Channel", type: "channel-search", editable: true, value: "" },
614
+ { id: "title", label: "Embed Title", type: "text", editable: true, value: "Need help?" },
615
+ { id: "description", label: "Embed Description", type: "textarea", editable: true, value: "Click below to open a ticket." },
616
+ { id: "buttonLabel", label: "Button Label", type: "text", editable: true, value: "Open Ticket" }
617
+ ],
618
+ actions: [{ id: "publishTicketPanel", label: "Publish", variant: "primary", collectFields: true }]
619
+ }
620
+ ],
621
+ actions: {
622
+ publishTicketPanel: async (_context, body) => {
623
+ const data = body as { values?: Record<string, unknown> };
624
+ const values = data.values ?? {};
625
+ return { ok: true, message: `Ticket panel published as '${String(values.title ?? "Need help?")}'`, data: values };
626
+ }
627
+ }
628
+ }
629
+ ]
630
+ ```
631
+
632
+ ### Announcement Composer (with Role Mention)
633
+
634
+ ```ts
635
+ plugins: [
636
+ {
637
+ id: "announcements",
638
+ name: "Announcements",
639
+ scope: "guild",
640
+ getPanels: async () => [
641
+ {
642
+ id: "announce",
643
+ title: "Announcement Composer",
644
+ fields: [
645
+ { id: "channel", label: "Channel", type: "channel-search", editable: true, value: "" },
646
+ { id: "role", label: "Mention Role", type: "role-search", editable: true, value: "" },
647
+ { id: "content", label: "Content", type: "textarea", editable: true, value: "Server update goes here..." }
648
+ ],
649
+ actions: [{ id: "sendAnnouncement", label: "Send", variant: "primary", collectFields: true }]
650
+ }
651
+ ],
652
+ actions: {
653
+ sendAnnouncement: async (_context, body) => {
654
+ const payload = body as { values?: Record<string, unknown> };
655
+ const channel = payload.values?.channel as { id?: string; name?: string } | null;
656
+ const role = payload.values?.role as { id?: string; name?: string } | null;
657
+ const mention = role?.id ? `<@&${role.id}> ` : "";
658
+ const content = String(payload.values?.content ?? "");
659
+
660
+ return {
661
+ ok: true,
662
+ message: `Announcement queued for #${channel?.name ?? "unknown"}`,
663
+ data: { message: mention + content, channel }
664
+ };
665
+ }
666
+ }
667
+ }
668
+ ]
669
+ ```
670
+
671
+ ## License
672
+
673
+ MIT