Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,7 @@ jobs:
"search-prerendered-test.ts",
"types-endpoint-test.ts",
"server-endpoints/authentication-test.ts",
"server-endpoints/bot-registration-test.ts",
"server-endpoints/index-responses-test.ts",
"server-endpoints/maintenance-endpoints-test.ts",
"server-endpoints/queue-status-test.ts",
Expand Down
12 changes: 12 additions & 0 deletions packages/base/command.gts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,18 @@ export class CreateAIAssistantRoomResult extends CardDef {
@field roomId = contains(StringField);
}

export class RegisterBotInput extends CardDef {
@field username = contains(StringField);
}

export class RegisterBotResult extends CardDef {
@field botRegistrationId = contains(StringField);
}

export class UnregisterBotInput extends CardDef {
@field botRegistrationId = contains(StringField);
}

export class SetActiveLLMInput extends CardDef {
@field roomId = contains(StringField);
@field model = contains(StringField);
Expand Down
82 changes: 82 additions & 0 deletions packages/bot-runner/README.md
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>"
}
}
9 changes: 9 additions & 0 deletions packages/bot-runner/instrument.ts
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
});
}
36 changes: 36 additions & 0 deletions packages/bot-runner/lib/membership-handler.ts
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);
}
};
Comment thread
tintinthong marked this conversation as resolved.
}
105 changes: 105 additions & 0 deletions packages/bot-runner/lib/timeline-handler.ts
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,
};
}
73 changes: 73 additions & 0 deletions packages/bot-runner/main.ts
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);
Comment thread
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);
});
Comment thread
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);
});
26 changes: 26 additions & 0 deletions packages/bot-runner/package.json
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"
}
}
5 changes: 5 additions & 0 deletions packages/bot-runner/setup-logger.ts
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',
);
Loading