├── .editorconfig ├── .github └── workflows │ └── crystal.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── config-example.yml ├── docs ├── Matrix.html ├── Matrix │ ├── Architect.html │ └── Architect │ │ ├── Bot.html │ │ ├── Commands.html │ │ ├── Commands │ │ ├── Base.html │ │ ├── Base │ │ │ └── Runner.html │ │ ├── Room.html │ │ ├── Room │ │ │ └── Order.html │ │ ├── RunnerError.html │ │ ├── User.html │ │ └── Version.html │ │ ├── Config.html │ │ ├── Connection.html │ │ ├── Connection │ │ └── ExecError.html │ │ ├── Errors.html │ │ ├── Errors │ │ └── RateLimited.html │ │ ├── Events.html │ │ └── Events │ │ ├── Invite.html │ │ ├── Message.html │ │ ├── RoomEvent.html │ │ └── Sync.html ├── css │ └── style.css ├── index.html ├── index.json ├── js │ └── doc.js └── search-index.js ├── shard.lock ├── shard.yml ├── spec ├── commands_spec.cr ├── matrix-architect_spec.cr └── spec_helper.cr └── src ├── bot.cr ├── commands.cr ├── commands ├── base.cr ├── bot.cr ├── room.cr ├── user.cr └── version.cr ├── connection.cr ├── errors.cr ├── events.cr ├── main.cr └── matrix-architect.cr /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.cr] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.github/workflows/crystal.yml: -------------------------------------------------------------------------------- 1 | name: Crystal CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | container: 15 | image: crystallang/crystal 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Install dependencies 20 | run: shards install 21 | - name: Run tests 22 | run: crystal spec 23 | - name: Run format 24 | run: crystal tool format --check 25 | - name: Run lint 26 | run: bin/ameba 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /lib/ 2 | /bin/ 3 | /.shards/ 4 | *.dwarf 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM crystallang/crystal:0.35.1 as build 2 | 3 | COPY . /src 4 | WORKDIR /src 5 | RUN make all 6 | 7 | FROM debian:stable-slim 8 | 9 | RUN apt-get update && \ 10 | apt-get install -y libyaml-0-2 libssl1.1 libevent-2.1-6 ca-certificates 11 | COPY --from=build /src/matrix-architect /app/matrix-architect 12 | WORKDIR /app 13 | 14 | ENTRYPOINT ["/app/matrix-architect"] 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Alexandre Morignot 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: 2 | crystal build src/main.cr -o matrix-architect 3 | 4 | run: 5 | crystal run src/main.cr 6 | 7 | static: 8 | crystal build --static src/main.cr 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # matrix-architect 2 | 3 | A bot to manage your Synapse home server. 4 | It uses Synapse's [admin API](https://github.com/matrix-org/synapse/tree/master/docs/admin_api) 5 | to provides management commands. 6 | 7 | Current state of API implementation: 8 | * [ ] account_validity 9 | * [ ] delete_group 10 | * [ ] media_admin_api 11 | * [ ] purge_history_api 12 | * [ ] purge_remote_media 13 | * [x] purge_room 14 | * `!room purge ` 15 | * `!room garbage-collect` 16 | * [ ] register_api 17 | * [ ] room_membership 18 | * [x] rooms 19 | * `!room count` 20 | * `!room delete ` 21 | * `!room details ` 22 | * `!room list` 23 | * `!room top-complexity` 24 | * `!room top-members` 25 | * [ ] server_notices 26 | * [x] shutdown_room 27 | * `!room shutdown ` 28 | * [x] user_admin_api 29 | * `!user list` 30 | * `!user query ` 31 | * `!user deactivate ` 32 | * `!user reset-password ` 33 | * [x] version_api 34 | * `!version` 35 | 36 | See `!help` for more details about the bot's commands. 37 | 38 | You can join the discussion at [#matrix-architect:cervoi.se](https://matrix.to/#/!jLGHUlotkWeYLUTQEQ:cervoi.se?via=cervoi.se). 39 | 40 | ## Installation 41 | 42 | ### Static build 43 | 44 | ~You can download a static build from the [releases](https://github.com/erdnaxeli/matrix-architect/releases) page.~ well actually this is not live yet 45 | 46 | ### Docker 47 | 48 | You can use the provided `Dockerfile` to build a docker image, or use the already built one (see the usage section for more details): 49 | ``` 50 | docker run --init -v $PWD/config.yml:/app/config.yml erdnaxeli/matrix-architect 51 | ``` 52 | 53 | ### From source 54 | 55 | If you want to build it yourself you need to [install Crystal](https://crystal-lang.org/install/) 0.35, then clone the code, go to the new folder and: 56 | 57 | ``` 58 | make 59 | ``` 60 | 61 | You can also build a static binary with 62 | ``` 63 | make static 64 | ``` 65 | 66 | Note that the static build (manually or from the releases) is not actually totally 67 | static (see the [Cristal wiki](https://github.com/crystal-lang/crystal/wiki/Static-Linking)). 68 | If you have trouble you want prefer to build yourself a not static binary. 69 | 70 | ## Usage 71 | 72 | Set the configuration: 73 | 74 | 1. Create a new account for the bot on your HS, with your favorite client 75 | 2. Log out (to discard any e2e key that would have been created) 76 | 4. Set the new created account as 77 | [admin](https://github.com/matrix-org/synapse/tree/master/docs/admin_api). 78 | 3. Run `./matrix-architect gen-config` 79 | 80 | Run the bot with `./matrix-architect`. If you let the log level to "info" you should 81 | see some messages. 82 | 83 | You can now talk to the bot on Matrix! 84 | 85 | ### With docker 86 | 87 | The commands are a little bit different: 88 | ``` 89 | # create an empty config file so we can mount it in the docker container 90 | touch config.yml 91 | # generate the config 92 | docker run -it --rm --init -v $PWD/config.yml:/app/config.yml erdnaxeli/matrix-architect gen-config 93 | # run the bot 94 | docker run --init -v $PWD/config.yml:/app/config.yml erdnaxeli/matrix-architect 95 | ``` 96 | 97 | The bot does not register any signal handlers, so the `--init` parameter is mandatory 98 | if you want it to respond correctly to `^C` or `docker stop`. 99 | 100 | ## Security consideration 101 | 102 | This bot use the Synapse's admin API (everything under `/_synapse/admin`). 103 | Although only admin users can use this API, make it available to the whole Internet 104 | is not recommanded. You probably want to run the bot on the same host as your 105 | Synapse instance and communicate through localhost (or you can use a private network). 106 | 107 | Note that the domain used to talk to Synapse is your (public) homeserver domain, 108 | so it means that (for example) if you want to access to the admin API on localhost 109 | only you need to have your homeserver domain resolves to `localhost` (by adding an 110 | entry to `/etc/hosts`). The public API (everything under `/_matrix`) must also be 111 | accessible on the same domain and IP. 112 | 113 | ## Contributing 114 | 115 | 1. Fork it () 116 | 2. Create your feature branch (`git checkout -b my-new-feature`) 117 | 3. Commit your changes (`git commit -am 'Add some feature'`) 118 | 4. Push to the branch (`git push origin my-new-feature`) 119 | 5. Create a new Pull Request 120 | 121 | Don't forget to run `crystal tool format` on any code you commit. 122 | 123 | Your are advised to open an issue before opening a pull request. 124 | In that issue you can describe the context and discuss your proposal. 125 | 126 | ## TODO 127 | 128 | Non ordered list of things I would like to do: 129 | 130 | * Implement more API commands: 131 | I am not sure we need all the admin API available though the bot, but it's sure we need more. 132 | * Implement new commands: 133 | there is probably space to implement new commands that combine different APIs, 134 | like the garbage-collect one. 135 | * Provide administration for bridges? That could be something useful. 136 | * Test the code 137 | 138 | ## Contributors 139 | 140 | - [erdnaxeli](https://github.com/erdnaxeli) - creator and maintainer 141 | -------------------------------------------------------------------------------- /config-example.yml: -------------------------------------------------------------------------------- 1 | # Your homeserver's url. It must be accessible using HTTPS. 2 | homeserver: your.homeserver.org 3 | # The access token for your bot account. 4 | # The account must be admin, see: 5 | # https://github.com/matrix-org/synapse/tree/master/docs/admin_api 6 | access_token: someaccesstoken 7 | # List of users allowed to use the bot. 8 | users: 9 | - @some:admin.org 10 | # Optional 11 | # The log level, must be either 12 | # debug 13 | # info (default) 14 | # warn 15 | # error 16 | # none 17 | #log_level: info 18 | -------------------------------------------------------------------------------- /docs/Matrix.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 19 | 20 | 21 | Matrix - matrix-architect lint-dev 22 | 25 | 26 | 27 | 28 | 203 | 204 | 205 |
206 |

207 | 208 | module Matrix 209 | 210 |

211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 |

230 | 231 | 234 | 235 | Defined in: 236 |

237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 |
252 | 253 |
254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 |
264 | 265 | 266 | 267 | -------------------------------------------------------------------------------- /docs/Matrix/Architect.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 19 | 20 | 21 | Matrix::Architect - matrix-architect lint-dev 22 | 25 | 26 | 27 | 28 | 203 | 204 | 205 |
206 |

207 | 208 | module Matrix::Architect 209 | 210 |

211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 |

