diff --git a/apps/showcase-bot/config.yaml b/apps/showcase-bot/config.yaml index 221ec87..454030c 100644 --- a/apps/showcase-bot/config.yaml +++ b/apps/showcase-bot/config.yaml @@ -11,7 +11,11 @@ slack: - command: /showcase description: Block Kit showcase commands usage_hint: '[generate|clear|modal|help]' - shortcuts: [] + shortcuts: + - callback_id: showcase_message_context + name: Showcase message context + description: Show message details in a modal + type: message actions: {} modals: showcase_modal: showcase_modal diff --git a/apps/showcase-bot/src/listeners/index.ts b/apps/showcase-bot/src/listeners/index.ts index 03b189c..4b31564 100644 --- a/apps/showcase-bot/src/listeners/index.ts +++ b/apps/showcase-bot/src/listeners/index.ts @@ -1,8 +1,10 @@ import type { App } from '@slack/bolt' import * as commands from './commands/showcase' import * as actions from './actions/showcase-actions' +import * as shortcuts from './shortcuts/showcase-shortcuts' export function registerListeners(app: App) { commands.register(app) actions.register(app) + shortcuts.register(app) } diff --git a/apps/showcase-bot/src/listeners/shortcuts/showcase-shortcuts.ts b/apps/showcase-bot/src/listeners/shortcuts/showcase-shortcuts.ts new file mode 100644 index 0000000..bb6a600 --- /dev/null +++ b/apps/showcase-bot/src/listeners/shortcuts/showcase-shortcuts.ts @@ -0,0 +1,89 @@ +import type { App, MessageShortcut } from '@slack/bolt' +import type { KnownBlock } from '@slack/types' +import { slackLogger } from '../../utils/logger' + +export function register(app: App) { + app.shortcut( + { callback_id: 'showcase_message_context', type: 'message_action' }, + async ({ shortcut, ack, client }) => { + await ack() + + const { message, channel, user } = shortcut + + // Truncate text to stay within Slack's 3000-char mrkdwn limit + const maxTextLen = 3000 - '```\n\n```'.length - '*Text*\n'.length + const rawText = message.text || '' + const truncatedText = + rawText.length > maxTextLen + ? rawText.slice(0, maxTextLen - 3) + '...' + : rawText + + const fields = [ + ['Timestamp', message.ts], + ['Channel', channel.id], + ['User', user.id], + ['Text', truncatedText ? `\`\`\`${truncatedText}\`\`\`` : '_empty_'], + ] + + const blocks: KnownBlock[] = fields.map(([label, value]) => ({ + type: 'section' as const, + text: { + type: 'mrkdwn' as const, + text: `*${label}*\n${value}`, + }, + })) + + // Show attached files + const files = message.files as + | Array<{ url_private?: string; mimetype?: string }> + | undefined + if (files?.length) { + for (const file of files) { + if (file.url_private && file.mimetype?.startsWith('image/')) { + blocks.push({ + type: 'image', + image_url: file.url_private, + alt_text: 'Attached image', + }) + } + } + } + + // Show images from Block Kit blocks + const msgBlocks = message.blocks as + | Array<{ + type: string + image_url?: string + alt_text?: string + title?: { text?: string } + }> + | undefined + if (msgBlocks?.length) { + for (const block of msgBlocks) { + if (block.type === 'image' && block.image_url) { + blocks.push({ + type: 'image', + image_url: block.image_url, + alt_text: block.alt_text || block.title?.text || 'Image', + }) + } + } + } + + try { + await client.views.open({ + trigger_id: shortcut.trigger_id, + view: { + type: 'modal', + title: { type: 'plain_text', text: 'Message Context' }, + close: { type: 'plain_text', text: 'Close' }, + blocks, + }, + }) + slackLogger.info('Opened message context modal') + } catch (err) { + slackLogger.error({ err }, 'Failed to open message context modal') + } + } + ) +} diff --git a/apps/showcase-bot/src/messages/blocks/13-template-approval.json b/apps/showcase-bot/src/messages/blocks/13-template-approval.json new file mode 100644 index 0000000..556771d --- /dev/null +++ b/apps/showcase-bot/src/messages/blocks/13-template-approval.json @@ -0,0 +1,109 @@ +{ + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "You have a new request:\n**" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Type:*\nPaid time off\n*When:*\nAug 10-Aug 13\n*Hours:* 16.0 (2 days)\n*Remaining balance:* 32.0 hours (4 days)\n*Comments:* \"Family in town, going camping!\"" + }, + "accessory": { + "type": "image", + "image_url": "https://api.slack.com/img/blocks/bkb_template_images/approvalsNewDevice.png", + "alt_text": "computer thumbnail" + } + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "emoji": true, + "text": "Approve" + }, + "style": "primary", + "action_id": "showcase_approval_approve_pto", + "value": "click_me_123" + }, + { + "type": "button", + "text": { + "type": "plain_text", + "emoji": true, + "text": "Deny" + }, + "style": "danger", + "action_id": "showcase_approval_deny_pto", + "value": "click_me_123" + } + ] + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "You have a new request:\n**" + } + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Type:*\nComputer (laptop)" + }, + { + "type": "mrkdwn", + "text": "*When:*\nSubmitted Aug 10" + }, + { + "type": "mrkdwn", + "text": "*Last Update:*\nMar 10, 2015 (3 years, 5 months)" + }, + { + "type": "mrkdwn", + "text": "*Reason:*\nAll vowel keys aren't working." + }, + { + "type": "mrkdwn", + "text": "*Specs:*\n\"Cheetah Pro 15\" - Fast, really fast" + } + ] + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "emoji": true, + "text": "Approve" + }, + "style": "primary", + "action_id": "showcase_approval_approve_device", + "value": "click_me_123" + }, + { + "type": "button", + "text": { + "type": "plain_text", + "emoji": true, + "text": "Deny" + }, + "style": "danger", + "action_id": "showcase_approval_deny_device", + "value": "click_me_123" + } + ] + } + ] +} diff --git a/apps/showcase-bot/src/messages/blocks/14-template-notification.json b/apps/showcase-bot/src/messages/blocks/14-template-notification.json new file mode 100644 index 0000000..10053a4 --- /dev/null +++ b/apps/showcase-bot/src/messages/blocks/14-template-notification.json @@ -0,0 +1,109 @@ +{ + "blocks": [ + { + "type": "section", + "text": { + "type": "plain_text", + "emoji": true, + "text": "Looks like you have a scheduling conflict with this event:" + } + }, + { + "type": "divider" + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "**\nTuesday, January 21 4:00-4:30pm\nBuilding 2 - Havarti Cheese (3)\n2 guests" + }, + "accessory": { + "type": "image", + "image_url": "https://api.slack.com/img/blocks/bkb_template_images/notifications.png", + "alt_text": "calendar thumbnail" + } + }, + { + "type": "context", + "elements": [ + { + "type": "image", + "image_url": "https://api.slack.com/img/blocks/bkb_template_images/notificationsWarningIcon.png", + "alt_text": "notifications warning icon" + }, + { + "type": "mrkdwn", + "text": "*Conflicts with Team Huddle: 4:15-4:30pm*" + } + ] + }, + { + "type": "divider" + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Propose a new time:*" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Today - 4:30-5pm*\nEveryone is available: @iris, @zelda" + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "emoji": true, + "text": "Choose" + }, + "action_id": "showcase_notification_choose_today", + "value": "click_me_123" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Tomorrow - 4-4:30pm*\nEveryone is available: @iris, @zelda" + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "emoji": true, + "text": "Choose" + }, + "action_id": "showcase_notification_choose_tomorrow_4", + "value": "click_me_123" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Tomorrow - 6-6:30pm*\nSome people aren't available: @iris, ~@zelda~" + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "emoji": true, + "text": "Choose" + }, + "action_id": "showcase_notification_choose_tomorrow_6", + "value": "click_me_123" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "**" + } + } + ] +} diff --git a/apps/showcase-bot/src/messages/blocks/15-template-vote.json b/apps/showcase-bot/src/messages/blocks/15-template-vote.json new file mode 100644 index 0000000..13898fc --- /dev/null +++ b/apps/showcase-bot/src/messages/blocks/15-template-vote.json @@ -0,0 +1,137 @@ +{ + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Where should we order lunch from?* Poll by " + } + }, + { + "type": "divider" + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":sushi: *Ace Wasabi Rock-n-Roll Sushi Bar*\nThe best landlocked sushi restaurant." + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "emoji": true, + "text": "Vote" + }, + "action_id": "showcase_vote_sushi", + "value": "click_me_123" + } + }, + { + "type": "context", + "elements": [ + { + "type": "image", + "image_url": "https://api.slack.com/img/blocks/bkb_template_images/profile_1.png", + "alt_text": "Michael Scott" + }, + { + "type": "image", + "image_url": "https://api.slack.com/img/blocks/bkb_template_images/profile_2.png", + "alt_text": "Dwight Schrute" + }, + { + "type": "image", + "image_url": "https://api.slack.com/img/blocks/bkb_template_images/profile_3.png", + "alt_text": "Pam Beesly" + }, + { + "type": "plain_text", + "emoji": true, + "text": "3 votes" + } + ] + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":hamburger: *Super Hungryman Hamburgers*\nOnly for the hungriest of the hungry." + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "emoji": true, + "text": "Vote" + }, + "action_id": "showcase_vote_hamburger", + "value": "click_me_123" + } + }, + { + "type": "context", + "elements": [ + { + "type": "image", + "image_url": "https://api.slack.com/img/blocks/bkb_template_images/profile_4.png", + "alt_text": "Angela" + }, + { + "type": "image", + "image_url": "https://api.slack.com/img/blocks/bkb_template_images/profile_2.png", + "alt_text": "Dwight Schrute" + }, + { + "type": "plain_text", + "emoji": true, + "text": "2 votes" + } + ] + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":ramen: *Kagawa-Ya Udon Noodle Shop*\nDo you like to shop for noodles? We have noodles." + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "emoji": true, + "text": "Vote" + }, + "action_id": "showcase_vote_ramen", + "value": "click_me_123" + } + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": "No votes" + } + ] + }, + { + "type": "divider" + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "emoji": true, + "text": "Add a suggestion" + }, + "action_id": "showcase_vote_add_suggestion", + "value": "click_me_123" + } + ] + } + ] +} diff --git a/apps/ui/src/components/BotAboutHeader.svelte b/apps/ui/src/components/BotAboutHeader.svelte index 3e840b0..f153f07 100644 --- a/apps/ui/src/components/BotAboutHeader.svelte +++ b/apps/ui/src/components/BotAboutHeader.svelte @@ -10,12 +10,20 @@
-
- - - +
+ {#if bot.iconUrl} + {bot.name} + {:else} + + + + {/if}
diff --git a/apps/ui/src/components/ImagePreviewModal.svelte b/apps/ui/src/components/ImagePreviewModal.svelte index b0b5819..538a611 100644 --- a/apps/ui/src/components/ImagePreviewModal.svelte +++ b/apps/ui/src/components/ImagePreviewModal.svelte @@ -199,7 +199,7 @@
void onDelete?: (ts: string) => void - onGenerateImage?: (message: SimulatorMessage) => void onImagePreview?: ( imageUrl: string, imageAlt: string, @@ -58,28 +62,24 @@ message, replyCount = 0, hasDraft = false, + isGrouped = false, onOpenThread, onDelete, - onGenerateImage, onImagePreview, }: Props = $props() - let menuOpen = $state(false) - let menuButton = $state(null) - let imageExpanded = $derived(message.file?.isExpanded ?? true) - let isBot = $derived(isBotUserId(message.user)) let isEphemeral = $derived(message.subtype === 'ephemeral') - let hasImage = $derived(message.file?.mimetype?.startsWith('image/') ?? false) - let messageShortcut = $derived(getMessageShortcut()) + let shortcutGroups = $derived(getAllMessageShortcuts()) + let hasShortcuts = $derived(shortcutGroups.length > 0) + let botInfo = $derived(isBot ? getBotByUserId(message.user) : undefined) let displayName = $derived.by(() => { if (!isBot) return simulatorState.simulatedUserName || 'You' - // Get bot name from connected bots - const bot = getBotByUserId(message.user) - return bot?.name ?? simulatorState.botName + return botInfo?.name ?? simulatorState.botName }) let avatarLetter = $derived(displayName.charAt(0).toUpperCase()) let timestamp = $derived(formatTimestamp(message.ts)) + let timestampShort = $derived(formatTimestampShort(message.ts)) let fullDate = $derived(formatFullDate(message.ts)) let formattedText = $derived.by(() => { // Replace internal user IDs BEFORE markdown processing (handles <@userId> format) @@ -316,24 +316,16 @@ ) } - function toggleMenu(e: MouseEvent) { - e.stopPropagation() - menuOpen = !menuOpen - } - function handleDelete() { - menuOpen = false onDelete?.(message.ts) } - function handleGenerateImage() { - onGenerateImage?.(message) - } - - function handleClickOutside(e: MouseEvent) { - if (menuButton && !menuButton.contains(e.target as Node)) { - menuOpen = false - } + function handleShortcut(shortcut: Shortcut) { + triggerMessageShortcut(shortcut.callback_id, { + ts: message.ts, + text: message.text, + file: message.file, + }) } function getEmoji(name: string): string { @@ -341,12 +333,30 @@ } - +{#snippet shortcutItemContent(group: BotShortcutGroup, shortcut: Shortcut)} + + {#if group.botIcon?.startsWith('http')} + + {:else} + {group.botIcon || group.botName.charAt(0).toUpperCase()} + {/if} + {shortcut.name} + {group.botName} + +{/snippet}
{#if isEphemeral}
{/if}
- {#if onDelete} + {#if onDelete || hasShortcuts}
- - {#if menuOpen} -
+ - -
+ + + + {#if hasShortcuts} + + Connect to apps + + {#each shortcutGroups as group, i (group.botId)} + {#if i > 0} + + {/if} + {#each group.shortcuts as shortcut (shortcut.callback_id)} + handleShortcut(shortcut)} + > + {@render shortcutItemContent(group, shortcut)} + + {/each} + {/each} + + + {#if onDelete} + + {/if} + {/if} + {#if onDelete} + + + Delete message + + {/if} + + +
+ {/if} + {#if isGrouped} +
+ {timestampShort} +
+ {:else} +
+ {#if isBot && botInfo?.iconUrl} + {displayName} + {:else if isBot} + + {:else} + {avatarLetter} {/if}
{/if} -
- {#if isBot} - - {:else} - {avatarLetter} - {/if} -
-
- {displayName} - {#if isBot} - APP + {displayName} + {#if isBot} + APP + {/if} + {timestamp} - {/if} - {timestamp} -
+
+ {/if} {#if hasBlocks}
{#if message.file.mimetype.startsWith('image/')} - {#if message.file.title} -
- {message.file.title} - -
- {/if} - {#if imageExpanded} - - {/if} + /> +
{:else}
- {#if onDelete || (hasImage && onGenerateImage && messageShortcut)} + {#if onDelete || hasShortcuts} - {#if hasImage && onGenerateImage && messageShortcut} - - - {messageShortcut.name} - + {#if hasShortcuts} + + Connect to apps + + {#each shortcutGroups as group, i (group.botId)} + {#if i > 0} + + {/if} + {#each group.shortcuts as shortcut (shortcut.callback_id)} + handleShortcut(shortcut)}> + {@render shortcutItemContent(group, shortcut)} + + {/each} + {/each} + + + {#if onDelete} + + {/if} {/if} {#if onDelete} diff --git a/apps/ui/src/components/MessagePanel.svelte b/apps/ui/src/components/MessagePanel.svelte index 99a6a9d..cf0242b 100644 --- a/apps/ui/src/components/MessagePanel.svelte +++ b/apps/ui/src/components/MessagePanel.svelte @@ -47,23 +47,17 @@ Trash2, } from '@lucide/svelte' import { tick } from 'svelte' - import { - clearChannelMessages, - deleteMessage, - triggerMessageShortcut, - } from '../lib/dispatcher.svelte' - import type { SimulatorMessage } from '../lib/types' + import { clearChannelMessages, deleteMessage } from '$lib/dispatcher.svelte' import { getChannelDisplayName, getChannelMessages, - getMessageShortcut, getParentMessages, getReplyCount, hasThreadDraft, simulatorState, - } from '../lib/state.svelte' - import type { Channel } from '../lib/types' - import { formatDateLabel, getDateKey } from '../lib/time' + } from '$lib/state.svelte' + import type { Channel } from '$lib/types' + import { formatDateLabel, getDateKey, isWithinMinutes } from '$lib/time' import BotAboutHeader from './BotAboutHeader.svelte' import DaySeparator from './DaySeparator.svelte' import Message from './Message.svelte' @@ -97,16 +91,6 @@ deleteMessage(simulatorState.currentChannel, ts) } - function handleGenerateImage(message: SimulatorMessage) { - const shortcut = getMessageShortcut() - if (!shortcut) return - triggerMessageShortcut(shortcut.callback_id, { - ts: message.ts, - text: message.text, - file: message.file, - }) - } - function toggleMenu(e: MouseEvent) { e.stopPropagation() menuOpen = !menuOpen @@ -280,16 +264,22 @@
{:else if messages.length > 0} {#each messages as message, i (message.ts)} - {#if i === 0 || getDateKey(message.ts) !== getDateKey(messages[i - 1]!.ts)} + {@const prevMessage = messages[i - 1]} + {@const isDaySeparator = + i === 0 || getDateKey(message.ts) !== getDateKey(prevMessage!.ts)} + {#if isDaySeparator} {/if} {/each} diff --git a/apps/ui/src/components/ModalOverlay.svelte b/apps/ui/src/components/ModalOverlay.svelte index 65f4f2c..0f9f992 100644 --- a/apps/ui/src/components/ModalOverlay.svelte +++ b/apps/ui/src/components/ModalOverlay.svelte @@ -36,6 +36,11 @@ {} ) + // Validation errors per block ID + let validationErrors = $state>({}) + // Track whether the user has attempted to submit (enables live revalidation) + let hasSubmitted = $state(false) + /** * Extract initial values from modal blocks to pre-populate formValues */ @@ -140,15 +145,23 @@ $effect(() => { if (simulatorState.activeModal) { formValues = extractInitialValues(simulatorState.activeModal.view.blocks) - fileFormValues = {} // Reset file values when modal changes + fileFormValues = {} + validationErrors = {} + hasSubmitted = false } }) + function revalidate() { + if (!hasSubmitted || !simulatorState.activeModal) return + validationErrors = validateRequiredFields() + } + function handleInputChange(blockId: string, actionId: string, value: string) { if (!formValues[blockId]) { formValues[blockId] = {} } formValues[blockId][actionId] = { value } + revalidate() } function handleFileChange( @@ -160,6 +173,7 @@ fileFormValues[blockId] = {} } fileFormValues[blockId][actionId] = files + revalidate() } function handleCheckboxChange( @@ -171,6 +185,7 @@ formValues[blockId] = {} } formValues[blockId][actionId] = { selected_options: selectedOptions } + revalidate() } function handleRadioChange( @@ -185,6 +200,7 @@ selected_option: option, value: option.value, } + revalidate() } async function handleAction(actionId: string, value: string) { @@ -192,9 +208,45 @@ await sendBlockAction(simulatorState.activeModal.viewId, actionId, value) } + function validateRequiredFields(): Record { + if (!simulatorState.activeModal) return {} + const blocks = simulatorState.activeModal.view.blocks + const errors: Record = {} + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i] + if (block?.type === 'input' && !(block as SlackInputBlock).optional) { + const inputBlock = block as SlackInputBlock + const blockId = inputBlock.block_id ?? `block-${i}` + const actionId = inputBlock.element.action_id + const val = formValues[blockId]?.[actionId] + const files = fileFormValues[blockId]?.[actionId] + + const isEmpty = + inputBlock.element.type === 'file_input' + ? !files || files.length === 0 + : !val?.value && + !val?.selected_option && + (!val?.selected_options || val.selected_options.length === 0) + + if (isEmpty) { + errors[blockId] = 'Please complete this required field.' + } + } + } + return errors + } + async function handleSubmit() { if (!simulatorState.activeModal) return + hasSubmitted = true + const errors = validateRequiredFields() + + if (Object.keys(errors).length > 0) { + validationErrors = errors + return + } + // Merge form values with file values // File inputs are submitted as { files: UploadedFile[] } const mergedValues: Record> = { @@ -268,7 +320,7 @@ tabindex="-1" >
diff --git a/apps/ui/src/components/Sidebar.svelte b/apps/ui/src/components/Sidebar.svelte index a6feb07..1003cff 100644 --- a/apps/ui/src/components/Sidebar.svelte +++ b/apps/ui/src/components/Sidebar.svelte @@ -205,11 +205,19 @@ } }} > - - - + {#if bot.iconUrl} + {bot.name} + {:else} + + + + {/if} diff --git a/apps/ui/src/components/ThreadPanel.svelte b/apps/ui/src/components/ThreadPanel.svelte index 9d068a2..f9ce12f 100644 --- a/apps/ui/src/components/ThreadPanel.svelte +++ b/apps/ui/src/components/ThreadPanel.svelte @@ -1,20 +1,15 @@
@@ -94,7 +79,6 @@
@@ -107,11 +91,14 @@
{/if} - {#each replies as message (message.ts)} + {#each replies as message, i (message.ts)} + {@const prevMessage = i === 0 ? parentMessage : replies[i - 1]} {/each} diff --git a/apps/ui/src/components/blockkit/BlockKitRenderer.svelte b/apps/ui/src/components/blockkit/BlockKitRenderer.svelte index 77d6696..2dd65f0 100644 --- a/apps/ui/src/components/blockkit/BlockKitRenderer.svelte +++ b/apps/ui/src/components/blockkit/BlockKitRenderer.svelte @@ -49,6 +49,8 @@ option: SlackOption ) => void onImagePreview?: (imageUrl: string, imageAlt: string) => void + imageCollapsible?: boolean + errors?: Record } let { @@ -61,6 +63,8 @@ onCheckboxChange, onRadioChange, onImagePreview, + imageCollapsible, + errors = {}, }: Props = $props() function getBlockId(block: SlackBlock, index: number): string { @@ -79,6 +83,7 @@ blockId={getBlockId(block, index)} {values} {fileValues} + error={errors[getBlockId(block, index)]} {onInputChange} {onFileChange} {onCheckboxChange} @@ -91,7 +96,11 @@ {:else if block.type === 'context'} {:else if block.type === 'image'} - + {:else if block.type === 'header'} {:else if block.type === 'rich_text'} diff --git a/apps/ui/src/components/blockkit/blocks/ImageBlock.svelte b/apps/ui/src/components/blockkit/blocks/ImageBlock.svelte index 67ce498..cf38c76 100644 --- a/apps/ui/src/components/blockkit/blocks/ImageBlock.svelte +++ b/apps/ui/src/components/blockkit/blocks/ImageBlock.svelte @@ -6,9 +6,10 @@ interface Props { block: SlackImageBlock onImagePreview?: (imageUrl: string, imageAlt: string) => void + collapsible?: boolean } - let { block, onImagePreview }: Props = $props() + let { block, onImagePreview, collapsible = true }: Props = $props() let collapsed = $state(false) let imageSize = $state(null) @@ -19,6 +20,7 @@ } $effect(() => { + if (!collapsible) return const controller = new AbortController() imageSize = null const url = `${EMULATOR_API_URL}/api/simulator/image-size?url=${encodeURIComponent(block.image_url)}` @@ -39,24 +41,28 @@
- - {#if block.title}{renderText(block.title)}{/if} - {#if imageSize}({imageSize}){/if} - - - {#if !collapsed} + {#if block.title || (collapsible && imageSize)} + + {#if block.title}{renderText(block.title)}{/if} + {#if collapsible} + {#if imageSize}({imageSize}){/if} + + {/if} + + {/if} + {#if !collapsed || !collapsible} {#if onImagePreview}
+ + diff --git a/apps/ui/src/components/blockkit/blocks/SectionBlock.svelte b/apps/ui/src/components/blockkit/blocks/SectionBlock.svelte index fe85b33..b0452f9 100644 --- a/apps/ui/src/components/blockkit/blocks/SectionBlock.svelte +++ b/apps/ui/src/components/blockkit/blocks/SectionBlock.svelte @@ -9,7 +9,7 @@ SlackDatePickerElement, SlackTimePickerElement, SlackWorkspaceSelectElement, - } from '../../../lib/types' + } from '$lib/types' import { renderMrkdwn } from '../context' import Button from '../elements/Button.svelte' import StaticSelect from '../elements/StaticSelect.svelte' @@ -37,14 +37,14 @@ {#snippet textContent()} {#if block.text}
- {@html renderMrkdwn(block.text)} + {@html renderMrkdwn(block.text, { useBr: true })}
{/if} {#if block.fields}
{#each block.fields as field, i (i)} -
- {@html renderMrkdwn(field)} +
+ {@html renderMrkdwn(field, { useBr: true })}
{/each}
diff --git a/apps/ui/src/components/blockkit/context.ts b/apps/ui/src/components/blockkit/context.ts index 62e11d4..9585c65 100644 --- a/apps/ui/src/components/blockkit/context.ts +++ b/apps/ui/src/components/blockkit/context.ts @@ -45,7 +45,10 @@ export function renderText(textObj: SlackViewTextObject | undefined): string { * Returns sanitized HTML string that should be rendered with {@html}. * Respects the text object type: plain_text is escaped, mrkdwn is parsed. */ -export function renderMrkdwn(textObj: SlackViewTextObject | undefined): string { +export function renderMrkdwn( + textObj: SlackViewTextObject | undefined, + options?: { useBr?: boolean } +): string { if (!textObj) return '' // Plain text: escape HTML entities, no mrkdwn parsing @@ -57,7 +60,7 @@ export function renderMrkdwn(textObj: SlackViewTextObject | undefined): string { } // mrkdwn type: parse with @botarium/mrkdwn, sanitize output - const html = mrkdwnToHtml(textObj.text) + const html = mrkdwnToHtml(textObj.text, options) return DOMPurify.sanitize(html, SANITIZE_CONFIG) } diff --git a/apps/ui/src/lib/components/ui/context-menu/context-menu-sub-content.svelte b/apps/ui/src/lib/components/ui/context-menu/context-menu-sub-content.svelte index ade1438..59a7b6c 100644 --- a/apps/ui/src/lib/components/ui/context-menu/context-menu-sub-content.svelte +++ b/apps/ui/src/lib/components/ui/context-menu/context-menu-sub-content.svelte @@ -13,7 +13,7 @@ bind:ref data-slot="context-menu-sub-content" class={cn( - 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--bits-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg', + 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--bits-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg', className )} {...restProps} diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte new file mode 100644 index 0000000..3ece1fe --- /dev/null +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte @@ -0,0 +1,16 @@ + + + diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte new file mode 100644 index 0000000..09356c0 --- /dev/null +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte @@ -0,0 +1,43 @@ + + + + {#snippet children({ checked, indeterminate })} + + {#if indeterminate} + + {:else} + + {/if} + + {@render childrenProp?.()} + {/snippet} + diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte new file mode 100644 index 0000000..31eb51e --- /dev/null +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte @@ -0,0 +1,31 @@ + + + + + diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte new file mode 100644 index 0000000..850e171 --- /dev/null +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte @@ -0,0 +1,22 @@ + + + diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte new file mode 100644 index 0000000..f2d49ea --- /dev/null +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte @@ -0,0 +1,14 @@ + + + diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte new file mode 100644 index 0000000..f916d57 --- /dev/null +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte @@ -0,0 +1,27 @@ + + + diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte new file mode 100644 index 0000000..7c8e68c --- /dev/null +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte @@ -0,0 +1,24 @@ + + +
+ {@render children?.()} +
diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte new file mode 100644 index 0000000..692de5b --- /dev/null +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte new file mode 100644 index 0000000..0780bc2 --- /dev/null +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte @@ -0,0 +1,16 @@ + + + diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte new file mode 100644 index 0000000..98030d4 --- /dev/null +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte @@ -0,0 +1,33 @@ + + + + {#snippet children({ checked })} + + {#if checked} + + {/if} + + {@render childrenProp?.({ checked })} + {/snippet} + diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte new file mode 100644 index 0000000..c06bb76 --- /dev/null +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte @@ -0,0 +1,17 @@ + + + diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte new file mode 100644 index 0000000..e559c82 --- /dev/null +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte new file mode 100644 index 0000000..5edcdb5 --- /dev/null +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte @@ -0,0 +1,20 @@ + + + diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte new file mode 100644 index 0000000..253ef59 --- /dev/null +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte @@ -0,0 +1,29 @@ + + + + {@render children?.()} + + diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte new file mode 100644 index 0000000..62fc013 --- /dev/null +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte @@ -0,0 +1,10 @@ + + + diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte new file mode 100644 index 0000000..f8c9dcd --- /dev/null +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte @@ -0,0 +1,14 @@ + + + diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu.svelte new file mode 100644 index 0000000..f2e1d2d --- /dev/null +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu.svelte @@ -0,0 +1,10 @@ + + + diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/index.ts b/apps/ui/src/lib/components/ui/dropdown-menu/index.ts new file mode 100644 index 0000000..1df559b --- /dev/null +++ b/apps/ui/src/lib/components/ui/dropdown-menu/index.ts @@ -0,0 +1,54 @@ +import Root from './dropdown-menu.svelte' +import Sub from './dropdown-menu-sub.svelte' +import CheckboxGroup from './dropdown-menu-checkbox-group.svelte' +import CheckboxItem from './dropdown-menu-checkbox-item.svelte' +import Content from './dropdown-menu-content.svelte' +import Group from './dropdown-menu-group.svelte' +import Item from './dropdown-menu-item.svelte' +import Label from './dropdown-menu-label.svelte' +import RadioGroup from './dropdown-menu-radio-group.svelte' +import RadioItem from './dropdown-menu-radio-item.svelte' +import Separator from './dropdown-menu-separator.svelte' +import Shortcut from './dropdown-menu-shortcut.svelte' +import Trigger from './dropdown-menu-trigger.svelte' +import SubContent from './dropdown-menu-sub-content.svelte' +import SubTrigger from './dropdown-menu-sub-trigger.svelte' +import GroupHeading from './dropdown-menu-group-heading.svelte' +import Portal from './dropdown-menu-portal.svelte' + +export { + CheckboxGroup, + CheckboxItem, + Content, + Portal, + Root as DropdownMenu, + CheckboxGroup as DropdownMenuCheckboxGroup, + CheckboxItem as DropdownMenuCheckboxItem, + Content as DropdownMenuContent, + Portal as DropdownMenuPortal, + Group as DropdownMenuGroup, + Item as DropdownMenuItem, + Label as DropdownMenuLabel, + RadioGroup as DropdownMenuRadioGroup, + RadioItem as DropdownMenuRadioItem, + Separator as DropdownMenuSeparator, + Shortcut as DropdownMenuShortcut, + Sub as DropdownMenuSub, + SubContent as DropdownMenuSubContent, + SubTrigger as DropdownMenuSubTrigger, + Trigger as DropdownMenuTrigger, + GroupHeading as DropdownMenuGroupHeading, + Group, + GroupHeading, + Item, + Label, + RadioGroup, + RadioItem, + Root, + Separator, + Shortcut, + Sub, + SubContent, + SubTrigger, + Trigger, +} diff --git a/apps/ui/src/lib/dispatcher.svelte.ts b/apps/ui/src/lib/dispatcher.svelte.ts index d7cded0..e0ce796 100644 --- a/apps/ui/src/lib/dispatcher.svelte.ts +++ b/apps/ui/src/lib/dispatcher.svelte.ts @@ -291,9 +291,19 @@ function handleSSEEvent(event: { bot?: { id: string appConfig: { - app: { name: string; description?: string } + app: { + name: string + description?: string + icon_emoji?: string + icon_url?: string + } commands?: Array<{ command: string; description: string }> - shortcuts?: Array<{ callback_id: string; name: string }> + shortcuts?: Array<{ + callback_id: string + name: string + description: string + type: 'message' | 'global' + }> } connectedAt: string status: 'connecting' | 'connected' | 'disconnected' @@ -420,7 +430,9 @@ function handleSSEEvent(event: { connectedAt: event.bot.connectedAt, status: event.bot.status, commands: event.bot.appConfig.commands?.length ?? 0, - shortcuts: event.bot.appConfig.shortcuts?.length ?? 0, + shortcuts: event.bot.appConfig.shortcuts ?? [], + iconEmoji: event.bot.appConfig.app.icon_emoji, + iconUrl: event.bot.appConfig.app.icon_url, } addConnectedBot(botInfo) // Also update app config and commands when a bot connects diff --git a/apps/ui/src/lib/state.svelte.ts b/apps/ui/src/lib/state.svelte.ts index 315636b..7a8af4c 100644 --- a/apps/ui/src/lib/state.svelte.ts +++ b/apps/ui/src/lib/state.svelte.ts @@ -103,8 +103,7 @@ export { setAvailableCommands, getFilteredCommands, setAppConfig, - getShortcut, - getMessageShortcut, + getAllMessageShortcuts, showModal, updateModal, closeModal, @@ -114,3 +113,4 @@ export { isBotUserId, getBotByUserId, } from './state/bots.svelte' +export type { BotShortcutGroup } from './state/bots.svelte' diff --git a/apps/ui/src/lib/state/bots.svelte.ts b/apps/ui/src/lib/state/bots.svelte.ts index 23c2e80..7e5b8de 100644 --- a/apps/ui/src/lib/state/bots.svelte.ts +++ b/apps/ui/src/lib/state/bots.svelte.ts @@ -5,6 +5,7 @@ import type { SlashCommand, + Shortcut, SlackView, SlackAppConfig, ConnectedBotInfo, @@ -44,20 +45,30 @@ export function setAppConfig(config: SlackAppConfig | null): void { } } -// Get shortcut by callback_id -export function getShortcut( - callbackId: string -): SlackAppConfig['shortcuts'][number] | undefined { - return simulatorState.appConfig?.shortcuts.find( - (s) => s.callback_id === callbackId - ) +// Shortcut group for context menu display +export interface BotShortcutGroup { + botId: string + botName: string + botIcon?: string + shortcuts: Shortcut[] } -// Get first message shortcut (for context menu) -export function getMessageShortcut(): - | SlackAppConfig['shortcuts'][number] - | undefined { - return simulatorState.appConfig?.shortcuts.find((s) => s.type === 'message') +// Get all message shortcuts from connected bots, grouped by bot +export function getAllMessageShortcuts(): BotShortcutGroup[] { + const groups: BotShortcutGroup[] = [] + for (const bot of simulatorState.connectedBots.values()) { + if (bot.status !== 'connected') continue + const messageShortcuts = bot.shortcuts.filter((s) => s.type === 'message') + if (messageShortcuts.length > 0) { + groups.push({ + botId: bot.id, + botName: bot.name, + botIcon: bot.iconEmoji || bot.iconUrl, + shortcuts: messageShortcuts, + }) + } + } + return groups } // ============================================================================= diff --git a/apps/ui/src/lib/time.ts b/apps/ui/src/lib/time.ts index a32ec40..10a6b36 100644 --- a/apps/ui/src/lib/time.ts +++ b/apps/ui/src/lib/time.ts @@ -66,6 +66,31 @@ export function formatFullDate(ts: string): string { }) } +/** + * Format a Slack timestamp to a short time string without AM/PM (e.g., "12:34") + */ +export function formatTimestampShort(ts: string): string { + const seconds = parseFloat(ts) + const date = new Date(seconds * 1000) + return date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: false, + }) +} + +/** + * Check if two Slack timestamps are within a given number of minutes + */ +export function isWithinMinutes( + ts1: string, + ts2: string, + minutes: number +): boolean { + const diff = Math.abs(parseFloat(ts1) - parseFloat(ts2)) + return diff < minutes * 60 +} + /** * Format a Slack timestamp to a relative time string (e.g., "5 min ago") */ diff --git a/apps/ui/src/lib/types.ts b/apps/ui/src/lib/types.ts index 9be8191..88fbdfc 100644 --- a/apps/ui/src/lib/types.ts +++ b/apps/ui/src/lib/types.ts @@ -580,5 +580,7 @@ export interface ConnectedBotInfo { connectedAt: string status: 'connecting' | 'connected' | 'disconnected' commands: number - shortcuts: number + shortcuts: Shortcut[] + iconEmoji?: string + iconUrl?: string } diff --git a/packages/mrkdwn/src/mrkdwn-to-html.test.ts b/packages/mrkdwn/src/mrkdwn-to-html.test.ts index b77bd8e..4a10083 100644 --- a/packages/mrkdwn/src/mrkdwn-to-html.test.ts +++ b/packages/mrkdwn/src/mrkdwn-to-html.test.ts @@ -44,6 +44,24 @@ describe('mrkdwnToHtml', () => { ) }) + it('renders link without protocol (with label)', () => { + expect(mrkdwnToHtml('')).toBe( + 'Fred Enriquez' + ) + }) + + it('renders link without protocol (without label)', () => { + expect(mrkdwnToHtml('')).toBe( + 'example.com' + ) + }) + + it('renders bold link without protocol', () => { + expect(mrkdwnToHtml('**')).toBe( + 'Fred Enriquez' + ) + }) + it('renders mailto link', () => { expect(mrkdwnToHtml('')).toBe( 'Email' diff --git a/packages/mrkdwn/src/mrkdwn-to-html.ts b/packages/mrkdwn/src/mrkdwn-to-html.ts index 43d2bf0..41e9db2 100644 --- a/packages/mrkdwn/src/mrkdwn-to-html.ts +++ b/packages/mrkdwn/src/mrkdwn-to-html.ts @@ -24,7 +24,7 @@ function formatInline(text: string): string { // HTML-escape remaining text text = escapeHtml(text) - // Links: and + // Links: and (with protocol) text = text.replace( /<((?:https?|mailto):.*?)\|(.*?)>/g, '$2' @@ -34,6 +34,16 @@ function formatInline(text: string): string { '$1' ) + // Links without protocol: and + text = text.replace( + /<([^@#!][^|]*?\.[^|]*?)\|(.*?)>/g, + '$2' + ) + text = text.replace( + /<([^@#!][^|]*?\.[^|]*?)>/g, + '$1' + ) + // User mentions: <@U123> text = text.replace( /<@([A-Z0-9]+)>/g, @@ -89,7 +99,10 @@ function formatInline(text: string): string { * Processes block-level elements (code blocks, blockquotes, lists) line-by-line, * then applies inline formatting within each block. */ -export function mrkdwnToHtml(text: string): string { +export function mrkdwnToHtml( + text: string, + options?: { useBr?: boolean } +): string { if (!text) return '' // Phase 1: Extract code blocks (``` ... ```) and replace with placeholders @@ -102,8 +115,8 @@ export function mrkdwnToHtml(text: string): string { return `\uE000CB${idx}\uE000` }) - // Line-break span used instead of
for CSS-controllable spacing - const BR = '' + // Line-break element:
for compact blocks, styled span for messages + const BR = options?.useBr ? '
' : '' // Phase 2: Process line-by-line for block-level elements const lines = text.split('\n') diff --git a/packages/slack/src/server/index.ts b/packages/slack/src/server/index.ts index 54297dd..6754c42 100644 --- a/packages/slack/src/server/index.ts +++ b/packages/slack/src/server/index.ts @@ -469,7 +469,7 @@ export async function startEmulatorServer( // File serving endpoint (serves uploaded files via HTTP) const fileServeMatch = path.match(/^\/api\/simulator\/files\/([^/]+)$/) - if (fileServeMatch && req.method === 'GET') { + if (fileServeMatch && (req.method === 'GET' || req.method === 'HEAD')) { const fileId = fileServeMatch[1] ?? '' return await webApi.handleGetFile(fileId) } @@ -501,7 +501,7 @@ export async function startEmulatorServer( method: 'HEAD', signal: AbortSignal.timeout(5000), }) - let size = Number(res.headers.get('content-length') || 0) + let size = res.ok ? Number(res.headers.get('content-length') || 0) : 0 if (!size) { const full = await fetch(imageUrl, { signal: AbortSignal.timeout(5000), @@ -511,12 +511,23 @@ export async function startEmulatorServer( } return Response.json( { ok: true, size }, - { headers: { 'Access-Control-Allow-Origin': '*' } } + { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Cache-Control': 'no-store', + }, + } ) } catch { return Response.json( { ok: false, error: 'fetch failed' }, - { status: 502, headers: { 'Access-Control-Allow-Origin': '*' } } + { + status: 502, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Cache-Control': 'no-store', + }, + } ) } } diff --git a/packages/slack/src/server/socket-mode.ts b/packages/slack/src/server/socket-mode.ts index f9b4279..fe91981 100644 --- a/packages/slack/src/server/socket-mode.ts +++ b/packages/slack/src/server/socket-mode.ts @@ -677,7 +677,10 @@ export class SocketModeServer { } } - async dispatchShortcut(payload: MessageShortcutPayload): Promise { + async dispatchShortcut( + payload: MessageShortcutPayload, + targetBotId?: string + ): Promise { if (this.connections.size === 0) { socketModeLogger.warn( `No bots connected, shortcut not dispatched: ${payload.callback_id}` @@ -699,7 +702,37 @@ export class SocketModeServer { const message = JSON.stringify(envelope) - // Send to all connected bots + // Targeted dispatch: send only to the bot that owns the shortcut + if (targetBotId) { + const bot = this.state.getBot(targetBotId) + if (!bot || bot.status !== 'connected') { + socketModeLogger.warn( + { targetBotId }, + 'Target bot not found or not connected for shortcut dispatch' + ) + return + } + const conn = this.connections.get(bot.connectionId) + if (conn) { + await this.sendWithAck(conn, envelope.envelope_id, message) + socketModeLogger.debug( + `Shortcut dispatched to bot ${targetBotId}: ${payload.callback_id}` + ) + } else { + socketModeLogger.warn( + { + targetBotId, + connectionId: bot.connectionId, + envelopeId: envelope.envelope_id, + callbackId: payload.callback_id, + }, + 'Bot is connected but active socket not found, shortcut dropped' + ) + } + return + } + + // Fallback: broadcast to all connected bots const sendPromises: Promise[] = [] for (const conn of this.connections.values()) { sendPromises.push(this.sendWithAck(conn, envelope.envelope_id, message)) diff --git a/packages/slack/src/server/state.ts b/packages/slack/src/server/state.ts index 8d0f699..3b30f28 100644 --- a/packages/slack/src/server/state.ts +++ b/packages/slack/src/server/state.ts @@ -925,6 +925,19 @@ export class EmulatorState { return undefined } + /** + * Find the connected bot that registered a given shortcut by callback_id + */ + getBotForShortcut(callbackId: string): ConnectedBot | undefined { + for (const bot of this.connectedBots.values()) { + if (bot.status !== 'connected') continue + if (bot.appConfig.shortcuts?.some((s) => s.callback_id === callbackId)) { + return bot + } + } + return undefined + } + // ========================================================================== // View / Modal Operations // ========================================================================== diff --git a/packages/slack/src/server/types.ts b/packages/slack/src/server/types.ts index 8330729..c6e3286 100644 --- a/packages/slack/src/server/types.ts +++ b/packages/slack/src/server/types.ts @@ -89,6 +89,8 @@ export interface SlackAppConfig { description?: string id?: string // Bot identifier in simulator (from config.yaml simulator.id) configPort?: number // Port for bot's config HTTP server (usually bot port + 1) + icon_emoji?: string // Bot icon as emoji (e.g., ':robot_face:') + icon_url?: string // Bot icon as image URL } commands: SlashCommandDefinition[] shortcuts: ShortcutDefinition[] @@ -121,7 +123,7 @@ export interface SlashCommandPayload { // ============================================================================= export interface MessageShortcutPayload { - type: 'shortcut' + type: 'message_action' callback_id: string trigger_id: string message: { @@ -131,6 +133,7 @@ export interface MessageShortcutPayload { mimetype?: string url_private?: string }> + blocks?: unknown[] } channel: { id: string } user: { id: string; username: string } diff --git a/packages/slack/src/server/web-api.ts b/packages/slack/src/server/web-api.ts index 495370a..b1d9bb6 100644 --- a/packages/slack/src/server/web-api.ts +++ b/packages/slack/src/server/web-api.ts @@ -1407,7 +1407,9 @@ export class SlackWebAPI { connectedAt: bot.connectedAt.toISOString(), status: bot.status, commands: bot.appConfig.commands?.length ?? 0, - shortcuts: bot.appConfig.shortcuts?.length ?? 0, + shortcuts: bot.appConfig.shortcuts ?? [], + iconEmoji: bot.appConfig.app.icon_emoji, + iconUrl: bot.appConfig.app.icon_url, configPort: bot.appConfig.app.configPort, })) return Response.json({ ok: true, bots }, { headers: corsHeaders() }) @@ -1955,24 +1957,69 @@ export class SlackWebAPI { userName: user_name, }) + // Look up the full message from state for complete data (text, files) + const storedMessage = this.state.getMessage(channel, message.ts) + + // Build files array from stored message file (has full metadata) or fallback to request + const files: Array<{ mimetype?: string; url_private?: string }> = + storedMessage?.file + ? [ + { + mimetype: storedMessage.file.mimetype, + url_private: storedMessage.file.url_private, + }, + ] + : (message.files ?? []) + + // Also extract images from Block Kit image blocks so bots see them as files + if (storedMessage?.blocks) { + for (const block of storedMessage.blocks) { + const b = block as { type?: string; image_url?: string } + if (b.type === 'image' && b.image_url) { + const ext = b.image_url + ?.split('.') + .pop() + ?.split('?')[0] + ?.toLowerCase() + const mimeByExt: Record = { + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + svg: 'image/svg+xml', + } + const mimetype = ext && mimeByExt[ext] ? mimeByExt[ext] : undefined + files.push({ mimetype, url_private: b.image_url }) + } + } + } + + // Resolve the owning bot by callback_id for targeted dispatch + const targetBot = this.state.getBotForShortcut(callback_id) + webApiLogger.info( - { callback_id, triggerId, channel }, + { callback_id, triggerId, channel, targetBot: targetBot?.id }, 'Dispatching message shortcut' ) - // Dispatch shortcut to connected bots - await this.socketMode.dispatchShortcut({ - type: 'shortcut', - callback_id, - trigger_id: triggerId, - message: { - ts: message.ts, - text: message.text, - files: message.files, + // Dispatch shortcut to the owning bot (or broadcast as fallback) + await this.socketMode.dispatchShortcut( + { + type: 'message_action', + callback_id, + trigger_id: triggerId, + message: { + ts: message.ts, + text: storedMessage?.text || message.text, + files, + blocks: storedMessage?.blocks, + }, + channel: { id: channel }, + user: { id: user, username: user_name || 'simulator_user' }, }, - channel: { id: channel }, - user: { id: user, username: user_name || 'simulator_user' }, - }) + targetBot?.id + ) return Response.json( { ok: true, trigger_id: triggerId },