Initial commit

All tools successfully used for what I needed them for
This commit is contained in:
Olivier 'reivilibre 2024-11-28 10:31:35 +00:00
commit 4c8e4bf726
14 changed files with 4110 additions and 0 deletions

3
.envrc Normal file
View File

@ -0,0 +1,3 @@
source_url "https://raw.githubusercontent.com/cachix/devenv/95f329d49a8a5289d31e0982652f7058a189bfca/direnvrc" "sha256-d+8cBpDfDBj41inrADaJt+bDWhOktwslgoP5YiGJ1v0="
use devenv

14
.gitignore vendored Normal file
View File

@ -0,0 +1,14 @@
# Devenv
.devenv*
devenv.local.nix
# direnv
.direnv
# pre-commit
.pre-commit-config.yaml
# Added by cargo
/target

3217
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

14
Cargo.toml Normal file
View File

@ -0,0 +1,14 @@
[package]
name = "matricks"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4.5.21", features = ["derive"] }
errify = { version = "0.3.0", features = ["eyre"] }
eyre = "0.6.12"
matrix-sdk = { version = "0.8.0", default-features = false, features = ["eyre", "rustls-tls"] }
regex = "1.11.1"
tokio = { version = "1.41.1", features = ["full"] }
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }

104
README.md Normal file
View File

