diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..700deb2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,67 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What is Botarium + +Botarium is a local Slack bot development simulator. Build, test, and debug Slack bots entirely on your machine without deploying to real Slack. + +- Emulator app that runs in the browser or as a native desktop app (Electron) +- CLI tools to scaffold bots from scratch +- Development tools: persistence layer, Slack mrkdwn converter, and AI integration + +## Commands + +```sh +bun run dev # Start web UI dev server (Vite, port 5173) +bun run dev:electron # Start desktop app (Electron + Vite + emulator) +bun run build # Build all packages and apps +bun run test # Run all tests (bun test) +bun test # Run a single test file +bun run check # typecheck + format:check + lint (CI runs this) +bun run typecheck # TypeScript check across all workspaces +bun run lint # ESLint +bun run format # Prettier (write) +bun run cli create # Scaffold a new bot interactively +``` + +Run the emulator standalone: `bun run packages/slack/src/server/index.ts` + +## Architecture + +Bun monorepo with workspaces in `apps/` and `packages/`. + +### Packages + +- **`packages/core`** — Plugin system and CLI entry point. Defines the `BotariumPlugin` interface that platform plugins implement (`createEmulator`, `defaultPort`, `envVarName`). Currently, only Slack is implemented, but the design supports adding other platforms. +- **`packages/slack`** — Slack API emulator. A Bun HTTP + WebSocket server that implements Slack Web API endpoints, Socket Mode protocol, SSE event broadcasting to the frontend, and optional SQLite persistence. Key files: `server/state.ts` (in-memory state), `server/web-api.ts` (API handlers), `server/socket-mode.ts` (WebSocket protocol), `server/persistence.ts` (SQLite). +- **`packages/mrkdwn`** — Bidirectional converters: Slack mrkdwn to HTML (for rendering in UI) and Markdown to mrkdwn (for AI responses). Uses `marked` for Markdown parsing. +- **`packages/create-bot`** — CLI tool (`create-botarium`) that scaffolds new bots via Handlebars templates in `templates/`. + +### Apps + +- **`apps/ui`** — Svelte 5 + Tailwind CSS 4 chat interface. Uses Svelte 5 runes (`$state`, `$derived`, `$effect`) for reactivity. Key state files: `lib/state.svelte.ts` (reactive UI state), `lib/dispatcher.svelte.ts` (SSE connection to emulator, API calls), `lib/backend-state.svelte.ts` (Electron IPC bridge). Uses `$lib` path alias for `src/lib/`. +- **`apps/electron`** — Desktop wrapper. Manages emulator and bot child processes, provides IPC for settings (encrypted via OS keychain), and bundles everything into a native app. + +### Data Flow + +1. User types in UI → HTTP POST to emulator `/api/simulator/user-message` +2. Emulator creates Slack event → sends via WebSocket (Socket Mode) to connected bot +3. Bot processes event → calls Slack Web API endpoints (e.g., `chat.postMessage`) on the emulator +4. Emulator broadcasts SSE event → UI updates reactively + +## Code Style + +- No semicolons, single quotes, trailing commas (es5) — enforced by Prettier +- `{@html}` is intentionally used in Svelte for mrkdwn rendering (ESLint rule disabled) +- Prefix unused variables with `_` +- Svelte components use runes (`$state`, `$derived`, `$effect`), not legacy stores +- TypeScript strict mode with `noUncheckedIndexedAccess` + +## Testing + +Tests use Bun's built-in test runner (`bun:test`). Test files are colocated with source as `*.test.ts`. Main test areas: + +- `packages/mrkdwn/src/` — mrkdwn/HTML conversion tests +- `packages/create-bot/src/` — scaffold and template utility tests +- `apps/ui/src/lib/` — UI state tests diff --git a/apps/electron/bots.json b/apps/electron/bots.json index c603a69..1f00567 100644 --- a/apps/electron/bots.json +++ b/apps/electron/bots.json @@ -1,3 +1,8 @@ { - "bots": [] + "bots": [ + { + "source": "../showcase-bot", + "name": "showcase" + } + ] } diff --git a/apps/showcase-bot/README.md b/apps/showcase-bot/README.md new file mode 100644 index 0000000..e807dfe --- /dev/null +++ b/apps/showcase-bot/README.md @@ -0,0 +1,95 @@ +# Showcase Bot + +A Slack bot that populates a channel with [Block Kit](https://api.slack.com/block-kit) examples and echoes back interactive element payloads. Built with [@slack/bolt](https://slack.dev/bolt-js/) and [Bun](https://bun.sh). + +## How it works + +On startup (in simulator mode), the bot automatically posts a series of Block Kit messages to the `#showcase` channel. These messages demonstrate various block types and interactive elements. When a user interacts with any element, the bot responds with the raw action payload so you can inspect exactly what Slack sends. + +### Block Kit samples + +Message definitions live in `src/messages/blocks/` as numbered JSON files, loaded and posted in order: + +| # | File | What it shows | +| --- | ---------------------------- | -------------------------------------------------------- | +| 01 | `text-and-layout.json` | Headers, sections, dividers, context, images | +| 02 | `button-variations.json` | Primary, danger, and default buttons | +| 03 | `selection-elements.json` | Static selects, multi-selects, overflow menus | +| 04 | `radio-and-checkboxes.json` | Radio buttons and checkbox groups | +| 05 | `date-and-time-pickers.json` | Date pickers, time pickers, datetime pickers | +| 06 | `section-accessories.json` | Buttons, selects, and overflows as section accessories | +| 07 | `combined-actions.json` | Multiple interactive elements in a single actions block | +| 08 | `rich-text.json` | Rich text with formatting, lists, quotes, code blocks | +| 09 | `template-newsletter.json` | A realistic newsletter-style message template | +| 10 | `kitchen-sink.json` | Mixed rich text, actions, selects, and feedback elements | + +JSON files support template variables (`{{TODAY_DATE}}`, `{{TODAY_NOON_UNIX}}`) that are resolved at load time. + +You can try pasting examples from Slack's [Block Kit Builder](https://app.slack.com/block-kit-builder) to show them in the emulator. + +### Action responses + +All interactive elements use action IDs prefixed with `showcase_`. A single catch-all handler matches this prefix: + +```js +app.action(/^showcase_/, ...) +``` + +When triggered, the handler acknowledges the action and posts a reply containing the action type, action ID, and the full JSON payload. This makes it easy to see exactly what data each interactive element produces. + +Modal submissions (callback ID `showcase_modal`) are handled the same way -- the bot posts the `view.state.values` back to the channel. + +### Slash command + +The bot registers a `/showcase` command with the following subcommands: + +| Subcommand | Description | +| ---------- | -------------------------------------------------------- | +| `generate` | Clear and re-populate `#showcase` with all block samples | +| `clear` | Remove all messages from `#showcase` | +| `modal` | Open a modal with various input elements | +| `help` | Show available subcommands | + +## Running + +```sh +# With the Botarium simulator +bun run dev:local + +# With real Slack credentials +SLACK_BOT_TOKEN=xoxb-... SLACK_APP_TOKEN=xapp-... bun run dev +``` + +### Environment variables + +| Variable | Required | Description | +| ---------------------- | ---------------- | --------------------------------------------------------------- | +| `SLACK_BOT_TOKEN` | Yes (production) | Bot user OAuth token | +| `SLACK_APP_TOKEN` | Yes (production) | App-level token for Socket Mode | +| `SLACK_SIGNING_SECRET` | Yes (production) | Signing secret for request verification | +| `SLACK_API_URL` | No | Set automatically in simulator mode | +| `BOT_NAME` | No | Display name (default: `Showcase Bot`) | +| `PORT` | No | Server port (default: `3000`) | +| `LOG_LEVEL` | No | `silent`, `debug`, `info`, `warn`, or `error` (default: `info`) | + +In simulator mode, token and secret values are generated automatically. + +## Project structure + +```text +src/ + app.ts # Entry point, Bolt app setup, startup sequence + settings.ts # Environment variable validation (zod) + config/ + loader.ts # Bot configuration loader + http-server.ts # Config server for simulator registration + listeners/ + index.ts # Registers commands and actions + commands/showcase.ts # /showcase command and message posting + actions/showcase-actions.ts # block_actions and view_submission handlers + messages/ + showcase-messages.ts # Loads and templates block JSON files + blocks/*.json # Block Kit message definitions + utils/ + logger.ts # Pino logger setup +``` diff --git a/apps/showcase-bot/bunfig.toml b/apps/showcase-bot/bunfig.toml new file mode 100644 index 0000000..e69de29 diff --git a/apps/showcase-bot/config.yaml b/apps/showcase-bot/config.yaml new file mode 100644 index 0000000..221ec87 --- /dev/null +++ b/apps/showcase-bot/config.yaml @@ -0,0 +1,42 @@ +# Showcase Bot Configuration +# Sends categorized Block Kit example messages for visual verification. + +# Simulator-specific configuration (not sent to Slack) +simulator: + id: showcase # Isolates DM messages per-bot in the simulator + +# Slack app configuration +slack: + commands: + - command: /showcase + description: Block Kit showcase commands + usage_hint: '[generate|clear|modal|help]' + shortcuts: [] + actions: {} + modals: + showcase_modal: showcase_modal + +# Settings with schema metadata for UI generation +settings: + bot_name: + value: Showcase Bot + schema: + type: string + label: Bot Name + description: Display name for the bot in conversations + group: bot + required: true + + bot_description: + value: Sends categorized Block Kit example messages for visual verification. + schema: + type: string + label: Bot Description + description: Short description shown in the DM about section + group: bot + +# Group definitions with display order +groups: + - id: bot + label: Bot Configuration + order: 1 diff --git a/apps/showcase-bot/package.json b/apps/showcase-bot/package.json new file mode 100644 index 0000000..30e7108 --- /dev/null +++ b/apps/showcase-bot/package.json @@ -0,0 +1,26 @@ +{ + "name": "showcase-bot", + "version": "0.1.0", + "description": "Block Kit showcase bot for Botarium", + "type": "module", + "private": true, + "scripts": { + "start": "bun run src/app.ts", + "dev": "bun --watch run src/app.ts", + "dev:local": "SLACK_API_URL=http://localhost:7557 bun --watch run src/app.ts", + "typecheck": "tsc --noEmit", + "test": "echo 'No tests yet'" + }, + "dependencies": { + "@slack/bolt": "^4.6.0", + "@slack/types": "^2.15.0", + "@slack/web-api": "^7.10.0", + "pino": "^10.1.0", + "zod": "^4.3.5" + }, + "devDependencies": { + "@types/bun": "^1.3.6", + "pino-pretty": "^13.1.3", + "typescript": "^5" + } +} diff --git a/apps/showcase-bot/src/app.ts b/apps/showcase-bot/src/app.ts new file mode 100644 index 0000000..3ce0175 --- /dev/null +++ b/apps/showcase-bot/src/app.ts @@ -0,0 +1,271 @@ +import { App, LogLevel } from '@slack/bolt' +import { registerListeners } from './listeners/index' +import { sendShowcaseMessages, HELP_TEXT } from './listeners/commands/showcase' +import { settings, isSimulatorMode } from './settings' +import { startConfigServer } from './config/http-server' +import { slackConfig, config } from './config/loader' +import { appLogger, slackLogger } from './utils/logger' + +// Simulator tokens for local mode - unique per bot to enable multi-bot support +const SIMULATOR_BOT_TOKEN = `xoxb-${config.simulator.id}` +const SIMULATOR_APP_TOKEN = `xapp-${config.simulator.id}` + +// Track connection state for reconnection handling +let hasConnectedOnce = false + +// Config server port (set when server starts) +let configServerPort: number | undefined + +/** + * Poll the emulator's health endpoint to verify a WebSocket connection exists. + * This ensures registration happens only after the WebSocket is tracked. + */ +async function waitForWebSocketConnection( + apiUrl: string, + timeoutMs: number = 4000 +): Promise { + const startTime = Date.now() + const baseUrl = apiUrl.replace(/\/api$/, '') + + while (Date.now() - startTime < timeoutMs) { + try { + const response = await fetch(`${baseUrl}/health`) + if (response.ok) { + const data = (await response.json()) as { connected_bots?: number } + // Health endpoint returns connected_bots which tracks WebSocket connections + if (data.connected_bots && data.connected_bots > 0) { + return true + } + } + } catch { + // Emulator not ready yet + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + return false +} + +function createApp() { + const logLevel = + settings.LOG_LEVEL === 'debug' ? LogLevel.DEBUG : LogLevel.INFO + + if (isSimulatorMode) { + slackLogger.info( + { apiUrl: process.env.SLACK_API_URL }, + 'Connecting to simulator' + ) + return new App({ + token: SIMULATOR_BOT_TOKEN, + appToken: SIMULATOR_APP_TOKEN, + socketMode: true, + logLevel, + clientOptions: { + slackApiUrl: process.env.SLACK_API_URL, + }, + }) + } + + return new App({ + token: settings.SLACK_BOT_TOKEN, + appToken: settings.SLACK_APP_TOKEN, + socketMode: true, + logLevel, + }) +} + +interface RegistrationResponse { + ok: boolean + error?: string + message?: string + settings?: Record +} + +async function registerWithSimulator(maxRetries = 10, retryDelayMs = 1000) { + const apiUrl = process.env.SLACK_API_URL + if (!apiUrl) return + + // Build registration payload with actual config server port + const registrationPayload = { + ...slackConfig, + app: { ...slackConfig.app, configPort: configServerPort }, + } + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + // apiUrl already includes /api suffix (e.g., http://localhost:7557/api) + const response = await fetch(`${apiUrl}/config/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(registrationPayload), + }) + + const data = (await response.json()) as RegistrationResponse + + if (!response.ok) { + // Handle WebSocket not ready - use faster retries for this transient condition + if (data.error === 'no_websocket_connection') { + slackLogger.debug( + { attempt, maxRetries }, + 'WebSocket not tracked yet, retrying quickly...' + ) + await new Promise((resolve) => setTimeout(resolve, 200)) + continue + } + throw new Error( + `HTTP ${response.status}: ${data.message || data.error}` + ) + } + + // Apply simulator settings to process.env (for API keys, etc.) + if (data.settings) { + const apiKeySettings = [ + 'OPENAI_API_KEY', + 'ANTHROPIC_API_KEY', + 'GOOGLE_API_KEY', + 'AI_PROVIDER', + ] + for (const key of apiKeySettings) { + if (data.settings[key] && !process.env[key]) { + process.env[key] = data.settings[key] + slackLogger.debug(`Applied ${key} from simulator settings`) + } + } + } + + slackLogger.info('Registered with simulator') + return + } catch (error) { + if (attempt < maxRetries) { + slackLogger.debug( + { attempt, maxRetries, error: String(error) }, + 'Simulator not ready, retrying...' + ) + await new Promise((resolve) => setTimeout(resolve, retryDelayMs)) + } else { + slackLogger.warn({ error }, 'Failed to register with simulator') + } + } + } +} + +async function main() { + appLogger.info({ simulatorMode: isSimulatorMode }, 'Starting bot...') + + // Start config server for simulator (uses random port) + if (isSimulatorMode) { + const server = startConfigServer() + if (server) { + configServerPort = server.port + } + } + + const app = createApp() + registerListeners(app) + + // Add WebSocket event logging for diagnostics (simulator mode only) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const receiver = (app as any).receiver as { + client?: { + on?: (event: string, callback: (...args: unknown[]) => void) => void + } + } + + if (isSimulatorMode && receiver?.client?.on) { + receiver.client.on('connecting', () => + slackLogger.debug('WebSocket connecting...') + ) + receiver.client.on('connected', async () => { + slackLogger.info('WebSocket connected') + + if (hasConnectedOnce) { + // This is a reconnection - re-register with simulator + slackLogger.info('Reconnected - re-registering with simulator...') + const apiUrl = process.env.SLACK_API_URL + if (apiUrl) { + const connected = await waitForWebSocketConnection(apiUrl, 5000) + if (!connected) { + slackLogger.warn( + 'WebSocket not tracked after reconnection, attempting registration anyway' + ) + } + await registerWithSimulator() + } + } + hasConnectedOnce = true + }) + receiver.client.on('disconnected', () => + slackLogger.warn('WebSocket disconnected') + ) + receiver.client.on('error', (err: unknown) => + slackLogger.error({ err }, 'WebSocket error') + ) + } + + await app.start() + slackLogger.info('Slack app started') + + // Register with simulator after verifying WebSocket is tracked + if (isSimulatorMode) { + const apiUrl = process.env.SLACK_API_URL + if (apiUrl) { + slackLogger.info('Waiting for WebSocket connection to be tracked...') + const connected = await waitForWebSocketConnection(apiUrl, 5000) + + if (!connected) { + slackLogger.warn( + 'WebSocket not detected after 5s, attempting registration anyway' + ) + } + + await registerWithSimulator() + + // Pre-populate #showcase channel with Block Kit examples + slackLogger.info('Populating #showcase channel...') + try { + await sendShowcaseMessages(app.client) + } catch (err) { + slackLogger.error({ err }, 'Failed to populate showcase channel') + } + + // Send help text to bot DM so it's not empty on first open + // If the last message is already the help text, replace it (avoids duplicates across restarts) + const dmChannel = `D_${config.simulator.id}` + try { + const history = await app.client.conversations.history({ + channel: dmChannel, + limit: 1, + }) + const lastMessage = history.messages?.[0] + if (lastMessage?.ts && lastMessage.text === HELP_TEXT) { + await app.client.chat.delete({ + channel: dmChannel, + ts: lastMessage.ts, + }) + } + await app.client.chat.postMessage({ + channel: dmChannel, + text: HELP_TEXT, + }) + } catch (err) { + slackLogger.error({ err }, 'Failed to send DM help message') + } + } + } + + // Graceful shutdown + const shutdown = async () => { + appLogger.info('Shutting down...') + await app.stop() + process.exit(0) + } + + process.on('SIGTERM', shutdown) + process.on('SIGINT', shutdown) + + appLogger.info(`${settings.BOT_NAME} is running!`) +} + +main().catch((error) => { + appLogger.fatal({ error }, 'Fatal error') + process.exit(1) +}) diff --git a/apps/showcase-bot/src/config/http-server.ts b/apps/showcase-bot/src/config/http-server.ts new file mode 100644 index 0000000..af5cf50 --- /dev/null +++ b/apps/showcase-bot/src/config/http-server.ts @@ -0,0 +1,96 @@ +/** + * HTTP server for /config endpoint + * + * Exposes bot configuration schema for the simulator's Settings UI. + * Runs alongside Slack Bolt (which uses socket mode). + */ + +import { config, type SettingSchema, type GroupDefinition } from './loader' +import { appLogger } from '../utils/logger' + +export interface ConfigResponse { + schema: { + settings: Record + groups: GroupDefinition[] + model_tiers: Record> + } + values: Record +} + +/** + * Build the config response, excluding secret values + */ +function getConfigResponse(): ConfigResponse { + const values: Record = {} + const settingsSchema: Record = {} + + for (const [key, def] of Object.entries(config.settings)) { + // Always include schema + settingsSchema[key] = def.schema + + // Include values for non-secrets + // For secrets, include empty string as placeholder + if (def.schema.type === 'secret') { + values[key] = '' + } else if (def.env) { + // Value comes from env var + values[key] = process.env[def.env] ?? def.value ?? '' + } else { + values[key] = def.value ?? '' + } + } + + return { + schema: { + settings: settingsSchema, + groups: config.groups, + model_tiers: {}, + }, + values, + } +} + +/** + * Start the config HTTP server on a random available port + * Returns the server instance with its actual port, or null if failed + */ +export function startConfigServer() { + try { + const server = Bun.serve({ + port: 0, // Let OS pick an available port + hostname: '127.0.0.1', // Explicit IPv4 for Electron compatibility + async fetch(req) { + const url = new URL(req.url) + + // CORS preflight + if (req.method === 'OPTIONS') { + return new Response(null, { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + }, + }) + } + + const corsHeaders = { 'Access-Control-Allow-Origin': '*' } + + if (url.pathname === '/config') { + return Response.json(getConfigResponse(), { headers: corsHeaders }) + } + + if (url.pathname === '/health') { + return Response.json({ ok: true }, { headers: corsHeaders }) + } + + return new Response('Not Found', { status: 404 }) + }, + }) + + appLogger.info({ port: server.port }, 'Config server started') + return server + } catch (error) { + appLogger.warn({ error }, 'Failed to start config server (non-fatal)') + return null + } +} diff --git a/apps/showcase-bot/src/config/loader.ts b/apps/showcase-bot/src/config/loader.ts new file mode 100644 index 0000000..2244a73 --- /dev/null +++ b/apps/showcase-bot/src/config/loader.ts @@ -0,0 +1,110 @@ +/** + * Config loader - parses config.yaml and provides typed access + * + * This is the single source of truth for bot configuration. + * Settings can have: + * - value: direct value + * - env: reference to environment variable (for secrets) + * - schema: metadata for UI generation + */ + +import rawConfig from '../../config.yaml' + +// Schema field types for UI generation +export type FieldType = + | 'string' + | 'text' + | 'number' + | 'secret' + | 'select' + | 'model_select' + | 'boolean' + +export interface SelectOption { + value: string + label: string +} + +export interface FieldCondition { + field: string + equals: string +} + +export interface SettingSchema { + type: FieldType + label: string + description?: string + group: string + required?: boolean + required_when?: FieldCondition + condition?: FieldCondition + options?: SelectOption[] + min?: number + max?: number + placeholder?: string + tier?: string // For model_select: fast, default + provider_field?: string // For model_select: which field determines provider +} + +export interface SettingDefinition { + value?: unknown + env?: string + schema: SettingSchema +} + +export interface GroupDefinition { + id: string + label: string + order: number + collapsible?: boolean + expanded?: boolean +} + +// Slack app configuration types +export interface SlashCommand { + command: string + description: string + usage_hint?: string +} + +export interface Shortcut { + callback_id: string + name: string + description: string + type: 'message' | 'global' +} + +export interface SlackConfig { + commands: SlashCommand[] + shortcuts: Shortcut[] + actions: Record + modals: Record +} + +// Simulator-specific configuration +export interface SimulatorConfig { + id: string // Isolates DM messages per-bot in the simulator +} + +export interface ConfigFile { + simulator: SimulatorConfig + slack: SlackConfig + settings: Record + groups: GroupDefinition[] +} + +// Export typed config +export const config = rawConfig as ConfigFile + +// Get bot name from settings (with fallback) +const botName = (config.settings.bot_name?.value as string) ?? 'Showcase Bot' +const botDescription = config.settings.bot_description?.value as + | string + | undefined + +// Convenience exports for Slack config +// Include app info for emulator registration (derived from bot_name setting) +export const slackConfig = { + app: { name: botName, description: botDescription, id: config.simulator.id }, + ...config.slack, +} diff --git a/apps/showcase-bot/src/listeners/actions/showcase-actions.ts b/apps/showcase-bot/src/listeners/actions/showcase-actions.ts new file mode 100644 index 0000000..7009361 --- /dev/null +++ b/apps/showcase-bot/src/listeners/actions/showcase-actions.ts @@ -0,0 +1,85 @@ +import type { App } from '@slack/bolt' +import { slackLogger } from '../../utils/logger' + +const SHOWCASE_CHANNEL = 'C_SHOWCASE' + +export function register(app: App) { + // Catch all showcase_ prefixed actions + app.action(/^showcase_/, async ({ action, ack, body, client }) => { + await ack() + + // Build a readable summary of the action payload + const actionSummary = JSON.stringify(action, null, 2) + + // Truncate if very long (e.g., large selected_options arrays) + const displaySummary = + actionSummary.length > 1500 + ? actionSummary.substring(0, 1500) + '\n...(truncated)' + : actionSummary + + // Determine channel from body (block_actions payloads include channel) + const channel = + (body as { channel?: { id: string } }).channel?.id ?? SHOWCASE_CHANNEL + + await client.chat.postMessage({ + channel, + text: `Action: ${(action as { action_id: string }).action_id}`, + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*Received \`block_actions\`* from \`${(action as { action_id: string }).action_id}\``, + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `\`\`\`${displaySummary}\`\`\``, + }, + }, + ], + }) + + slackLogger.info( + { actionId: (action as { action_id: string }).action_id }, + 'Echoed block_action' + ) + }) + + // Handle modal submission + app.view('showcase_modal', async ({ view, ack, client }) => { + await ack() + + const valuesSummary = JSON.stringify(view.state.values, null, 2) + const displaySummary = + valuesSummary.length > 2000 + ? valuesSummary.substring(0, 2000) + '\n...(truncated)' + : valuesSummary + + // Send the submission echo to #showcase channel + await client.chat.postMessage({ + channel: SHOWCASE_CHANNEL, + text: 'Modal submitted', + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: '*Received `view_submission`* for `showcase_modal`', + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `\`\`\`${displaySummary}\`\`\``, + }, + }, + ], + }) + + slackLogger.info('Echoed view_submission') + }) +} diff --git a/apps/showcase-bot/src/listeners/commands/showcase.ts b/apps/showcase-bot/src/listeners/commands/showcase.ts new file mode 100644 index 0000000..7df4069 --- /dev/null +++ b/apps/showcase-bot/src/listeners/commands/showcase.ts @@ -0,0 +1,255 @@ +import type { App } from '@slack/bolt' +import { showcaseMessages } from '../../messages/showcase-messages' +import { slackLogger } from '../../utils/logger' + +const SHOWCASE_CHANNEL = 'C_SHOWCASE' + +/** + * Clear all messages from the #showcase channel. + */ +export async function clearShowcaseChannel(client: App['client']) { + try { + const history = await client.conversations.history({ + channel: SHOWCASE_CHANNEL, + limit: 200, + }) + if (history.messages) { + for (const msg of history.messages) { + if (msg.ts) { + await client.chat.delete({ channel: SHOWCASE_CHANNEL, ts: msg.ts }) + } + } + } + } catch { + // Channel may be empty or not yet available — safe to ignore + } +} + +/** + * Send all showcase messages to the #showcase channel. + * Clears existing messages first to prevent duplicates across restarts. + * Reusable: called both on startup (auto-populate) and via /showcase command. + */ +export async function sendShowcaseMessages(client: App['client']) { + await clearShowcaseChannel(client) + + for (const message of showcaseMessages) { + try { + await client.chat.postMessage({ + channel: SHOWCASE_CHANNEL, + text: message.fallbackText, + blocks: message.blocks, + }) + } catch (err) { + slackLogger.error( + { err, fallbackText: message.fallbackText }, + 'Failed to send showcase message' + ) + } + } + slackLogger.info( + { messageCount: showcaseMessages.length }, + 'Sent showcase messages' + ) +} + +export const HELP_TEXT = [ + '*/showcase* commands:', + '- `/showcase generate` -- Populate #showcase with Block Kit examples', + '- `/showcase clear` -- Clear all messages from #showcase', + '- `/showcase modal` -- Open a modal with input elements', + '- `/showcase help` -- Show this help message', +].join('\n') + +async function postHelpMessage( + client: App['client'], + channel: string, + user: string, + prefix?: string +) { + const text = prefix ? `${prefix}\n\n${HELP_TEXT}` : HELP_TEXT + await client.chat.postEphemeral({ + channel, + user, + text, + }) +} + +export function register(app: App) { + app.command('/showcase', async ({ command, ack, client }) => { + await ack() + + const subcommand = command.text.trim().toLowerCase().split(/\s+/)[0] || '' + + switch (subcommand) { + case 'generate': + await sendShowcaseMessages(client) + break + + case 'clear': + await clearShowcaseChannel(client) + await client.chat.postEphemeral({ + channel: command.channel_id, + user: command.user_id, + text: 'Cleared all messages from #showcase.', + }) + break + + case 'modal': + try { + await client.views.open({ + trigger_id: command.trigger_id, + view: { + type: 'modal', + callback_id: 'showcase_modal', + title: { type: 'plain_text', text: 'Input Showcase' }, + submit: { type: 'plain_text', text: 'Submit' }, + close: { type: 'plain_text', text: 'Cancel' }, + blocks: [ + { + type: 'input', + label: { type: 'plain_text', text: 'Your Name' }, + hint: { + type: 'plain_text', + text: 'Enter your full name', + }, + element: { + type: 'plain_text_input', + action_id: 'showcase_modal_name', + }, + }, + { + type: 'input', + label: { type: 'plain_text', text: 'Email Address' }, + element: { + type: 'email_text_input', + action_id: 'showcase_modal_email', + placeholder: { + type: 'plain_text', + text: 'name@example.com', + }, + }, + }, + { + type: 'input', + label: { type: 'plain_text', text: 'Website' }, + element: { + type: 'url_text_input', + action_id: 'showcase_modal_url', + placeholder: { + type: 'plain_text', + text: 'https://example.com', + }, + }, + }, + { + type: 'input', + label: { type: 'plain_text', text: 'Quantity' }, + hint: { + type: 'plain_text', + text: 'Enter a number between 1 and 100', + }, + element: { + type: 'number_input', + action_id: 'showcase_modal_quantity', + is_decimal_allowed: false, + min_value: '1', + max_value: '100', + }, + }, + { + type: 'input', + label: { type: 'plain_text', text: 'Start Date' }, + element: { + type: 'datepicker', + action_id: 'showcase_modal_start_date', + }, + }, + { + type: 'input', + label: { type: 'plain_text', text: 'Start Time' }, + element: { + type: 'timepicker', + action_id: 'showcase_modal_start_time', + }, + }, + { + type: 'input', + label: { type: 'plain_text', text: 'Event DateTime' }, + element: { + type: 'datetimepicker', + action_id: 'showcase_modal_event_datetime', + }, + }, + { + type: 'input', + label: { type: 'plain_text', text: 'Priority Level' }, + element: { + type: 'radio_buttons', + action_id: 'showcase_modal_priority', + options: [ + { + text: { type: 'plain_text', text: 'Low' }, + value: 'low', + }, + { + text: { type: 'plain_text', text: 'Medium' }, + value: 'medium', + }, + { + text: { type: 'plain_text', text: 'High' }, + value: 'high', + }, + ], + }, + }, + { + type: 'input', + label: { + type: 'plain_text', + text: 'Notification Preferences', + }, + element: { + type: 'checkboxes', + action_id: 'showcase_modal_notifications', + options: [ + { + text: { type: 'plain_text', text: 'Email' }, + value: 'email', + }, + { + text: { type: 'plain_text', text: 'SMS' }, + value: 'sms', + }, + { + text: { type: 'plain_text', text: 'Push' }, + value: 'push', + }, + ], + }, + }, + ], + }, + }) + slackLogger.info('Opened showcase modal') + } catch (err) { + slackLogger.error({ err }, 'Failed to open showcase modal') + } + break + + case 'help': + case '': + await postHelpMessage(client, command.channel_id, command.user_id) + break + + default: + await postHelpMessage( + client, + command.channel_id, + command.user_id, + 'Unknown subcommand.' + ) + break + } + }) +} diff --git a/apps/showcase-bot/src/listeners/index.ts b/apps/showcase-bot/src/listeners/index.ts new file mode 100644 index 0000000..03b189c --- /dev/null +++ b/apps/showcase-bot/src/listeners/index.ts @@ -0,0 +1,8 @@ +import type { App } from '@slack/bolt' +import * as commands from './commands/showcase' +import * as actions from './actions/showcase-actions' + +export function registerListeners(app: App) { + commands.register(app) + actions.register(app) +} diff --git a/apps/showcase-bot/src/messages/blocks/01-text-and-layout.json b/apps/showcase-bot/src/messages/blocks/01-text-and-layout.json new file mode 100644 index 0000000..c4a87e7 --- /dev/null +++ b/apps/showcase-bot/src/messages/blocks/01-text-and-layout.json @@ -0,0 +1,50 @@ +{ + "fallbackText": "Text & Layout Blocks", + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "Block Kit Showcase", + "emoji": true + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Bold text*, _italic text_, ~strikethrough~, `inline code`, , and a blockquote:\n> This is a blockquote with *formatting*" + } + }, + { + "type": "section", + "fields": [ + { "type": "mrkdwn", "text": "*Field 1*\nLeft column value" }, + { "type": "mrkdwn", "text": "_Field 2_\nRight column value" } + ] + }, + { + "type": "context", + "elements": [ + { + "type": "image", + "image_url": "https://placecats.com/32/32", + "alt_text": "cat avatar" + }, + { + "type": "mrkdwn", + "text": "Posted by *Showcase Bot* | Context block with image and text" + } + ] + }, + { + "type": "divider" + }, + { + "type": "image", + "image_url": "https://placecats.com/300/200", + "alt_text": "A placeholder cat image", + "title": { "type": "plain_text", "text": "Image Block", "emoji": true } + } + ] +} diff --git a/apps/showcase-bot/src/messages/blocks/02-button-variations.json b/apps/showcase-bot/src/messages/blocks/02-button-variations.json new file mode 100644 index 0000000..96766dc --- /dev/null +++ b/apps/showcase-bot/src/messages/blocks/02-button-variations.json @@ -0,0 +1,34 @@ +{ + "fallbackText": "Button Variations", + "blocks": [ + { + "type": "header", + "text": { "type": "plain_text", "text": "Buttons", "emoji": true } + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": { "type": "plain_text", "text": "Primary", "emoji": true }, + "style": "primary", + "action_id": "showcase_button_primary", + "value": "primary_clicked" + }, + { + "type": "button", + "text": { "type": "plain_text", "text": "Danger", "emoji": true }, + "style": "danger", + "action_id": "showcase_button_danger", + "value": "danger_clicked" + }, + { + "type": "button", + "text": { "type": "plain_text", "text": "Default", "emoji": true }, + "action_id": "showcase_button_default", + "value": "default_clicked" + } + ] + } + ] +} diff --git a/apps/showcase-bot/src/messages/blocks/03-selection-elements.json b/apps/showcase-bot/src/messages/blocks/03-selection-elements.json new file mode 100644 index 0000000..b01e816 --- /dev/null +++ b/apps/showcase-bot/src/messages/blocks/03-selection-elements.json @@ -0,0 +1,75 @@ +{ + "fallbackText": "Selection Elements", + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "Selection Elements", + "emoji": true + } + }, + { + "type": "actions", + "elements": [ + { + "type": "static_select", + "placeholder": { + "type": "plain_text", + "text": "Choose an option", + "emoji": true + }, + "action_id": "showcase_static_select", + "options": [ + { + "text": { + "type": "plain_text", + "text": "Option A", + "emoji": true + }, + "value": "option_a" + }, + { + "text": { + "type": "plain_text", + "text": "Option B", + "emoji": true + }, + "value": "option_b" + }, + { + "text": { + "type": "plain_text", + "text": "Option C", + "emoji": true + }, + "value": "option_c" + } + ] + }, + { + "type": "overflow", + "action_id": "showcase_overflow", + "options": [ + { + "text": { "type": "plain_text", "text": "Edit", "emoji": true }, + "value": "edit" + }, + { + "text": { + "type": "plain_text", + "text": "Archive", + "emoji": true + }, + "value": "archive" + }, + { + "text": { "type": "plain_text", "text": "Delete", "emoji": true }, + "value": "delete" + } + ] + } + ] + } + ] +} diff --git a/apps/showcase-bot/src/messages/blocks/04-radio-and-checkboxes.json b/apps/showcase-bot/src/messages/blocks/04-radio-and-checkboxes.json new file mode 100644 index 0000000..0b1ab02 --- /dev/null +++ b/apps/showcase-bot/src/messages/blocks/04-radio-and-checkboxes.json @@ -0,0 +1,122 @@ +{ + "fallbackText": "Radio Buttons & Checkboxes", + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "Radio Buttons & Checkboxes", + "emoji": true + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Size Selection* (radio buttons accessory)" + }, + "accessory": { + "type": "radio_buttons", + "action_id": "showcase_radio_size", + "options": [ + { + "text": { "type": "plain_text", "text": "Small", "emoji": true }, + "description": { "type": "plain_text", "text": "Compact layout" }, + "value": "small" + }, + { + "text": { "type": "plain_text", "text": "Medium", "emoji": true }, + "description": { "type": "plain_text", "text": "Standard layout" }, + "value": "medium" + }, + { + "text": { "type": "plain_text", "text": "Large", "emoji": true }, + "description": { "type": "plain_text", "text": "Expanded layout" }, + "value": "large" + } + ] + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Notification Preferences* (checkboxes accessory)" + }, + "accessory": { + "type": "checkboxes", + "action_id": "showcase_checkbox_notifications", + "options": [ + { + "text": { "type": "plain_text", "text": "Email", "emoji": true }, + "description": { "type": "plain_text", "text": "Daily digest" }, + "value": "email" + }, + { + "text": { "type": "plain_text", "text": "SMS", "emoji": true }, + "description": { "type": "plain_text", "text": "Urgent only" }, + "value": "sms" + }, + { + "text": { "type": "plain_text", "text": "Push", "emoji": true }, + "description": { "type": "plain_text", "text": "Real-time alerts" }, + "value": "push" + } + ] + } + }, + { + "type": "actions", + "elements": [ + { + "type": "radio_buttons", + "action_id": "showcase_radio_priority", + "options": [ + { + "text": { "type": "plain_text", "text": "Low", "emoji": true }, + "value": "low" + }, + { + "text": { "type": "plain_text", "text": "Medium", "emoji": true }, + "value": "medium" + }, + { + "text": { "type": "plain_text", "text": "High", "emoji": true }, + "value": "high" + } + ] + }, + { + "type": "checkboxes", + "action_id": "showcase_checkbox_features", + "options": [ + { + "text": { + "type": "plain_text", + "text": "Dark mode", + "emoji": true + }, + "value": "dark_mode" + }, + { + "text": { + "type": "plain_text", + "text": "Notifications", + "emoji": true + }, + "value": "notifications" + }, + { + "text": { + "type": "plain_text", + "text": "Auto-save", + "emoji": true + }, + "value": "auto_save" + } + ] + } + ] + } + ] +} diff --git a/apps/showcase-bot/src/messages/blocks/05-date-and-time-pickers.json b/apps/showcase-bot/src/messages/blocks/05-date-and-time-pickers.json new file mode 100644 index 0000000..8efb30a --- /dev/null +++ b/apps/showcase-bot/src/messages/blocks/05-date-and-time-pickers.json @@ -0,0 +1,77 @@ +{ + "fallbackText": "Date & Time Pickers", + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "Date & Time Pickers", + "emoji": true + } + }, + { + "type": "actions", + "elements": [ + { + "type": "datepicker", + "action_id": "showcase_datepicker", + "initial_date": "{{TODAY_DATE}}", + "placeholder": { + "type": "plain_text", + "text": "Select a date", + "emoji": true + } + }, + { + "type": "timepicker", + "action_id": "showcase_timepicker", + "initial_time": "09:00", + "placeholder": { + "type": "plain_text", + "text": "Select a time", + "emoji": true + } + }, + { + "type": "datetimepicker", + "action_id": "showcase_datetimepicker", + "initial_date_time": "{{TODAY_NOON_UNIX}}" + } + ] + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Delivery Date* (datepicker accessory)" + }, + "accessory": { + "type": "datepicker", + "action_id": "showcase_datepicker_accessory", + "initial_date": "{{TODAY_DATE}}", + "placeholder": { + "type": "plain_text", + "text": "Select a delivery date", + "emoji": true + } + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Meeting Time* (timepicker accessory)" + }, + "accessory": { + "type": "timepicker", + "action_id": "showcase_timepicker_accessory", + "initial_time": "14:30", + "placeholder": { + "type": "plain_text", + "text": "Choose a meeting time", + "emoji": true + } + } + } + ] +} diff --git a/apps/showcase-bot/src/messages/blocks/06-section-accessories.json b/apps/showcase-bot/src/messages/blocks/06-section-accessories.json new file mode 100644 index 0000000..8161675 --- /dev/null +++ b/apps/showcase-bot/src/messages/blocks/06-section-accessories.json @@ -0,0 +1,85 @@ +{ + "fallbackText": "Section Accessories", + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "Section Accessories", + "emoji": true + } + }, + { + "type": "section", + "text": { "type": "mrkdwn", "text": "Click the action button" }, + "accessory": { + "type": "button", + "text": { "type": "plain_text", "text": "Action", "emoji": true }, + "action_id": "showcase_section_button", + "value": "section_button_clicked" + } + }, + { + "type": "section", + "text": { "type": "mrkdwn", "text": "Choose a priority" }, + "accessory": { + "type": "static_select", + "action_id": "showcase_section_select", + "placeholder": { + "type": "plain_text", + "text": "Select priority", + "emoji": true + }, + "options": [ + { + "text": { "type": "plain_text", "text": "Low", "emoji": true }, + "value": "low" + }, + { + "text": { "type": "plain_text", "text": "Medium", "emoji": true }, + "value": "medium" + }, + { + "text": { "type": "plain_text", "text": "High", "emoji": true }, + "value": "high" + }, + { + "text": { "type": "plain_text", "text": "Critical", "emoji": true }, + "value": "critical" + } + ] + } + }, + { + "type": "section", + "text": { "type": "mrkdwn", "text": "More options available" }, + "accessory": { + "type": "overflow", + "action_id": "showcase_section_overflow", + "options": [ + { + "text": { "type": "plain_text", "text": "Settings", "emoji": true }, + "value": "settings" + }, + { + "text": { "type": "plain_text", "text": "Help", "emoji": true }, + "value": "help" + }, + { + "text": { "type": "plain_text", "text": "About", "emoji": true }, + "value": "about" + } + ] + } + }, + { + "type": "section", + "text": { "type": "mrkdwn", "text": "Section with an image accessory" }, + "accessory": { + "type": "image", + "image_url": "https://placecats.com/128/128", + "alt_text": "A cute cat" + } + } + ] +} diff --git a/apps/showcase-bot/src/messages/blocks/07-combined-actions.json b/apps/showcase-bot/src/messages/blocks/07-combined-actions.json new file mode 100644 index 0000000..741ecda --- /dev/null +++ b/apps/showcase-bot/src/messages/blocks/07-combined-actions.json @@ -0,0 +1,76 @@ +{ + "fallbackText": "Combined Actions", + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "Combined Actions", + "emoji": true + } + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": { "type": "plain_text", "text": "Submit", "emoji": true }, + "style": "primary", + "action_id": "showcase_combined_button", + "value": "submit" + }, + { + "type": "static_select", + "action_id": "showcase_combined_select", + "placeholder": { + "type": "plain_text", + "text": "Pick one", + "emoji": true + }, + "options": [ + { + "text": { "type": "plain_text", "text": "Alpha", "emoji": true }, + "value": "alpha" + }, + { + "text": { "type": "plain_text", "text": "Beta", "emoji": true }, + "value": "beta" + }, + { + "text": { "type": "plain_text", "text": "Gamma", "emoji": true }, + "value": "gamma" + } + ] + }, + { + "type": "datepicker", + "action_id": "showcase_combined_datepicker", + "initial_date": "{{TODAY_DATE}}", + "placeholder": { + "type": "plain_text", + "text": "Pick a date", + "emoji": true + } + }, + { + "type": "overflow", + "action_id": "showcase_combined_overflow", + "options": [ + { + "text": { "type": "plain_text", "text": "Export", "emoji": true }, + "value": "export" + }, + { + "text": { "type": "plain_text", "text": "Print", "emoji": true }, + "value": "print" + }, + { + "text": { "type": "plain_text", "text": "Share", "emoji": true }, + "value": "share" + } + ] + } + ] + } + ] +} diff --git a/apps/showcase-bot/src/messages/blocks/08-rich-text.json b/apps/showcase-bot/src/messages/blocks/08-rich-text.json new file mode 100644 index 0000000..907de36 --- /dev/null +++ b/apps/showcase-bot/src/messages/blocks/08-rich-text.json @@ -0,0 +1,171 @@ +{ + "blocks": [ + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Check out these different block types with paragraph breaks between them:\n\n" + } + ] + }, + { + "type": "rich_text_preformatted", + "elements": [ + { + "type": "text", + "text": "Hello there, I am preformatted block!\n\nI can have multiple paragraph breaks within the block." + } + ] + }, + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "\nI am rich text with a paragraph break following preformatted text. \n\nI can have multiple paragraph breaks within the block.\n\n" + } + ] + }, + { + "type": "rich_text_quote", + "elements": [ + { + "type": "text", + "text": "I am a basic rich text quote, \n\nI can have multiple paragraph breaks within the block." + } + ] + }, + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "\nI am rich text with a paragraph after the quote block\n\n" + } + ] + }, + { + "type": "rich_text_quote", + "elements": [ + { + "type": "text", + "text": "I am a basic quote block following rich text" + } + ] + }, + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "\n" + } + ] + }, + { + "type": "rich_text_preformatted", + "elements": [ + { + "type": "text", + "text": "I am more preformatted text following a quote block" + } + ] + }, + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "\n" + } + ] + }, + { + "type": "rich_text_quote", + "elements": [ + { + "type": "text", + "text": "I am a basic quote block following preformatted text" + } + ] + }, + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "\n" + } + ] + }, + { + "type": "rich_text_list", + "style": "bullet", + "indent": 0, + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "list item one" + } + ] + }, + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "list item two" + } + ] + } + ] + }, + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "\nI am rich text with a paragraph break after a list" + } + ] + } + ] + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": "*This* is :smile: markdown" + }, + { + "type": "image", + "image_url": "https://pbs.twimg.com/profile_images/625633822235693056/lNGUneLX_400x400.jpg", + "alt_text": "cute cat" + }, + { + "type": "image", + "image_url": "https://pbs.twimg.com/profile_images/625633822235693056/lNGUneLX_400x400.jpg", + "alt_text": "cute cat" + }, + { + "type": "image", + "image_url": "https://pbs.twimg.com/profile_images/625633822235693056/lNGUneLX_400x400.jpg", + "alt_text": "cute cat" + }, + { + "type": "plain_text", + "text": "Author: K A Applegate", + "emoji": true + } + ] + } + ] +} diff --git a/apps/showcase-bot/src/messages/blocks/09-template-newsletter.json b/apps/showcase-bot/src/messages/blocks/09-template-newsletter.json new file mode 100644 index 0000000..9959349 --- /dev/null +++ b/apps/showcase-bot/src/messages/blocks/09-template-newsletter.json @@ -0,0 +1,177 @@ +{ + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": ":newspaper: Paper Company Newsletter :newspaper:" + } + }, + { + "type": "context", + "elements": [ + { + "text": "*November 12, 2019* | Sales Team Announcements", + "type": "mrkdwn" + } + ] + }, + { + "type": "divider" + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": " :loud_sound: *IN CASE YOU MISSED IT* :loud_sound:" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Replay our screening of *Threat Level Midnight* and pick up a copy of the DVD to give to your customers at the front desk." + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "text": "Watch Now", + "emoji": true + }, + "action_id": "showcase_newsletter_watch" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "The *2019 Dundies* happened. \nAwards were given, heroes were recognized. \nCheck out *#dundies-2019* to see who won awards." + } + }, + { + "type": "divider" + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":calendar: | *UPCOMING EVENTS* | :calendar: " + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "`11/20-11/22` *Beet the Competition* _ annual retreat at Schrute Farms_" + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "text": "RSVP", + "emoji": true + }, + "action_id": "showcase_newsletter_rsvp_retreat" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "`12/01` *Toby's Going Away Party* at _Benihana_" + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "text": "Learn More", + "emoji": true + }, + "action_id": "showcase_newsletter_learn_more" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "`11/13` :pretzel: *Pretzel Day* :pretzel: at _Scranton Office_" + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "text": "RSVP", + "emoji": true + }, + "action_id": "showcase_newsletter_rsvp_pretzel" + } + }, + { + "type": "divider" + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":calendar: | *PAST EVENTS* | :calendar: " + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "`10/21` *Conference Room Meeting*" + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "text": "Watch Recording", + "emoji": true + }, + "action_id": "showcase_newsletter_recording" + } + }, + { + "type": "divider" + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*FOR YOUR INFORMATION*" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":printer: *Sabre Printers* are no longer catching on fire! The newest version of our printers are safe to use. Make sure to tell your customers today.", + "verbatim": false + } + }, + { + "type": "divider" + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Please join me in welcoming our 3 *new hires* to the Paper Company family! \n\n *Robert California*, CEO \n\n *Ryan Howard*, Temp \n\n *Erin Hannon*, Receptionist " + } + }, + { + "type": "divider" + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": ":pushpin: Do you have something to include in the newsletter? Here's *how to submit content*." + } + ] + } + ] +} diff --git a/apps/showcase-bot/src/messages/blocks/10-kitchen-sink.json b/apps/showcase-bot/src/messages/blocks/10-kitchen-sink.json new file mode 100644 index 0000000..cc76e2c --- /dev/null +++ b/apps/showcase-bot/src/messages/blocks/10-kitchen-sink.json @@ -0,0 +1,485 @@ +{ + "blocks": [ + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Hello there, I am a basic rich text block!" + } + ] + } + ] + }, + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Hello there, " + }, + { + "type": "text", + "text": "I am a bold rich text block!", + "style": { + "bold": true + } + } + ] + } + ] + }, + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Hello there, " + }, + { + "type": "text", + "text": "I am a strikethrough rich text block!", + "style": { + "strike": true + } + } + ] + } + ] + }, + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "emoji", + "name": "basketball" + }, + { + "type": "text", + "text": " " + }, + { + "type": "emoji", + "name": "snowboarder" + }, + { + "type": "text", + "text": " " + }, + { + "type": "emoji", + "name": "checkered_flag" + } + ] + } + ] + }, + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Basic bullet list with rich elements\n" + } + ] + }, + { + "type": "rich_text_list", + "style": "bullet", + "indent": 0, + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "item 1: " + }, + { + "type": "emoji", + "name": "basketball" + } + ] + }, + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "item 2: " + }, + { + "type": "text", + "text": "this is a list item" + } + ] + }, + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "item 3: " + }, + { + "type": "link", + "url": "https://example.com/", + "text": "with a link", + "style": { + "bold": true + } + } + ] + }, + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "item 4: " + }, + { + "type": "text", + "text": "we are near the end" + } + ] + }, + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "item 5: " + }, + { + "type": "text", + "text": "this is the end" + } + ] + } + ] + } + ] + }, + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Check out these different block types with paragraph breaks between them:\n\n" + } + ] + }, + { + "type": "rich_text_preformatted", + "elements": [ + { + "type": "text", + "text": "Hello there, I am preformatted block!\n\nI can have multiple paragraph breaks within the block." + } + ] + }, + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "\nI am rich text with a paragraph break following preformatted text. \n\nI can have multiple paragraph breaks within the block.\n\n" + } + ] + }, + { + "type": "rich_text_quote", + "elements": [ + { + "type": "text", + "text": "I am a basic rich text quote, \n\nI can have multiple paragraph breaks within the block." + } + ] + }, + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "\nI am rich text with a paragraph after the quote block\n\n" + } + ] + }, + { + "type": "rich_text_quote", + "elements": [ + { + "type": "text", + "text": "I am a basic quote block following rich text" + } + ] + }, + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "\n" + } + ] + }, + { + "type": "rich_text_preformatted", + "elements": [ + { + "type": "text", + "text": "I am more preformatted text following a quote block" + } + ] + }, + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "\n" + } + ] + }, + { + "type": "rich_text_quote", + "elements": [ + { + "type": "text", + "text": "I am a basic quote block following preformatted text" + } + ] + }, + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "\n" + } + ] + }, + { + "type": "rich_text_list", + "style": "bullet", + "indent": 0, + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "list item one" + } + ] + }, + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "list item two" + } + ] + } + ] + }, + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "\nI am rich text with a paragraph break after a list" + } + ] + } + ] + }, + { + "type": "context_actions", + "elements": [ + { + "type": "feedback_buttons", + "action_id": "showcase_feedback", + "positive_button": { + "text": { + "type": "plain_text", + "text": "Good Response" + }, + "value": "positive" + }, + "negative_button": { + "text": { + "type": "plain_text", + "text": "Bad Response" + }, + "value": "negative" + } + }, + { + "type": "icon_button", + "action_id": "showcase_remove", + "icon": "trash", + "text": { + "type": "plain_text", + "text": "Remove" + } + } + ] + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "text": "Click Me", + "emoji": true + }, + "value": "click_me_123", + "action_id": "showcase_kitchen_sink_0" + } + ] + }, + { + "type": "actions", + "elements": [ + { + "type": "conversations_select", + "placeholder": { + "type": "plain_text", + "text": "Select a conversation", + "emoji": true + }, + "initial_conversation": "G12345678", + "action_id": "showcase_kitchen_sink_convo_0" + }, + { + "type": "users_select", + "placeholder": { + "type": "plain_text", + "text": "Select a user", + "emoji": true + }, + "initial_user": "U12345678", + "action_id": "showcase_kitchen_sink_1" + }, + { + "type": "channels_select", + "placeholder": { + "type": "plain_text", + "text": "Select a channel", + "emoji": true + }, + "initial_channel": "C12345678", + "action_id": "showcase_kitchen_sink_2" + } + ] + }, + { + "type": "actions", + "elements": [ + { + "type": "external_select", + "placeholder": { + "type": "plain_text", + "text": "Search external data", + "emoji": true + }, + "action_id": "actionId-4" + }, + { + "type": "multi_users_select", + "placeholder": { + "type": "plain_text", + "text": "Select users", + "emoji": true + }, + "action_id": "actionId-5" + }, + { + "type": "multi_conversations_select", + "placeholder": { + "type": "plain_text", + "text": "Select conversations", + "emoji": true + }, + "action_id": "actionId-6" + }, + { + "type": "multi_channels_select", + "placeholder": { + "type": "plain_text", + "text": "Select channels", + "emoji": true + }, + "action_id": "actionId-7" + }, + { + "type": "multi_external_select", + "placeholder": { + "type": "plain_text", + "text": "Search external items", + "emoji": true + }, + "action_id": "actionId-8" + }, + { + "type": "static_select", + "placeholder": { + "type": "plain_text", + "text": "Select an item", + "emoji": true + }, + "options": [ + { + "text": { + "type": "plain_text", + "text": "*plain_text option 0*", + "emoji": true + }, + "value": "value-0" + }, + { + "text": { + "type": "plain_text", + "text": "*plain_text option 1*", + "emoji": true + }, + "value": "value-1" + }, + { + "text": { + "type": "plain_text", + "text": "*plain_text option 2*", + "emoji": true + }, + "value": "value-2" + } + ], + "action_id": "showcase_kitchen_sink_3" + } + ] + } + ] +} diff --git a/apps/showcase-bot/src/messages/blocks/11-images.json b/apps/showcase-bot/src/messages/blocks/11-images.json new file mode 100644 index 0000000..c563f7b --- /dev/null +++ b/apps/showcase-bot/src/messages/blocks/11-images.json @@ -0,0 +1,14 @@ +{ + "blocks": [ + { + "type": "image", + "title": { + "type": "plain_text", + "text": "I love tacos", + "emoji": true + }, + "image_url": "https://assets3.thrillist.com/v1/image/1682388/size/tl-horizontal_main.jpg", + "alt_text": "delicious tacos" + } + ] +} diff --git a/apps/showcase-bot/src/messages/blocks/12-tables.json b/apps/showcase-bot/src/messages/blocks/12-tables.json new file mode 100644 index 0000000..f7aa20b --- /dev/null +++ b/apps/showcase-bot/src/messages/blocks/12-tables.json @@ -0,0 +1,247 @@ +{ + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Tables*" + } + }, + { + "type": "table", + "column_settings": [ + { "align": "left", "is_wrapped": true }, + { "align": "center" }, + { "align": "right" } + ], + "rows": [ + [ + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Feature", + "style": { "bold": true } + } + ] + } + ] + }, + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Status", + "style": { "bold": true } + } + ] + } + ] + }, + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Priority", + "style": { "bold": true } + } + ] + } + ] + } + ], + [ + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Authentication" + } + ] + } + ] + }, + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Complete", + "style": { "bold": true } + } + ] + } + ] + }, + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "High" + } + ] + } + ] + } + ], + [ + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Dashboard" + } + ] + } + ] + }, + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "In Progress", + "style": { "italic": true } + } + ] + } + ] + }, + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Medium" + } + ] + } + ] + } + ], + [ + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "API v2" + } + ] + } + ] + }, + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Planned" + } + ] + } + ] + }, + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Low" + } + ] + } + ] + } + ], + [ + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Mobile App" + } + ] + } + ] + }, + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Blocked", + "style": { "bold": true } + } + ] + } + ] + }, + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "High" + } + ] + } + ] + } + ] + ] + } + ] +} diff --git a/apps/showcase-bot/src/messages/showcase-messages.ts b/apps/showcase-bot/src/messages/showcase-messages.ts new file mode 100644 index 0000000..f095daa --- /dev/null +++ b/apps/showcase-bot/src/messages/showcase-messages.ts @@ -0,0 +1,33 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import type { KnownBlock } from '@slack/types' + +export interface ShowcaseMessage { + fallbackText: string + blocks: KnownBlock[] +} + +function resolveTemplates(raw: string): string { + const today = new Date() + const yyyy = today.getFullYear() + const mm = String(today.getMonth() + 1).padStart(2, '0') + const dd = String(today.getDate()).padStart(2, '0') + const todayDate = `${yyyy}-${mm}-${dd}` + + const noonUtc = new Date(`${todayDate}T12:00:00Z`) + const noonUnix = Math.floor(noonUtc.getTime() / 1000) + + return raw + .replaceAll('{{TODAY_DATE}}', todayDate) + .replaceAll('"{{TODAY_NOON_UNIX}}"', String(noonUnix)) +} + +const blocksDir = join(import.meta.dir, 'blocks') +const files = readdirSync(blocksDir) + .filter((f) => f.endsWith('.json')) + .sort() + +export const showcaseMessages: ShowcaseMessage[] = files.map((file) => { + const raw = readFileSync(join(blocksDir, file), 'utf-8') + return JSON.parse(resolveTemplates(raw)) +}) diff --git a/apps/showcase-bot/src/settings.ts b/apps/showcase-bot/src/settings.ts new file mode 100644 index 0000000..4a59147 --- /dev/null +++ b/apps/showcase-bot/src/settings.ts @@ -0,0 +1,47 @@ +import { z } from 'zod' + +// Local simulator mode (SLACK_API_URL set) +export const isSimulatorMode = Boolean(process.env.SLACK_API_URL) +const isLocalMode = isSimulatorMode + +const envSchema = z.object({ + // Bot Configuration + BOT_NAME: z.string().default('Showcase Bot'), + + // Slack Configuration (optional in local simulator mode) + SLACK_BOT_TOKEN: isLocalMode + ? z.string().default('xoxb-local') + : z.string().startsWith('xoxb-'), + SLACK_APP_TOKEN: isLocalMode + ? z.string().default('xapp-local') + : z.string().startsWith('xapp-'), + SLACK_SIGNING_SECRET: isLocalMode + ? z.string().default('local') + : z.string().min(1), + + // Server + PORT: z.coerce.number().default(3000), + LOG_LEVEL: z + .enum(['silent', 'debug', 'info', 'warn', 'error']) + .default('info'), +}) + +export type Settings = z.infer + +function loadSettings(): Settings { + const result = envSchema.safeParse(process.env) + + if (!result.success) { + console.error('Invalid environment configuration:') + for (const issue of result.error.issues) { + console.error(` ${issue.path.join('.')}: ${issue.message}`) + } + throw new Error( + 'Failed to load settings. Check your environment variables.' + ) + } + + return result.data +} + +export const settings = loadSettings() diff --git a/apps/showcase-bot/src/utils/logger.ts b/apps/showcase-bot/src/utils/logger.ts new file mode 100644 index 0000000..86684d8 --- /dev/null +++ b/apps/showcase-bot/src/utils/logger.ts @@ -0,0 +1,23 @@ +import pino from 'pino' +import { settings, isSimulatorMode } from '../settings' + +// Use JSON output in simulator mode (so Electron can parse logs) +// Use pretty output in local dev mode (when running standalone) +const usePretty = + process.env.NODE_ENV !== 'production' && + !isSimulatorMode && + process.env.TERM_PROGRAM + +export const logger = pino({ + level: settings.LOG_LEVEL, + transport: usePretty ? { target: 'pino-pretty' } : undefined, +}) + +export type ModuleName = 'App' | 'Slack' + +export function createLogger(module: ModuleName): pino.Logger { + return logger.child({ module }) +} + +export const appLogger = createLogger('App') +export const slackLogger = createLogger('Slack') diff --git a/apps/showcase-bot/tsconfig.json b/apps/showcase-bot/tsconfig.json new file mode 100644 index 0000000..1271646 --- /dev/null +++ b/apps/showcase-bot/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "allowJs": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*", "*.config.ts"], + "exclude": ["node_modules"] +} diff --git a/apps/ui/package.json b/apps/ui/package.json index eb91465..851c9e9 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -12,12 +12,12 @@ "typecheck": "svelte-check --tsconfig ./tsconfig.json" }, "dependencies": { + "@botarium/mrkdwn": "workspace:*", "@lucide/svelte": "^0.561.0", "@types/dompurify": "^3.2.0", "clsx": "^2.1.1", "dompurify": "^3.3.1", "paneforge": "^1.0.2", - "slack-markdown": "^0.3.0", "tailwind-merge": "^3.4.0", "tailwind-variants": "^3.2.2", "tw-animate-css": "^1.4.0" diff --git a/apps/ui/src/app.css b/apps/ui/src/app.css index f43d722..ed068a3 100644 --- a/apps/ui/src/app.css +++ b/apps/ui/src/app.css @@ -253,6 +253,19 @@ -webkit-app-region: no-drag; } +/* Channel header tab */ +@utility channel-tab { + @apply flex items-center gap-1.5 text-[13px] font-semibold px-3 pt-1.5 pb-2 border-b-2 border-transparent text-slack-text-muted cursor-pointer rounded-t transition-colors no-drag; + + &:hover { + @apply bg-slack-hover; + } + + &[data-active='true'] { + @apply text-slack-text border-white; + } +} + /* Panel tab button */ @utility panel-tab { @apply bg-transparent border-none px-2 py-1 text-base font-bold text-(--text-muted) cursor-pointer rounded transition-colors no-drag; @@ -295,3 +308,141 @@ } } } + +/* Mrkdwn rendered text styles (shared across messages, blocks, modals) */ +.mrkdwn .c-mrkdwn__br { + display: block; + height: 8px; +} + +.mrkdwn > :is(pre, blockquote, ul, ol) { + margin: 8px 0; +} + +.mrkdwn > :is(pre, blockquote, ul, ol):first-child { + margin-top: 0; +} + +.mrkdwn > :is(pre, blockquote, ul, ol):last-child { + margin-bottom: 0; +} + +.mrkdwn code { + background: #8881; + border: 1px solid #8883; + border-radius: 3px; + padding: 2px 4px; + font-family: var(--font-mono); + font-size: 12px; + color: #e6902c; +} + +.mrkdwn pre { + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 4px; + padding: 8px 12px; + overflow-x: auto; + white-space: pre; +} + +.mrkdwn pre code { + background: none; + border: none; + padding: 0; + color: var(--text-primary); +} + +.mrkdwn a { + color: #1d9bd1; + text-decoration: none; +} + +.mrkdwn a:hover { + text-decoration: underline; +} + +.mrkdwn blockquote { + border-left: 4px solid #ddd; + padding-left: 12px; + color: var(--text-secondary); +} + +.mrkdwn ul, +.mrkdwn ol { + padding-left: 24px; +} + +.mrkdwn ul { + list-style-type: disc; +} + +.mrkdwn ol { + list-style-type: decimal; +} + +.mrkdwn li { + margin: 2px 0; +} + +.mrkdwn .s-mention { + background: rgba(232, 171, 76, 0.2); + color: #e8ab4c; + padding: 0 2px; + border-radius: 3px; +} + +.mrkdwn .s-emoji { + font-style: normal; + position: relative; + cursor: default; +} + +.mrkdwn .s-emoji-tip { + visibility: hidden; + opacity: 0; + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + background: #1a1d21; + border: 1px solid #38393d; + border-radius: 8px; + padding: 8px 12px; + white-space: nowrap; + pointer-events: none; + user-select: none; + transition: + visibility 0.1s, + opacity 0.1s; + z-index: 10; +} + +.mrkdwn .s-emoji-tip::after { + content: ''; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + border: 5px solid transparent; + border-top-color: #38393d; +} + +.mrkdwn .s-emoji-big { + font-size: 48px; + line-height: 1.2; +} + +.mrkdwn .s-emoji-code { + font-size: 12px; + color: #ababad; +} + +.mrkdwn .s-emoji:hover .s-emoji-tip { + visibility: visible; + opacity: 1; +} diff --git a/apps/ui/src/components/App.svelte b/apps/ui/src/components/App.svelte index 83ba0a8..4be6550 100644 --- a/apps/ui/src/components/App.svelte +++ b/apps/ui/src/components/App.svelte @@ -8,6 +8,7 @@ loadCommands, loadAppConfig, loadConnectedBots, + loadChannels, } from '../lib/dispatcher.svelte' import { isElectron, getElectronAPI } from '../lib/electron-api' import { @@ -22,7 +23,7 @@ restoreMessages, simulatorState, } from '../lib/state.svelte' - import { CHANNELS } from '../lib/types' + import type { Channel } from '../lib/types' import InputBar from './InputBar.svelte' import LoadingSpinner from './LoadingSpinner.svelte' import LogPanel from './LogPanel.svelte' @@ -45,15 +46,22 @@ let logPanelVisible = $state(false) let rhsTab = $state<'thread' | 'logs'>('thread') let mainInputValue = $state('') - let previewImage = $state<{ url: string; alt: string } | null>(null) + let previewImage = $state<{ + url: string + alt: string + userName?: string + isBot?: boolean + timestamp?: string + channelName?: string + } | null>(null) // Derive active thread from centralized state (synced with URL) let activeThreadTs = $derived(simulatorState.currentThreadTs) // Check if input should be disabled (bot DM channel and all bots disconnected) let isBotInputDisabled = $derived(() => { - const currentChannel = CHANNELS.find( - (c) => c.id === simulatorState.currentChannel + const currentChannel = simulatorState.channels.find( + (c: Channel) => c.id === simulatorState.currentChannel ) if (currentChannel?.type !== 'dm') return false return ( @@ -129,9 +137,14 @@ } simulatorState.messagesLoaded = true - // Load available slash commands, app config, and connected bots from emulator + // Load available slash commands, app config, connected bots, and channels from emulator // These will be populated after bot registers them - await Promise.all([loadCommands(), loadAppConfig(), loadConnectedBots()]) + await Promise.all([ + loadCommands(), + loadAppConfig(), + loadConnectedBots(), + loadChannels(), + ]) } async function handleSend(text: string) { @@ -176,8 +189,15 @@ rhsTab = tabId as 'thread' | 'logs' } - function handleImagePreview(url: string, alt: string) { - previewImage = { url, alt } + function handleImagePreview( + url: string, + alt: string, + userName?: string, + isBot?: boolean, + timestamp?: string, + channelName?: string + ) { + previewImage = { url, alt, userName, isBot, timestamp, channelName } } function handleCloseImagePreview() { @@ -320,6 +340,10 @@ {/if} diff --git a/apps/ui/src/components/BotAboutHeader.svelte b/apps/ui/src/components/BotAboutHeader.svelte new file mode 100644 index 0000000..3e840b0 --- /dev/null +++ b/apps/ui/src/components/BotAboutHeader.svelte @@ -0,0 +1,45 @@ + + +
+
+ + + +
+
+ + {bot.name} + + + app + + {#if bot.status === 'connected'} + + {/if} +
+ {#if bot.description} +

+ {bot.description} +

+ {/if} +
+
+

+ This is the very beginning of your direct message history with @{bot.name}. +

+
diff --git a/apps/ui/src/components/CreateChannelModal.svelte b/apps/ui/src/components/CreateChannelModal.svelte new file mode 100644 index 0000000..d251ff5 --- /dev/null +++ b/apps/ui/src/components/CreateChannelModal.svelte @@ -0,0 +1,125 @@ + + + + + + diff --git a/apps/ui/src/components/DaySeparator.svelte b/apps/ui/src/components/DaySeparator.svelte new file mode 100644 index 0000000..d8a68be --- /dev/null +++ b/apps/ui/src/components/DaySeparator.svelte @@ -0,0 +1,17 @@ + + +
+
+ + {label} + +
+
diff --git a/apps/ui/src/components/ImagePreviewModal.svelte b/apps/ui/src/components/ImagePreviewModal.svelte index 16315c9..b0b5819 100644 --- a/apps/ui/src/components/ImagePreviewModal.svelte +++ b/apps/ui/src/components/ImagePreviewModal.svelte @@ -1,5 +1,5 @@ + + + +{#if showCreateModal} + (showCreateModal = false)} + onCreate={handleCreateChannel} + /> +{/if} + + +{#if contextMenu} +
+ +
+{/if} diff --git a/apps/ui/src/components/ThreadPanel.svelte b/apps/ui/src/components/ThreadPanel.svelte index de06dbc..9d068a2 100644 --- a/apps/ui/src/components/ThreadPanel.svelte +++ b/apps/ui/src/components/ThreadPanel.svelte @@ -21,7 +21,14 @@ interface Props { threadTs: string mockApp: MockApp - onImagePreview?: (imageUrl: string, imageAlt: string) => void + onImagePreview?: ( + imageUrl: string, + imageAlt: string, + userName?: string, + isBot?: boolean, + timestamp?: string, + channelName?: string + ) => void } let { threadTs, mockApp, onImagePreview }: Props = $props() diff --git a/apps/ui/src/components/blockkit/BlockKitRenderer.svelte b/apps/ui/src/components/blockkit/BlockKitRenderer.svelte index 0e46e0d..1b3887b 100644 --- a/apps/ui/src/components/blockkit/BlockKitRenderer.svelte +++ b/apps/ui/src/components/blockkit/BlockKitRenderer.svelte @@ -5,8 +5,11 @@ SlackInputBlock, SlackActionsBlock, SlackContextBlock, + SlackContextActionsBlock, SlackImageBlock, SlackHeaderBlock, + SlackRichTextBlock, + SlackTableBlock, SlackOption, UploadedFile, } from '../../lib/types' @@ -20,6 +23,9 @@ import ContextBlock from './blocks/ContextBlock.svelte' import ImageBlock from './blocks/ImageBlock.svelte' import HeaderBlock from './blocks/HeaderBlock.svelte' + import RichTextBlock from './blocks/RichTextBlock.svelte' + import TableBlock from './blocks/TableBlock.svelte' + import ContextActionsBlock from './blocks/ContextActionsBlock.svelte' interface Props { blocks: SlackBlock[] @@ -37,6 +43,12 @@ actionId: string, selectedOptions: SlackOption[] ) => void + onRadioChange?: ( + blockId: string, + actionId: string, + option: SlackOption + ) => void + onImagePreview?: (imageUrl: string, imageAlt: string) => void } let { @@ -47,6 +59,8 @@ onInputChange, onFileChange, onCheckboxChange, + onRadioChange, + onImagePreview, }: Props = $props() function getBlockId(block: SlackBlock, index: number): string { @@ -54,7 +68,7 @@ } -
+
{#each blocks as block, index (getBlockId(block, index))} {#if block.type === 'section'} @@ -67,6 +81,7 @@ {onInputChange} {onFileChange} {onCheckboxChange} + {onRadioChange} /> {:else if block.type === 'actions'} @@ -75,9 +90,18 @@ {:else if block.type === 'context'} {:else if block.type === 'image'} - + {:else if block.type === 'header'} + {:else if block.type === 'rich_text'} + + {:else if block.type === 'table'} + + {:else if block.type === 'context_actions'} + {:else} {@const unknownBlock = block as { type: string }} diff --git a/apps/ui/src/components/blockkit/blocks/ActionsBlock.svelte b/apps/ui/src/components/blockkit/blocks/ActionsBlock.svelte index b24a0df..4cb7380 100644 --- a/apps/ui/src/components/blockkit/blocks/ActionsBlock.svelte +++ b/apps/ui/src/components/blockkit/blocks/ActionsBlock.svelte @@ -3,9 +3,23 @@ SlackActionsBlock, SlackButtonElement, SlackStaticSelectElement, + SlackOverflowElement, + SlackRadioButtonsElement, + SlackCheckboxesElement, + SlackDatePickerElement, + SlackTimePickerElement, + SlackDateTimePickerElement, + SlackWorkspaceSelectElement, } from '../../../lib/types' import Button from '../elements/Button.svelte' import StaticSelect from '../elements/StaticSelect.svelte' + import WorkspaceSelect from '../elements/WorkspaceSelect.svelte' + import OverflowMenu from '../elements/OverflowMenu.svelte' + import RadioButtonGroup from '../elements/RadioButtonGroup.svelte' + import Checkboxes from '../elements/Checkboxes.svelte' + import DatePicker from '../elements/DatePicker.svelte' + import TimePicker from '../elements/TimePicker.svelte' + import DateTimePicker from '../elements/DateTimePicker.svelte' interface Props { block: SlackActionsBlock @@ -15,7 +29,7 @@ let { block, onAction }: Props = $props() -
+
{#each block.elements as element, i (i)} {#if element.type === 'button'}
diff --git a/apps/ui/src/components/blockkit/blocks/ContextActionsBlock.svelte b/apps/ui/src/components/blockkit/blocks/ContextActionsBlock.svelte new file mode 100644 index 0000000..f3a3938 --- /dev/null +++ b/apps/ui/src/components/blockkit/blocks/ContextActionsBlock.svelte @@ -0,0 +1,79 @@ + + +
+ {#each block.elements as element, i (i)} + {#if element.type === 'feedback_buttons'} + {@const fb = element as SlackFeedbackButtonsElement} + + + {:else if element.type === 'icon_button'} + {@const ib = element as SlackIconButtonElement} + {@const IconComponent = iconMap[ib.icon]} + + {/if} + {/each} +
+ + diff --git a/apps/ui/src/components/blockkit/blocks/ContextBlock.svelte b/apps/ui/src/components/blockkit/blocks/ContextBlock.svelte index 99dd207..dfe1ecf 100644 --- a/apps/ui/src/components/blockkit/blocks/ContextBlock.svelte +++ b/apps/ui/src/components/blockkit/blocks/ContextBlock.svelte @@ -13,10 +13,13 @@ let { block }: Props = $props() -
+
{#each block.elements as el, i (i)} {#if 'text' in el} - {@html renderMrkdwn(el as SlackViewTextObject)} + {@html renderMrkdwn(el as SlackViewTextObject)} {:else if el.type === 'image'} {/if} diff --git a/apps/ui/src/components/blockkit/blocks/DividerBlock.svelte b/apps/ui/src/components/blockkit/blocks/DividerBlock.svelte index 7a495db..961a18e 100644 --- a/apps/ui/src/components/blockkit/blocks/DividerBlock.svelte +++ b/apps/ui/src/components/blockkit/blocks/DividerBlock.svelte @@ -1 +1 @@ -
+
diff --git a/apps/ui/src/components/blockkit/blocks/ImageBlock.svelte b/apps/ui/src/components/blockkit/blocks/ImageBlock.svelte index 875f7c4..67ce498 100644 --- a/apps/ui/src/components/blockkit/blocks/ImageBlock.svelte +++ b/apps/ui/src/components/blockkit/blocks/ImageBlock.svelte @@ -1,20 +1,80 @@ -
- {#if block.title} -

- {renderText(block.title)} -

+
+ + {#if block.title}{renderText(block.title)}{/if} + {#if imageSize}({imageSize}){/if} + + + {#if !collapsed} + {#if onImagePreview} + + {:else} + {block.alt_text} + {/if} {/if} -
diff --git a/apps/ui/src/components/blockkit/blocks/InputBlock.svelte b/apps/ui/src/components/blockkit/blocks/InputBlock.svelte index 07a3d13..55b719b 100644 --- a/apps/ui/src/components/blockkit/blocks/InputBlock.svelte +++ b/apps/ui/src/components/blockkit/blocks/InputBlock.svelte @@ -5,14 +5,30 @@ SlackStaticSelectElement, SlackFileInputElement, SlackCheckboxesElement, + SlackNumberInputElement, + SlackEmailInputElement, + SlackUrlInputElement, + SlackRadioButtonsElement, + SlackDatePickerElement, + SlackTimePickerElement, + SlackDateTimePickerElement, + SlackWorkspaceSelectElement, SlackOption, UploadedFile, } from '../../../lib/types' import { renderText, type FormValues, type FileValues } from '../context' import PlainTextInput from '../elements/PlainTextInput.svelte' import StaticSelect from '../elements/StaticSelect.svelte' + import WorkspaceSelect from '../elements/WorkspaceSelect.svelte' import FileInput from '../elements/FileInput.svelte' import Checkboxes from '../elements/Checkboxes.svelte' + import NumberInput from '../elements/NumberInput.svelte' + import EmailInput from '../elements/EmailInput.svelte' + import UrlInput from '../elements/UrlInput.svelte' + import RadioButtonGroup from '../elements/RadioButtonGroup.svelte' + import DatePicker from '../elements/DatePicker.svelte' + import TimePicker from '../elements/TimePicker.svelte' + import DateTimePicker from '../elements/DateTimePicker.svelte' interface Props { block: SlackInputBlock @@ -30,6 +46,11 @@ actionId: string, selectedOptions: SlackOption[] ) => void + onRadioChange?: ( + blockId: string, + actionId: string, + option: SlackOption + ) => void } let { @@ -40,6 +61,7 @@ onInputChange, onFileChange, onCheckboxChange, + onRadioChange, }: Props = $props() function getInputValue(actionId: string): string { @@ -59,7 +81,7 @@ } -
+
{#if block.element.type !== 'file_input'}