Skip to content

Commit 94a0901

Browse files
authored
Create bot runner package (#3868)
* wip * Create bot registration endpoint (#3881) * add bot registration endpoint * add script to register bot * add register bot commands * update generic bot runner * fix copilot recommendations * fix handling of bot runner with registrations. * fix tests + fix copilot recommendations * include accept header and content type for test * cleanup code * get type of full row * add method to fetchRequestFromContext * fix schema * lint * fix lint types and add test for runner * fix type * simplify usage of username rather than matrix_user_id * fix more lint * fix lint * more defensive programming * fix lint * add logs * fix migration file name * fix lint * forgot migration contents * fix copilot changes * remove botRunner from createAiAssisstantRoom * update sentry_dsn * add readme
1 parent 06f550e commit 94a0901

29 files changed

Lines changed: 1794 additions & 21 deletions

.github/workflows/ci.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,7 @@ jobs:
461461
"search-prerendered-test.ts",
462462
"types-endpoint-test.ts",
463463
"server-endpoints/authentication-test.ts",
464+
"server-endpoints/bot-registration-test.ts",
464465
"server-endpoints/index-responses-test.ts",
465466
"server-endpoints/maintenance-endpoints-test.ts",
466467
"server-endpoints/queue-status-test.ts",

packages/base/command.gts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,18 @@ export class CreateAIAssistantRoomResult extends CardDef {
249249
@field roomId = contains(StringField);
250250
}
251251

252+
export class RegisterBotInput extends CardDef {
253+
@field username = contains(StringField);
254+
}
255+
256+
export class RegisterBotResult extends CardDef {
257+
@field botRegistrationId = contains(StringField);
258+
}
259+
260+
export class UnregisterBotInput extends CardDef {
261+
@field botRegistrationId = contains(StringField);
262+
}
263+
252264
export class SetActiveLLMInput extends CardDef {
253265
@field roomId = contains(StringField);
254266
@field model = contains(StringField);

packages/bot-runner/README.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# Bot Runner
2+
3+
This doc describes the bot runner process, how it registers, and how it is invited into AI assistant rooms.
4+
5+
## Overview
6+
7+
The bot runner is a separate Node process that listens to Matrix room events and can enqueue work via the realm server queue.
8+
9+
The bot runner is a valid matrix user and has admin access.
10+
11+
In order to use it, a user must
12+
- invite the bot runner admin to a room
13+
- register the bot via the realm-server bot-registration endpoint
14+
- register bot commands so the bot runner knows what matrix event to listen to and the corresponding command to fire
15+
16+
## How to Run Locally
17+
18+
Environment variables:
19+
- `MATRIX_URL` (default: `http://localhost:8008`)
20+
- `BOT_RUNNER_USERNAME` (default: `bot-runner`)
21+
- `BOT_RUNNER_PASSWORD` (default: `password`)
22+
- `LOG_LEVELS` (default: `*=info`)
23+
- `BOT_RUNNER_SENTRY_DSN` (optional)
24+
- `SENTRY_ENVIRONMENT` (optional, default: `development`)
25+
26+
```
27+
pnpm start:development
28+
```
29+
30+
31+
## Bot Registration
32+
33+
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`.
34+
35+
### Register
36+
37+
Register (JSON:API):
38+
- POST `/_bot-registration`
39+
- Body:
40+
{
41+
"data": {
42+
"type": "bot-registration",
43+
"attributes": {
44+
"username": "@bot-runner:localhost"
45+
}
46+
}
47+
}
48+
- The request must be authenticated with a realm server JWT.
49+
- The `username` is the Matrix user id and must match the authenticated user id.
50+
51+
### List
52+
53+
List registrations:
54+
- GET `/_bot-registrations`
55+
- Only returns bot registrations for the authenticated user.
56+
57+
### Register via script
58+
59+
Register via script
60+
```sh
61+
REALM_SERVER_URL="http://localhost:4201" \
62+
REALM_SERVER_JWT="..." \
63+
USERNAME="@bot-runner:localhost" \
64+
./packages/realm-server/scripts/register-bot.sh
65+
```
66+
67+
Defaults and requirements:
68+
- `REALM_SERVER_URL` (default: `http://localhost:4201`)
69+
- `REALM_SERVER_JWT` (required)
70+
- `USERNAME` (default: `@user:localhost`, Matrix user id)
71+
72+
### Unregister
73+
74+
Unregister:
75+
- DELETE `/_bot-registration`
76+
- Body:
77+
{
78+
"data": {
79+
"type": "bot-registration",
80+
"id": "<botRegistrationId>"
81+
}
82+
}

packages/bot-runner/instrument.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import * as Sentry from '@sentry/node';
2+
3+
if (process.env.BOT_RUNNER_SENTRY_DSN) {
4+
Sentry.init({
5+
dsn: process.env.BOT_RUNNER_SENTRY_DSN,
6+
environment: process.env.SENTRY_ENVIRONMENT || 'development',
7+
maxValueLength: 8192, // prevents error messages reported in sentry from being truncated
8+
});
9+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { logger } from '@cardstack/runtime-common';
2+
import * as Sentry from '@sentry/node';
3+
import type { MatrixClient, MatrixEvent, RoomMember } from 'matrix-js-sdk';
4+
5+
const log = logger('bot-runner');
6+
7+
export interface MembershipHandlerOptions {
8+
client: MatrixClient;
9+
authUserId: string;
10+
startTime: number;
11+
}
12+
13+
export function onMembershipEvent({
14+
client,
15+
authUserId,
16+
startTime,
17+
}: MembershipHandlerOptions) {
18+
return async function handleMembershipEvent(
19+
membershipEvent: MatrixEvent,
20+
member: RoomMember,
21+
) {
22+
try {
23+
let originServerTs = membershipEvent.event.origin_server_ts;
24+
if (originServerTs == null || originServerTs < startTime) {
25+
return;
26+
}
27+
if (member.membership === 'invite' && member.userId === authUserId) {
28+
log.info(`joining room ${member.roomId} from invite`);
29+
await client.joinRoom(member.roomId);
30+
}
31+
} catch (error) {
32+
log.error('error handling membership event', error);
33+
Sentry.captureException(error);
34+
}
35+
};
36+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { logger, param, query } from '@cardstack/runtime-common';
2+
import * as Sentry from '@sentry/node';
3+
import type { DBAdapter, PgPrimitive } from '@cardstack/runtime-common';
4+
import type { MatrixEvent, Room } from 'matrix-js-sdk';
5+
6+
const log = logger('bot-runner');
7+
export interface BotRegistration {
8+
id: string;
9+
created_at: string;
10+
username: string;
11+
}
12+
13+
export interface TimelineHandlerOptions {
14+
authUserId: string;
15+
dbAdapter: DBAdapter;
16+
}
17+
18+
export function onTimelineEvent({
19+
authUserId,
20+
dbAdapter,
21+
}: TimelineHandlerOptions) {
22+
return async function handleTimelineEvent(
23+
event: MatrixEvent,
24+
room: Room | undefined,
25+
toStartOfTimeline: boolean | undefined,
26+
) {
27+
try {
28+
if (!room || toStartOfTimeline) {
29+
return;
30+
}
31+
if (room.getMyMembership() !== 'join') {
32+
return;
33+
}
34+
35+
let senderUsername = event.getSender();
36+
if (!senderUsername || senderUsername === authUserId) {
37+
return;
38+
}
39+
40+
let registrations = await getRegistrationsForUser(
41+
dbAdapter,
42+
senderUsername,
43+
);
44+
if (!registrations.length) {
45+
return;
46+
}
47+
log.debug(
48+
`received event from ${senderUsername} in room ${room.roomId} with ${registrations.length} registrations`,
49+
);
50+
for (let registration of registrations) {
51+
let createdAt = Date.parse(registration.created_at);
52+
if (Number.isNaN(createdAt)) {
53+
continue;
54+
}
55+
let eventTimestamp = event.event.origin_server_ts;
56+
if (eventTimestamp == null || eventTimestamp < createdAt) {
57+
continue;
58+
}
59+
// TODO: filter out events we want to handle based on the registration (e.g. command messages, system events)
60+
// TODO: handle the event for this registration (e.g. enqueue a job).
61+
}
62+
} catch (error) {
63+
log.error('error handling timeline event', error);
64+
Sentry.captureException(error);
65+
}
66+
};
67+
}
68+
69+
async function getRegistrationsForUser(
70+
dbAdapter: DBAdapter,
71+
username: string,
72+
): Promise<BotRegistration[]> {
73+
let rows = await query(dbAdapter, [
74+
`SELECT br.id, br.username, br.created_at`,
75+
`FROM bot_registrations br`,
76+
`WHERE br.username = `,
77+
param(username),
78+
]);
79+
80+
let registrations: BotRegistration[] = [];
81+
for (let row of rows) {
82+
let registration = toBotRegistration(row);
83+
if (registration) {
84+
registrations.push(registration);
85+
}
86+
}
87+
return registrations;
88+
}
89+
90+
function toBotRegistration(
91+
row: Record<string, PgPrimitive>,
92+
): BotRegistration | null {
93+
if (
94+
typeof row.id !== 'string' ||
95+
typeof row.username !== 'string' ||
96+
typeof row.created_at !== 'string'
97+
) {
98+
return null;
99+
}
100+
return {
101+
id: row.id,
102+
username: row.username,
103+
created_at: row.created_at,
104+
};
105+
}

packages/bot-runner/main.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import './instrument';
2+
import './setup-logger'; // This should be first
3+
import { RoomMemberEvent, RoomEvent, createClient } from 'matrix-js-sdk';
4+
import { PgAdapter, PgQueuePublisher } from '@cardstack/postgres';
5+
import { logger } from '@cardstack/runtime-common';
6+
import { onMembershipEvent } from './lib/membership-handler';
7+
import { onTimelineEvent } from './lib/timeline-handler';
8+
9+
const log = logger('bot-runner');
10+
const startTime = Date.now();
11+
12+
const matrixUrl = process.env.MATRIX_URL || 'http://localhost:8008';
13+
const botUsername = process.env.BOT_RUNNER_USERNAME || 'bot-runner';
14+
const botPassword = process.env.BOT_RUNNER_PASSWORD || 'password';
15+
16+
(async () => {
17+
let client = createClient({
18+
baseUrl: matrixUrl,
19+
});
20+
21+
let auth = await client
22+
.loginWithPassword(botUsername, botPassword)
23+
.catch((error) => {
24+
log.error(error);
25+
log.error(
26+
`Bot runner could not login to Matrix at ${matrixUrl}. Check credentials and server availability.`,
27+
);
28+
process.exit(1);
29+
});
30+
31+
log.info(`logged in as ${auth.user_id}`);
32+
33+
let dbAdapter = new PgAdapter();
34+
let queuePublisher = new PgQueuePublisher(dbAdapter);
35+
36+
const shutdown = async () => {
37+
log.info('shutting down bot runner...');
38+
try {
39+
await queuePublisher.destroy();
40+
await dbAdapter.close();
41+
} catch (error) {
42+
log.error('error during shutdown', error);
43+
process.exit(1);
44+
}
45+
process.exit(0);
46+
};
47+
48+
process.on('SIGINT', shutdown);
49+
process.on('SIGTERM', shutdown);
50+
51+
client.on(
52+
RoomMemberEvent.Membership,
53+
onMembershipEvent({
54+
client,
55+
authUserId: auth.user_id,
56+
startTime,
57+
}),
58+
);
59+
60+
let handleTimelineEvent = onTimelineEvent({
61+
authUserId: auth.user_id,
62+
dbAdapter,
63+
});
64+
client.on(RoomEvent.Timeline, async (event, room, toStartOfTimeline) => {
65+
await handleTimelineEvent(event, room, toStartOfTimeline);
66+
});
67+
68+
client.startClient();
69+
log.info('bot runner listening for Matrix events');
70+
})().catch((error) => {
71+
log.error('bot runner failed to start', error);
72+
process.exit(1);
73+
});

packages/bot-runner/package.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"name": "@cardstack/bot-runner",
3+
"dependencies": {
4+
"@cardstack/postgres": "workspace:*",
5+
"@cardstack/runtime-common": "workspace:*",
6+
"@sentry/node": "catalog:",
7+
"matrix-js-sdk": "catalog:",
8+
"ts-node": "^10.9.2",
9+
"typescript": "catalog:"
10+
},
11+
"devDependencies": {
12+
"@cardstack/local-types": "workspace:*",
13+
"@types/node": "catalog:",
14+
"@types/qunit": "catalog:",
15+
"qunit": "catalog:"
16+
},
17+
"scripts": {
18+
"start": "NODE_NO_WARNINGS=1 ts-node --transpileOnly main",
19+
"start:development": "NODE_NO_WARNINGS=1 PGDATABASE=boxel PGPORT=5435 ts-node --transpileOnly main",
20+
"test": "NODE_NO_WARNINGS=1 qunit --require ts-node/register/transpile-only tests/index.ts",
21+
"lint": "glint"
22+
},
23+
"volta": {
24+
"extends": "../../package.json"
25+
}
26+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { makeLogDefinitions } from '@cardstack/runtime-common';
2+
3+
(globalThis as any)._logDefinitions = makeLogDefinitions(
4+
process.env.LOG_LEVELS || '*=info',
5+
);

0 commit comments

Comments
 (0)