From d522fb172aa368b631aeadf834e3b8c4e3abf77e Mon Sep 17 00:00:00 2001 From: Richard Tan Date: Mon, 12 Jan 2026 18:18:51 +0800 Subject: [PATCH 1/2] Submission bot and refactor bot core feature --- .github/workflows/ci-lint.yaml | 8 + packages/ai-bot/lib/shutdown.ts | 58 ---- packages/ai-bot/lib/signal-handlers.ts | 41 --- packages/ai-bot/main.ts | 116 ++----- packages/ai-bot/package.json | 1 + packages/base/command.gts | 8 + packages/bot-core/.eslintrc.js | 43 +++ packages/bot-core/index.ts | 29 ++ packages/bot-core/lib/matrix-client.ts | 69 ++++ packages/bot-core/lib/membership.ts | 76 +++++ .../queries.ts => bot-core/lib/room-lock.ts} | 40 ++- packages/bot-core/lib/shutdown.ts | 109 ++++++ packages/bot-core/lib/signal-handlers.ts | 62 ++++ packages/bot-core/lib/sliding-sync.ts | 61 ++++ packages/bot-core/package.json | 29 ++ packages/bot-core/tsconfig.json | 31 ++ packages/host/app/components/matrix/room.gts | 13 +- packages/host/app/resources/room.ts | 8 +- packages/host/app/services/matrix-service.ts | 10 + packages/matrix/package.json | 3 +- packages/runtime-common/constants.ts | 1 + packages/submission-bot/.eslintrc.js | 43 +++ packages/submission-bot/README.md | 70 ++++ packages/submission-bot/main.ts | 310 ++++++++++++++++++ packages/submission-bot/package.json | 34 ++ packages/submission-bot/setup-logger.ts | 5 + packages/submission-bot/tsconfig.json | 31 ++ pnpm-lock.yaml | 93 ++++-- 28 files changed, 1192 insertions(+), 210 deletions(-) delete mode 100644 packages/ai-bot/lib/shutdown.ts delete mode 100644 packages/ai-bot/lib/signal-handlers.ts create mode 100644 packages/bot-core/.eslintrc.js create mode 100644 packages/bot-core/index.ts create mode 100644 packages/bot-core/lib/matrix-client.ts create mode 100644 packages/bot-core/lib/membership.ts rename packages/{ai-bot/lib/queries.ts => bot-core/lib/room-lock.ts} (59%) create mode 100644 packages/bot-core/lib/shutdown.ts create mode 100644 packages/bot-core/lib/signal-handlers.ts create mode 100644 packages/bot-core/lib/sliding-sync.ts create mode 100644 packages/bot-core/package.json create mode 100644 packages/bot-core/tsconfig.json create mode 100644 packages/submission-bot/.eslintrc.js create mode 100644 packages/submission-bot/README.md create mode 100644 packages/submission-bot/main.ts create mode 100644 packages/submission-bot/package.json create mode 100644 packages/submission-bot/setup-logger.ts create mode 100644 packages/submission-bot/tsconfig.json diff --git a/.github/workflows/ci-lint.yaml b/.github/workflows/ci-lint.yaml index 1f940eae1a..0c88f6e89a 100644 --- a/.github/workflows/ci-lint.yaml +++ b/.github/workflows/ci-lint.yaml @@ -119,6 +119,14 @@ jobs: if: always() run: pnpm run lint working-directory: packages/ai-bot + - name: Lint Bot Core + if: always() + run: pnpm run lint + working-directory: packages/bot-core + - name: Lint Submission Bot + if: always() + run: pnpm run lint + working-directory: packages/submission-bot - name: Lint Workspace Sync CLI if: always() run: pnpm run lint diff --git a/packages/ai-bot/lib/shutdown.ts b/packages/ai-bot/lib/shutdown.ts deleted file mode 100644 index 5aa15c2ec5..0000000000 --- a/packages/ai-bot/lib/shutdown.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { logger } from '@cardstack/runtime-common'; - -let log = logger('ai-bot:shutdown'); - -let _isShuttingDown = false; - -export function isShuttingDown(): boolean { - return _isShuttingDown; -} - -export function setShuttingDown(value: boolean): void { - _isShuttingDown = value; -} -let activeGenerations: Map = new Map(); - -export function setActiveGenerations(generations: Map) { - activeGenerations = generations; -} - -export async function waitForActiveGenerations(): Promise { - let minutes = 10; - const maxWaitTime = minutes * 60 * 1000; - let waitTime = 0; - - while (activeGenerations.size > 0) { - if (waitTime === 0) { - log.info( - `Waiting for active generations to finish (count: ${activeGenerations.size})...`, - ); - } - - await new Promise((resolve) => setTimeout(resolve, 1000)); - waitTime += 1000; - - if (waitTime > maxWaitTime) { - log.error( - `Max wait time reached for waiting for active generations to finish (${minutes} minutes), exiting... (active generations: ${activeGenerations.size})`, - ); - process.exit(1); - } - } -} - -let waitForActiveGenerationsPromise: Promise | undefined; - -export async function handleShutdown(): Promise { - if (waitForActiveGenerationsPromise) { - return waitForActiveGenerationsPromise; - } - - _isShuttingDown = true; - - log.info('Shutting down...'); - - waitForActiveGenerationsPromise = waitForActiveGenerations(); - await waitForActiveGenerationsPromise; - waitForActiveGenerationsPromise = undefined; -} diff --git a/packages/ai-bot/lib/signal-handlers.ts b/packages/ai-bot/lib/signal-handlers.ts deleted file mode 100644 index 5e4dd7841f..0000000000 --- a/packages/ai-bot/lib/signal-handlers.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { logger } from '@cardstack/runtime-common'; -import { handleShutdown } from './shutdown'; - -let log = logger('ai-bot:signals'); - -let firstSigintTime = 0; -const SIGINT_DEBOUNCE_MS = 50; // Treat SIGINTs within 50ms as the same event - -export function setupSignalHandlers(): void { - // Handle SIGTERM (sent by ECS when it shuts down the instance) - process.on('SIGTERM', async () => { - await handleShutdown(); - process.exit(0); - }); - - // Handle SIGINT (Ctrl+C from user) - process.on('SIGINT', () => { - const now = Date.now(); - - // For some reason, SIGINT is being sent multiple times when Ctrl+C is pressed. - // This is a workaround to ignore the duplicate SIGINTs. - - // If this is the very first SIGINT or enough time has passed since the last "real" SIGINT - if (firstSigintTime === 0) { - // First SIGINT ever - firstSigintTime = now; - log.info( - 'Gracefully shutting down... (Press Ctrl+C again to force exit)', - ); - // Start graceful shutdown asynchronously but don't await here - handleShutdown().then(() => process.exit(0)); - } else if (now - firstSigintTime > SIGINT_DEBOUNCE_MS) { - // This is a genuine second Ctrl+C (after the debounce period) - log.info('Exiting immediately...'); - process.exit(1); - } else { - // This is a duplicate/rapid SIGINT from the same Ctrl+C press, ignore it - // (no logging for this case) - } - }); -} diff --git a/packages/ai-bot/main.ts b/packages/ai-bot/main.ts index ca59204844..690a7507fe 100644 --- a/packages/ai-bot/main.ts +++ b/packages/ai-bot/main.ts @@ -1,8 +1,7 @@ import './instrument'; import './setup-logger'; // This should be first import type { MatrixEvent } from 'matrix-js-sdk'; -import { RoomMemberEvent, RoomEvent, createClient } from 'matrix-js-sdk'; -import { SlidingSync, type MSC3575List } from 'matrix-js-sdk/lib/sliding-sync'; +import { RoomEvent } from 'matrix-js-sdk'; import OpenAI from 'openai'; import { logger, @@ -23,12 +22,17 @@ import { constructHistory, } from '@cardstack/runtime-common/ai'; import { validateAICredits } from '@cardstack/billing/ai-billing'; +import { APP_BOXEL_CODE_PATCH_CORRECTNESS_MSGTYPE } from '@cardstack/runtime-common/matrix-constants'; import { - SLIDING_SYNC_AI_ROOM_LIST_NAME, - SLIDING_SYNC_LIST_TIMELINE_LIMIT, - SLIDING_SYNC_TIMEOUT, - APP_BOXEL_CODE_PATCH_CORRECTNESS_MSGTYPE, -} from '@cardstack/runtime-common/matrix-constants'; + createBotMatrixClient, + acquireRoomLock, + releaseRoomLock, + createShutdownHandler, + setupSignalHandlers, + isShuttingDown, + createSlidingSync, + setupAutoJoinOnInvite, +} from '@cardstack/bot-core'; import { handleDebugCommands } from './lib/debug'; import { Responder } from './lib/responder'; @@ -45,12 +49,7 @@ import { PgAdapter } from '@cardstack/postgres'; import type { ChatCompletionMessageParam } from 'openai/resources'; import type { OpenAIError } from 'openai/error'; import type { ChatCompletionStream } from 'openai/lib/ChatCompletionStream'; -import { acquireRoomLock, releaseRoomLock } from './lib/queries'; -import { DebugLogger } from 'matrix-js-sdk/lib/logger'; -import { setupSignalHandlers } from './lib/signal-handlers'; -import { isShuttingDown, setActiveGenerations } from './lib/shutdown'; import type { MatrixClient } from 'matrix-js-sdk'; -import { debug } from 'debug'; import { profEnabled, profTime, profNote } from './lib/profiler'; import { publishCodePatchCorrectnessMessage } from './lib/code-patch-correctness'; @@ -163,63 +162,34 @@ let assistant: Assistant; (async () => { const matrixUrl = process.env.MATRIX_URL || 'http://localhost:8008'; - let matrixDebugLogger = !process.env.DISABLE_MATRIX_JS_LOGGING - ? new DebugLogger(debug(`matrix-js-sdk:${aiBotUsername}`)) - : undefined; - let client = createClient({ - baseUrl: matrixUrl, - logger: matrixDebugLogger, + const enableDebugLogging = !process.env.DISABLE_MATRIX_JS_LOGGING; + + // Create and authenticate Matrix client using bot-core + const { client, userId: aiBotUserId } = await createBotMatrixClient({ + matrixUrl, + username: aiBotUsername, + password: process.env.BOXEL_AIBOT_PASSWORD || 'pass', + enableDebugLogging, + }).catch((e) => { + log.error(e); + process.exit(1); }); - let auth = await client - .loginWithPassword( - aiBotUsername, - process.env.BOXEL_AIBOT_PASSWORD || 'pass', - ) - .catch((e) => { - log.error(e); - log.info(`The matrix bot could not login to the server. -Common issues are: -- The server is not running (configured to use ${matrixUrl}) - - Check it is reachable at ${matrixUrl}/_matrix/client/versions - - If running in development, check the docker container is running (see the boxel README) -- The bot is not registered on the matrix server - - The bot uses the username ${aiBotUsername} -- The bot is registered but the password is incorrect - - The bot password ${ - process.env.BOXEL_AIBOT_PASSWORD - ? 'is set in the env var, check it is correct' - : 'is not set in the env var so defaults to "pass"' - } - `); - process.exit(1); - }); - let { user_id: aiBotUserId } = auth; assistant = new Assistant(client, aiBotUserId, aiBotInstanceId); - // Set up signal handlers for graceful shutdown - setupSignalHandlers(); - - // Share activeGenerations map with shutdown module - setActiveGenerations(activeGenerations); + // Set up signal handlers for graceful shutdown using bot-core + const handleShutdown = createShutdownHandler({ + activeWork: activeGenerations, + workLabel: 'active generations', + }); + setupSignalHandlers({ onShutdown: handleShutdown, botName: 'ai-bot' }); - client.on(RoomMemberEvent.Membership, function (event, member) { - if (event.event.origin_server_ts! < startTime) { - return; - } - if (member.membership === 'invite' && member.userId === aiBotUserId) { - client - .joinRoom(member.roomId) - .then(function () { - log.info('%s auto-joined %s', member.name, member.roomId); - }) - .catch(function (err) { - log.info( - 'Error joining this room, typically happens when a user invites then leaves before this is joined', - err, - ); - }); - } + // Set up auto-join on invite using bot-core + setupAutoJoinOnInvite({ + client, + botUserId: aiBotUserId, + ignoreEventsBefore: startTime, + botName: 'ai-bot', }); // TODO: Set this up to use a queue that gets drained (CS-8516) @@ -604,22 +574,8 @@ Common issues are: } }); - let lists: Map = new Map(); - lists.set(SLIDING_SYNC_AI_ROOM_LIST_NAME, { - ranges: [[0, 0]], - filters: { - is_dm: false, - }, - timeline_limit: SLIDING_SYNC_LIST_TIMELINE_LIMIT, - required_state: [['*', '*']], - }); - let slidingSync = new SlidingSync( - client.baseUrl, - lists, - { timeline_limit: SLIDING_SYNC_LIST_TIMELINE_LIMIT }, - client, - SLIDING_SYNC_TIMEOUT, - ); + // Set up sliding sync using bot-core + const slidingSync = createSlidingSync({ client }); await client.startClient({ slidingSync, }); diff --git a/packages/ai-bot/package.json b/packages/ai-bot/package.json index 6408d58f93..b5ffaabfbb 100644 --- a/packages/ai-bot/package.json +++ b/packages/ai-bot/package.json @@ -2,6 +2,7 @@ "name": "@cardstack/ai-bot", "dependencies": { "@cardstack/billing": "workspace:*", + "@cardstack/bot-core": "workspace:*", "@cardstack/postgres": "workspace:*", "@cardstack/runtime-common": "workspace:*", "@sentry/node": "catalog:", diff --git a/packages/base/command.gts b/packages/base/command.gts index e710809389..a28627ebf7 100644 --- a/packages/base/command.gts +++ b/packages/base/command.gts @@ -299,6 +299,14 @@ export class OpenAiAssistantRoomInput extends CardDef { @field roomId = contains(StringField); } +// Submission Bot types +export class InviteSubmissionBotInput extends CardDef { + @field submissionTarget = contains(StringField); // What is being submitted (e.g., PR URL, card ID) + @field submissionType = contains(StringField); // 'pull-request' | 'card' | 'other' + @field autoStart = contains(BooleanField); // Whether to start processing immediately + @field metadata = contains(JsonField); // Additional metadata for the submission +} + export class AddFieldToCardDefinitionInput extends CardDef { @field realm = contains(StringField); @field path = contains(StringField); diff --git a/packages/bot-core/.eslintrc.js b/packages/bot-core/.eslintrc.js new file mode 100644 index 0000000000..5769e59a4a --- /dev/null +++ b/packages/bot-core/.eslintrc.js @@ -0,0 +1,43 @@ +'use strict'; + +module.exports = { + root: true, + env: { + node: true, + }, + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 2020, + sourceType: 'module', + ecmaFeatures: { + legacyDecorators: true, + }, + }, + plugins: ['ember'], + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:prettier/recommended', + ], + rules: { + '@typescript-eslint/consistent-type-imports': [ + 'error', + { + disallowTypeAnnotations: false, + }, + ], + '@typescript-eslint/no-import-type-side-effects': 'error', + // this doesn't work well with the monorepo. Typescript already complains if you try to import something that's not found + 'import/no-unresolved': 'off', + 'prefer-const': 'off', + '@typescript-eslint/ban-ts-comment': 'off', + '@typescript-eslint/ban-types': 'off', + '@typescript-eslint/prefer-as-const': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], + }, +}; diff --git a/packages/bot-core/index.ts b/packages/bot-core/index.ts new file mode 100644 index 0000000000..b8a718a4a2 --- /dev/null +++ b/packages/bot-core/index.ts @@ -0,0 +1,29 @@ +// Matrix client utilities +export { + createBotMatrixClient, + type BotMatrixClientConfig, +} from './lib/matrix-client'; + +// Room locking for concurrency control +export { acquireRoomLock, releaseRoomLock } from './lib/room-lock'; + +// Graceful shutdown utilities +export { + isShuttingDown, + setShuttingDown, + handleShutdown, + createShutdownHandler, + type ShutdownConfig, +} from './lib/shutdown'; + +// Signal handlers (SIGTERM, SIGINT) +export { + setupSignalHandlers, + type SignalHandlerConfig, +} from './lib/signal-handlers'; + +// Sliding sync setup +export { createSlidingSync, type SlidingSyncConfig } from './lib/sliding-sync'; + +// Membership utilities (auto-join on invite) +export { setupAutoJoinOnInvite, type AutoJoinConfig } from './lib/membership'; diff --git a/packages/bot-core/lib/matrix-client.ts b/packages/bot-core/lib/matrix-client.ts new file mode 100644 index 0000000000..68d98d0c1b --- /dev/null +++ b/packages/bot-core/lib/matrix-client.ts @@ -0,0 +1,69 @@ +import { createClient, type MatrixClient } from 'matrix-js-sdk'; +import { DebugLogger } from 'matrix-js-sdk/lib/logger'; +import debug from 'debug'; +import { logger } from '@cardstack/runtime-common'; + +let log = logger('bot-core:matrix-client'); + +export interface BotMatrixClientConfig { + /** Matrix homeserver URL (e.g., 'http://localhost:8008') */ + matrixUrl: string; + /** Bot username (without @ or :server) */ + username: string; + /** Bot password */ + password: string; + /** Whether to enable Matrix SDK debug logging */ + enableDebugLogging?: boolean; +} + +export interface BotMatrixClientResult { + client: MatrixClient; + userId: string; +} + +/** + * Creates and authenticates a Matrix client for a bot. + * + * @example + * ```ts + * const { client, userId } = await createBotMatrixClient({ + * matrixUrl: 'http://localhost:8008', + * username: 'mybot', + * password: 'secret', + * }); + * ``` + */ +export async function createBotMatrixClient( + config: BotMatrixClientConfig, +): Promise { + const { matrixUrl, username, password, enableDebugLogging = false } = config; + + let matrixDebugLogger = enableDebugLogging + ? new DebugLogger(debug(`matrix-js-sdk:${username}`)) + : undefined; + + let client = createClient({ + baseUrl: matrixUrl, + logger: matrixDebugLogger, + }); + + let auth = await client.loginWithPassword(username, password).catch((e) => { + log.error(e); + log.info(`The matrix bot could not login to the server. +Common issues are: +- The server is not running (configured to use ${matrixUrl}) + - Check it is reachable at ${matrixUrl}/_matrix/client/versions + - If running in development, check the docker container is running (see the boxel README) +- The bot is not registered on the matrix server + - The bot uses the username ${username} +- The bot is registered but the password is incorrect + `); + throw new Error(`Failed to login as ${username}`); + }); + + let { user_id: userId } = auth; + + log.info(`Logged in as ${userId}`); + + return { client, userId }; +} diff --git a/packages/bot-core/lib/membership.ts b/packages/bot-core/lib/membership.ts new file mode 100644 index 0000000000..176707b66a --- /dev/null +++ b/packages/bot-core/lib/membership.ts @@ -0,0 +1,76 @@ +import { RoomMemberEvent } from 'matrix-js-sdk'; +import type { MatrixClient, MatrixEvent, RoomMember } from 'matrix-js-sdk'; +import { logger } from '@cardstack/runtime-common'; + +let log = logger('bot-core:membership'); + +export interface AutoJoinConfig { + /** Matrix client instance */ + client: MatrixClient; + /** The bot's user ID (e.g., '@aibot:localhost') */ + botUserId: string; + /** Timestamp to ignore events before (prevents processing old invites on startup) */ + ignoreEventsBefore?: number; + /** Callback when successfully joined a room */ + onRoomJoined?: (roomId: string) => void | Promise; + /** Bot name for logging (default: 'bot') */ + botName?: string; +} + +/** + * Sets up automatic room joining when the bot is invited. + * + * This is a common pattern for Matrix bots - automatically accept + * room invitations so users can interact with the bot. + * + * @example + * ```ts + * setupAutoJoinOnInvite({ + * client, + * botUserId: '@mybot:localhost', + * ignoreEventsBefore: Date.now(), + * onRoomJoined: async (roomId) => { + * await client.sendMessage(roomId, { body: 'Hello!', msgtype: 'm.text' }); + * }, + * }); + * ``` + */ +export function setupAutoJoinOnInvite(config: AutoJoinConfig): void { + const { + client, + botUserId, + ignoreEventsBefore = 0, + onRoomJoined, + botName = 'bot', + } = config; + + client.on( + RoomMemberEvent.Membership, + async function (event: MatrixEvent, member: RoomMember) { + // Ignore old events (from before bot started) + if ( + ignoreEventsBefore > 0 && + event.event.origin_server_ts! < ignoreEventsBefore + ) { + return; + } + + // Only respond to invites for this bot + if (member.membership === 'invite' && member.userId === botUserId) { + try { + await client.joinRoom(member.roomId); + log.info(`[${botName}] Auto-joined room ${member.roomId}`); + + if (onRoomJoined) { + await onRoomJoined(member.roomId); + } + } catch (err) { + log.info( + `[${botName}] Error joining room ${member.roomId}, typically happens when a user invites then leaves before join completes`, + err, + ); + } + } + }, + ); +} diff --git a/packages/ai-bot/lib/queries.ts b/packages/bot-core/lib/room-lock.ts similarity index 59% rename from packages/ai-bot/lib/queries.ts rename to packages/bot-core/lib/room-lock.ts index 91680d4b36..e26b51fbe2 100644 --- a/packages/ai-bot/lib/queries.ts +++ b/packages/bot-core/lib/room-lock.ts @@ -8,10 +8,35 @@ import { asExpressions, } from '@cardstack/runtime-common'; +/** + * Attempts to acquire an exclusive lock for processing events in a room. + * + * This prevents multiple bot instances from processing events in the same room + * concurrently. The lock is acquired by upserting a row in the database. + * + * @param pgAdapter - Database adapter + * @param roomId - Matrix room ID + * @param botInstanceId - Unique identifier for this bot instance + * @param eventId - The event ID being processed + * @returns true if lock was acquired, false if another instance holds the lock + * + * @example + * ```ts + * const gotLock = await acquireRoomLock(pgAdapter, roomId, instanceId, eventId); + * if (!gotLock) { + * return; // Another instance is processing this room + * } + * try { + * // Process the event + * } finally { + * await releaseRoomLock(pgAdapter, roomId); + * } + * ``` + */ export async function acquireRoomLock( pgAdapter: PgAdapter, roomId: string, - aiBotInstanceId: string, + botInstanceId: string, eventId: string, ): Promise { // Attempts to take an exclusive lock per room by upserting a row. The insert succeeds when no @@ -19,7 +44,7 @@ export async function acquireRoomLock( // has a non-null completed_at, effectively allowing the next bot instance to pick up where the // prior one finished. let { valueExpressions, nameExpressions } = asExpressions({ - ai_bot_instance_id: aiBotInstanceId, + ai_bot_instance_id: botInstanceId, room_id: roomId, event_id_being_processed: eventId, }); @@ -41,7 +66,16 @@ export async function acquireRoomLock( return lockRow.length > 0; } -export async function releaseRoomLock(pgAdapter: PgAdapter, roomId: string) { +/** + * Releases the room lock after processing is complete. + * + * @param pgAdapter - Database adapter + * @param roomId - Matrix room ID + */ +export async function releaseRoomLock( + pgAdapter: PgAdapter, + roomId: string, +): Promise { await query(pgAdapter, [ `UPDATE ai_bot_event_processing SET completed_at = NOW() WHERE room_id = `, param(roomId), diff --git a/packages/bot-core/lib/shutdown.ts b/packages/bot-core/lib/shutdown.ts new file mode 100644 index 0000000000..9edbe0ad32 --- /dev/null +++ b/packages/bot-core/lib/shutdown.ts @@ -0,0 +1,109 @@ +import { logger } from '@cardstack/runtime-common'; + +let log = logger('bot-core:shutdown'); + +let _isShuttingDown = false; + +/** + * Returns whether the bot is currently in shutdown mode. + * When true, the bot should not accept new work. + */ +export function isShuttingDown(): boolean { + return _isShuttingDown; +} + +/** + * Sets the shutdown state. Typically called by signal handlers. + */ +export function setShuttingDown(value: boolean): void { + _isShuttingDown = value; +} + +export interface ShutdownConfig { + /** Map of active work items to wait for before shutdown */ + activeWork: Map; + /** Maximum time to wait for active work in milliseconds (default: 10 minutes) */ + maxWaitTimeMs?: number; + /** Label for the active work in logs (default: 'active work') */ + workLabel?: string; +} + +/** + * Creates a shutdown handler that waits for active work to complete. + * + * @example + * ```ts + * const activeGenerations = new Map(); + * const handleShutdown = createShutdownHandler({ + * activeWork: activeGenerations, + * workLabel: 'active generations', + * }); + * + * // In signal handler: + * await handleShutdown(); + * ``` + */ +export function createShutdownHandler(config: ShutdownConfig) { + const { + activeWork, + maxWaitTimeMs = 10 * 60 * 1000, // 10 minutes + workLabel = 'active work', + } = config; + + let waitPromise: Promise | undefined; + + async function waitForActiveWork(): Promise { + let waitTime = 0; + + while (activeWork.size > 0) { + if (waitTime === 0) { + log.info( + `Waiting for ${workLabel} to finish (count: ${activeWork.size})...`, + ); + } + + await new Promise((resolve) => setTimeout(resolve, 1000)); + waitTime += 1000; + + if (waitTime > maxWaitTimeMs) { + log.error( + `Max wait time reached for ${workLabel} (${maxWaitTimeMs / 60000} minutes), exiting... (remaining: ${activeWork.size})`, + ); + process.exit(1); + } + } + } + + return async function handleShutdown(): Promise { + if (waitPromise) { + return waitPromise; + } + + _isShuttingDown = true; + log.info('Shutting down...'); + + waitPromise = waitForActiveWork(); + await waitPromise; + waitPromise = undefined; + }; +} + +// Simple shutdown handler for bots without active work tracking +let simpleWaitPromise: Promise | undefined; + +/** + * Simple shutdown handler that just sets the shutdown flag. + * Use createShutdownHandler() for more complex scenarios with active work tracking. + */ +export async function handleShutdown(): Promise { + if (simpleWaitPromise) { + return simpleWaitPromise; + } + + _isShuttingDown = true; + log.info('Shutting down...'); + + simpleWaitPromise = Promise.resolve(); + await simpleWaitPromise; + simpleWaitPromise = undefined; +} diff --git a/packages/bot-core/lib/signal-handlers.ts b/packages/bot-core/lib/signal-handlers.ts new file mode 100644 index 0000000000..920bfebab2 --- /dev/null +++ b/packages/bot-core/lib/signal-handlers.ts @@ -0,0 +1,62 @@ +import { logger } from '@cardstack/runtime-common'; + +let log = logger('bot-core:signals'); + +export interface SignalHandlerConfig { + /** Function to call when shutdown is triggered */ + onShutdown: () => Promise; + /** Bot name for logging (default: 'bot') */ + botName?: string; +} + +let firstSigintTime = 0; +const SIGINT_DEBOUNCE_MS = 50; // Treat SIGINTs within 50ms as the same event + +/** + * Sets up signal handlers for graceful shutdown. + * + * Handles: + * - SIGTERM: Graceful shutdown (e.g., from container orchestration) + * - SIGINT: Ctrl+C from user. First press triggers graceful shutdown, + * second press forces immediate exit. + * + * @example + * ```ts + * const handleShutdown = createShutdownHandler({ activeWork: myActiveWork }); + * setupSignalHandlers({ onShutdown: handleShutdown, botName: 'ai-bot' }); + * ``` + */ +export function setupSignalHandlers(config: SignalHandlerConfig): void { + const { onShutdown, botName = 'bot' } = config; + + // Handle SIGTERM (sent by ECS when it shuts down the instance) + process.on('SIGTERM', async () => { + log.info(`[${botName}] Received SIGTERM, shutting down gracefully...`); + await onShutdown(); + process.exit(0); + }); + + // Handle SIGINT (Ctrl+C from user) + process.on('SIGINT', () => { + const now = Date.now(); + + // For some reason, SIGINT is being sent multiple times when Ctrl+C is pressed. + // This is a workaround to ignore the duplicate SIGINTs. + + // If this is the very first SIGINT or enough time has passed since the last "real" SIGINT + if (firstSigintTime === 0) { + // First SIGINT ever + firstSigintTime = now; + log.info( + `[${botName}] Gracefully shutting down... (Press Ctrl+C again to force exit)`, + ); + // Start graceful shutdown asynchronously but don't await here + onShutdown().then(() => process.exit(0)); + } else if (now - firstSigintTime > SIGINT_DEBOUNCE_MS) { + // This is a genuine second Ctrl+C (after the debounce period) + log.info(`[${botName}] Exiting immediately...`); + process.exit(1); + } + // Duplicate/rapid SIGINT from the same Ctrl+C press - ignore it + }); +} diff --git a/packages/bot-core/lib/sliding-sync.ts b/packages/bot-core/lib/sliding-sync.ts new file mode 100644 index 0000000000..b714048c9f --- /dev/null +++ b/packages/bot-core/lib/sliding-sync.ts @@ -0,0 +1,61 @@ +import { SlidingSync, type MSC3575List } from 'matrix-js-sdk/lib/sliding-sync'; +import type { MatrixClient } from 'matrix-js-sdk'; +import { + SLIDING_SYNC_AI_ROOM_LIST_NAME, + SLIDING_SYNC_LIST_TIMELINE_LIMIT, + SLIDING_SYNC_TIMEOUT, +} from '@cardstack/runtime-common/matrix-constants'; + +export interface SlidingSyncConfig { + /** Matrix client instance */ + client: MatrixClient; + /** Custom list name (default: AI room list name from constants) */ + listName?: string; + /** Timeline limit for sync (default: from constants) */ + timelineLimit?: number; + /** Sync timeout in ms (default: from constants) */ + timeout?: number; + /** Custom room filters */ + filters?: { + is_dm?: boolean; + [key: string]: unknown; + }; +} + +/** + * Creates a SlidingSync instance configured for bot use. + * + * Sliding sync is an efficient way to sync only the rooms the bot cares about, + * rather than syncing the entire Matrix state. + * + * @example + * ```ts + * const slidingSync = createSlidingSync({ client }); + * await client.startClient({ slidingSync }); + * ``` + */ +export function createSlidingSync(config: SlidingSyncConfig): SlidingSync { + const { + client, + listName = SLIDING_SYNC_AI_ROOM_LIST_NAME, + timelineLimit = SLIDING_SYNC_LIST_TIMELINE_LIMIT, + timeout = SLIDING_SYNC_TIMEOUT, + filters = { is_dm: false }, + } = config; + + let lists: Map = new Map(); + lists.set(listName, { + ranges: [[0, 0]], + filters, + timeline_limit: timelineLimit, + required_state: [['*', '*']], + }); + + return new SlidingSync( + client.baseUrl, + lists, + { timeline_limit: timelineLimit }, + client, + timeout, + ); +} diff --git a/packages/bot-core/package.json b/packages/bot-core/package.json new file mode 100644 index 0000000000..33a93e48a3 --- /dev/null +++ b/packages/bot-core/package.json @@ -0,0 +1,29 @@ +{ + "name": "@cardstack/bot-core", + "version": "0.0.1", + "description": "Shared utilities for Matrix bot services", + "main": "index.ts", + "dependencies": { + "@cardstack/postgres": "workspace:*", + "@cardstack/runtime-common": "workspace:*", + "debug": "^4.4.3", + "matrix-js-sdk": "catalog:" + }, + "devDependencies": { + "@cardstack/local-types": "workspace:*", + "@types/debug": "^4.1.12", + "@types/node": "catalog:", + "concurrently": "catalog:", + "typescript": "catalog:" + }, + "scripts": { + "lint": "concurrently \"pnpm:lint:*(!fix)\" --names \"lint:\"", + "lint:fix": "concurrently \"pnpm:lint:*:fix\" --names \"fix:\"", + "lint:js": "eslint . --report-unused-disable-directives --cache", + "lint:js:fix": "eslint . --report-unused-disable-directives --fix", + "lint:glint": "glint" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/packages/bot-core/tsconfig.json b/packages/bot-core/tsconfig.json new file mode 100644 index 0000000000..f129afe5b5 --- /dev/null +++ b/packages/bot-core/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "es2023", + "lib": ["es2023", "dom"], + "allowJs": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "allowSyntheticDefaultImports": true, + "noImplicitAny": true, + "noImplicitThis": true, + "alwaysStrict": true, + "strictNullChecks": true, + "strictPropertyInitialization": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noEmitOnError": false, + "noEmit": true, + "inlineSourceMap": true, + "inlineSources": true, + "baseUrl": ".", + "esModuleInterop": true, + "experimentalDecorators": true, + "skipLibCheck": true, + "strict": true, + "types": ["@cardstack/local-types"] + }, + "include": ["./**/*"], + "exclude": ["node_modules"] +} diff --git a/packages/host/app/components/matrix/room.gts b/packages/host/app/components/matrix/room.gts index bd0e90f3a2..1b7f220d4d 100644 --- a/packages/host/app/components/matrix/room.gts +++ b/packages/host/app/components/matrix/room.gts @@ -1258,10 +1258,15 @@ export default class Room extends Component { } private get generatingResults() { - return ( - this.messages[this.messages.length - 1] && - !this.messages[this.messages.length - 1].isStreamingFinished - ); + let lastMessage = this.messages[this.messages.length - 1]; + if (!lastMessage) { + return false; + } + // Only show "Generating results..." for AI bot messages that are streaming + // Submission bot messages don't use streaming and should not trigger this + const isFromAiBot = + lastMessage.author.userId === this.matrixService.aiBotUserId; + return isFromAiBot && !lastMessage.isStreamingFinished; } @cached diff --git a/packages/host/app/resources/room.ts b/packages/host/app/resources/room.ts index 949029f961..62d402c96f 100644 --- a/packages/host/app/resources/room.ts +++ b/packages/host/app/resources/room.ts @@ -137,8 +137,12 @@ export class RoomResource extends Resource { return; } let memberIds = this.matrixRoom.memberIds; - // If the AI bot is not in the room, don't process the events - if (!memberIds || !memberIds.includes(this.matrixService.aiBotUserId)) { + // If neither the AI bot nor the submission bot is in the room, don't process the events + const hasAiBot = memberIds?.includes(this.matrixService.aiBotUserId); + const hasSubmissionBot = memberIds?.includes( + this.matrixService.submissionBotUserId, + ); + if (!memberIds || (!hasAiBot && !hasSubmissionBot)) { return; } // TODO: enabledSkillCards can have references to skills whose URL diff --git a/packages/host/app/services/matrix-service.ts b/packages/host/app/services/matrix-service.ts index 32e619c790..0b8c65a89a 100644 --- a/packages/host/app/services/matrix-service.ts +++ b/packages/host/app/services/matrix-service.ts @@ -28,6 +28,7 @@ import type { } from '@cardstack/runtime-common'; import { aiBotUsername, + submissionBotUsername, logger, isCardInstance, Deferred, @@ -399,6 +400,11 @@ export default class MatrixService extends Service { return `@${aiBotUsername}:${server}`; } + get submissionBotUserId() { + let server = this.userId!.split(':')[1]; + return `@${submissionBotUsername}:${server}`; + } + get userName() { return this.userId ? getMatrixUsername(this.userId) : null; } @@ -1358,6 +1364,10 @@ export default class MatrixService extends Service { } } + async inviteToRoom(roomId: string, userId: string) { + return this.client.invite(roomId, userId); + } + async sendStateEvent( roomId: string, eventType: string, diff --git a/packages/matrix/package.json b/packages/matrix/package.json index ef4c284d0a..e872ad5dbc 100644 --- a/packages/matrix/package.json +++ b/packages/matrix/package.json @@ -31,13 +31,14 @@ "start:admin": "ts-node --transpileOnly ./scripts/admin-console", "stop:admin": "docker stop synapse-admin && docker rm synapse-admin", "register-bot-user": "MATRIX_USERNAME=aibot MATRIX_PASSWORD=pass ts-node --transpileOnly ./scripts/register-test-user.ts", + "register-submission-bot": "MATRIX_USERNAME=submissionbot MATRIX_PASSWORD=pass ts-node --transpileOnly ./scripts/register-test-user.ts", "register-test-user": "MATRIX_USERNAME=user MATRIX_PASSWORD=password ts-node --transpileOnly ./scripts/register-test-user.ts", "register-skills-writer": "MATRIX_USERNAME=skills_writer MATRIX_PASSWORD=password ts-node --transpileOnly ./scripts/register-test-user.ts", "register-homepage-writer": "MATRIX_USERNAME=homepage_writer MATRIX_PASSWORD=password ts-node --transpileOnly ./scripts/register-test-user.ts", "register-realm-users": "./scripts/register-realm-users.sh", "register-test-admin": "MATRIX_IS_ADMIN=TRUE MATRIX_USERNAME=admin MATRIX_PASSWORD=password ts-node --transpileOnly ./scripts/register-test-user.ts", "register-test-admin-and-token": "pnpm register-test-admin && ts-node --transpileOnly ./scripts/register-test-token.ts", - "register-all": "pnpm register-test-admin-and-token && pnpm register-realm-users && pnpm register-bot-user && pnpm register-test-user && pnpm register-skills-writer && pnpm register-homepage-writer", + "register-all": "pnpm register-test-admin-and-token && pnpm register-realm-users && pnpm register-bot-user && pnpm register-submission-bot && pnpm register-test-user && pnpm register-skills-writer && pnpm register-homepage-writer", "test": "./scripts/test.sh", "test:group": "./scripts/test.sh", "wait": "sleep 10000000", diff --git a/packages/runtime-common/constants.ts b/packages/runtime-common/constants.ts index 02a3d57380..6456cd595e 100644 --- a/packages/runtime-common/constants.ts +++ b/packages/runtime-common/constants.ts @@ -41,6 +41,7 @@ export const realmURL = Symbol.for('cardstack-realm-url'); export const relativeTo = Symbol.for('cardstack-relative-to'); export const aiBotUsername = 'aibot'; +export const submissionBotUsername = 'submissionbot'; export const CardContextName = 'card-context'; export const CardCrudFunctionsContextName = 'card-crud-functions-context'; diff --git a/packages/submission-bot/.eslintrc.js b/packages/submission-bot/.eslintrc.js new file mode 100644 index 0000000000..5769e59a4a --- /dev/null +++ b/packages/submission-bot/.eslintrc.js @@ -0,0 +1,43 @@ +'use strict'; + +module.exports = { + root: true, + env: { + node: true, + }, + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 2020, + sourceType: 'module', + ecmaFeatures: { + legacyDecorators: true, + }, + }, + plugins: ['ember'], + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:prettier/recommended', + ], + rules: { + '@typescript-eslint/consistent-type-imports': [ + 'error', + { + disallowTypeAnnotations: false, + }, + ], + '@typescript-eslint/no-import-type-side-effects': 'error', + // this doesn't work well with the monorepo. Typescript already complains if you try to import something that's not found + 'import/no-unresolved': 'off', + 'prefer-const': 'off', + '@typescript-eslint/ban-ts-comment': 'off', + '@typescript-eslint/ban-types': 'off', + '@typescript-eslint/prefer-as-const': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], + }, +}; diff --git a/packages/submission-bot/README.md b/packages/submission-bot/README.md new file mode 100644 index 0000000000..1d7e33d4ac --- /dev/null +++ b/packages/submission-bot/README.md @@ -0,0 +1,70 @@ +# Submission Bot + +A Matrix bot for handling PR submissions and related actions in Boxel. + +## Overview + +The Submission Bot is designed to be invited to Matrix rooms to handle submission workflows. Unlike the AI bot (which waits for user messages), the Submission Bot: + +1. **Sends the first message** when invited to a room +2. **Reads submission context** from room state (set by the invite command) +3. **Executes server-side actions** like interacting with GitHub APIs + +## Usage + +### Inviting the Bot + +Use the `InviteSubmissionBotCommand` from the host app: + +```typescript +await new InviteSubmissionBotCommand(commandContext).execute({ + roomId: 'your-room-id', + submissionTarget: 'https://github.com/org/repo/pull/123', + type: 'pull-request', + autoStart: true, +}); +``` + +### Commands + +Once in a room, users can interact with the bot using these commands: + +- `submit pr ` - Submit a pull request for review +- `status` - Check current submission status +- `help` - Show help message + +## Environment Variables + +| Variable | Description | Default | +| ------------------------------- | ----------------------------- | ----------------------- | +| `MATRIX_URL` | Matrix homeserver URL | `http://localhost:8008` | +| `BOXEL_SUBMISSION_BOT_PASSWORD` | Bot password | `pass` | +| `DISABLE_MATRIX_JS_LOGGING` | Disable Matrix SDK debug logs | unset | + +## Development + +```bash +# Start the bot +pnpm start + +# Start with development database +pnpm start:development +``` + +## Architecture + +This bot uses `@cardstack/bot-core` for shared Matrix bot infrastructure: + +- `createBotMatrixClient()` - Matrix authentication +- `setupAutoJoinOnInvite()` - Auto-join with callback for greeting +- `acquireRoomLock()` / `releaseRoomLock()` - Concurrency control +- `createShutdownHandler()` / `setupSignalHandlers()` - Graceful shutdown +- `createSlidingSync()` - Efficient room sync + +## TODO + +- [ ] Implement GitHub API integration +- [ ] Add PR validation logic +- [ ] Create/update cards based on PR content +- [ ] Add more command handlers +- [ ] Add tests diff --git a/packages/submission-bot/main.ts b/packages/submission-bot/main.ts new file mode 100644 index 0000000000..976a00a411 --- /dev/null +++ b/packages/submission-bot/main.ts @@ -0,0 +1,310 @@ +import './setup-logger'; // This should be first +import { RoomEvent, MsgType } from 'matrix-js-sdk'; +import type { MatrixEvent, Room } from 'matrix-js-sdk'; +import { + logger, + uuidv4, + submissionBotUsername, +} from '@cardstack/runtime-common'; +import { + createBotMatrixClient, + acquireRoomLock, + releaseRoomLock, + createShutdownHandler, + setupSignalHandlers, + isShuttingDown, + createSlidingSync, + setupAutoJoinOnInvite, +} from '@cardstack/bot-core'; +import { PgAdapter } from '@cardstack/postgres'; +import * as Sentry from '@sentry/node'; + +const log = logger('submission-bot'); + +// Bot configuration +const botInstanceId = uuidv4(); + +// Track active submissions for graceful shutdown +const activeSubmissions = new Map< + string, + { roomId: string; startedAt: number } +>(); + +// Room state event type for submission context +const SUBMISSION_CONTEXT_EVENT_TYPE = 'com.cardstack.submission_context'; + +interface SubmissionContext { + /** What is being submitted (e.g., PR URL, card ID) */ + target: string; + /** Type of submission */ + type: 'pull-request' | 'card' | 'other'; + /** Additional metadata */ + metadata?: Record; + /** Whether to start processing immediately after joining */ + autoStart?: boolean; +} + +/** + * Sends a text message to a room. + */ +async function sendTextMessage( + client: Awaited>['client'], + roomId: string, + message: string, +): Promise { + await client.sendMessage(roomId, { + msgtype: MsgType.Text, + body: message, + format: 'org.matrix.custom.html', + formatted_body: message.replace(/\*\*(.*?)\*\*/g, '$1'), + }); +} + +/** + * Sends a greeting message when the bot joins a room. + */ +async function sendGreetingMessage( + client: Awaited>['client'], + roomId: string, + context?: SubmissionContext, +): Promise { + let message: string; + + if (context) { + switch (context.type) { + case 'pull-request': + message = `Hi! 👋 I'm the Submission Bot. I see you want to submit a pull request: **${context.target}**\n\nI'll help you with the submission process. Let me check the details...`; + break; + case 'card': + message = `Hi! 👋 I'm the Submission Bot. I'll help you submit: **${context.target}**`; + break; + default: + message = `Hi! 👋 I'm the Submission Bot. I'm ready to help with your submission.`; + } + } else { + message = `Hi! 👋 I'm the Submission Bot. I was invited to this room but didn't receive any submission context. Please use the invite command with proper parameters.`; + } + + await sendTextMessage(client, roomId, message); +} + +/** + * Retrieves submission context from room state. + */ +async function getSubmissionContext( + client: Awaited>['client'], + roomId: string, +): Promise { + try { + const stateEvent = await client.getStateEvent( + roomId, + SUBMISSION_CONTEXT_EVENT_TYPE, + '', + ); + return stateEvent as SubmissionContext; + } catch (e) { + // State event doesn't exist + log.debug(`No submission context found for room ${roomId}`); + return undefined; + } +} + +/** + * Handles the submission flow for a pull request. + */ +async function handlePullRequestSubmission( + client: Awaited>['client'], + roomId: string, + context: SubmissionContext, +): Promise { + const submissionId = uuidv4(); + activeSubmissions.set(submissionId, { roomId, startedAt: Date.now() }); + + try { + await sendTextMessage(client, roomId, '🔍 Analyzing pull request...'); + + // TODO: Implement actual PR submission logic + // - Fetch PR details from GitHub API + // - Validate PR meets requirements + // - Create/update relevant cards + // - Report status back to room + + await sendTextMessage( + client, + roomId, + '✅ Pull request analysis complete. (Implementation pending)', + ); + } finally { + activeSubmissions.delete(submissionId); + } +} + +/** + * Handles timeline events in rooms. + */ +async function handleTimelineEvent( + client: Awaited>['client'], + pgAdapter: PgAdapter, + botUserId: string, + event: MatrixEvent, + room: Room | undefined, + startTime: number, +): Promise { + if (!room) return; + + const eventId = event.getId()!; + const senderUserId = event.getSender()!; + + // Ignore old events + if (event.event.origin_server_ts! < startTime) return; + + // Ignore own messages + if (senderUserId === botUserId) return; + + // Only respond to message events + if (event.getType() !== 'm.room.message') return; + + const eventBody = event.getContent().body || ''; + + // Acquire room lock to prevent concurrent processing + const gotLock = await acquireRoomLock( + pgAdapter, + room.roomId, + botInstanceId, + eventId, + ); + + if (!gotLock) { + log.debug(`Could not acquire lock for room ${room.roomId}, skipping`); + return; + } + + try { + if (isShuttingDown()) { + return; + } + + log.info( + `[${room.roomId}] Processing message from ${senderUserId}: ${eventBody.substring(0, 50)}...`, + ); + + // TODO: Implement command parsing and handling + // Example commands the submission bot might handle: + // - "submit PR " - Submit a pull request + // - "status" - Check submission status + // - "cancel" - Cancel current submission + + if (eventBody.toLowerCase().startsWith('submit pr ')) { + const prUrl = eventBody.substring('submit pr '.length).trim(); + const context: SubmissionContext = { + target: prUrl, + type: 'pull-request', + }; + await handlePullRequestSubmission(client, room.roomId, context); + } else if (eventBody.toLowerCase() === 'status') { + await sendTextMessage( + client, + room.roomId, + `📊 Active submissions: ${activeSubmissions.size}`, + ); + } else if (eventBody.toLowerCase() === 'help') { + await sendTextMessage( + client, + room.roomId, + `**Submission Bot Commands:** +- \`submit pr \` - Submit a pull request for review +- \`status\` - Check current submission status +- \`help\` - Show this help message`, + ); + } + } catch (e) { + log.error(`Error processing event ${eventId}:`, e); + Sentry.captureException(e, { + extra: { roomId: room.roomId, eventId }, + }); + } finally { + await releaseRoomLock(pgAdapter, room.roomId); + } +} + +// Main entry point +(async () => { + const startTime = Date.now(); + const matrixUrl = process.env.MATRIX_URL || 'http://localhost:8008'; + const enableDebugLogging = !process.env.DISABLE_MATRIX_JS_LOGGING; + + log.info('Starting Submission Bot...'); + + // Create and authenticate Matrix client using bot-core + const { client, userId: botUserId } = await createBotMatrixClient({ + matrixUrl, + username: submissionBotUsername, + password: process.env.BOXEL_SUBMISSION_BOT_PASSWORD || 'pass', + enableDebugLogging, + }).catch((e) => { + log.error('Failed to create Matrix client:', e); + process.exit(1); + }); + + const pgAdapter = new PgAdapter(); + + // Set up graceful shutdown using bot-core + const handleShutdown = createShutdownHandler({ + activeWork: activeSubmissions, + workLabel: 'active submissions', + }); + setupSignalHandlers({ + onShutdown: handleShutdown, + botName: 'submission-bot', + }); + + // Set up auto-join on invite using bot-core + // Key difference from ai-bot: we send a greeting message when joining! + setupAutoJoinOnInvite({ + client, + botUserId, + ignoreEventsBefore: startTime, + botName: 'submission-bot', + onRoomJoined: async (roomId: string) => { + log.info(`Joined room ${roomId}, sending greeting...`); + + // Get submission context from room state (set by invite command) + const context = await getSubmissionContext(client, roomId); + + // Send greeting message (bot sends first!) + await sendGreetingMessage(client, roomId, context); + + // If autoStart is enabled, begin processing immediately + if (context?.autoStart && context.type === 'pull-request') { + await handlePullRequestSubmission(client, roomId, context); + } + }, + }); + + // Set up timeline event handler + client.on( + RoomEvent.Timeline, + async function (event, room, toStartOfTimeline) { + if (toStartOfTimeline) return; // Don't process paginated results + + await handleTimelineEvent( + client, + pgAdapter, + botUserId, + event, + room, + startTime, + ); + }, + ); + + // Set up sliding sync using bot-core + const slidingSync = createSlidingSync({ client }); + await client.startClient({ slidingSync }); + + log.info('Submission Bot started successfully'); +})().catch((e) => { + log.error('Fatal error:', e); + Sentry.captureException(e); + process.exit(1); +}); diff --git a/packages/submission-bot/package.json b/packages/submission-bot/package.json new file mode 100644 index 0000000000..458d83730b --- /dev/null +++ b/packages/submission-bot/package.json @@ -0,0 +1,34 @@ +{ + "name": "@cardstack/submission-bot", + "version": "0.0.1", + "description": "Matrix bot for handling PR submissions and related actions", + "main": "main.ts", + "dependencies": { + "@cardstack/bot-core": "workspace:*", + "@cardstack/postgres": "workspace:*", + "@cardstack/runtime-common": "workspace:*", + "@sentry/node": "catalog:", + "debug": "^4.4.3", + "matrix-js-sdk": "catalog:", + "ts-node": "^10.9.2", + "typescript": "catalog:" + }, + "devDependencies": { + "@cardstack/local-types": "workspace:*", + "@types/debug": "^4.1.12", + "@types/node": "catalog:", + "concurrently": "catalog:" + }, + "scripts": { + "lint": "concurrently \"pnpm:lint:*(!fix)\" --names \"lint:\"", + "lint:fix": "concurrently \"pnpm:lint:*:fix\" --names \"fix:\"", + "lint:js": "eslint . --report-unused-disable-directives --cache", + "lint:js:fix": "eslint . --report-unused-disable-directives --fix", + "lint:glint": "glint", + "start": "NODE_NO_WARNINGS=1 ts-node --transpileOnly main", + "start:development": "NODE_NO_WARNINGS=1 PGDATABASE=boxel PGPORT=5435 ts-node --transpileOnly main" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/packages/submission-bot/setup-logger.ts b/packages/submission-bot/setup-logger.ts new file mode 100644 index 0000000000..ce3997ba4f --- /dev/null +++ b/packages/submission-bot/setup-logger.ts @@ -0,0 +1,5 @@ +import { makeLogDefinitions } from '@cardstack/runtime-common'; + +(globalThis as any)._logDefinitions = makeLogDefinitions( + process.env.LOG_LEVELS || '*=info', +); diff --git a/packages/submission-bot/tsconfig.json b/packages/submission-bot/tsconfig.json new file mode 100644 index 0000000000..f129afe5b5 --- /dev/null +++ b/packages/submission-bot/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "es2023", + "lib": ["es2023", "dom"], + "allowJs": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "allowSyntheticDefaultImports": true, + "noImplicitAny": true, + "noImplicitThis": true, + "alwaysStrict": true, + "strictNullChecks": true, + "strictPropertyInitialization": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noEmitOnError": false, + "noEmit": true, + "inlineSourceMap": true, + "inlineSources": true, + "baseUrl": ".", + "esModuleInterop": true, + "experimentalDecorators": true, + "skipLibCheck": true, + "strict": true, + "types": ["@cardstack/local-types"] + }, + "include": ["./**/*"], + "exclude": ["node_modules"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc3d0cc4de..29d98be6c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -771,6 +771,9 @@ importers: '@cardstack/billing': specifier: workspace:* version: link:../billing + '@cardstack/bot-core': + specifier: workspace:* + version: link:../bot-core '@cardstack/postgres': specifier: workspace:* version: link:../postgres @@ -898,7 +901,7 @@ importers: version: 6.3.0 ember-concurrency: specifier: 'catalog:' - version: 4.0.3(@babel/core@7.26.10)(@glimmer/tracking@1.1.2)(@glint/template@1.3.0)(ember-source@5.4.1(patch_hash=f4d53cc0efd30368b913bdc898f342658768eaf0a8fb44678c0a07cf3aac24c1)(@babel/core@7.26.10)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.99.6)) + version: 4.0.3(@babel/core@7.26.10)(@glimmer/tracking@1.1.2)(@glint/template@1.3.0)(ember-source@5.4.1(patch_hash=f4d53cc0efd30368b913bdc898f342658768eaf0a8fb44678c0a07cf3aac24c1)(@babel/core@7.26.10)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)) ember-css-url: specifier: ^1.0.0 version: 1.0.0(patch_hash=0e5253a008cc7bf02424d786e7a8fb2397bc48962f3cd1443f2c0fdd8200c96d) @@ -943,6 +946,31 @@ importers: specifier: 'catalog:' version: 8.2.2 + packages/bot-core: + dependencies: + '@cardstack/postgres': + specifier: workspace:* + version: link:../postgres + '@cardstack/runtime-common': + specifier: workspace:* + version: link:../runtime-common + debug: + specifier: ^4.4.3 + version: 4.4.3(supports-color@8.1.1) + matrix-js-sdk: + specifier: 'catalog:' + version: 38.3.0(patch_hash=0472d34281d936a5dcdacff67d2851d88c1df9593cd06b7725ee8414c12aa1d5) + devDependencies: + '@types/debug': + specifier: ^4.1.12 + version: 4.1.12 + '@types/node': + specifier: 'catalog:' + version: 24.3.0 + typescript: + specifier: 'catalog:' + version: 5.8.3 + packages/boxel-homepage-realm: {} packages/boxel-icons: @@ -1097,7 +1125,7 @@ importers: version: 5.2.1 ember-concurrency: specifier: 'catalog:' - version: 4.0.3(@babel/core@7.26.10)(@glimmer/tracking@1.1.2)(@glint/template@1.3.0)(ember-source@5.4.1(patch_hash=f4d53cc0efd30368b913bdc898f342658768eaf0a8fb44678c0a07cf3aac24c1)(@babel/core@7.26.10)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.99.6)) + version: 4.0.3(@babel/core@7.26.10)(@glimmer/tracking@1.1.2)(@glint/template@1.3.0)(ember-source@5.4.1(patch_hash=f4d53cc0efd30368b913bdc898f342658768eaf0a8fb44678c0a07cf3aac24c1)(@babel/core@7.26.10)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)) ember-load-initializers: specifier: ^2.1.2 version: 2.1.2(@babel/core@7.26.10) @@ -1491,7 +1519,7 @@ importers: version: 8.0.4(patch_hash=19b0fc5d4bd8b9aa296c4065fa5e33bdbb965db0b277810b596eacd0b9e2f428)(@ember/string@4.0.1)(@ember/test-helpers@5.2.2(@babel/core@7.26.10)(@glint/template@1.3.0))(@glimmer/component@2.0.0)(@glimmer/tracking@1.1.2)(@glint/environment-ember-loose@1.5.2(@glimmer/component@2.0.0)(@glint/template@1.3.0)(ember-cli-htmlbars@6.3.0)(ember-modifier@4.1.0(ember-source@5.4.1(patch_hash=f4d53cc0efd30368b913bdc898f342658768eaf0a8fb44678c0a07cf3aac24c1)(@babel/core@7.26.10)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.99.6))))(@glint/template@1.3.0)(ember-source@5.4.1(patch_hash=f4d53cc0efd30368b913bdc898f342658768eaf0a8fb44678c0a07cf3aac24c1)(@babel/core@7.26.10)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.99.6)) ember-concurrency: specifier: 'catalog:' - version: 4.0.3(@babel/core@7.26.10)(@glimmer/tracking@1.1.2)(@glint/template@1.3.0)(ember-source@5.4.1(patch_hash=f4d53cc0efd30368b913bdc898f342658768eaf0a8fb44678c0a07cf3aac24c1)(@babel/core@7.26.10)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.99.6)) + version: 4.0.3(@babel/core@7.26.10)(@glimmer/tracking@1.1.2)(@glint/template@1.3.0)(ember-source@5.4.1(patch_hash=f4d53cc0efd30368b913bdc898f342658768eaf0a8fb44678c0a07cf3aac24c1)(@babel/core@7.26.10)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)) ember-concurrency-ts: specifier: 'catalog:' version: 0.3.1(ember-concurrency@4.0.3(@babel/core@7.26.10)(@glimmer/tracking@1.1.2)(@glint/template@1.3.0)(ember-source@5.4.1(patch_hash=f4d53cc0efd30368b913bdc898f342658768eaf0a8fb44678c0a07cf3aac24c1)(@babel/core@7.26.10)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.99.6))) @@ -1922,7 +1950,7 @@ importers: version: 8.2.2 ember-concurrency: specifier: 'catalog:' - version: 4.0.3(@babel/core@7.26.10)(@glimmer/tracking@1.1.2)(@glint/template@1.3.0)(ember-source@5.4.1(patch_hash=f4d53cc0efd30368b913bdc898f342658768eaf0a8fb44678c0a07cf3aac24c1)(@babel/core@7.26.10)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.99.6)) + version: 4.0.3(@babel/core@7.26.10)(@glimmer/tracking@1.1.2)(@glint/template@1.3.0)(ember-source@5.4.1(patch_hash=f4d53cc0efd30368b913bdc898f342658768eaf0a8fb44678c0a07cf3aac24c1)(@babel/core@7.26.10)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)) ember-modifier: specifier: ^4.1.0 version: 4.1.0(ember-source@5.4.1(patch_hash=f4d53cc0efd30368b913bdc898f342658768eaf0a8fb44678c0a07cf3aac24c1)(@babel/core@7.26.10)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.99.6)) @@ -2040,7 +2068,7 @@ importers: version: 2.2.0(@babel/core@7.26.10)(@ember/test-helpers@5.2.2(@babel/core@7.26.10)(@glint/template@1.3.0))(@glint/environment-ember-loose@1.5.2(@glimmer/component@2.0.0)(@glint/template@1.3.0)(ember-cli-htmlbars@6.3.0)(ember-modifier@4.1.0(ember-source@5.4.1(patch_hash=f4d53cc0efd30368b913bdc898f342658768eaf0a8fb44678c0a07cf3aac24c1)(@babel/core@7.26.10)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5))))(@glint/template@1.3.0)(ember-source@5.4.1(patch_hash=f4d53cc0efd30368b913bdc898f342658768eaf0a8fb44678c0a07cf3aac24c1)(@babel/core@7.26.10)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)) ember-concurrency: specifier: 'catalog:' - version: 4.0.3(@babel/core@7.26.10)(@glimmer/tracking@1.1.2)(@glint/template@1.3.0)(ember-source@5.4.1(patch_hash=f4d53cc0efd30368b913bdc898f342658768eaf0a8fb44678c0a07cf3aac24c1)(@babel/core@7.26.10)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.99.6)) + version: 4.0.3(@babel/core@7.26.10)(@glimmer/tracking@1.1.2)(@glint/template@1.3.0)(ember-source@5.4.1(patch_hash=f4d53cc0efd30368b913bdc898f342658768eaf0a8fb44678c0a07cf3aac24c1)(@babel/core@7.26.10)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)) ember-modifier: specifier: ^4.1.0 version: 4.1.0(ember-source@5.4.1(patch_hash=f4d53cc0efd30368b913bdc898f342658768eaf0a8fb44678c0a07cf3aac24c1)(@babel/core@7.26.10)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.99.6)) @@ -2323,7 +2351,7 @@ importers: version: 6.0.1(ember-source@5.4.1(patch_hash=f4d53cc0efd30368b913bdc898f342658768eaf0a8fb44678c0a07cf3aac24c1)(@babel/core@7.26.10)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.99.6)) ember-concurrency: specifier: 'catalog:' - version: 4.0.3(@babel/core@7.26.10)(@glimmer/tracking@1.1.2)(@glint/template@1.3.0)(ember-source@5.4.1(patch_hash=f4d53cc0efd30368b913bdc898f342658768eaf0a8fb44678c0a07cf3aac24c1)(@babel/core@7.26.10)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.99.6)) + version: 4.0.3(@babel/core@7.26.10)(@glimmer/tracking@1.1.2)(@glint/template@1.3.0)(ember-source@5.4.1(patch_hash=f4d53cc0efd30368b913bdc898f342658768eaf0a8fb44678c0a07cf3aac24c1)(@babel/core@7.26.10)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)) ember-css-url: specifier: ^1.0.0 version: 1.0.0(patch_hash=0e5253a008cc7bf02424d786e7a8fb2397bc48962f3cd1443f2c0fdd8200c96d) @@ -3104,6 +3132,40 @@ importers: packages/skills-realm: {} + packages/submission-bot: + dependencies: + '@cardstack/bot-core': + specifier: workspace:* + version: link:../bot-core + '@cardstack/postgres': + specifier: workspace:* + version: link:../postgres + '@cardstack/runtime-common': + specifier: workspace:* + version: link:../runtime-common + '@sentry/node': + specifier: 'catalog:' + version: 8.31.0 + debug: + specifier: ^4.4.3 + version: 4.4.3(supports-color@8.1.1) + matrix-js-sdk: + specifier: 'catalog:' + version: 38.3.0(patch_hash=0472d34281d936a5dcdacff67d2851d88c1df9593cd06b7725ee8414c12aa1d5) + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@24.3.0)(typescript@5.8.3) + typescript: + specifier: 'catalog:' + version: 5.8.3 + devDependencies: + '@types/debug': + specifier: ^4.1.12 + version: 4.1.12 + '@types/node': + specifier: 'catalog:' + version: 24.3.0 + packages/template-lint: devDependencies: ember-template-lint: @@ -5398,67 +5460,56 @@ packages: resolution: {integrity: sha512-y/qUMOpJxBMy8xCXD++jeu8t7kzjlOCkoxxajL58G62PJGBZVl/Gwpm7JK9+YvlB701rcQTzjUZ1JgUoPTnoQA==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.40.0': resolution: {integrity: sha512-GoCsPibtVdJFPv/BOIvBKO/XmwZLwaNWdyD8TKlXuqp0veo2sHE+A/vpMQ5iSArRUz/uaoj4h5S6Pn0+PdhRjg==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.40.0': resolution: {integrity: sha512-L5ZLphTjjAD9leJzSLI7rr8fNqJMlGDKlazW2tX4IUF9P7R5TMQPElpH82Q7eNIDQnQlAyiNVfRPfP2vM5Avvg==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.40.0': resolution: {integrity: sha512-ATZvCRGCDtv1Y4gpDIXsS+wfFeFuLwVxyUBSLawjgXK2tRE6fnsQEkE4csQQYWlBlsFztRzCnBvWVfcae/1qxQ==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loongarch64-gnu@4.40.0': resolution: {integrity: sha512-wG9e2XtIhd++QugU5MD9i7OnpaVb08ji3P1y/hNbxrQ3sYEelKJOq1UJ5dXczeo6Hj2rfDEL5GdtkMSVLa/AOg==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-powerpc64le-gnu@4.40.0': resolution: {integrity: sha512-vgXfWmj0f3jAUvC7TZSU/m/cOE558ILWDzS7jBhiCAFpY2WEBn5jqgbqvmzlMjtp8KlLcBlXVD2mkTSEQE6Ixw==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.40.0': resolution: {integrity: sha512-uJkYTugqtPZBS3Z136arevt/FsKTF/J9dEMTX/cwR7lsAW4bShzI2R0pJVw+hcBTWF4dxVckYh72Hk3/hWNKvA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.40.0': resolution: {integrity: sha512-rKmSj6EXQRnhSkE22+WvrqOqRtk733x3p5sWpZilhmjnkHkpeCgWsFFo0dGnUGeA+OZjRl3+VYq+HyCOEuwcxQ==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.40.0': resolution: {integrity: sha512-SpnYlAfKPOoVsQqmTFJ0usx0z84bzGOS9anAC0AZ3rdSo3snecihbhFTlJZ8XMwzqAcodjFU4+/SM311dqE5Sw==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.40.0': resolution: {integrity: sha512-RcDGMtqF9EFN8i2RYN2W+64CdHruJ5rPqrlYw+cgM3uOVPSsnAQps7cpjXe9be/yDp8UC7VLoCoKC8J3Kn2FkQ==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.40.0': resolution: {integrity: sha512-HZvjpiUmSNx5zFgwtQAV1GaGazT2RWvqeDi0hV+AtC8unqqDSsaFjPxfsO6qPtKRRg25SisACWnJ37Yio8ttaw==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.40.0': resolution: {integrity: sha512-UtZQQI5k/b8d7d3i9AZmA/t+Q4tk3hOC0tMOMSq2GlMYOfxbesxG4mJSeDp0EHs30N9bsfwUvs3zF4v/RzOeTQ==} @@ -20174,11 +20225,11 @@ snapshots: dependencies: ember-cli-babel: 7.26.11 ember-cli-htmlbars: 4.5.0 - ember-concurrency: 4.0.3(@babel/core@7.26.10)(@glimmer/tracking@1.1.2)(@glint/template@1.3.0)(ember-source@5.4.1(patch_hash=f4d53cc0efd30368b913bdc898f342658768eaf0a8fb44678c0a07cf3aac24c1)(@babel/core@7.26.10)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.99.6)) + ember-concurrency: 4.0.3(@babel/core@7.26.10)(@glimmer/tracking@1.1.2)(@glint/template@1.3.0)(ember-source@5.4.1(patch_hash=f4d53cc0efd30368b913bdc898f342658768eaf0a8fb44678c0a07cf3aac24c1)(@babel/core@7.26.10)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)) transitivePeerDependencies: - supports-color - ember-concurrency@4.0.3(@babel/core@7.26.10)(@glimmer/tracking@1.1.2)(@glint/template@1.3.0)(ember-source@5.4.1(patch_hash=f4d53cc0efd30368b913bdc898f342658768eaf0a8fb44678c0a07cf3aac24c1)(@babel/core@7.26.10)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.99.6)): + ember-concurrency@4.0.3(@babel/core@7.26.10)(@glimmer/tracking@1.1.2)(@glint/template@1.3.0)(ember-source@5.4.1(patch_hash=f4d53cc0efd30368b913bdc898f342658768eaf0a8fb44678c0a07cf3aac24c1)(@babel/core@7.26.10)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)): dependencies: '@babel/helper-module-imports': 7.25.9(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.26.5 @@ -20465,7 +20516,7 @@ snapshots: '@glimmer/tracking': 1.1.2 decorator-transforms: 2.3.0(@babel/core@7.26.10) ember-assign-helper: 0.5.0(ember-source@5.4.1(patch_hash=f4d53cc0efd30368b913bdc898f342658768eaf0a8fb44678c0a07cf3aac24c1)(@babel/core@7.26.10)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.99.6)) - ember-concurrency: 4.0.3(@babel/core@7.26.10)(@glimmer/tracking@1.1.2)(@glint/template@1.3.0)(ember-source@5.4.1(patch_hash=f4d53cc0efd30368b913bdc898f342658768eaf0a8fb44678c0a07cf3aac24c1)(@babel/core@7.26.10)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.99.6)) + ember-concurrency: 4.0.3(@babel/core@7.26.10)(@glimmer/tracking@1.1.2)(@glint/template@1.3.0)(ember-source@5.4.1(patch_hash=f4d53cc0efd30368b913bdc898f342658768eaf0a8fb44678c0a07cf3aac24c1)(@babel/core@7.26.10)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)) ember-element-helper: 0.8.6(@glint/environment-ember-loose@1.5.2(@glimmer/component@2.0.0)(@glint/template@1.3.0)(ember-cli-htmlbars@6.3.0)(ember-modifier@4.1.0(ember-source@5.4.1(patch_hash=f4d53cc0efd30368b913bdc898f342658768eaf0a8fb44678c0a07cf3aac24c1)(@babel/core@7.26.10)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5))))(@glint/template@1.3.0)(ember-source@5.4.1(patch_hash=f4d53cc0efd30368b913bdc898f342658768eaf0a8fb44678c0a07cf3aac24c1)(@babel/core@7.26.10)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)) ember-source: 5.4.1(patch_hash=f4d53cc0efd30368b913bdc898f342658768eaf0a8fb44678c0a07cf3aac24c1)(@babel/core@7.26.10)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.99.6) ember-truth-helpers: 4.0.3(ember-source@5.4.1(patch_hash=f4d53cc0efd30368b913bdc898f342658768eaf0a8fb44678c0a07cf3aac24c1)(@babel/core@7.26.10)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.99.6)) @@ -20486,7 +20537,7 @@ snapshots: decorator-transforms: 1.1.0(@babel/core@7.26.10) ember-assign-helper: 0.5.0(ember-source@5.4.1(patch_hash=f4d53cc0efd30368b913bdc898f342658768eaf0a8fb44678c0a07cf3aac24c1)(@babel/core@7.26.10)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.99.6)) ember-basic-dropdown: 8.0.4(patch_hash=19b0fc5d4bd8b9aa296c4065fa5e33bdbb965db0b277810b596eacd0b9e2f428)(@ember/string@4.0.1)(@ember/test-helpers@5.2.2(@babel/core@7.26.10)(@glint/template@1.3.0))(@glimmer/component@2.0.0)(@glimmer/tracking@1.1.2)(@glint/environment-ember-loose@1.5.2(@glimmer/component@2.0.0)(@glint/template@1.3.0)(ember-cli-htmlbars@6.3.0)(ember-modifier@4.1.0(ember-source@5.4.1(patch_hash=f4d53cc0efd30368b913bdc898f342658768eaf0a8fb44678c0a07cf3aac24c1)(@babel/core@7.26.10)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.99.6))))(@glint/template@1.3.0)(ember-source@5.4.1(patch_hash=f4d53cc0efd30368b913bdc898f342658768eaf0a8fb44678c0a07cf3aac24c1)(@babel/core@7.26.10)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.99.6)) - ember-concurrency: 4.0.3(@babel/core@7.26.10)(@glimmer/tracking@1.1.2)(@glint/template@1.3.0)(ember-source@5.4.1(patch_hash=f4d53cc0efd30368b913bdc898f342658768eaf0a8fb44678c0a07cf3aac24c1)(@babel/core@7.26.10)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.99.6)) + ember-concurrency: 4.0.3(@babel/core@7.26.10)(@glimmer/tracking@1.1.2)(@glint/template@1.3.0)(ember-source@5.4.1(patch_hash=f4d53cc0efd30368b913bdc898f342658768eaf0a8fb44678c0a07cf3aac24c1)(@babel/core@7.26.10)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)) ember-source: 5.4.1(patch_hash=f4d53cc0efd30368b913bdc898f342658768eaf0a8fb44678c0a07cf3aac24c1)(@babel/core@7.26.10)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.99.6) ember-truth-helpers: 4.0.3(ember-source@5.4.1(patch_hash=f4d53cc0efd30368b913bdc898f342658768eaf0a8fb44678c0a07cf3aac24c1)(@babel/core@7.26.10)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.99.6)) transitivePeerDependencies: From 979b6af5122e15ad2fba31645d1e2a022634a928 Mon Sep 17 00:00:00 2001 From: Richard Tan Date: Mon, 12 Jan 2026 18:19:01 +0800 Subject: [PATCH 2/2] Invite Submission bot command --- .../catalog-app/listing/listing.gts | 36 ++++++++ packages/host/app/commands/index.ts | 5 ++ .../app/commands/invite-submission-bot.ts | 90 +++++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 packages/host/app/commands/invite-submission-bot.ts diff --git a/packages/catalog-realm/catalog-app/listing/listing.gts b/packages/catalog-realm/catalog-app/listing/listing.gts index 9dc2dd9036..3aed44aad4 100644 --- a/packages/catalog-realm/catalog-app/listing/listing.gts +++ b/packages/catalog-realm/catalog-app/listing/listing.gts @@ -33,6 +33,7 @@ import { } from '@cardstack/boxel-ui/components'; import { eq, type MenuItemOptions } from '@cardstack/boxel-ui/helpers'; import Refresh from '@cardstack/boxel-icons/refresh'; +import Send from '@cardstack/boxel-icons/send'; import Wand from '@cardstack/boxel-icons/wand'; import AppListingHeader from '../components/app-listing-header'; @@ -42,6 +43,7 @@ import ListOfPills from '../components/list-of-pills'; import { listingActions, isReady } from '../resources/listing-actions'; import GetAllRealmMetasCommand from '@cardstack/boxel-host/commands/get-all-realm-metas'; +import InviteSubmissionBotCommand from '@cardstack/boxel-host/commands/invite-submission-bot'; import ListingGenerateExampleCommand from '@cardstack/boxel-host/commands/listing-generate-example'; import ListingUpdateSpecsCommand from '@cardstack/boxel-host/commands/listing-update-specs'; @@ -635,6 +637,36 @@ export class Listing extends CardDef { }; } + private getSubmitForReviewMenuItem( + params: GetCardMenuItemParams, + ): MenuItemOptions | undefined { + if (params.menuContext !== 'interact') { + return; + } + const commandContext = params.commandContext; + const cardId = this.id; + if (!commandContext || !cardId) { + return; + } + + return { + label: 'Submit for Review', + id: 'submit-listing-for-review', + icon: Send, + action: async () => { + try { + await new InviteSubmissionBotCommand(commandContext).execute({ + submissionTarget: cardId, + submissionType: 'listing', + autoStart: true, + }); + } catch (error) { + console.warn('Failed to submit listing for review', { error }); + } + }, + }; + } + [getCardMenuItems](params: GetCardMenuItemParams): MenuItemOptions[] { let menuItems = super [getCardMenuItems](params) @@ -648,6 +680,10 @@ export class Listing extends CardDef { if (updateSpecs) { menuItems = [...menuItems, updateSpecs]; } + const submitForReview = this.getSubmitForReviewMenuItem(params); + if (submitForReview) { + menuItems = [...menuItems, submitForReview]; + } } return menuItems; } diff --git a/packages/host/app/commands/index.ts b/packages/host/app/commands/index.ts index f78084bb5c..6b6370bbb5 100644 --- a/packages/host/app/commands/index.ts +++ b/packages/host/app/commands/index.ts @@ -18,6 +18,7 @@ import * as GenerateThemeExampleCommandModule from './generate-theme-example'; import * as GetAllRealmMetasCommandModule from './get-all-realm-metas'; import * as GetCardCommandModule from './get-card'; import * as GetEventsFromRoomCommandModule from './get-events-from-room'; +import * as InviteSubmissionBotCommandModule from './invite-submission-bot'; import * as LintAndFixCommandModule from './lint-and-fix'; import * as ListingBuildCommandModule from './listing-action-build'; import * as ListingInitCommandModule from './listing-action-init'; @@ -114,6 +115,10 @@ export function shimHostCommands(virtualNetwork: VirtualNetwork) { '@cardstack/boxel-host/commands/get-events-from-room', GetEventsFromRoomCommandModule, ); + virtualNetwork.shimModule( + '@cardstack/boxel-host/commands/invite-submission-bot', + InviteSubmissionBotCommandModule, + ); virtualNetwork.shimModule( '@cardstack/boxel-host/commands/lint-and-fix', LintAndFixCommandModule, diff --git a/packages/host/app/commands/invite-submission-bot.ts b/packages/host/app/commands/invite-submission-bot.ts new file mode 100644 index 0000000000..63b031ea52 --- /dev/null +++ b/packages/host/app/commands/invite-submission-bot.ts @@ -0,0 +1,90 @@ +import { service } from '@ember/service'; + +import format from 'date-fns/format'; + +import type * as BaseCommandModule from 'https://cardstack.com/base/command'; + +import HostBaseCommand from '../lib/host-base-command'; + +import type MatrixService from '../services/matrix-service'; +import type OperatorModeStateService from '../services/operator-mode-state-service'; + +// Room state event type for submission context +const SUBMISSION_CONTEXT_EVENT_TYPE = 'com.cardstack.submission_context'; + +export default class InviteSubmissionBotCommand extends HostBaseCommand< + typeof BaseCommandModule.InviteSubmissionBotInput +> { + @service declare private matrixService: MatrixService; + @service declare private operatorModeStateService: OperatorModeStateService; + + static actionVerb = 'Submit'; + + async getInputType() { + let commandModule = await this.loadCommandModule(); + const { InviteSubmissionBotInput } = commandModule; + return InviteSubmissionBotInput; + } + + requireInputFields = ['submissionTarget']; + + protected async run( + input: BaseCommandModule.InviteSubmissionBotInput, + ): Promise { + const { matrixService, operatorModeStateService } = this; + const { submissionTarget, submissionType, autoStart, metadata } = input; + + if (!submissionTarget) { + throw new Error('submissionTarget is required'); + } + + const submissionBotId = matrixService.submissionBotUserId; + const userId = matrixService.userId; + + if (!userId) { + throw new Error('User must be logged in to submit for review'); + } + + // Always create a new room for the submission + const roomName = `Submission: ${submissionType || 'review'} - ${format(new Date(), 'yyyy-MM-dd HH:mm')}`; + + const roomResult = await matrixService.createRoom({ + preset: matrixService.privateChatPreset, + invite: [submissionBotId], + name: roomName, + room_alias_name: encodeURIComponent( + `submission-${format(new Date(), "yyyy-MM-dd'T'HH:mm:ss.SSSxxx")}-${userId}`, + ), + power_level_content_override: { + users: { + [userId]: 100, + [submissionBotId]: 50, + }, + }, + }); + + const roomId = roomResult.room_id; + + // Store submission context in room state + // This allows the submission bot to read context when it joins + await matrixService.sendStateEvent( + roomId, + SUBMISSION_CONTEXT_EVENT_TYPE, + { + target: submissionTarget, + type: submissionType || 'other', + autoStart: autoStart ?? false, + metadata: metadata || {}, + invitedAt: new Date().toISOString(), + invitedBy: userId, + }, + '', // empty state key + ); + + // Open the AI assistant panel with the new room + operatorModeStateService.openAiAssistant(); + matrixService.currentRoomId = roomId; + + return undefined; + } +}