├── .eslintignore ├── .eslintrc.cjs ├── .github └── workflows │ └── main.yml ├── .gitignore ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src ├── BlogService.ts ├── index.ts ├── matrix │ ├── MatrixClient.ts │ ├── node.ts │ └── types.ts └── types.ts └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | # don't ever lint node_modules 2 | node_modules 3 | # don't lint build output (make sure it's set to your correct build folder name) 4 | dist 5 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | parserOptions: { 5 | tsconfigRootDir: __dirname, 6 | project: ['./tsconfig.json'], 7 | }, 8 | plugins: ['@typescript-eslint'], 9 | extends: [ 10 | 'eslint:recommended', 11 | 'plugin:@typescript-eslint/recommended', 12 | 'plugin:@typescript-eslint/recommended-requiring-type-checking', 13 | 'prettier', 14 | ], 15 | }; 16 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | build: 5 | name: Build, lint, and test on Node ${{ matrix.node }} and ${{ matrix.os }} 6 | 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | node: ['12.x', '14.x'] 11 | os: [ubuntu-latest, windows-latest, macOS-latest] 12 | 13 | steps: 14 | - name: Checkout repo 15 | uses: actions/checkout@v2 16 | 17 | - name: Use Node ${{ matrix.node }} 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: ${{ matrix.node }} 21 | 22 | - name: Install deps and build (with cache) 23 | uses: bahmutov/npm-install@v1 24 | 25 | - name: Lint 26 | run: npm run lint 27 | 28 | # - name: Test 29 | # run: yarn test --ci --coverage --maxWorkers=2 30 | 31 | - name: Build 32 | run: npm run build 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules/ 4 | dist 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Rafał Hirsz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # matrix-blog 2 | 3 | A library to interact with a Matrix server in a way that treats it as a backend for a blog. 4 | 5 | Check out [matrix-blog-admin](https://github.com/evoL/matrix-blog-admin) — the admin panel for matrix-blog. 6 | 7 | See my blog for a write-up on how this works: https://evolved.systems/hosting-a-blog-on-matrix/ 8 | 9 | ## Example (node.js) 10 | 11 | ```js 12 | import { BlogService, MatrixClient } from 'matrix-blog'; 13 | import fetch from 'node-fetch'; 14 | 15 | const serverName = 'example.com'; 16 | const homeserverUrl = 'https://example.com'; 17 | const blogSpaceId = '!somethingsomething:example.com'; 18 | const accessToken = 'YOUR_ACCESS_TOKEN'; 19 | 20 | const client = new MatrixClient(serverName, homeserverUrl, fetch); 21 | const blog = new BlogService(client); 22 | 23 | client.setAccessToken(accessToken); 24 | 25 | // Fetches blog posts along with their contents. 26 | blog.getFullPosts(blogSpaceId).then((posts) => { 27 | console.log(posts); 28 | }); 29 | ``` 30 | 31 | ## License 32 | 33 | Written by Rafał Hirsz. This package is licensed under the terms of the MIT license. 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "matrix-blog", 3 | "author": "Rafał Hirsz", 4 | "version": "0.1.1", 5 | "license": "MIT", 6 | "type": "module", 7 | "source": "src/index.ts", 8 | "main": "dist/matrix-blog.cjs", 9 | "typings": "dist/index.d.ts", 10 | "module": "dist/matrix-blog.esm.js", 11 | "exports": "./dist/matrix-blog.es2017.js", 12 | "files": [ 13 | "dist", 14 | "src" 15 | ], 16 | "engines": { 17 | "node": ">=12" 18 | }, 19 | "scripts": { 20 | "start": "microbundle watch", 21 | "build": "microbundle", 22 | "lint": "eslint . --ext .js,.ts" 23 | }, 24 | "prettier": { 25 | "printWidth": 80, 26 | "semi": true, 27 | "singleQuote": true, 28 | "trailingComma": "es5", 29 | "endOfLine": "auto" 30 | }, 31 | "devDependencies": { 32 | "@typescript-eslint/eslint-plugin": "^4.31.1", 33 | "@typescript-eslint/parser": "^4.31.1", 34 | "eslint": "^7.32.0", 35 | "eslint-config-prettier": "^8.3.0", 36 | "microbundle": "^0.13.3", 37 | "prettier": "^2.4.1", 38 | "tslib": "^2.3.1", 39 | "typescript": "^4.4.3" 40 | }, 41 | "dependencies": { 42 | "node-fetch": "^3.0.0-beta.9" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/BlogService.ts: -------------------------------------------------------------------------------- 1 | import { MatrixClient, MatrixError } from './matrix/MatrixClient'; 2 | import type { 3 | CanonicalAliasEvent, 4 | MembershipEvent, 5 | NameEvent, 6 | PersistedStateEvent, 7 | SpaceParentEvent, 8 | StateEvent, 9 | TextMessageEvent, 10 | TopicEvent, 11 | } from './matrix/types'; 12 | import type { 13 | Blog, 14 | BlogWithPostMetadata, 15 | Post, 16 | NewPost, 17 | PostContent, 18 | PostMetadata, 19 | } from './types'; 20 | 21 | const TYPE_KEY = 'type'; 22 | const SPACE_VALUE = 'm.space'; 23 | const CHILD_EVENT = 'm.space.child'; 24 | const PARENT_EVENT = 'm.space.parent'; 25 | 26 | const POST_CONTENT_EVENT = 'co.hirsz.blog.post_content'; 27 | 28 | interface SpaceCreateEvent { 29 | [TYPE_KEY]?: string; 30 | } 31 | interface PostContentEvent { 32 | event_id: string; 33 | } 34 | 35 | export class BlogServiceError extends Error {} 36 | 37 | export class BlogService { 38 | constructor( 39 | private readonly matrixClient: MatrixClient, 40 | private readonly roomPrefix = 'blog.' 41 | ) {} 42 | 43 | createLocalRoomAlias(name: string): string { 44 | return `${this.roomPrefix}${name}`; 45 | } 46 | 47 | createRoomAlias(slug: string): string { 48 | return `#${this.createLocalRoomAlias( 49 | slug 50 | )}:${this.matrixClient.getServerName()}`; 51 | } 52 | 53 | getSlugFromRoomAlias(alias: string): string | undefined { 54 | const rx = new RegExp(`^#${escapeRegexp(this.roomPrefix)}([^:]+)`); 55 | const matches = rx.exec(alias); 56 | if (!matches) return undefined; 57 | return matches[1]; 58 | } 59 | 60 | async getBlog(id: string): Promise { 61 | const stateEvents = await this.getStateEvents(id); 62 | 63 | // Populate the name 64 | let name: string | undefined; 65 | const nameEvent = stateEvents.find((e) => e.type === 'm.room.name') as 66 | | StateEvent 67 | | undefined; 68 | if (nameEvent) { 69 | name = nameEvent.content.name; 70 | } 71 | 72 | // Populate the topic 73 | let topic: string | undefined; 74 | const topicEvent = stateEvents.find( 75 | (e) => e.type === 'm.room.topic' 76 | ) as StateEvent; 77 | if (topicEvent) { 78 | topic = topicEvent.content.topic; 79 | } 80 | 81 | return { id, title: name, description: topic }; 82 | } 83 | 84 | async getPosts(blogId: string): Promise> { 85 | const blogWithPosts = await this.getBlogWithPosts(blogId); 86 | return blogWithPosts.posts; 87 | } 88 | 89 | async getFullPosts(blogId: string): Promise> { 90 | const postMetadata = await this.getPosts(blogId); 91 | 92 | // Get the content for each post. 93 | const contents = await Promise.all( 94 | postMetadata.map((post) => this.getPostContent(post.id)) 95 | ); 96 | 97 | // Zip the arrays together to form full posts. 98 | return postMetadata.map( 99 | (post, i) => Object.assign(post, contents[i]) as Post 100 | ); 101 | } 102 | 103 | async getBlogWithPosts(id: string): Promise { 104 | const spaceSummary = await this.matrixClient.getSpaceSummary(id); 105 | const blogRoom = spaceSummary.rooms.find((room) => room.room_id === id); 106 | if (!blogRoom) { 107 | throw new BlogServiceError('Could not find blog room'); 108 | } 109 | 110 | const posts = spaceSummary.rooms 111 | .filter((room) => room.room_id !== id) 112 | .map((room) => ({ 113 | id: room.room_id, 114 | title: room.name, 115 | summary: room.topic, 116 | slug: 117 | room.canonical_alias && 118 | this.getSlugFromRoomAlias(room.canonical_alias), 119 | })); 120 | 121 | return { 122 | id, 123 | title: blogRoom.name, 124 | description: blogRoom.topic, 125 | posts, 126 | }; 127 | } 128 | 129 | async getPost(postId: string): Promise { 130 | const [title, summary, slug, content] = await Promise.all([ 131 | this.getPostTitle(postId), 132 | this.getPostSummary(postId), 133 | this.getPostSlug(postId), 134 | this.getPostContent(postId), 135 | ]); 136 | 137 | return { 138 | id: postId, 139 | title, 140 | summary, 141 | slug, 142 | ...content, 143 | }; 144 | } 145 | 146 | async addPost(blogId: string, post: NewPost): Promise { 147 | const postId = await this.matrixClient.createRoom({ 148 | name: post.title, 149 | topic: post.summary, 150 | room_alias_name: post.slug && this.createLocalRoomAlias(post.slug), 151 | preset: 'public_chat', 152 | initial_state: [ 153 | { 154 | type: 'm.room.history_visibility', 155 | content: { history_visibility: 'world_readable' }, 156 | }, 157 | ], 158 | }); 159 | 160 | const message = this.matrixClient.sendMessageEvent( 161 | postId, 162 | 'm.room.message', 163 | { 164 | msgtype: 'm.text', 165 | format: 'org.matrix.custom.html', 166 | body: post.text, 167 | formatted_body: post.html, 168 | } 169 | ); 170 | 171 | const serverName = this.matrixClient.getServerName(); 172 | const child = this.matrixClient.sendStateEvent( 173 | blogId, 174 | CHILD_EVENT, 175 | postId, 176 | { 177 | via: [serverName], 178 | } 179 | ); 180 | const parent = this.matrixClient.sendStateEvent( 181 | postId, 182 | PARENT_EVENT, 183 | blogId, 184 | { 185 | via: [serverName], 186 | canonical: true, 187 | } 188 | ); 189 | 190 | const [messageEventId] = await Promise.all([message, child, parent]); 191 | 192 | // Mark the message event ID. 193 | await this.matrixClient.sendStateEvent(postId, POST_CONTENT_EVENT, '', { 194 | event_id: messageEventId, 195 | }); 196 | 197 | return { 198 | id: postId, 199 | title: post.title, 200 | summary: post.summary, 201 | slug: post.slug, 202 | }; 203 | } 204 | 205 | async deletePost( 206 | postId: string, 207 | reason = 'Deleting blog post' 208 | ): Promise { 209 | const stateEvents = await this.matrixClient.getStateEvents(postId); 210 | 211 | const parentEvent = stateEvents.find((e) => e.type === PARENT_EVENT) as 212 | | PersistedStateEvent 213 | | undefined; 214 | if (!parentEvent) { 215 | throw new BlogServiceError('No parent linkage'); 216 | } 217 | 218 | const currentUserId = await this.matrixClient.getCurrentUser(); 219 | 220 | // In parallel: 221 | const eventPromises: Array> = []; 222 | 223 | // 1. Remove parent link 224 | eventPromises.push( 225 | this.matrixClient.redactEvent(postId, parentEvent.event_id, reason) 226 | ); 227 | 228 | // 2. Remove child link 229 | const blogId = parentEvent.state_key; 230 | eventPromises.push( 231 | this.matrixClient.getStateEvents(blogId).then((parentStateEvents) => { 232 | const childEvent = parentStateEvents.find( 233 | (e) => e.type === CHILD_EVENT && e.state_key === postId 234 | ); 235 | if (!childEvent) return; 236 | return this.matrixClient.redactEvent( 237 | blogId, 238 | childEvent.event_id, 239 | reason 240 | ); 241 | }) 242 | ); 243 | 244 | // 3. Remove alias 245 | const aliasEvent = stateEvents.find( 246 | (e) => e.type === 'm.room.canonical_alias' 247 | ) as PersistedStateEvent | undefined; 248 | if (aliasEvent?.content.alias != null) { 249 | eventPromises.push( 250 | this.matrixClient.removeRoomAlias(aliasEvent.content.alias) 251 | ); 252 | } 253 | 254 | // 4. Remove all other members 255 | const memberships = stateEvents.filter( 256 | (e) => e.type === 'm.room.member' && e.state_key !== currentUserId 257 | ) as ReadonlyArray>; 258 | for (const membership of memberships) { 259 | eventPromises.push( 260 | this.matrixClient.kickUser(postId, membership.state_key, reason) 261 | ); 262 | } 263 | 264 | await Promise.all(eventPromises); 265 | 266 | // Finally, leave the room. 267 | await this.matrixClient.leaveRoom(postId); 268 | } 269 | 270 | async editPost(postId: string, post: Partial): Promise { 271 | const promises: Array> = []; 272 | 273 | if (post.title != null) { 274 | promises.push(this.setPostTitle(postId, post.title)); 275 | } 276 | if (post.summary != null) { 277 | promises.push(this.setPostSummary(postId, post.summary)); 278 | } 279 | if (post.slug != null) { 280 | promises.push(this.setPostSlug(postId, post.slug)); 281 | } 282 | if (post.text != null && post.html != null) { 283 | promises.push( 284 | this.setPostContent(postId, { text: post.text, html: post.html }) 285 | ); 286 | } 287 | 288 | await Promise.all(promises); 289 | } 290 | 291 | private async getPostTitle(postId: string): Promise { 292 | const event = (await this.matrixClient.getStateEvent( 293 | postId, 294 | 'm.room.name' 295 | )) as NameEvent; 296 | return event.name; 297 | } 298 | 299 | private setPostTitle(postId: string, title: string): Promise { 300 | return this.matrixClient.sendStateEvent(postId, 'm.room.name', '', { 301 | name: title, 302 | }); 303 | } 304 | 305 | private async getPostSummary(postId: string): Promise { 306 | try { 307 | const event = (await this.matrixClient.getStateEvent( 308 | postId, 309 | 'm.room.topic' 310 | )) as TopicEvent; 311 | return event.topic; 312 | } catch (e) { 313 | if (e instanceof MatrixError && e.status === 404) { 314 | return undefined; 315 | } 316 | throw e; 317 | } 318 | } 319 | 320 | private setPostSummary(postId: string, summary: string): Promise { 321 | return this.matrixClient.sendStateEvent(postId, 'm.room.topic', '', { 322 | topic: summary, 323 | }); 324 | } 325 | 326 | private async getPostSlug(postId: string): Promise { 327 | try { 328 | const { alias } = (await this.matrixClient.getStateEvent( 329 | postId, 330 | 'm.room.canonical_alias' 331 | )) as CanonicalAliasEvent; 332 | if (!alias) return undefined; 333 | 334 | return this.getSlugFromRoomAlias(alias); 335 | } catch (e) { 336 | if (e instanceof MatrixError && e.status === 404) { 337 | return undefined; 338 | } 339 | throw e; 340 | } 341 | } 342 | 343 | private async setPostSlug(postId: string, slug: string): Promise { 344 | // Allow to unset a slug by passing an empty string. 345 | const newAlias = slug && this.createRoomAlias(slug); 346 | 347 | // Get the old alias 348 | let oldAlias: string | undefined; 349 | try { 350 | const aliasEvent = (await this.matrixClient.getStateEvent( 351 | postId, 352 | 'm.room.canonical_alias' 353 | )) as CanonicalAliasEvent; 354 | oldAlias = aliasEvent.alias; 355 | } catch (e) { 356 | // It's fine if there's no event. 357 | } 358 | 359 | // If the old alias is the same as the new one, do nothing. 360 | if (oldAlias === newAlias) return; 361 | 362 | // Swap aliases 363 | if (newAlias) { 364 | await this.matrixClient.addRoomAlias(newAlias, postId); 365 | await this.matrixClient.sendStateEvent( 366 | postId, 367 | 'm.room.canonical_alias', 368 | '', 369 | { alias: newAlias } 370 | ); 371 | } 372 | if (oldAlias) { 373 | await this.matrixClient.removeRoomAlias(oldAlias); 374 | } 375 | } 376 | 377 | private async getPostContent(postId: string): Promise { 378 | // Get state events 379 | const stateEvents = await this.matrixClient.getStateEvents(postId); 380 | 381 | // Find the message event ID 382 | const postContent = stateEvents.find( 383 | (event) => event.type === POST_CONTENT_EVENT 384 | ) as PersistedStateEvent | undefined; 385 | if (!postContent) { 386 | throw new BlogServiceError('Could not find post content event'); 387 | } 388 | 389 | // Find the latest slug event to check if the post is published 390 | const aliasEvent = stateEvents.find( 391 | (event) => event.type === 'm.room.canonical_alias' 392 | ) as PersistedStateEvent | undefined; 393 | const publishedMs = aliasEvent?.content.alias 394 | ? aliasEvent.origin_server_ts 395 | : undefined; 396 | 397 | // Get the message 398 | const message = await this.matrixClient.getEvent( 399 | postId, 400 | postContent.content.event_id 401 | ); 402 | const content = message.content as TextMessageEvent; 403 | 404 | if (!content.formatted_body) { 405 | throw new BlogServiceError('No formatted_body in the blog post event'); 406 | } 407 | 408 | // Extract the edit timestamp. 409 | const relations = message.unsigned?.['m.relations'] as 410 | | { 'm.replace'?: { origin_server_ts: number } } 411 | | undefined; 412 | const editedMs = relations?.['m.replace']?.origin_server_ts; 413 | 414 | return { 415 | text: content.body, 416 | html: content.formatted_body, 417 | created_ms: message.origin_server_ts, 418 | published_ms: publishedMs, 419 | edited_ms: editedMs, 420 | }; 421 | } 422 | 423 | private async setPostContent( 424 | postId: string, 425 | content: { text: string; html: string } 426 | ): Promise { 427 | // Find the message event ID 428 | const postContent = (await this.matrixClient.getStateEvent( 429 | postId, 430 | POST_CONTENT_EVENT 431 | )) as PostContentEvent; 432 | 433 | // Send the new message 434 | return await this.matrixClient.sendMessageEvent(postId, 'm.room.message', { 435 | msgtype: 'm.text', 436 | format: 'org.matrix.custom.html', 437 | body: `(edited) ${content.text}`, 438 | formatted_body: `

