|
| 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 | +})(); |
0 commit comments