230 | 231 | 234 | 235 | Defined in: 236 |

237 | 238 | 239 | 240 | 241 | 242 |

243 | 244 | 247 | 248 | Constant Summary 249 |

250 | 251 |
252 | 253 |
254 | Log = ::Log.for(self) 255 |
256 | 257 | 258 |
259 | VERSION = "0.1.0" 260 |
261 | 262 | 263 |
264 | 265 | 266 | 267 | 268 | 269 |

270 | 271 | 274 | 275 | Class Method Summary 276 |

277 |
    278 | 279 |
  • 280 | .get_config 281 | 282 |
  • 283 | 284 |
  • 285 | .run 286 | 287 |
  • 288 | 289 |
290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 |
298 | 299 |
300 | 301 | 302 | 303 | 304 |

305 | 306 | 309 | 310 | Class Method Detail 311 |

312 | 313 |
314 |
315 | 316 | def self.get_config 317 | 318 | # 319 |
320 | 321 |
322 |
323 | 324 |
325 |
326 | 327 |
328 |
329 | 330 | def self.run 331 | 332 | # 333 |
334 | 335 |
336 |
337 | 338 |
339 |
340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 |
348 | 349 | 350 | 351 | -------------------------------------------------------------------------------- /docs/Matrix/Architect/Bot.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 19 | 20 | 21 | Matrix::Architect::Bot - matrix-architect lint-dev 22 | 25 | 26 | 27 | 28 | 203 | 204 | 205 |
206 |

207 | 208 | class Matrix::Architect::Bot 209 | 210 |

211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 |

232 | 233 | 236 | 237 | Defined in: 238 |

239 | 240 | 241 | 242 | 243 | 244 |

245 | 246 | 249 | 250 | Constant Summary 251 |

252 | 253 |
254 | 255 |
256 | Log = Matrix::Architect::Log.for(self) 257 |
258 | 259 | 260 |
261 | 262 | 263 | 264 |

265 | 266 | 269 | 270 | Constructors 271 |

272 | 280 | 281 | 282 | 283 | 284 | 285 |

286 | 287 | 290 | 291 | Instance Method Summary 292 |

293 | 306 | 307 | 308 | 309 | 310 | 311 |
312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 |
334 | 335 | 336 |

337 | 338 | 341 | 342 | Constructor Detail 343 |

344 | 345 |
346 |
347 | 348 | def self.new(config : Config) 349 | 350 | # 351 |
352 | 353 |
354 |
355 | 356 |
357 |
358 | 359 | 360 | 361 | 362 | 363 | 364 |

365 | 366 | 369 | 370 | Instance Method Detail 371 |

372 | 373 |
374 |
375 | 376 | def exec_command(message, event) : Nil 377 | 378 | # 379 |
380 | 381 |
382 |
383 | 384 |
385 |
386 | 387 |
388 |
389 | 390 | def run : Nil 391 | 392 | # 393 |
394 | 395 |
396 |
397 | 398 |
399 |
400 | 401 | 402 | 403 | 404 | 405 |
406 | 407 | 408 | 409 | -------------------------------------------------------------------------------- /docs/Matrix/Architect/Commands.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 19 | 20 | 21 | Matrix::Architect::Commands - matrix-architect lint-dev 22 | 25 | 26 | 27 | 28 | 203 | 204 | 205 |
206 |

207 | 208 | module Matrix::Architect::Commands 209 | 210 |

211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 |

230 | 231 | 234 | 235 | Defined in: 236 |

237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 |

246 | 247 | 250 | 251 | Class Method Summary 252 |

253 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 |
269 | 270 |
271 | 272 | 273 | 274 | 275 |

276 | 277 | 280 | 281 | Class Method Detail 282 |

283 | 284 |
285 |
286 | 287 | def self.run(line, room_id, conn) : Nil 288 | 289 | # 290 |
291 | 292 |
293 |
294 | 295 |
296 |
297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 |
305 | 306 | 307 | 308 | -------------------------------------------------------------------------------- /docs/Matrix/Architect/Commands/Base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 19 | 20 | 21 | Matrix::Architect::Commands::Base - matrix-architect lint-dev 22 | 25 | 26 | 27 | 28 | 203 | 204 | 205 |
206 |

207 | 208 | class Matrix::Architect::Commands::Base 209 | 210 |

211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 |

227 | 228 | 231 | 232 | Direct Known Subclasses 233 |

234 | 239 | 240 | 241 | 242 | 243 | 244 | 245 |

246 | 247 | 250 | 251 | Defined in: 252 |

253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 |

264 | 265 | 268 | 269 | Instance Method Summary 270 |

271 | 281 | 282 | 283 | 284 | 285 | 286 |
287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 |
309 | 310 | 311 | 312 | 313 | 314 | 315 |

316 | 317 | 320 | 321 | Instance Method Detail 322 |

323 | 324 |
325 |
326 | 327 | def run_with_progress(progress_wait : Time::Span, &) 328 | 329 | # 330 |
331 | 332 |
333 | 334 |

Runs a command and exec some block on success and progress.

335 | 336 |

Yield a Runner object. progress_wait is the time to wait between 337 | execution of the progress callback.

338 | 339 |
run_with_progress(10) do |runner|
340 |   runner.command { puts "I am starting"; sleep 25; puts "I am working" }
341 |   runner.on_progress { |s| puts "I am waiting #{s}" }
342 |   runner.on_success { |s| puts "I am done #{s}" }
343 | end
344 | 345 |

The above produce

346 | 347 |
I am starting
348 | I am waiting 10
349 | I am waiting 20
350 | I am working
351 | I am done 25
352 |
353 | 354 |
355 |
356 | 357 |
358 |
359 | 360 | 361 | 362 | 363 | 364 |
365 | 366 | 367 | 368 | -------------------------------------------------------------------------------- /docs/Matrix/Architect/Commands/RunnerError.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 19 | 20 | 21 | Matrix::Architect::Commands::RunnerError - matrix-architect lint-dev 22 | 25 | 26 | 27 | 28 | 203 | 204 | 205 |
206 |

207 | 208 | class Matrix::Architect::Commands::RunnerError 209 | 210 |

211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 |

232 | 233 | 236 | 237 | Defined in: 238 |

239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 |
254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 |
286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 |
296 | 297 | 298 | 299 | -------------------------------------------------------------------------------- /docs/Matrix/Architect/Commands/User.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 19 | 20 | 21 | Matrix::Architect::Commands::User - matrix-architect lint-dev 22 | 25 | 26 | 27 | 28 | 203 | 204 | 205 |
206 |

207 | 208 | class Matrix::Architect::Commands::User 209 | 210 |

211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 |

232 | 233 | 236 | 237 | Defined in: 238 |

239 | 240 | 241 | 242 | 243 | 244 | 245 |

246 | 247 | 250 | 251 | Constructors 252 |

253 | 261 | 262 | 263 | 264 |

265 | 266 | 269 | 270 | Class Method Summary 271 |

272 | 285 | 286 | 287 | 288 |

289 | 290 | 293 | 294 | Instance Method Summary 295 |

296 |
    297 | 298 |
  • 299 | #run(args) 300 | 301 |
  • 302 | 303 |
304 | 305 | 306 | 307 | 308 | 309 |
310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 |
332 | 333 | 334 |

335 | 336 | 339 | 340 | Constructor Detail 341 |

342 | 343 |
344 |
345 | 346 | def self.new(room_id : String, conn : Connection) 347 | 348 | # 349 |
350 | 351 |
352 |
353 | 354 |
355 |
356 | 357 | 358 | 359 | 360 |

361 | 362 | 365 | 366 | Class Method Detail 367 |

368 | 369 |
370 |
371 | 372 | def self.run(args, room_id, conn) 373 | 374 | # 375 |
376 | 377 |
378 |
379 | 380 |
381 |
382 | 383 |
384 |
385 | 386 | def self.usage(str) 387 | 388 | # 389 |
390 | 391 |
392 |
393 | 394 |
395 |
396 | 397 | 398 | 399 | 400 |

401 | 402 | 405 | 406 | Instance Method Detail 407 |

408 | 409 |
410 |
411 | 412 | def run(args) 413 | 414 | # 415 |
416 | 417 |
418 |
419 | 420 |
421 |
422 | 423 | 424 | 425 | 426 | 427 |
428 | 429 | 430 | 431 | -------------------------------------------------------------------------------- /docs/Matrix/Architect/Commands/Version.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 19 | 20 | 21 | Matrix::Architect::Commands::Version - matrix-architect lint-dev 22 | 25 | 26 | 27 | 28 | 203 | 204 | 205 |
206 |

207 | 208 | module Matrix::Architect::Commands::Version 209 | 210 |

211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 |

230 | 231 | 234 | 235 | Defined in: 236 |

237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 |

246 | 247 | 250 | 251 | Class Method Summary 252 |

253 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 |
274 | 275 |
276 | 277 | 278 | 279 | 280 |

281 | 282 | 285 | 286 | Class Method Detail 287 |

288 | 289 |
290 |
291 | 292 | def self.run(args, room_id, conn) 293 | 294 | # 295 |
296 | 297 |
298 |
299 | 300 |
301 |
302 | 303 |
304 |
305 | 306 | def self.usage(str) 307 | 308 | # 309 |
310 | 311 |
312 |
313 | 314 |
315 |
316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 |
324 | 325 | 326 | 327 | -------------------------------------------------------------------------------- /docs/Matrix/Architect/Connection/ExecError.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 19 | 20 | 21 | Matrix::Architect::Connection::ExecError - matrix-architect lint-dev 22 | 25 | 26 | 27 | 28 | 203 | 204 | 205 |
206 |

