Skip to content

Commit 69ec23c

Browse files
Draft example bot
0 parents  commit 69ec23c

File tree

7 files changed

+1639
-0
lines changed

7 files changed

+1639
-0
lines changed

.gitignore

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Application
2+
/data
3+
4+
# TypeScript
5+
/dist
6+
7+
# Yarn
8+
/node_modules

matrix-js-sdk.d.ts

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/* Missing from @types/matrix-js-sdk */
2+
declare module "matrix-js-sdk" {
3+
export class AutoDiscovery {
4+
public static findClientConfig(domain: string): Promise<ClientWellKnown>;
5+
}
6+
7+
export interface ClientWellKnown {
8+
"m.homeserver": {
9+
base_url: string;
10+
};
11+
}
12+
}

package.json

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"name": "patch",
3+
"version": "0.0.0",
4+
"license": "GPL-3.0-only",
5+
"private": true,
6+
"type": "module",
7+
"engines": {
8+
"node": "16"
9+
},
10+
"dependencies": {
11+
"bottleneck": "^2.19.5",
12+
"luxon": "^2.0.2",
13+
"matrix-bot-sdk": "^0.5.19",
14+
"matrix-js-sdk": "^12.5.0",
15+
"node-fetch": "^3.0.0"
16+
},
17+
"devDependencies": {
18+
"@tsconfig/node16": "^1.0.2",
19+
"@types/luxon": "^2.0.3",
20+
"@types/node": "^16.7.10",
21+
"typescript": "^4.4.2"
22+
},
23+
"scripts": {
24+
"build": "./node_modules/.bin/tsc",
25+
"start": "node ./dist/main.js"
26+
}
27+
}

src/main.ts

