Compare commits
No commits in common. "python-legacy" and "main" have entirely different histories.
python-leg
...
main
23 changed files with 5458 additions and 239 deletions
7
.editorconfig
Normal file
7
.editorconfig
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
charset = utf-8
|
7
.forgejo/Containerfile.build
Normal file
7
.forgejo/Containerfile.build
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
# Containerfile for CI build container
|
||||||
|
|
||||||
|
FROM archlinux:base-devel
|
||||||
|
|
||||||
|
RUN pacman -Sy --noconfirm archlinux-keyring && pacman -Syu --noconfirm nodejs git rustup sqlite openssh rsync
|
||||||
|
RUN mkdir -p ~/.cargo && echo 'registries.crates-io.protocol = "sparse"' >> ~/.cargo/config.toml
|
||||||
|
RUN rustup update stable beta nightly
|
18
.forgejo/workflows/build-builder.yml
Normal file
18
.forgejo/workflows/build-builder.yml
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
cron: "0 17 * * 4" # every thursday evening (just after rustc release)
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build_and_push:
|
||||||
|
name: Build and push container
|
||||||
|
runs-on: archlinux
|
||||||
|
steps:
|
||||||
|
- name: Login to registry
|
||||||
|
run: echo ${{ secrets.CONTAINER_REGISTRY_PASS }} | docker login -u ${{ vars.CONTAINER_REGISTRY_USER }} --password-stdin git.it-syndikat.org
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
file: .forgejo/Containerfile.build
|
||||||
|
push: true
|
||||||
|
tags: ${{ vars.BUILDER_CONTAINER_NAME }}
|
76
.forgejo/workflows/build.yml
Normal file
76
.forgejo/workflows/build.yml
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
name: Cargo Build & Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
name: Check formatting and lints
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: git.it-syndikat.org/it-syndikat/its-matrix-bot-build
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
toolchain:
|
||||||
|
- stable
|
||||||
|
#- beta
|
||||||
|
#- nightly
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Set Rust toolchain
|
||||||
|
run: rustup default ${{ matrix.toolchain }}
|
||||||
|
- name: Restore cached build artifacts
|
||||||
|
uses: https://github.com/Swatinem/rust-cache@v2
|
||||||
|
- name: Check formatting
|
||||||
|
run: cargo +nightly fmt --check
|
||||||
|
- name: Check clippy lints
|
||||||
|
run: cargo clippy -- -D warnings
|
||||||
|
build_and_test:
|
||||||
|
name: Build and test
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: git.it-syndikat.org/it-syndikat/its-matrix-bot-build
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
toolchain:
|
||||||
|
- stable
|
||||||
|
#- beta
|
||||||
|
#- nightly
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Set Rust toolchain
|
||||||
|
run: rustup default ${{ matrix.toolchain }}
|
||||||
|
- name: Restore cached build artifacts
|
||||||
|
uses: https://github.com/Swatinem/rust-cache@v2
|
||||||
|
- name: Build application
|
||||||
|
run: cargo build --release
|
||||||
|
- name: Run tests
|
||||||
|
run: cargo test
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: artifacts
|
||||||
|
path: target/release/its-matrix-bot
|
||||||
|
deploy:
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
name: Deploy to vandal
|
||||||
|
needs: [check, build_and_test]
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: git.it-syndikat.org/it-syndikat/its-matrix-bot-build
|
||||||
|
steps:
|
||||||
|
- uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: artifacts
|
||||||
|
- name: Deploy to vandal
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
chmod 700 ~/.ssh
|
||||||
|
echo "${{ secrets.SSH_KEY_VANDAL }}" > ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
echo "vandal.srv.it-syndikat.org ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKwSkHeKN+dRkw2Lx0KZTdXZOjAjkrM14XzujXxK3oGw" >> ~/.ssh/known_hosts
|
||||||
|
chmod +x its-matrix-bot
|
||||||
|
rsync -vva its-matrix-bot deployer@vandal.srv.it-syndikat.org:/
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -1,2 +1 @@
|
||||||
__pycache__
|
/target
|
||||||
.mypy_cache
|
|
||||||
|
|
4216
Cargo.lock
generated
Normal file
4216
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
37
Cargo.toml
Normal file
37
Cargo.toml
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
[package]
|
||||||
|
name = "its-matrix-bot"
|
||||||
|
version = "0.2.0"
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.84"
|
||||||
|
|
||||||
|
description = "IT-Syndikat matrix bot"
|
||||||
|
repository = "https://git.it-syndikat.org/IT-Syndikat/its-matrix-bot"
|
||||||
|
license = "AGPL-3.0-or-later"
|
||||||
|
|
||||||
|
build = "build.rs"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
vergen-git2 = { version = "1.0.5", features = ["build", "cargo"] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
clap = { version = "4.0.22", features = ["color", "derive"] }
|
||||||
|
color-eyre = "0.6.2"
|
||||||
|
futures = "0.3.31"
|
||||||
|
matrix-sdk = { version = "0.10.0", features = ["e2e-encryption", "eyre"] }
|
||||||
|
never-say-never = "6.6.666"
|
||||||
|
reqwest = { version = "0.12.12", features = ["json"] }
|
||||||
|
serde_json = "1.0.111"
|
||||||
|
serde = { version = "1.0.147", features = ["derive"] }
|
||||||
|
spaceapi = "0.9.0"
|
||||||
|
thiserror = "2.0.11"
|
||||||
|
time = { version = "0.3.17", features = ["local-offset", "formatting", "macros"] }
|
||||||
|
tokio-util = { version = "0.7.13", features = ["rt"] }
|
||||||
|
tokio = { version = "1.21.2", features = ["full"] }
|
||||||
|
toml = "0.8.20"
|
||||||
|
tracing = "0.1.37"
|
||||||
|
tracing-error = "0.2.0"
|
||||||
|
tracing-subscriber = { version = "0.3.16", features = ["env-filter"] }
|
||||||
|
url = { version = "2.3.1", features = ["serde"] }
|
||||||
|
xdg = "2.4.1"
|
|
@ -1,8 +0,0 @@
|
||||||
FROM docker.io/alpine:latest
|
|
||||||
|
|
||||||
RUN apk --no-cache add git py3-pip py3-matrix-nio py3-cryptography py3-pillow &&\
|
|
||||||
git clone https://git.it-syndikat.org/IT-Syndikat/its-matrix-bot.git /matrix-bot && \
|
|
||||||
cd /matrix-bot && \
|
|
||||||
pip install .
|
|
||||||
|
|
||||||
CMD python3 -m its_matrix_bot -c /its-matrix.toml
|
|
30
README.md
30
README.md
|
@ -2,7 +2,35 @@
|
||||||
|
|
||||||
Running in [#lobby:it-syndik.at](matrix:r/lobby:it-syndik.at).
|
Running in [#lobby:it-syndik.at](matrix:r/lobby:it-syndik.at).
|
||||||
|
|
||||||
|
This bot has been rewritten in Rust, the original python implementation can be found in the
|
||||||
|
[python-legacy branch][pycode].
|
||||||
|
|
||||||
|
[pycode]: https://git.it-syndikat.org/IT-Syndikat/its-matrix-bot/src/branch/python-legacy
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Commits pushed to `main` are automatically deployed to production (running on
|
||||||
|
`vandal.srv.it-syndikat.org`) by CI.
|
||||||
|
|
||||||
|
For performance, CI builds happen in a prebuilt container described by `Containerfile.build`, which
|
||||||
|
is regularly built and deployed using `.forgejo/workflows/build-builder.yml`.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
For all these steps, either install the binary using `cargo install` and then run it as
|
||||||
|
`its-matrix-bot`, or run the project directly from the repository using `cargo run --` (e.g. `cargo
|
||||||
|
run -- setup`).
|
||||||
|
|
||||||
|
1. Copy `config.toml.example` to `~/.config/its-matrix-bot/config.toml` (or some other location
|
||||||
|
later specified using `-c, --config`)
|
||||||
|
2. Run first-time setup using `its-matrix-bot setup [-h HOMESERVER_URL] USERNAME` and enter the bot
|
||||||
|
user's password.
|
||||||
|
3. Start the bot using `its-matrix-bot run`.
|
||||||
|
|
||||||
## Available commands
|
## Available commands
|
||||||
|
|
||||||
- `!isitopen`: check if the hackerspace is currently open
|
- `!isitopen`: check if the hackerspace is currently open
|
||||||
- `!spaceping`: play a chime in the hackerpace to attempt to get someone to read your matrix messages
|
- `!spaceping`: play a chime in the hackerpace to attempt to get someone to read your matrix
|
||||||
|
messages
|
||||||
|
|
||||||
|
<!-- vim: set tw=100: -->
|
||||||
|
|
11
build.rs
Normal file
11
build.rs
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
use std::error::Error;
|
||||||
|
|
||||||
|
use vergen_git2::{BuildBuilder, Emitter, Git2Builder};
|
||||||
|
|
||||||
|
pub fn main() -> Result<(), Box<dyn Error>> {
|
||||||
|
Emitter::new()
|
||||||
|
.add_instructions(&Git2Builder::all_git()?)?
|
||||||
|
.add_instructions(&BuildBuilder::all_build()?)?
|
||||||
|
.emit()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
1
clippy.toml
Normal file
1
clippy.toml
Normal file
|
@ -0,0 +1 @@
|
||||||
|
doc-valid-idents = ["SpaceAPI"]
|
|
@ -1,13 +1,8 @@
|
||||||
[app]
|
[app]
|
||||||
command_prefix = "!"
|
command_prefix = "!"
|
||||||
|
|
||||||
[matrix]
|
[space]
|
||||||
homeserver = ...
|
spaceapi_url = "https://spaceapi.it-syndikat.org/api/status.php"
|
||||||
username = ...
|
spaceping_url = "https://homeassistant.asozial.it-syndikat.org/api/webhook/spaceping"
|
||||||
access_token = ...
|
spaceping_token = "foo"
|
||||||
|
announce_rooms = ["#lobby:it-syndik.at"]
|
||||||
[spaceping]
|
|
||||||
api_token = ...
|
|
||||||
|
|
||||||
[isitopen]
|
|
||||||
announce_rooms = ["!room_id:homeserver.example"]
|
|
||||||
|
|
3
deny.toml
Normal file
3
deny.toml
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
[licenses]
|
||||||
|
allow-osi-fsf-free = "both"
|
||||||
|
allow = ["Unicode-DFS-2016", "CC0-1.0"]
|
|
@ -1,124 +0,0 @@
|
||||||
import asyncio
|
|
||||||
import datetime
|
|
||||||
import logging
|
|
||||||
from nio.rooms import MatrixRoom
|
|
||||||
from nio.events.room_events import RoomMessage
|
|
||||||
import simplematrixbotlib as botlib
|
|
||||||
|
|
||||||
from .its_api import ItSyndikatApi
|
|
||||||
from .config import Config
|
|
||||||
|
|
||||||
|
|
||||||
class ItSyndikatBot:
|
|
||||||
bot: botlib.Bot
|
|
||||||
its_api: ItSyndikatApi
|
|
||||||
|
|
||||||
config: Config
|
|
||||||
|
|
||||||
def __init__(self, config: Config):
|
|
||||||
self.config = config
|
|
||||||
self.its_api = ItSyndikatApi(config)
|
|
||||||
|
|
||||||
self.current_open_state = None
|
|
||||||
|
|
||||||
creds = botlib.Creds(
|
|
||||||
config.matrix_homeserver,
|
|
||||||
config.matrix_username,
|
|
||||||
access_token=config.matrix_access_token,
|
|
||||||
session_stored_file="",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.bot = botlib.Bot(creds)
|
|
||||||
|
|
||||||
self.bot.listener.on_message_event(self.on_message)
|
|
||||||
|
|
||||||
async def run(self):
|
|
||||||
async def poll_for_changes():
|
|
||||||
while True:
|
|
||||||
logging.debug("Polling open state")
|
|
||||||
try:
|
|
||||||
status = await self.its_api.status()
|
|
||||||
new_state = status["state"]["open"]
|
|
||||||
if (
|
|
||||||
self.current_open_state is not None
|
|
||||||
and new_state != self.current_open_state
|
|
||||||
):
|
|
||||||
await self.announce_open_change(new_state)
|
|
||||||
self.current_open_state = new_state
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Polling for open state failed: {e}")
|
|
||||||
|
|
||||||
await asyncio.sleep(60)
|
|
||||||
|
|
||||||
asyncio.create_task(poll_for_changes())
|
|
||||||
|
|
||||||
await self.bot.main()
|
|
||||||
|
|
||||||
async def on_message(self, room, message):
|
|
||||||
m = botlib.MessageMatch(room, message, self.bot, self.config.command_prefix)
|
|
||||||
|
|
||||||
if m.is_not_from_this_bot() and m.prefix():
|
|
||||||
if m.command("echo"):
|
|
||||||
await self.echo(room, message, m.args())
|
|
||||||
elif m.command("isitopen"):
|
|
||||||
await self.isitopen(room, message)
|
|
||||||
elif m.command("spaceping"):
|
|
||||||
await self.spaceping(room, message)
|
|
||||||
else:
|
|
||||||
await self.bot.api.send_text_message(
|
|
||||||
room.room_id, f"Unknown command: {m.command()}"
|
|
||||||
)
|
|
||||||
|
|
||||||
async def announce_open_change(self, now_open: bool):
|
|
||||||
logging.info("Open state changed: now " + ("open" if now_open else "closed"))
|
|
||||||
|
|
||||||
room_ids = self.config.isitopen_announce_rooms
|
|
||||||
if now_open:
|
|
||||||
message = "opening IT-Syndikat - Ohai!"
|
|
||||||
else:
|
|
||||||
message = "closing IT-Syndikat - nap time!"
|
|
||||||
|
|
||||||
for room_id in room_ids:
|
|
||||||
await self.bot.api.async_client.room_send(
|
|
||||||
room_id=room_id,
|
|
||||||
message_type="m.room.message",
|
|
||||||
content={
|
|
||||||
"msgtype": "m.notice",
|
|
||||||
"body": message,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def reply(self, room, message, reply):
|
|
||||||
await self.bot.api.async_client.room_send(
|
|
||||||
room_id=room.room_id,
|
|
||||||
message_type="m.room.message",
|
|
||||||
content={
|
|
||||||
"msgtype": "m.text",
|
|
||||||
"body": reply,
|
|
||||||
"m.relates_to": {"m.in_reply_to": {"event_id": message.event_id}},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def echo(self, room, message, args):
|
|
||||||
await self.bot.api.send_text_message(
|
|
||||||
room.room_id, " ".join(arg for arg in args)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def isitopen(self, room, message):
|
|
||||||
try:
|
|
||||||
status = await self.its_api.status()
|
|
||||||
is_open = status["state"]["open"]
|
|
||||||
self.current_open_state = is_open
|
|
||||||
if is_open:
|
|
||||||
date = datetime.datetime.fromtimestamp(status["state"]["lastchange"])
|
|
||||||
text = f"positive! space has been open since {date}"
|
|
||||||
else:
|
|
||||||
text = "negative!"
|
|
||||||
except Exception as e:
|
|
||||||
text = f"error checking space status: {e}"
|
|
||||||
|
|
||||||
await self.reply(room, message, text)
|
|
||||||
|
|
||||||
async def spaceping(self, room: MatrixRoom, message: RoomMessage):
|
|
||||||
await self.its_api.ping()
|
|
||||||
await self.reply(room, message, "Hello Space!")
|
|
|
@ -1,20 +0,0 @@
|
||||||
import asyncio
|
|
||||||
import argparse
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from . import ItSyndikatBot
|
|
||||||
from .config import Config
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.DEBUG)
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="IT-Syndikat matrix bot")
|
|
||||||
parser.add_argument(
|
|
||||||
"-c",
|
|
||||||
"--config",
|
|
||||||
help="path to the config file",
|
|
||||||
)
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
bot = ItSyndikatBot(Config(args.config))
|
|
||||||
asyncio.run(bot.run())
|
|
|
@ -1,31 +0,0 @@
|
||||||
import toml
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
command_prefix: str
|
|
||||||
|
|
||||||
matrix_homeserver: str
|
|
||||||
matrix_user: str
|
|
||||||
matrix_access_token: str
|
|
||||||
|
|
||||||
spaceping_token: str
|
|
||||||
|
|
||||||
isitopen_announce_rooms: List[str]
|
|
||||||
|
|
||||||
def __init__(self, path=None):
|
|
||||||
if path is None:
|
|
||||||
path = "/etc/itsyndikat-bot.toml"
|
|
||||||
|
|
||||||
config = toml.load(path)
|
|
||||||
|
|
||||||
self.command_prefix = config["app"]["command_prefix"]
|
|
||||||
|
|
||||||
matrix = config["matrix"]
|
|
||||||
self.matrix_homeserver = matrix["homeserver"]
|
|
||||||
self.matrix_username = matrix["username"]
|
|
||||||
self.matrix_access_token = matrix["access_token"]
|
|
||||||
|
|
||||||
self.spaceping_token = config["spaceping"]["api_token"]
|
|
||||||
|
|
||||||
self.isitopen_announce_rooms = config["isitopen"]["announce_rooms"]
|
|
|
@ -1,26 +0,0 @@
|
||||||
import aiohttp
|
|
||||||
|
|
||||||
from .config import Config
|
|
||||||
|
|
||||||
|
|
||||||
class ItSyndikatApi:
|
|
||||||
base_url: str
|
|
||||||
config: Config
|
|
||||||
|
|
||||||
def __init__(self, config: Config):
|
|
||||||
self.base_url = "https://spaceapi.it-syndikat.org/api/"
|
|
||||||
self.config = config
|
|
||||||
|
|
||||||
async def status(self):
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
async with session.get(self.base_url + "status.php") as response:
|
|
||||||
return await response.json()
|
|
||||||
|
|
||||||
async def ping(self):
|
|
||||||
params = {"apikey": self.config.spaceping_token}
|
|
||||||
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
async with session.post(
|
|
||||||
self.base_url + "ping.php", params=params
|
|
||||||
) as response:
|
|
||||||
await response.text()
|
|
|
@ -1,17 +0,0 @@
|
||||||
[build-system]
|
|
||||||
requires = ["setuptools"]
|
|
||||||
build-backend = "setuptools.build_meta"
|
|
||||||
|
|
||||||
[project]
|
|
||||||
name = "its-matrix-bot"
|
|
||||||
version = "0.0.1"
|
|
||||||
description = "IT-Syndikat matrix bot"
|
|
||||||
readme = "README.md"
|
|
||||||
license = {text = "GNU Affero General Public License"}
|
|
||||||
classifiers = [
|
|
||||||
"Programming Language :: Python :: 3",
|
|
||||||
]
|
|
||||||
dependencies = [
|
|
||||||
"simplematrixbotlib",
|
|
||||||
"aiohttp",
|
|
||||||
]
|
|
15
rustfmt.toml
Normal file
15
rustfmt.toml
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
error_on_line_overflow = true
|
||||||
|
|
||||||
|
format_code_in_doc_comments = true
|
||||||
|
format_macro_bodies = true
|
||||||
|
format_macro_matchers = true
|
||||||
|
|
||||||
|
group_imports = "StdExternalCrate"
|
||||||
|
imports_granularity = "Crate"
|
||||||
|
reorder_imports = true
|
||||||
|
|
||||||
|
hex_literal_case = "Upper"
|
||||||
|
newline_style = "Unix"
|
||||||
|
reorder_impl_items = true
|
||||||
|
use_field_init_shorthand = true
|
||||||
|
wrap_comments = true
|
554
src/bot.rs
Normal file
554
src/bot.rs
Normal file
|
@ -0,0 +1,554 @@
|
||||||
|
use std::{
|
||||||
|
borrow::Cow,
|
||||||
|
path::Path,
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
use color_eyre::{
|
||||||
|
eyre::{bail, eyre, Context},
|
||||||
|
Help, Result,
|
||||||
|
};
|
||||||
|
use matrix_sdk::{
|
||||||
|
config::SyncSettings,
|
||||||
|
event_handler::Ctx,
|
||||||
|
room::Room,
|
||||||
|
ruma::{
|
||||||
|
api::client::receipt::create_receipt::v3::ReceiptType,
|
||||||
|
events::{
|
||||||
|
receipt::ReceiptThread,
|
||||||
|
room::{
|
||||||
|
member::{MembershipState, StrippedRoomMemberEvent},
|
||||||
|
message::{
|
||||||
|
AddMentions, ForwardThread, MessageType, OriginalRoomMessageEvent,
|
||||||
|
OriginalSyncRoomMessageEvent, RoomMessageEventContent, TextMessageEventContent,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OwnedRoomOrAliasId, RoomAliasId, RoomId, RoomOrAliasId, SecondsSinceUnixEpoch,
|
||||||
|
},
|
||||||
|
Client, RoomState,
|
||||||
|
};
|
||||||
|
use never_say_never::Never;
|
||||||
|
use reqwest::Url;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use time::{
|
||||||
|
convert::{Day, Hour, Minute, Second},
|
||||||
|
ext::NumericalDuration,
|
||||||
|
macros::format_description,
|
||||||
|
OffsetDateTime, UtcOffset,
|
||||||
|
};
|
||||||
|
use tokio::time::sleep;
|
||||||
|
use tracing::{event, info, instrument, span, Level};
|
||||||
|
|
||||||
|
use crate::{its_api::ItsApi, session_path, sqlite_store_path, SessionData};
|
||||||
|
|
||||||
|
const STATE_POLLING_FREQUENCY: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct AppConfig {
|
||||||
|
command_prefix: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct SpaceConfig {
|
||||||
|
spaceapi_url: Url,
|
||||||
|
spaceping_url: Url,
|
||||||
|
spaceping_token: String,
|
||||||
|
announce_rooms: Vec<OwnedRoomOrAliasId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
app: AppConfig,
|
||||||
|
space: SpaceConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum OpenState {
|
||||||
|
Open { since: OffsetDateTime },
|
||||||
|
Closed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpenState {
|
||||||
|
pub fn is_open(&self) -> bool {
|
||||||
|
matches!(self, OpenState::Open { .. })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(api))]
|
||||||
|
async fn get_open_state(api: &ItsApi) -> Result<OpenState> {
|
||||||
|
let state = api
|
||||||
|
.status()
|
||||||
|
.await?
|
||||||
|
.state
|
||||||
|
.ok_or(eyre!("missing `state` key"))?;
|
||||||
|
|
||||||
|
let is_open = state.open.ok_or(eyre!("missing `open` key"))?;
|
||||||
|
let state = if is_open {
|
||||||
|
let last_change = state
|
||||||
|
.lastchange
|
||||||
|
.ok_or(eyre!("missing `lastchange` field"))?
|
||||||
|
.try_into()
|
||||||
|
.wrap_err("lastchange timestamp out of i64 range")?;
|
||||||
|
let last_change = OffsetDateTime::from_unix_timestamp(last_change)
|
||||||
|
.wrap_err("lastchange timestamp out of range")?;
|
||||||
|
OpenState::Open { since: last_change }
|
||||||
|
} else {
|
||||||
|
OpenState::Closed
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument]
|
||||||
|
fn format_isitopen_response(
|
||||||
|
open_state: OpenState,
|
||||||
|
now: OffsetDateTime,
|
||||||
|
offset: Option<UtcOffset>,
|
||||||
|
) -> Result<String> {
|
||||||
|
match open_state {
|
||||||
|
OpenState::Open { since } => {
|
||||||
|
let duration = now - since;
|
||||||
|
|
||||||
|
let since_str = if let Some(offset) = offset {
|
||||||
|
let since = since.to_offset(offset);
|
||||||
|
|
||||||
|
if duration > 20.hours() {
|
||||||
|
since.format(format_description!(
|
||||||
|
"[year]-[month]-[day] [hour]:[minute]:[second]"
|
||||||
|
))?
|
||||||
|
} else {
|
||||||
|
since.format(format_description!("[hour]:[minute]:[second]"))?
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
format!("{since}")
|
||||||
|
};
|
||||||
|
|
||||||
|
if duration.is_positive() {
|
||||||
|
let for_str = {
|
||||||
|
if duration < 1.minutes() {
|
||||||
|
format!("{}s", duration.whole_seconds())
|
||||||
|
} else if duration < 1.hours() {
|
||||||
|
let mins = duration.whole_minutes();
|
||||||
|
format!("{mins} minute{}", if mins == 1 { "" } else { "s" })
|
||||||
|
} else if duration < 1.days() {
|
||||||
|
let mins = duration.whole_minutes() % i64::from(Minute::per(Hour));
|
||||||
|
format!("{}h{mins}min", duration.whole_hours())
|
||||||
|
} else {
|
||||||
|
let hours = duration.whole_hours() % i64::from(Hour::per(Day));
|
||||||
|
format!("{}d{hours}h", duration.whole_days())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(format!(
|
||||||
|
"positive! space has been open for {for_str} (since {since_str})"
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Ok(format!(
|
||||||
|
"positive! space has been open since {since_str} (in the future?!)"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OpenState::Closed => Ok("negative!".to_owned()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Bot {
|
||||||
|
config: Config,
|
||||||
|
announce_rooms: Vec<Room>,
|
||||||
|
|
||||||
|
client: Client,
|
||||||
|
api: ItsApi,
|
||||||
|
open_state: Mutex<Option<OpenState>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Bot {
|
||||||
|
/// Creates a new bot instance and logs in to the homeserver.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the login failed.
|
||||||
|
#[instrument]
|
||||||
|
pub async fn new(config: Config) -> Result<Self> {
|
||||||
|
let mut builder = Client::builder().sqlite_store(sqlite_store_path()?, None);
|
||||||
|
|
||||||
|
let session_path = session_path()?;
|
||||||
|
let session_data = Self::load_session(&session_path)
|
||||||
|
.await
|
||||||
|
.wrap_err("Failed to load session data")
|
||||||
|
.note("Has the bot been initialized?")
|
||||||
|
.suggestion("Run `its-matrix-bot setup`")?;
|
||||||
|
|
||||||
|
if let Some(url) = session_data.homeserver_url {
|
||||||
|
builder = builder.homeserver_url(url);
|
||||||
|
} else {
|
||||||
|
builder = builder.server_name(session_data.session.meta.user_id.server_name());
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = builder.build().await?;
|
||||||
|
client.restore_session(session_data.session).await?;
|
||||||
|
|
||||||
|
let api = ItsApi::new(
|
||||||
|
config.space.spaceapi_url.clone(),
|
||||||
|
config.space.spaceping_url.clone(),
|
||||||
|
config.space.spaceping_token.clone(),
|
||||||
|
);
|
||||||
|
let open_state = get_open_state(&api).await.ok();
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
config,
|
||||||
|
announce_rooms: vec![],
|
||||||
|
client,
|
||||||
|
api,
|
||||||
|
open_state: Mutex::new(open_state),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument]
|
||||||
|
async fn load_session(path: &Path) -> Result<SessionData> {
|
||||||
|
let data = toml::from_str(&tokio::fs::read_to_string(path).await?)?;
|
||||||
|
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the internally stored open state and announces any changes to
|
||||||
|
/// the state in the announcement channels.
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
async fn update_open_state(&self) -> Result<OpenState> {
|
||||||
|
let new_state = get_open_state(&self.api).await?;
|
||||||
|
|
||||||
|
let is_open = new_state.is_open();
|
||||||
|
let was_open = self
|
||||||
|
.open_state
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.as_ref()
|
||||||
|
.map(OpenState::is_open);
|
||||||
|
if was_open.is_some_and(|was_open| was_open != is_open) {
|
||||||
|
let message = if is_open {
|
||||||
|
"opening IT-Syndikat - Ohai!"
|
||||||
|
} else {
|
||||||
|
"closing IT-Syndikat - nap time!"
|
||||||
|
};
|
||||||
|
|
||||||
|
for room in &self.announce_rooms {
|
||||||
|
room.send(RoomMessageEventContent::notice_plain(message))
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*self.open_state.lock().unwrap() = Some(new_state);
|
||||||
|
Ok(new_state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves a [`RoomOrAliasId`] to a room ID if it is an alias, or returns
|
||||||
|
/// the room ID unchanged.
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
async fn room_id<'id>(&self, room: &'id RoomOrAliasId) -> Result<Cow<'id, RoomId>> {
|
||||||
|
let id = if let Ok(room_id) = <&RoomId>::try_from(room) {
|
||||||
|
Cow::Borrowed(room_id)
|
||||||
|
} else {
|
||||||
|
event!(Level::DEBUG, "resolving room alias");
|
||||||
|
|
||||||
|
let alias = <&RoomAliasId>::try_from(room)
|
||||||
|
.expect("room identifier is neither a room ID nor a room alias");
|
||||||
|
|
||||||
|
Cow::Owned(self.client.resolve_room_alias(alias).await?.room_id)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collects the configured announcement rooms and joins them if necessary.
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
async fn join_announce_rooms(&mut self) -> Result<()> {
|
||||||
|
for room in &self.config.space.announce_rooms {
|
||||||
|
if let Some(room) = self.client.get_room(&self.room_id(room).await?) {
|
||||||
|
if room.state() == RoomState::Joined {
|
||||||
|
self.announce_rooms.push(room);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
event!(Level::INFO, %room, "joining announcement room");
|
||||||
|
let room = self.client.join_room_by_id_or_alias(room, &[]).await?;
|
||||||
|
|
||||||
|
let Some(room) = self.client.get_room(room.room_id()) else {
|
||||||
|
bail!("announcement room not found after joining");
|
||||||
|
};
|
||||||
|
if room.state() != RoomState::Joined {
|
||||||
|
bail!("announcement room not joined after joining");
|
||||||
|
}
|
||||||
|
self.announce_rooms.push(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self, ev, room))]
|
||||||
|
async fn handle_command(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
ev: &OriginalRoomMessageEvent,
|
||||||
|
room: &Room,
|
||||||
|
command: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
let reply = |msg: &str| {
|
||||||
|
room.send(RoomMessageEventContent::text_plain(msg).make_reply_to(
|
||||||
|
ev,
|
||||||
|
ForwardThread::Yes,
|
||||||
|
AddMentions::Yes,
|
||||||
|
))
|
||||||
|
};
|
||||||
|
|
||||||
|
match command {
|
||||||
|
"isitopen" => {
|
||||||
|
reply(&format_isitopen_response(
|
||||||
|
self.update_open_state().await?,
|
||||||
|
OffsetDateTime::now_utc(),
|
||||||
|
UtcOffset::current_local_offset().ok(),
|
||||||
|
)?)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
"spaceping" => {
|
||||||
|
self.api.ping().await?;
|
||||||
|
reply("Hello Space!").await?;
|
||||||
|
}
|
||||||
|
"version" => {
|
||||||
|
reply(&format!(
|
||||||
|
"{}, built from git commit {}",
|
||||||
|
crate::APPLICATION_NAME,
|
||||||
|
crate::GIT_HASH,
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
reply("Unknown command").await?;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
async fn handle_message(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
ev: &OriginalRoomMessageEvent,
|
||||||
|
room: &Room,
|
||||||
|
) -> Result<()> {
|
||||||
|
event!(Level::TRACE, ?ev, "handling message");
|
||||||
|
if SecondsSinceUnixEpoch::now().get() - ev.origin_server_ts.as_secs()
|
||||||
|
> Second::per(Day).into()
|
||||||
|
{
|
||||||
|
event!(
|
||||||
|
Level::INFO,
|
||||||
|
origin_server_ts = i64::from(ev.origin_server_ts.get()),
|
||||||
|
"message is too old, ignoring"
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if Some(&*ev.sender) == self.client.user_id() {
|
||||||
|
event!(Level::TRACE, "message from ourselves, ignoring");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let MessageType::Text(TextMessageEventContent { body, .. }) = &ev.content.msgtype else {
|
||||||
|
event!(Level::TRACE, "non-plaintext message, ignoring");
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let command = body.strip_prefix(&self.config.app.command_prefix);
|
||||||
|
let may_be_dm = !matches!(room.is_direct().await, Ok(false));
|
||||||
|
|
||||||
|
if command.is_some() || may_be_dm {
|
||||||
|
room.send_single_receipt(
|
||||||
|
ReceiptType::FullyRead,
|
||||||
|
ReceiptThread::Unthreaded,
|
||||||
|
ev.event_id.clone(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(command) = command else {
|
||||||
|
event!(Level::TRACE, "non-command message, ignoring");
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(error) = self.handle_command(ev, room, command).await {
|
||||||
|
event!(Level::WARN, ?error, "handling command failed: {error:#}");
|
||||||
|
let _ignore = room
|
||||||
|
.send(RoomMessageEventContent::text_plain(format!(
|
||||||
|
"error handling command: {error:#}"
|
||||||
|
)))
|
||||||
|
.await;
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_invite(
|
||||||
|
ev: StrippedRoomMemberEvent,
|
||||||
|
room: Room,
|
||||||
|
this: Ctx<Arc<Self>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let this_user = this
|
||||||
|
.client
|
||||||
|
.user_id()
|
||||||
|
.expect("User ID not set after logging in");
|
||||||
|
|
||||||
|
if ev.state_key != this_user {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if ev.content.membership != MembershipState::Invite {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(room_id = %room.room_id(), sender = %ev.sender, "Accepting room invite");
|
||||||
|
|
||||||
|
room.join().await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs the bot. This function does not return except in case of error.
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
pub async fn run(mut self) -> Result<Never> {
|
||||||
|
self.client.sync_once(SyncSettings::default()).await?;
|
||||||
|
|
||||||
|
self.join_announce_rooms()
|
||||||
|
.await
|
||||||
|
.wrap_err("failed to join announcement rooms")?;
|
||||||
|
|
||||||
|
let this = Arc::new(self);
|
||||||
|
|
||||||
|
let this_poll = Arc::clone(&this);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let this = this_poll;
|
||||||
|
|
||||||
|
let span = span!(Level::INFO, "state polling task");
|
||||||
|
let _enter = span.enter();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if let Err(error) = this.update_open_state().await {
|
||||||
|
event!(Level::WARN, %error, "failed to update state");
|
||||||
|
};
|
||||||
|
sleep(STATE_POLLING_FREQUENCY).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.add_event_handler_context(Arc::clone(&this));
|
||||||
|
this.client.add_event_handler(
|
||||||
|
|ev: OriginalSyncRoomMessageEvent, room: Room, this: Ctx<Arc<Self>>| async move {
|
||||||
|
let ev = ev.into_full_event(room.room_id().to_owned());
|
||||||
|
if let Err(error) = (*this).handle_message(&ev, &room).await {
|
||||||
|
event!(Level::WARN, event = ?ev, ?error, "handling message failed");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this.client.add_event_handler(Self::handle_invite);
|
||||||
|
|
||||||
|
// Box humongous future
|
||||||
|
Box::pin(this.client.sync(SyncSettings::default())).await?;
|
||||||
|
unreachable!("sync() returned unexpectedly")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use time::{macros::datetime, Duration, UtcOffset};
|
||||||
|
|
||||||
|
use super::{format_isitopen_response, OpenState};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_format_isitopen_response() {
|
||||||
|
const OFFSET: UtcOffset = {
|
||||||
|
let Ok(off) = UtcOffset::from_hms(1, 30, 0) else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
off
|
||||||
|
};
|
||||||
|
let now = datetime!(2021-02-03 18:05:00 +01:00:00);
|
||||||
|
let now_utc = now.to_offset(UtcOffset::UTC);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
format_isitopen_response(OpenState::Closed, now, None).unwrap(),
|
||||||
|
"negative!"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
format_isitopen_response(OpenState::Closed, now, Some(OFFSET)).unwrap(),
|
||||||
|
"negative!"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
format_isitopen_response(
|
||||||
|
OpenState::Open {
|
||||||
|
since: now_utc - Duration::minutes(1)
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
None
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
"positive! space has been open for 1 minute (since 2021-02-03 17:04:00.0 +00:00:00)"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
format_isitopen_response(
|
||||||
|
OpenState::Open {
|
||||||
|
since: now_utc - Duration::seconds(42)
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
Some(OFFSET),
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
"positive! space has been open for 42s (since 18:34:18)"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
format_isitopen_response(
|
||||||
|
OpenState::Open {
|
||||||
|
since: now_utc - Duration::minutes(1)
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
Some(OFFSET),
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
"positive! space has been open for 1 minute (since 18:34:00)"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
format_isitopen_response(
|
||||||
|
OpenState::Open {
|
||||||
|
since: now - Duration::minutes(59)
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
Some(OFFSET),
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
"positive! space has been open for 59 minutes (since 17:36:00)"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
format_isitopen_response(
|
||||||
|
OpenState::Open {
|
||||||
|
since: now - Duration::hours(23) - Duration::minutes(20)
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
Some(OFFSET),
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
"positive! space has been open for 23h20min (since 2021-02-02 19:15:00)"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
format_isitopen_response(
|
||||||
|
OpenState::Open {
|
||||||
|
since: now - Duration::hours(25)
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
Some(OFFSET),
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
"positive! space has been open for 1d1h (since 2021-02-02 17:35:00)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
125
src/its_api.rs
Normal file
125
src/its_api.rs
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
use reqwest::{Client, Url};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use thiserror::Error;
|
||||||
|
use tracing::{event, instrument, Level};
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("invalid URL")]
|
||||||
|
InvalidUrl(#[from] url::ParseError),
|
||||||
|
#[error("error retrieving API response")]
|
||||||
|
ResponseBody(#[source] reqwest::Error),
|
||||||
|
#[error("invalid JSON in response")]
|
||||||
|
InvalidJson(#[source] serde_json::Error),
|
||||||
|
#[error("network request failed")]
|
||||||
|
Network(#[source] reqwest::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handler for the IT-Syndikat API.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct ItsApi {
|
||||||
|
spaceapi_url: Url,
|
||||||
|
|
||||||
|
spaceping_url: Url,
|
||||||
|
spaceping_token: String,
|
||||||
|
|
||||||
|
client: Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn response_get_lastchange(value: &mut serde_json::Value) -> Option<&mut serde_json::Value> {
|
||||||
|
value
|
||||||
|
.as_object_mut()?
|
||||||
|
.get_mut("state")?
|
||||||
|
.as_object_mut()?
|
||||||
|
.get_mut("lastchange")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn response_get_icon(
|
||||||
|
value: &mut serde_json::Value,
|
||||||
|
) -> Option<&mut serde_json::Map<String, serde_json::Value>> {
|
||||||
|
value
|
||||||
|
.as_object_mut()?
|
||||||
|
.get_mut("state")?
|
||||||
|
.as_object_mut()?
|
||||||
|
.get_mut("icon")?
|
||||||
|
.as_object_mut()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn patch_response(value: &mut serde_json::Value) {
|
||||||
|
// https://github.com/home-assistant/core/pull/83871
|
||||||
|
if let Some(lastchange) = response_get_lastchange(value) {
|
||||||
|
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
|
||||||
|
if let Some(f) = lastchange.as_f64() {
|
||||||
|
*lastchange = (f as u64).into();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://github.com/home-assistant/core/pull/108596
|
||||||
|
if let Some(icon) = response_get_icon(value) {
|
||||||
|
if let Some(closed) = icon.remove("close") {
|
||||||
|
icon.insert("closed".to_owned(), closed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ItsApi {
|
||||||
|
/// Constructs a new IT-Syndikat API handler.
|
||||||
|
#[instrument]
|
||||||
|
pub fn new(spaceapi_url: Url, spaceping_url: Url, spaceping_token: String) -> Self {
|
||||||
|
Self {
|
||||||
|
spaceapi_url,
|
||||||
|
spaceping_url,
|
||||||
|
spaceping_token,
|
||||||
|
client: Client::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request the SpaceAPI status.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// This function returns an error if the network request fails, or if the
|
||||||
|
/// returned data can not be parsed as a valid SpaceAPI response.
|
||||||
|
#[instrument]
|
||||||
|
pub async fn status(&self) -> Result<spaceapi::Status, Error> {
|
||||||
|
event!(Level::DEBUG, "requesting spaceapi status");
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.get(self.spaceapi_url.clone())
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(Error::Network)?
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map_err(Error::ResponseBody)?;
|
||||||
|
let mut response = serde_json::from_slice(&response).map_err(Error::InvalidJson)?;
|
||||||
|
|
||||||
|
patch_response(&mut response);
|
||||||
|
|
||||||
|
let status = spaceapi::Status::deserialize(response).map_err(Error::InvalidJson)?;
|
||||||
|
|
||||||
|
event!(Level::DEBUG, ?status);
|
||||||
|
Ok(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Makes the box in the space beep.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// This function returns an error if the network request fails.
|
||||||
|
#[instrument]
|
||||||
|
pub async fn ping(&self) -> Result<(), Error> {
|
||||||
|
event!(Level::INFO, "sending spaceping");
|
||||||
|
|
||||||
|
self.client
|
||||||
|
.post(self.spaceping_url.clone())
|
||||||
|
.form(&[("apikey", &self.spaceping_token)])
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(Error::Network)?
|
||||||
|
.error_for_status()
|
||||||
|
.map_err(Error::ResponseBody)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
143
src/main.rs
Normal file
143
src/main.rs
Normal file
|
@ -0,0 +1,143 @@
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![deny(unused_must_use)]
|
||||||
|
#![warn(
|
||||||
|
clippy::pedantic,
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::cast_possible_wrap,
|
||||||
|
clippy::cast_precision_loss,
|
||||||
|
clippy::cast_sign_loss
|
||||||
|
)]
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use bot::{Bot, Config};
|
||||||
|
use clap::Parser as _;
|
||||||
|
use color_eyre::{eyre::Context, Result};
|
||||||
|
use matrix_sdk::authentication::matrix::MatrixSession;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tracing::{event, instrument, Level};
|
||||||
|
use xdg::BaseDirectories;
|
||||||
|
|
||||||
|
mod bot;
|
||||||
|
mod its_api;
|
||||||
|
mod setup;
|
||||||
|
|
||||||
|
const APPLICATION_NAME: &str = "its-matrix-bot";
|
||||||
|
const GIT_HASH: &str = env!("VERGEN_GIT_SHA");
|
||||||
|
|
||||||
|
/// The session data required by the bot to log in. Stored as TOML at the path
|
||||||
|
/// given by [`session_path()`].
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct SessionData {
|
||||||
|
pub homeserver_url: Option<String>,
|
||||||
|
pub session: MatrixSession,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(clap::Parser)]
|
||||||
|
#[command(version = crate::GIT_HASH)]
|
||||||
|
struct Cli {
|
||||||
|
/// Override path to the bot's configuration file
|
||||||
|
#[arg(long, short = 'c')]
|
||||||
|
config: Option<PathBuf>,
|
||||||
|
#[command(subcommand)]
|
||||||
|
sub: Subcommand,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(clap::Subcommand, Debug)]
|
||||||
|
enum Subcommand {
|
||||||
|
Setup(setup::Setup),
|
||||||
|
/// Run the bot
|
||||||
|
Run,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the default path to the bot's configuration file.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the directory containing the configuration file
|
||||||
|
/// could not be created.
|
||||||
|
fn default_config_path() -> Result<PathBuf> {
|
||||||
|
let base_dirs = BaseDirectories::with_prefix(APPLICATION_NAME)?;
|
||||||
|
let path = base_dirs.place_config_file("config.toml")?;
|
||||||
|
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the path to the bot's session file.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the directory containing the session file
|
||||||
|
/// could not be created.
|
||||||
|
fn session_path() -> Result<PathBuf> {
|
||||||
|
let base_dirs = BaseDirectories::with_prefix(APPLICATION_NAME)?;
|
||||||
|
let path = base_dirs.place_data_file("state.toml")?;
|
||||||
|
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the path to the bot's internal database.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if no base directory could be determined (e.g. because
|
||||||
|
/// no home directory is set).
|
||||||
|
fn sqlite_store_path() -> Result<PathBuf> {
|
||||||
|
let base_dirs = BaseDirectories::with_prefix(APPLICATION_NAME)?;
|
||||||
|
Ok(base_dirs.get_data_home())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument]
|
||||||
|
async fn load_config(path: &Path) -> Result<Config> {
|
||||||
|
let config = toml::from_str(&tokio::fs::read_to_string(path).await?)?;
|
||||||
|
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install_tracing() {
|
||||||
|
use tracing_subscriber::{
|
||||||
|
prelude::__tracing_subscriber_SubscriberExt, util::SubscriberInitExt, EnvFilter,
|
||||||
|
};
|
||||||
|
|
||||||
|
let fmt_layer = tracing_subscriber::fmt::layer().with_target(false);
|
||||||
|
let filter_layer = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| EnvFilter::from("info"));
|
||||||
|
let error_layer = tracing_error::ErrorLayer::default();
|
||||||
|
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(fmt_layer)
|
||||||
|
.with(filter_layer)
|
||||||
|
.with(error_layer)
|
||||||
|
.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
install_tracing();
|
||||||
|
color_eyre::install()?;
|
||||||
|
|
||||||
|
let args = Cli::parse();
|
||||||
|
|
||||||
|
match args.sub {
|
||||||
|
Subcommand::Setup(setup) => {
|
||||||
|
setup::setup(setup).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Subcommand::Run => {
|
||||||
|
let config_path = if let Some(config) = args.config {
|
||||||
|
config
|
||||||
|
} else {
|
||||||
|
default_config_path()?
|
||||||
|
};
|
||||||
|
|
||||||
|
let config = load_config(&config_path)
|
||||||
|
.await
|
||||||
|
.wrap_err("Failed to load bot configuration")?;
|
||||||
|
|
||||||
|
let bot = Bot::new(config).await?;
|
||||||
|
|
||||||
|
event!(Level::INFO, "logged in successfully, starting bot");
|
||||||
|
bot.run().await?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
210
src/setup.rs
Normal file
210
src/setup.rs
Normal file
|
@ -0,0 +1,210 @@
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
use clap::Args;
|
||||||
|
use color_eyre::{
|
||||||
|
eyre::{bail, eyre, OptionExt},
|
||||||
|
Result,
|
||||||
|
};
|
||||||
|
use futures::StreamExt;
|
||||||
|
use matrix_sdk::{
|
||||||
|
authentication::matrix::MatrixSession,
|
||||||
|
config::SyncSettings,
|
||||||
|
crypto::{format_emojis, SasState},
|
||||||
|
encryption::verification::{Verification, VerificationRequestState},
|
||||||
|
ruma::{events::key::verification::VerificationMethod, UserId},
|
||||||
|
Client,
|
||||||
|
};
|
||||||
|
use tokio::io::{AsyncBufReadExt, BufReader, Lines, Stdin};
|
||||||
|
use tokio_util::task::AbortOnDropHandle;
|
||||||
|
use tracing::{error, info, instrument};
|
||||||
|
|
||||||
|
use crate::SessionData;
|
||||||
|
|
||||||
|
/// Perform first-time setup using password login
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
pub(crate) struct Setup {
|
||||||
|
/// The bot's matrix username, e.g. `@mybot:myhomeserver.example`
|
||||||
|
username: Box<UserId>,
|
||||||
|
/// An optional homeserver base URL. Will attempt to auto-detect if not
|
||||||
|
/// specified.
|
||||||
|
#[arg(long, short = 's')]
|
||||||
|
homeserver_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(setup, client, stdin))]
|
||||||
|
async fn login_password_interactive(
|
||||||
|
setup: &Setup,
|
||||||
|
client: &Client,
|
||||||
|
stdin: &mut Lines<BufReader<Stdin>>,
|
||||||
|
) -> Result<MatrixSession> {
|
||||||
|
print!("Enter password for {}: ", setup.username);
|
||||||
|
std::io::stdout().flush()?;
|
||||||
|
|
||||||
|
let password = stdin
|
||||||
|
.next_line()
|
||||||
|
.await?
|
||||||
|
.ok_or(eyre!("Password is required on first start"))?;
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.matrix_auth()
|
||||||
|
.login_username(&setup.username, &password)
|
||||||
|
.initial_device_display_name("its-matrix-bot")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(MatrixSession::from(&response))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(client, stdin))]
|
||||||
|
async fn recover_key_backup(client: &Client, stdin: &mut Lines<BufReader<Stdin>>) -> Result<()> {
|
||||||
|
if !client
|
||||||
|
.encryption()
|
||||||
|
.backups()
|
||||||
|
.fetch_exists_on_server()
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
bail!("No key backup found on server, please set up manually");
|
||||||
|
}
|
||||||
|
|
||||||
|
print!("Enter backup recovery passphrase/security key: ");
|
||||||
|
let recovery_key = stdin
|
||||||
|
.next_line()
|
||||||
|
.await?
|
||||||
|
.ok_or_eyre("A recovery key or passphrase is required")?;
|
||||||
|
client
|
||||||
|
.encryption()
|
||||||
|
.recovery()
|
||||||
|
.recover(&recovery_key)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(client, stdin))]
|
||||||
|
async fn verify_device(client: &Client, stdin: &mut Lines<BufReader<Stdin>>) -> Result<()> {
|
||||||
|
info!("Starting device verification");
|
||||||
|
|
||||||
|
let methods = vec![VerificationMethod::SasV1];
|
||||||
|
let verification_request = client
|
||||||
|
.encryption()
|
||||||
|
.get_user_identity(client.user_id().expect("User ID not set after logging in"))
|
||||||
|
.await?
|
||||||
|
.ok_or_eyre("own device not set even after logging in")?
|
||||||
|
.request_verification_with_methods(methods.clone())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
info!("Waiting for verification to be accepted on another device...");
|
||||||
|
|
||||||
|
let mut changes = verification_request.changes();
|
||||||
|
let sas = loop {
|
||||||
|
let Some(state) = changes.next().await else {
|
||||||
|
bail!("verification request stream unexpectedly ended");
|
||||||
|
};
|
||||||
|
|
||||||
|
match state {
|
||||||
|
VerificationRequestState::Ready {
|
||||||
|
other_device_data, ..
|
||||||
|
} => {
|
||||||
|
info!(other_device_id = %other_device_data.device_id(), "Verification accepted");
|
||||||
|
}
|
||||||
|
VerificationRequestState::Transitioned { verification } => {
|
||||||
|
let Verification::SasV1(sas) = verification else {
|
||||||
|
bail!("Unsupported verification method {verification:?}");
|
||||||
|
};
|
||||||
|
|
||||||
|
break sas;
|
||||||
|
}
|
||||||
|
VerificationRequestState::Done => unreachable!(),
|
||||||
|
VerificationRequestState::Cancelled(cancel_info) => {
|
||||||
|
bail!("Verification cancelled: {cancel_info:?}");
|
||||||
|
}
|
||||||
|
VerificationRequestState::Created { .. }
|
||||||
|
| VerificationRequestState::Requested { .. } => {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sas.accept().await?;
|
||||||
|
|
||||||
|
let mut changes = sas.changes();
|
||||||
|
loop {
|
||||||
|
let Some(state) = changes.next().await else {
|
||||||
|
bail!("SAS stream unexpectedly ended");
|
||||||
|
};
|
||||||
|
|
||||||
|
match state {
|
||||||
|
SasState::KeysExchanged { emojis, .. } => {
|
||||||
|
let Some(emojis) = emojis else {
|
||||||
|
bail!("Emoji not available");
|
||||||
|
};
|
||||||
|
let emojis = format_emojis(emojis.emojis);
|
||||||
|
|
||||||
|
print!("{emojis}\nDo these emoji match the other device? [y/N] ");
|
||||||
|
|
||||||
|
let did_match = stdin
|
||||||
|
.next_line()
|
||||||
|
.await?
|
||||||
|
.is_some_and(|s| s.eq_ignore_ascii_case("y") || s.eq_ignore_ascii_case("yes"));
|
||||||
|
|
||||||
|
if did_match {
|
||||||
|
info!("Confirming verification");
|
||||||
|
sas.confirm().await?;
|
||||||
|
} else {
|
||||||
|
info!("Cancelling verification");
|
||||||
|
sas.cancel().await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SasState::Done { .. } => {
|
||||||
|
info!("Verification successful!");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
SasState::Cancelled(cancel_info) => bail!("Verification cancelled: {cancel_info:?}"),
|
||||||
|
SasState::Created { .. }
|
||||||
|
| SasState::Started { .. }
|
||||||
|
| SasState::Accepted { .. }
|
||||||
|
| SasState::Confirmed => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn setup(setup: Setup) -> Result<()> {
|
||||||
|
let mut stdin = tokio::io::BufReader::new(tokio::io::stdin()).lines();
|
||||||
|
|
||||||
|
let client = {
|
||||||
|
let mut builder = Client::builder().sqlite_store(crate::sqlite_store_path()?, None);
|
||||||
|
if let Some(ref url) = setup.homeserver_url {
|
||||||
|
builder = builder.homeserver_url(url);
|
||||||
|
} else {
|
||||||
|
builder = builder.server_name(setup.username.server_name());
|
||||||
|
}
|
||||||
|
builder.build().await?
|
||||||
|
};
|
||||||
|
|
||||||
|
let session = login_password_interactive(&setup, &client, &mut stdin).await?;
|
||||||
|
|
||||||
|
let _sync_handle = {
|
||||||
|
let client = client.clone();
|
||||||
|
AbortOnDropHandle::new(tokio::spawn(async move {
|
||||||
|
client.sync(SyncSettings::default()).await
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = recover_key_backup(&client, &mut stdin).await {
|
||||||
|
error!(%err, "Key backup recovery failed, logging out");
|
||||||
|
client.matrix_auth().logout().await?;
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(err) = verify_device(&client, &mut stdin).await {
|
||||||
|
error!(%err, "Device verification failed, logging out");
|
||||||
|
client.matrix_auth().logout().await?;
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = SessionData {
|
||||||
|
session,
|
||||||
|
homeserver_url: setup.homeserver_url,
|
||||||
|
};
|
||||||
|
tokio::fs::write(crate::session_path()?, toml::to_string(&data)?).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue