-
Notifications
You must be signed in to change notification settings - Fork 12
Create bot runner package #3868
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
442f82c
wip
tintinthong 5eed8a5
Create bot registration endpoint (#3881)
tintinthong b2d280e
Merge branch 'main' into create-bot-runner-package
tintinthong c525189
add logs
tintinthong 32e24ec
fix migration file name
tintinthong 061da7d
fix lint
tintinthong cc60d6e
forgot migration contents
tintinthong b8b3478
fix copilot changes
tintinthong 839bc42
remove botRunner from createAiAssisstantRoom
tintinthong 02cc129
update sentry_dsn
tintinthong d21e3ed
add readme
tintinthong File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| # Bot Runner | ||
|
|
||
| This doc describes the bot runner process, how it registers, and how it is invited into AI assistant rooms. | ||
|
|
||
| ## Overview | ||
|
|
||
| The bot runner is a separate Node process that listens to Matrix room events and can enqueue work via the realm server queue. | ||
|
|
||
| The bot runner is a valid matrix user and has admin access. | ||
|
|
||
| In order to use it, a user must | ||
| - invite the bot runner admin to a room | ||
| - register the bot via the realm-server bot-registration endpoint | ||
| - register bot commands so the bot runner knows what matrix event to listen to and the corresponding command to fire | ||
|
|
||
| ## How to Run Locally | ||
|
|
||
| Environment variables: | ||
| - `MATRIX_URL` (default: `http://localhost:8008`) | ||
| - `BOT_RUNNER_USERNAME` (default: `bot-runner`) | ||
| - `BOT_RUNNER_PASSWORD` (default: `password`) | ||
| - `LOG_LEVELS` (default: `*=info`) | ||
| - `BOT_RUNNER_SENTRY_DSN` (optional) | ||
| - `SENTRY_ENVIRONMENT` (optional, default: `development`) | ||
|
|
||
| ``` | ||
| pnpm start:development | ||
| ``` | ||
|
|
||
|
|
||
| ## Bot Registration | ||
|
|
||
| The realm server stores bot registration rows. This does not create a Matrix user; it records the Matrix user ID (e.g. `@user:localhost`, which is also validated against the users table) and assigns a bot registration `id`. | ||
|
|
||
| ### Register | ||
|
|
||
| Register (JSON:API): | ||
| - POST `/_bot-registration` | ||
| - Body: | ||
| { | ||
| "data": { | ||
| "type": "bot-registration", | ||
| "attributes": { | ||
| "username": "@bot-runner:localhost" | ||
| } | ||
| } | ||
| } | ||
| - The request must be authenticated with a realm server JWT. | ||
| - The `username` is the Matrix user id and must match the authenticated user id. | ||
|
|
||
| ### List | ||
|
|
||
| List registrations: | ||
| - GET `/_bot-registrations` | ||
| - Only returns bot registrations for the authenticated user. | ||
|
|
||
| ### Register via script | ||
|
|
||
| Register via script | ||
| ```sh | ||
| REALM_SERVER_URL="http://localhost:4201" \ | ||
| REALM_SERVER_JWT="..." \ | ||
| USERNAME="@bot-runner:localhost" \ | ||
| ./packages/realm-server/scripts/register-bot.sh | ||
| ``` | ||
|
|
||
| Defaults and requirements: | ||
| - `REALM_SERVER_URL` (default: `http://localhost:4201`) | ||
| - `REALM_SERVER_JWT` (required) | ||
| - `USERNAME` (default: `@user:localhost`, Matrix user id) | ||
|
|
||
| ### Unregister | ||
|
|
||
| Unregister: | ||
| - DELETE `/_bot-registration` | ||
| - Body: | ||
| { | ||
| "data": { | ||
| "type": "bot-registration", | ||
| "id": "<botRegistrationId>" | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| import * as Sentry from '@sentry/node'; | ||
|
|
||
| if (process.env.BOT_RUNNER_SENTRY_DSN) { | ||
| Sentry.init({ | ||
| dsn: process.env.BOT_RUNNER_SENTRY_DSN, | ||
| environment: process.env.SENTRY_ENVIRONMENT || 'development', | ||
| maxValueLength: 8192, // prevents error messages reported in sentry from being truncated | ||
| }); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| import { logger } from '@cardstack/runtime-common'; | ||
| import * as Sentry from '@sentry/node'; | ||
| import type { MatrixClient, MatrixEvent, RoomMember } from 'matrix-js-sdk'; | ||
|
|
||
| const log = logger('bot-runner'); | ||
|
|
||
| export interface MembershipHandlerOptions { | ||
| client: MatrixClient; | ||
| authUserId: string; | ||
| startTime: number; | ||
| } | ||
|
|
||
| export function onMembershipEvent({ | ||
| client, | ||
| authUserId, | ||
| startTime, | ||
| }: MembershipHandlerOptions) { | ||
| return async function handleMembershipEvent( | ||
| membershipEvent: MatrixEvent, | ||
| member: RoomMember, | ||
| ) { | ||
| try { | ||
| let originServerTs = membershipEvent.event.origin_server_ts; | ||
| if (originServerTs == null || originServerTs < startTime) { | ||
| return; | ||
| } | ||
| if (member.membership === 'invite' && member.userId === authUserId) { | ||
| log.info(`joining room ${member.roomId} from invite`); | ||
| await client.joinRoom(member.roomId); | ||
| } | ||
| } catch (error) { | ||
| log.error('error handling membership event', error); | ||
| Sentry.captureException(error); | ||
| } | ||
| }; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| import { logger, param, query } from '@cardstack/runtime-common'; | ||
| import * as Sentry from '@sentry/node'; | ||
| import type { DBAdapter, PgPrimitive } from '@cardstack/runtime-common'; | ||
| import type { MatrixEvent, Room } from 'matrix-js-sdk'; | ||
|
|
||
| const log = logger('bot-runner'); | ||
| export interface BotRegistration { | ||
| id: string; | ||
| created_at: string; | ||
| username: string; | ||
| } | ||
|
|
||
| export interface TimelineHandlerOptions { | ||
| authUserId: string; | ||
| dbAdapter: DBAdapter; | ||
| } | ||
|
|
||
| export function onTimelineEvent({ | ||
| authUserId, | ||
| dbAdapter, | ||
| }: TimelineHandlerOptions) { | ||
| return async function handleTimelineEvent( | ||
| event: MatrixEvent, | ||
| room: Room | undefined, | ||
| toStartOfTimeline: boolean | undefined, | ||
| ) { | ||
| try { | ||
| if (!room || toStartOfTimeline) { | ||
| return; | ||
| } | ||
| if (room.getMyMembership() !== 'join') { | ||
| return; | ||
| } | ||
|
|
||
| let senderUsername = event.getSender(); | ||
| if (!senderUsername || senderUsername === authUserId) { | ||
| return; | ||
| } | ||
|
|
||
| let registrations = await getRegistrationsForUser( | ||
| dbAdapter, | ||
| senderUsername, | ||
| ); | ||
| if (!registrations.length) { | ||
| return; | ||
| } | ||
| log.debug( | ||
| `received event from ${senderUsername} in room ${room.roomId} with ${registrations.length} registrations`, | ||
| ); | ||
| for (let registration of registrations) { | ||
| let createdAt = Date.parse(registration.created_at); | ||
| if (Number.isNaN(createdAt)) { | ||
| continue; | ||
| } | ||
| let eventTimestamp = event.event.origin_server_ts; | ||
| if (eventTimestamp == null || eventTimestamp < createdAt) { | ||
| continue; | ||
| } | ||
| // TODO: filter out events we want to handle based on the registration (e.g. command messages, system events) | ||
| // TODO: handle the event for this registration (e.g. enqueue a job). | ||
| } | ||
| } catch (error) { | ||
| log.error('error handling timeline event', error); | ||
| Sentry.captureException(error); | ||
| } | ||
| }; | ||
| } | ||
|
|
||
| async function getRegistrationsForUser( | ||
| dbAdapter: DBAdapter, | ||
| username: string, | ||
| ): Promise<BotRegistration[]> { | ||
| let rows = await query(dbAdapter, [ | ||
| `SELECT br.id, br.username, br.created_at`, | ||
| `FROM bot_registrations br`, | ||
| `WHERE br.username = `, | ||
| param(username), | ||
| ]); | ||
|
|
||
| let registrations: BotRegistration[] = []; | ||
| for (let row of rows) { | ||
| let registration = toBotRegistration(row); | ||
| if (registration) { | ||
| registrations.push(registration); | ||
| } | ||
| } | ||
| return registrations; | ||
| } | ||
|
|
||
| function toBotRegistration( | ||
| row: Record<string, PgPrimitive>, | ||
| ): BotRegistration | null { | ||
| if ( | ||
| typeof row.id !== 'string' || | ||
| typeof row.username !== 'string' || | ||
| typeof row.created_at !== 'string' | ||
| ) { | ||
| return null; | ||
| } | ||
| return { | ||
| id: row.id, | ||
| username: row.username, | ||
| created_at: row.created_at, | ||
| }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| import './instrument'; | ||
| import './setup-logger'; // This should be first | ||
| import { RoomMemberEvent, RoomEvent, createClient } from 'matrix-js-sdk'; | ||
| import { PgAdapter, PgQueuePublisher } from '@cardstack/postgres'; | ||
| import { logger } from '@cardstack/runtime-common'; | ||
| import { onMembershipEvent } from './lib/membership-handler'; | ||
| import { onTimelineEvent } from './lib/timeline-handler'; | ||
|
|
||
| const log = logger('bot-runner'); | ||
| const startTime = Date.now(); | ||
|
|
||
| const matrixUrl = process.env.MATRIX_URL || 'http://localhost:8008'; | ||
| const botUsername = process.env.BOT_RUNNER_USERNAME || 'bot-runner'; | ||
| const botPassword = process.env.BOT_RUNNER_PASSWORD || 'password'; | ||
|
|
||
| (async () => { | ||
| let client = createClient({ | ||
| baseUrl: matrixUrl, | ||
| }); | ||
|
|
||
| let auth = await client | ||
| .loginWithPassword(botUsername, botPassword) | ||
| .catch((error) => { | ||
| log.error(error); | ||
| log.error( | ||
| `Bot runner could not login to Matrix at ${matrixUrl}. Check credentials and server availability.`, | ||
| ); | ||
| process.exit(1); | ||
| }); | ||
|
|
||
| log.info(`logged in as ${auth.user_id}`); | ||
|
|
||
| let dbAdapter = new PgAdapter(); | ||
| let queuePublisher = new PgQueuePublisher(dbAdapter); | ||
|
tintinthong marked this conversation as resolved.
|
||
|
|
||
| const shutdown = async () => { | ||
| log.info('shutting down bot runner...'); | ||
| try { | ||
| await queuePublisher.destroy(); | ||
| await dbAdapter.close(); | ||
| } catch (error) { | ||
| log.error('error during shutdown', error); | ||
| process.exit(1); | ||
| } | ||
| process.exit(0); | ||
| }; | ||
|
|
||
| process.on('SIGINT', shutdown); | ||
| process.on('SIGTERM', shutdown); | ||
|
|
||
| client.on( | ||
| RoomMemberEvent.Membership, | ||
| onMembershipEvent({ | ||
| client, | ||
| authUserId: auth.user_id, | ||
| startTime, | ||
| }), | ||
| ); | ||
|
|
||
| let handleTimelineEvent = onTimelineEvent({ | ||
| authUserId: auth.user_id, | ||
| dbAdapter, | ||
| }); | ||
| client.on(RoomEvent.Timeline, async (event, room, toStartOfTimeline) => { | ||
| await handleTimelineEvent(event, room, toStartOfTimeline); | ||
| }); | ||
|
tintinthong marked this conversation as resolved.
|
||
|
|
||
| client.startClient(); | ||
| log.info('bot runner listening for Matrix events'); | ||
| })().catch((error) => { | ||
| log.error('bot runner failed to start', error); | ||
| process.exit(1); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| { | ||
| "name": "@cardstack/bot-runner", | ||
| "dependencies": { | ||
| "@cardstack/postgres": "workspace:*", | ||
| "@cardstack/runtime-common": "workspace:*", | ||
| "@sentry/node": "catalog:", | ||
| "matrix-js-sdk": "catalog:", | ||
| "ts-node": "^10.9.2", | ||
| "typescript": "catalog:" | ||
| }, | ||
| "devDependencies": { | ||
| "@cardstack/local-types": "workspace:*", | ||
| "@types/node": "catalog:", | ||
| "@types/qunit": "catalog:", | ||
| "qunit": "catalog:" | ||
| }, | ||
| "scripts": { | ||
| "start": "NODE_NO_WARNINGS=1 ts-node --transpileOnly main", | ||
| "start:development": "NODE_NO_WARNINGS=1 PGDATABASE=boxel PGPORT=5435 ts-node --transpileOnly main", | ||
| "test": "NODE_NO_WARNINGS=1 qunit --require ts-node/register/transpile-only tests/index.ts", | ||
| "lint": "glint" | ||
| }, | ||
| "volta": { | ||
| "extends": "../../package.json" | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| import { makeLogDefinitions } from '@cardstack/runtime-common'; | ||
|
|
||
| (globalThis as any)._logDefinitions = makeLogDefinitions( | ||
| process.env.LOG_LEVELS || '*=info', | ||
| ); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.