Compare commits

...

28 commits

Author SHA1 Message Date
Ondřej Hruška 5389031d8c nobot fix 2022-11-02 09:28:40 +01:00
Ondřej Hruška e77c8157ae
cargo fmt, version bump 2021-11-02 23:49:00 +01:00
Ondřej Hruška cb6724baab
add server lowercasing also to group_config::acct_to_server() 2021-11-02 23:48:43 +01:00
Ondřej Hruška cbd3c0a575 Merge branch 'master' of fgaz/group-actor into master 2021-11-02 22:31:56 +00:00
Francesco Gazzetta e1ac2777f3 Normalize server from group actor too
as specified in tests
2021-11-02 11:36:45 +01:00
Francesco Gazzetta 900f499932 Fix minor mistakes in tests that made them fail 2021-11-02 11:36:45 +01:00
Ondřej Hruška ebcf12e46c
changelog 2021-10-12 10:38:46 +02:00
Ondřej Hruška 0d37425c32
fix hashtag not working in mention 2021-10-12 10:38:02 +02:00
Ondřej Hruška 7f14dbc215
fix bad version 2021-10-12 00:40:40 +02:00
Ondřej Hruška 5fb5f087d6
hashtag bug fix 2021-10-12 00:36:08 +02:00
Ondřej Hruška 6ff0e3653d
changelog, add json5 2021-10-12 00:20:42 +02:00
Ondřej Hruška 4ddc26c6ca
fixes in help cmd, make everything use markdown 2021-10-12 00:12:19 +02:00
Ondřej Hruška 63c4c5f2e8 add option to override locale messages per-group, update readme 2021-10-11 13:24:17 +02:00
Ondřej Hruška 305d91d1dc Merge branch 'trans' 2021-10-10 15:39:08 +02:00
Ondřej Hruška e76da157b3 fixes for locales, improvements, more logging, stub cs translation 2021-10-10 15:38:56 +02:00
Ondřej Hruška 239e15afdd add missing trans, untested! 2021-10-09 21:49:26 +02:00
Ondřej Hruška bd47a004bf
wip translation system 2021-10-06 01:09:20 +02:00
Ondřej Hruška 881411ebd3
add notif dedup to avoid duplicate announcement 2021-10-05 11:46:11 +02:00
Ondřej Hruška de3fd4e729
improvements, more config, add -q, readme 2021-10-05 10:39:10 +02:00
Ondřej Hruška 7ea6225ae9
group config files refactored to groups.d and subfolders, WIP common config file 2021-10-05 00:44:39 +02:00
Ondřej Hruška 3a4f0ef153 fix threading in chained responses 2021-09-19 15:09:08 +02:00
Ondřej Hruška 6c9041eedd Merge branch 'smart-split' 2021-09-19 14:52:37 +02:00
Ondřej Hruška f492e9c44a finish smart_split 2021-09-19 14:52:20 +02:00
Ondřej Hruška 748023c410 wip smart split to fit instance character limit 2021-09-18 21:55:16 +02:00
Ondřej Hruška e5ce0bdeb7 sleep after posting 2021-09-18 20:50:14 +02:00
Ondřej Hruška 6734917d9c minor fix 2021-09-18 19:47:19 +02:00
Ondřej Hruška b9dcf22016 better reconnect and missed status handling 2021-09-18 14:40:51 +02:00
Ondřej Hruška 5c34aa11b5
misskey fix 2021-09-07 21:24:15 +02:00
18 changed files with 2230 additions and 1076 deletions

3
.gitignore vendored
View file

@ -5,3 +5,6 @@ group-actor-data.toml
groups.json
fedigroups
*.bak
*.old
/groups
/groups.d

View file

@ -1,5 +1,36 @@
# Changelog
## v0.4.5
- Ignore #nobot in bio if the user is also a member
## v0.4.4
- Fix some failing tests
- Lowercase the domain when normalizing an account
## v0.4.3
- Fix hashtag not working in a mention
## v0.4.2
- Fix URL fragment detected as hashtag
## v0.4.1
- "en" translation fixes
- add `messages.json`
- Config files are now parsed as JSON5 = comments are allowed
## v0.4.0
- Add a translation system using the `locales` folder
- Add more trace logging
## v0.3.0
- Changed config/storage format to directory-based, removed shared config mutex
- Made more options configurable (timeouts, catch-up limits, etc)
- Changed default log level to Debug, added `-q` to reduce it (opposite of `-v`)
- Code cleaning
## v0.2.8
- fix error processing statuses when a misskey poll has infinite run time
## v0.2.7
- Fix some wrong responses to admin commands
- Remove automatic announcements from some admin commands

71
Cargo.lock generated
View file