@ -0,0 +1,104 @@
# Matricks: tricks (tools) for Matrix
a small collection of tools for Matrix...
## Usage
First you need to already have an access token and URL for your homeserver.
Then expose them as env vars, e.g. with the following shell snippet.
(You could also put this in a file and source it with `. matricks_auth.sh` if desired!)
```
read MATRICKS_HOMESERVER # enter your homeserver, e.g. https://matrix.librepush.net
read MATRICKS_TOKEN # enter your access token
export MATRICKS_HOMESERVER MATRICKS_TOKEN
```
### Space tools
#### `space tree`: List a space hierarchy from a root space
You just need to provide a room ID or room alias for the root space.
```
matricks space tree #myspace:example.org
```
The output of this command is a restricted Markdown subset, suitable both for human reading and for use in later commands as a Room Batch File.
### Room tools
All these commands take a source of room IDs, either a single room ID as `--one !roomid:example.com`, or a list of room IDs from a Room Batch File as `--file path/to/file.md`.
This room source argument should be *before* the next subcommand.
#### `rooms aliases`: List aliases from rooms
```
matricks rooms --one '!roomid:example.org` aliases --if-not-match '#nomatch.*' --if-match '#[0-9a-f]*:.*'
```
The `--if-match` and `--if-not-match` predicates arguments can be specified multiple times and let you specify
regular expressions to match aliases to include or omit, respectively.
When multiple predicates are specified, ALL predicates must be satisfied (i.e. it's a big logical AND across all of them).
#### `rooms make-read-only`: Make a room read-only
```
matricks rooms --one '!roomid:example.org` make-read-only --power-level 50
```
This command makes the specified room(s) effectively read-only to unprivileged users by
updating the `m.room.power_levels` state event to:
- set `event_default` to at least the threshold PL
- set all event types' required power level to at least the threshold PL
The `--power-level` argument specifies the threshold power level (PL); that is,
which power level or higher is allowed to send events from now on.
### Room Alias Tools
All these commands take a source of room aliases, either a single room alias as `--one #room:example.com`, or a list of room aliases from an Alias Batch File as `--file path/to/file.md`.
This room source argument should be *before* the next subcommand.
#### `aliases remove`: Remove room aliases
```
matricks aliases --one '#room:example.org' remove
```
This command removes the specified room alias(es).
## File Formats
### Room Batch File
A Room Batch File is a Markdown file that conveniently happens to contain Room IDs embedded in backticks (i.e. code segments).
Room IDs must not split across lines.
The position and formatting of Room IDs is otherwise unimportant.
Example
```markdown
Text text text `!room:example.org`
- Text text text `!room2:example.org`
- Text text text `!room3:example.org`
```
### Alias Batch File
This is the same as a Room Batch File, but instead of room IDs, it contains room aliases.
Example
```markdown
- Some room `#room:example.org`
- Some room 2 `#room2:example.org`
```

116
devenv.lock Normal file
View File

@ -0,0 +1,116 @@
{
"nodes": {
"devenv": {
"locked": {
"dir": "src/modules",
"lastModified": 1732585607,
"owner": "cachix",
"repo": "devenv",
"rev": "a520f05c40ebecaf5e17064b27e28ba8e70c49fb",
"type": "github"
},
"original": {
"dir": "src/modules",
"owner": "cachix",
"repo": "devenv",
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1696426674,
"owner": "edolstra",
"repo": "flake-compat",
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"pre-commit-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1732238832,
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "8edf06bea5bcbee082df1b7369ff973b91618b8d",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-stable": {
"locked": {
"lastModified": 1731797254,
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e8c38b73aeb218e27163376a2d617e61a2ad9b59",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-24.05",
"repo": "nixpkgs",
"type": "github"
}
},
"pre-commit-hooks": {
"inputs": {
"flake-compat": "flake-compat",
"gitignore": "gitignore",
"nixpkgs": [
"nixpkgs"
],
"nixpkgs-stable": "nixpkgs-stable"
},
"locked": {
"lastModified": 1732021966,
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"rev": "3308484d1a443fc5bc92012435d79e80458fe43c",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"type": "github"
}
},
"root": {
"inputs": {
"devenv": "devenv",
"nixpkgs": "nixpkgs",
"pre-commit-hooks": "pre-commit-hooks"
}
}
},
"root": "root",
"version": 7
}

10
devenv.nix Normal file
View File

@ -0,0 +1,10 @@
{ pkgs, lib, config, inputs, ... }:
{
cachix.enable = false;
# https://devenv.sh/languages/
languages.rust.enable = true;
# See full reference at https://devenv.sh/reference/options/
}

15
devenv.yaml Normal file
View File

@ -0,0 +1,15 @@
# yaml-language-server: $schema=https://devenv.sh/devenv.schema.json
inputs:
nixpkgs:
url: github:NixOS/nixpkgs/nixpkgs-unstable
# If you're using non-OSS software, you can set allowUnfree to true.
# allowUnfree: true
# If you're willing to use a package that's vulnerable
# permittedInsecurePackages:
# - "openssl-1.1.1w"
# If you have more than one devenv you can merge them
#imports:
# - ./backend

17
src/aliases_remove.rs Normal file
View File

@ -0,0 +1,17 @@
use eyre::Context;
use matrix_sdk::{
ruma::{api::client::alias::delete_alias, OwnedRoomAliasId},
Client,
};
pub async fn run(client: &Client, aliases: &[OwnedRoomAliasId]) -> eyre::Result<()> {
for alias in aliases {
let request = delete_alias::v3::Request::new(alias.clone());
client
.send(request, None)
.await
.with_context(|| format!("failed to request deletion of {alias}"))?;
}
Ok(())
}

171
src/bin/matricks.rs Normal file
View File

@ -0,0 +1,171 @@
use std::{path::PathBuf, process::ExitCode};
use clap::Parser;
use eyre::{bail, Context};
use matricks::{
aliases_remove, parse_alias_batch_file, parse_room_batch_file,
rooms_aliases::{self, AliasesArgs},
rooms_make_read_only, space_tree,
};
use matrix_sdk::ruma::{OwnedRoomAliasId, OwnedRoomId, OwnedRoomOrAliasId};
use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt};
#[derive(Clone, Parser)]
pub struct Options {
#[command(subcommand)]
command: TopLevelCommand,
}
#[derive(Clone, Parser)]
pub enum TopLevelCommand {
#[command(subcommand)]
Space(SpaceCommand),
Rooms {
/// Operate on one room
#[arg(long = "one")]
one: Option<OwnedRoomId>,
/// Operate on all room IDs found in the given Room Batch File.
/// See the README for documentation on what a Room Batch File looks like.
#[arg(long = "file")]
file: Option<PathBuf>,
#[command(subcommand)]
subcommand: RoomsCommand,
},
Aliases {
/// Operate on one alias
#[arg(long = "one")]
one: Option<OwnedRoomAliasId>,
/// Operate on all room aliases found in the given Alias Batch File.
/// See the README for documentation on what a Alias Batch File looks like.
#[arg(long = "file")]
file: Option<PathBuf>,
#[command(subcommand)]
subcommand: AliasesCommand,
},
}
#[derive(Clone, Parser)]
pub enum SpaceCommand {
Tree { root_space: OwnedRoomOrAliasId },
}
#[derive(Clone, Parser)]
pub enum RoomsCommand {
/// Make the room(s) read-only except to sufficiently privileged users.
///
/// This is suitable for archiving rooms that are no longer used.
MakeReadOnly {
/// The power level to set as the required threshold for sending room messages.
/// 100 (Admin) or 50 (Moderator) is usually a sensible choice.
#[arg(long = "power-level")]
power_level: u32,
},
/// Lists aliases assigned to the rooms, with some filtering options.
///
/// The output format is in Alias Batch File.
Aliases(AliasesArgs),
}
#[derive(Clone, Parser)]
pub enum AliasesCommand {
/// Remove the aliases.
Remove,
}
#[tokio::main]
async fn main() -> eyre::Result<ExitCode> {
tracing_subscriber::registry()
.with(fmt::layer().with_writer(std::io::stderr))
.with(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
let matricks_level = std::env::var("MATRICKS_LOG").unwrap_or("info".to_owned());
format!("matricks={matricks_level},matrix_sdk_base=error,info").into()
}),
)
.init();
let options = Options::parse();
match &options.command {
TopLevelCommand::Space(space_command) => match space_command {
SpaceCommand::Tree { root_space } => {
let client = matricks::get_client_from_env()
.await
.context("could not get client from env")?;
space_tree::run(&client, root_space).await?;
}
},
TopLevelCommand::Rooms {
one,
file,
subcommand,
} => {
let room_sources_count = one.is_some() as u32 + file.is_some() as u32;
if room_sources_count != 1 {
bail!("Exactly one of --one and --file must be provided.");
}
let room_ids = if let Some(one) = one {
vec![one.clone()]
} else if let Some(file) = file {
parse_room_batch_file(file)
.await
.context("could not parse --file Room Batch File")?
} else {
// We already checked if one of the sources was provided, so we can't hit this
unreachable!();
};
match subcommand {
RoomsCommand::MakeReadOnly { power_level } => {
let client = matricks::get_client_from_env()
.await
.context("could not get client from env")?;
rooms_make_read_only::run(&client, &room_ids, *power_level).await?
}
RoomsCommand::Aliases(args) => {
let client = matricks::get_client_from_env()
.await
.context("could not get client from env")?;
rooms_aliases::run(&client, &room_ids, args).await?;
}
}
}
TopLevelCommand::Aliases {
one,
file,
subcommand,
} => {
let alias_sources_count = one.is_some() as u32 + file.is_some() as u32;
if alias_sources_count != 1 {
bail!("Exactly one of --one and --file must be provided.");
}
let room_aliases = if let Some(one) = one {
vec![one.clone()]
} else if let Some(file) = file {
parse_alias_batch_file(file)
.await
.context("could not parse --file Alias Batch File")?
} else {
// We already checked if one of the sources was provided, so we can't hit this
unreachable!();
};
match subcommand {
AliasesCommand::Remove => {
let client = matricks::get_client_from_env()
.await
.context("could not get client from env")?;
aliases_remove::run(&client, &room_aliases).await?;
}
}
}
}
Ok(ExitCode::SUCCESS)
}

151
src/lib.rs Normal file
View File

@ -0,0 +1,151 @@
use std::path::Path;
use eyre::Context;
use matrix_sdk::{
config::SyncSettings,
matrix_auth::{MatrixAuth, MatrixSession, MatrixSessionTokens},
ruma::{
api::client::account::whoami, device_id, user_id, OwnedRoomAliasId, OwnedRoomId,
RoomAliasId, RoomId,
},
AuthSession, Client, SessionMeta,
};
use regex::{Regex, RegexBuilder};
use tracing::debug;
pub mod space_tree;
pub mod rooms_aliases;
pub mod rooms_make_read_only;
pub mod aliases_remove;
pub const USER_AGENT: &str = concat!("matricks/", env!("CARGO_PKG_VERSION"));
pub async fn get_client_from_env() -> eyre::Result<Client> {
let access_token = std::env::var("MATRICKS_TOKEN")
.context("you should supply an access token as env var MATRICKS_TOKEN")?;
let homeserver_uri = std::env::var("MATRICKS_HOMESERVER")
.context("you should supply a homeserver URI as MATRICKS_HOMESERVER env var")?;
let client = Client::builder()
.homeserver_url(&homeserver_uri)
.user_agent(USER_AGENT)
.build()
.await
.context("could not build client")?;
client
.restore_session(MatrixSession {
meta: SessionMeta {
user_id: user_id!("@nobody:example.org").to_owned(),
device_id: device_id!("???unknown???").to_owned(),
},
tokens: MatrixSessionTokens {
access_token: access_token.clone(),
refresh_token: None,
},
})
.await
.context("could not restore pre-session")?;
let whoami = client
.send(whoami::v3::Request::new(), None)
.await
.context("could not issue /whoami call")?;
debug!("logged in as {}/{:?}", whoami.user_id, whoami.device_id);
let client = Client::builder()
.homeserver_url(&homeserver_uri)
.user_agent(USER_AGENT)
.build()
.await
.context("could not build client")?;
client
.restore_session(MatrixSession {
meta: SessionMeta {
user_id: whoami.user_id,
device_id: whoami
.device_id
.unwrap_or_else(|| device_id!("???NoDeviceId???").to_owned()),
},
tokens: MatrixSessionTokens {
access_token: access_token.clone(),
refresh_token: None,
},
})
.await
.context("could not restore pre-session")?;
Ok(client)
}
async fn initial_sync(client: &Client) -> eyre::Result<()> {
// Sync once so we don't process old messages
for attempt in 0..3 {
match client
.sync_once(SyncSettings::default())
.await
.context("Failed initial sync")
{
Ok(_) => {
break;
}
Err(err) => {
if attempt == 2 {
return Err(err);
}
eprintln!("{err:?}");
}
}
}
Ok(())
}
/// Extracts the room IDs from a Room Batch File.
pub async fn parse_room_batch_file(path: &Path) -> eyre::Result<Vec<OwnedRoomId>> {
let room_id_finder_regex = RegexBuilder::new(r#"`(![^:\s]+:[a-z0-9-_.]+)`"#)
.build()
.expect("static regex");
let file_content = tokio::fs::read_to_string(&path)
.await
.with_context(|| format!("could not read file: {path:?}"))?;
let mut out = Vec::new();
for line in file_content.lines() {
for found_room_id in room_id_finder_regex.captures_iter(line) {
if let Ok(room_id) = <&RoomId>::try_from(found_room_id.get(1).unwrap().as_str()) {
out.push(room_id.to_owned());
}
}
}
Ok(out)
}
/// Extracts the room aliases from an Alias Batch File.
pub async fn parse_alias_batch_file(path: &Path) -> eyre::Result<Vec<OwnedRoomAliasId>> {
let room_id_finder_regex = RegexBuilder::new(r#"`(#[^:\s]+:[a-z0-9-_.]+)`"#)
.build()
.expect("static regex");
let file_content = tokio::fs::read_to_string(&path)
.await
.with_context(|| format!("could not read file: {path:?}"))?;
let mut out = Vec::new();
for line in file_content.lines() {
for found_room_alias in room_id_finder_regex.captures_iter(line) {
if let Ok(room_alias) =
<&RoomAliasId>::try_from(found_room_alias.get(1).unwrap().as_str())
{
out.push(room_alias.to_owned());
}
}
}
Ok(out)
}

