16 Commits
16 changed files with 737 additions and 342 deletions
+19
View File
@@ -1,5 +1,24 @@
# Changelog # Changelog
## 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 ## v0.3.0
- Changed config/storage format to directory-based, removed shared config mutex - Changed config/storage format to directory-based, removed shared config mutex
- Made more options configurable (timeouts, catch-up limits, etc) - Made more options configurable (timeouts, catch-up limits, etc)
Generated
+68 -1
View File
@@ -328,13 +328,14 @@ checksum = "e88a8acf291dafb59c2d96e8f59828f3838bb1a70398823ade51a84de6a6deed"
[[package]] [[package]]
name = "fedigroups" name = "fedigroups"
version = "0.3.0" version = "0.4.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
"elefren", "elefren",
"env_logger", "env_logger",
"futures 0.3.16", "futures 0.3.16",
"json5",
"log 0.4.14", "log 0.4.14",
"native-tls", "native-tls",
"once_cell", "once_cell",
@@ -764,6 +765,17 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "kernel32-sys" name = "kernel32-sys"
version = "0.2.2" version = "0.2.2"
@@ -829,6 +841,12 @@ dependencies = [
"cfg-if 1.0.0", "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]] [[package]]
name = "matches" name = "matches"
version = "0.1.8" version = "0.1.8"
@@ -1105,6 +1123,49 @@ version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" 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]] [[package]]
name = "phf" name = "phf"
version = "0.7.24" version = "0.7.24"
@@ -2110,6 +2171,12 @@ version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f6906492a7cd215bfa4cf595b600146ccfac0c79bcbd1f3000162af5e8b06" checksum = "879f6906492a7cd215bfa4cf595b600146ccfac0c79bcbd1f3000162af5e8b06"
[[package]]
name = "ucd-trie"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56dee185309b50d1f11bfedef0fe6d036842e3fb77413abef29f8f8d1c5d4c1c"
[[package]] [[package]]
name = "unicase" name = "unicase"
version = "1.4.2" version = "1.4.2"
+2 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "fedigroups" name = "fedigroups"
version = "0.3.0" version = "0.4.4"
authors = ["Ondřej Hruška <ondra@ondrovo.com>"] authors = ["Ondřej Hruška <ondra@ondrovo.com>"]
edition = "2018" edition = "2018"
publish = false publish = false
@@ -26,6 +26,7 @@ futures = "0.3"
voca_rs = "1.13.0" voca_rs = "1.13.0"
regex = "1.5.4" regex = "1.5.4"
once_cell = "1.8.0" once_cell = "1.8.0"
json5 = "0.4.1"
native-tls = "0.2.8" native-tls = "0.2.8"
websocket = "0.26.2" websocket = "0.26.2"
+58 -23
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!** 3. **Make sure you auth as the correct user!**
4. Paste the Oauth2 token you got into the terminal, hit enter. 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. 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 ### 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: A typical setup could look like this:
``` ```
├── groups.d ├── groups
│ ├── betty@piggo.space │ ├── betty@piggo.space
│ │ ├── config.json │ │ ├── config.json
│ │ ├── control.json │ │ ├── control.json
│ │ └── state.json │ │ └── state.json
│ └── chatterbox@botsin.space │ └── chatterbox@botsin.space
│ ├── config.json │ ├── config.json ... fixed config edited manually
│ ├── control.json │ ├── messages.json ... custom locale overrides (optional)
── state.json ── 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 └── 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 #### Common config
There is one shared config file: `groups.json` There is one shared config file: `groups.json`
- If the file does not exist, default settings are used. This is usually good enough. - If the file does not exist, default settings are used. This is usually sufficient.
- This file applies to all groups - This file applies to all groups and serves as the default config.
- 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
``` ```
{ {
// 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 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 number of missed statuses to process after connect
max_catchup_statuses: 50, "max_catchup_statuses": 50,
// Delay between fetched pages when catching up // 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. // 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. // 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 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 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. // Timeout for a notification/timeline socket to be considered alive.
// If nothing arrives in this interval, reopen it. Some servers have a buggy socket // If nothing arrives in this interval, reopen it. Some servers have a buggy socket
// implementation where it stays open but no longer works. // 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. // 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. // 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 #### Per-group config
Each group is stored as a sub-directory of `groups.d/`. The sub-directories are normally named after their accounts, 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.d/betty@piggo.space/`. 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. 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.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 State is split from Control to limit the write frequency of the control file. Timestamps can be updated multiple times
per minute. 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!** **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! 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** **config.json**
@@ -127,9 +160,11 @@ The file formats are quite self-explanatory (again, remove comments before copyi
{ {
// Enable or disable the group service // Enable or disable the group service
"enabled": true, "enabled": true,
// Group locale (optional, defaults to "en")
"locale": "en",
// Group account name // Group account name
"acct": "group@myserver.xyz", "acct": "group@myserver.xyz",
// Saved mastodon API credentials // Saved mastodon API credentials, this is created when authenticating the group.
"appdata": { "appdata": {
"base": "https://myserver.xyz", "base": "https://myserver.xyz",
"client_id": "...", "client_id": "...",
@@ -180,7 +215,7 @@ Internal use, millisecond timestamps of the last-seen status and notification.
### Running ### Running
To run the group service, simply run it with no arguments. 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. 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. Config files can have limited permissions to avoid accidental overwrite.
+3
View File
@@ -0,0 +1,3 @@
{
"ping_response": "pong, toto je fedigroups verze {version}"
}
+55
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}"
}
+6 -4
View File
@@ -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> = static RE_ANNOUNCE: once_cell::sync::Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?:^|\s|>|\n)[\\/]announce\s+(.*)$").unwrap()); 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> = 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> = 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> { pub fn parse_status_tags(content: &str) -> Vec<String> {
debug!("Raw content: {}", content); debug!("Raw content: {}", content);
@@ -563,6 +563,8 @@ mod test {
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 #banana gfdfgd"));
assert!(RE_A_HASHTAG.is_match("foo #городДляЛюдей aaa")); 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() { for (i, c) in RE_A_HASHTAG.captures_iter("foo #banana #χαλβάς #ласточка").enumerate() {
if i == 0 { 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 #tag sdfsd"));
assert!(!RE_NOBOT_TAG.is_match("banana #nobotanicals sdfsd")); assert!(!RE_NOBOT_TAG.is_match("banana #nobotanicals sdfsd"));
assert!(RE_NOBOT_TAG.is_match("#nobot")); 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("aaa #nobot"));
assert!(RE_NOBOT_TAG.is_match("#nobot xxx")); assert!(RE_NOBOT_TAG.is_match("#nobot xxx"));
assert!(RE_NOBOT_TAG.is_match("#nobot\nxxx")); assert!(RE_NOBOT_TAG.is_match("#nobot\nxxx"));
+2
View File
@@ -19,6 +19,8 @@ pub enum GroupError {
#[error(transparent)] #[error(transparent)]
Serializer(#[from] serde_json::Error), Serializer(#[from] serde_json::Error),
#[error(transparent)] #[error(transparent)]
Serializer5(#[from] json5::Error),
#[error(transparent)]
Elefren(#[from] elefren::Error), Elefren(#[from] elefren::Error),
} }
+176 -194
View File
@@ -12,8 +12,11 @@ use crate::error::GroupError;
use crate::group_handler::GroupHandle; use crate::group_handler::GroupHandle;
use crate::store::group_config::GroupConfig; use crate::store::group_config::GroupConfig;
use crate::store::CommonConfig; use crate::store::CommonConfig;
use crate::tr::TranslationTable;
use crate::utils; use crate::utils;
use crate::utils::{normalize_acct, LogError}; use crate::utils::{normalize_acct, LogError, VisExt};
use crate::{grp_debug, grp_info, grp_warn};
pub struct ProcessMention<'a> { pub struct ProcessMention<'a> {
status: Status, status: Status,
@@ -26,15 +29,18 @@ pub struct ProcessMention<'a> {
status_user_id: String, status_user_id: String,
can_write: bool, can_write: bool,
is_admin: bool, is_admin: bool,
replies: Vec<String>, replies: String,
announcements: Vec<String>, announcements: String,
do_boost_prev_post: bool, do_boost_prev_post: bool,
want_markdown: bool,
} }
impl<'a> ProcessMention<'a> { 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> { 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( match tokio::time::timeout(
Duration::from_secs(5), Duration::from_secs(5),
@@ -44,7 +50,7 @@ impl<'a> ProcessMention<'a> {
.await .await
{ {
Err(_) => { Err(_) => {
warn!("Account lookup timeout!"); grp_warn!(self, "Account lookup timeout!");
Err(GroupError::ApiTimeout) Err(GroupError::ApiTimeout)
} }
Ok(Err(e)) => { Ok(Err(e)) => {
@@ -56,14 +62,14 @@ impl<'a> ProcessMention<'a> {
// XXX limit is 1! // XXX limit is 1!
let acct_normalized = normalize_acct(&item.acct, &self.group_acct)?; let acct_normalized = normalize_acct(&item.acct, &self.group_acct)?;
if acct_normalized == 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)); return Ok(Some(item.id));
} else { } 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) Ok(None)
} }
} }
@@ -72,9 +78,11 @@ impl<'a> ProcessMention<'a> {
fn append_admin_list_to_reply(&mut self) { fn append_admin_list_to_reply(&mut self) {
let mut admins = self.config.get_admins().collect::<Vec<_>>(); let mut admins = self.config.get_admins().collect::<Vec<_>>();
admins.sort(); admins.sort();
let mut to_add = String::new();
for a in admins { 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) { fn append_member_list_to_reply(&mut self) {
@@ -83,24 +91,26 @@ impl<'a> ProcessMention<'a> {
members.extend(admins.iter()); members.extend(admins.iter());
members.sort(); members.sort();
members.dedup(); members.dedup();
let mut to_add = String::new();
for m in members { for m in members {
self.replies.push(if admins.contains(&m) { to_add.push_str(&if admins.contains(&m) {
format!("- {} [admin]", m) crate::tr!(self, "user_list_entry_admin", user = m)
} else { } 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> { 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.client.follow(id).await?;
self.delay_after_post().await; self.delay_after_post().await;
Ok(()) Ok(())
} }
async fn unfollow_user_by_id(&self, id: &str) -> Result<(), GroupError> { 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.client.unfollow(id).await?;
self.delay_after_post().await; self.delay_after_post().await;
Ok(()) Ok(())
@@ -111,7 +121,7 @@ impl<'a> ProcessMention<'a> {
let status_acct = normalize_acct(&status.account.acct, &group_acct)?.to_string(); let status_acct = normalize_acct(&status.account.acct, &group_acct)?.to_string();
if gh.config.is_banned(&status_acct) { 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(()); return Ok(());
} }
@@ -122,10 +132,9 @@ impl<'a> ProcessMention<'a> {
cc: &gh.cc, cc: &gh.cc,
can_write: gh.config.can_write(&status_acct), can_write: gh.config.can_write(&status_acct),
is_admin: gh.config.is_admin(&status_acct), is_admin: gh.config.is_admin(&status_acct),
replies: vec![], replies: String::new(),
announcements: vec![], announcements: String::new(),
do_boost_prev_post: false, do_boost_prev_post: false,
want_markdown: false,
group_acct, group_acct,
status_acct, status_acct,
status, status,
@@ -141,11 +150,11 @@ impl<'a> ProcessMention<'a> {
} }
fn add_reply(&mut self, line: impl AsRef<str>) { 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>) { 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> { async fn handle(mut self) -> Result<(), GroupError> {
@@ -155,11 +164,14 @@ impl<'a> ProcessMention<'a> {
self.handle_post_with_no_commands().await; self.handle_post_with_no_commands().await;
} else { } else {
if commands.contains(&StatusCommand::Ignore) { if commands.contains(&StatusCommand::Ignore) {
debug!("Notif ignored because of ignore command"); grp_debug!(self, "Notif ignored because of ignore command");
return Ok(()); return Ok(());
} }
for cmd in commands { for cmd in commands {
if !self.replies.is_empty() {
self.replies.push('\n'); // make sure there's a newline between batched commands.
}
match cmd { match cmd {
StatusCommand::Undo => { StatusCommand::Undo => {
self.cmd_undo().await.log_error("Error handling undo cmd"); self.cmd_undo().await.log_error("Error handling undo cmd");
@@ -207,7 +219,7 @@ impl<'a> ProcessMention<'a> {
self.cmd_grant_admin(&u).await.log_error("Error handling grant-admin cmd"); self.cmd_grant_admin(&u).await.log_error("Error handling grant-admin cmd");
} }
StatusCommand::RemoveAdmin(u) => { 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 => { StatusCommand::OpenGroup => {
self.cmd_open_group().await; self.cmd_open_group().await;
@@ -248,33 +260,29 @@ impl<'a> ProcessMention<'a> {
self.delay_after_post().await; self.delay_after_post().await;
} }
Err(e) => { Err(e) => {
warn!("Can't reblog: {}", e); grp_warn!(self, "Can't reblog: {}", e);
} }
} }
} }
} }
if !self.replies.is_empty() { if !self.replies.is_empty() {
let mut msg = self.replies.join("\n"); let mut msg = std::mem::take(&mut self.replies);
debug!("r={}", msg); grp_debug!(self, "r={}", msg);
if self.want_markdown { self.apply_trailing_hashtag_pleroma_bug_workaround(&mut msg);
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?; self.send_reply_multipart(mention, msg).await?;
} }
if !self.announcements.is_empty() { if !self.announcements.is_empty() {
let mut msg = self.announcements.join("\n"); let mut msg = std::mem::take(&mut self.announcements);
debug!("a={}", msg); grp_debug!(self, "a={}", msg);
if self.want_markdown { self.apply_trailing_hashtag_pleroma_bug_workaround(&mut msg);
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?; self.send_announcement_multipart(&msg).await?;
} }
@@ -288,11 +296,7 @@ impl<'a> ProcessMention<'a> {
for p in parts { for p in parts {
if let Ok(post) = StatusBuilder::new() if let Ok(post) = StatusBuilder::new()
.status(p) .status(p)
.content_type(if self.want_markdown { .content_type("text/markdown")
"text/markdown"
} else {
"text/plain"
})
.in_reply_to(&parent) .in_reply_to(&parent)
.visibility(Visibility::Direct) .visibility(Visibility::Direct)
.build() .build()
@@ -336,24 +340,45 @@ impl<'a> ProcessMention<'a> {
} }
async fn handle_post_with_no_commands(&mut self) { async fn handle_post_with_no_commands(&mut self) {
debug!("No commands in post"); grp_debug!(self, "No commands in post");
if self.status.in_reply_to_id.is_none() {
if self.can_write { 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. // 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; // tokio::time::sleep(DELAY_BEFORE_ACTION).await;
self.reblog_status().await; self.reblog_status().await;
// Otherwise, don't react
} else { } 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 { } 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) { async fn cmd_announce(&mut self, msg: String) {
info!("Sending PSA"); grp_info!(self, "Sending PSA");
self.add_announcement(msg); self.add_announcement(msg);
} }
@@ -361,29 +386,29 @@ impl<'a> ProcessMention<'a> {
if self.can_write { if self.can_write {
self.do_boost_prev_post = self.status.in_reply_to_id.is_some(); self.do_boost_prev_post = self.status.in_reply_to_id.is_some();
} else { } 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) { async fn cmd_optout(&mut self) {
if self.is_admin { 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) { } 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 { } else {
self.config.set_optout(&self.status_acct, true); 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) { async fn cmd_optin(&mut self) {
if self.is_admin { 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) { } 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 { } else {
self.config.set_optout(&self.status_acct, false); 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"));
} }
} }
@@ -395,19 +420,19 @@ impl<'a> ProcessMention<'a> {
// This is a post sent by the group user, likely an announcement. // This is a post sent by the group user, likely an announcement.
// Undo here means delete it. // Undo here means delete it.
if self.is_admin { 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.client.delete_status(parent_status_id).await?;
self.delay_after_post().await; self.delay_after_post().await;
} else { } 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 { } 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 // User unboosting own post boosted by accident, or admin doing it
self.client.unreblog(parent_status_id).await?; self.client.unreblog(parent_status_id).await?;
self.delay_after_post().await; self.delay_after_post().await;
} else { } 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, // 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) // they then can't reverse that (only admin or the post's author can)
} }
@@ -422,18 +447,18 @@ impl<'a> ProcessMention<'a> {
if !self.config.is_banned(&u) { if !self.config.is_banned(&u) {
match self.config.ban_user(&u, true) { match self.config.ban_user(&u, true) {
Ok(_) => { 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"); self.unfollow_by_acct(&u).await.log_error("Failed to unfollow banned user");
} }
Err(e) => { 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 { } 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 { } else {
warn!("Ignore cmd, user not admin"); grp_warn!(self, "Ignore cmd, user not admin");
} }
Ok(()) Ok(())
} }
@@ -444,18 +469,18 @@ impl<'a> ProcessMention<'a> {
if self.config.is_banned(&u) { if self.config.is_banned(&u) {
match self.config.ban_user(&u, false) { match self.config.ban_user(&u, false) {
Ok(_) => { Ok(_) => {
self.add_reply(format!("User {} un-banned!", u)); self.add_reply(crate::tr!(self, "cmd_unban_user_ok", user = &u));
// no announcement here // no announcement here
} }
Err(_) => { Err(e) => {
unreachable!() self.add_reply(crate::tr!(self, "cmd_error", cause = &e.to_string()));
} }
} }
} else { } 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 { } else {
warn!("Ignore cmd, user not admin"); grp_warn!(self, "Ignore cmd, user not admin");
} }
Ok(()) Ok(())
} }
@@ -465,17 +490,17 @@ impl<'a> ProcessMention<'a> {
if !self.config.is_server_banned(s) { if !self.config.is_server_banned(s) {
match self.config.ban_server(s, true) { match self.config.ban_server(s, true) {
Ok(_) => { Ok(_) => {
self.add_reply(format!("Server {} banned from group!", s)); self.add_reply(crate::tr!(self, "cmd_ban_server_ok", server = s));
} }
Err(e) => { 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 { } 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 { } else {
warn!("Ignore cmd, user not admin"); grp_warn!(self, "Ignore cmd, user not admin");
} }
} }
@@ -484,17 +509,17 @@ impl<'a> ProcessMention<'a> {
if self.config.is_server_banned(s) { if self.config.is_server_banned(s) {
match self.config.ban_server(s, false) { match self.config.ban_server(s, false) {
Ok(_) => { Ok(_) => {
self.add_reply(format!("Server {} un-banned!", s)); self.add_reply(crate::tr!(self, "cmd_unban_server_ok", server = s));
} }
Err(e) => { Err(e) => {
self.add_reply(format!("Unexpected error occured: {}", e)); self.add_reply(crate::tr!(self, "cmd_error", cause = &e.to_string()));
} }
} }
} else { } 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 { } else {
warn!("Ignore cmd, user not admin"); grp_warn!(self, "Ignore cmd, user not admin");
} }
} }
@@ -504,15 +529,16 @@ impl<'a> ProcessMention<'a> {
// Allow even if the user is already a member - that will trigger re-follow // Allow even if the user is already a member - that will trigger re-follow
match self.config.set_member(&u, true) { match self.config.set_member(&u, true) {
Ok(_) => { 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"); self.follow_by_acct(&u).await.log_error("Failed to follow");
} }
Err(e) => { 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 { } else {
warn!("Ignore cmd, user not admin"); grp_warn!(self, "Ignore cmd, user not admin");
} }
Ok(()) Ok(())
} }
@@ -522,15 +548,15 @@ impl<'a> ProcessMention<'a> {
if self.is_admin { if self.is_admin {
match self.config.set_member(&u, false) { match self.config.set_member(&u, false) {
Ok(_) => { 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"); self.unfollow_by_acct(&u).await.log_error("Failed to unfollow removed user");
} }
Err(e) => { Err(e) => {
self.add_reply(format!("Unexpected error occured: {}", e)); self.add_reply(crate::tr!(self, "cmd_error", cause = &e.to_string()));
} }
} }
} else { } else {
warn!("Ignore cmd, user not admin"); grp_warn!(self, "Ignore cmd, user not admin");
} }
Ok(()) Ok(())
} }
@@ -539,12 +565,12 @@ impl<'a> ProcessMention<'a> {
if self.is_admin { if self.is_admin {
if !self.config.is_tag_followed(&tag) { if !self.config.is_tag_followed(&tag) {
self.config.add_tag(&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 { } 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 { } else {
warn!("Ignore cmd, user not admin"); grp_warn!(self, "Ignore cmd, user not admin");
} }
} }
@@ -552,12 +578,12 @@ impl<'a> ProcessMention<'a> {
if self.is_admin { if self.is_admin {
if self.config.is_tag_followed(&tag) { if self.config.is_tag_followed(&tag) {
self.config.remove_tag(&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 { } 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 { } else {
warn!("Ignore cmd, user not admin"); grp_warn!(self, "Ignore cmd, user not admin");
} }
} }
@@ -570,38 +596,38 @@ impl<'a> ProcessMention<'a> {
// try to make the config a little more sane, admins should be members // try to make the config a little more sane, admins should be members
let _ = self.config.set_member(&u, true); 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) => { 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 { } else {
self.add_reply(format!("No action, \"{}\" is admin already", u)); self.add_reply(crate::tr!(self, "cmd_admin_fail_already", user = &u));
} }
} else { } else {
warn!("Ignore cmd, user not admin"); grp_warn!(self, "Ignore cmd, user not admin");
} }
Ok(()) 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)?; let u = normalize_acct(user, &self.group_acct)?;
if self.is_admin { if self.is_admin {
if self.config.is_admin(&u) { if self.config.is_admin(&u) {
match self.config.set_admin(&u, false) { match self.config.set_admin(&u, false) {
Ok(_) => { 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) => { 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 { } 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 { } else {
warn!("Ignore cmd, user not admin"); grp_warn!(self, "Ignore cmd, user not admin");
} }
Ok(()) Ok(())
} }
@@ -610,12 +636,12 @@ impl<'a> ProcessMention<'a> {
if self.is_admin { if self.is_admin {
if self.config.is_member_only() { if self.config.is_member_only() {
self.config.set_member_only(false); self.config.set_member_only(false);
self.add_reply("Group changed to open-access"); self.add_reply(crate::tr!(self, "cmd_open_resp"));
} else { } else {
self.add_reply("No action, group is open-access already"); self.add_reply(crate::tr!(self, "cmd_open_resp_already"));
} }
} else { } else {
warn!("Ignore cmd, user not admin"); grp_warn!(self, "Ignore cmd, user not admin");
} }
} }
@@ -623,106 +649,75 @@ impl<'a> ProcessMention<'a> {
if self.is_admin { if self.is_admin {
if !self.config.is_member_only() { if !self.config.is_member_only() {
self.config.set_member_only(true); self.config.set_member_only(true);
self.add_reply("Group changed to member-only"); self.add_reply(crate::tr!(self, "cmd_close_resp"));
} else { } else {
self.add_reply("No action, group is member-only already"); self.add_reply(crate::tr!(self, "cmd_close_resp_already"));
} }
} else { } else {
warn!("Ignore cmd, user not admin"); grp_warn!(self, "Ignore cmd, user not admin");
} }
} }
async fn cmd_help(&mut self) { async fn cmd_help(&mut self) {
self.want_markdown = true;
let membership_line = if self.is_admin { 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) { } 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() { } 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 { } 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() { 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 { } 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( self.add_reply(crate::tr!(self, "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",
);
if self.is_admin { if !self.is_admin {
self.add_reply("`/members`, `/who` - show group members / admins"); self.add_reply(crate::tr!(self, "help_member_commands"));
// 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)");
} }
// XXX when used on instance with small character limit, this won't fit!
if self.is_admin { if self.is_admin {
self.add_reply( self.add_reply(crate::tr!(self, "help_admin_commands"));
"\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",
);
} }
} }
async fn cmd_list_members(&mut self) { async fn cmd_list_members(&mut self) {
self.want_markdown = true;
if self.is_admin { if self.is_admin {
self.add_reply("Group members:"); self.add_reply(crate::tr!(self, "member_list_heading"));
self.append_member_list_to_reply(); self.append_member_list_to_reply();
} else { } else {
self.add_reply("Group admins:"); self.add_reply(crate::tr!(self, "admin_list_heading"));
self.append_admin_list_to_reply(); self.append_admin_list_to_reply();
} }
} }
async fn cmd_list_tags(&mut self) { async fn cmd_list_tags(&mut self) {
self.add_reply("Group tags:"); self.add_reply(crate::tr!(self, "tag_list_heading"));
self.want_markdown = true;
let mut tags = self.config.get_tags().collect::<Vec<_>>(); let mut tags = self.config.get_tags().collect::<Vec<_>>();
tags.sort(); tags.sort();
let mut to_add = String::new();
for t in tags { 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) { async fn cmd_leave(&mut self) {
if self.config.is_member_or_admin(&self.status_acct) { if self.config.is_member_or_admin(&self.status_acct) {
// admin can leave but that's a bad idea // admin can leave but that's a bad idea
let _ = self.config.set_member(&self.status_acct, false); let _ = self.config.set_member(&self.status_acct, false);
self.add_reply( self.add_reply(crate::tr!(self, "cmd_leave_resp"));
"You're no longer a group member. Unfollow the group user to stop receiving group messages.",
);
} }
self.unfollow_user_by_id(&self.status_user_id) self.unfollow_user_by_id(&self.status_user_id)
@@ -732,7 +727,7 @@ impl<'a> ProcessMention<'a> {
async fn cmd_join(&mut self) { async fn cmd_join(&mut self) {
if self.config.is_member_or_admin(&self.status_acct) { 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 // Already a member, so let's try to follow the user
// again, maybe first time it failed // again, maybe first time it failed
self.follow_user_by_id(&self.status_user_id).await.log_error("Failed to follow"); self.follow_user_by_id(&self.status_user_id).await.log_error("Failed to follow");
@@ -740,12 +735,7 @@ impl<'a> ProcessMention<'a> {
// Not a member yet // Not a member yet
if self.config.is_member_only() { if self.config.is_member_only() {
// No you can't // No you can't
self.add_reply( self.add_reply(crate::tr!(self, "welcome_closed"));
"\
Sorry, this group is closed to new sign-ups.\n\
Please ask one of the group admins to add you:",
);
self.append_admin_list_to_reply(); self.append_admin_list_to_reply();
} else { } else {
// Open access, try to follow back // Open access, try to follow back
@@ -753,21 +743,13 @@ impl<'a> ProcessMention<'a> {
// This only fails if the user is banned, but that is filtered above // This only fails if the user is banned, but that is filtered above
let _ = self.config.set_member(&self.status_acct, true); let _ = self.config.set_member(&self.status_acct, true);
self.add_reply( self.add_reply(crate::tr!(self, "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\n\
Use /help for more info.",
);
} }
} }
} }
async fn cmd_ping(&mut self) { async fn cmd_ping(&mut self) {
self.add_reply(format!( self.add_reply(crate::tr!(self, "ping_response", version = env!("CARGO_PKG_VERSION")));
"pong, this is fedigroups service v{}",
env!("CARGO_PKG_VERSION")
));
} }
async fn unfollow_by_acct(&self, acct: &str) -> Result<(), GroupError> { async fn unfollow_by_acct(&self, acct: &str) -> Result<(), GroupError> {
@@ -809,13 +791,13 @@ impl<'a> ProcessMention<'a> {
async fn delay_after_post(&self) { async fn delay_after_post(&self) {
tokio::time::sleep(Duration::from_secs_f64(self.cc.delay_after_post_s)).await; tokio::time::sleep(Duration::from_secs_f64(self.cc.delay_after_post_s)).await;
} }
}
fn apply_trailing_hashtag_pleroma_bug_workaround(msg: &mut String) { fn apply_trailing_hashtag_pleroma_bug_workaround(&self, msg: &mut String) {
if crate::command::RE_HASHTAG_TRIGGERING_PLEROMA_BUG.is_match(msg) { if crate::command::RE_HASHTAG_TRIGGERING_PLEROMA_BUG.is_match(msg) {
// if a status ends with a hashtag, pleroma will fuck it up // if a status ends with a hashtag, pleroma will fuck it up
debug!("Adding \" .\" to fix pleroma hashtag eating bug!"); grp_debug!(self, "Adding \" .\" to fix pleroma hashtag eating bug!");
msg.push_str(" ."); msg.push_str(" .");
}
} }
} }
@@ -829,29 +811,29 @@ fn smart_split(msg: &str, prefix: Option<String>, limit: usize) -> Vec<String> {
let mut parts_to_send = vec![]; let mut parts_to_send = vec![];
let mut this_piece = prefix.clone(); let mut this_piece = prefix.clone();
for l in msg.split('\n') { for l in msg.split('\n') {
println!("* Line: {:?}", l); // println!("* Line: {:?}", l);
match (this_piece.len() + l.len()).cmp(&limit) { match (this_piece.len() + l.len()).cmp(&limit) {
Ordering::Less => { Ordering::Less => {
println!("append line"); // println!("append line");
// this line still fits comfortably // this line still fits comfortably
this_piece.push_str(l); this_piece.push_str(l);
this_piece.push('\n'); this_piece.push('\n');
} }
Ordering::Equal => { Ordering::Equal => {
println!("exactly fits within limit"); // println!("exactly fits within limit");
// this line exactly reaches the limit // this line exactly reaches the limit
this_piece.push_str(l); this_piece.push_str(l);
parts_to_send.push(std::mem::take(&mut this_piece).trim().to_owned()); parts_to_send.push(std::mem::take(&mut this_piece).trim().to_owned());
this_piece.push_str(&prefix); this_piece.push_str(&prefix);
} }
Ordering::Greater => { 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 // line too long to append
if this_piece != prefix { if this_piece != prefix {
let trimmed = this_piece.trim(); let trimmed = this_piece.trim();
if !trimmed.is_empty() { if !trimmed.is_empty() {
println!("flush buffer: {:?}", trimmed); // println!("flush buffer: {:?}", trimmed);
parts_to_send.push(trimmed.to_owned()); parts_to_send.push(trimmed.to_owned());
} }
} }
@@ -862,18 +844,18 @@ fn smart_split(msg: &str, prefix: Option<String>, limit: usize) -> Vec<String> {
while this_piece.len() > limit { while this_piece.len() > limit {
// line too long, try splitting at the last space, if any // line too long, try splitting at the last space, if any
let to_send = if let Some(last_space) = (&this_piece[..=limit]).rfind(' ') { 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); let mut p = this_piece.split_off(last_space + 1);
std::mem::swap(&mut p, &mut this_piece); std::mem::swap(&mut p, &mut this_piece);
p p
} else { } 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); let mut p = this_piece.split_off(limit);
std::mem::swap(&mut p, &mut this_piece); std::mem::swap(&mut p, &mut this_piece);
p p
}; };
let part_trimmed = to_send.trim(); let part_trimmed = to_send.trim();
println!("flush buffer: {:?}", part_trimmed); // println!("flush buffer: {:?}", part_trimmed);
parts_to_send.push(part_trimmed.to_owned()); parts_to_send.push(part_trimmed.to_owned());
this_piece = format!("{}{}", prefix, this_piece.trim()); this_piece = format!("{}{}", prefix, this_piece.trim());
} }
@@ -885,7 +867,7 @@ fn smart_split(msg: &str, prefix: Option<String>, limit: usize) -> Vec<String> {
if this_piece != prefix { if this_piece != prefix {
let leftover_trimmed = this_piece.trim(); let leftover_trimmed = this_piece.trim();
if !leftover_trimmed.is_empty() { if !leftover_trimmed.is_empty() {
println!("flush buffer: {:?}", leftover_trimmed); // println!("flush buffer: {:?}", leftover_trimmed);
parts_to_send.push(leftover_trimmed.to_owned()); parts_to_send.push(leftover_trimmed.to_owned());
} }
} }
@@ -917,21 +899,21 @@ mod test {
let to_split = "foo\nbar\nbaz"; let to_split = "foo\nbar\nbaz";
let parts = super::smart_split(to_split, None, 1000); 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] #[test]
fn test_smart_split_nosplit_prefix() { fn test_smart_split_nosplit_prefix() {
let to_split = "foo\nbar\nbaz"; let to_split = "foo\nbar\nbaz";
let parts = super::smart_split(to_split, Some("PREFIX".to_string()), 1000); 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] #[test]
fn test_smart_split_prefix_each() { fn test_smart_split_prefix_each() {
let to_split = "1234\n56\n7"; let to_split = "1234\n56\n7";
let parts = super::smart_split(to_split, Some("PREFIX".to_string()), 10); 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] #[test]
@@ -973,7 +955,7 @@ mod test {
let to_split = "one two threefourfive six"; let to_split = "one two threefourfive six";
let parts = super::smart_split(to_split, None, 10); let parts = super::smart_split(to_split, None, 10);
assert_eq!( 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 parts
); );
} }
+36 -42
View File
@@ -19,6 +19,7 @@ use crate::command::StatusCommand;
use crate::error::GroupError; use crate::error::GroupError;
use crate::store::CommonConfig; use crate::store::CommonConfig;
use crate::store::GroupConfig; use crate::store::GroupConfig;
use crate::tr::TranslationTable;
use crate::utils::{normalize_acct, LogError, VisExt}; use crate::utils::{normalize_acct, LogError, VisExt};
mod handle_mention; mod handle_mention;
@@ -41,19 +42,12 @@ pub struct GroupInternal {
impl Default for GroupInternal { impl Default for GroupInternal {
fn default() -> Self { fn default() -> Self {
Self { Self {
recently_seen_notif_statuses: VecDeque::new() recently_seen_notif_statuses: VecDeque::new(),
} }
} }
} }
// TODO move other options to common_config! #[macro_export]
// // 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_rules! grp_debug { macro_rules! grp_debug {
($self:ident, $f:expr) => { ($self:ident, $f:expr) => {
::log::debug!(concat!("(@{}) ", $f), $self.config.get_acct()); ::log::debug!(concat!("(@{}) ", $f), $self.config.get_acct());
@@ -63,6 +57,7 @@ macro_rules! grp_debug {
}; };
} }
#[macro_export]
macro_rules! grp_info { macro_rules! grp_info {
($self:ident, $f:expr) => { ($self:ident, $f:expr) => {
::log::info!(concat!("(@{}) ", $f), $self.config.get_acct()); ::log::info!(concat!("(@{}) ", $f), $self.config.get_acct());
@@ -72,6 +67,7 @@ macro_rules! grp_info {
}; };
} }
#[macro_export]
macro_rules! grp_trace { macro_rules! grp_trace {
($self:ident, $f:expr) => { ($self:ident, $f:expr) => {
::log::trace!(concat!("(@{}) ", $f), $self.config.get_acct()); ::log::trace!(concat!("(@{}) ", $f), $self.config.get_acct());
@@ -81,6 +77,7 @@ macro_rules! grp_trace {
}; };
} }
#[macro_export]
macro_rules! grp_warn { macro_rules! grp_warn {
($self:ident, $f:expr) => { ($self:ident, $f:expr) => {
::log::warn!(concat!("(@{}) ", $f), $self.config.get_acct()); ::log::warn!(concat!("(@{}) ", $f), $self.config.get_acct());
@@ -90,6 +87,7 @@ macro_rules! grp_warn {
}; };
} }
#[macro_export]
macro_rules! grp_error { macro_rules! grp_error {
($self:ident, $f:expr) => { ($self:ident, $f:expr) => {
::log::error!(concat!("(@{}) ", $f), $self.config.get_acct()); ::log::error!(concat!("(@{}) ", $f), $self.config.get_acct());
@@ -168,27 +166,31 @@ impl GroupHandle {
let socket_open_time = Instant::now(); let socket_open_time = Instant::now();
let mut last_rx = Instant::now(); let mut last_rx = Instant::now();
match self.catch_up_with_missed_notifications().await { if self.cc.max_catchup_notifs > 0 {
Ok(true) => { match self.catch_up_with_missed_notifications().await {
grp_debug!(self, "Some missed notifs handled"); Ok(true) => {
} grp_debug!(self, "Some missed notifs handled");
Ok(false) => { }
grp_debug!(self, "No notifs missed"); Ok(false) => {
} grp_debug!(self, "No notifs missed");
Err(e) => { }
grp_error!(self, "Failed to handle missed notifs: {}", e); Err(e) => {
grp_error!(self, "Failed to handle missed notifs: {}", e);
}
} }
} }
match self.catch_up_with_missed_statuses().await { if self.cc.max_catchup_statuses > 0 {
Ok(true) => { match self.catch_up_with_missed_statuses().await {
grp_debug!(self, "Some missed statuses handled"); Ok(true) => {
} grp_debug!(self, "Some missed statuses handled");
Ok(false) => { }
grp_debug!(self, "No statuses missed"); Ok(false) => {
} grp_debug!(self, "No statuses missed");
Err(e) => { }
grp_error!(self, "Failed to handle missed statuses: {}", e); Err(e) => {
grp_error!(self, "Failed to handle missed statuses: {}", e);
}
} }
} }
@@ -531,6 +533,10 @@ impl GroupHandle {
res res
} }
fn tr(&self) -> &TranslationTable {
self.config.tr()
}
async fn handle_new_follow(&mut self, notif_acct: &str, notif_user_id: &str) { async fn handle_new_follow(&mut self, notif_acct: &str, notif_user_id: &str) {
let mut follow_back = false; let mut follow_back = false;
let text = if self.config.is_member_only() { let text = if self.config.is_member_only() {
@@ -539,26 +545,14 @@ impl GroupHandle {
let mut admins = self.config.get_admins().cloned().collect::<Vec<_>>(); let mut admins = self.config.get_admins().cloned().collect::<Vec<_>>();
admins.sort(); admins.sort();
format!( crate::tr!(self, "mention_prefix", user = notif_acct)
"\ + &crate::tr!(self, "welcome_member_only", admins = &admins.join(", "))
@{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(", ")
)
} else { } else {
follow_back = true; 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!( crate::tr!(self, "mention_prefix", user = notif_acct) + &crate::tr!(self, "welcome_public")
"\
@{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
)
}; };
let post = StatusBuilder::new() let post = StatusBuilder::new()
+12 -8
View File
@@ -18,10 +18,14 @@ use crate::utils::acct_to_server;
mod command; mod command;
mod error; mod error;
#[macro_use]
mod group_handler; mod group_handler;
mod store; mod store;
mod utils; mod utils;
#[macro_use]
mod tr;
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
let args = clap::App::new("groups") let args = clap::App::new("groups")
@@ -72,10 +76,8 @@ async fn main() -> anyhow::Result<()> {
let default_level = 3; let default_level = 3;
let level = ( let level = (default_level as isize + args.occurrences_of("verbose") as isize
default_level as isize - args.occurrences_of("quiet") as isize)
+ args.occurrences_of("verbose") as isize
- args.occurrences_of("quiet") as isize)
.clamp(0, LEVELS.len() as isize) as usize; .clamp(0, LEVELS.len() as isize) as usize;
env_logger::Builder::new() env_logger::Builder::new()
@@ -90,7 +92,7 @@ async fn main() -> anyhow::Result<()> {
.filter_module("mio", LevelFilter::Warn) .filter_module("mio", LevelFilter::Warn)
.init(); .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(), store_dir: args.value_of("config").unwrap_or(".").to_string(),
}) })
.await?; .await?;
@@ -104,14 +106,14 @@ async fn main() -> anyhow::Result<()> {
} }
if let Some(server) = acct_to_server(acct) { if let Some(server) = acct_to_server(acct) {
let g = store store
.auth_new_group(NewGroupOptions { .auth_new_group(NewGroupOptions {
server: format!("https://{}", server), server: format!("https://{}", server),
acct: acct.to_string(), acct: acct.to_string(),
}) })
.await?; .await?;
eprintln!("New group @{} added to config!", g.config.get_acct()); eprintln!("New group added to config!");
return Ok(()); return Ok(());
} else { } else {
anyhow::bail!("--auth handle must be username@server, got: \"{}\"", handle); anyhow::bail!("--auth handle must be username@server, got: \"{}\"", handle);
@@ -120,11 +122,13 @@ async fn main() -> anyhow::Result<()> {
if let Some(acct) = args.value_of("reauth") { if let Some(acct) = args.value_of("reauth") {
let acct = acct.trim_start_matches('@'); let acct = acct.trim_start_matches('@');
let _ = store.reauth_group(acct).await?; store.reauth_group(acct).await?;
eprintln!("Group @{} re-authed!", acct); eprintln!("Group @{} re-authed!", acct);
return Ok(()); return Ok(());
} }
store.find_locales().await;
// Start // Start
let groups = store.spawn_groups().await?; let groups = store.spawn_groups().await?;
+22
View File
@@ -1,6 +1,13 @@
use crate::store::DEFAULT_LOCALE_NAME;
use crate::tr::TranslationTable;
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub struct CommonConfig { pub struct CommonConfig {
pub groups_dir: String,
pub locales_dir: String,
pub validate_locales: bool,
/// Max number of missed notifs to process after connect /// Max number of missed notifs to process after connect
pub max_catchup_notifs: usize, pub max_catchup_notifs: usize,
/// Max number of missed statuses to process after connect /// 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. /// 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. /// This is a work-around for servers that stop sending notifs after a while.
pub socket_retire_time_s: f64, pub socket_retire_time_s: f64,
#[serde(skip)]
pub tr: HashMap<String, TranslationTable>,
} }
impl Default for CommonConfig { impl Default for CommonConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
groups_dir: "groups".to_string(),
locales_dir: "locales".to_string(),
validate_locales: true,
max_catchup_notifs: 30, max_catchup_notifs: 30,
max_catchup_statuses: 50, max_catchup_statuses: 50,
delay_fetch_page_s: 0.25, delay_fetch_page_s: 0.25,
@@ -34,6 +46,16 @@ impl Default for CommonConfig {
delay_reopen_error_s: 5.0, delay_reopen_error_s: 5.0,
socket_alive_timeout_s: 30.0, socket_alive_timeout_s: 30.0,
socket_retire_time_s: 120.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"),
} }
} }
} }
+78 -32
View File
@@ -4,6 +4,8 @@ use std::path::{Path, PathBuf};
use elefren::AppData; use elefren::AppData;
use crate::error::GroupError; use crate::error::GroupError;
use crate::store::{CommonConfig, DEFAULT_LOCALE_NAME};
use crate::tr::TranslationTable;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
@@ -13,6 +15,8 @@ struct FixedConfig {
acct: String, acct: String,
/// elefren data /// elefren data
appdata: AppData, appdata: AppData,
/// configured locale to use
locale: String,
/// Server's character limit /// Server's character limit
character_limit: usize, character_limit: usize,
#[serde(skip)] #[serde(skip)]
@@ -66,6 +70,8 @@ pub struct GroupConfig {
control: MutableConfig, control: MutableConfig,
/// State config with timestamps and transient data that is changed frequently /// State config with timestamps and transient data that is changed frequently
state: StateConfig, 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 { impl Default for FixedConfig {
@@ -73,6 +79,7 @@ impl Default for FixedConfig {
Self { Self {
enabled: true, enabled: true,
acct: "".to_string(), acct: "".to_string(),
locale: DEFAULT_LOCALE_NAME.to_string(),
appdata: AppData { appdata: AppData {
base: Default::default(), base: Default::default(),
client_id: Default::default(), client_id: Default::default(),
@@ -151,22 +158,12 @@ impl_change_tracking!(FixedConfig);
impl_change_tracking!(MutableConfig); impl_change_tracking!(MutableConfig);
impl_change_tracking!(StateConfig); 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> { async fn load_or_create_control_file(control_path: impl AsRef<Path>) -> Result<MutableConfig, GroupError> {
let control_path = control_path.as_ref(); let control_path = control_path.as_ref();
let mut dirty = false; let mut dirty = false;
let mut control: MutableConfig = if control_path.is_file() { let mut control: MutableConfig = if control_path.is_file() {
let f = tokio::fs::read(&control_path).await?; 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._path = control_path.to_owned();
control control
} else { } 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 dirty = false;
let mut state: StateConfig = if state_path.is_file() { let mut state: StateConfig = if state_path.is_file() {
let f = tokio::fs::read(&state_path).await?; 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._path = state_path.to_owned();
control control
} else { } else {
@@ -205,7 +202,22 @@ async fn load_or_create_state_file(state_path: impl AsRef<Path>) -> Result<State
Ok(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 { impl GroupConfig {
pub fn tr(&self) -> &TranslationTable {
&self._group_tr
}
pub(crate) fn is_dirty(&self) -> bool { pub(crate) fn is_dirty(&self) -> bool {
self.config.is_dirty() || self.control.is_dirty() || self.state.is_dirty() self.config.is_dirty() || self.control.is_dirty() || self.state.is_dirty()
} }
@@ -247,7 +259,11 @@ impl GroupConfig {
} }
/// (re)init using new authorization /// (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() { if !group_dir.is_dir() {
debug!("Creating group directory"); debug!("Creating group directory");
tokio::fs::create_dir_all(&group_dir).await?; tokio::fs::create_dir_all(&group_dir).await?;
@@ -263,7 +279,7 @@ impl GroupConfig {
let mut dirty = false; let mut dirty = false;
let mut config: FixedConfig = if config_path.is_file() { let mut config: FixedConfig = if config_path.is_file() {
let f = tokio::fs::read(&config_path).await?; 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; config._path = config_path;
if config.appdata != appdata { if config.appdata != appdata {
config.appdata = appdata; config.appdata = appdata;
@@ -294,21 +310,27 @@ impl GroupConfig {
/* state */ /* state */
let state = load_or_create_state_file(state_path).await?; 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(); 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 config_path = group_dir.join("config.json");
let control_path = group_dir.join("control.json"); let control_path = group_dir.join("control.json");
let state_path = group_dir.join("state.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 // try to reuse content of the files, if present
/* config */ /* config */
let f = tokio::fs::read(&config_path).await?; 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; config._path = config_path;
/* control */ /* control */
@@ -317,7 +339,20 @@ impl GroupConfig {
/* state */ /* state */
let state = load_or_create_state_file(state_path).await?; 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(); g.warn_of_bad_config();
Ok(g) Ok(g)
} }
@@ -437,7 +472,7 @@ impl GroupConfig {
/// Check if the user's server is banned /// Check if the user's server is banned
fn is_users_server_banned(&self, acct: &str) -> bool { fn is_users_server_banned(&self, acct: &str) -> bool {
let server = acct_to_server(acct); let server = acct_to_server(acct);
self.is_server_banned(server) self.is_server_banned(&server)
} }
pub(crate) fn can_write(&self, acct: &str) -> bool { pub(crate) fn can_write(&self, acct: &str) -> bool {
@@ -553,8 +588,8 @@ impl GroupConfig {
} }
} }
fn acct_to_server(acct: &str) -> &str { fn acct_to_server(acct: &str) -> String {
acct.split('@').nth(1).unwrap_or_default() crate::utils::acct_to_server(acct).unwrap_or_default()
} }
#[cfg(test)] #[cfg(test)]
@@ -562,16 +597,25 @@ mod tests {
use crate::error::GroupError; use crate::error::GroupError;
use crate::store::group_config::{acct_to_server, GroupConfig}; 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] #[test]
fn test_acct_to_server() { fn test_acct_to_server() {
assert_eq!("pikachu.rocks", acct_to_server("raichu@pikachu.rocks")); assert_eq!("pikachu.rocks".to_string(), acct_to_server("raichu@pikachu.rocks"));
assert_eq!("pikachu.rocks", acct_to_server("m@pikachu.rocks")); assert_eq!("pikachu.rocks".to_string(), acct_to_server("m@pikachu.rocks"));
assert_eq!("", acct_to_server("what")); assert_eq!("".to_string(), acct_to_server("what"));
} }
#[test] #[test]
fn test_default_rules() { fn test_default_rules() {
let group = GroupConfig::default(); let group = empty_group_config();
assert!(!group.is_member_only()); assert!(!group.is_member_only());
assert!(!group.is_member("piggo@piggo.space")); assert!(!group.is_member("piggo@piggo.space"));
assert!(!group.is_admin("piggo@piggo.space")); assert!(!group.is_admin("piggo@piggo.space"));
@@ -580,7 +624,7 @@ mod tests {
#[test] #[test]
fn test_member_only() { 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"); assert!(group.can_write("piggo@piggo.space"), "rando can write in public group");
group.set_member_only(true); group.set_member_only(true);
@@ -617,7 +661,7 @@ mod tests {
#[test] #[test]
fn test_banned_users() { fn test_banned_users() {
// Banning single user // Banning single user
let mut group = GroupConfig::default(); let mut group = empty_group_config();
group.ban_user("piggo@piggo.space", true).unwrap(); group.ban_user("piggo@piggo.space", true).unwrap();
assert!(!group.can_write("piggo@piggo.space"), "banned user can't post"); assert!(!group.can_write("piggo@piggo.space"), "banned user can't post");
group.ban_user("piggo@piggo.space", false).unwrap(); group.ban_user("piggo@piggo.space", false).unwrap();
@@ -627,7 +671,7 @@ mod tests {
#[test] #[test]
fn test_banned_members() { fn test_banned_members() {
// Banning single user // Banning single user
let mut group = GroupConfig::default(); let mut group = empty_group_config();
group.set_member_only(true); group.set_member_only(true);
group.set_member("piggo@piggo.space", true).unwrap(); group.set_member("piggo@piggo.space", true).unwrap();
@@ -636,19 +680,21 @@ mod tests {
assert!(!group.is_banned("piggo@piggo.space"), "user not banned by default"); assert!(!group.is_banned("piggo@piggo.space"), "user not banned by default");
group.ban_user("piggo@piggo.space", true).unwrap(); 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.is_banned("piggo@piggo.space"), "banned user is banned");
assert!(!group.can_write("piggo@piggo.space"), "banned member can't post"); assert!(!group.can_write("piggo@piggo.space"), "banned member can't post");
// unban // unban
group.ban_user("piggo@piggo.space", false).unwrap(); 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"); assert!(group.can_write("piggo@piggo.space"), "un-ban works");
} }
#[test] #[test]
fn test_server_ban() { 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"); assert!(group.can_write("hitler@nazi.camp"), "randos can write");
group.ban_server("nazi.camp", true).unwrap(); group.ban_server("nazi.camp", true).unwrap();
@@ -668,7 +714,7 @@ mod tests {
#[test] #[test]
fn test_sanity() { fn test_sanity() {
let mut group = GroupConfig::default(); let mut group = empty_group_config();
group.set_admin("piggo@piggo.space", true).unwrap(); group.set_admin("piggo@piggo.space", true).unwrap();
assert_eq!( assert_eq!(
+111 -31
View File
@@ -9,6 +9,7 @@ use crate::group_handler::{GroupHandle, GroupInternal};
pub mod common_config; pub mod common_config;
pub mod group_config; pub mod group_config;
use crate::tr::TranslationTable;
pub use common_config::CommonConfig; pub use common_config::CommonConfig;
pub use group_config::GroupConfig; pub use group_config::GroupConfig;
@@ -16,7 +17,8 @@ pub use group_config::GroupConfig;
pub struct ConfigStore { pub struct ConfigStore {
store_path: PathBuf, store_path: PathBuf,
groups_path: PathBuf, groups_path: PathBuf,
config: Arc<CommonConfig>, locales_path: PathBuf,
config: CommonConfig,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -30,10 +32,13 @@ pub struct StoreOptions {
pub store_dir: String, pub store_dir: String,
} }
const DEFAULT_LOCALE_NAME: &str = "en";
const DEFAULT_LOCALE_JSON: &str = include_str!("../../locales/en.json");
impl ConfigStore { impl ConfigStore {
/// Create a new instance of the store. /// Create a new instance of the store.
/// If a path is given, it will try to load the content from a file. /// 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 given_path: &Path = options.store_dir.as_ref();
let mut common_file: Option<PathBuf> = None; let mut common_file: Option<PathBuf> = None;
@@ -66,7 +71,7 @@ impl ConfigStore {
let config: CommonConfig = if let Some(cf) = &common_file { let config: CommonConfig = if let Some(cf) = &common_file {
debug!("Loading common config from {}", cf.display()); debug!("Loading common config from {}", cf.display());
let f = tokio::fs::read(&cf).await?; let f = tokio::fs::read(&cf).await?;
serde_json::from_slice(&f)? json5::from_str(&String::from_utf8_lossy(&f))?
} else { } else {
debug!("No common config file, using defaults"); debug!("No common config file, using defaults");
CommonConfig::default() CommonConfig::default()
@@ -74,21 +79,41 @@ impl ConfigStore {
debug!("Using common config:\n{:#?}", config); 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() { if !groups_path.exists() {
debug!("Creating groups directory"); debug!("Creating groups directory");
tokio::fs::create_dir_all(&groups_path).await?; 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(), store_path: base_dir.to_owned(),
groups_path, groups_path,
config: Arc::new(config), locales_path,
})) config,
})
} }
/// Spawn a new group /// 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) let registration = Registration::new(&opts.server)
.client_name("group-actor") .client_name("group-actor")
.force_login(true) .force_login(true)
@@ -102,17 +127,16 @@ impl ConfigStore {
let group_dir = self.groups_path.join(&opts.acct); 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 // save & persist
let group_account = match client.verify_credentials().await { match client.verify_credentials().await {
Ok(account) => { Ok(account) => {
info!( info!(
"Group account verified: @{}, \"{}\"", "Group account verified: @{}, \"{}\"",
account.acct, account.display_name account.acct, account.display_name
); );
account
} }
Err(e) => { Err(e) => {
error!("Group @{} auth error: {}", opts.acct, e); error!("Group @{} auth error: {}", opts.acct, e);
@@ -120,20 +144,14 @@ impl ConfigStore {
} }
}; };
Ok(GroupHandle { Ok(())
group_account,
client,
config: data,
cc: self.config.clone(),
internal: GroupInternal::default(),
})
} }
/// Re-auth an existing group /// 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 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); println!("--- Re-authenticating bot user @{} ---", acct);
let registration = Registration::new(config.get_appdata().base.to_string()) let registration = Registration::new(config.get_appdata().base.to_string())
@@ -151,7 +169,7 @@ impl ConfigStore {
config.set_appdata(appdata); config.set_appdata(appdata);
config.save_if_needed(true).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) => { Ok(account) => {
info!( info!(
"Group account verified: @{}, \"{}\"", "Group account verified: @{}, \"{}\"",
@@ -165,26 +183,88 @@ impl ConfigStore {
} }
}; };
Ok(GroupHandle { Ok(())
group_account, }
client,
config, pub async fn find_locales(&mut self) {
cc: self.config.clone(), // Load the default locale, it will be used as fallback to fill-in missing keys
internal: GroupInternal::default(), 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 /// 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()); info!("Starting group services for groups in {}", self.groups_path.display());
let dirs = std::fs::read_dir(&self.groups_path)?; let dirs = std::fs::read_dir(&self.groups_path)?;
let config = Arc::new(self.config);
// Connect in parallel // Connect in parallel
Ok(futures::stream::iter(dirs) Ok(futures::stream::iter(dirs)
.map(|entry_maybe: Result<std::fs::DirEntry, std::io::Error>| async { .map(|entry_maybe: Result<std::fs::DirEntry, std::io::Error>| async {
match entry_maybe { match entry_maybe {
Ok(entry) => { 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() { if !gc.is_enabled() {
debug!("Group @{} is DISABLED", gc.get_acct()); debug!("Group @{} is DISABLED", gc.get_acct());
@@ -213,7 +293,7 @@ impl ConfigStore {
group_account: my_account, group_account: my_account,
client, client,
config: gc, config: gc,
cc: self.config.clone(), cc: config.clone(),
internal: GroupInternal::default(), internal: GroupInternal::default(),
}) })
} }
+83
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),*
])
};
}
+6 -6
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> { pub(crate) fn acct_to_server(acct: &str) -> Option<String> {
acct.trim_start_matches('@').split('@').nth(1) acct.trim_start_matches('@').split('@').nth(1).map(|s| s.to_lowercase())
} }
pub(crate) fn normalize_acct(acct: &str, group: &str) -> Result<String, GroupError> { pub(crate) fn normalize_acct(acct: &str, group: &str) -> Result<String, GroupError> {
@@ -45,8 +45,8 @@ mod test {
#[test] #[test]
fn test_acct_to_server() { fn test_acct_to_server() {
assert_eq!(Some("novak"), acct_to_server("pepa@novak")); assert_eq!(Some("novak".to_string()), acct_to_server("pepa@novak"));
assert_eq!(Some("banana.co.uk"), acct_to_server("@pepa@banana.co.uk")); assert_eq!(Some("banana.co.uk".to_string()), acct_to_server("@pepa@banana.co.uk"));
assert_eq!(None, acct_to_server("probably_local")); assert_eq!(None, acct_to_server("probably_local"));
} }
@@ -82,11 +82,11 @@ mod test {
); );
assert_eq!( assert_eq!(
Ok("piggo@piggo.space".into()), Ok("piggo@piggo.space".into()),
normalize_acct("piGGgo@pIggo.spaCe", "uhh") normalize_acct("piGGo@pIggo.spaCe", "uhh")
); );
assert_eq!( assert_eq!(
Ok("piggo@banana.nana".into()), 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")); assert_eq!(Err(GroupError::BadConfig("_".into())), normalize_acct("piggo", "uhh"));
} }