207 | 208 | class Matrix::Architect::Connection::ExecError 209 | 210 |

211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 |

232 | 233 | 236 | 237 | Defined in: 238 |

239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 |
254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 |
286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 |
296 | 297 | 298 | 299 | -------------------------------------------------------------------------------- /docs/Matrix/Architect/Errors.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 19 | 20 | 21 | Matrix::Architect::Errors - matrix-architect lint-dev 22 | 25 | 26 | 27 | 28 | 203 | 204 | 205 |
206 |

207 | 208 | module Matrix::Architect::Errors 209 | 210 |

211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 |

230 | 231 | 234 | 235 | Defined in: 236 |

237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 |
252 | 253 |
254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 |
264 | 265 | 266 | 267 | -------------------------------------------------------------------------------- /docs/Matrix/Architect/Errors/RateLimited.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 19 | 20 | 21 | Matrix::Architect::Errors::RateLimited - matrix-architect lint-dev 22 | 25 | 26 | 27 | 28 | 203 | 204 | 205 |
206 |

207 | 208 | struct Matrix::Architect::Errors::RateLimited 209 | 210 |

211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 |

232 | 233 | 236 | 237 | Defined in: 238 |

239 | 240 | 241 | 242 | 243 | 244 | 245 |

246 | 247 | 250 | 251 | Constructors 252 |

253 | 261 | 262 | 263 | 264 | 265 | 266 |

267 | 268 | 271 | 272 | Instance Method Summary 273 |

274 | 282 | 283 | 284 | 285 | 286 | 287 |
288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 |
320 | 321 | 322 |

323 | 324 | 327 | 328 | Constructor Detail 329 |

330 | 331 |
332 |
333 | 334 | def self.new(payload) 335 | 336 | # 337 |
338 | 339 |
340 |
341 | 342 |
343 |
344 | 345 | 346 | 347 | 348 | 349 | 350 |

351 | 352 | 355 | 356 | Instance Method Detail 357 |

358 | 359 |
360 |
361 | 362 | def retry_after_ms : Int32 363 | 364 | # 365 |
366 | 367 |
368 |
369 | 370 |
371 |
372 | 373 | 374 | 375 | 376 | 377 |
378 | 379 | 380 | 381 | -------------------------------------------------------------------------------- /docs/Matrix/Architect/Events.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 19 | 20 | 21 | Matrix::Architect::Events - matrix-architect lint-dev 22 | 25 | 26 | 27 | 28 | 203 | 204 | 205 |
206 |

207 | 208 | module Matrix::Architect::Events 209 | 210 |

211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 |

230 | 231 | 234 | 235 | Defined in: 236 |

237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 |
252 | 253 |
254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 |
264 | 265 | 266 | 267 | -------------------------------------------------------------------------------- /docs/Matrix/Architect/Events/Invite.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 19 | 20 | 21 | Matrix::Architect::Events::Invite - matrix-architect lint-dev 22 | 25 | 26 | 27 | 28 | 203 | 204 | 205 |
206 |

207 | 208 | struct Matrix::Architect::Events::Invite 209 | 210 |

211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 |

232 | 233 | 236 | 237 | Defined in: 238 |

239 | 240 | 241 | 242 | 243 | 244 | 245 |

246 | 247 | 250 | 251 | Constructors 252 |

253 | 261 | 262 | 263 | 264 | 265 | 266 |

267 | 268 | 271 | 272 | Instance Method Summary 273 |

274 | 282 | 283 | 284 | 285 | 286 | 287 |
288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 |
320 | 321 | 322 |

323 | 324 | 327 | 328 | Constructor Detail 329 |

330 | 331 |
332 |
333 | 334 | def self.new(room_id, payload : JSON::Any) 335 | 336 | # 337 |
338 | 339 |
340 |
341 | 342 |
343 |
344 | 345 | 346 | 347 | 348 | 349 | 350 |

351 | 352 | 355 | 356 | Instance Method Detail 357 |

358 | 359 |
360 |
361 | 362 | def room_id : String 363 | 364 | # 365 |
366 | 367 |
368 |
369 | 370 |
371 |
372 | 373 | 374 | 375 | 376 | 377 |
378 | 379 | 380 | 381 | -------------------------------------------------------------------------------- /docs/Matrix/Architect/Events/Message.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 19 | 20 | 21 | Matrix::Architect::Events::Message - matrix-architect lint-dev 22 | 25 | 26 | 27 | 28 | 203 | 204 | 205 |
206 |

207 | 208 | struct Matrix::Architect::Events::Message 209 | 210 |

211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 |

232 | 233 | 236 | 237 | Defined in: 238 |

239 | 240 | 241 | 242 | 243 | 244 | 245 |

246 | 247 | 250 | 251 | Constructors 252 |

253 | 261 | 262 | 263 | 264 | 265 | 266 |

267 | 268 | 271 | 272 | Instance Method Summary 273 |

274 | 282 | 283 | 284 | 285 | 286 | 287 |
288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 |
320 | 321 | 322 |

323 | 324 | 327 | 328 | Constructor Detail 329 |

330 | 331 |
332 |
333 | 334 | def self.new(payload : JSON::Any) 335 | 336 | # 337 |
338 | 339 |
340 |
341 | 342 |
343 |
344 | 345 | 346 | 347 | 348 | 349 | 350 |

351 | 352 | 355 | 356 | Instance Method Detail 357 |

358 | 359 |
360 |
361 | 362 | def body : String 363 | 364 | # 365 |
366 | 367 |
368 |
369 | 370 |
371 |
372 | 373 | 374 | 375 | 376 | 377 |
378 | 379 | 380 | 381 | -------------------------------------------------------------------------------- /docs/Matrix/Architect/Events/RoomEvent.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 19 | 20 | 21 | Matrix::Architect::Events::RoomEvent - matrix-architect lint-dev 22 | 25 | 26 | 27 | 28 | 203 | 204 | 205 |
206 |

207 | 208 | struct Matrix::Architect::Events::RoomEvent 209 | 210 |

211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 |

232 | 233 | 236 | 237 | Defined in: 238 |

239 | 240 | 241 | 242 | 243 | 244 | 245 |

246 | 247 | 250 | 251 | Constructors 252 |

253 | 261 | 262 | 263 | 264 | 265 | 266 |

267 | 268 | 271 | 272 | Instance Method Summary 273 |

274 | 292 | 293 | 294 | 295 | 296 | 297 |
298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 |
330 | 331 | 332 |

333 | 334 | 337 | 338 | Constructor Detail 339 |

340 | 341 |
342 |
343 | 344 | def self.new(room_id, payload : JSON::Any) 345 | 346 | # 347 |
348 | 349 |
350 |
351 | 352 |
353 |
354 | 355 | 356 | 357 | 358 | 359 | 360 |

361 | 362 | 365 | 366 | Instance Method Detail 367 |

368 | 369 |
370 |
371 | 372 | def message? 373 | 374 | # 375 |
376 | 377 |
378 |
379 | 380 |
381 |
382 | 383 |
384 |
385 | 386 | def room_id : String 387 | 388 | # 389 |
390 | 391 |
392 |
393 | 394 |
395 |
396 | 397 |
398 |
399 | 400 | def sender : String 401 | 402 | # 403 |
404 | 405 |
406 |
407 | 408 |
409 |
410 | 411 | 412 | 413 | 414 | 415 |
416 | 417 | 418 | 419 | -------------------------------------------------------------------------------- /docs/Matrix/Architect/Events/Sync.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 19 | 20 | 21 | Matrix::Architect::Events::Sync - matrix-architect lint-dev 22 | 25 | 26 | 27 | 28 | 203 | 204 | 205 |
206 |

207 | 208 | struct Matrix::Architect::Events::Sync 209 | 210 |

211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 |

232 | 233 | 236 | 237 | Defined in: 238 |

239 | 240 | 241 | 242 | 243 | 244 | 245 |

246 | 247 | 250 | 251 | Constructors 252 |

253 | 261 | 262 | 263 | 264 | 265 | 266 |

267 | 268 | 271 | 272 | Instance Method Summary 273 |

274 | 287 | 288 | 289 | 290 | 291 | 292 |
293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 |
325 | 326 | 327 |

328 | 329 | 332 | 333 | Constructor Detail 334 |

335 | 336 |
337 |
338 | 339 | def self.new(payload : JSON::Any) 340 | 341 | # 342 |
343 | 344 |
345 |
346 | 347 |
348 |
349 | 350 | 351 | 352 | 353 | 354 | 355 |

356 | 357 | 360 | 361 | Instance Method Detail 362 |

363 | 364 |
365 |
366 | 367 | def invites(&) 368 | 369 | # 370 |
371 | 372 |
373 |
374 | 375 |
376 |
377 | 378 |
379 |
380 | 381 | def room_events(&) 382 | 383 | # 384 |
385 | 386 |
387 |
388 | 389 |
390 |
391 | 392 | 393 | 394 | 395 | 396 |
397 | 398 | 399 | 400 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 19 | 20 | 21 | matrix-architect lint-dev 22 | 25 | 26 | 27 | 28 | 203 | 204 | 205 |
206 |