96
src/rooms_aliases.rs Normal file
View File

@ -0,0 +1,96 @@
use std::{collections::BTreeSet, ops::Deref, str::FromStr};
use clap::Args;
use eyre::ContextCompat;
use matrix_sdk::{
ruma::{OwnedRoomAliasId, OwnedRoomId, RoomAliasId},
Client,
};
use regex::Regex;
use crate::initial_sync;
#[derive(Clone, Args)]
pub struct AliasesArgs {
#[arg(long = "if-match")]
if_match: Vec<AnchoredRegex>,
#[arg(long = "if-not-match")]
if_not_match: Vec<AnchoredRegex>,
}
#[derive(Clone)]
struct AnchoredRegex(Regex);
impl Deref for AnchoredRegex {
type Target = Regex;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl FromStr for AnchoredRegex {
type Err = regex::Error;
fn from_str(value: &'_ str) -> Result<Self, Self::Err> {
Regex::try_from(format!("^{value}$")).map(Self)
}
}
pub async fn run(
client: &Client,
room_ids: &[OwnedRoomId],
args: &AliasesArgs,
) -> eyre::Result<()> {
initial_sync(client).await?;
let aliases = find_aliases(client, room_ids, args).await?;
for alias in aliases {
println!("- `{alias}`");
}
Ok(())
}
async fn find_aliases(
client: &Client,
room_ids: &[OwnedRoomId],
args: &AliasesArgs,
) -> eyre::Result<BTreeSet<OwnedRoomAliasId>> {
let mut out = BTreeSet::new();
for room_id in room_ids {
let room = client
.get_room(room_id)
.with_context(|| format!("client not in room {room_id}"))?;
if let Some(canon_alias) = room.canonical_alias() {
if does_alias_match(&canon_alias, args) {
out.insert(canon_alias);
}
}
for alias in room.alt_aliases() {
if does_alias_match(&alias, args) {
out.insert(alias);
}
}
}
Ok(out)
}
fn does_alias_match(alias: &RoomAliasId, args: &AliasesArgs) -> bool {
for rule in &args.if_match {
if !rule.is_match(alias.as_str()) {
return false;
}
}
for rule in &args.if_not_match {
if rule.is_match(alias.as_str()) {
return false;
}
}
true
}

View File

@ -0,0 +1,63 @@
use errify::{errify, errify_with};
use eyre::{bail, Context, ContextCompat};
use matrix_sdk::{
ruma::{
events::room::power_levels::{self, RoomPowerLevelsEventContent},
OwnedRoomId,
},
Client, Room,
};
use tracing::debug;
use crate::initial_sync;
pub async fn run(client: &Client, rooms: &[OwnedRoomId], power_level: u32) -> eyre::Result<()> {
initial_sync(client).await?;
for room_id in rooms {
let Some(room) = client.get_room(room_id) else {
bail!("could not find room {room_id} in client")
};
make_room_read_only(&room, power_level).await?;
}
Ok(())
}
/// Perform a Read-Modify-Write to the `m.room.power_levels` event to
/// effectively make the room read-only.
///
/// The following modifications are performed:
///
/// - The `event_default` power level is increased to at least `new_min_power_level`.
/// - The power level for each specified event type in `events` is increased to at least `new_min_power_level`.
#[errify("failed to make room {room:?} read-only")]
async fn make_room_read_only(room: &Room, new_min_power_level: u32) -> eyre::Result<()> {
let power_level_state = room
.get_state_event_static::<RoomPowerLevelsEventContent>()
.await
.context("fetching power level event")?
.context("there are no power levels in this room")?;
debug!("In room {}:", room.room_id());
let mut power_levels = power_level_state
.deserialize()
.context("could not deserialise power levels")?
.power_levels();
debug!("Original PLs: {power_levels:?}");
// Modify the power levels
power_levels.events_default = power_levels.events_default.max(new_min_power_level.into());
for event_power_level in power_levels.events.values_mut() {
*event_power_level = (*event_power_level).max(new_min_power_level.into());
}
debug!("New PLs: {power_levels:?}");
// Send the new event
room.send_state_event(RoomPowerLevelsEventContent::from(power_levels))
.await
.context("failed to send power levels")?;
Ok(())
}

119
src/space_tree.rs Normal file
View File

@ -0,0 +1,119 @@
use std::{collections::BTreeSet, future::Future, pin::Pin};
use eyre::{bail, Context, ContextCompat};
use matrix_sdk::{
config::SyncSettings,
ruma::{
events::space::child::SpaceChildEventContent, OwnedRoomAliasId, OwnedRoomId, RoomAliasId,
RoomId, RoomOrAliasId,
},
Client, Room,
};
use crate::initial_sync;
struct SpaceChild {
pub room_id: OwnedRoomId,
pub name: String,
pub aliases: BTreeSet<OwnedRoomAliasId>,
pub children: Vec<SpaceChild>,
}
pub async fn run(client: &Client, root_space: &RoomOrAliasId) -> eyre::Result<()> {
let room_id = if root_space.is_room_id() {
<&RoomId>::try_from(root_space).unwrap().to_owned()
} else {
let room_alias = <&RoomAliasId>::try_from(root_space).unwrap();
client
.resolve_room_alias(room_alias)
.await
.context("could not resolve alias of root space")?
.room_id
};
initial_sync(client).await?;
let summary = find_and_summarise_room(client, &room_id)
.await?
.context("did not find that room; are we in it?")?;
print_tree(&summary, 0);
Ok(())
}
fn find_and_summarise_room<'a>(
client: &'a Client,
room_id: &'a RoomId,
) -> Pin<Box<impl Future<Output = eyre::Result<Option<SpaceChild>>> + 'a>> {
Box::pin(async move {
for room in client.rooms() {
if room.room_id() == room_id {
return summarise_room(client, &room).await.map(Some);
}
}
Ok(None)
})
}
async fn summarise_room(client: &Client, room: &Room) -> eyre::Result<SpaceChild> {
let mut children = Vec::new();
let child_space_events = room
.get_state_events_static::<SpaceChildEventContent>()
.await
.with_context(|| format!("could not get state for room {}", room.room_id()))?;
for child_space_state_event in child_space_events {
let child = child_space_state_event.deserialize().with_context(|| {
format!("could not deserialise state event: {child_space_state_event:?}")
})?;
let child_room_id = child.state_key();
let summarise_room_opt = find_and_summarise_room(client, child_room_id)
.await
.with_context(|| format!("could not summarise {child_room_id:?}"))?;
if let Some(summary) = summarise_room_opt {
children.push(summary);
}
}
let mut aliases = BTreeSet::new();
if let Some(canon_alias) = room.canonical_alias() {
aliases.insert(canon_alias);
}
aliases.extend(room.alt_aliases());
Ok(SpaceChild {
room_id: room.room_id().to_owned(),
name: room.name().unwrap_or_default(),
aliases,
children,
})
}
fn print_tree(node: &SpaceChild, depth: u32) {
let SpaceChild {
room_id,
name,
aliases,
children,
} = &node;
let prefix = " ".repeat(depth as usize * 2);
print!("{prefix}- {name} `{room_id}`");
if !aliases.is_empty() {
print!(" = ");
for (idx, alias) in aliases.iter().enumerate() {
if idx > 0 {
print!(", ");
}
print!("{alias}");
}
}
println!();
for child in children {
print_tree(child, depth + 1);
}
}