Compare commits
13
Commits
v0.3.0
..
e1ac2777f3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1ac2777f3 | ||
|
|
900f499932 | ||
|
|
ebcf12e46c
|
||
|
|
0d37425c32
|
||
|
|
7f14dbc215
|
||
|
|
5fb5f087d6
|
||
|
|
6ff0e3653d
|
||
|
|
4ddc26c6ca
|
||
|
|
63c4c5f2e8 | ||
|
|
305d91d1dc | ||
|
|
e76da157b3 | ||
|
|
239e15afdd | ||
|
|
bd47a004bf
|
@@ -1,5 +1,20 @@
|
||||
# Changelog
|
||||
|
||||
## 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)
|
||||
|
||||
Generated
+68
-1
@@ -328,13 +328,14 @@ checksum = "e88a8acf291dafb59c2d96e8f59828f3838bb1a70398823ade51a84de6a6deed"
|
||||
|
||||
[[package]]
|
||||
name = "fedigroups"
|
||||
version = "0.3.0"
|
||||
version = "0.4.3"
|
||||
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"
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "fedigroups"
|
||||
version = "0.3.0"
|
||||
version = "0.4.3"
|
||||
authors = ["Ondřej Hruška <ondra@ondrovo.com>"]
|
||||
edition = "2018"
|
||||
publish = false
|
||||
@@ -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"
|
||||
|
||||
@@ -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 the directory `groups.d/account@server/`, which is created if missing.
|
||||
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,61 +44,93 @@ In case you need to re-authenticate an existing group, do the same but use `-A`
|
||||
|
||||
### Editing config
|
||||
|
||||
**JSON does not support comments! Remove comments before using examples copied from this guide!**
|
||||
**Staring v0.4.1, the config files support comments!**
|
||||
|
||||
A typical setup could look like this:
|
||||
|
||||
```
|
||||
├── groups.d
|
||||
├── groups
|
||||
│ ├── betty@piggo.space
|
||||
│ │ ├── config.json
|
||||
│ │ ├── control.json
|
||||
│ │ └── state.json
|
||||
│ └── chatterbox@botsin.space
|
||||
│ ├── config.json
|
||||
│ ├── control.json
|
||||
│ └── state.json
|
||||
│ ├── 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
|
||||
{
|
||||
"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 good enough.
|
||||
- This file applies to all groups
|
||||
- Prior to 0.3, the groups were also configured here.
|
||||
- Running 0.3+ with the old file will produce an error, you need to update the config before continuing - move groups to subfolders
|
||||
- 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_catchup_notifs": 30,
|
||||
// Max number of missed statuses to process after connect
|
||||
max_catchup_statuses: 50,
|
||||
"max_catchup_statuses": 50,
|
||||
// Delay between fetched pages when catching up
|
||||
delay_fetch_page_s: 0.25,
|
||||
"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_after_post_s": 0.0,
|
||||
// Delay before trying to re-connect after the server closed the socket
|
||||
delay_reopen_closed_s: 0.5,
|
||||
"delay_reopen_closed_s": 0.5,
|
||||
// Delay before trying to re-connect after an error
|
||||
delay_reopen_error_s: 5.0,
|
||||
"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,
|
||||
"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,
|
||||
"socket_retire_time_s": 120.0
|
||||
}
|
||||
```
|
||||
|
||||
#### Per-group config
|
||||
|
||||
Each group is stored as a sub-directory of `groups.d/`. The sub-directories are normally named after their accounts,
|
||||
but this is not required. For example, `groups.d/betty@piggo.space/`.
|
||||
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.
|
||||
|
||||
@@ -111,6 +143,7 @@ Only the `config.json` file with credentials is required; the others will be cre
|
||||
- `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!**
|
||||
|
||||
@@ -119,7 +152,7 @@ Note that changing config externally requires a restart. It's better to use slas
|
||||
|
||||
When adding hashtags, *they must be entered as lowercase* and without the `#` symbol!
|
||||
|
||||
The file formats are quite self-explanatory (again, remove comments before copying, JSON does not support comments!)
|
||||
The file formats are quite self-explanatory:
|
||||
|
||||
**config.json**
|
||||
|
||||
@@ -127,9 +160,11 @@ The file formats are quite self-explanatory (again, remove comments before copyi
|
||||
{
|
||||
// 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
|
||||
// Saved mastodon API credentials, this is created when authenticating the group.
|
||||
"appdata": {
|
||||
"base": "https://myserver.xyz",
|
||||
"client_id": "...",
|
||||
@@ -180,7 +215,7 @@ Internal use, millisecond timestamps of the last-seen status and notification.
|
||||
### Running
|
||||
|
||||
To run the group service, simply run it with no arguments.
|
||||
It will read the `groups.json` file (if present), find groups in `groups.d/` and start the services for you.
|
||||
It will read the `groups.json` file (if present), find groups in `groups/` and start the services for you.
|
||||
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"ping_response": "pong, toto je fedigroups verze {version}"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
+6
-4
@@ -135,13 +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);
|
||||
@@ -563,6 +563,8 @@ mod test {
|
||||
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 {
|
||||
@@ -590,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"));
|
||||
|
||||
@@ -19,6 +19,8 @@ pub enum GroupError {
|
||||
#[error(transparent)]
|
||||
Serializer(#[from] serde_json::Error),
|
||||
#[error(transparent)]
|
||||
Serializer5(#[from] json5::Error),
|
||||
#[error(transparent)]
|
||||
Elefren(#[from] elefren::Error),
|
||||
}
|
||||
|
||||
|
||||
+183
-200
@@ -2,18 +2,25 @@ use std::cmp::Ordering;
|
||||
use std::collections::HashSet;
|
||||
use std::time::Duration;
|
||||
|
||||
use elefren::{FediClient, SearchType, StatusBuilder};
|
||||
use elefren::entities::account::Account;
|
||||
use elefren::entities::prelude::Status;
|
||||
use elefren::status_builder::Visibility;
|
||||
use elefren::{FediClient, SearchType, StatusBuilder};
|
||||
|
||||
use crate::command::{StatusCommand, RE_NOBOT_TAG};
|
||||
use crate::command::{RE_NOBOT_TAG, StatusCommand};
|
||||
use crate::error::GroupError;
|
||||
use crate::group_handler::GroupHandle;
|
||||
use crate::store::group_config::GroupConfig;
|
||||
use crate::store::CommonConfig;
|
||||
use crate::store::group_config::GroupConfig;
|
||||
use crate::tr::TranslationTable;
|
||||
use crate::utils;
|
||||
use crate::utils::{normalize_acct, LogError};
|
||||
use crate::utils::{LogError, normalize_acct, VisExt};
|
||||
|
||||
use crate::{
|
||||
grp_debug,
|
||||
grp_warn,
|
||||
grp_info
|
||||
};
|
||||
|
||||
pub struct ProcessMention<'a> {
|
||||
status: Status,
|
||||
@@ -26,25 +33,28 @@ pub struct ProcessMention<'a> {
|
||||
status_user_id: String,
|
||||
can_write: bool,
|
||||
is_admin: bool,
|
||||
replies: Vec<String>,
|
||||
announcements: Vec<String>,
|
||||
replies: String,
|
||||
announcements: String,
|
||||
do_boost_prev_post: bool,
|
||||
want_markdown: bool,
|
||||
}
|
||||
|
||||
impl<'a> ProcessMention<'a> {
|
||||
fn tr(&self) -> &TranslationTable {
|
||||
self.config.tr()
|
||||
}
|
||||
|
||||
async fn lookup_acct_id(&self, acct: &str, followed: bool) -> Result<Option<String>, GroupError> {
|
||||
debug!("Looking up user ID by acct: {}", acct);
|
||||
grp_debug!(self, "Looking up user ID by acct: {}", acct);
|
||||
|
||||
match tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
self.client
|
||||
.search_v2(acct, !followed, Some(SearchType::Accounts), Some(1), followed),
|
||||
)
|
||||
.await
|
||||
.await
|
||||
{
|
||||
Err(_) => {
|
||||
warn!("Account lookup timeout!");
|
||||
grp_warn!(self, "Account lookup timeout!");
|
||||
Err(GroupError::ApiTimeout)
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
@@ -56,14 +66,14 @@ impl<'a> ProcessMention<'a> {
|
||||
// XXX limit is 1!
|
||||
let acct_normalized = normalize_acct(&item.acct, &self.group_acct)?;
|
||||
if acct_normalized == acct {
|
||||
debug!("Search done, account found: {}", item.acct);
|
||||
grp_debug!(self, "Search done, account found: {}", item.acct);
|
||||
return Ok(Some(item.id));
|
||||
} else {
|
||||
warn!("Found wrong account: {}", item.acct);
|
||||
grp_warn!(self, "Found wrong account: {}", item.acct);
|
||||
}
|
||||
}
|
||||
|
||||
debug!("Search done, nothing found");
|
||||
grp_debug!(self, "Search done, nothing found");
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
@@ -72,9 +82,11 @@ impl<'a> ProcessMention<'a> {
|
||||
fn append_admin_list_to_reply(&mut self) {
|
||||
let mut admins = self.config.get_admins().collect::<Vec<_>>();
|
||||
admins.sort();
|
||||
let mut to_add = String::new();
|
||||
for a in admins {
|
||||
self.replies.push(format!("- {}", a));
|
||||
to_add.push_str(&crate::tr!(self, "user_list_entry", user = a));
|
||||
}
|
||||
self.add_reply(&to_add);
|
||||
}
|
||||
|
||||
fn append_member_list_to_reply(&mut self) {
|
||||
@@ -83,24 +95,26 @@ impl<'a> ProcessMention<'a> {
|
||||
members.extend(admins.iter());
|
||||
members.sort();
|
||||
members.dedup();
|
||||
let mut to_add = String::new();
|
||||
for m in members {
|
||||
self.replies.push(if admins.contains(&m) {
|
||||
format!("- {} [admin]", m)
|
||||
to_add.push_str(&if admins.contains(&m) {
|
||||
crate::tr!(self, "user_list_entry_admin", user=m)
|
||||
} else {
|
||||
format!("- {}", m)
|
||||
crate::tr!(self, "user_list_entry", user=m)
|
||||
});
|
||||
}
|
||||
self.add_reply(&to_add);
|
||||
}
|
||||
|
||||
async fn follow_user_by_id(&self, id: &str) -> Result<(), GroupError> {
|
||||
debug!("Trying to follow user #{}", id);
|
||||
grp_debug!(self, "Trying to follow user #{}", id);
|
||||
self.client.follow(id).await?;
|
||||
self.delay_after_post().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn unfollow_user_by_id(&self, id: &str) -> Result<(), GroupError> {
|
||||
debug!("Trying to unfollow user #{}", id);
|
||||
grp_debug!(self, "Trying to unfollow user #{}", id);
|
||||
self.client.unfollow(id).await?;
|
||||
self.delay_after_post().await;
|
||||
Ok(())
|
||||
@@ -111,7 +125,7 @@ impl<'a> ProcessMention<'a> {
|
||||
let status_acct = normalize_acct(&status.account.acct, &group_acct)?.to_string();
|
||||
|
||||
if gh.config.is_banned(&status_acct) {
|
||||
warn!("Status author {} is banned!", status_acct);
|
||||
grp_warn!(gh, "Status author {} is banned!", status_acct);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -122,10 +136,9 @@ impl<'a> ProcessMention<'a> {
|
||||
cc: &gh.cc,
|
||||
can_write: gh.config.can_write(&status_acct),
|
||||
is_admin: gh.config.is_admin(&status_acct),
|
||||
replies: vec![],
|
||||
announcements: vec![],
|
||||
replies: String::new(),
|
||||
announcements: String::new(),
|
||||
do_boost_prev_post: false,
|
||||
want_markdown: false,
|
||||
group_acct,
|
||||
status_acct,
|
||||
status,
|
||||
@@ -136,16 +149,17 @@ impl<'a> ProcessMention<'a> {
|
||||
}
|
||||
|
||||
async fn reblog_status(&self) {
|
||||
self.client.reblog(&self.status.id).await.log_error("Failed to reblog status");
|
||||
self.client.reblog(&self.status.id)
|
||||
.await.log_error("Failed to reblog status");
|
||||
self.delay_after_post().await;
|
||||
}
|
||||
|
||||
fn add_reply(&mut self, line: impl AsRef<str>) {
|
||||
self.replies.push(line.as_ref().trim_matches(' ').to_string())
|
||||
self.replies.push_str(line.as_ref())
|
||||
}
|
||||
|
||||
fn add_announcement(&mut self, line: impl AsRef<str>) {
|
||||
self.announcements.push(line.as_ref().trim_matches(' ').to_string())
|
||||
self.announcements.push_str(line.as_ref())
|
||||
}
|
||||
|
||||
async fn handle(mut self) -> Result<(), GroupError> {
|
||||
@@ -155,11 +169,14 @@ impl<'a> ProcessMention<'a> {
|
||||
self.handle_post_with_no_commands().await;
|
||||
} else {
|
||||
if commands.contains(&StatusCommand::Ignore) {
|
||||
debug!("Notif ignored because of ignore command");
|
||||
grp_debug!(self, "Notif ignored because of ignore command");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for cmd in commands {
|
||||
if !self.replies.is_empty() {
|
||||
self.replies.push('\n'); // make sure there's a newline between batched commands.
|
||||
}
|
||||
match cmd {
|
||||
StatusCommand::Undo => {
|
||||
self.cmd_undo().await.log_error("Error handling undo cmd");
|
||||
@@ -207,7 +224,7 @@ impl<'a> ProcessMention<'a> {
|
||||
self.cmd_grant_admin(&u).await.log_error("Error handling grant-admin cmd");
|
||||
}
|
||||
StatusCommand::RemoveAdmin(u) => {
|
||||
self.cmd_revoke_member(&u).await.log_error("Error handling grant-admin cmd");
|
||||
self.cmd_revoke_admin(&u).await.log_error("Error handling grant-admin cmd");
|
||||
}
|
||||
StatusCommand::OpenGroup => {
|
||||
self.cmd_open_group().await;
|
||||
@@ -248,33 +265,29 @@ impl<'a> ProcessMention<'a> {
|
||||
self.delay_after_post().await;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Can't reblog: {}", e);
|
||||
grp_warn!(self, "Can't reblog: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !self.replies.is_empty() {
|
||||
let mut msg = self.replies.join("\n");
|
||||
debug!("r={}", msg);
|
||||
let mut msg = std::mem::take(&mut self.replies);
|
||||
grp_debug!(self, "r={}", msg);
|
||||
|
||||
if self.want_markdown {
|
||||
apply_trailing_hashtag_pleroma_bug_workaround(&mut msg);
|
||||
}
|
||||
self.apply_trailing_hashtag_pleroma_bug_workaround(&mut msg);
|
||||
|
||||
let mention = format!("@{user} ", user = self.status_acct);
|
||||
let mention = crate::tr!(self, "mention_prefix", user = &self.status_acct);
|
||||
self.send_reply_multipart(mention, msg).await?;
|
||||
}
|
||||
|
||||
if !self.announcements.is_empty() {
|
||||
let mut msg = self.announcements.join("\n");
|
||||
debug!("a={}", msg);
|
||||
let mut msg = std::mem::take(&mut self.announcements);
|
||||
grp_debug!(self, "a={}", msg);
|
||||
|
||||
if self.want_markdown {
|
||||
apply_trailing_hashtag_pleroma_bug_workaround(&mut msg);
|
||||
}
|
||||
self.apply_trailing_hashtag_pleroma_bug_workaround(&mut msg);
|
||||
|
||||
let msg = format!("**📢 Group announcement**\n{msg}", msg = msg);
|
||||
let msg = crate::tr!(self, "group_announcement", message = &msg);
|
||||
self.send_announcement_multipart(&msg).await?;
|
||||
}
|
||||
|
||||
@@ -288,11 +301,7 @@ impl<'a> ProcessMention<'a> {
|
||||
for p in parts {
|
||||
if let Ok(post) = StatusBuilder::new()
|
||||
.status(p)
|
||||
.content_type(if self.want_markdown {
|
||||
"text/markdown"
|
||||
} else {
|
||||
"text/plain"
|
||||
})
|
||||
.content_type("text/markdown")
|
||||
.in_reply_to(&parent)
|
||||
.visibility(Visibility::Direct)
|
||||
.build()
|
||||
@@ -336,24 +345,45 @@ impl<'a> ProcessMention<'a> {
|
||||
}
|
||||
|
||||
async fn handle_post_with_no_commands(&mut self) {
|
||||
debug!("No commands in post");
|
||||
if self.status.in_reply_to_id.is_none() {
|
||||
if self.can_write {
|
||||
grp_debug!(self, "No commands in post");
|
||||
|
||||
if self.status.visibility.is_private() {
|
||||
grp_debug!(self, "Mention is private, discard");
|
||||
return;
|
||||
}
|
||||
|
||||
if self.can_write {
|
||||
if self.status.in_reply_to_id.is_none() {
|
||||
// Someone tagged the group in OP, boost it.
|
||||
info!("Boosting OP mention");
|
||||
grp_info!(self, "Boosting OP mention");
|
||||
// tokio::time::sleep(DELAY_BEFORE_ACTION).await;
|
||||
self.reblog_status().await;
|
||||
// Otherwise, don't react
|
||||
} else {
|
||||
warn!("User @{} can't post to group!", self.status_acct);
|
||||
// Check for tags
|
||||
let tags = crate::command::parse_status_tags(&self.status.content);
|
||||
grp_debug!(self, "Tags in mention: {:?}", tags);
|
||||
|
||||
for t in tags {
|
||||
if self.config.is_tag_followed(&t) {
|
||||
grp_info!(self, "REBLOG #{} STATUS", t);
|
||||
self.client.reblog(&self.status.id).await.log_error("Failed to reblog");
|
||||
self.delay_after_post().await;
|
||||
return;
|
||||
} else {
|
||||
grp_debug!(self, "#{} is not a group tag", t);
|
||||
}
|
||||
}
|
||||
|
||||
grp_debug!(self, "Not OP & no tags, ignore mention");
|
||||
}
|
||||
// Otherwise, don't react
|
||||
} else {
|
||||
debug!("Not OP, ignore mention");
|
||||
grp_warn!(self, "User @{} can't post to group!", self.status_acct);
|
||||
}
|
||||
}
|
||||
|
||||
async fn cmd_announce(&mut self, msg: String) {
|
||||
info!("Sending PSA");
|
||||
grp_info!(self, "Sending PSA");
|
||||
self.add_announcement(msg);
|
||||
}
|
||||
|
||||
@@ -361,53 +391,53 @@ impl<'a> ProcessMention<'a> {
|
||||
if self.can_write {
|
||||
self.do_boost_prev_post = self.status.in_reply_to_id.is_some();
|
||||
} else {
|
||||
warn!("User @{} can't share to group!", self.status_acct);
|
||||
grp_warn!(self, "User @{} can't share to group!", self.status_acct);
|
||||
}
|
||||
}
|
||||
|
||||
async fn cmd_optout(&mut self) {
|
||||
if self.is_admin {
|
||||
self.add_reply("Group admins can't opt-out.");
|
||||
self.add_reply(crate::tr!(self, "cmd_optout_fail_admin_cant"));
|
||||
} else if self.config.is_member(&self.status_acct) {
|
||||
self.add_reply("Group members can't opt-out. You have to leave first.");
|
||||
self.add_reply(crate::tr!(self, "cmd_optout_fail_member_cant"));
|
||||
} else {
|
||||
self.config.set_optout(&self.status_acct, true);
|
||||
self.add_reply("Your posts will no longer be shared to the group.");
|
||||
self.add_reply(crate::tr!(self, "cmd_optout_ok"));
|
||||
}
|
||||
}
|
||||
|
||||
async fn cmd_optin(&mut self) {
|
||||
if self.is_admin {
|
||||
self.add_reply("Opt-in has no effect for admins.");
|
||||
self.add_reply(crate::tr!(self, "cmd_optin_fail_admin_cant"));
|
||||
} else if self.config.is_member(&self.status_acct) {
|
||||
self.add_reply("Opt-in has no effect for members.");
|
||||
self.add_reply(crate::tr!(self, "cmd_optin_fail_member_cant"));
|
||||
} else {
|
||||
self.config.set_optout(&self.status_acct, false);
|
||||
self.add_reply("Your posts can now be shared to the group.");
|
||||
self.add_reply(crate::tr!(self, "cmd_optin_ok"));
|
||||
}
|
||||
}
|
||||
|
||||
async fn cmd_undo(&mut self) -> Result<(), GroupError> {
|
||||
if let (Some(ref parent_account_id), Some(ref parent_status_id)) =
|
||||
(&self.status.in_reply_to_account_id, &self.status.in_reply_to_id)
|
||||
(&self.status.in_reply_to_account_id, &self.status.in_reply_to_id)
|
||||
{
|
||||
if parent_account_id == &self.group_account.id {
|
||||
// This is a post sent by the group user, likely an announcement.
|
||||
// Undo here means delete it.
|
||||
if self.is_admin {
|
||||
info!("Deleting group post #{}", parent_status_id);
|
||||
grp_info!(self, "Deleting group post #{}", parent_status_id);
|
||||
self.client.delete_status(parent_status_id).await?;
|
||||
self.delay_after_post().await;
|
||||
} else {
|
||||
warn!("Only admin can delete posts made by the group user");
|
||||
grp_warn!(self, "Only admin can delete posts made by the group user");
|
||||
}
|
||||
} else if self.is_admin || parent_account_id == &self.status_user_id {
|
||||
info!("Un-reblogging post #{}", parent_status_id);
|
||||
grp_info!(self, "Un-reblogging post #{}", parent_status_id);
|
||||
// User unboosting own post boosted by accident, or admin doing it
|
||||
self.client.unreblog(parent_status_id).await?;
|
||||
self.delay_after_post().await;
|
||||
} else {
|
||||
warn!("Only the author and admins can undo reblogs");
|
||||
grp_warn!(self, "Only the author and admins can undo reblogs");
|
||||
// XXX this means when someone /b's someone else's post to a group,
|
||||
// they then can't reverse that (only admin or the post's author can)
|
||||
}
|
||||
@@ -422,18 +452,18 @@ impl<'a> ProcessMention<'a> {
|
||||
if !self.config.is_banned(&u) {
|
||||
match self.config.ban_user(&u, true) {
|
||||
Ok(_) => {
|
||||
self.add_reply(format!("User {} banned from group!", u));
|
||||
self.add_reply(crate::tr!(self, "cmd_ban_user_ok", user = &u));
|
||||
self.unfollow_by_acct(&u).await.log_error("Failed to unfollow banned user");
|
||||
}
|
||||
Err(e) => {
|
||||
self.add_reply(format!("Failed to ban user {}: {}", u, e));
|
||||
self.add_reply(crate::tr!(self, "cmd_error", cause = &e.to_string()));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.add_reply(format!("No action, user {} is already banned", u));
|
||||
self.add_reply(crate::tr!(self, "cmd_ban_user_fail_already", user = &u));
|
||||
}
|
||||
} else {
|
||||
warn!("Ignore cmd, user not admin");
|
||||
grp_warn!(self, "Ignore cmd, user not admin");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -444,18 +474,18 @@ impl<'a> ProcessMention<'a> {
|
||||
if self.config.is_banned(&u) {
|
||||
match self.config.ban_user(&u, false) {
|
||||
Ok(_) => {
|
||||
self.add_reply(format!("User {} un-banned!", u));
|
||||
self.add_reply(crate::tr!(self, "cmd_unban_user_ok", user = &u));
|
||||
// no announcement here
|
||||
}
|
||||
Err(_) => {
|
||||
unreachable!()
|
||||
Err(e) => {
|
||||
self.add_reply(crate::tr!(self, "cmd_error", cause = &e.to_string()));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.add_reply(format!("No action, user {} is not banned", u));
|
||||
self.add_reply(crate::tr!(self, "cmd_unban_user_fail_already", user = &u));
|
||||
}
|
||||
} else {
|
||||
warn!("Ignore cmd, user not admin");
|
||||
grp_warn!(self, "Ignore cmd, user not admin");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -465,17 +495,17 @@ impl<'a> ProcessMention<'a> {
|
||||
if !self.config.is_server_banned(s) {
|
||||
match self.config.ban_server(s, true) {
|
||||
Ok(_) => {
|
||||
self.add_reply(format!("Server {} banned from group!", s));
|
||||
self.add_reply(crate::tr!(self, "cmd_ban_server_ok", server = s));
|
||||
}
|
||||
Err(e) => {
|
||||
self.add_reply(format!("Failed to ban server {}: {}", s, e));
|
||||
self.add_reply(crate::tr!(self, "cmd_error", cause = &e.to_string()));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.add_reply(format!("No action, server {} already banned", s));
|
||||
self.add_reply(crate::tr!(self, "cmd_ban_server_fail_already", server = s));
|
||||
}
|
||||
} else {
|
||||
warn!("Ignore cmd, user not admin");
|
||||
grp_warn!(self, "Ignore cmd, user not admin");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,17 +514,17 @@ impl<'a> ProcessMention<'a> {
|
||||
if self.config.is_server_banned(s) {
|
||||
match self.config.ban_server(s, false) {
|
||||
Ok(_) => {
|
||||
self.add_reply(format!("Server {} un-banned!", s));
|
||||
self.add_reply(crate::tr!(self, "cmd_unban_server_ok", server = s));
|
||||
}
|
||||
Err(e) => {
|
||||
self.add_reply(format!("Unexpected error occured: {}", e));
|
||||
self.add_reply(crate::tr!(self, "cmd_error", cause = &e.to_string()));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.add_reply(format!("No action, server {} is not banned", s));
|
||||
self.add_reply(crate::tr!(self, "cmd_unban_server_fail_already", server = s));
|
||||
}
|
||||
} else {
|
||||
warn!("Ignore cmd, user not admin");
|
||||
grp_warn!(self, "Ignore cmd, user not admin");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -504,15 +534,16 @@ impl<'a> ProcessMention<'a> {
|
||||
// Allow even if the user is already a member - that will trigger re-follow
|
||||
match self.config.set_member(&u, true) {
|
||||
Ok(_) => {
|
||||
self.add_reply(format!("User {} added to the group!", u));
|
||||
self.add_reply(crate::tr!(self, "cmd_add_user_ok", user = &u));
|
||||
// marked as member, now also follow the user
|
||||
self.follow_by_acct(&u).await.log_error("Failed to follow");
|
||||
}
|
||||
Err(e) => {
|
||||
self.add_reply(format!("Failed to add user {} to group: {}", u, e));
|
||||
self.add_reply(crate::tr!(self, "cmd_error", cause = &e.to_string()));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn!("Ignore cmd, user not admin");
|
||||
grp_warn!(self, "Ignore cmd, user not admin");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -522,15 +553,15 @@ impl<'a> ProcessMention<'a> {
|
||||
if self.is_admin {
|
||||
match self.config.set_member(&u, false) {
|
||||
Ok(_) => {
|
||||
self.add_reply(format!("User {} removed from the group.", u));
|
||||
self.add_reply(crate::tr!(self, "cmd_remove_user_ok", user = &u));
|
||||
self.unfollow_by_acct(&u).await.log_error("Failed to unfollow removed user");
|
||||
}
|
||||
Err(e) => {
|
||||
self.add_reply(format!("Unexpected error occured: {}", e));
|
||||
self.add_reply(crate::tr!(self, "cmd_error", cause = &e.to_string()));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn!("Ignore cmd, user not admin");
|
||||
grp_warn!(self, "Ignore cmd, user not admin");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -539,12 +570,12 @@ impl<'a> ProcessMention<'a> {
|
||||
if self.is_admin {
|
||||
if !self.config.is_tag_followed(&tag) {
|
||||
self.config.add_tag(&tag);
|
||||
self.add_reply(format!("Tag \"{}\" added to the group!", tag));
|
||||
self.add_reply(crate::tr!(self, "cmd_add_tag_ok", tag = &tag));
|
||||
} else {
|
||||
self.add_reply(format!("No action, \"{}\" is already a group tag", tag));
|
||||
self.add_reply(crate::tr!(self, "cmd_add_tag_fail_already", tag = &tag));
|
||||
}
|
||||
} else {
|
||||
warn!("Ignore cmd, user not admin");
|
||||
grp_warn!(self, "Ignore cmd, user not admin");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -552,12 +583,12 @@ impl<'a> ProcessMention<'a> {
|
||||
if self.is_admin {
|
||||
if self.config.is_tag_followed(&tag) {
|
||||
self.config.remove_tag(&tag);
|
||||
self.add_reply(format!("Tag \"{}\" removed from the group!", tag));
|
||||
self.add_reply(crate::tr!(self, "cmd_remove_tag_ok", tag = &tag));
|
||||
} else {
|
||||
self.add_reply(format!("No action, \"{}\" is not a group tag", tag));
|
||||
self.add_reply(crate::tr!(self, "cmd_remove_tag_fail_already", tag = &tag));
|
||||
}
|
||||
} else {
|
||||
warn!("Ignore cmd, user not admin");
|
||||
grp_warn!(self, "Ignore cmd, user not admin");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -570,38 +601,38 @@ impl<'a> ProcessMention<'a> {
|
||||
// try to make the config a little more sane, admins should be members
|
||||
let _ = self.config.set_member(&u, true);
|
||||
|
||||
self.add_reply(format!("User {} is now a group admin!", u));
|
||||
self.add_reply(crate::tr!(self, "cmd_admin_ok", user = &u));
|
||||
}
|
||||
Err(e) => {
|
||||
self.add_reply(format!("Failed to make user {} a group admin: {}", u, e));
|
||||
self.add_reply(crate::tr!(self, "cmd_error", cause = &e.to_string()));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.add_reply(format!("No action, \"{}\" is admin already", u));
|
||||
self.add_reply(crate::tr!(self, "cmd_admin_fail_already", user = &u));
|
||||
}
|
||||
} else {
|
||||
warn!("Ignore cmd, user not admin");
|
||||
grp_warn!(self, "Ignore cmd, user not admin");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cmd_revoke_member(&mut self, user: &str) -> Result<(), GroupError> {
|
||||
async fn cmd_revoke_admin(&mut self, user: &str) -> Result<(), GroupError> {
|
||||
let u = normalize_acct(user, &self.group_acct)?;
|
||||
if self.is_admin {
|
||||
if self.config.is_admin(&u) {
|
||||
match self.config.set_admin(&u, false) {
|
||||
Ok(_) => {
|
||||
self.add_reply(format!("User {} is no longer a group admin!", u));
|
||||
self.add_reply(crate::tr!(self, "cmd_unadmin_ok", user = &u));
|
||||
}
|
||||
Err(e) => {
|
||||
self.add_reply(format!("Failed to revoke {}'s group admin: {}", u, e));
|
||||
self.add_reply(crate::tr!(self, "cmd_error", cause = &e.to_string()));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.add_reply(format!("No action, user {} is not admin", u));
|
||||
self.add_reply(crate::tr!(self, "cmd_unadmin_fail_already", user = &u));
|
||||
}
|
||||
} else {
|
||||
warn!("Ignore cmd, user not admin");
|
||||
grp_warn!(self, "Ignore cmd, user not admin");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -610,12 +641,12 @@ impl<'a> ProcessMention<'a> {
|
||||
if self.is_admin {
|
||||
if self.config.is_member_only() {
|
||||
self.config.set_member_only(false);
|
||||
self.add_reply("Group changed to open-access");
|
||||
self.add_reply(crate::tr!(self, "cmd_open_resp"));
|
||||
} else {
|
||||
self.add_reply("No action, group is open-access already");
|
||||
self.add_reply(crate::tr!(self, "cmd_open_resp_already"));
|
||||
}
|
||||
} else {
|
||||
warn!("Ignore cmd, user not admin");
|
||||
grp_warn!(self, "Ignore cmd, user not admin");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -623,106 +654,71 @@ impl<'a> ProcessMention<'a> {
|
||||
if self.is_admin {
|
||||
if !self.config.is_member_only() {
|
||||
self.config.set_member_only(true);
|
||||
self.add_reply("Group changed to member-only");
|
||||
self.add_reply(crate::tr!(self, "cmd_close_resp"));
|
||||
} else {
|
||||
self.add_reply("No action, group is member-only already");
|
||||
self.add_reply(crate::tr!(self, "cmd_close_resp_already"));
|
||||
}
|
||||
} else {
|
||||
warn!("Ignore cmd, user not admin");
|
||||
grp_warn!(self, "Ignore cmd, user not admin");
|
||||
}
|
||||
}
|
||||
|
||||
async fn cmd_help(&mut self) {
|
||||
self.want_markdown = true;
|
||||
|
||||
let membership_line = if self.is_admin {
|
||||
"*You are an admin.*"
|
||||
crate::tr!(self, "help_membership_admin")
|
||||
} else if self.config.is_member(&self.status_acct) {
|
||||
"*You are a member.*"
|
||||
crate::tr!(self, "help_membership_member")
|
||||
} else if self.config.is_member_only() {
|
||||
"*You are not a member, ask one of the admins to add you.*"
|
||||
crate::tr!(self, "help_membership_guest_closed")
|
||||
} else {
|
||||
"*You are not a member, follow or use /join to join the group.*"
|
||||
crate::tr!(self, "help_membership_guest_open")
|
||||
};
|
||||
|
||||
if self.config.is_member_only() {
|
||||
self.add_reply(format!("This is a member-only group. {}", membership_line));
|
||||
self.add_reply(crate::tr!(self, "help_group_info_closed", membership = &membership_line));
|
||||
} else {
|
||||
self.add_reply(format!("This is a public-access group. {}", membership_line));
|
||||
self.add_reply(crate::tr!(self, "help_group_info_open", membership = &membership_line));
|
||||
}
|
||||
|
||||
self.add_reply(
|
||||
"\
|
||||
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",
|
||||
);
|
||||
self.add_reply(crate::tr!(self, "help_basic_commands"));
|
||||
|
||||
if self.is_admin {
|
||||
self.add_reply("`/members`, `/who` - show group members / admins");
|
||||
// undo is listed as an admin command
|
||||
} else {
|
||||
self.add_reply("`/admins` - show group admins");
|
||||
self.add_reply("`/undo` - un-boost your post (use in a reply)");
|
||||
if !self.is_admin {
|
||||
self.add_reply(crate::tr!(self, "help_member_commands"));
|
||||
}
|
||||
|
||||
// XXX when used on instance with small character limit, this won't fit!
|
||||
|
||||
if self.is_admin {
|
||||
self.add_reply(
|
||||
"\n\
|
||||
**Admin commands:**\n\
|
||||
`/ping` - check the group works\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",
|
||||
);
|
||||
self.add_reply(crate::tr!(self, "help_admin_commands"));
|
||||
}
|
||||
}
|
||||
|
||||
async fn cmd_list_members(&mut self) {
|
||||
self.want_markdown = true;
|
||||
if self.is_admin {
|
||||
self.add_reply("Group members:");
|
||||
self.add_reply(crate::tr!(self, "member_list_heading"));
|
||||
self.append_member_list_to_reply();
|
||||
} else {
|
||||
self.add_reply("Group admins:");
|
||||
self.add_reply(crate::tr!(self, "admin_list_heading"));
|
||||
self.append_admin_list_to_reply();
|
||||
}
|
||||
}
|
||||
|
||||
async fn cmd_list_tags(&mut self) {
|
||||
self.add_reply("Group tags:");
|
||||
self.want_markdown = true;
|
||||
self.add_reply(crate::tr!(self, "tag_list_heading"));
|
||||
let mut tags = self.config.get_tags().collect::<Vec<_>>();
|
||||
tags.sort();
|
||||
|
||||
let mut to_add = String::new();
|
||||
|
||||
for t in tags {
|
||||
self.replies.push(format!("- {}", t).to_string());
|
||||
to_add.push_str(&crate::tr!(self, "tag_list_entry", tag=t));
|
||||
}
|
||||
self.add_reply(to_add);
|
||||
}
|
||||
|
||||
async fn cmd_leave(&mut self) {
|
||||
if self.config.is_member_or_admin(&self.status_acct) {
|
||||
// admin can leave but that's a bad idea
|
||||
let _ = self.config.set_member(&self.status_acct, false);
|
||||
self.add_reply(
|
||||
"You're no longer a group member. Unfollow the group user to stop receiving group messages.",
|
||||
);
|
||||
self.add_reply(crate::tr!(self, "cmd_leave_resp"));
|
||||
}
|
||||
|
||||
self.unfollow_user_by_id(&self.status_user_id)
|
||||
@@ -732,7 +728,7 @@ impl<'a> ProcessMention<'a> {
|
||||
|
||||
async fn cmd_join(&mut self) {
|
||||
if self.config.is_member_or_admin(&self.status_acct) {
|
||||
debug!("Already member or admin, try to follow-back again");
|
||||
grp_debug!(self, "Already member or admin, try to follow-back again");
|
||||
// Already a member, so let's try to follow the user
|
||||
// again, maybe first time it failed
|
||||
self.follow_user_by_id(&self.status_user_id).await.log_error("Failed to follow");
|
||||
@@ -740,12 +736,7 @@ impl<'a> ProcessMention<'a> {
|
||||
// Not a member yet
|
||||
if self.config.is_member_only() {
|
||||
// No you can't
|
||||
self.add_reply(
|
||||
"\
|
||||
Sorry, this group is closed to new sign-ups.\n\
|
||||
Please ask one of the group admins to add you:",
|
||||
);
|
||||
|
||||
self.add_reply(crate::tr!(self, "welcome_closed"));
|
||||
self.append_admin_list_to_reply();
|
||||
} else {
|
||||
// Open access, try to follow back
|
||||
@@ -753,21 +744,13 @@ impl<'a> ProcessMention<'a> {
|
||||
|
||||
// This only fails if the user is banned, but that is filtered above
|
||||
let _ = self.config.set_member(&self.status_acct, true);
|
||||
self.add_reply(
|
||||
"\
|
||||
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\n\
|
||||
Use /help for more info.",
|
||||
);
|
||||
self.add_reply(crate::tr!(self, "welcome_join_cmd"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn cmd_ping(&mut self) {
|
||||
self.add_reply(format!(
|
||||
"pong, this is fedigroups service v{}",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
));
|
||||
self.add_reply(crate::tr!(self, "ping_response", version = env!("CARGO_PKG_VERSION")));
|
||||
}
|
||||
|
||||
async fn unfollow_by_acct(&self, acct: &str) -> Result<(), GroupError> {
|
||||
@@ -809,13 +792,13 @@ impl<'a> ProcessMention<'a> {
|
||||
async fn delay_after_post(&self) {
|
||||
tokio::time::sleep(Duration::from_secs_f64(self.cc.delay_after_post_s)).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_trailing_hashtag_pleroma_bug_workaround(msg: &mut String) {
|
||||
if crate::command::RE_HASHTAG_TRIGGERING_PLEROMA_BUG.is_match(msg) {
|
||||
// if a status ends with a hashtag, pleroma will fuck it up
|
||||
debug!("Adding \" .\" to fix pleroma hashtag eating bug!");
|
||||
msg.push_str(" .");
|
||||
fn apply_trailing_hashtag_pleroma_bug_workaround(&self, msg: &mut String) {
|
||||
if crate::command::RE_HASHTAG_TRIGGERING_PLEROMA_BUG.is_match(msg) {
|
||||
// if a status ends with a hashtag, pleroma will fuck it up
|
||||
grp_debug!(self, "Adding \" .\" to fix pleroma hashtag eating bug!");
|
||||
msg.push_str(" .");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -829,29 +812,29 @@ fn smart_split(msg: &str, prefix: Option<String>, limit: usize) -> Vec<String> {
|
||||
let mut parts_to_send = vec![];
|
||||
let mut this_piece = prefix.clone();
|
||||
for l in msg.split('\n') {
|
||||
println!("* Line: {:?}", l);
|
||||
// println!("* Line: {:?}", l);
|
||||
|
||||
match (this_piece.len() + l.len()).cmp(&limit) {
|
||||
Ordering::Less => {
|
||||
println!("append line");
|
||||
// println!("append line");
|
||||
// this line still fits comfortably
|
||||
this_piece.push_str(l);
|
||||
this_piece.push('\n');
|
||||
}
|
||||
Ordering::Equal => {
|
||||
println!("exactly fits within limit");
|
||||
// println!("exactly fits within limit");
|
||||
// this line exactly reaches the limit
|
||||
this_piece.push_str(l);
|
||||
parts_to_send.push(std::mem::take(&mut this_piece).trim().to_owned());
|
||||
this_piece.push_str(&prefix);
|
||||
}
|
||||
Ordering::Greater => {
|
||||
println!("too long to append (already {} + new {})", this_piece.len(), l.len());
|
||||
// println!("too long to append (already {} + new {})", this_piece.len(), l.len());
|
||||
// line too long to append
|
||||
if this_piece != prefix {
|
||||
let trimmed = this_piece.trim();
|
||||
if !trimmed.is_empty() {
|
||||
println!("flush buffer: {:?}", trimmed);
|
||||
// println!("flush buffer: {:?}", trimmed);
|
||||
parts_to_send.push(trimmed.to_owned());
|
||||
}
|
||||
}
|
||||
@@ -862,18 +845,18 @@ fn smart_split(msg: &str, prefix: Option<String>, limit: usize) -> Vec<String> {
|
||||
while this_piece.len() > limit {
|
||||
// line too long, try splitting at the last space, if any
|
||||
let to_send = if let Some(last_space) = (&this_piece[..=limit]).rfind(' ') {
|
||||
println!("line split at word boundary");
|
||||
// println!("line split at word boundary");
|
||||
let mut p = this_piece.split_off(last_space + 1);
|
||||
std::mem::swap(&mut p, &mut this_piece);
|
||||
p
|
||||
} else {
|
||||
println!("line split at exact len (no word boundary found)");
|
||||
// println!("line split at exact len (no word boundary found)");
|
||||
let mut p = this_piece.split_off(limit);
|
||||
std::mem::swap(&mut p, &mut this_piece);
|
||||
p
|
||||
};
|
||||
let part_trimmed = to_send.trim();
|
||||
println!("flush buffer: {:?}", part_trimmed);
|
||||
// println!("flush buffer: {:?}", part_trimmed);
|
||||
parts_to_send.push(part_trimmed.to_owned());
|
||||
this_piece = format!("{}{}", prefix, this_piece.trim());
|
||||
}
|
||||
@@ -885,7 +868,7 @@ fn smart_split(msg: &str, prefix: Option<String>, limit: usize) -> Vec<String> {
|
||||
if this_piece != prefix {
|
||||
let leftover_trimmed = this_piece.trim();
|
||||
if !leftover_trimmed.is_empty() {
|
||||
println!("flush buffer: {:?}", leftover_trimmed);
|
||||
// println!("flush buffer: {:?}", leftover_trimmed);
|
||||
parts_to_send.push(leftover_trimmed.to_owned());
|
||||
}
|
||||
}
|
||||
@@ -917,21 +900,21 @@ mod test {
|
||||
let to_split = "foo\nbar\nbaz";
|
||||
|
||||
let parts = super::smart_split(to_split, None, 1000);
|
||||
assert_eq!(vec!["foo\nbar\nbaz".to_string(),], parts);
|
||||
assert_eq!(vec!["foo\nbar\nbaz".to_string()], parts);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_smart_split_nosplit_prefix() {
|
||||
let to_split = "foo\nbar\nbaz";
|
||||
let parts = super::smart_split(to_split, Some("PREFIX".to_string()), 1000);
|
||||
assert_eq!(vec!["PREFIXfoo\nbar\nbaz".to_string(),], parts);
|
||||
assert_eq!(vec!["PREFIXfoo\nbar\nbaz".to_string()], parts);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_smart_split_prefix_each() {
|
||||
let to_split = "1234\n56\n7";
|
||||
let parts = super::smart_split(to_split, Some("PREFIX".to_string()), 10);
|
||||
assert_eq!(vec!["PREFIX1234".to_string(), "PREFIX56\n7".to_string(),], parts);
|
||||
assert_eq!(vec!["PREFIX1234".to_string(), "PREFIX56\n7".to_string()], parts);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -973,7 +956,7 @@ mod test {
|
||||
let to_split = "one two threefourfive six";
|
||||
let parts = super::smart_split(to_split, None, 10);
|
||||
assert_eq!(
|
||||
vec!["one two".to_string(), "threefourf".to_string(), "ive six".to_string(),],
|
||||
vec!["one two".to_string(), "threefourf".to_string(), "ive six".to_string()],
|
||||
parts
|
||||
);
|
||||
}
|
||||
|
||||
+36
-41
@@ -19,6 +19,7 @@ use crate::command::StatusCommand;
|
||||
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;
|
||||
@@ -46,14 +47,7 @@ impl Default for GroupInternal {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO move other options to common_config!
|
||||
|
||||
// // const DELAY_BEFORE_ACTION: Duration = Duration::from_millis(250);
|
||||
// const DELAY_REOPEN_STREAM: Duration = Duration::from_millis(500);
|
||||
// // higher because we can expect a lot of non-hashtag statuses here
|
||||
// const SOCKET_ALIVE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
// const SOCKET_RETIRE_TIME: Duration = Duration::from_secs(120);
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! grp_debug {
|
||||
($self:ident, $f:expr) => {
|
||||
::log::debug!(concat!("(@{}) ", $f), $self.config.get_acct());
|
||||
@@ -63,6 +57,7 @@ macro_rules! grp_debug {
|
||||
};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! grp_info {
|
||||
($self:ident, $f:expr) => {
|
||||
::log::info!(concat!("(@{}) ", $f), $self.config.get_acct());
|
||||
@@ -72,6 +67,7 @@ macro_rules! grp_info {
|
||||
};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! grp_trace {
|
||||
($self:ident, $f:expr) => {
|
||||
::log::trace!(concat!("(@{}) ", $f), $self.config.get_acct());
|
||||
@@ -81,6 +77,7 @@ macro_rules! grp_trace {
|
||||
};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! grp_warn {
|
||||
($self:ident, $f:expr) => {
|
||||
::log::warn!(concat!("(@{}) ", $f), $self.config.get_acct());
|
||||
@@ -90,6 +87,7 @@ macro_rules! grp_warn {
|
||||
};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! grp_error {
|
||||
($self:ident, $f:expr) => {
|
||||
::log::error!(concat!("(@{}) ", $f), $self.config.get_acct());
|
||||
@@ -168,27 +166,31 @@ impl GroupHandle {
|
||||
let socket_open_time = Instant::now();
|
||||
let mut last_rx = Instant::now();
|
||||
|
||||
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);
|
||||
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) => {
|
||||
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.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -531,6 +533,10 @@ impl GroupHandle {
|
||||
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() {
|
||||
@@ -539,26 +545,15 @@ 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");
|
||||
|
||||
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()
|
||||
|
||||
+10
-4
@@ -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")
|
||||
@@ -90,7 +94,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
.filter_module("mio", LevelFilter::Warn)
|
||||
.init();
|
||||
|
||||
let store = store::ConfigStore::load_from_fs(StoreOptions {
|
||||
let mut store = store::ConfigStore::load_from_fs(StoreOptions {
|
||||
store_dir: args.value_of("config").unwrap_or(".").to_string(),
|
||||
})
|
||||
.await?;
|
||||
@@ -104,14 +108,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);
|
||||
@@ -120,11 +124,13 @@ 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?;
|
||||
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
use std::collections::HashMap;
|
||||
use crate::store::DEFAULT_LOCALE_NAME;
|
||||
use crate::tr::TranslationTable;
|
||||
|
||||
#[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
|
||||
@@ -21,11 +28,16 @@ pub struct CommonConfig {
|
||||
/// 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,
|
||||
@@ -34,6 +46,16 @@ impl Default for CommonConfig {
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+58
-26
@@ -4,6 +4,8 @@ use std::path::{Path, PathBuf};
|
||||
use elefren::AppData;
|
||||
|
||||
use crate::error::GroupError;
|
||||
use crate::store::{DEFAULT_LOCALE_NAME, CommonConfig};
|
||||
use crate::tr::TranslationTable;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
@@ -13,6 +15,8 @@ struct FixedConfig {
|
||||
acct: String,
|
||||
/// elefren data
|
||||
appdata: AppData,
|
||||
/// configured locale to use
|
||||
locale: String,
|
||||
/// Server's character limit
|
||||
character_limit: usize,
|
||||
#[serde(skip)]
|
||||
@@ -66,6 +70,8 @@ pub struct GroupConfig {
|
||||
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 {
|
||||
@@ -73,6 +79,7 @@ impl Default for FixedConfig {
|
||||
Self {
|
||||
enabled: true,
|
||||
acct: "".to_string(),
|
||||
locale: DEFAULT_LOCALE_NAME.to_string(),
|
||||
appdata: AppData {
|
||||
base: Default::default(),
|
||||
client_id: Default::default(),
|
||||
@@ -151,22 +158,12 @@ impl_change_tracking!(FixedConfig);
|
||||
impl_change_tracking!(MutableConfig);
|
||||
impl_change_tracking!(StateConfig);
|
||||
|
||||
impl Default for GroupConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
config: Default::default(),
|
||||
control: Default::default(),
|
||||
state: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 = serde_json::from_slice(&f)?;
|
||||
let mut control: MutableConfig = json5::from_str(&String::from_utf8_lossy(&f))?;
|
||||
control._path = control_path.to_owned();
|
||||
control
|
||||
} else {
|
||||
@@ -188,7 +185,7 @@ async fn load_or_create_state_file(state_path: impl AsRef<Path>) -> Result<State
|
||||
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 = serde_json::from_slice(&f)?;
|
||||
let mut control: StateConfig = json5::from_str(&String::from_utf8_lossy(&f))?;
|
||||
control._path = state_path.to_owned();
|
||||
control
|
||||
} else {
|
||||
@@ -205,7 +202,22 @@ async fn load_or_create_state_file(state_path: impl AsRef<Path>) -> Result<State
|
||||
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()
|
||||
}
|
||||
@@ -247,7 +259,7 @@ impl GroupConfig {
|
||||
}
|
||||
|
||||
/// (re)init using new authorization
|
||||
pub(crate) async fn from_appdata(acct: String, appdata: AppData, group_dir: PathBuf) -> Result<Self, GroupError> {
|
||||
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?;
|
||||
@@ -263,7 +275,7 @@ impl GroupConfig {
|
||||
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 = serde_json::from_slice(&f)?;
|
||||
let mut config: FixedConfig = json5::from_str(&String::from_utf8_lossy(&f))?;
|
||||
config._path = config_path;
|
||||
if config.appdata != appdata {
|
||||
config.appdata = appdata;
|
||||
@@ -294,21 +306,22 @@ impl GroupConfig {
|
||||
/* state */
|
||||
let state = load_or_create_state_file(state_path).await?;
|
||||
|
||||
let g = GroupConfig { config, control, state };
|
||||
let g = GroupConfig { config, control, state, _group_tr: TranslationTable::new() };
|
||||
g.warn_of_bad_config();
|
||||
Ok(g)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn from_dir(group_dir: PathBuf) -> Result<Self, GroupError> {
|
||||
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 = serde_json::from_slice(&f)?;
|
||||
let mut config: FixedConfig = json5::from_str(&String::from_utf8_lossy(&f))?;
|
||||
config._path = config_path;
|
||||
|
||||
/* control */
|
||||
@@ -317,7 +330,15 @@ impl GroupConfig {
|
||||
/* state */
|
||||
let state = load_or_create_state_file(state_path).await?;
|
||||
|
||||
let g = GroupConfig { config, control, state };
|
||||
/* 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)
|
||||
}
|
||||
@@ -562,6 +583,15 @@ 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", acct_to_server("raichu@pikachu.rocks"));
|
||||
@@ -571,7 +601,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_default_rules() {
|
||||
let group = GroupConfig::default();
|
||||
let group = empty_group_config();
|
||||
assert!(!group.is_member_only());
|
||||
assert!(!group.is_member("piggo@piggo.space"));
|
||||
assert!(!group.is_admin("piggo@piggo.space"));
|
||||
@@ -580,7 +610,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_member_only() {
|
||||
let mut group = GroupConfig::default();
|
||||
let mut group = empty_group_config();
|
||||
assert!(group.can_write("piggo@piggo.space"), "rando can write in public group");
|
||||
|
||||
group.set_member_only(true);
|
||||
@@ -617,7 +647,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_banned_users() {
|
||||
// Banning single user
|
||||
let mut group = GroupConfig::default();
|
||||
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();
|
||||
@@ -627,7 +657,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_banned_members() {
|
||||
// Banning single user
|
||||
let mut group = GroupConfig::default();
|
||||
let mut group = empty_group_config();
|
||||
group.set_member_only(true);
|
||||
|
||||
group.set_member("piggo@piggo.space", true).unwrap();
|
||||
@@ -636,19 +666,21 @@ mod tests {
|
||||
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_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 = GroupConfig::default();
|
||||
let mut group = empty_group_config();
|
||||
assert!(group.can_write("hitler@nazi.camp"), "randos can write");
|
||||
|
||||
group.ban_server("nazi.camp", true).unwrap();
|
||||
@@ -668,7 +700,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_sanity() {
|
||||
let mut group = GroupConfig::default();
|
||||
let mut group = empty_group_config();
|
||||
|
||||
group.set_admin("piggo@piggo.space", true).unwrap();
|
||||
assert_eq!(
|
||||
|
||||
+109
-31
@@ -11,12 +11,14 @@ pub mod common_config;
|
||||
pub mod group_config;
|
||||
pub use common_config::CommonConfig;
|
||||
pub use group_config::GroupConfig;
|
||||
use crate::tr::TranslationTable;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ConfigStore {
|
||||
store_path: PathBuf,
|
||||
groups_path: PathBuf,
|
||||
config: Arc<CommonConfig>,
|
||||
locales_path: PathBuf,
|
||||
config: CommonConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -30,10 +32,13 @@ pub struct StoreOptions {
|
||||
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 load_from_fs(options: StoreOptions) -> Result<Arc<Self>, GroupError> {
|
||||
pub async fn load_from_fs(options: StoreOptions) -> Result<Self, GroupError> {
|
||||
let given_path: &Path = options.store_dir.as_ref();
|
||||
|
||||
let mut common_file: Option<PathBuf> = None;
|
||||
@@ -66,7 +71,7 @@ impl ConfigStore {
|
||||
let config: CommonConfig = if let Some(cf) = &common_file {
|
||||
debug!("Loading common config from {}", cf.display());
|
||||
let f = tokio::fs::read(&cf).await?;
|
||||
serde_json::from_slice(&f)?
|
||||
json5::from_str(&String::from_utf8_lossy(&f))?
|
||||
} else {
|
||||
debug!("No common config file, using defaults");
|
||||
CommonConfig::default()
|
||||
@@ -74,21 +79,41 @@ impl ConfigStore {
|
||||
|
||||
debug!("Using common config:\n{:#?}", config);
|
||||
|
||||
let groups_path = base_dir.join("groups.d");
|
||||
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?;
|
||||
}
|
||||
|
||||
Ok(Arc::new(Self {
|
||||
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,
|
||||
config: Arc::new(config),
|
||||
}))
|
||||
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)
|
||||
@@ -102,17 +127,16 @@ impl ConfigStore {
|
||||
|
||||
let group_dir = self.groups_path.join(&opts.acct);
|
||||
|
||||
let data = GroupConfig::from_appdata(opts.acct.clone(), appdata, group_dir).await?;
|
||||
GroupConfig::initialize_by_appdata(opts.acct.clone(), appdata, group_dir).await?;
|
||||
|
||||
// save & persist
|
||||
|
||||
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);
|
||||
@@ -120,20 +144,14 @@ impl ConfigStore {
|
||||
}
|
||||
};
|
||||
|
||||
Ok(GroupHandle {
|
||||
group_account,
|
||||
client,
|
||||
config: data,
|
||||
cc: self.config.clone(),
|
||||
internal: GroupInternal::default(),
|
||||
})
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-auth an existing group
|
||||
pub async fn reauth_group(self: &Arc<Self>, acct: &str) -> Result<GroupHandle, GroupError> {
|
||||
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).await?;
|
||||
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())
|
||||
@@ -151,7 +169,7 @@ impl ConfigStore {
|
||||
config.set_appdata(appdata);
|
||||
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: @{}, \"{}\"",
|
||||
@@ -165,26 +183,86 @@ impl ConfigStore {
|
||||
}
|
||||
};
|
||||
|
||||
Ok(GroupHandle {
|
||||
group_account,
|
||||
client,
|
||||
config,
|
||||
cc: self.config.clone(),
|
||||
internal: GroupInternal::default(),
|
||||
})
|
||||
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>) -> Result<Vec<GroupHandle>, GroupError> {
|
||||
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
|
||||
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()).await.ok()?;
|
||||
let gc = GroupConfig::from_dir(entry.path(), &config).await.ok()?;
|
||||
|
||||
if !gc.is_enabled() {
|
||||
debug!("Group @{} is DISABLED", gc.get_acct());
|
||||
@@ -213,7 +291,7 @@ impl ConfigStore {
|
||||
group_account: my_account,
|
||||
client,
|
||||
config: gc,
|
||||
cc: self.config.clone(),
|
||||
cc: config.clone(),
|
||||
internal: GroupInternal::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
//! 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),*
|
||||
])
|
||||
};
|
||||
}
|
||||
+6
-6
@@ -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"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user