207 | 210 | matrix-architect

211 | 212 |

A bot to manage your Synapse home server. 213 | It uses Synapse's admin API 214 | to provides management commands.

215 | 216 |

Current state of API implementation:

217 | 218 |
  • [ ] account_validity
  • [ ] delete_group
  • [ ] media_admin_api
  • [ ] purge_history_api
  • [ ] purge_remote_media
  • [x] purge_room
    • !room purge
    • !room garbage-collect
  • [ ] register_api
  • [ ] room_membership
  • [x] rooms
    • !room count
    • !room details
    • !room list
    • !room top-complexity
    • !room top-members
  • [ ] server_notices
  • [x] shutdown_room
    • !room shutdown
  • [x] user_admin_api
    • !user list
    • !user query
    • !user deactivate
    • !user reset-password
  • [x] version_api
    • !version
219 | 220 |

See !help for more details about the bot's commands.

221 | 222 |

223 | 226 | Installation

227 | 228 |

You can download a static build from the releases page.

229 | 230 |

If you want to build it yourself you need to install Crystal 0.34, then clone the code, go to the new folder and:

231 | 232 |
make
233 | 234 |

You can also build a static binary with

235 | 236 |
make static
237 | 238 |

Note that the static build (manually or from the releases) is not actually totally 239 | static (see the Cristal wiki). 240 | If you have trouble you want prefer to build yourself a not static binary.

241 | 242 |

243 | 246 | Usage

247 | 248 |

Setting the configuration:

249 | 250 |
  1. Create a new account for the bot on your HS, with your favorite client
  2. Log out (to discard any e2e key that would have been created)
  3. Use the script tools/get_access_token.sh to get a new access_token for the bot
  4. Set the new created account as 251 | admin.
  5. Copy the config-example.yml file to config.yml and fill the values
252 | 253 |

Run the bot with ./matrix-architect. If you let the log level to "info" you should 254 | see some messages.

255 | 256 |

You can now talk to the bot on Matrix!

257 | 258 |

259 | 262 | Contributing

