kimaki 0.4.78 → 0.4.80

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.
Files changed (90) hide show
  1. package/dist/anthropic-auth-plugin.js +628 -0
  2. package/dist/channel-management.js +2 -2
  3. package/dist/cli.js +316 -129
  4. package/dist/commands/action-buttons.js +1 -1
  5. package/dist/commands/login.js +634 -277
  6. package/dist/commands/model.js +91 -6
  7. package/dist/commands/paginated-select.js +57 -0
  8. package/dist/commands/resume.js +2 -2
  9. package/dist/commands/tasks.js +205 -0
  10. package/dist/commands/undo-redo.js +80 -18
  11. package/dist/context-awareness-plugin.js +347 -0
  12. package/dist/database.js +103 -7
  13. package/dist/db.js +39 -1
  14. package/dist/discord-bot.js +42 -19
  15. package/dist/discord-urls.js +11 -0
  16. package/dist/discord-ws-proxy.js +350 -0
  17. package/dist/discord-ws-proxy.test.js +500 -0
  18. package/dist/errors.js +1 -1
  19. package/dist/gateway-session.js +163 -0
  20. package/dist/hrana-server.js +114 -4
  21. package/dist/interaction-handler.js +30 -7
  22. package/dist/ipc-tools-plugin.js +186 -0
  23. package/dist/message-preprocessing.js +56 -11
  24. package/dist/onboarding-welcome.js +1 -1
  25. package/dist/opencode-interrupt-plugin.js +133 -75
  26. package/dist/opencode-plugin.js +12 -389
  27. package/dist/opencode.js +59 -5
  28. package/dist/parse-permission-rules.test.js +117 -0
  29. package/dist/queue-drain-after-interactive-ui.e2e.test.js +119 -0
  30. package/dist/session-handler/thread-session-runtime.js +68 -29
  31. package/dist/startup-time.e2e.test.js +295 -0
  32. package/dist/store.js +1 -0
  33. package/dist/system-message.js +3 -1
  34. package/dist/task-runner.js +7 -3
  35. package/dist/task-schedule.js +12 -0
  36. package/dist/thread-message-queue.e2e.test.js +13 -1
  37. package/dist/undo-redo.e2e.test.js +166 -0
  38. package/dist/utils.js +4 -1
  39. package/dist/voice-attachment.js +34 -0
  40. package/dist/voice-handler.js +11 -9
  41. package/dist/voice-message.e2e.test.js +78 -0
  42. package/dist/voice.test.js +31 -0
  43. package/package.json +12 -7
  44. package/skills/egaki/SKILL.md +80 -15
  45. package/skills/errore/SKILL.md +13 -0
  46. package/skills/lintcn/SKILL.md +749 -0
  47. package/skills/npm-package/SKILL.md +17 -3
  48. package/skills/spiceflow/SKILL.md +14 -0
  49. package/skills/zele/SKILL.md +9 -0
  50. package/src/anthropic-auth-plugin.ts +732 -0
  51. package/src/channel-management.ts +2 -2
  52. package/src/cli.ts +354 -132
  53. package/src/commands/action-buttons.ts +1 -0
  54. package/src/commands/login.ts +836 -337
  55. package/src/commands/model.ts +102 -7
  56. package/src/commands/paginated-select.ts +81 -0
  57. package/src/commands/resume.ts +6 -1
  58. package/src/commands/tasks.ts +293 -0
  59. package/src/commands/undo-redo.ts +87 -20
  60. package/src/context-awareness-plugin.ts +469 -0
  61. package/src/database.ts +138 -7
  62. package/src/db.ts +40 -1
  63. package/src/discord-bot.ts +46 -19
  64. package/src/discord-urls.ts +12 -0
  65. package/src/errors.ts +1 -1
  66. package/src/hrana-server.ts +124 -3
  67. package/src/interaction-handler.ts +41 -9
  68. package/src/ipc-tools-plugin.ts +228 -0
  69. package/src/message-preprocessing.ts +82 -11
  70. package/src/onboarding-welcome.ts +1 -1
  71. package/src/opencode-interrupt-plugin.ts +164 -91
  72. package/src/opencode-plugin.ts +13 -483
  73. package/src/opencode.ts +60 -5
  74. package/src/parse-permission-rules.test.ts +127 -0
  75. package/src/queue-drain-after-interactive-ui.e2e.test.ts +151 -0
  76. package/src/session-handler/thread-runtime-state.ts +4 -1
  77. package/src/session-handler/thread-session-runtime.ts +82 -20
  78. package/src/startup-time.e2e.test.ts +372 -0
  79. package/src/store.ts +8 -0
  80. package/src/system-message.ts +10 -1
  81. package/src/task-runner.ts +9 -22
  82. package/src/task-schedule.ts +15 -0
  83. package/src/thread-message-queue.e2e.test.ts +14 -1
  84. package/src/undo-redo.e2e.test.ts +207 -0
  85. package/src/utils.ts +7 -0
  86. package/src/voice-attachment.ts +51 -0
  87. package/src/voice-handler.ts +15 -7
  88. package/src/voice-message.e2e.test.ts +95 -0
  89. package/src/voice.test.ts +36 -0
  90. package/src/onboarding-tutorial-plugin.ts +0 -93