@ -276,7 +276,7 @@ checksum = "ea57b42383d091c85abcc2706240b94ab2a8fa1fc81c10ff23c4de06e2a90b5e"
[[package]]
name = "elefren"
version = "0.22.0"
source = "git+https://git.ondrovo.com/MightyPork/elefren-fork.git?rev=7847df0#7847df0e2b2c0f6b9a089e2db2f9b10dfe070a45"
source = "git+https://git.ondrovo.com/MightyPork/elefren-fork.git?rev=b10e5935ae32f4756b19e9ca58b78a5382f865d1#b10e5935ae32f4756b19e9ca58b78a5382f865d1"
dependencies = [
"chrono",
"doc-comment",
@ -328,13 +328,14 @@ checksum = "e88a8acf291dafb59c2d96e8f59828f3838bb1a70398823ade51a84de6a6deed"
[[package]]
name = "fedigroups"
version = "0.2.7"
version = "0.4.4"
dependencies = [
"anyhow",
"clap",
"elefren",
"env_logger",
"futures 0.3.16",
"json5",
"log 0.4.14",
"native-tls",
"once_cell",
@ -764,6 +765,17 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "json5"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1"
dependencies = [
"pest",
"pest_derive",
"serde",
]
[[package]]
name = "kernel32-sys"
version = "0.2.2"
@ -829,6 +841,12 @@ dependencies = [
"cfg-if 1.0.0",
]
[[package]]
name = "maplit"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
[[package]]
name = "matches"
version = "0.1.8"
@ -1105,6 +1123,49 @@ version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e"
[[package]]
name = "pest"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10f4872ae94d7b90ae48754df22fd42ad52ce740b8f370b03da4835417403e53"
dependencies = [
"ucd-trie",
]
[[package]]
name = "pest_derive"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "833d1ae558dc601e9a60366421196a8d94bc0ac980476d0b67e1d0988d72b2d0"
dependencies = [
"pest",
"pest_generator",
]
[[package]]
name = "pest_generator"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "99b8db626e31e5b81787b9783425769681b347011cc59471e33ea46d2ea0cf55"
dependencies = [
"pest",
"pest_meta",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "pest_meta"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54be6e404f5317079812fc8f9f5279de376d8856929e21c184ecf6bbd692a11d"
dependencies = [
"maplit",
"pest",
"sha-1 0.8.2",
]
[[package]]
name = "phf"
version = "0.7.24"
@ -2110,6 +2171,12 @@ version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f6906492a7cd215bfa4cf595b600146ccfac0c79bcbd1f3000162af5e8b06"
[[package]]
name = "ucd-trie"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56dee185309b50d1f11bfedef0fe6d036842e3fb77413abef29f8f8d1c5d4c1c"
[[package]]
name = "unicase"
version = "1.4.2"

View file

@ -1,6 +1,6 @@
[package]
name = "fedigroups"
version = "0.2.7"
version = "0.4.5"
authors = ["Ondřej Hruška <ondra@ondrovo.com>"]
edition = "2018"
publish = false
@ -10,7 +10,7 @@ build = "build.rs"
[dependencies]
#elefren = { path = "../elefren22-fork" }
elefren = { git = "https://git.ondrovo.com/MightyPork/elefren-fork.git", rev = "7847df0" }
elefren = { git = "https://git.ondrovo.com/MightyPork/elefren-fork.git", rev = "b10e5935ae32f4756b19e9ca58b78a5382f865d1" }
env_logger = "0.9.0"
@ -26,6 +26,7 @@ futures = "0.3"
voca_rs = "1.13.0"
regex = "1.5.4"
once_cell = "1.8.0"
json5 = "0.4.1"
native-tls = "0.2.8"
websocket = "0.26.2"

205
README.md
View file

@ -36,7 +36,7 @@ You can also run the program using Cargo, that is handy for development: `cargo
3. **Make sure you auth as the correct user!**
4. Paste the Oauth2 token you got into the terminal, hit enter.
The program now ends. The credentials are saved in a file `groups.json`.
The program now ends. The credentials are saved in the directory `groups/account@server/`, which is created if missing.
You can repeat this for any number of groups.
@ -44,55 +44,185 @@ In case you need to re-authenticate an existing group, do the same but use `-A`
### Editing config
**Do not edit the config while the group service is running, it will overwrite your changes!**
**Staring v0.4.1, the config files support comments!**
The JSON file is easily editable, you can e.g. add yourself as an admin (use the e-mail format, e.g. `piggo@piggo.space`).
The file format is quite self-explanatory.
A typical setup could look like this:
```
├── groups
│ ├── betty@piggo.space
│ │ ├── config.json
│ │ ├── control.json
│ │ └── state.json
│ └── chatterbox@botsin.space
│ ├── config.json ... fixed config edited manually
│ ├── messages.json ... custom locale overrides (optional)
│ ├── control.json ... mutable config updated by the group service
│ └── state.json ... group state data
├── locales
│ ├── ... custom locale files, same format like en.json
│ └── ru.json
└── groups.json
```
#### Locales
English locale ("en") is bundled in the binary. Additional locales can be placed in the `locales` folder.
If an entry is missing, the English version will be used.
The locale file looks like this (excerpt):
```json
{
"groups": {
"group@myserver.xyz": {
"enabled": true,
"acct": "group@myserver.xyz",
"appdata": {
"base": "https://myserver.xyz",
"client_id": "...",
"client_secret": "...",
"redirect": "urn:ietf:wg:oauth:2.0:oob",
"token": "..."
},
"group_tags": [
"grouptest"
],
"admin_users": [
"admin@myserver.xyz"
],
"member_only": false,
"member_users": [],
"banned_users": [],
"banned_servers": [
"bad-stuff-here.cc"
],
"last_notif_ts": 1630011219000,
"last_status_ts": 1630011362000
}
"group_announcement": "**📢Group announcement**\n{message}",
"ping_response": "pong, this is fedigroups service v{version}"
}
```
- All messages can use markdown formatting.
- Words in curly braces (`{}`) are substitution tokens. These must be preserved in all translations.
- Pay attention to line endings and blank lines (`\n`). Some messages from the locale file are combined to form the
final post, leaving out newlines can result in a mangled output.
The locale to use is chosen in each group's `config.json`, "en" by default (if not specified).
Group-specific overrides are also possible: create a file `messages.json` in the group folder
and define messages you wish to change, e.g. the greeting or announcement templates.
#### Common config
There is one shared config file: `groups.json`
- If the file does not exist, default settings are used. This is usually sufficient.
- This file applies to all groups and serves as the default config.
```
{
// name of the directory with groups
"groups_dir": "groups",
// name of the directory with locales
"locales_dir": "locales",
// Show warning if locales are missing keys
"validate_locales": true,
// Max number of missed notifs to process after connect
"max_catchup_notifs": 30,
// Max number of missed statuses to process after connect
"max_catchup_statuses": 50,
// Delay between fetched pages when catching up
"delay_fetch_page_s": 0.25,
// Delay after sending a status, making a follow or some other action.
// Set if there are Throttled errors and you need to slow the service down.
"delay_after_post_s": 0.0,
// Delay before trying to re-connect after the server closed the socket
"delay_reopen_closed_s": 0.5,
// Delay before trying to re-connect after an error
"delay_reopen_error_s": 5.0,
// Timeout for a notification/timeline socket to be considered alive.
// If nothing arrives in this interval, reopen it. Some servers have a buggy socket
// implementation where it stays open but no longer works.
"socket_alive_timeout_s": 30.0,
// Time after which a socket is always closed, even if seemingly alive.
// This is a work-around for servers that stop sending notifs after a while.
"socket_retire_time_s": 120.0
}
```
#### Per-group config
Each group is stored as a sub-directory of `groups/`. The sub-directories are normally named after their accounts,
but this is not required. For example, `groups/betty@piggo.space/`.
The group's config and state is split into three files in a way that minimizes the risk of data loss.
Only the `config.json` file with credentials is required; the others will be created as needed by the group daemon.
- `config.json` - immutable config, never changed beside when you run the `-A` command to reauth a group.
This is where the account name, the auth token and the `enabled` flag are stored.
- `control.json` - settings and state that change at runtime or can be set using slash commands.
This file is overwritten by the group service when needed.
- `state.json` - frequently changing state data. The last-seen status/notification timestamps are kept here.
State is split from Control to limit the write frequency of the control file. Timestamps can be updated multiple times
per minute.
- `messages.json` - optional per-group locale overrides
**Do not edit the control and state files while the group service is running, it may overwrite your changes!**
The JSON files are easily editable, you can e.g. add yourself as an admin (use the e-mail format, e.g. `piggo@piggo.space`).
Note that changing config externally requires a restart. It's better to use slash commands and update the config at run-time.
When adding hashtags, *they must be entered as lowercase* and without the `#` symbol!
The file formats are quite self-explanatory:
**config.json**
```json
{
// Enable or disable the group service
"enabled": true,
// Group locale (optional, defaults to "en")
"locale": "en",
// Group account name
"acct": "group@myserver.xyz",
// Saved mastodon API credentials, this is created when authenticating the group.
"appdata": {
"base": "https://myserver.xyz",
"client_id": "...",
"client_secret": "...",
"redirect": "urn:ietf:wg:oauth:2.0:oob",
"token": "..."
}
}
```
- `group_tags` - group hashtags (without the `#`). The group reblogs anything with these hashtags if the author is a member.
- `member_users` - group members, used to track whose hashtags should be reblogged; in member-only groups, this is also a user whitelist.
- `banned_users` - can't post or interact with the group service
- `banned_servers` - work like an instance block
**control.json**
```json
{
// List of group hashtags, lowercase.
// The group reblogs anything with these hashtags if the author is a member.
"group_tags": [
"grouptest"
],
// List of admin users (e-mail format)
"admin_users": [
"admin@myserver.xyz"
],
// Restrict write access to manually added members
"member_only": false,
// List of member users (e-mail format)
"member_users": [],
// List of banned users (e-mail format), their posts and actions are ignored by the group
"banned_users": [],
// List of banned servers, users from there can't interact with the group and their posts can't be shared.
"banned_servers": [
"bad-stuff-here.cc"
]
}
```
**state.json**
Internal use, millisecond timestamps of the last-seen status and notification.
```json
{
"last_notif_ts": 1630011219000,
"last_status_ts": 1630011362000
}
```
### Running
To run the group service, simply run it with no arguments. It will read what to do from `groups.json`.
To run the group service, simply run it with no arguments.
It will read the `groups.json` file (if present), find groups in `groups/` and start the services for you.
Note that the file must be writable, it is updated at run-time.
Note that the control and status files must be writable, they are updated at run-time.
Config files can have limited permissions to avoid accidental overwrite.
An example systemd service file is included in the repository as well. Make sure to set up the system user/group and file permissions according to your needs. You can use targets in the included `Makefile` to manage the systemd service and look at logs.
An example systemd service file is included in the repository as well.
Make sure to set up the system user/group and file permissions according to your needs.
You can use targets in the included `Makefile` to manage the systemd service and look at logs.
## Group usage
@ -116,7 +246,6 @@ These won't be shared:
- `ducks suck`
- `@group #ducks /i` (anything with the "ignore" command is ignored)
- `@group /remove #ducks` (admin command, even if it includes a group hashtag)
- `@otheruser tell me about ducks` (in a thread)
- `@otheruser @group tell me about ducks` (in a thread)
### Commands

3
locales/cs.json Normal file
View file

@ -0,0 +1,3 @@
{
"ping_response": "pong, toto je fedigroups verze {version}"
}

55
locales/en.json Normal file
View file

@ -0,0 +1,55 @@
{
"welcome_public": "Welcome to the group! The group user will now follow you back to complete the sign-up.\nTo share a post, @ the group user or use a group hashtag.\n\nUse /help for more info.",
"welcome_member_only": "Welcome to the group! This group has posting restricted to members.\nIf you'd like to join, please ask one of the group admins:\n{admins}",
"welcome_join_cmd": "Welcome to the group! The group user will now follow you to complete the sign-up. Make sure you follow back to receive shared posts!\n\nUse /help for more info.",
"welcome_closed": "Sorry, this group is closed to new sign-ups.\nPlease ask one of the group admins to add you:\n",
"user_list_entry": "- {user}\n",
"user_list_entry_admin": "- {user} [admin]\n",
"help_group_info_closed": "This is a member-only group. {membership}\n",
"help_group_info_open": "This is a public-access group. {membership}\n",
"help_membership_admin": "*You are an admin.*",
"help_membership_member": "*You are a member.*",
"help_membership_guest_closed": "*You are not a member, ask one of the admins to add you.*",
"help_membership_guest_open": "*You are not a member, follow or use /join to join the group.*",
"help_admin_commands": "\n\n**Admin commands:**\n`/ping` - check the group works\n`/members` - show group members / admins\n`/add user` - add a member (user@domain)\n`/remove user` - remove a member\n`/add #hashtag` - add a group hashtag\n`/remove #hashtag` - remove a group hashtag\n`/undo` - un-boost a replied-to post, delete an announcement\n`/ban x` - ban a user or server\n`/unban x` - lift a ban\n`/admin user` - grant admin rights\n`/deadmin user` - revoke admin rights\n`/closegroup` - make member-only\n`/opengroup` - make public-access\n`/announce x` - make a public announcement",
"help_basic_commands": "To share a post, @ the group user or use a group hashtag.\n\n**Supported commands:**\n`/boost`, `/b` - boost the replied-to post into the group\n`/ignore`, `/i` - make the group ignore the post\n`/tags` - show group hashtags\n`/join` - (re-)join the group\n`/leave` - leave the group\n`/optout` - forbid sharing of your posts",
"help_member_commands": "\n`/admins` - show group admins\n`/undo` - un-boost your post (use in a reply)",
"cmd_leave_ok": "You're no longer a group member. Unfollow the group user to stop receiving group messages.",
"member_list_heading": "Group members:\n",
"admin_list_heading": "Group admins:\n",
"tag_list_heading": "Group tags:\n",
"tag_list_entry": "- {tag}\n",
"cmd_error": "Command failed: {cause}",
"cmd_close_ok": "Group changed to member-only",
"cmd_close_fail_already": "No action, group is member-only already",
"cmd_open_ok": "Group changed to open-access",
"cmd_open_fail_already": "No action, group is open-access already",
"cmd_optout_fail_admin_cant": "Group admins can't opt-out.",
"cmd_optout_fail_member_cant": "Group members can't opt-out. You have to leave first.",
"cmd_optout_ok": "Your posts will no longer be shared to the group.",
"cmd_optin_fail_admin_cant": "Opt-in has no effect for admins.",
"cmd_optin_fail_member_cant": "Opt-in has no effect for members.",
"cmd_optin_ok": "Your posts can now be shared to the group.",
"cmd_ban_user_ok": "User {user} banned from group!",
"cmd_ban_user_fail_already": "No action, user {user} is already banned",
"cmd_unban_user_ok": "User {user} un-banned!",
"cmd_unban_user_fail_already": "No action, user {user} is not banned",
"cmd_ban_server_ok": "Server {server} banned from group!",
"cmd_ban_server_fail_already": "No action, server {server} already banned",
"cmd_unban_server_ok": "Server {server} un-banned!",
"cmd_unban_server_fail_already": "No action, server {server} is not banned",
"cmd_add_user_ok": "User {user} added to the group!",
"cmd_remove_user_ok": "User {user} removed from the group.",
"cmd_add_tag_ok": "Tag #{tag} added to the group!",
"cmd_add_tag_fail_already": "No action, #{tag} is already a group tag",
"cmd_remove_tag_ok": "Tag #{tag} removed from the group!",
"cmd_remove_tag_fail_already": "No action, #{tag} is not a group tag",
"cmd_admin_ok": "User {user} is now a group admin!",
"cmd_admin_fail_already": "No action, user {user} is a group admin already",
"cmd_unadmin_ok": "User {user} is no longer a group admin!",
"cmd_unadmin_fail_already": "No action, user {user} is not a group admin",
"mention_prefix": "@{user} ",
"group_announcement": "**📢Group announcement**\n{message}",
"ping_response": "pong, this is fedigroups service v{version}"
}

View file

@ -1,6 +1,6 @@
use crate::utils;
use once_cell::sync::Lazy;
use regex::Regex;
use crate::utils;
#[derive(Debug, Clone, PartialEq)]
pub enum StatusCommand {
@ -101,7 +101,8 @@ static RE_UNBAN_SERVER: once_cell::sync::Lazy<Regex> = Lazy::new(|| command!(r"u
static RE_ADD_MEMBER: once_cell::sync::Lazy<Regex> = Lazy::new(|| command!(r"(?:add|follow)\s+", p_user!()));
static RE_REMOVE_MEMBER: once_cell::sync::Lazy<Regex> = Lazy::new(|| command!(r"(?:kick|unfollow|remove)\s+", p_user!()));
static RE_REMOVE_MEMBER: once_cell::sync::Lazy<Regex> =
Lazy::new(|| command!(r"(?:kick|unfollow|remove)\s+", p_user!()));
static RE_ADD_TAG: once_cell::sync::Lazy<Regex> = Lazy::new(|| command!(r"(?:add|follow)\s+", p_hashtag!()));
@ -134,14 +135,13 @@ static RE_PING: once_cell::sync::Lazy<Regex> = Lazy::new(|| command!(r"ping"));
static RE_ANNOUNCE: once_cell::sync::Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?:^|\s|>|\n)[\\/]announce\s+(.*)$").unwrap());
static RE_A_HASHTAG: once_cell::sync::Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?:^|\b|\s|>|\n)#(\w+)").unwrap());
static RE_A_HASHTAG: once_cell::sync::Lazy<Regex> = Lazy::new(|| Regex::new(r"(?:^|\s|>|\n)#(\w+)").unwrap());
pub static RE_NOBOT_TAG: once_cell::sync::Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?:^|\b|\s|>|\n)#nobot(?:\b|$)").unwrap());
Lazy::new(|| Regex::new(r"(?:^|\s|>|\n)#nobot(?:\b|$)").unwrap());
pub static RE_HASHTAG_TRIGGERING_PLEROMA_BUG: once_cell::sync::Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?:^|\b|\s|>|\n)#\w+[^\s]*$").unwrap());
Lazy::new(|| Regex::new(r"(?:^|\s|>|\n)#\w+[^\s]*$").unwrap());
pub fn parse_status_tags(content: &str) -> Vec<String> {
debug!("Raw content: {}", content);
@ -336,11 +336,15 @@ pub fn parse_slash_commands(content: &str) -> Vec<StatusCommand> {
#[cfg(test)]
mod test {
use crate::command::{parse_slash_commands, RE_A_HASHTAG, RE_HASHTAG_TRIGGERING_PLEROMA_BUG, RE_NOBOT_TAG, RE_ADD_TAG, RE_JOIN, StatusCommand};
use crate::command::{
parse_slash_commands, StatusCommand, RE_ADD_TAG, RE_A_HASHTAG, RE_HASHTAG_TRIGGERING_PLEROMA_BUG, RE_JOIN,
RE_NOBOT_TAG,
};
use super::{
RE_ADD_MEMBER, RE_ANNOUNCE, RE_BAN_SERVER, RE_BAN_USER, RE_BOOST, RE_CLOSE_GROUP, RE_GRANT_ADMIN, RE_HELP,
RE_IGNORE, RE_LEAVE, RE_OPTOUT, RE_OPTIN, RE_MEMBERS, RE_OPEN_GROUP, RE_REMOVE_MEMBER, RE_REVOKE_ADMIN, RE_TAGS, RE_UNDO,
RE_IGNORE, RE_LEAVE, RE_MEMBERS, RE_OPEN_GROUP, RE_OPTIN, RE_OPTOUT, RE_REMOVE_MEMBER, RE_REVOKE_ADMIN,
RE_TAGS, RE_UNDO,
};
#[test]
@ -556,7 +560,11 @@ mod test {
assert!(RE_A_HASHTAG.is_match("#banana"));
assert!(RE_A_HASHTAG.is_match("#ласточка"));
assert!(RE_A_HASHTAG.is_match("#χαλβάς"));
assert!(RE_A_HASHTAG.is_match("#городДляЛюдей"));
assert!(RE_A_HASHTAG.is_match("foo #banana gfdfgd"));
assert!(RE_A_HASHTAG.is_match("foo #городДляЛюдей aaa"));
assert!(!RE_A_HASHTAG.is_match("foo https://google.com/#banana gfdfgd"));
assert!(!RE_A_HASHTAG.is_match("foo https://google.com/?foo#banana gfdfgd"));
for (i, c) in RE_A_HASHTAG.captures_iter("foo #banana #χαλβάς #ласточка").enumerate() {
if i == 0 {
@ -584,7 +592,7 @@ mod test {
assert!(!RE_NOBOT_TAG.is_match("banana #tag sdfsd"));
assert!(!RE_NOBOT_TAG.is_match("banana #nobotanicals sdfsd"));
assert!(RE_NOBOT_TAG.is_match("#nobot"));
assert!(RE_NOBOT_TAG.is_match("aaa#nobot"));
assert!(RE_NOBOT_TAG.is_match("aaa\n#nobot"));
assert!(RE_NOBOT_TAG.is_match("aaa #nobot"));
assert!(RE_NOBOT_TAG.is_match("#nobot xxx"));
assert!(RE_NOBOT_TAG.is_match("#nobot\nxxx"));

View file

@ -8,10 +8,10 @@ pub enum GroupError {
UserIsBanned,
#[error("User opted out from the group")]
UserOptedOut,
#[error("User opted out from the group using #nobot")]
UserOptedOutNobot,
#[error("Server could not be banned because there are admin users on it")]
AdminsOnServer,
#[error("Group config is missing in the config store")]
GroupNotExist,
#[error("Config error: {0}")]
BadConfig(Cow<'static, str>),
#[error("API request timed out")]
@ -21,6 +21,8 @@ pub enum GroupError {
#[error(transparent)]
Serializer(#[from] serde_json::Error),
#[error(transparent)]
Serializer5(#[from] json5::Error),
#[error(transparent)]
Elefren(#[from] elefren::Error),
}
@ -32,7 +34,6 @@ impl PartialEq for GroupError {
(Self::UserIsAdmin, Self::UserIsAdmin)
| (Self::UserIsBanned, Self::UserIsBanned)
| (Self::AdminsOnServer, Self::AdminsOnServer)
| (Self::GroupNotExist, Self::GroupNotExist)
| (Self::BadConfig(_), Self::BadConfig(_))
)
}

File diff suppressed because it is too large Load diff

View file

@ -1,73 +1,117 @@
use std::collections::VecDeque;
use std::sync::Arc;
use std::time::{Duration, Instant};
use elefren::{FediClient, StatusBuilder};
use elefren::debug::EventDisplay;
use elefren::debug::NotificationDisplay;
use elefren::debug::StatusDisplay;
use elefren::entities::account::Account;
use elefren::entities::event::Event;
use elefren::entities::notification::{Notification, NotificationType};
use elefren::entities::status::Status;
use elefren::status_builder::Visibility;
use elefren::{FediClient, StatusBuilder};
use futures::StreamExt;
use handle_mention::ProcessMention;
use crate::error::GroupError;
use crate::store::ConfigStore;
use crate::store::data::GroupConfig;
use crate::utils::{LogError, normalize_acct, VisExt};
use crate::command::StatusCommand;
use elefren::entities::account::Account;
use crate::error::GroupError;
use crate::store::CommonConfig;
use crate::store::GroupConfig;
use crate::tr::TranslationTable;
use crate::utils::{normalize_acct, LogError, VisExt};
mod handle_mention;
/// This is one group's config store capable of persistence
#[derive(Debug)]
pub struct GroupHandle {
pub(crate) group_account: Account,
pub(crate) client: FediClient,
pub(crate) config: GroupConfig,
pub(crate) store: Arc<ConfigStore>,
pub group_account: Account,
pub client: FediClient,
pub config: GroupConfig,
pub cc: Arc<CommonConfig>,
pub internal: GroupInternal,
}
// const DELAY_BEFORE_ACTION: Duration = Duration::from_millis(250);
const DELAY_REOPEN_STREAM: Duration = Duration::from_millis(500);
const MAX_CATCHUP_NOTIFS: usize = 25;
// also statuses
const MAX_CATCHUP_STATUSES: usize = 50;
// higher because we can expect a lot of non-hashtag statuses here
const PERIODIC_SAVE: Duration = Duration::from_secs(60);
const SOCKET_ALIVE_TIMEOUT: Duration = Duration::from_secs(30);
const SOCKET_RETIRE_TIME: Duration = Duration::from_secs(120);
const PING_INTERVAL: Duration = Duration::from_secs(15); // must be < periodic save!
#[derive(Debug)]
pub struct GroupInternal {
recently_seen_notif_statuses: VecDeque<String>,
}
impl Default for GroupInternal {
fn default() -> Self {
Self {
recently_seen_notif_statuses: VecDeque::new(),
}
}
}
#[macro_export]
macro_rules! grp_debug {
($self:ident, $f:expr) => {
::log::debug!(concat!("(@{}) ", $f), $self.config.get_acct());
};
($self:ident, $f:expr, $($arg:tt)+) => {
::log::debug!(concat!("(@{}) ", $f), $self.config.get_acct(), $($arg)+);
};
}
#[macro_export]
macro_rules! grp_info {
($self:ident, $f:expr) => {
::log::info!(concat!("(@{}) ", $f), $self.config.get_acct());
};
($self:ident, $f:expr, $($arg:tt)+) => {
::log::info!(concat!("(@{}) ", $f), $self.config.get_acct(), $($arg)+);
};
}
#[macro_export]
macro_rules! grp_trace {
($self:ident, $f:expr) => {
::log::trace!(concat!("(@{}) ", $f), $self.config.get_acct());
};
($self:ident, $f:expr, $($arg:tt)+) => {
::log::trace!(concat!("(@{}) ", $f), $self.config.get_acct(), $($arg)+);
};
}
#[macro_export]
macro_rules! grp_warn {
($self:ident, $f:expr) => {
::log::warn!(concat!("(@{}) ", $f), $self.config.get_acct());
};
($self:ident, $f:expr, $($arg:tt)+) => {
::log::warn!(concat!("(@{}) ", $f), $self.config.get_acct(), $($arg)+);
};
}
#[macro_export]
macro_rules! grp_error {
($self:ident, $f:expr) => {
::log::error!(concat!("(@{}) ", $f), $self.config.get_acct());
};
($self:ident, $f:expr, $($arg:tt)+) => {
::log::error!(concat!("(@{}) ", $f), $self.config.get_acct(), $($arg)+);
};
}
impl GroupHandle {
#[allow(unused)]
pub async fn save(&mut self) -> Result<(), GroupError> {
debug!("Saving group config & status");
self.store.set_group_config(self.config.clone()).await?;
trace!("Saved");
self.config.clear_dirty_status();
grp_debug!(self, "Saving group state unconditionally");
self.config.save(false).await?;
Ok(())
}
pub async fn save_if_needed(&mut self) -> Result<(), GroupError> {
if self.config.is_dirty() {
self.save().await?;
grp_debug!(self, "Saving group state due to changes");
self.config.save_if_needed(false).await?;
}
Ok(())
}
/*
pub async fn reload(&mut self) -> Result<(), GroupError> {
if let Some(g) = self.store.get_group_config(self.config.get_acct()).await {
self.config = g;
Ok(())
} else {
Err(GroupError::GroupNotExist)
}
}
*/
}
trait NotifTimestamp {
@ -90,75 +134,91 @@ impl NotifTimestamp for Status {
impl GroupHandle {
pub async fn run(&mut self) -> Result<(), GroupError> {
assert!(PERIODIC_SAVE >= PING_INTERVAL);
loop {
debug!("Opening streaming API socket");
let mut next_save = Instant::now() + PERIODIC_SAVE; // so we save at start
let mut events = self.client.streaming_user().await?;
match self.run_internal().await {
Ok(()) => unreachable!(),
Err(e @ GroupError::BadConfig(_)) => {
grp_error!(self, "ERROR in group handler, aborting! {}", e);
return Err(e);
}
Err(other) => {
grp_error!(self, "ERROR in group handler, will restart! {}", other);
tokio::time::sleep(Duration::from_secs_f64(self.cc.delay_reopen_error_s)).await;
}
}
}
}
pub async fn run_internal(&mut self) -> Result<(), GroupError> {
loop {
grp_debug!(self, "Opening streaming API socket");
// wrapped in a timeout, this seems like the only place the group could hang
// (https://git.ondrovo.com/MightyPork/group-actor/issues/8)
let mut events = match tokio::time::timeout(Duration::from_secs(3), self.client.streaming_user()).await {
Ok(Ok(events)) => events,
Ok(Err(e)) => return Err(e.into()),
Err(_) => {
return Err(GroupError::ApiTimeout);
}
};
let socket_open_time = Instant::now();
let mut last_rx = Instant::now();
// let mut last_ping = Instant::now();
match self.catch_up_with_missed_notifications().await {
Ok(true) => {
debug!("Some missed notifs handled");
}
Ok(false) => {
debug!("No notifs missed");
}
Err(e) => {
error!("Failed to handle missed notifs: {}", e);
if self.cc.max_catchup_notifs > 0 {
match self.catch_up_with_missed_notifications().await {
Ok(true) => {
grp_debug!(self, "Some missed notifs handled");
}
Ok(false) => {
grp_debug!(self, "No notifs missed");
}
Err(e) => {
grp_error!(self, "Failed to handle missed notifs: {}", e);
}
}
}
match self.catch_up_with_missed_statuses().await {
Ok(true) => {
debug!("Some missed statuses handled");
}
Ok(false) => {
debug!("No statuses missed");
}
Err(e) => {
error!("Failed to handle missed statuses: {}", e);
if self.cc.max_catchup_statuses > 0 {
match self.catch_up_with_missed_statuses().await {
Ok(true) => {
grp_debug!(self, "Some missed statuses handled");
}
Ok(false) => {
grp_debug!(self, "No statuses missed");
}
Err(e) => {
grp_error!(self, "Failed to handle missed statuses: {}", e);
}
}
}
if self.config.is_dirty() {
// save asap
next_save = Instant::now() - PERIODIC_SAVE
}
self.save_if_needed().await.log_error("Failed to save");
'rx: loop {
if next_save < Instant::now() {
trace!("Save time elapsed, saving if needed");
self.save_if_needed().await.log_error("Failed to save group");
next_save = Instant::now() + PERIODIC_SAVE;
}
let remains_to_idle_close =
Duration::from_secs_f64(self.cc.socket_alive_timeout_s).saturating_sub(last_rx.elapsed());
let remains_to_idle_close = SOCKET_ALIVE_TIMEOUT.saturating_sub(last_rx.elapsed());
let remains_to_retire = SOCKET_RETIRE_TIME.saturating_sub(socket_open_time.elapsed());
let remains_to_retire =
Duration::from_secs_f64(self.cc.socket_retire_time_s).saturating_sub(socket_open_time.elapsed());
if remains_to_idle_close.is_zero() {
warn!("Socket idle too long, close");
grp_warn!(self, "Socket idle too long, close");
break 'rx;
}
if remains_to_retire.is_zero() {
debug!("Socket open too long, closing");
grp_debug!(self, "Socket open too long, closing");
break 'rx;
}
trace!("Waiting for message");
let timeout = next_save
.saturating_duration_since(Instant::now())
.min(remains_to_idle_close)
.min(remains_to_retire)
.max(Duration::from_secs(1)); // at least 1s
let timeout = remains_to_idle_close.min(remains_to_retire).max(Duration::from_secs(1)); // at least 1s
grp_debug!(self, "Wait for message {:?}", timeout);
match tokio::time::timeout(timeout, events.next()).await {
Ok(Some(event)) => {
last_rx = Instant::now();
debug!("(@{}) Event: {}", self.config.get_acct(), EventDisplay(&event));
grp_debug!(self, "(@{}) Event: {}", self.config.get_acct(), EventDisplay(&event));
match event {
Event::Update(status) => {
self.handle_status(status).await.log_error("Error handling a status");
@ -170,37 +230,33 @@ impl GroupHandle {
Event::FiltersChanged => {}
Event::Heartbeat => {}
}
self.save_if_needed().await.log_error("Failed to save");
}
Ok(None) => {
warn!("Group @{} socket closed, restarting...", self.config.get_acct());
grp_warn!(self, "Group @{} socket closed, restarting...", self.config.get_acct());
break 'rx;
}
Err(_) => {
// Timeout so we can save if needed
}
}
/* ping is nice, but pleroma still sometimes doesnt send
notifs after a while, just let it expire */
// if last_ping.elapsed() > PING_INTERVAL {
// last_ping = Instant::now();
// trace!("Pinging");
// if events.send_ping()
// .await.is_err() {
// break 'rx;
// }
// }
}
warn!("Notif stream closed, will reopen");
tokio::time::sleep(DELAY_REOPEN_STREAM).await;
grp_warn!(self, "Notif stream closed, will reopen");
tokio::time::sleep(Duration::from_secs_f64(self.cc.delay_reopen_closed_s)).await;
}
}
async fn handle_notification(&mut self, n: Notification) -> Result<(), GroupError> {
debug!("Handling notif #{}", n.id);
grp_debug!(self, "Handling notif #{}", n.id);
grp_trace!(self, "{:?}", n);
let ts = n.timestamp_millis();
if ts < self.config.get_last_notif() {
grp_debug!(self, "Notif is too old, discard");
return Ok(());
}
self.config.set_last_notif(ts);
let group_acct = self.config.get_acct().to_string();
@ -208,33 +264,34 @@ impl GroupHandle {
let notif_acct = normalize_acct(&n.account.acct, &group_acct)?;
if notif_acct == group_acct {
debug!("This is our post, ignore that");
grp_debug!(self, "This is our post, ignore that");
return Ok(());
}
if self.config.is_banned(&notif_acct) {
warn!("Notification actor {} is banned!", notif_acct);
grp_warn!(self, "Notification actor {} is banned!", notif_acct);
return Ok(());
}
match n.notification_type {
NotificationType::Mention => {
if let Some(status) = n.status {
self.handle_mention_status(status).await?;
if self.internal.recently_seen_notif_statuses.contains(&status.id) {
grp_debug!(self, "Already saw this notif, discard");
} else {
self.internal.recently_seen_notif_statuses.push_front(status.id.clone());
while self.internal.recently_seen_notif_statuses.len() > 64 {
let _ = self.internal.recently_seen_notif_statuses.pop_back();
}
self.handle_mention_status(status).await?;
}
}
}
NotificationType::Follow => {
info!("New follower!");
grp_info!(self, "New follower!");
// Just greet the user always
self.handle_new_follow(&notif_acct, notif_user_id).await;
// if self.config.is_member_or_admin(&notif_acct) {
// // Already joined, just doing something silly, ignore this
// debug!("User already a member, ignoring");
// } else {
//
// }
}
NotificationType::Favourite => {}
NotificationType::Reblog => {}
@ -246,68 +303,100 @@ impl GroupHandle {
/// Handle a non-mention status for tags
async fn handle_status(&mut self, s: Status) -> Result<(), GroupError> {
debug!("Handling status #{}", s.id);
grp_debug!(self, "Handling status #{}", s.id);
grp_trace!(self, "{:?}", s);
let ts = s.timestamp_millis();
self.config.set_last_status(ts);
// Short circuit checks
if s.visibility.is_private() {
debug!("Status is direct/private, discard");
return Ok(());
}
if !s.content.contains('#') {
debug!("No tags in status, discard");
return Ok(());
}
let private = s.visibility.is_private();
let has_hashtags = s.content.contains('#');
let group_user = self.config.get_acct();
let status_user = normalize_acct(&s.account.acct, group_user)?;
let member_or_admin = self.config.is_member_or_admin(&status_user);
let commands = crate::command::parse_slash_commands(&s.content);
if status_user == group_user {
debug!("This is our post, discard");
grp_debug!(self, "This is our post, discard");
return Ok(());
}
if self.config.is_banned(&status_user) {
debug!("Status author @{} is banned, discard", status_user);
grp_debug!(self, "Status author @{} is banned, discard", status_user);
return Ok(());
}
// optout does not work for members and admins, so don't check it
if !self.config.is_member_or_admin(&status_user) {
debug!("Status author @{} is not a member, discard", status_user);
if self.config.is_optout(&status_user) && !member_or_admin {
grp_debug!(self, "Status author @{} opted out, discard", status_user);
return Ok(());
}
let commands = crate::command::parse_slash_commands(&s.content);
if commands.contains(&StatusCommand::Ignore) {
debug!("Post has IGNORE command, discard");
grp_debug!(self, "Post has IGNORE command, discard");
return Ok(());
}
for m in s.mentions {
// Sometimes notifications don't work, but we see the mentions as statuses
for m in &s.mentions {
let mentioned_user = normalize_acct(&m.acct, group_user)?;
if mentioned_user == group_user {
let notif_time = self.config.get_last_notif();
if notif_time <= ts {
grp_debug!(
self,
"mentioned but status is older than last notif, can't be a valid notif, discard"
);
return Ok(());
}
if !commands.is_empty() {
debug!("Detected commands for this group, tags dont apply; discard");
grp_debug!(self, "Detected commands for this group, handle as notif");
return self
.handle_notification(Notification {
id: s.id.clone(), // ???
notification_type: NotificationType::Mention,
created_at: s.created_at,
account: s.account.clone(),
status: Some(s),
})
.await;
} else if private {
grp_debug!(self, "mention in private without commands, discard, this is nothing");
return Ok(());
}
}
}
// optout does not work for members and admins, so don't check it
if !member_or_admin {
grp_debug!(self, "Status author @{} is not a member, discard", status_user);
return Ok(());
}
if private {
grp_debug!(self, "Status is private, discard");
return Ok(());
}
if !has_hashtags {
grp_debug!(self, "No hashtags, discard");
return Ok(());
}
let tags = crate::command::parse_status_tags(&s.content);
debug!("Tags in status: {:?}", tags);
grp_debug!(self, "Tags in status: {:?}", tags);
'tags: for t in tags {
if self.config.is_tag_followed(&t) {
info!("REBLOG #{} STATUS", t);
self.client.reblog(&s.id).await
.log_error("Failed to reblog");
grp_info!(self, "REBLOG #{} STATUS", t);
self.client.reblog(&s.id).await.log_error("Failed to reblog");
self.delay_after_post().await;
break 'tags; // do not reblog multiple times!
} else {
debug!("#{} is not a group tag", t);
grp_debug!(self, "#{} is not a group tag", t);
}
}
@ -316,6 +405,7 @@ impl GroupHandle {
async fn follow_user(&mut self, id: &str) -> Result<(), GroupError> {
self.client.follow(id).await?;
self.delay_after_post().await;
Ok(())
}
@ -331,22 +421,28 @@ impl GroupHandle {
// They are retrieved newest first, but we want oldest first for chronological handling
let mut num = 0;
let mut old_pn = 0;
while let Some(n) = iter.next_item().await {
let ts = n.timestamp_millis();
if ts <= last_notif {
break; // reached our last seen notif
}
debug!("Inspecting notif {}", NotificationDisplay(&n));
grp_debug!(self, "Inspecting notif {}", NotificationDisplay(&n));
grp_trace!(self, "{:?}", n);
notifs_to_handle.push(n);
num += 1;
if num > MAX_CATCHUP_NOTIFS {
warn!("Too many notifs missed to catch up!");
if num > self.cc.max_catchup_notifs {
grp_warn!(self, "Too many notifs missed to catch up!");
break;
}
// sleep so we dont make the api angry
tokio::time::sleep(Duration::from_millis(250)).await;
let pn = iter.page_num();
if pn != old_pn {
old_pn = pn;
// sleep so we dont make the api angry
tokio::time::sleep(Duration::from_secs_f64(self.cc.delay_fetch_page_s)).await;
}
}
if notifs_to_handle.is_empty() {
@ -355,10 +451,10 @@ impl GroupHandle {
notifs_to_handle.reverse();
debug!("{} notifications to catch up!", notifs_to_handle.len());
grp_debug!(self, "{} notifications to catch up!", notifs_to_handle.len());
for n in notifs_to_handle {
debug!("Handling missed notification: {}", NotificationDisplay(&n));
grp_debug!(self, "Handling missed notification: {}", NotificationDisplay(&n));
self.handle_notification(n).await.log_error("Error handling a notification");
}
@ -379,29 +475,33 @@ impl GroupHandle {
let mut newest_status = None;
let mut num = 0;
let mut old_pn = 0;
while let Some(s) = iter.next_item().await {
let ts = s.timestamp_millis();
if ts <= last_status {
break; // reached our last seen status (hopefully there arent any retro-bumped)
}
debug!("Inspecting status {}", StatusDisplay(&s));
grp_debug!(self, "Inspecting status {}", StatusDisplay(&s));
grp_trace!(self, "{:?}", s);
if newest_status.is_none() {
newest_status = Some(ts);
}
if s.content.contains('#') && !s.visibility.is_private() {
statuses_to_handle.push(s);
}
statuses_to_handle.push(s);
num += 1;
if num > MAX_CATCHUP_STATUSES {
warn!("Too many statuses missed to catch up!");
if num > self.cc.max_catchup_statuses {
grp_warn!(self, "Too many statuses missed to catch up!");
break;
}
// sleep so we dont make the api angry
tokio::time::sleep(Duration::from_millis(250)).await;
let pn = iter.page_num();
if pn != old_pn {
old_pn = pn;
// sleep so we dont make the api angry
tokio::time::sleep(Duration::from_secs_f64(self.cc.delay_fetch_page_s)).await;
}
}
if let Some(ts) = newest_status {
@ -409,17 +509,17 @@ impl GroupHandle {
}
if statuses_to_handle.is_empty() {
grp_debug!(self, "No statuses to handle");
return Ok(false);
}
statuses_to_handle.reverse();
debug!("{} statuses to catch up!", statuses_to_handle.len());
grp_debug!(self, "{} statuses to catch up!", statuses_to_handle.len());
for s in statuses_to_handle {
debug!("Handling missed status: {}", StatusDisplay(&s));
self.handle_status(s).await
.log_error("Error handling a status");
grp_debug!(self, "Handling missed status: {}", StatusDisplay(&s));
self.handle_status(s).await.log_error("Error handling a status");
}
Ok(true)
@ -428,12 +528,15 @@ impl GroupHandle {
async fn handle_mention_status(&mut self, status: Status) -> Result<(), GroupError> {
let res = ProcessMention::run(self, status).await;
self.save_if_needed().await
.log_error("Failed to save");
self.save_if_needed().await.log_error("Failed to save");
res
}
fn tr(&self) -> &TranslationTable {
self.config.tr()
}
async fn handle_new_follow(&mut self, notif_acct: &str, notif_user_id: &str) {
let mut follow_back = false;
let text = if self.config.is_member_only() {
@ -442,25 +545,14 @@ impl GroupHandle {
let mut admins = self.config.get_admins().cloned().collect::<Vec<_>>();
admins.sort();
format!("\
@{user} Welcome to the group! This group has posting restricted to members. \
If you'd like to join, please ask one of the group admins:\n\
{admins}",
user = notif_acct,
admins = admins.join(", ")
)
crate::tr!(self, "mention_prefix", user = notif_acct)
+ &crate::tr!(self, "welcome_member_only", admins = &admins.join(", "))
} else {
follow_back = true;
self.config.set_member(notif_acct, true)
.log_error("Fail add a member");
self.config.set_member(notif_acct, true).log_error("Fail add a member");
format!("\
@{user} Welcome to the group! The group user will now follow you back to complete the sign-up. \
To share a post, @ the group user or use a group hashtag.\n\n\
Use /help for more info.",
user = notif_acct
)
crate::tr!(self, "mention_prefix", user = notif_acct) + &crate::tr!(self, "welcome_public")
};
let post = StatusBuilder::new()
@ -470,12 +562,15 @@ impl GroupHandle {
.build()
.expect("error build status");
self.client.new_status(post).await
.log_error("Failed to post");
self.client.new_status(post).await.log_error("Failed to post");
self.delay_after_post().await;
if follow_back {
self.follow_user(notif_user_id).await
.log_error("Failed to follow back");
self.follow_user(notif_user_id).await.log_error("Failed to follow back");
}
}
async fn delay_after_post(&self) {
tokio::time::sleep(Duration::from_secs_f64(self.cc.delay_after_post_s)).await;
}
}

View file

@ -18,10 +18,14 @@ use crate::utils::acct_to_server;
mod command;
mod error;
#[macro_use]
mod group_handler;
mod store;
mod utils;
#[macro_use]
mod tr;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args = clap::App::new("groups")
@ -31,12 +35,18 @@ async fn main() -> anyhow::Result<()> {
.multiple(true)
.help("increase logging, can be repeated"),
)
.arg(
Arg::with_name("quiet")
.short("q")
.multiple(true)
.help("decrease logging, can be repeated"),
)
.arg(
Arg::with_name("config")
.short("c")
.long("config")
.takes_value(true)
.help("set custom storage file, defaults to groups.json"),
.help("set custom config directory, defaults to the current folder"),
)
.arg(
Arg::with_name("auth")
@ -64,20 +74,26 @@ async fn main() -> anyhow::Result<()> {
LevelFilter::Trace,
];
let default_level = 2;
let default_level = 3;
let level = (default_level + args.occurrences_of("verbose") as usize).min(LEVELS.len());
let level = (default_level as isize + args.occurrences_of("verbose") as isize
- args.occurrences_of("quiet") as isize)
.clamp(0, LEVELS.len() as isize) as usize;
env_logger::Builder::new()
.filter_level(LEVELS[level])
.write_style(env_logger::WriteStyle::Always)
.filter_module("rustls", LevelFilter::Warn)
.filter_module("reqwest", LevelFilter::Warn)
.filter_module("tungstenite", LevelFilter::Warn)
.filter_module("tokio_tungstenite", LevelFilter::Warn)
.filter_module("tokio_util", LevelFilter::Warn)
.filter_module("want", LevelFilter::Warn)
.filter_module("mio", LevelFilter::Warn)
.init();
let store = store::ConfigStore::new(StoreOptions {
store_path: args.value_of("config").unwrap_or("groups.json").to_string(),
save_pretty: true,
let mut store = store::ConfigStore::load_from_fs(StoreOptions {
store_dir: args.value_of("config").unwrap_or(".").to_string(),
})
.await?;
@ -90,14 +106,14 @@ async fn main() -> anyhow::Result<()> {
}
if let Some(server) = acct_to_server(acct) {
let g = store
store
.auth_new_group(NewGroupOptions {
server: format!("https://{}", server),
acct: acct.to_string(),
})
.await?;
eprintln!("New group @{} added to config!", g.config.get_acct());
eprintln!("New group added to config!");
return Ok(());
} else {
anyhow::bail!("--auth handle must be username@server, got: \"{}\"", handle);
@ -106,17 +122,24 @@ async fn main() -> anyhow::Result<()> {
if let Some(acct) = args.value_of("reauth") {
let acct = acct.trim_start_matches('@');
let _ = store.reauth_group(acct).await?;
store.reauth_group(acct).await?;
eprintln!("Group @{} re-authed!", acct);
return Ok(());
}
store.find_locales().await;
// Start
let groups = store.spawn_groups().await;
let groups = store.spawn_groups().await?;
let mut handles = vec![];
for mut g in groups {
handles.push(tokio::spawn(async move { g.run().await }));
handles.push(tokio::spawn(async move {
match g.run().await {
Ok(()) => unreachable!(),
Err(e) => error!("GROUP FAILED! {}", e),
}
}));
}
futures::future::join_all(handles).await;

View file

@ -0,0 +1,61 @@
use crate::store::DEFAULT_LOCALE_NAME;
use crate::tr::TranslationTable;
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct CommonConfig {
pub groups_dir: String,
pub locales_dir: String,
pub validate_locales: bool,
/// Max number of missed notifs to process after connect
pub max_catchup_notifs: usize,
/// Max number of missed statuses to process after connect
pub max_catchup_statuses: usize,
/// Delay between fetched pages when catching up
pub delay_fetch_page_s: f64,
/// Delay after sending a status, making a follow or some other action.
/// Set if there are Throttled errors and you need to slow the service down.
pub delay_after_post_s: f64,
/// Delay before trying to re-connect after the server closed the socket
pub delay_reopen_closed_s: f64,
/// Delay before trying to re-connect after an error
pub delay_reopen_error_s: f64,
/// Timeout for a notification/timeline socket to be considered alive.
/// If nothing arrives in this interval, reopen it. Some servers have a buggy socket
/// implementation where it stays open but no longer works.
pub socket_alive_timeout_s: f64,
/// Time after which a socket is always closed, even if seemingly alive.
/// This is a work-around for servers that stop sending notifs after a while.
pub socket_retire_time_s: f64,
#[serde(skip)]
pub tr: HashMap<String, TranslationTable>,
}
impl Default for CommonConfig {
fn default() -> Self {
Self {
groups_dir: "groups".to_string(),
locales_dir: "locales".to_string(),
validate_locales: true,
max_catchup_notifs: 30,
max_catchup_statuses: 50,
delay_fetch_page_s: 0.25,
delay_after_post_s: 0.0,
delay_reopen_closed_s: 0.5,
delay_reopen_error_s: 5.0,
socket_alive_timeout_s: 30.0,
socket_retire_time_s: 120.0,
tr: Default::default(),
}
}
}
impl CommonConfig {
pub fn tr(&self, lang: &str) -> &TranslationTable {
match self.tr.get(lang) {
Some(tr) => tr,
None => self.tr.get(DEFAULT_LOCALE_NAME).expect("default locale is not loaded"),
}
}
}

View file

@ -1,455 +0,0 @@
use std::collections::{HashMap, HashSet};
use elefren::AppData;
use crate::error::GroupError;
/// This is the inner data struct holding the config
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub(crate) struct Config {
pub(crate) groups: HashMap<String, GroupConfig>,
}
impl Config {
// pub(crate) fn iter_groups(&self) -> impl Iterator<Item = &GroupConfig> {
// self.groups.values()
// }
pub(crate) fn get_group_config(&self, acct: &str) -> Option<&GroupConfig> {
self.groups.get(acct)
}
pub(crate) fn set_group_config(&mut self, grp: GroupConfig) {
self.groups.insert(grp.acct.clone(), grp);
}
}
/// This is the inner data struct holding a group's config
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub(crate) struct GroupConfig {
enabled: bool,
/// Group actor's acct
acct: String,
/// elefren data
appdata: AppData,
/// Hashtags the group will auto-boost from it's members
group_tags: HashSet<String>,
/// List of admin account "acct" names, e.g. piggo@piggo.space
admin_users: HashSet<String>,
/// List of users allowed to post to the group, if it is member-only
member_users: HashSet<String>,
/// List of users banned from posting to the group
banned_users: HashSet<String>,
/// Users who decided they don't want to be shared to the group (does not apply to members)
optout_users: HashSet<String>,
/// True if only members should be allowed to write
member_only: bool,
/// Banned domain names, e.g. kiwifarms.cc
banned_servers: HashSet<String>,
/// Last seen notification timestamp (millis)
last_notif_ts: u64,
/// Last seen status timestamp (millis)
last_status_ts: u64,
#[serde(skip)]
dirty: bool,
}
impl Default for GroupConfig {
fn default() -> Self {
Self {
enabled: true,
acct: "".to_string(),
appdata: AppData {
base: Default::default(),
client_id: Default::default(),
client_secret: Default::default(),
redirect: Default::default(),
token: Default::default(),
},
group_tags: Default::default(),
admin_users: Default::default(),
member_users: Default::default(),
banned_users: Default::default(),
optout_users: Default::default(),
member_only: false,
banned_servers: Default::default(),
last_notif_ts: 0,
last_status_ts: 0,
dirty: false,
}
}
}
impl GroupConfig {
pub(crate) fn new(acct: String, appdata: AppData) -> Self {
Self {
acct,
appdata,
..Default::default()
}
}
pub(crate) fn is_enabled(&self) -> bool {
self.enabled
}
/*
pub(crate) fn set_enabled(&mut self, ena: bool) {
self.enabled = ena;
self.mark_dirty();
}
*/
pub(crate) fn get_appdata(&self) -> &AppData {
&self.appdata
}
pub(crate) fn set_appdata(&mut self, appdata: AppData) {
if self.appdata != appdata {
self.mark_dirty();
}
self.appdata = appdata;
}
pub(crate) fn get_admins(&self) -> impl Iterator<Item = &String> {
self.admin_users.iter()
}
pub(crate) fn get_members(&self) -> impl Iterator<Item = &String> {
self.member_users.iter()
}
pub(crate) fn get_tags(&self) -> impl Iterator<Item = &String> {
self.group_tags.iter()
}
pub(crate) fn set_last_notif(&mut self, ts: u64) {
if self.last_notif_ts != ts {
self.mark_dirty();
}
self.last_notif_ts = self.last_notif_ts.max(ts);
}
pub(crate) fn get_last_notif(&self) -> u64 {
self.last_notif_ts
}
pub(crate) fn set_last_status(&mut self, ts: u64) {
if self.last_status_ts != ts {
self.mark_dirty();
}
self.last_status_ts = self.last_status_ts.max(ts);
}
pub(crate) fn get_last_status(&self) -> u64 {
self.last_status_ts
}
pub(crate) fn get_acct(&self) -> &str {
&self.acct
}
pub(crate) fn is_optout(&self, acct: &str) -> bool {
self.optout_users.contains(acct)
}
pub(crate) fn is_admin(&self, acct: &str) -> bool {
self.admin_users.contains(acct)
}
pub(crate) fn is_member(&self, acct: &str) -> bool {
self.member_users.contains(acct)
}
pub(crate) fn is_member_or_admin(&self, acct: &str) -> bool {
self.is_member(acct)
|| self.is_admin(acct)
}
pub(crate) fn is_banned(&self, acct: &str) -> bool {
self.banned_users.contains(acct) || self.is_users_server_banned(acct)
}
pub(crate) fn is_server_banned(&self, server: &str) -> bool {
self.banned_servers.contains(server)
}
/// Check if the user's server is banned
fn is_users_server_banned(&self, acct: &str) -> bool {
let server = acct_to_server(acct);
self.is_server_banned(server)
}
pub(crate) fn can_write(&self, acct: &str) -> bool {
if self.is_admin(acct) {
true
} else {
!self.is_banned(acct) && (!self.is_member_only() || self.is_member(acct))
}
}
pub(crate) fn set_admin(&mut self, acct: &str, admin: bool) -> Result<(), GroupError> {
let change = if admin {
if self.is_banned(acct) {
return Err(GroupError::UserIsBanned);
}
self.admin_users.insert(acct.to_owned())
} else {
self.admin_users.remove(acct)
};
if change {
self.mark_dirty();
}
Ok(())
}
pub(crate) fn set_member(&mut self, acct: &str, member: bool) -> Result<(), GroupError> {
let change = if member {
if self.is_banned(acct) {
return Err(GroupError::UserIsBanned);
}
self.member_users.insert(acct.to_owned())
} else {
self.member_users.remove(acct)
};
if change {
self.mark_dirty();
}
Ok(())
}
pub(crate) fn set_optout(&mut self, acct: &str, optout: bool) {
let change = if optout {
self.optout_users.insert(acct.to_owned())
} else {
self.optout_users.remove(acct)
};
if change {
self.mark_dirty();
}
}
pub(crate) fn ban_user(&mut self, acct: &str, ban: bool) -> Result<(), GroupError> {
let mut change = false;
if ban {
if self.is_admin(acct) {
return Err(GroupError::UserIsAdmin);
}
// Banned user is also kicked
change |= self.member_users.remove(acct);
change |= self.banned_users.insert(acct.to_owned());
} else {
change |= self.banned_users.remove(acct);
}
if change {
self.mark_dirty();
}
Ok(())
}
pub(crate) fn ban_server(&mut self, server: &str, ban: bool) -> Result<(), GroupError> {
let changed = if ban {
for acct in &self.admin_users {
let acct_server = acct_to_server(acct);
if acct_server == server {
return Err(GroupError::AdminsOnServer);
}
}
self.banned_servers.insert(server.to_owned())
} else {
self.banned_servers.remove(server)
};
if changed {
self.mark_dirty();
}
Ok(())
}
pub(crate) fn add_tag(&mut self, tag: &str) {
if self.group_tags.insert(tag.to_string()) {
self.mark_dirty();
}
}
pub(crate) fn remove_tag(&mut self, tag: &str) {
if self.group_tags.remove(tag) {
self.mark_dirty();
}
}
pub(crate) fn is_tag_followed(&self, tag: &str) -> bool {
self.group_tags.contains(tag)
}
pub(crate) fn set_member_only(&mut self, member_only: bool) {
if self.member_only != member_only {
self.mark_dirty();
}
self.member_only = member_only;
}
pub(crate) fn is_member_only(&self) -> bool {
self.member_only
}
pub(crate) fn mark_dirty(&mut self) {
self.dirty = true;
}
pub(crate) fn is_dirty(&self) -> bool {
self.dirty
}
pub(crate) fn clear_dirty_status(&mut self) {
self.dirty = false;
}
}
fn acct_to_server(acct: &str) -> &str {
acct.split('@').nth(1).unwrap_or_default()
}
#[cfg(test)]
mod tests {
use crate::error::GroupError;
use crate::store::data::{acct_to_server, GroupConfig};
#[test]
fn test_acct_to_server() {
assert_eq!("pikachu.rocks", acct_to_server("raichu@pikachu.rocks"));
assert_eq!("pikachu.rocks", acct_to_server("m@pikachu.rocks"));
assert_eq!("", acct_to_server("what"));
}
#[test]
fn test_default_rules() {
let group = GroupConfig::default();
assert!(!group.is_member_only());
assert!(!group.is_member("piggo@piggo.space"));
assert!(!group.is_admin("piggo@piggo.space"));
assert!(group.can_write("piggo@piggo.space"), "anyone can post by default");
}
#[test]
fn test_member_only() {
let mut group = GroupConfig::default();
assert!(group.can_write("piggo@piggo.space"), "rando can write in public group");
group.set_member_only(true);
assert!(
!group.can_write("piggo@piggo.space"),
"rando can't write in member-only group"
);
// Admin in member only
group.set_admin("piggo@piggo.space", true).unwrap();
assert!(
group.can_write("piggo@piggo.space"),
"admin non-member can write in member-only group"
);
group.set_admin("piggo@piggo.space", false).unwrap();
assert!(
!group.can_write("piggo@piggo.space"),
"removed admin removes privileged write access"
);
// Member in member only
group.set_member("piggo@piggo.space", true).unwrap();
assert!(
group.can_write("piggo@piggo.space"),
"member can post in member-only group"
);
group.set_admin("piggo@piggo.space", true).unwrap();
assert!(
group.can_write("piggo@piggo.space"),
"member+admin can post in member-only group"
);
}
#[test]
fn test_banned_users() {
// Banning single user
let mut group = GroupConfig::default();
group.ban_user("piggo@piggo.space", true).unwrap();
assert!(!group.can_write("piggo@piggo.space"), "banned user can't post");
group.ban_user("piggo@piggo.space", false).unwrap();
assert!(group.can_write("piggo@piggo.space"), "un-ban works");
}
#[test]
fn test_banned_members() {
// Banning single user
let mut group = GroupConfig::default();
group.set_member_only(true);
group.set_member("piggo@piggo.space", true).unwrap();
assert!(group.can_write("piggo@piggo.space"), "member can write");
assert!(group.is_member("piggo@piggo.space"), "member is member");
assert!(!group.is_banned("piggo@piggo.space"), "user not banned by default");
group.ban_user("piggo@piggo.space", true).unwrap();
assert!(group.is_member("piggo@piggo.space"), "still member even if banned");
assert!(group.is_banned("piggo@piggo.space"), "banned user is banned");
assert!(!group.can_write("piggo@piggo.space"), "banned member can't post");
// unban
group.ban_user("piggo@piggo.space", false).unwrap();
assert!(group.can_write("piggo@piggo.space"), "un-ban works");
}
#[test]
fn test_server_ban() {
let mut group = GroupConfig::default();
assert!(group.can_write("hitler@nazi.camp"), "randos can write");
group.ban_server("nazi.camp", true).unwrap();
assert!(
!group.can_write("hitler@nazi.camp"),
"users from banned server can't write"
);
assert!(
!group.can_write("1488@nazi.camp"),
"users from banned server can't write"
);
assert!(group.can_write("troll@freezepeach.xyz"), "other users can still write");
group.ban_server("nazi.camp", false).unwrap();
assert!(group.can_write("hitler@nazi.camp"), "server unban works");
}
#[test]
fn test_sanity() {
let mut group = GroupConfig::default();
group.set_admin("piggo@piggo.space", true).unwrap();
assert_eq!(
Err(GroupError::UserIsAdmin),
group.ban_user("piggo@piggo.space", true),
"can't bad admin users"
);
group.ban_user("piggo@piggo.space", false).expect("can unbad admin");
group.ban_user("hitler@nazi.camp", true).unwrap();
assert_eq!(
Err(GroupError::UserIsBanned),
group.set_admin("hitler@nazi.camp", true),
"can't make banned users admins"
);
group.ban_server("freespeechextremist.com", true).unwrap();
assert_eq!(
Err(GroupError::UserIsBanned),
group.set_admin("nibber@freespeechextremist.com", true),
"can't make server-banned users admins"
);
assert!(group.is_admin("piggo@piggo.space"));
assert_eq!(
Err(GroupError::AdminsOnServer),
group.ban_server("piggo.space", true),
"can't bad server with admins"
);
}
}

748
src/store/group_config.rs Normal file
View file

@ -0,0 +1,748 @@
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use elefren::AppData;
use crate::error::GroupError;
use crate::store::{CommonConfig, DEFAULT_LOCALE_NAME};
use crate::tr::TranslationTable;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct FixedConfig {
enabled: bool,
/// Group actor's acct
acct: String,
/// elefren data
appdata: AppData,
/// configured locale to use
locale: String,
/// Server's character limit
character_limit: usize,
#[serde(skip)]
_dirty: bool,
#[serde(skip)]
_path: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct MutableConfig {
/// Hashtags the group will auto-boost from it's members
group_tags: HashSet<String>,
/// List of admin account "acct" names, e.g. piggo@piggo.space
admin_users: HashSet<String>,
/// List of users allowed to post to the group, if it is member-only
member_users: HashSet<String>,
/// List of users banned from posting to the group
banned_users: HashSet<String>,
/// Users who decided they don't want to be shared to the group (does not apply to members)
optout_users: HashSet<String>,
/// True if only members should be allowed to write
member_only: bool,
/// Banned domain names, e.g. kiwifarms.cc
banned_servers: HashSet<String>,
#[serde(skip)]
_dirty: bool,
#[serde(skip)]
_path: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct StateConfig {
/// Last seen notification timestamp (millis)
last_notif_ts: u64,
/// Last seen status timestamp (millis)
last_status_ts: u64,
#[serde(skip)]
_dirty: bool,
#[serde(skip)]
_path: PathBuf,
}
/// This is the inner data struct holding a group's config
#[derive(Debug, Clone)]
pub struct GroupConfig {
/// Fixed config that we only read
config: FixedConfig,
/// Mutable config we can write
control: MutableConfig,
/// State config with timestamps and transient data that is changed frequently
state: StateConfig,
/// Group-specific translation table; this is a clone of the global table with group-specific overrides applied.
_group_tr: TranslationTable,
}
impl Default for FixedConfig {
fn default() -> Self {
Self {
enabled: true,
acct: "".to_string(),
locale: DEFAULT_LOCALE_NAME.to_string(),
appdata: AppData {
base: Default::default(),
client_id: Default::default(),
client_secret: Default::default(),
redirect: Default::default(),
token: Default::default(),
},
character_limit: 5000,
_dirty: false,
_path: PathBuf::default(),
}
}
}
impl Default for MutableConfig {
fn default() -> Self {
Self {
group_tags: Default::default(),
admin_users: Default::default(),
member_users: Default::default(),
banned_users: Default::default(),
optout_users: Default::default(),
member_only: false,
banned_servers: Default::default(),
_dirty: false,
_path: PathBuf::default(),
}
}
}
impl Default for StateConfig {
fn default() -> Self {
Self {
last_notif_ts: 0,
last_status_ts: 0,
_dirty: false,
_path: PathBuf::default(),
}
}
}
macro_rules! impl_change_tracking {
($struc:ident) => {
impl $struc {
pub(crate) fn mark_dirty(&mut self) {
self._dirty = true;
}
pub(crate) fn is_dirty(&self) -> bool {
self._dirty
}
pub(crate) fn clear_dirty_status(&mut self) {
self._dirty = false;
}
pub(crate) async fn save_if_needed(&mut self) -> Result<bool, GroupError> {
if self.is_dirty() {
self.save().await?;
Ok(true)
} else {
Ok(false)
}
}
pub(crate) async fn save(&mut self) -> Result<(), GroupError> {
tokio::fs::write(&self._path, serde_json::to_string_pretty(&self)?.as_bytes()).await?;
self.clear_dirty_status();
Ok(())
}
}
};
}
impl_change_tracking!(FixedConfig);
impl_change_tracking!(MutableConfig);
impl_change_tracking!(StateConfig);
async fn load_or_create_control_file(control_path: impl AsRef<Path>) -> Result<MutableConfig, GroupError> {
let control_path = control_path.as_ref();
let mut dirty = false;
let mut control: MutableConfig = if control_path.is_file() {
let f = tokio::fs::read(&control_path).await?;
let mut control: MutableConfig = json5::from_str(&String::from_utf8_lossy(&f))?;
control._path = control_path.to_owned();
control
} else {
debug!("control file missing, creating empty");
dirty = true;
MutableConfig {
_path: control_path.to_owned(),
..Default::default()
}
};
if dirty {
control.save().await?;
}
Ok(control)
}
async fn load_or_create_state_file(state_path: impl AsRef<Path>) -> Result<StateConfig, GroupError> {
let state_path = state_path.as_ref();
let mut dirty = false;
let mut state: StateConfig = if state_path.is_file() {
let f = tokio::fs::read(&state_path).await?;
let mut control: StateConfig = json5::from_str(&String::from_utf8_lossy(&f))?;
control._path = state_path.to_owned();
control
} else {
debug!("state file missing, creating empty");
dirty = true;
StateConfig {
_path: state_path.to_owned(),
..Default::default()
}
};
if dirty {
state.save().await?;
}
Ok(state)
}
async fn load_locale_override_file(locale_path: impl AsRef<Path>) -> Result<Option<TranslationTable>, GroupError> {
let locale_path = locale_path.as_ref();
if locale_path.is_file() {
let f = tokio::fs::read(&locale_path).await?;
let opt: TranslationTable = json5::from_str(&String::from_utf8_lossy(&f))?;
Ok(Some(opt))
} else {
Ok(None)
}
}
impl GroupConfig {
pub fn tr(&self) -> &TranslationTable {
&self._group_tr
}
pub(crate) fn is_dirty(&self) -> bool {
self.config.is_dirty() || self.control.is_dirty() || self.state.is_dirty()
}
/// Save only what changed
pub(crate) async fn save_if_needed(&mut self, danger_allow_overwriting_config: bool) -> Result<(), GroupError> {
#[allow(clippy::collapsible_if)]
if danger_allow_overwriting_config {
if self.config.save_if_needed().await? {
debug!(
"Written {} config file {}",
self.config.acct,
self.config._path.display()
);
}
}
if self.control.save_if_needed().await? {
debug!(
"Written {} control file {}",
self.config.acct,
self.control._path.display()
);
}
if self.state.save_if_needed().await? {
debug!("Written {} state file {}", self.config.acct, self.state._path.display());
}
Ok(())
}
/// Save all unconditionally
#[allow(unused)]
pub(crate) async fn save(&mut self, danger_allow_overwriting_config: bool) -> Result<(), GroupError> {
if danger_allow_overwriting_config {
self.config.save().await?;
}
self.control.save().await?;
self.state.save().await?;
Ok(())
}
/// (re)init using new authorization
pub(crate) async fn initialize_by_appdata(
acct: String,
appdata: AppData,
group_dir: PathBuf,
) -> Result<(), GroupError> {
if !group_dir.is_dir() {
debug!("Creating group directory");
tokio::fs::create_dir_all(&group_dir).await?;
}
let config_path = group_dir.join("config.json");
let control_path = group_dir.join("control.json");
let state_path = group_dir.join("state.json");
// try to reuse content of the files, if present
/* config */
let mut dirty = false;
let mut config: FixedConfig = if config_path.is_file() {
let f = tokio::fs::read(&config_path).await?;
let mut config: FixedConfig = json5::from_str(&String::from_utf8_lossy(&f))?;
config._path = config_path;
if config.appdata != appdata {
config.appdata = appdata;
dirty = true;
}
if config.acct != acct {
config.acct = acct.clone();
dirty = true;
}
config
} else {
dirty = true;
FixedConfig {
acct: acct.clone(),
appdata,
_path: config_path,
..Default::default()
}
};
if dirty {
debug!("config file for {} changed, creating/updating", acct);
config.save().await?;
}
/* control */
let control = load_or_create_control_file(control_path).await?;
/* state */
let state = load_or_create_state_file(state_path).await?;
let g = GroupConfig {
config,
control,
state,
_group_tr: TranslationTable::new(),
};
g.warn_of_bad_config();
Ok(())
}
pub(crate) async fn from_dir(group_dir: PathBuf, cc: &CommonConfig) -> Result<Self, GroupError> {
let config_path = group_dir.join("config.json");
let control_path = group_dir.join("control.json");
let state_path = group_dir.join("state.json");
let locale_path = group_dir.join("messages.json");
// try to reuse content of the files, if present
/* config */
let f = tokio::fs::read(&config_path).await?;
let mut config: FixedConfig = json5::from_str(&String::from_utf8_lossy(&f))?;
config._path = config_path;
/* control */
let control = load_or_create_control_file(control_path).await?;
/* state */
let state = load_or_create_state_file(state_path).await?;
/* translation table */
let mut tr = cc.tr(&config.locale).clone();
if let Some(locale_overrides) = load_locale_override_file(locale_path).await? {
for (k, v) in locale_overrides.entries() {
tr.add_translation(k, v);
}
}
let g = GroupConfig {
config,
control,
state,
_group_tr: tr,
};
g.warn_of_bad_config();
Ok(g)
}
fn warn_of_bad_config(&self) {
for t in &self.control.group_tags {
if &t.to_lowercase() != t {
warn!(
"Group {} hashtag \"{}\" is not lowercase, it won't work!",
self.config.acct, t
);
}
}
for u in self
.control
.admin_users
.iter()
.chain(self.control.member_users.iter())
.chain(self.control.banned_users.iter())
.chain(self.control.optout_users.iter())
{
if &u.to_lowercase() != u {
warn!(
"Group {} config contains a user with non-lowercase name \"{}\", it won't work!",
self.config.acct, u
);
}
if u.starts_with('@') || u.chars().filter(|c| *c == '@').count() != 1 {
warn!("Group {} config contains an invalid user name: {}", self.config.acct, u);
}
}
}
pub(crate) fn get_character_limit(&self) -> usize {
self.config.character_limit
}
pub(crate) fn is_enabled(&self) -> bool {
self.config.enabled
}
pub(crate) fn get_appdata(&self) -> &AppData {
&self.config.appdata
}
pub(crate) fn set_appdata(&mut self, appdata: AppData) {
if self.config.appdata != appdata {
self.config.mark_dirty();
}
self.config.appdata = appdata;
}
pub(crate) fn get_admins(&self) -> impl Iterator<Item = &String> {
self.control.admin_users.iter()
}
pub(crate) fn get_members(&self) -> impl Iterator<Item = &String> {
self.control.member_users.iter()
}
pub(crate) fn get_tags(&self) -> impl Iterator<Item = &String> {
self.control.group_tags.iter()
}
pub(crate) fn set_last_notif(&mut self, ts: u64) {
if self.state.last_notif_ts != ts {
self.state.mark_dirty();
}
self.state.last_notif_ts = self.state.last_notif_ts.max(ts);
}
pub(crate) fn get_last_notif(&self) -> u64 {
self.state.last_notif_ts
}
pub(crate) fn set_last_status(&mut self, ts: u64) {
if self.state.last_status_ts != ts {
self.state.mark_dirty();
}
self.state.last_status_ts = self.state.last_status_ts.max(ts);
}
pub(crate) fn get_last_status(&self) -> u64 {
self.state.last_status_ts
}
pub(crate) fn get_acct(&self) -> &str {
&self.config.acct
}
pub(crate) fn is_optout(&self, acct: &str) -> bool {
self.control.optout_users.contains(acct)
}
pub(crate) fn is_admin(&self, acct: &str) -> bool {
self.control.admin_users.contains(acct)
}
pub(crate) fn is_member(&self, acct: &str) -> bool {
self.control.member_users.contains(acct)
}
pub(crate) fn is_member_or_admin(&self, acct: &str) -> bool {
self.is_member(acct) || self.is_admin(acct)
}
pub(crate) fn is_banned(&self, acct: &str) -> bool {
self.control.banned_users.contains(acct) || self.is_users_server_banned(acct)
}
pub(crate) fn is_server_banned(&self, server: &str) -> bool {
self.control.banned_servers.contains(server)
}
/// Check if the user's server is banned
fn is_users_server_banned(&self, acct: &str) -> bool {
let server = acct_to_server(acct);
self.is_server_banned(&server)
}
pub(crate) fn can_write(&self, acct: &str) -> bool {
if self.is_admin(acct) {
true
} else {
!self.is_banned(acct) && (!self.is_member_only() || self.is_member(acct))
}
}
pub(crate) fn set_admin(&mut self, acct: &str, admin: bool) -> Result<(), GroupError> {
let change = if admin {
if self.is_banned(acct) {
return Err(GroupError::UserIsBanned);
}
self.control.admin_users.insert(acct.to_owned())
} else {
self.control.admin_users.remove(acct)
};
if change {
self.control.mark_dirty();
}
Ok(())
}
pub(crate) fn set_member(&mut self, acct: &str, member: bool) -> Result<(), GroupError> {
let change = if member {
if self.is_banned(acct) {
return Err(GroupError::UserIsBanned);
}
self.control.member_users.insert(acct.to_owned())
} else {
self.control.member_users.remove(acct)
};
if change {
self.control.mark_dirty();
}
Ok(())
}
pub(crate) fn set_optout(&mut self, acct: &str, optout: bool) {
let change = if optout {
self.control.optout_users.insert(acct.to_owned())
} else {
self.control.optout_users.remove(acct)
};
if change {
self.control.mark_dirty();
}
}
pub(crate) fn ban_user(&mut self, acct: &str, ban: bool) -> Result<(), GroupError> {
let mut change = false;
if ban {
if self.is_admin(acct) {
return Err(GroupError::UserIsAdmin);
}
// Banned user is also kicked
change |= self.control.member_users.remove(acct);
change |= self.control.banned_users.insert(acct.to_owned());
} else {
change |= self.control.banned_users.remove(acct);
}
if change {
self.control.mark_dirty();
}
Ok(())
}
pub(crate) fn ban_server(&mut self, server: &str, ban: bool) -> Result<(), GroupError> {
let changed = if ban {
for acct in &self.control.admin_users {
let acct_server = acct_to_server(acct);
if acct_server == server {
return Err(GroupError::AdminsOnServer);
}
}
self.control.banned_servers.insert(server.to_owned())
} else {
self.control.banned_servers.remove(server)
};
if changed {
self.control.mark_dirty();
}
Ok(())
}
pub(crate) fn add_tag(&mut self, tag: &str) {
if self.control.group_tags.insert(tag.to_string()) {
self.control.mark_dirty();
}
}
pub(crate) fn remove_tag(&mut self, tag: &str) {
if self.control.group_tags.remove(tag) {
self.control.mark_dirty();
}
}
pub(crate) fn is_tag_followed(&self, tag: &str) -> bool {
self.control.group_tags.contains(tag)
}
pub(crate) fn set_member_only(&mut self, member_only: bool) {
if self.control.member_only != member_only {
self.control.mark_dirty();
}
self.control.member_only = member_only;
}
pub(crate) fn is_member_only(&self) -> bool {
self.control.member_only
}
}
fn acct_to_server(acct: &str) -> String {
crate::utils::acct_to_server(acct).unwrap_or_default()
}
#[cfg(test)]
mod tests {
use crate::error::GroupError;
use crate::store::group_config::{acct_to_server, GroupConfig};
fn empty_group_config() -> GroupConfig {
GroupConfig {
config: Default::default(),
control: Default::default(),
state: Default::default(),
_group_tr: Default::default(),
}
}
#[test]
fn test_acct_to_server() {
assert_eq!("pikachu.rocks".to_string(), acct_to_server("raichu@pikachu.rocks"));
assert_eq!("pikachu.rocks".to_string(), acct_to_server("m@pikachu.rocks"));
assert_eq!("".to_string(), acct_to_server("what"));
}
#[test]
fn test_default_rules() {
let group = empty_group_config();
assert!(!group.is_member_only());
assert!(!group.is_member("piggo@piggo.space"));
assert!(!group.is_admin("piggo@piggo.space"));
assert!(group.can_write("piggo@piggo.space"), "anyone can post by default");
}
#[test]
fn test_member_only() {
let mut group = empty_group_config();
assert!(group.can_write("piggo@piggo.space"), "rando can write in public group");
group.set_member_only(true);
assert!(
!group.can_write("piggo@piggo.space"),
"rando can't write in member-only group"
);
// Admin in member only
group.set_admin("piggo@piggo.space", true).unwrap();
assert!(
group.can_write("piggo@piggo.space"),
"admin non-member can write in member-only group"
);
group.set_admin("piggo@piggo.space", false).unwrap();
assert!(
!group.can_write("piggo@piggo.space"),
"removed admin removes privileged write access"
);
// Member in member only
group.set_member("piggo@piggo.space", true).unwrap();
assert!(
group.can_write("piggo@piggo.space"),
"member can post in member-only group"
);
group.set_admin("piggo@piggo.space", true).unwrap();
assert!(
group.can_write("piggo@piggo.space"),
"member+admin can post in member-only group"
);
}
#[test]
fn test_banned_users() {
// Banning single user
let mut group = empty_group_config();
group.ban_user("piggo@piggo.space", true).unwrap();
assert!(!group.can_write("piggo@piggo.space"), "banned user can't post");
group.ban_user("piggo@piggo.space", false).unwrap();
assert!(group.can_write("piggo@piggo.space"), "un-ban works");
}
#[test]
fn test_banned_members() {
// Banning single user
let mut group = empty_group_config();
group.set_member_only(true);
group.set_member("piggo@piggo.space", true).unwrap();
assert!(group.can_write("piggo@piggo.space"), "member can write");
assert!(group.is_member("piggo@piggo.space"), "member is member");
assert!(!group.is_banned("piggo@piggo.space"), "user not banned by default");
group.ban_user("piggo@piggo.space", true).unwrap();
assert!(!group.is_member("piggo@piggo.space"), "banned user is kicked");
assert!(group.is_banned("piggo@piggo.space"), "banned user is banned");
assert!(!group.can_write("piggo@piggo.space"), "banned member can't post");
// unban
group.ban_user("piggo@piggo.space", false).unwrap();
assert!(!group.can_write("piggo@piggo.space"), "unbanned member is still kicked");
group.set_member("piggo@piggo.space", true).unwrap();
assert!(group.can_write("piggo@piggo.space"), "un-ban works");
}
#[test]
fn test_server_ban() {
let mut group = empty_group_config();
assert!(group.can_write("hitler@nazi.camp"), "randos can write");
group.ban_server("nazi.camp", true).unwrap();
assert!(
!group.can_write("hitler@nazi.camp"),
"users from banned server can't write"
);
assert!(
!group.can_write("1488@nazi.camp"),
"users from banned server can't write"
);
assert!(group.can_write("troll@freezepeach.xyz"), "other users can still write");
group.ban_server("nazi.camp", false).unwrap();
assert!(group.can_write("hitler@nazi.camp"), "server unban works");
}
#[test]
fn test_sanity() {
let mut group = empty_group_config();
group.set_admin("piggo@piggo.space", true).unwrap();
assert_eq!(
Err(GroupError::UserIsAdmin),
group.ban_user("piggo@piggo.space", true),
"can't bad admin users"
);
group.ban_user("piggo@piggo.space", false).expect("can unbad admin");
group.ban_user("hitler@nazi.camp", true).unwrap();
assert_eq!(
Err(GroupError::UserIsBanned),
group.set_admin("hitler@nazi.camp", true),
"can't make banned users admins"
);
group.ban_server("freespeechextremist.com", true).unwrap();
assert_eq!(
Err(GroupError::UserIsBanned),
group.set_admin("nibber@freespeechextremist.com", true),
"can't make server-banned users admins"
);
assert!(group.is_admin("piggo@piggo.space"));
assert_eq!(
Err(GroupError::AdminsOnServer),
group.ban_server("piggo.space", true),
"can't bad server with admins"
);
}
}

View file

@ -3,21 +3,22 @@ use std::sync::Arc;
use elefren::{scopes, FediClient, Registration, Scopes};
use futures::StreamExt;
use tokio::sync::RwLock;
use data::{Config, GroupConfig};
use crate::error::GroupError;
use crate::group_handler::GroupHandle;
use std::time::Duration;
use crate::group_handler::{GroupHandle, GroupInternal};
pub(crate) mod data;
pub mod common_config;
pub mod group_config;
use crate::tr::TranslationTable;
pub use common_config::CommonConfig;
pub use group_config::GroupConfig;
#[derive(Debug, Default)]
pub struct ConfigStore {
store_path: PathBuf,
save_pretty: bool,
data: tokio::sync::RwLock<Config>,
groups_path: PathBuf,
locales_path: PathBuf,
config: CommonConfig,
}
#[derive(Debug)]
@ -28,34 +29,91 @@ pub struct NewGroupOptions {
#[derive(Debug)]
pub struct StoreOptions {
pub store_path: String,
pub save_pretty: bool,
pub store_dir: String,
}
const DEFAULT_LOCALE_NAME: &str = "en";
const DEFAULT_LOCALE_JSON: &str = include_str!("../../locales/en.json");
impl ConfigStore {
/// Create a new instance of the store.
/// If a path is given, it will try to load the content from a file.
pub async fn new(options: StoreOptions) -> Result<Arc<Self>, GroupError> {
let path: &Path = options.store_path.as_ref();
pub async fn load_from_fs(options: StoreOptions) -> Result<Self, GroupError> {
let given_path: &Path = options.store_dir.as_ref();
let config = if path.is_file() {
let f = tokio::fs::read(path).await?;
serde_json::from_slice(&f)?
let mut common_file: Option<PathBuf> = None;
let base_dir: PathBuf;
if given_path.is_file() {
if given_path.extension().unwrap_or_default().to_string_lossy() == "json" {
// this is a groups.json file
common_file = Some(given_path.to_owned());
base_dir = given_path
.parent()
.ok_or_else(|| GroupError::BadConfig("no parent dir".into()))?
.to_owned();
} else {
return Err(GroupError::BadConfig("bad config file, should be JSON".into()));
}
} else if given_path.is_dir() {
let cf = given_path.join("groups.json");
if cf.is_file() {
common_file = Some(cf);
}
base_dir = given_path.to_owned();
} else {
let empty = Config::default();
tokio::fs::write(path, serde_json::to_string(&empty)?.as_bytes()).await?;
empty
return Err(GroupError::BadConfig("bad config file/dir".into()));
}
if !base_dir.is_dir() {
return Err(GroupError::BadConfig("base dir does not exist".into()));
}
let config: CommonConfig = if let Some(cf) = &common_file {
debug!("Loading common config from {}", cf.display());
let f = tokio::fs::read(&cf).await?;
json5::from_str(&String::from_utf8_lossy(&f))?
} else {
debug!("No common config file, using defaults");
CommonConfig::default()
};
Ok(Arc::new(Self {
store_path: path.to_owned(),
save_pretty: options.save_pretty,
data: RwLock::new(config),
}))
debug!("Using common config:\n{:#?}", config);
let groups_path = if config.groups_dir.starts_with('/') {
PathBuf::from(&config.groups_dir)
} else {
base_dir.join(&config.groups_dir)
};
if !groups_path.exists() {
debug!("Creating groups directory");
tokio::fs::create_dir_all(&groups_path).await?;
}
let locales_path = if config.locales_dir.starts_with('/') {
PathBuf::from(&config.locales_dir)
} else {
base_dir.join(&config.locales_dir)
};
// warn, this is usually not a good idea beside for testing
if config.max_catchup_notifs == 0 {
warn!("Missed notifications catch-up is disabled!");
}
if config.max_catchup_statuses == 0 {
warn!("Missed statuses catch-up is disabled!");
}
Ok(Self {
store_path: base_dir.to_owned(),
groups_path,
locales_path,
config,
})
}
/// Spawn a new group
pub async fn auth_new_group(self: &Arc<Self>, opts: NewGroupOptions) -> Result<GroupHandle, GroupError> {
pub async fn auth_new_group(&self, opts: NewGroupOptions) -> Result<(), GroupError> {
let registration = Registration::new(&opts.server)
.client_name("group-actor")
.force_login(true)
@ -67,18 +125,18 @@ impl ConfigStore {
let client = elefren::helpers::cli::authenticate(registration).await?;
let appdata = client.data.clone();
let data = GroupConfig::new(opts.acct.clone(), appdata);
let group_dir = self.groups_path.join(&opts.acct);
GroupConfig::initialize_by_appdata(opts.acct.clone(), appdata, group_dir).await?;
// save & persist
self.set_group_config(data.clone()).await?;
let group_account = match client.verify_credentials().await {
match client.verify_credentials().await {
Ok(account) => {
info!(
"Group account verified: @{}, \"{}\"",
account.acct, account.display_name
);
account
}
Err(e) => {
error!("Group @{} auth error: {}", opts.acct, e);
@ -86,19 +144,14 @@ impl ConfigStore {
}
};
Ok(GroupHandle {
group_account,
client,
config: data,
store: self.clone(),
})
Ok(())
}
/// Re-auth an existing group
pub async fn reauth_group(self: &Arc<Self>, acct: &str) -> Result<GroupHandle, GroupError> {
let groups = self.data.read().await;
let mut config = groups.get_group_config(acct).ok_or(GroupError::GroupNotExist)?.clone();
drop(groups);
pub async fn reauth_group(&self, acct: &str) -> Result<(), GroupError> {
let group_dir = self.groups_path.join(&acct);
let mut config = GroupConfig::from_dir(group_dir, &self.config).await?;
println!("--- Re-authenticating bot user @{} ---", acct);
let registration = Registration::new(config.get_appdata().base.to_string())
@ -114,9 +167,9 @@ impl ConfigStore {
let appdata = client.data.clone();
config.set_appdata(appdata);
self.set_group_config(config.clone()).await?;
config.save_if_needed(true).await?;
let group_account = match client.verify_credentials().await {
let _group_account = match client.verify_credentials().await {
Ok(account) => {
info!(
"Group account verified: @{}, \"{}\"",
@ -130,99 +183,136 @@ impl ConfigStore {
}
};
Ok(GroupHandle {
group_account,
client,
config,
store: self.clone(),
})
Ok(())
}
pub async fn find_locales(&mut self) {
// Load the default locale, it will be used as fallback to fill-in missing keys
self.load_locale(DEFAULT_LOCALE_NAME, DEFAULT_LOCALE_JSON, true);
if !self.locales_path.is_dir() {
debug!("No locales path set!");
return;
}
let entries = match std::fs::read_dir(&self.locales_path) {
Ok(ee) => ee,
Err(e) => {
warn!("Error listing locales: {}", e);
return;
}
};
for e in entries {
if let Ok(e) = e {
let path = e.path();
if path.is_file() && path.extension().unwrap_or_default().to_string_lossy() == "json" {
let filename = path.file_name().unwrap_or_default().to_string_lossy();
debug!("Loading locale {}", filename);
match tokio::fs::read(&path).await {
Ok(f) => {
let locale_name = path.file_stem().unwrap_or_default().to_string_lossy();
self.load_locale(&locale_name, &String::from_utf8_lossy(&f), false);
}
Err(e) => {
error!("Failed to read locale file {}: {}", path.display(), e);
}
}
}
}
}
}
fn load_locale(&mut self, locale_name: &str, locale_json: &str, is_default: bool) {
if let Ok(mut tr) = json5::from_str::<TranslationTable>(locale_json) {
debug!("Loaded locale: {}", locale_name);
if !is_default {
let def_tr = self.config.tr.get(DEFAULT_LOCALE_NAME).expect("Default locale not loaded!");
for (k, v) in def_tr.entries() {
if !tr.translation_exists(k) {
if self.config.validate_locales {
warn!(
"locale \"{}\" is missing \"{}\", default: {:?}",
locale_name,
k,
def_tr.get_translation_raw(k).unwrap()
);
}
tr.add_translation(k, v);
}
}
}
self.config.tr.insert(locale_name.to_owned(), tr);
} else {
error!("Failed to parse locale {}", locale_name);
}
}
/// Spawn existing group using saved creds
pub async fn spawn_groups(self: Arc<Self>) -> Vec<GroupHandle> {
let groups = self.data.read().await.clone();
let groups_iter = groups.groups.into_values();
pub async fn spawn_groups(self) -> Result<Vec<GroupHandle>, GroupError> {
info!("Starting group services for groups in {}", self.groups_path.display());
let dirs = std::fs::read_dir(&self.groups_path)?;
let config = Arc::new(self.config);
// Connect in parallel
futures::stream::iter(groups_iter)
.map(|gc| async {
if !gc.is_enabled() {
debug!("Group @{} is DISABLED", gc.get_acct());
return None;
}
Ok(futures::stream::iter(dirs)
.map(|entry_maybe: Result<std::fs::DirEntry, std::io::Error>| async {
match entry_maybe {
Ok(entry) => {
let gc = GroupConfig::from_dir(entry.path(), &config).await.ok()?;
debug!("Connecting to @{}", gc.get_acct());
if !gc.is_enabled() {
debug!("Group @{} is DISABLED", gc.get_acct());
return None;
}
let client = FediClient::from(gc.get_appdata().clone());
debug!("Connecting to @{}", gc.get_acct());
let my_account = match client.verify_credentials().await {
Ok(account) => {
info!(
"Group account verified: @{}, \"{}\"",
account.acct, account.display_name
);
account
let client = FediClient::from(gc.get_appdata().clone());
let my_account = match client.verify_credentials().await {
Ok(account) => {
info!(
"Group account verified: @{}, \"{}\"",
account.acct, account.display_name
);
account
}
Err(e) => {
error!("Group @{} auth error: {}", gc.get_acct(), e);
return None;
}
};
Some(GroupHandle {
group_account: my_account,
client,
config: gc,
cc: config.clone(),
internal: GroupInternal::default(),
})
}
Err(e) => {
error!("Group @{} auth error: {}", gc.get_acct(), e);
return None;
error!("{}", e);
None
}
};
Some(GroupHandle {
group_account: my_account,
client,
config: gc,
store: self.clone(),
})
}
})
.buffer_unordered(8)
.collect::<Vec<_>>()
.await
.into_iter()
.flatten()
.collect()
.collect())
}
pub async fn group_exists(&self, acct : &str) -> bool {
self.data.read().await.groups.contains_key(acct)
}
/*
pub(crate) async fn get_group_config(&self, group: &str) -> Option<GroupConfig> {
let c = self.data.read().await;
c.get_group_config(group).cloned()
}
*/
//noinspection RsSelfConvention
/// Set group config to the store. The store then saved.
pub(crate) async fn set_group_config(&self, config: GroupConfig) -> Result<(), GroupError> {
trace!("Locking mutex");
if let Ok(mut data) = tokio::time::timeout(Duration::from_secs(1), self.data.write()).await {
trace!("Locked");
data.set_group_config(config);
trace!("Writing file");
self.persist(&data).await?;
} else {
error!("DEADLOCK? Timeout waiting for data RW Lock in settings store");
}
Ok(())
}
/// Persist the store
async fn persist(&self, data: &Config) -> Result<(), GroupError> {
tokio::fs::write(
&self.store_path,
if self.save_pretty {
serde_json::to_string_pretty(&data)
} else {
serde_json::to_string(&data)
}?
.as_bytes(),
)
.await?;
Ok(())
pub async fn group_exists(&self, acct: &str) -> bool {
self.store_path.join(acct).join("config.json").is_file()
}
}
@ -232,19 +322,3 @@ fn make_scopes() -> Scopes {
| Scopes::write(scopes::Write::Media)
| Scopes::write(scopes::Write::Follows)
}
// trait TapOk<T> {
// fn tap_ok<F: FnOnce(&T)>(self, f: F) -> Self;
// }
//
// impl<T, E> TapOk<T> for Result<T, E> {
// fn tap_ok<F: FnOnce(&T)>(self, f: F) -> Self {
// match self {
// Ok(v) => {
// f(&v);
// Ok(v)
// }
// Err(e) => Err(e)
// }
// }
// }

83
src/tr.rs Normal file
View file

@ -0,0 +1,83 @@
//! magic for custom translations and strings
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TranslationTable {
#[serde(flatten)]
entries: HashMap<String, String>,
}
impl TranslationTable {
#[allow(unused)]
pub fn new() -> Self {
Self::default()
}
/// Iterate all entries
pub fn entries(&self) -> impl Iterator<Item = (&String, &String)> {
self.entries.iter()
}
pub fn get_translation_raw(&self, key: &str) -> Option<&str> {
self.entries.get(key).map(|s| s.as_str())
}
/// Add or update a translation
pub fn add_translation(&mut self, key: impl ToString, subs: impl ToString) {
self.entries.insert(key.to_string(), subs.to_string());
}
pub fn translation_exists(&self, key: &str) -> bool {
self.entries.contains_key(key)
}
pub fn subs(&self, key: &str, substitutions: &[&str]) -> String {
match self.entries.get(key) {
Some(s) => {
// TODO optimize
let mut s = s.clone();
for pair in substitutions.chunks(2) {
if pair.len() != 2 {
continue;
}
s = s.replace(&format!("{{{}}}", pair[0]), pair[1]);
}
s
}
None => key.to_owned(),
}
}
}
#[cfg(test)]
mod tests {
use crate::tr::TranslationTable;
#[test]
fn deser_tr_table() {
let tr: TranslationTable = serde_json::from_str(r#"{"foo":"bar"}"#).unwrap();
assert_eq!("bar", tr.subs("foo", &[]));
assert_eq!("xxx", tr.subs("xxx", &[]));
}
#[test]
fn subs() {
let mut tr = TranslationTable::new();
tr.add_translation("hello_user", "Hello, {user}!");
assert_eq!("Hello, James!", tr.subs("hello_user", &["user", "James"]));
}
}
#[macro_export]
macro_rules! tr {
($tr_haver:expr, $key:literal) => {
$tr_haver.tr().subs($key, &[])
};
($tr_haver:expr, $key:literal, $($k:tt=$value:expr),*) => {
$tr_haver.tr().subs($key, &[
$(stringify!($k), $value),*
])
};
}

View file

@ -19,8 +19,8 @@ impl<V, E: Error> LogError for Result<V, E> {
}
}
pub(crate) fn acct_to_server(acct: &str) -> Option<&str> {
acct.trim_start_matches('@').split('@').nth(1)
pub(crate) fn acct_to_server(acct: &str) -> Option<String> {
acct.trim_start_matches('@').split('@').nth(1).map(|s| s.to_lowercase())
}
pub(crate) fn normalize_acct(acct: &str, group: &str) -> Result<String, GroupError> {
@ -45,8 +45,8 @@ mod test {
#[test]
fn test_acct_to_server() {
assert_eq!(Some("novak"), acct_to_server("pepa@novak"));
assert_eq!(Some("banana.co.uk"), acct_to_server("@pepa@banana.co.uk"));
assert_eq!(Some("novak".to_string()), acct_to_server("pepa@novak"));
assert_eq!(Some("banana.co.uk".to_string()), acct_to_server("@pepa@banana.co.uk"));
assert_eq!(None, acct_to_server("probably_local"));
}
@ -82,11 +82,11 @@ mod test {
);
assert_eq!(
Ok("piggo@piggo.space".into()),
normalize_acct("piGGgo@pIggo.spaCe", "uhh")
normalize_acct("piGGo@pIggo.spaCe", "uhh")
);
assert_eq!(
Ok("piggo@banana.nana".into()),
normalize_acct("piGGgo", "foo@baNANA.nana")
normalize_acct("piGGo", "foo@baNANA.nana")
);
assert_eq!(Err(GroupError::BadConfig("_".into())), normalize_acct("piggo", "uhh"));
}
@ -105,9 +105,7 @@ impl VisExt for Visibility {
fn make_unlisted(self) -> Self {
match self {
Visibility::Public => {
Visibility::Unlisted
}
Visibility::Public => Visibility::Unlisted,
other => other,
}
}