263 | 264 |
  1. Fork it (<https://github.com/erdnaxeli/matrix-architect/fork>)
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create a new Pull Request
265 | 266 |

Don't forget to run crystal tool format on any code you commit.

267 | 268 |

Your are advised to open an issue before opening a pull request. 269 | In that issue you can describe the context and discuss your proposal.

270 | 271 |

272 | 275 | TODO

276 | 277 |

Non ordered list of things I would like to do:

278 | 279 |
  • Implement more API commands: 280 | I am not sure we need all the admin API available though the bot, but it's sure we need more.
  • Implement new commands: 281 | there is probably space to implement new commands that combine different APIs, 282 | like the garbage-collect one.
  • Provide administration for bridges? That could be something useful.
  • Give some love to the cli executable:
    • implement account creation in the main executable
    • implement config generation
    • read the config file from well known places or cli parameter
  • Test the code
283 | 284 |

285 | 288 | Contributors

289 | 290 | 291 |
292 | 293 | 294 | -------------------------------------------------------------------------------- /shard.lock: -------------------------------------------------------------------------------- 1 | version: 2.0 2 | shards: 3 | ameba: 4 | git: https://github.com/crystal-ameba/ameba.git 5 | version: 0.12.1 6 | 7 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: matrix-architect 2 | version: 0.1.0 3 | 4 | authors: 5 | - Alexandre Morignot 6 | 7 | targets: 8 | matrix-architect: 9 | main: src/matrix-architect.cr 10 | 11 | crystal: 0.35.0 12 | 13 | license: MIT 14 | 15 | development_dependencies: 16 | ameba: 17 | github: crystal-ameba/ameba 18 | version: ~> 0.12.0 19 | -------------------------------------------------------------------------------- /spec/commands_spec.cr: -------------------------------------------------------------------------------- 1 | require "json" 2 | 3 | require "./spec_helper" 4 | require "../src/commands" 5 | 6 | class FakeConnection 7 | include Matrix::Architect::Connection 8 | 9 | getter room_id 10 | getter msg 11 | getter html_msg 12 | getter user_id = "dummy" 13 | 14 | def send_message(@room_id : String, @msg : String, @html_msg : String? = nil) 15 | "dummy" 16 | end 17 | 18 | def get(path, **options) : JSON::Any 19 | JSON.parse("{}") 20 | end 21 | 22 | def post(path, data = nil, **options) : JSON::Any 23 | JSON.parse("{}") 24 | end 25 | 26 | def edit_message(room_id : String, event_id : String, message : String, html : String? = nil) 27 | end 28 | end 29 | 30 | describe Matrix::Architect::Commands do 31 | describe ".run" do 32 | it "handles help" do 33 | conn = FakeConnection.new 34 | Matrix::Architect::Commands.run("!help", "42", conn) 35 | 36 | conn.room_id.should eq "42" 37 | conn.msg.should eq "Manage your matrix server. 38 | !bot manage the bot itself 39 | !room manage rooms 40 | !user manage users 41 | !version get Synapse and Python versions 42 | -h show this help" 43 | end 44 | end 45 | 46 | describe ".parse" do 47 | it "parses args" do 48 | args = Matrix::Architect::Commands.parse("!this is a command --with some --flags") 49 | args.should eq ["!this", "is", "a", "command", "--with", "some", "--flags"] 50 | end 51 | 52 | it "handles double quotes" do 53 | args = Matrix::Architect::Commands.parse(%(!this is "a command" --)) 54 | args.should eq ["!this", "is", "a command", "--"] 55 | end 56 | 57 | it "handles simple quotes" do 58 | args = Matrix::Architect::Commands.parse(%(!this is 'a command')) 59 | args.should eq ["!this", "is", "a command"] 60 | end 61 | 62 | it "handles very weird cases" do 63 | args = Matrix::Architect::Commands.parse(%(!this 'is a "'very wei"rd co"m"mand please" don't do t'h'a't p"leas"e)) 64 | args.should eq ["!this", %(is a "very), "weird command please", "dont do that", "please"] 65 | end 66 | 67 | it "handles error" do 68 | args = Matrix::Architect::Commands.parse(%(!this is an "error)) 69 | args.should eq ["-h"] 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /spec/matrix-architect_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe "Matrix::Architect" do 4 | # TODO: Write tests 5 | 6 | it "works" do 7 | true.should eq(true) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/matrix-architect" 3 | -------------------------------------------------------------------------------- /src/bot.cr: -------------------------------------------------------------------------------- 1 | require "./commands" 2 | require "./connection" 3 | require "./events" 4 | 5 | module Matrix::Architect 6 | class Bot 7 | Log = Matrix::Architect::Log.for(self) 8 | 9 | def initialize(@config : Config) 10 | @conn = ConnectionImpl.new(@config.hs_url, @config.access_token) 11 | end 12 | 13 | def run : Nil 14 | first_sync = true 15 | channel = Channel(Events::Sync).new 16 | @conn.sync(channel) 17 | 18 | loop do 19 | sync = channel.receive 20 | 21 | sync.invites do |invite| 22 | begin 23 | @conn.join(invite.room_id) 24 | rescue Connection::ExecError 25 | end 26 | end 27 | 28 | if first_sync 29 | # Ignore the first sync's messages as it can contains events already 30 | # seen. 31 | first_sync = false 32 | next 33 | end 34 | 35 | sync.room_events do |event| 36 | if (message = event.message?) && event.sender != @conn.user_id && @config.users_id.includes? event.sender 37 | spawn exec_command message, event 38 | end 39 | end 40 | end 41 | end 42 | 43 | def exec_command(message, event) : Nil 44 | Commands.run message.body, event.room_id, @conn 45 | rescue ex : Exception 46 | Log.error(exception: ex) { %(Error while executing command "#{message.body}") } 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /src/commands.cr: -------------------------------------------------------------------------------- 1 | require "./commands/*" 2 | 3 | require "option_parser" 4 | 5 | # OptionParser fixed so sub commands with hyphen work. 6 | # See https://github.com/crystal-lang/crystal/pull/9465 7 | class OptionParser 8 | private def parse_flag_definition(flag : String) 9 | case flag 10 | when /^--(\S+)\s+\[\S+\]$/ 11 | {"--#{$1}", FlagValue::Optional} 12 | when /^--(\S+)(\s+|\=)(\S+)?$/ 13 | {"--#{$1}", FlagValue::Required} 14 | when /^--\S+$/ 15 | # This can't be merged with `else` otherwise /-(.)/ matches 16 | {flag, FlagValue::None} 17 | when /^-(.)\s*\[\S+\]$/ 18 | {flag[0..1], FlagValue::Optional} 19 | when /^-(.)\s+\S+$/, /^-(.)\s+$/, /^-(.)\S+$/ 20 | {flag[0..1], FlagValue::Required} 21 | else 22 | # This happens for -f without argument 23 | {flag, FlagValue::None} 24 | end 25 | end 26 | end 27 | 28 | module Matrix::Architect 29 | module Commands 30 | # A job to be executed. 31 | class Job 32 | @block : Proc(Nil)? = nil 33 | getter help_msg : String? = nil 34 | 35 | def initialize(@parser : OptionParser) 36 | end 37 | 38 | # Calls the job. 39 | def call : Nil 40 | @block.try &.call 41 | end 42 | 43 | def empty? : Bool 44 | @block.nil? 45 | end 46 | 47 | # Registers the block to be executed on job call. 48 | def exec(&@block) 49 | end 50 | 51 | # Registers the help to be shown. 52 | def help 53 | @help_msg = @parser.to_s if @help_msg.nil? 54 | end 55 | end 56 | 57 | def self.run(line : String, room_id : String, conn : Connection) : Nil 58 | args = parse(line) 59 | if !args.size || args[0][0] != '!' 60 | return 61 | end 62 | 63 | parser = OptionParser.new 64 | job = Job.new(parser) 65 | 66 | parser.banner = "Manage your matrix server." 67 | parser.on("!bot", "manage the bot itself") do 68 | Bot.new(room_id, conn).parse(parser, job) 69 | end 70 | parser.on("!room", "manage rooms") do 71 | Room.new(room_id, conn).parse(parser, job) 72 | end 73 | parser.on("!user", "manage users") do 74 | User.new(room_id, conn).parse(parser, job) 75 | end 76 | parser.on("!version", "get Synapse and Python versions") do 77 | Version.new(room_id, conn).parse(parser, job) 78 | end 79 | parser.on("-h", "show this help") do 80 | job.help 81 | end 82 | parser.invalid_option do 83 | job.help 84 | end 85 | parser.missing_option { job.help } 86 | parser.unknown_args do |_, _| 87 | # `unknown_args` is always called last, so if no job have been registered 88 | # when we got here we just show the help. 89 | # This acts like a `missing_subcommand` method. 90 | if job.empty? 91 | job.help 92 | end 93 | end 94 | 95 | parser.parse(args) 96 | 97 | if msg = job.help_msg 98 | conn.send_message(room_id, msg) 99 | else 100 | job.call 101 | end 102 | end 103 | 104 | def self.parse(line) 105 | delimiter = nil 106 | args = [] of String 107 | acc = String::Builder.new 108 | 109 | line.each_char do |c| 110 | if delimiter.nil? 111 | if c == ' ' 112 | if acc.empty? 113 | next 114 | else 115 | args << acc.to_s 116 | acc = String::Builder.new 117 | end 118 | elsif c == '"' || c == '\'' 119 | delimiter = c 120 | else 121 | acc << c 122 | end 123 | else 124 | if c == delimiter 125 | delimiter = nil 126 | else 127 | acc << c 128 | end 129 | end 130 | end 131 | 132 | if !delimiter.nil? 133 | # there is a odd number of delimiter 134 | ["-h"] 135 | else 136 | args << acc.to_s 137 | args 138 | end 139 | end 140 | end 141 | end 142 | -------------------------------------------------------------------------------- /src/commands/base.cr: -------------------------------------------------------------------------------- 1 | require "../connection" 2 | 3 | module Matrix::Architect 4 | module Commands 5 | class RunnerError < Exception 6 | end 7 | 8 | # Base class for all commands. 9 | # 10 | # The command reiceve an `OptionParser` an a `Job` on its `run` method. 11 | # It should use the parser to define any subcommands or options and the job 12 | # to register the code that will actually execute after the whole parsing 13 | # process. 14 | # 15 | # Example: 16 | # ``` 17 | # class HelloWorldCommand < Base 18 | # @flag : String? = nil 19 | # 20 | # def parse(parser, job) 21 | # parser.on("subcommand", "a subcommand example") do 22 | # parser.on("-f FLAG", "--flag=FLAG", "a flag example") do |flag| 23 | # @flag = flag 24 | # end 25 | # job.exec { subcommand } 26 | # end 27 | # end 28 | # 29 | # def subcommand 30 | # send_message "Hello world with a subcommand" 31 | # 32 | # if !@flag.nil? 33 | # send_message "You sent the flag #{@flag}" 34 | # end 35 | # end 36 | # end 37 | # ``` 38 | class Base 39 | #  Configures the execution of a command. Used with `Base#run`. 40 | class Runner 41 | getter command_callback 42 | getter success_callback 43 | getter progress_callback 44 | 45 | #  Saves the command to run. If not set an error `RunError` wil be raised. 46 | def command(&block) 47 | @command_callback = block 48 | end 49 | 50 | # Saves a proc to execute on command success. 51 | # 52 | # When executing the proc, gives it the total execution time. 53 | def on_success(&block : Time::Span -> Nil) 54 | @success_callback = block 55 | end 56 | 57 | # Saves a proc to execute on command progression. 58 | # 59 | # When executing the proc, gives it the current execution time. 60 | def on_progress(&block : Time::Span -> Nil) 61 | @progress_callback = block 62 | end 63 | end 64 | 65 | # Creates a new instance of the command and runs it. 66 | def self.run(args, room_id : String, conn : Connection) : Nil 67 | self.new(room_id, conn).run args 68 | end 69 | 70 | def initialize(@room_id : String, @conn : Connection) 71 | end 72 | 73 | # Runs a command and exec some block on success and progress. 74 | # 75 | # Yield a `Runner` object. *progress_wait* is the time to wait between 76 | # execution of the progress callback. 77 | # 78 | # ``` 79 | # run_with_progress(10) do |runner| 80 | # runner.command { puts "I am starting"; sleep 25; puts "I am working" } 81 | # runner.on_progress { |s| puts "I am waiting #{s}" } 82 | # runner.on_success { |s| puts "I am done #{s}" } 83 | # end 84 | # ``` 85 | # 86 | # The above produce 87 | # 88 | # ```text 89 | # I am starting 90 | # I am waiting 10 91 | # I am waiting 20 92 | # I am working 93 | # I am done 25 94 | # ``` 95 | protected def run_with_progress(progress_wait : Time::Span, &block) 96 | runner = Runner.new 97 | yield runner 98 | 99 | if runner.command_callback.nil? 100 | raise RunnerError.new("Missing command") 101 | end 102 | 103 | start = Time.utc 104 | done, error = Channel(Nil).new, Channel(Nil).new 105 | spawn do 106 | begin 107 | if command = runner.command_callback 108 | command.call 109 | end 110 | done.close 111 | rescue ex : Connection::ExecError 112 | @conn.send_message(@room_id, "Error: #{ex.message}") 113 | error.close 114 | end 115 | end 116 | 117 | loop do 118 | select 119 | when done.receive? 120 | if on_success = runner.success_callback 121 | on_success.call(Time.utc - start) 122 | end 123 | break 124 | when error.receive? 125 | break 126 | when timeout progress_wait 127 | if on_progress = runner.progress_callback 128 | on_progress.call(Time.utc - start) 129 | end 130 | end 131 | end 132 | end 133 | 134 | # Sends a message to the room where the command was executed. 135 | protected def send_message(msg, html_msg = nil) 136 | @conn.send_message(@room_id, msg, html_msg) 137 | end 138 | end 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /src/commands/bot.cr: -------------------------------------------------------------------------------- 1 | require "option_parser" 2 | 3 | require "./base" 4 | 5 | module Matrix::Architect 6 | module Commands 7 | class Bot < Base 8 | @option = false 9 | 10 | def parse(parser, job) : Nil 11 | parser.banner = "!bot COMMAND" 12 | parser.on("leave-rooms", "leave all rooms the bot is in, except the current one") do 13 | parser.banner = "!bot leave-rooms" 14 | job.exec { leave_rooms } 15 | end 16 | end 17 | 18 | def leave_rooms : Nil 19 | send_message "leaving" 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /src/commands/room.cr: -------------------------------------------------------------------------------- 1 | require "option_parser" 2 | 3 | require "../connection" 4 | require "./base" 5 | 6 | module Matrix::Architect 7 | module Commands 8 | class Room < Base 9 | enum Order 10 | JoinedLocalMembers 11 | JoinedMembers 12 | Name 13 | StateEvents 14 | 15 | def to_s 16 | super.underscore 17 | end 18 | end 19 | 20 | def parse(parser, job) : Nil 21 | parser.banner = "!room COMMAND" 22 | parser.on("count", "return the total count of rooms") do 23 | parser.banner = "!room count" 24 | job.exec { count } 25 | end 26 | parser.on("delete", "make all users leave a room and purge it") do 27 | block = false 28 | message = nil 29 | 30 | parser.banner = "!room delete [--block] [--purge] [--msg MSG] ROOM_ID" 31 | parser.on("--block", "prevent joining this room again") do 32 | block = true 33 | end 34 | parser.on("--msg MSG", "make all the users join a new room where a message will be shown") do |msg| 35 | message = msg 36 | end 37 | parser.unknown_args do |before, _| 38 | if before.size != 1 39 | job.help 40 | else 41 | job.exec { delete(before[0], block, message) } 42 | end 43 | end 44 | end 45 | parser.on("details", "get all details about a room") do 46 | parser.banner = "!room details ROOM_ID" 47 | parser.unknown_args do |before, _| 48 | if before.size != 1 49 | job.help 50 | else 51 | job.exec { details(before[0]) } 52 | end 53 | end 54 | end 55 | parser.on("garbage-collect", "purge all rooms without any local users joined") do 56 | parser.banner = "!room garbage-collect" 57 | job.exec { garbage_collect } 58 | end 59 | parser.on("list", "list rooms") do 60 | room_alias = nil 61 | parser.banner = "!room list [--alias FILTER]" 62 | parser.on("--alias FILTER", "filter on room's id") { |filter| room_alias = filter } 63 | 64 | job.exec { list room_alias } 65 | end 66 | parser.on("top-complexity", "get top 10 rooms by complexity, aka state events number") do 67 | parser.banner = "!room top-complexity" 68 | job.exec { top_rooms(Order::StateEvents) } 69 | end 70 | parser.on("top-local-members", "get top 10 rooms by local members count") do 71 | parser.banner = "!room top-local-member" 72 | job.exec { top_rooms(Order::JoinedLocalMembers) } 73 | end 74 | parser.on("top-members", "get top 10 rooms by members count") do 75 | parser.banner = "!room top-members" 76 | job.exec { top_rooms(Order::JoinedMembers) } 77 | end 78 | parser.on("purge", "remove all traces of a room from your database") do 79 | parser.banner = "!room purge ROOM_ID" 80 | parser.unknown_args do |before, _| 81 | if before.size != 1 82 | job.help 83 | else 84 | job.exec { purge(before[0]) } 85 | end 86 | end 87 | end 88 | parser.on("shutdown", "make all local users leave a room and remove its local aliases") do 89 | parser.unknown_args do |before, _| 90 | if before.size != 1 91 | job.help 92 | else 93 | job.exec { shutdown(before[0]) } 94 | end 95 | end 96 | end 97 | end 98 | 99 | private def build_rooms_list(rooms, key : String? = nil, limit = 0, is_html : Bool = false) 100 | String.build do |str| 101 | if is_html 102 | str << "
    " 103 | end 104 | 105 | rooms[0, (limit > 0) ? limit : rooms.size].each do |room| 106 | build_room_list(room, str, key, is_html) 107 | end 108 | 109 | if is_html 110 | str << "
" 111 | end 112 | 113 | if limit > 0 && rooms.size > limit 114 | str << "\nToo many rooms (" << rooms.size << "), " 115 | str << "showing only the " << limit << " first ones." 116 | end 117 | end 118 | end 119 | 120 | private def build_room_list(room, str, key : String? = nil, is_html : Bool = false) 121 | if is_html 122 | str << "
  • " 123 | else 124 | str << "* " 125 | end 126 | 127 | if name = room["name"].as_s? 128 | str << name << " " 129 | end 130 | 131 | if canonical_alias = room["canonical_alias"].as_s? 132 | str << canonical_alias << " " 133 | end 134 | 135 | str << room["room_id"].as_s 136 | if key 137 | str << " " << room[key] 138 | end 139 | 140 | str << "\n" 141 | 142 | if is_html 143 | str << "
  • " 144 | end 145 | end 146 | 147 | private def count 148 | rooms = get_rooms limit: 0 149 | rescue ex : Connection::ExecError 150 | @conn.send_message(@room_id, "Error: #{ex.message}") 151 | else 152 | @conn.send_message(@room_id, "There are #{rooms.size} rooms on this HS") 153 | end 154 | 155 | private def delete(room_id : String, block = false, message : String? = nil) 156 | if message.nil? 157 | data = {block: block} 158 | else 159 | data = { 160 | block: block, 161 | message: message, 162 | new_room_user_id: @conn.user_id, 163 | } 164 | end 165 | 166 | begin 167 | response = @conn.post("/v1/rooms/#{room_id}/delete", is_admin: true, data: data) 168 | rescue ex : Connection::ExecError 169 | @conn.send_message(@room_id, "Error: #{ex.message}") 170 | else 171 | msg = response.to_pretty_json 172 | @conn.send_message(@room_id, "```\n#{msg}\n```", "
    #{msg}
    ") 173 | end 174 | end 175 | 176 | private def details(room_id : String?) : Nil 177 | if room_id.nil? 178 | @conn.send_message(@room_id, "Usage: !room details ROOM_ID") 179 | return 180 | end 181 | 182 | begin 183 | response = @conn.get "/v1/rooms/#{room_id}", is_admin: true 184 | rescue ex : Connection::ExecError 185 | @conn.send_message(@room_id, "Error: #{ex.message}") 186 | else 187 | msg = response.to_pretty_json 188 | @conn.send_message(@room_id, "```\n#{msg}\n```", "
    #{msg}
    ") 189 | end 190 | end 191 | 192 | private def garbage_collect : Nil 193 | begin 194 | rooms = get_rooms(Order::JoinedLocalMembers, limit: 0, reverse: true) 195 | rescue ex : Connection::ExecError 196 | @conn.send_message(@room_id, "Error: #{ex.message}") 197 | return 198 | end 199 | 200 | idx = -1 201 | # TODO: is there a way to not go through all the rooms? 202 | rooms.each_index { |i| rooms[i]["joined_local_members"].as_i == 0 && (idx = i) } 203 | 204 | if idx == -1 205 | @conn.send_message(@room_id, "No rooms found for garbage collection") 206 | return 207 | end 208 | 209 | total = idx + 1 210 | @conn.send_message(@room_id, "Found #{total} rooms to garbage collect") 211 | event_id = @conn.send_message(@room_id, "starting") 212 | 213 | begin 214 | count = 0 215 | t_message = t_start = Time.utc 216 | 217 | rooms[0, total].each do |room| 218 | count += 1 219 | do_purge(room["room_id"].as_s) 220 | 221 | # update the message every 20s 222 | if (Time.utc - t_message).total_seconds >= 20 223 | t_message = Time.utc 224 | elapsed_time = t_message - t_start 225 | f_elapsed = time_span_to_s(elapsed_time) 226 | percents = 100 * count / total 227 | @conn.edit_message( 228 | @room_id, 229 | event_id, 230 | "#{count}/#{total} #{percents}% #{f_elapsed}" 231 | ) 232 | end 233 | end 234 | 235 | elapsed_time = Time.utc - t_start 236 | f_elapsed = time_span_to_s(elapsed_time) 237 | @conn.edit_message(@room_id, event_id, "garbage-collection done in #{f_elapsed}") 238 | rescue ex : Connection::ExecError 239 | @conn.send_message(@room_id, "Error: #{ex.message}") 240 | end 241 | end 242 | 243 | private def list(room_alias : String? = nil) : Nil 244 | begin 245 | rooms = get_rooms(Order::Name, limit: 0) 246 | rescue ex : Connection::ExecError 247 | @conn.send_message(@room_id, "Error: #{ex.message}") 248 | return 249 | end 250 | 251 | if filter = room_alias 252 | rooms.select! { |room| room["canonical_alias"].as_s?.try &.includes?(filter) } 253 | end 254 | 255 | if rooms.size == 0 256 | @conn.send_message(@room_id, "No rooms found") 257 | else 258 | msg = build_rooms_list(rooms, limit: 10) 259 | html = build_rooms_list(rooms, limit: 10, is_html: true) 260 | @conn.send_message(@room_id, msg, html) 261 | end 262 | end 263 | 264 | private def time_span_to_s(span : Time::Span) : String 265 | if span.total_seconds <= 60 266 | "#{span.seconds}s" 267 | else 268 | "#{span.total_minutes.to_i}m#{span.seconds}s" 269 | end 270 | end 271 | 272 | private def get_rooms(order = Order::Name, limit = 10, reverse = false) 273 | dir = (reverse) ? "b" : "f" 274 | response = @conn.get "/v1/rooms", is_admin: true, order_by: order, dir: dir 275 | rooms = response["rooms"].as_a 276 | 277 | while (limit == 0 || rooms.size <= limit) && (next_batch = response["next_batch"]?) 278 | response = @conn.get "/v1/rooms", is_admin: true, order_by: order, from: next_batch, dir: dir 279 | rooms.concat response["rooms"].as_a 280 | end 281 | 282 | rooms 283 | end 284 | 285 | private def purge(room_id : String) : Nil 286 | msg = "Purge starting, depending on the size of the room it may take a while" 287 | event_id = @conn.send_message(@room_id, msg) 288 | 289 | run_with_progress(2.seconds) do |runner| 290 | runner.command do 291 | if id = room_id 292 | do_purge(id) 293 | end 294 | end 295 | runner.on_progress do |time| 296 | @conn.edit_message(@room_id, event_id, "#{msg}: #{time.total_seconds.round}s") 297 | end 298 | runner.on_success do |time| 299 | @conn.edit_message(@room_id, event_id, "#{room_id} purged in #{time.total_seconds.round}s") 300 | end 301 | end 302 | end 303 | 304 | private def do_purge(room_id : String) : Nil 305 | @conn.post("/v1/purge_room", is_admin: true, data: {room_id: room_id}) 306 | end 307 | 308 | private def shutdown(room_id : String) : Nil 309 | event_id = @conn.send_message(@room_id, "Shuting down room #{room_id}") 310 | run_with_progress(20.seconds) do |runner| 311 | runner.command do 312 | @conn.post("/v1/shutdown_room/#{room_id}", is_admin: true, data: {new_room_user_id: @conn.user_id}) 313 | end 314 | runner.on_progress do |time| 315 | @conn.edit_message(@room_id, event_id, "Shuting down room #{room_id}: #{time.total_seconds.round}s") 316 | end 317 | runner.on_success do |time| 318 | @conn.edit_message(@room_id, event_id, "#{room_id} shutted down in #{time.total_seconds.round}s") 319 | end 320 | end 321 | end 322 | 323 | private def top_rooms(order : Order) : Nil 324 | rooms = get_rooms(order) 325 | rescue ex : Connection::ExecError 326 | @conn.send_message(@room_id, "Error: #{ex.message}") 327 | else 328 | msg = build_rooms_list(rooms[0, 10], order.to_s) 329 | html = build_rooms_list(rooms[0, 10], order.to_s, is_html: true) 330 | @conn.send_message(@room_id, msg, html) 331 | end 332 | end 333 | end 334 | end 335 | -------------------------------------------------------------------------------- /src/commands/user.cr: -------------------------------------------------------------------------------- 1 | require "option_parser" 2 | 3 | require "../connection" 4 | require "./base" 5 | 6 | module Matrix::Architect 7 | module Commands 8 | class User < Base 9 | def parse(parser, job) 10 | parser.banner = "!user COMMAND" 11 | parser.on("deactivate", "deactivate an account") do 12 | parser.banner = "!user deactivate USER_ID" 13 | parser.unknown_args do |before, _| 14 | if before.size != 1 15 | job.help 16 | elsif user_id = before[0]? 17 | job.exec { deactivate(user_id) } 18 | else 19 | job.help 20 | end 21 | end 22 | end 23 | parser.on("list", "list users") do 24 | guest = true 25 | user_id : String? = nil 26 | parser.banner = "!user list [--no-guests] [--user-id FILTER]" 27 | parser.on("--no-guests", "don't list guests") { guest = false } 28 | parser.on("--user-id FILTER", "filter users") { |filter| user_id = filter } 29 | job.exec { list(guest, user_id) } 30 | end 31 | parser.on("reset-password", "reset a user's password and return the new password") do 32 | logout = true 33 | parser.banner = "!user reset-password [--no-logout] USER_ID" 34 | parser.on("--no-logout", "don't log the user out of all their devices") { logout = false } 35 | parser.unknown_args do |before, _| 36 | if user_id = before[0]? 37 | job.exec { reset_password(user_id, logout) } 38 | else 39 | job.help 40 | end 41 | end 42 | end 43 | parser.on("query", "return informations about a specific user account") do 44 | parser.unknown_args do |before, _| 45 | if user_id = before[0]? 46 | job.exec { query(user_id) } 47 | else 48 | job.help 49 | end 50 | end 51 | end 52 | end 53 | 54 | private def build_users_msg(users, html = false, limit = 10) 55 | String.build do |str| 56 | str << users.size << " users found:\n" 57 | 58 | if html 59 | str << "
      " 60 | end 61 | 62 | users[0, limit].each do |user| 63 | if html 64 | str << "
    • " 65 | else 66 | str << "* " 67 | end 68 | 69 | str << user["name"].as_s 70 | str << %( ") << user["displayname"] << %(") 71 | 72 | if user["is_guest"].as_i == 1 73 | str << " guest" 74 | end 75 | 76 | if user["admin"].as_i == 1 77 | str << " admin" 78 | end 79 | 80 | if user["deactivated"].as_i == 1 81 | str << " deactivated" 82 | end 83 | 84 | if html 85 | str << "
    • " 86 | else 87 | str << "\n" 88 | end 89 | end 90 | 91 | if html 92 | str << "
    " 93 | end 94 | 95 | if users.size > limit 96 | str << "\nToo many users, " 97 | str << "showing only the " << limit << " first ones." 98 | end 99 | end 100 | end 101 | 102 | private def deactivate(user_id) 103 | @conn.post("/v1/deactivate/#{user_id}", is_admin: true) 104 | rescue ex : Connection::ExecError 105 | send_message "Error while deactivating the user: #{ex.message}" 106 | else 107 | send_message "user deactivated" 108 | end 109 | 110 | private def list(guests = true, user_id : String? = nil) : Nil 111 | params = {guest: guests, user_id: user_id} 112 | response = @conn.get "/v2/users", **params, is_admin: true 113 | users = response["users"].as_a 114 | 115 | while next_token = response["next_token"]? 116 | response = @conn.get "/v2/users", **params, is_admin: true, from: next_token.as_s 117 | users.concat(response["users"].as_a) 118 | end 119 | rescue ex : Connection::ExecError 120 | send_message "Error while getting users list: #{ex.message}" 121 | else 122 | msg = build_users_msg(users) 123 | html = build_users_msg(users, html: true) 124 | send_message msg, html 125 | end 126 | 127 | private def reset_password(user_id, logout) : Nil 128 | password = Random::Secure.base64(32)[0...-1] 129 | response = @conn.post( 130 | "/v1/reset_password/#{user_id}", 131 | {logout_devices: logout, new_password: password}, 132 | is_admin: true, 133 | ) 134 | rescue ex : Connection::ExecError 135 | send_message "Error: #{ex.message}" 136 | else 137 | puts response 138 | send_message "The new password is #{password}" 139 | end 140 | 141 | private def query(user_id) 142 | response = @conn.get("/v2/users/#{user_id}", is_admin: true) 143 | rescue ex : Connection::ExecError 144 | send_message "Error: #{ex.message}" 145 | else 146 | msg = response.to_pretty_json 147 | send_message "```\n#{msg}\n```", "
    #{msg}
    " 148 | end 149 | end 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /src/commands/version.cr: -------------------------------------------------------------------------------- 1 | require "../connection" 2 | require "./base" 3 | 4 | module Matrix::Architect 5 | module Commands 6 | class Version < Base 7 | def parse(parser, job) : Nil 8 | parser.banner = "!version" 9 | job.exec do 10 | begin 11 | response = @conn.get "/v1/server_version", is_admin: true 12 | rescue ex : Connection::ExecError 13 | send_message "Error: #{ex.message}" 14 | else 15 | msg = response.to_pretty_json 16 | send_message "```\n#{msg}\n```", "
    #{msg}
    " 17 | end 18 | end 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /src/connection.cr: -------------------------------------------------------------------------------- 1 | require "http/client" 2 | require "json" 3 | 4 | require "./events" 5 | require "./errors" 6 | 7 | module Matrix::Architect 8 | # Interafce to represent a Matrix client. 9 | module Connection 10 | class ExecError < Exception 11 | end 12 | 13 | abstract def edit_message(room_id : String, event_id : String, message : String, html : String? = nil) : Nil 14 | abstract def send_message(room_id : String, message : String, html : String? = nil) : String 15 | abstract def get(route, **options) : JSON::Any 16 | abstract def post(route, data = nil, **options) : JSON::Any 17 | abstract def put(route, data = nil) : JSON::Any 18 | end 19 | 20 | class ConnectionImpl 21 | include Connection 22 | 23 | Log = Matrix::Architect::Log.for(self) 24 | 25 | @syncing = false 26 | @tx_id = 0 27 | getter user_id : String = "" 28 | 29 | def initialize(@hs_url : String, @access_token : String) 30 | @hs_url = @hs_url.gsub(%r{https?://}, "") 31 | 32 | Log.info { "Connecting to #{hs_url}" } 33 | @client_sync = HTTP::Client.new(@hs_url, 443, true) 34 | @user_id = whoami 35 | 36 | Log.info { "User's id is #{@user_id}" } 37 | end 38 | 39 | def create_filter(filter) : String 40 | response = post "/user/#{@user_id}/filter", filter 41 | response["filter_id"].as_s 42 | end 43 | 44 | def join(room_id) 45 | post "/rooms/#{room_id}/join" 46 | end 47 | 48 | def edit_message(room_id : String, event_id : String, message : String, html : String? = nil) : Nil 49 | tx_id = get_tx_id 50 | new_content = get_message_content(message, html) 51 | data = new_content.merge( 52 | { 53 | "m.new_content": new_content, 54 | "m.relates_to": { 55 | rel_type: "m.replace", 56 | event_id: event_id, 57 | }, 58 | } 59 | ) 60 | put "/rooms/#{room_id}/send/m.room.message/#{tx_id}", data 61 | end 62 | 63 | def send_message(room_id : String, message : String, html : String? = nil) : String 64 | tx_id = get_tx_id 65 | data = get_message_content(message, html) 66 | response = put "/rooms/#{room_id}/send/m.room.message/#{tx_id}", data 67 | 68 | response["event_id"].as_s 69 | end 70 | 71 | def sync(channel) 72 | if @syncing 73 | raise Exception.new("Already syncing") 74 | end 75 | 76 | # create filter to use for sync 77 | filter = { 78 | account_data: {types: [] of String}, 79 | presence: {types: [] of String}, 80 | room: { 81 | account_data: {types: [] of String}, 82 | ephemeral: {types: [] of String}, 83 | timeline: {lazy_load_members: true}, 84 | state: {lazy_load_members: true}, 85 | }, 86 | } 87 | filter_id = create_filter filter 88 | 89 | spawn do 90 | next_batch = nil 91 | 92 | loop do 93 | begin 94 | if next_batch.nil? 95 | response = get "/sync", is_sync: true, filter: filter_id 96 | else 97 | response = get "/sync", is_sync: true, filter: filter_id, since: next_batch, timeout: 300_000 98 | end 99 | rescue ex : ExecError 100 | # The sync failed, this is probably due to the HS having 101 | # difficulties, let's not harm it anymore. 102 | Log.error(exception: ex) { "Error while syncing, waiting 10s before retry" } 103 | sleep 10 104 | next 105 | end 106 | 107 | next_batch = response["next_batch"]?.try &.to_s 108 | channel.send(Events::Sync.new(response)) 109 | end 110 | end 111 | end 112 | 113 | def whoami : String 114 | response = get "/account/whoami" 115 | response["user_id"].as_s 116 | end 117 | 118 | def get(route, **options) : JSON::Any 119 | exec "GET", route, **options 120 | end 121 | 122 | def post(route, data = nil, **options) : JSON::Any 123 | exec "POST", route, **options, body: data 124 | end 125 | 126 | def put(route, data = nil) : JSON::Any 127 | exec "PUT", route, body: data 128 | end 129 | 130 | private def exec(method, route, is_sync = false, is_admin = false, body = nil, **options) 131 | params = {} of String => String 132 | if !options.nil? 133 | options.each do |k, v| 134 | params[k.to_s] = v.to_s 135 | end 136 | end 137 | 138 | params = HTTP::Params.encode(params) 139 | if is_admin 140 | url = "/_synapse/admin#{route}?#{params}" 141 | else 142 | url = "/_matrix/client/r0#{route}?#{params}" 143 | end 144 | 145 | if is_sync 146 | client = @client_sync 147 | else 148 | client = HTTP::Client.new @hs_url, 443, true 149 | end 150 | 151 | headers = HTTP::Headers{"Authorization" => "Bearer #{@access_token}"} 152 | if !body.nil? 153 | body = body.to_json 154 | headers["Content-Type"] = "application/json" 155 | end 156 | 157 | Log.debug { "#{method} #{url}" } 158 | loop do 159 | response = client.exec method, url, headers, body 160 | 161 | begin 162 | case response.status_code 163 | when 200 164 | return JSON.parse(response.body) 165 | when 429 166 | content = JSON.parse(response.body) 167 | error = Errors::RateLimited.new(content) 168 | Log.warn { "Rate limited, retry after #{error.retry_after_ms}" } 169 | sleep (error.retry_after_ms + 100).milliseconds 170 | else 171 | raise ExecError.new("Invalid status code #{response.status_code}: #{response.body}") 172 | end 173 | rescue ex : JSON::ParseException 174 | Log.error(exception: ex) { "Error while parsing JSON" } 175 | Log.error { "Response body: #{response.body}" } 176 | raise ExecError.new 177 | end 178 | end 179 | end 180 | 181 | private def get_message_content(message : String, html : String? = nil) : NamedTuple 182 | data = { 183 | body: message, 184 | msgtype: "m.text", 185 | } 186 | 187 | if !html.nil? 188 | data = data.merge( 189 | { 190 | format: "org.matrix.custom.html", 191 | formatted_body: html, 192 | } 193 | ) 194 | end 195 | 196 | data 197 | end 198 | 199 | private def get_tx_id : String 200 | @tx_id += 1 201 | "#{Time.utc.to_unix_f}.#{@tx_id}" 202 | end 203 | end 204 | end 205 | -------------------------------------------------------------------------------- /src/errors.cr: -------------------------------------------------------------------------------- 1 | module Matrix::Architect 2 | module Errors 3 | struct RateLimited 4 | getter retry_after_ms : Int32 5 | 6 | def initialize(payload) 7 | @retry_after_ms = payload["retry_after_ms"].as_i 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /src/events.cr: -------------------------------------------------------------------------------- 1 | require "json" 2 | 3 | module Matrix::Architect 4 | module Events 5 | struct Invite 6 | getter room_id : String 7 | 8 | def initialize(room_id, payload : JSON::Any) 9 | @room_id = room_id 10 | @payload = payload 11 | end 12 | end 13 | 14 | struct Message 15 | getter body : String 16 | 17 | def initialize(@payload : JSON::Any) 18 | @body = @payload["content"]["body"].as_s 19 | end 20 | end 21 | 22 | struct RoomEvent 23 | getter room_id : String 24 | getter sender : String 25 | 26 | def initialize(@room_id, @payload : JSON::Any) 27 | @sender = @payload["sender"].as_s 28 | end 29 | 30 | def message? 31 | if @payload["type"] == "m.room.message" 32 | return Message.new(@payload) 33 | end 34 | end 35 | end 36 | 37 | struct Sync 38 | def initialize(payload : JSON::Any) 39 | @payload = payload 40 | end 41 | 42 | def invites(&block) 43 | @payload["rooms"]["invite"].as_h.each do |room_id, invite| 44 | yield Invite.new(room_id, invite) 45 | end 46 | end 47 | 48 | def room_events(&block) 49 | @payload["rooms"]["join"].as_h.each do |room_id, room| 50 | room["timeline"]["events"].as_a.each do |event| 51 | yield RoomEvent.new(room_id, event) 52 | end 53 | end 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /src/main.cr: -------------------------------------------------------------------------------- 1 | require "./matrix-architect" 2 | 3 | Matrix::Architect.run 4 | -------------------------------------------------------------------------------- /src/matrix-architect.cr: -------------------------------------------------------------------------------- 1 | require "http/client" 2 | require "http/headers" 3 | require "json" 4 | require "log" 5 | require "option_parser" 6 | require "yaml" 7 | 8 | require "./bot" 9 | 10 | module Matrix::Architect 11 | VERSION = "0.1.0" 12 | 13 | Log = ::Log.for(self) 14 | 15 | struct Config 16 | include YAML::Serializable 17 | include YAML::Serializable::Strict 18 | 19 | property access_token : String 20 | property log_level = ::Log::Severity::Info 21 | 22 | @[YAML::Field(key: "homeserver")] 23 | property hs_url : String 24 | 25 | @[YAML::Field(key: "users")] 26 | property users_id : Array(String) 27 | end 28 | 29 | def self.get_config(config_file) 30 | File.open(config_file) do |file| 31 | return Config.from_yaml(file) 32 | end 33 | rescue File::NotFoundError 34 | puts "Configuration file '#{config_file}' not found" 35 | rescue ex : YAML::ParseException 36 | puts "Error while reading config file: #{ex.message}" 37 | end 38 | 39 | def self.run : Nil 40 | config_file = "config.yml" 41 | gen = false 42 | 43 | OptionParser.parse do |parser| 44 | parser.on("gen-config", "generate the configuration file") { gen = true } 45 | parser.on("--config CONFIG_FILE", "specify a config file") { |c| config_file = c } 46 | parser.on("-h", "--help", "show this help") do 47 | puts parser 48 | exit 49 | end 50 | end 51 | 52 | if gen 53 | gen_config config_file 54 | else 55 | config = get_config(config_file) 56 | if !config.nil? 57 | ::Log.setup(config.log_level) 58 | Bot.new(config).run 59 | end 60 | end 61 | end 62 | 63 | def self.gen_config(filename) : Nil 64 | if File.exists?(filename) 65 | puts "File #{filename} already exists, do you want to overwrite it? (y/N) " 66 | overwrite = STDIN.gets.try { |r| r == "y" } || false 67 | if !overwrite 68 | return 69 | end 70 | end 71 | 72 | hs_url = read_var("Enter the homeserver URL: ") 73 | if !hs_url.starts_with?(/https?:\/\//) 74 | hs_url = "https://#{hs_url}" 75 | end 76 | 77 | user_id = read_var("Enter the bot's user id: ") 78 | user_password = read_var("Enter the bot's password: ", secret: true) 79 | users = read_list("Enter the allowed bot administrators, end with an empty line: ") 80 | 81 | response = HTTP::Client.post( 82 | "#{hs_url}/_matrix/client/r0/login", 83 | headers: HTTP::Headers{"Content-Type" => "application/json"}, 84 | body: { 85 | type: "m.login.password", 86 | identifier: { 87 | type: "m.id.user", 88 | user: user_id, 89 | }, 90 | password: user_password, 91 | }.to_json 92 | ) 93 | if response.status_code != 200 94 | puts "Got an response from the homserver with status code #{response.status_code}: #{response.body}" 95 | end 96 | 97 | data = Hash(String, JSON::Any).from_json(response.body) 98 | if !data.has_key?("access_token") 99 | puts "Unkwon response from homeserver: #{data}" 100 | return 101 | end 102 | 103 | File.open(filename, mode: "w") do |file| 104 | file << "--- 105 | homeserver: #{hs_url} 106 | access_token: #{data["access_token"]} 107 | log_level: info 108 | users: " 109 | if users.empty? 110 | file << "[]\n" 111 | else 112 | file << "\n" 113 | users.each { |user| file << " - \"" << user << "\"\n" } 114 | end 115 | end 116 | 117 | puts "Configuration file written!" 118 | end 119 | 120 | def self.read_var(prompt, secret = false, allow_empty = false) : String 121 | result = nil 122 | loop do 123 | print prompt 124 | if secret 125 | result = STDIN.noecho &.gets 126 | puts 127 | else 128 | result = STDIN.gets 129 | end 130 | 131 | break if result || allow_empty 132 | end 133 | 134 | if result.nil? 135 | "" 136 | else 137 | result 138 | end 139 | end 140 | 141 | def self.read_list(prompt) : Array(String) 142 | result = Array(String).new 143 | puts prompt 144 | loop do 145 | var = read_var("", allow_empty: true) 146 | break if var == "" 147 | result << var 148 | end 149 | 150 | result 151 | end 152 | end 153 | --------------------------------------------------------------------------------