@@ -31,6 +31,7 @@ import { getRuntime } from '../session-handler/thread-session-runtime.js'
31
31
  import { getThinkingValuesForModel } from '../thinking-utils.js'
32
32
  import { createLogger, LogPrefix } from '../logger.js'
33
33
  import * as errore from 'errore'
34
+ import { buildPaginatedOptions, parsePaginationValue } from './paginated-select.js'
34
35
 
35
36
  const modelLogger = createLogger(LogPrefix.MODEL)
36
37
 
@@ -51,6 +52,10 @@ type PendingModelContext = {
51
52
  selectedModelId?: string
52
53
  selectedVariant?: string | null
53
54
  availableVariants?: string[]
55
+ providerPage?: number
56
+ modelPage?: number
57
+ /** Header text shown above the provider select (current model info). */
58
+ providerSelectHeader?: string
54
59
  }
55
60
 
56
61
  const pendingModelContexts = new Map<string, PendingModelContext>()
@@ -464,6 +469,7 @@ export async function handleModelCommand({
464
469
  })()
465
470
 
466
471
  // Store context with a short hash key to avoid customId length limits.
472
+ const providerSelectHeader = `**Set Model Preference**\n${currentModelText}${variantText}\nSelect a provider:`
467
473
  const context = {
468
474
  dir: projectDirectory,
469
475
  channelId: targetChannelId,
@@ -471,13 +477,13 @@ export async function handleModelCommand({
471
477
  isThread: isThread,
472
478
  thread: isThread ? (channel as ThreadChannel) : undefined,
473
479
  appId,
480
+ providerSelectHeader,
474
481
  }
475
482
  const contextHash = crypto.randomBytes(8).toString('hex')
476
483
  setModelContext(contextHash, context)
477
484
 
478
- const options = [...availableProviders]
485
+ const allProviderOptions = [...availableProviders]
479
486
  .sort((a, b) => a.name.localeCompare(b.name))
480
- .slice(0, 25)
481
487
  .map((provider) => {
482
488
  const modelCount = Object.keys(provider.models || {}).length
483
489
  return {
@@ -491,6 +497,11 @@ export async function handleModelCommand({
491
497
  }
492
498
  })
493
499
 
500
+ const { options } = buildPaginatedOptions({
501
+ allOptions: allProviderOptions,
502
+ page: 0,
503
+ })
504
+
494
505
  const selectMenu = new StringSelectMenuBuilder()
495
506
  .setCustomId(`model_provider:${contextHash}`)
496
507
  .setPlaceholder('Select a provider')
@@ -500,7 +511,7 @@ export async function handleModelCommand({
500
511
  new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu)
501
512
 
502
513
  await interaction.editReply({
503
- content: `**Set Model Preference**\n${currentModelText}${variantText}\nSelect a provider:`,
514
+ content: providerSelectHeader,
504
515
  components: [actionRow],
505
516
  })
506
517
  } catch (error) {
@@ -547,6 +558,47 @@ export async function handleProviderSelectMenu(
547
558
  return
548
559
  }
549
560
 
561
+ // Handle pagination nav — re-render the same provider select with new page
562
+ const providerNavPage = parsePaginationValue(selectedProviderId)
563
+ if (providerNavPage !== undefined) {
564
+ context.providerPage = providerNavPage
565
+ setModelContext(contextHash, context)
566
+
567
+ const getClient = await initializeOpencodeForDirectory(context.dir)
568
+ if (getClient instanceof Error) {
569
+ await interaction.editReply({ content: getClient.message, components: [] })
570
+ return
571
+ }
572
+ const providersResponse = await getClient().provider.list({ directory: context.dir })
573
+ if (!providersResponse.data) {
574
+ await interaction.editReply({ content: 'Failed to fetch providers', components: [] })
575
+ return
576
+ }
577
+ const { all: allProviders, connected } = providersResponse.data
578
+ const availableProviders = allProviders.filter((p) => connected.includes(p.id))
579
+ const allProviderOptions = [...availableProviders]
580
+ .sort((a, b) => a.name.localeCompare(b.name))
581
+ .map((p) => {
582
+ const modelCount = Object.keys(p.models || {}).length
583
+ return {
584
+ label: p.name.slice(0, 100),
585
+ value: p.id,
586
+ description: `${modelCount} model${modelCount !== 1 ? 's' : ''} available`.slice(0, 100),
587
+ }
588
+ })
589
+ const { options } = buildPaginatedOptions({ allOptions: allProviderOptions, page: providerNavPage })
590
+ const selectMenu = new StringSelectMenuBuilder()
591
+ .setCustomId(`model_provider:${contextHash}`)
592
+ .setPlaceholder('Select a provider')
593
+ .addOptions(options)
594
+ const actionRow = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu)
595
+ await interaction.editReply({
596
+ content: context.providerSelectHeader || `**Set Model Preference**\nSelect a provider:`,
597
+ components: [actionRow],
598
+ })
599
+ return
600
+ }
601
+
550
602
  try {
551
603
  const getClient = await initializeOpencodeForDirectory(context.dir)
552
604
  if (getClient instanceof Error) {
@@ -597,15 +649,13 @@ export async function handleProviderSelectMenu(
597
649
  return
598
650
  }
599
651
 
600
- // Take first 25 models (most recent since sorted descending)
601
- const recentModels = models.slice(0, 25)
602
-
603
652
  // Update context with provider info and reuse the same hash
604
653
  context.providerId = selectedProviderId
605
654
  context.providerName = provider.name
655
+ context.modelPage = 0
606
656
  setModelContext(contextHash, context)
607
657
 
608
- const options = recentModels.map((model) => {
658
+ const allModelOptions = models.map((model) => {
609
659
  const dateStr = model.releaseDate
610
660
  ? new Date(model.releaseDate).toLocaleDateString()
611
661
  : 'Unknown date'
@@ -616,6 +666,11 @@ export async function handleProviderSelectMenu(
616
666
  }
617
667
  })
618
668
 
669
+ const { options } = buildPaginatedOptions({
670
+ allOptions: allModelOptions,
671
+ page: 0,
672
+ })
673
+
619
674
  const selectMenu = new StringSelectMenuBuilder()
620
675
  .setCustomId(`model_select:${contextHash}`)
621
676
  .setPlaceholder('Select a model')
@@ -673,6 +728,46 @@ export async function handleModelSelectMenu(
673
728
  return
674
729
  }
675
730
 
731
+ // Handle pagination nav — re-render the same model select with new page
732
+ const modelNavPage = parsePaginationValue(selectedModelId)
733
+ if (modelNavPage !== undefined) {
734
+ context.modelPage = modelNavPage
735
+ setModelContext(contextHash, context)
736
+
737
+ const getClient = await initializeOpencodeForDirectory(context.dir)
738
+ if (getClient instanceof Error) {
739
+ await interaction.editReply({ content: getClient.message, components: [] })
740
+ return
741
+ }
742
+ const providersResponse = await getClient().provider.list({ directory: context.dir })
743
+ const provider = providersResponse.data?.all.find((p) => p.id === context.providerId)
744
+ if (!provider) {
745
+ await interaction.editReply({ content: 'Provider not found', components: [] })
746
+ return
747
+ }
748
+ const allModelOptions = Object.entries(provider.models || {})
749
+ .map(([modelId, model]) => ({
750
+ label: model.name.slice(0, 100),
751
+ value: modelId,
752
+ description: (model.release_date
753
+ ? new Date(model.release_date).toLocaleDateString()
754
+ : 'Unknown date'
755
+ ).slice(0, 100),
756
+ }))
757
+ .sort((a, b) => a.label.localeCompare(b.label))
758
+ const { options } = buildPaginatedOptions({ allOptions: allModelOptions, page: modelNavPage })
759
+ const selectMenu = new StringSelectMenuBuilder()
760
+ .setCustomId(`model_select:${contextHash}`)
761
+ .setPlaceholder('Select a model')
762
+ .addOptions(options)
763
+ const actionRow = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu)
764
+ await interaction.editReply({
765
+ content: `**Set Model Preference**\nProvider: **${context.providerName}**\nSelect a model:`,
766
+ components: [actionRow],
767
+ })
768
+ return
769
+ }
770
+
676
771
  // Build full model ID: provider_id/model_id
677
772
  const fullModelId = `${context.providerId}/${selectedModelId}`
678
773
 
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Reusable paginated select menu helpers for Discord StringSelectMenuBuilder.
3
+ * Discord caps select menus at 25 options. This module slices a full options
4
+ * list into pages of PAGE_SIZE real items and appends "← Previous page" /
5
+ * "Next page →" sentinel options so the user can navigate. Handlers detect
6
+ * sentinel values via parsePaginationValue() and re-render the same select
7
+ * with the new page — reusing the same customId, no new interaction handlers.
8
+ */
9
+
10
+ const NAV_PREFIX = '__page_nav:'
11
+
12
+ /** 23 real items per page, leaving room for up to 2 nav sentinels (prev + next). */
13
+ const PAGE_SIZE = 23
14
+
15
+ export type SelectOption = {
16
+ label: string
17
+ value: string
18
+ description?: string
19
+ }
20
+
21
+ /**
22
+ * Build the options array for a single page, with prev/next nav sentinels.
23
+ * If allOptions fits in 25 items, returns them all with no nav items.
24
+ */
25
+ export function buildPaginatedOptions({
26
+ allOptions,
27
+ page,
28
+ }: {
29
+ allOptions: SelectOption[]
30
+ page: number
31
+ }): { options: SelectOption[]; totalPages: number } {
32
+ // No pagination needed — everything fits in one Discord select
33
+ if (allOptions.length <= 25) {
34
+ return { options: allOptions, totalPages: 1 }
35
+ }
36
+
37
+ const totalPages = Math.ceil(allOptions.length / PAGE_SIZE)
38
+ const safePage = Math.max(0, Math.min(page, totalPages - 1))
39
+ const start = safePage * PAGE_SIZE
40
+ const slice = allOptions.slice(start, start + PAGE_SIZE)
41
+
42
+ const result: SelectOption[] = []
43
+
44
+ if (safePage > 0) {
45
+ result.push({
46
+ label: `← Previous page (${safePage}/${totalPages})`,
47
+ value: `${NAV_PREFIX}${safePage - 1}`,
48
+ description: 'Go to previous page',
49
+ })
50
+ }
51
+
52
+ result.push(...slice)
53
+
54
+ if (safePage < totalPages - 1) {
55
+ result.push({
56
+ label: `Next page → (${safePage + 2}/${totalPages})`,
57
+ value: `${NAV_PREFIX}${safePage + 1}`,
58
+ description: 'Go to next page',
59
+ })
60
+ }
61
+
62
+ return { options: result, totalPages }
63
+ }
64
+
65
+ /**
66
+ * Check if a selected value is a pagination nav sentinel.
67
+ * Returns the target page number if so, undefined otherwise.
68
+ */
69
+ export function parsePaginationValue(
70
+ value: string,
71
+ ): number | undefined {
72
+ if (!value.startsWith(NAV_PREFIX)) {
73
+ return undefined
74
+ }
75
+ const pageStr = value.slice(NAV_PREFIX.length)
76
+ const page = Number(pageStr)
77
+ if (Number.isNaN(page)) {
78
+ return undefined
79
+ }
80
+ return page
81
+ }
@@ -15,7 +15,11 @@ import {
15
15
  getAllThreadSessionIds,
16
16
  } from '../database.js'
17
17
  import { initializeOpencodeForDirectory } from '../opencode.js'
18
- import { sendThreadMessage, resolveProjectDirectoryFromAutocomplete } from '../discord-utils.js'
18
+ import {
19
+ sendThreadMessage,
20
+ resolveProjectDirectoryFromAutocomplete,
21
+ NOTIFY_MESSAGE_FLAGS,
22
+ } from '../discord-utils.js'
19
23
  import { collectLastAssistantParts } from '../message-formatting.js'
20
24
  import { createLogger, LogPrefix } from '../logger.js'
21
25
  import * as errore from 'errore'
@@ -153,6 +157,7 @@ export async function handleResumeCommand({
153
157
  await sendThreadMessage(
154
158
  thread,
155
159
  `Failed to load message history, but session is connected. You can still send new messages.`,
160
+ { flags: NOTIFY_MESSAGE_FLAGS },
156
161
  )
157
162
  }
158
163
  } catch (error) {
@@ -0,0 +1,293 @@
1
+ // /tasks command — list all scheduled tasks sorted by next run time.
2
+ // Renders a markdown table that the CV2 pipeline auto-formats for Discord,
3
+ // including HTML-backed action buttons for cancellable tasks.
4
+
5
+ import {
6
+ ButtonInteraction,
7
+ ChatInputCommandInteraction,
8
+ ComponentType,
9
+ MessageFlags,
10
+ type APIMessageTopLevelComponent,
11
+ type APITextDisplayComponent,
12
+ type InteractionEditReplyOptions,
13
+ } from 'discord.js'
14
+ import {
15
+ cancelScheduledTask,
16
+ listScheduledTasks,
17
+ type ScheduledTask,
18
+ type ScheduledTaskStatus,
19
+ } from '../database.js'
20
+ import { splitTablesFromMarkdown } from '../format-tables.js'
21
+ import {
22
+ buildHtmlActionCustomId,
23
+ cancelHtmlActionsForOwner,
24
+ registerHtmlAction,
25
+ } from '../html-actions.js'
26
+ import { formatTimeAgo } from './worktrees.js'
27
+
28
+ function formatTimeUntil(date: Date): string {
29
+ const diffMs = date.getTime() - Date.now()
30
+ if (diffMs <= 0) {
31
+ return 'due now'
32
+ }
33
+ const totalSeconds = Math.floor(diffMs / 1000)
34
+ if (totalSeconds < 60) {
35
+ return `in ${totalSeconds}s`
36
+ }
37
+ const totalMinutes = Math.floor(totalSeconds / 60)
38
+ if (totalMinutes < 60) {
39
+ return `in ${totalMinutes}m`
40
+ }
41
+ const hours = Math.floor(totalMinutes / 60)
42
+ const minutes = totalMinutes % 60
43
+ if (hours < 24) {
44
+ return minutes > 0 ? `in ${hours}h ${minutes}m` : `in ${hours}h`
45
+ }
46
+ const days = Math.floor(hours / 24)
47
+ const remainingHours = hours % 24
48
+ return remainingHours > 0 ? `in ${days}d ${remainingHours}h` : `in ${days}d`
49
+ }
50
+
51
+ function scheduleLabel(task: ScheduledTask): string {
52
+ if (task.schedule_kind === 'cron') {
53
+ return task.cron_expr || 'cron'
54
+ }
55
+ return 'one-time'
56
+ }
57
+
58
+ function canCancelTask(task: ScheduledTask): boolean {
59
+ return task.status === 'planned' || task.status === 'running'
60
+ }
61
+
62
+ // Escape pipe chars and collapse whitespace so free-text fields don't break
63
+ // GFM table column alignment.
64
+ function sanitizeTableCell(value: string): string {
65
+ return value.replaceAll('|', '\\|').replace(/\s+/g, ' ').trim()
66
+ }
67
+
68
+ function buildCancelButtonHtml({ buttonId }: { buttonId: string }): string {
69
+ return `<button id="${buttonId}" variant="secondary">Delete</button>`
70
+ }
71
+
72
+ function buildActionCell(task: ScheduledTask): string {
73
+ if (!canCancelTask(task)) {
74
+ return '-'
75
+ }
76
+ return buildCancelButtonHtml({ buttonId: `cancel-task-${task.id}` })
77
+ }
78
+
79
+ // Cap rows to avoid exceeding Discord's 40-component CV2 limit.
80
+ // Each cancellable row renders as text + action row + button (~4 components),
81
+ // so 10 rows is a safe ceiling.
82
+ const MAX_TASK_ROWS = 10
83
+
84
+ function buildTaskTable({
85
+ tasks,
86
+ }: {
87
+ tasks: ScheduledTask[]
88
+ }): string {
89
+ const header = '| ID | Status | Prompt | Schedule | Next Run | Action |'
90
+ const separator = '|---|---|---|---|---|---|'
91
+ const rows = tasks.map((task) => {
92
+ const id = String(task.id)
93
+ const status = task.status
94
+ const prompt = sanitizeTableCell(
95
+ task.prompt_preview.length > 240
96
+ ? task.prompt_preview.slice(0, 237) + '...'
97
+ : task.prompt_preview,
98
+ )
99
+ const schedule = sanitizeTableCell(scheduleLabel(task))
100
+ const nextRun = (() => {
101
+ if (
102
+ task.status === 'completed' ||
103
+ task.status === 'cancelled' ||
104
+ task.status === 'failed'
105
+ ) {
106
+ return task.last_run_at ? formatTimeAgo(task.last_run_at) : '-'
107
+ }
108
+ return formatTimeUntil(task.next_run_at)
109
+ })()
110
+ const action = buildActionCell(task)
111
+ return `| ${id} | ${status} | ${prompt} | ${schedule} | ${nextRun} | ${action} |`
112
+ })
113
+ return [header, separator, ...rows].join('\n')
114
+ }
115
+
116
+ function getTasksActionOwnerKey({
117
+ userId,
118
+ channelId,
119
+ }: {
120
+ userId: string
121
+ channelId: string
122
+ }): string {
123
+ return `tasks:${userId}:${channelId}`
124
+ }
125
+
126
+ type TasksReplyTarget = {
127
+ guildId: string
128
+ userId: string
129
+ channelId: string
130
+ showAll: boolean
131
+ notice?: string
132
+ editReply: (
133
+ options: string | InteractionEditReplyOptions,
134
+ ) => Promise<unknown>
135
+ }
136
+
137
+ async function renderTasksReply({
138
+ guildId,
139
+ userId,
140
+ channelId,
141
+ showAll,
142
+ notice,
143
+ editReply,
144
+ }: TasksReplyTarget): Promise<void> {
145
+ const ownerKey = getTasksActionOwnerKey({ userId, channelId })
146
+ cancelHtmlActionsForOwner(ownerKey)
147
+
148
+ const statuses: ScheduledTaskStatus[] | undefined = showAll
149
+ ? undefined
150
+ : ['planned', 'running']
151
+ const allTasks = await listScheduledTasks({ statuses })
152
+ if (allTasks.length === 0) {
153
+ const message = notice
154
+ ? `${notice}\n\nNo scheduled tasks found.`
155
+ : 'No scheduled tasks found.'
156
+ const textDisplay: APITextDisplayComponent = {
157
+ type: ComponentType.TextDisplay,
158
+ content: message,
159
+ }
160
+ await editReply({
161
+ components: [textDisplay],
162
+ flags: MessageFlags.IsComponentsV2,
163
+ })
164
+ return
165
+ }
166
+
167
+ const tasks = allTasks.slice(0, MAX_TASK_ROWS)
168
+ const truncatedNotice =
169
+ allTasks.length > MAX_TASK_ROWS
170
+ ? `Showing ${MAX_TASK_ROWS}/${allTasks.length} tasks. Use \`kimaki task list\` for full list.`
171
+ : undefined
172
+ const combinedNotice = [notice, truncatedNotice].filter(Boolean).join('\n')
173
+
174
+ const cancellableTasksByButtonId = new Map<string, ScheduledTask>()
175
+ tasks.forEach((task) => {
176
+ if (!canCancelTask(task)) {
177
+ return
178
+ }
179
+ cancellableTasksByButtonId.set(`cancel-task-${task.id}`, task)
180
+ })
181
+
182
+ const tableMarkdown = buildTaskTable({ tasks })
183
+ const markdown = combinedNotice
184
+ ? `${combinedNotice}\n\n${tableMarkdown}`
185
+ : tableMarkdown
186
+ const segments = splitTablesFromMarkdown(markdown, {
187
+ resolveButtonCustomId: ({ button }) => {
188
+ const task = cancellableTasksByButtonId.get(button.id)
189
+ if (!task) {
190
+ return new Error(`No task registered for button ${button.id}`)
191
+ }
192
+
193
+ const actionId = registerHtmlAction({
194
+ ownerKey,
195
+ threadId: String(task.id),
196
+ run: async ({ interaction }) => {
197
+ await handleCancelTaskAction({
198
+ interaction,
199
+ taskId: task.id,
200
+ showAll,
201
+ })
202
+ },
203
+ })
204
+ return buildHtmlActionCustomId(actionId)
205
+ },
206
+ })
207
+
208
+ const components: APIMessageTopLevelComponent[] = segments.flatMap(
209
+ (segment) => {
210
+ if (segment.type === 'components') {
211
+ return segment.components
212
+ }
213
+ const textDisplay: APITextDisplayComponent = {
214
+ type: ComponentType.TextDisplay,
215
+ content: segment.text,
216
+ }
217
+ return [textDisplay]
218
+ },
219
+ )
220
+
221
+ await editReply({
222
+ components,
223
+ flags: MessageFlags.IsComponentsV2,
224
+ })
225
+ }
226
+
227
+ async function handleCancelTaskAction({
228
+ interaction,
229
+ taskId,
230
+ showAll,
231
+ }: {
232
+ interaction: ButtonInteraction
233
+ taskId: number
234
+ showAll: boolean
235
+ }): Promise<void> {
236
+ const guildId = interaction.guildId
237
+ if (!guildId) {
238
+ await interaction.editReply({
239
+ components: [
240
+ {
241
+ type: ComponentType.TextDisplay,
242
+ content: 'This action can only be used in a server.',
243
+ },
244
+ ],
245
+ flags: MessageFlags.IsComponentsV2,
246
+ })
247
+ return
248
+ }
249
+
250
+ const cancelled = await cancelScheduledTask(taskId)
251
+ const notice = cancelled
252
+ ? `Cancelled task #${taskId}.`
253
+ : `Task #${taskId} not found or already finalized.`
254
+
255
+ await renderTasksReply({
256
+ guildId,
257
+ userId: interaction.user.id,
258
+ channelId: interaction.channelId,
259
+ showAll,
260
+ notice,
261
+ editReply: (options) => {
262
+ return interaction.editReply(options)
263
+ },
264
+ })
265
+ }
266
+
267
+ export async function handleTasksCommand({
268
+ command,
269
+ }: {
270
+ command: ChatInputCommandInteraction
271
+ appId: string
272
+ }): Promise<void> {
273
+ const guildId = command.guildId
274
+ if (!guildId) {
275
+ await command.reply({
276
+ content: 'This command can only be used in a server.',
277
+ flags: MessageFlags.Ephemeral,
278
+ })
279
+ return
280
+ }
281
+
282
+ const showAll = command.options.getBoolean('all') ?? false
283
+ await command.deferReply({ flags: MessageFlags.Ephemeral })
284
+ await renderTasksReply({
285
+ guildId,
286
+ userId: command.user.id,
287
+ channelId: command.channelId,
288
+ showAll,
289
+ editReply: (options) => {
290
+ return command.editReply(options)
291
+ },
292
+ })
293
+ }