(edited)

${content.html}`, 439 | 'm.new_content': { 440 | msgtype: 'm.text', 441 | format: 'org.matrix.custom.html', 442 | body: content.text, 443 | formatted_body: content.html, 444 | }, 445 | 'm.relates_to': { 446 | rel_type: 'm.replace', 447 | event_id: postContent.event_id, 448 | }, 449 | }); 450 | } 451 | 452 | private async getStateEvents( 453 | blogId: string 454 | ): Promise>> { 455 | const stateEvents = await this.matrixClient.getStateEvents(blogId); 456 | 457 | // Validate that this is indeed a blog room by checking if it's a space. 458 | // Yes, this is hacky. 459 | const createEvent = stateEvents.find((e) => e.type === 'm.room.create') as 460 | | StateEvent 461 | | undefined; 462 | if (!createEvent) { 463 | throw new BlogServiceError('Could not find room creation event'); 464 | } 465 | if (createEvent.content[TYPE_KEY] !== SPACE_VALUE) { 466 | throw new BlogServiceError('This room is not a space'); 467 | } 468 | 469 | return stateEvents; 470 | } 471 | } 472 | 473 | function escapeRegexp(string: string): string { 474 | return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 475 | } 476 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './BlogService'; 2 | export * from './types'; 3 | export * from './matrix/MatrixClient'; 4 | -------------------------------------------------------------------------------- /src/matrix/MatrixClient.ts: -------------------------------------------------------------------------------- 1 | import type fetchFn from 'node-fetch'; 2 | import { 3 | CreateRoomRequest, 4 | PersistedStateEvent, 5 | SpaceSummaryRequest, 6 | SpaceSummaryResponse, 7 | } from './types'; 8 | 9 | interface CreateRoomResponse { 10 | room_id: string; 11 | } 12 | 13 | interface SendEventResponse { 14 | event_id: string; 15 | } 16 | 17 | export interface MatrixErrorDetails { 18 | errcode: string; 19 | error: string; 20 | } 21 | 22 | export class MatrixError extends Error { 23 | constructor(readonly status: number, readonly details: MatrixErrorDetails) { 24 | super(`Matrix Error: ${status} ${JSON.stringify(details)}`); 25 | } 26 | } 27 | 28 | export class MatrixClient { 29 | private accessToken = ''; 30 | private currentUserId?: string; 31 | 32 | constructor( 33 | private readonly serverName: string, 34 | private readonly homeserverUrl: string, 35 | private readonly fetch: typeof fetchFn 36 | ) {} 37 | 38 | setAccessToken(token: string): void { 39 | this.accessToken = token; 40 | } 41 | 42 | getServerName(): string { 43 | return this.serverName; 44 | } 45 | 46 | async getCurrentUser(): Promise { 47 | if (this.currentUserId == null) { 48 | const response = await this.sendRequest( 49 | '/_matrix/client/r0/account/whoami', 50 | 'get' 51 | ); 52 | const json = (await response.json()) as { user_id: string }; 53 | this.currentUserId = json.user_id; 54 | } 55 | return this.currentUserId; 56 | } 57 | 58 | async createRoom(req: CreateRoomRequest): Promise { 59 | const response = await this.sendRequest( 60 | '/_matrix/client/r0/createRoom', 61 | 'post', 62 | req 63 | ); 64 | 65 | const json = (await response.json()) as CreateRoomResponse; 66 | return json.room_id; 67 | } 68 | 69 | async getEvent( 70 | roomId: string, 71 | eventId: string 72 | ): Promise> { 73 | const response = await this.sendRequest( 74 | `/_matrix/client/r0/rooms/${roomId}/event/${eventId}`, 75 | 'get' 76 | ); 77 | 78 | return (await response.json()) as PersistedStateEvent; 79 | } 80 | 81 | async getStateEvent( 82 | roomId: string, 83 | type: string, 84 | stateKey = '' 85 | ): Promise { 86 | const response = await this.sendRequest( 87 | `/_matrix/client/r0/rooms/${roomId}/state/${type}/${stateKey}`, 88 | 'get' 89 | ); 90 | 91 | return await response.json(); 92 | } 93 | 94 | async getStateEvents( 95 | roomId: string 96 | ): Promise>> { 97 | const response = await this.sendRequest( 98 | `/_matrix/client/r0/rooms/${roomId}/state`, 99 | 'get' 100 | ); 101 | 102 | return (await response.json()) as ReadonlyArray< 103 | PersistedStateEvent 104 | >; 105 | } 106 | 107 | async sendStateEvent( 108 | roomId: string, 109 | eventType: string, 110 | stateKey: string, 111 | // object is a valid type here, we don't care about index signatures. 112 | // eslint-disable-next-line @typescript-eslint/ban-types 113 | event: object 114 | ): Promise { 115 | const response = await this.sendRequest( 116 | `/_matrix/client/r0/rooms/${roomId}/state/${eventType}/${stateKey}`, 117 | 'put', 118 | event 119 | ); 120 | 121 | const json = (await response.json()) as SendEventResponse; 122 | return json.event_id; 123 | } 124 | 125 | async sendMessageEvent( 126 | roomId: string, 127 | eventType: string, 128 | event: Record 129 | ): Promise { 130 | const txnId = randomString(8); 131 | const response = await this.sendRequest( 132 | `/_matrix/client/r0/rooms/${roomId}/send/${eventType}/${txnId}`, 133 | 'put', 134 | event 135 | ); 136 | 137 | const json = (await response.json()) as SendEventResponse; 138 | return json.event_id; 139 | } 140 | 141 | async redactEvent( 142 | roomId: string, 143 | eventId: string, 144 | reason?: string 145 | ): Promise { 146 | const txnId = randomString(8); 147 | const response = await this.sendRequest( 148 | `/_matrix/client/r0/rooms/${roomId}/redact/${eventId}/${txnId}`, 149 | 'put', 150 | { reason } 151 | ); 152 | 153 | const json = (await response.json()) as SendEventResponse; 154 | return json.event_id; 155 | } 156 | 157 | async leaveRoom(roomId: string): Promise { 158 | await this.sendRequest(`/_matrix/client/r0/rooms/${roomId}/leave`, 'post'); 159 | } 160 | 161 | async kickUser( 162 | roomId: string, 163 | userId: string, 164 | reason?: string 165 | ): Promise { 166 | await this.sendRequest(`/_matrix/client/r0/rooms/${roomId}/kick`, 'post', { 167 | user_id: userId, 168 | reason, 169 | }); 170 | } 171 | 172 | async addRoomAlias(alias: string, roomId: string): Promise { 173 | await this.sendRequest( 174 | `/_matrix/client/r0/directory/room/${encodeURIComponent(alias)}`, 175 | 'put', 176 | { room_id: roomId } 177 | ); 178 | } 179 | 180 | async removeRoomAlias(alias: string): Promise { 181 | await this.sendRequest( 182 | `/_matrix/client/r0/directory/room/${encodeURIComponent(alias)}`, 183 | 'delete' 184 | ); 185 | } 186 | 187 | async getSpaceSummary( 188 | roomId: string, 189 | options: SpaceSummaryRequest = {} 190 | ): Promise { 191 | const response = await this.sendRequest( 192 | `/_matrix/client/unstable/org.matrix.msc2946/rooms/${roomId}/spaces`, 193 | 'post', 194 | options 195 | ); 196 | 197 | return (await response.json()) as SpaceSummaryResponse; 198 | } 199 | 200 | private async sendRequest( 201 | endpoint: string, 202 | method: 'post' | 'put' | 'get' | 'delete', 203 | // object is a valid type here, we don't care about index signatures. 204 | // eslint-disable-next-line @typescript-eslint/ban-types 205 | body?: object 206 | ) { 207 | const headers: Record = { 208 | 'User-Agent': 'matrix-blog/0.1.0', 209 | }; 210 | if (this.accessToken) { 211 | headers['Authorization'] = `Bearer ${this.accessToken}`; 212 | } 213 | if (body) { 214 | headers['Content-Type'] = 'application/json'; 215 | } 216 | 217 | const response = await this.fetch(`${this.homeserverUrl}${endpoint}`, { 218 | method, 219 | body: body && JSON.stringify(body), 220 | headers, 221 | }); 222 | 223 | if (!response.ok) { 224 | const error = (await response.json()) as MatrixErrorDetails; 225 | throw new MatrixError(response.status, error); 226 | } 227 | 228 | return response; 229 | } 230 | } 231 | 232 | const CHARS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; 233 | function randomString(length: number): string { 234 | const array: string[] = []; 235 | while (length--) { 236 | const char = CHARS[(Math.random() * CHARS.length) | 0]; 237 | array.push(char); 238 | } 239 | return array.join(''); 240 | } 241 | -------------------------------------------------------------------------------- /src/matrix/node.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | import { MatrixClient } from './MatrixClient'; 3 | 4 | export function createMatrixClient( 5 | serverName: string, 6 | homeserverUrl: string 7 | ): MatrixClient { 8 | return new MatrixClient(serverName, homeserverUrl, fetch); 9 | } 10 | -------------------------------------------------------------------------------- /src/matrix/types.ts: -------------------------------------------------------------------------------- 1 | export interface Invite { 2 | display_name: string; 3 | signed: { mxid: string; signatures: unknown; token: string }; 4 | } 5 | 6 | export interface Invite3pid { 7 | id_server: string; 8 | id_access_token: string; 9 | medium: string; 10 | address: string; 11 | } 12 | 13 | export interface UnsignedData { 14 | age: number; 15 | [key: string]: unknown; 16 | } 17 | 18 | export interface StateEvent { 19 | type: string; 20 | state_key?: string; 21 | content: T; 22 | } 23 | 24 | export interface PersistedStateEvent extends StateEvent { 25 | state_key: string; 26 | event_id: string; 27 | sender: string; 28 | origin_server_ts: number; 29 | unsigned?: UnsignedData; 30 | room_id: string; 31 | prev_content?: T; 32 | } 33 | 34 | export interface NameEvent { 35 | name: string; 36 | } 37 | 38 | export interface TopicEvent { 39 | topic: string; 40 | } 41 | 42 | export interface CanonicalAliasEvent { 43 | alias?: string; 44 | alt_aliases?: readonly string[]; 45 | } 46 | 47 | export interface MembershipEvent { 48 | avatar_url?: string; 49 | displayname?: string | null; 50 | membership: 'invite' | 'join' | 'knock' | 'leave' | 'ban'; 51 | is_direct?: boolean; 52 | third_party_invite?: Invite; 53 | } 54 | 55 | export interface PowerLevelEvent { 56 | ban?: number; 57 | events?: Record; 58 | events_default?: number; 59 | invite?: number; 60 | kick?: number; 61 | redact?: number; 62 | state_default?: number; 63 | users?: Record; 64 | users_default?: number; 65 | notifications?: { room?: number }; 66 | } 67 | 68 | export interface MessageEvent { 69 | msgtype: string; 70 | body: string; 71 | } 72 | 73 | export interface TextMessageEvent extends MessageEvent { 74 | msgtype: 'm.text'; 75 | format?: 'org.matrix.custom.html'; 76 | formatted_body?: string; 77 | } 78 | 79 | export interface SpaceChildEvent { 80 | via: ReadonlyArray; 81 | suggested?: boolean; 82 | order?: string; 83 | } 84 | 85 | export interface SpaceParentEvent { 86 | via: ReadonlyArray; 87 | canonical?: boolean; 88 | } 89 | 90 | export interface PublicRoomsChunk { 91 | aliases?: ReadonlyArray; 92 | canonical_alias?: string; 93 | name?: string; 94 | num_joined_members: number; 95 | room_id: string; 96 | topic?: string; 97 | world_readable: boolean; 98 | guest_can_join: boolean; 99 | avatar_url?: string; 100 | } 101 | 102 | export interface CreateRoomRequest { 103 | visibility?: 'public' | 'private'; 104 | room_alias_name?: string; 105 | name?: string; 106 | topic?: string; 107 | invite?: readonly string[]; 108 | invite_3pid?: ReadonlyArray; 109 | room_version?: string; 110 | creation_content?: Record; 111 | initial_state?: ReadonlyArray>; 112 | preset?: 'private_chat' | 'public_chat' | 'trusted_private_chat'; 113 | is_direct?: boolean; 114 | power_level_content_override?: PowerLevelEvent; 115 | } 116 | 117 | export interface SpaceSummaryRequest { 118 | suggested_only?: boolean; 119 | max_rooms_per_space?: number; 120 | } 121 | 122 | export interface SpaceSummaryResponse { 123 | rooms: ReadonlyArray; 124 | events: ReadonlyArray>; 125 | } 126 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface Blog { 2 | id: string; 3 | title?: string; 4 | description?: string; 5 | } 6 | export interface PostMetadata { 7 | id: string; 8 | title?: string; 9 | summary?: string; 10 | slug?: string; 11 | } 12 | export interface PostContent { 13 | text: string; 14 | html: string; 15 | created_ms: number; 16 | edited_ms?: number; 17 | published_ms?: number; 18 | } 19 | export interface NewPost { 20 | title: string; 21 | summary?: string; 22 | slug?: string; 23 | text: string; 24 | html: string; 25 | } 26 | export type Post = PostMetadata & PostContent & { title: string }; 27 | 28 | export interface BlogWithPostMetadata extends Blog { 29 | posts: ReadonlyArray; 30 | } 31 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs 3 | "include": ["src", "types"], 4 | "compilerOptions": { 5 | "module": "esnext", 6 | "lib": ["es2019"], 7 | "importHelpers": true, 8 | // output .d.ts declaration files for consumers 9 | "declaration": true, 10 | // output .js.map sourcemap files for consumers 11 | "sourceMap": true, 12 | // match output dir to input dir. e.g. dist/index instead of dist/src/index 13 | "rootDir": "./src", 14 | // stricter type-checking for stronger correctness. Recommended by TS 15 | "strict": true, 16 | // linter checks for common issues 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": true, 19 | // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | // use Node's module resolution algorithm, instead of the legacy TS one 23 | "moduleResolution": "node", 24 | // interop between ESM and CJS modules. Recommended by TS 25 | "esModuleInterop": true, 26 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS 27 | "skipLibCheck": true, 28 | // error out if import and file system have a casing mismatch. Recommended by TS 29 | "forceConsistentCasingInFileNames": true 30 | } 31 | } 32 | --------------------------------------------------------------------------------