+319
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
import Bottleneck from "bottleneck";
2+
import { DateTime, Settings } from "luxon";
3+
import {
4+
MatrixClient,
5+
MentionPill,
6+
RichReply,
7+
SimpleFsStorageProvider,
8+
} from "matrix-bot-sdk";
9+
import { AutoDiscovery } from "matrix-js-sdk";
10+
import fetch from "node-fetch";
11+
import { env } from "./utilities";
12+
13+
Settings.defaultZone = "America/Los_Angeles";
14+
15+
const config = {
16+
homeserver: env("MATRIX_HOMESERVER"),
17+
accessToken: env("MATRIX_ACCESS_TOKEN"),
18+
19+
avatars: {
20+
home: "mxc://kvalhe.im/cXGNnZfJTYtnTbGIUptUmCsm",
21+
presentation: "mxc://kvalhe.im/JQhaLcmOzIYdRsQfWiqMCkFA",
22+
seagl: "mxc://kvalhe.im/bmasxrBuggGXtMmcaudPmYAN",
23+
videoStream: "mxc://kvalhe.im/sfRfgfLzEAVbnprJQYjbQRJm",
24+
},
25+
staffRoom: "!pQraPupVjTcEUwBmSt:seattlematrix.org", // #SeaGL-test:seattlematrix.org
26+
};
27+
28+
(async () => {
29+
// Rate limiter
30+
const limiter = new Bottleneck({ maxConcurrent: 1, minTime: 1 });
31+
limiter.on("failed", async (error, jobInfo) => {
32+
if (jobInfo.retryCount < 3 && error?.body?.errcode === "M_LIMIT_EXCEEDED") {
33+
const ms = error?.body?.retry_after_ms ?? 5000;
34+
35+
console.warn(`Rate limited for ${ms} ms`);
36+
return ms;
37+
}
38+
});
39+
40+
// Client
41+
const wellKnown = await AutoDiscovery.findClientConfig(config.homeserver);
42+
const baseUrl = wellKnown["m.homeserver"].base_url;
43+
const storage = new SimpleFsStorageProvider("data/state.json");
44+
const client = new MatrixClient(baseUrl, config.accessToken, storage);
45+
const getCustomData = async (roomId) => {
46+
try {
47+
return await limiter.schedule(() =>
48+
client.getRoomStateEvent(roomId, "org.seagl.patch", "")
49+
);
50+
} catch (error: any) {
51+
if (error.body?.errcode !== "M_NOT_FOUND") {
52+
throw error;
53+
}
54+
}
55+
};
56+
const userId = await limiter.schedule(() => client.getUserId());
57+
const joinedRoomIds = new Set(
58+
await limiter.schedule(() => client.getJoinedRooms())
59+
);
60+
const roomIdById = new Map();
61+
for (const roomId of joinedRoomIds) {
62+
const id = (await getCustomData(roomId))?.id;
63+
if (id !== undefined) {
64+
roomIdById.set(id, roomId);
65+
}
66+
}
67+
68+
// State
69+
let createdSpace = false;
70+
let space;
71+
const variables: Record<string, string> = {};
72+
73+
// Find or create space
74+
const spaceSpec = {
75+
avatar: config.avatars.seagl,
76+
localAlias: "SeaGL2021",
77+
name: "SeaGL 2021",
78+
topic: "Virtual Conference",
79+
};
80+
const spaceAlias = `#${spaceSpec.localAlias}:${config.homeserver}`;
81+
try {
82+
space = await limiter.schedule(() => client.getSpace(spaceAlias));
83+
console.info("🏘️ Space exists: %j", {
84+
alias: spaceAlias,
85+
roomId: space.roomId,
86+
});
87+
} catch (error: any) {
88+
if (error.body?.errcode !== "M_NOT_FOUND") {
89+
throw error;
90+
}
91+
92+
space = await limiter.schedule(() =>
93+
client.createSpace({
94+
avatarUrl: spaceSpec.avatar,
95+
isPublic: true,
96+
localpart: spaceSpec.localAlias,
97+
name: spaceSpec.name,
98+
topic: spaceSpec.topic,
99+
})
100+
);
101+
joinedRoomIds.add(space.roomId);
102+
createdSpace = true;
103+
console.info("🏘️ Created space: %j", {
104+
roomId: space.roomId,
105+
spec: spaceSpec,
106+
});
107+
}
108+
variables.space = (await MentionPill.forRoom(space.roomId, client)).html;
109+
110+
// Add staff room to space
111+
if (createdSpace && joinedRoomIds.has(config.staffRoom)) {
112+
await limiter.schedule(() =>
113+
space.addChildRoom(config.staffRoom, { order: "800" })
114+
);
115+
}
116+
117+
// Find or create rooms
118+
const getOsemRoomSpecs = async (slug) => {
119+
const url = `https://osem.seagl.org/api/v2/conferences/${slug}`;
120+
const response = (await (await fetch(url)).json()) as any;
121+
122+
const records = new Map<string, any>();
123+
for (const record of response.included) {
124+
records.set(`${record.type}-${record.id}`, record);
125+
}
126+
127+
return response.data.relationships.events.data.map(({ id, type }) => {
128+
const record = records.get(`${type}-${id}`);
129+
const beginning = DateTime.fromISO(record.attributes.beginning);
130+
131+
return {
132+
avatar: config.avatars.presentation,
133+
id: `seagl2021-osem-${type}-${id}`,
134+
name: `${beginning.toFormat("EEE HH:mm")} ${record.attributes.title}`,
135+
sortKey: "100",
136+
topic: "Conference Session · Code of Conduct: seagl.org/coc",
137+
welcome:
138+
"Squawk! I’m <strong>Patch</strong> (they/them), the SeaGL mascot. This room is dedicated to a single conference session. See {space} for a listing of all rooms.",
139+
widget: {
140+
avatar: config.avatars.videoStream,
141+
name: "Video Stream",
142+
stateKey: "patch",
143+
url: "https://attend.seagl.org/widgets/video-stream.html",
144+
},
145+
};
146+
});
147+
};
148+
const roomsSpec = [
149+
{
150+
avatar: config.avatars.home,
151+
id: "seagl2021-general",
152+
localAlias: "SeaGL2021-General",
153+
name: "General",
154+
sortKey: "010",
155+
suggested: true,
156+
topic: "General Discussion · Code of Conduct: seagl.org/coc",
157+
welcome:
158+
"Welcome to SeaGL 2021! I’m <strong>Patch</strong> (they/them), the SeaGL mascot. This is a central room for general discussion. See {space} for a listing of all rooms.",
159+
widget: {
160+
avatar: config.avatars.seagl,
161+
name: "Welcome",
162+
stateKey: "patch",
163+
url: "https://attend.seagl.org/widgets/welcome.html",
164+
},
165+
},
166+
...(await getOsemRoomSpecs("seagl2020")),
167+
];
168+
for (const spec of roomsSpec) {
169+
let roomId = roomIdById.get(spec.id);
170+
if (roomId === undefined) {
171+
roomId = await limiter.schedule(() =>
172+
client.createRoom({
173+
initial_state: [
174+
{
175+
type: "m.room.avatar",
176+
state_key: "",
177+
content: { url: spec.avatar },
178+
},
179+
{
180+
type: "m.room.guest_access",
181+
state_key: "",
182+
content: { guest_access: "can_join" },
183+
},
184+
{
185+
type: "m.room.history_visibility",
186+
state_key: "",
187+
content: { history_visibility: "world_readable" },
188+
},
189+
{
190+
type: "org.seagl.patch",
191+
state_key: "",
192+
content: { id: spec.id },
193+
},
194+
...(spec.widget
195+
? [
196+
{
197+
type: "im.vector.modular.widgets",
198+
state_key: spec.widget.stateKey,
199+
content: {
200+
type: "customwidget",
201+
creatorUserId: userId,
202+
name: spec.widget.name,
203+
avatar_url: spec.widget.avatar,
204+
url: spec.widget.url,
205+
},
206+
},
207+
{
208+
type: "io.element.widgets.layout",
209+
state_key: "",
210+
content: {
211+
widgets: {
212+
[spec.widget.stateKey]: {
213+
container: "top",
214+
height: 25,
215+
width: 100,
216+
index: 0,
217+
},
218+
},
219+
},
220+
},
221+
]
222+
: []),
223+
],
224+
name: spec.name,
225+
preset: "public_chat",
226+
room_alias_name: spec.localAlias,
227+
topic: spec.topic,
228+
visibility: "public",
229+
})
230+
);
231+
roomIdById.set(spec.id, roomId);
232+
joinedRoomIds.add(roomId);
233+
console.info("🏠 Created room: %j", { roomId, spec });
234+
await limiter.schedule(() =>
235+
space.addChildRoom(roomId, {
236+
order: spec.sortKey,
237+
suggested: spec.suggested,
238+
})
239+
);
240+
await limiter.schedule(() =>
241+
client.sendHtmlNotice(
242+
roomId,
243+
spec.welcome.replaceAll(/{(\w+)}/g, (_, name) => variables[name])
244+
)
245+
);
246+
} else {
247+
console.info("🏠 Room exists: %j", { id: spec.id, roomId });
248+
}
249+
}
250+
251+
// Handle invitations
252+
client.on("room.invite", async (roomId, event) => {
253+
if (roomId === config.staffRoom) {
254+
console.info("💌 Accepting invitation: %j", { roomId, event });
255+
await limiter.schedule(() => client.joinRoom(roomId));
256+
await limiter.schedule(() =>
257+
client.sendHtmlNotice(
258+
roomId,
259+
"Squawk! I’m <strong>Patch</strong> (they/them), the SeaGL mascot."
260+
)
261+
);
262+
263+
if (space !== undefined) {
264+
await limiter.schedule(() =>
265+
space.addChildRoom(roomId, { order: "800" })
266+
);
267+
await limiter.schedule(() =>
268+
client.sendHtmlNotice(roomId, `Come join me in ${variables.space}!`)
269+
);
270+
}
271+
} else {
272+
console.warn("🗑️ Rejecting invitation: %j", { roomId, event });
273+
await limiter.schedule(() => client.leaveRoom(roomId));
274+
}
275+
});
276+
277+
// Handle kicks
278+
client.on("room.leave", async (roomId, event) => {
279+
if (event.sender !== userId) {
280+
console.warn("👮 Got kicked: %j", { roomId, event });
281+
}
282+
});
283+
284+
// Handle staff commands
285+
client.on("room.message", async (roomId, event) => {
286+
if (
287+
!(
288+
event?.content?.msgtype === "m.text" &&
289+
event.sender !== userId &&
290+
event?.content?.body?.startsWith("!")
291+
)
292+
) {
293+
return;
294+
}
295+
296+
if (!(roomId === config.staffRoom && event?.content?.body === "!hello")) {
297+
console.warn("⚠️ Ignoring command: %j", { roomId, event });
298+
return;
299+
}
300+
301+
const text = "Hello World!";
302+
const content = RichReply.createFor(roomId, event, text, text);
303+
content.msgtype = "m.notice";
304+
305+
await limiter.schedule(() => client.sendMessage(roomId, content));
306+
});
307+
308+
// Start
309+
await client.start();
310+
console.info("🟢 Ready: %j", { userId, joinedRoomIds });
311+
if (createdSpace && joinedRoomIds.has(config.staffRoom)) {
312+
await limiter.schedule(() =>
313+
client.sendHtmlNotice(
314+
config.staffRoom,
315+
`Come join me in ${variables.space}!`
316+
)
317+
);
318+
}
319+
})();

src/utilities.ts

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export const env = (key: string): string => {
2+
const value = process.env[key];
3+
4+
if (typeof value !== "string") {
5+
throw new Error(`Missing environment variable: ${key}`);
6+
}
7+
8+
return value;
9+
};

tsconfig.json

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"extends": "@tsconfig/node16/tsconfig.json",
3+
4+
"compilerOptions": {
5+
"allowSyntheticDefaultImports": true,
6+
"module": "ES2020",
7+
"moduleResolution": "Node",
8+
"noImplicitAny": false,
9+
"outDir": "dist",
10+
}
11+
}

0 commit comments

Comments
 (0)