Compare commits

...

13 commits

Author SHA1 Message Date
Ondřej Hruška 5389031d8c nobot fix 2022-11-02 09:28:40 +01:00
Ondřej Hruška e77c8157ae
cargo fmt, version bump 2021-11-02 23:49:00 +01:00
Ondřej Hruška cb6724baab
add server lowercasing also to group_config::acct_to_server() 2021-11-02 23:48:43 +01:00
Ondřej Hruška cbd3c0a575 Merge branch 'master' of fgaz/group-actor into master 2021-11-02 22:31:56 +00:00
Francesco Gazzetta e1ac2777f3 Normalize server from group actor too
as specified in tests
2021-11-02 11:36:45 +01:00
Francesco Gazzetta 900f499932 Fix minor mistakes in tests that made them fail 2021-11-02 11:36:45 +01:00
Ondřej Hruška ebcf12e46c
changelog 2021-10-12 10:38:46 +02:00
Ondřej Hruška 0d37425c32
fix hashtag not working in mention 2021-10-12 10:38:02 +02:00
Ondřej Hruška 7f14dbc215
fix bad version 2021-10-12 00:40:40 +02:00
Ondřej Hruška 5fb5f087d6
hashtag bug fix 2021-10-12 00:36:08 +02:00
Ondřej Hruška 6ff0e3653d
changelog, add json5 2021-10-12 00:20:42 +02:00
Ondřej Hruška 4ddc26c6ca
fixes in help cmd, make everything use markdown 2021-10-12 00:12:19 +02:00
Ondřej Hruška 63c4c5f2e8 add option to override locale messages per-group, update readme 2021-10-11 13:24:17 +02:00
16 changed files with 376 additions and 188 deletions

View file

@ -1,5 +1,27 @@
# Changelog
## v0.4.5
- Ignore #nobot in bio if the user is also a member
## v0.4.4
- Fix some failing tests
- Lowercase the domain when normalizing an account
## v0.4.3
- Fix hashtag not working in a mention
## v0.4.2
- Fix URL fragment detected as hashtag
## v0.4.1
- "en" translation fixes
- add `messages.json`
- Config files are now parsed as JSON5 = comments are allowed
## v0.4.0
- Add a translation system using the `locales` folder
- Add more trace logging
## v0.3.0
- Changed config/storage format to directory-based, removed shared config mutex
- Made more options configurable (timeouts, catch-up limits, etc)

69
Cargo.lock generated
View file

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

View file

@ -1,6 +1,6 @@
[package]
name = "fedigroups"
version = "0.4.0"
version = "0.4.5"
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"

View file

@ -44,7 +44,7 @@ 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:
@ -55,43 +55,75 @@ A typical setup could look like this:
│ │ ├── 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
}
```
@ -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": "...",

View file

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

View file

@ -11,7 +11,7 @@
"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_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.",

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> =
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"));

View file

@ -8,6 +8,8 @@ pub enum GroupError {
UserIsBanned,
#[error("User opted out from the group")]
UserOptedOut,
#[error("User opted out from the group using #nobot")]
UserOptedOutNobot,
#[error("Server could not be banned because there are admin users on it")]
AdminsOnServer,
#[error("Config error: {0}")]
@ -19,6 +21,8 @@ pub enum GroupError {
#[error(transparent)]
Serializer(#[from] serde_json::Error),
#[error(transparent)]
Serializer5(#[from] json5::Error),
#[error(transparent)]
Elefren(#[from] elefren::Error),
}

View file

@ -14,7 +14,9 @@ use crate::store::group_config::GroupConfig;
use crate::store::CommonConfig;
use crate::tr::TranslationTable;
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> {
status: Status,
@ -30,26 +32,25 @@ pub struct ProcessMention<'a> {
replies: String,
announcements: String,
do_boost_prev_post: bool,
want_markdown: bool,
}
impl<'a> ProcessMention<'a> {
fn tr(&self) -> &TranslationTable {
self.cc.tr(self.config.get_locale())
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)) => {
@ -61,14 +62,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)
}
}
@ -93,23 +94,23 @@ impl<'a> ProcessMention<'a> {
let mut to_add = String::new();
for m in members {
to_add.push_str(&if admins.contains(&m) {
crate::tr!(self, "user_list_entry_admin", user=m)
crate::tr!(self, "user_list_entry_admin", user = m)
} else {
crate::tr!(self, "user_list_entry", user=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(())
@ -120,7 +121,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(());
}
@ -134,7 +135,6 @@ impl<'a> ProcessMention<'a> {
replies: String::new(),
announcements: String::new(),
do_boost_prev_post: false,
want_markdown: false,
group_acct,
status_acct,
status,
@ -145,8 +145,7 @@ 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;
}
@ -165,7 +164,7 @@ 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(());
}
@ -261,7 +260,7 @@ impl<'a> ProcessMention<'a> {
self.delay_after_post().await;
}
Err(e) => {
warn!("Can't reblog: {}", e);
grp_warn!(self, "Can't reblog: {}", e);
}
}
}
@ -269,11 +268,9 @@ impl<'a> ProcessMention<'a> {
if !self.replies.is_empty() {
let mut msg = std::mem::take(&mut self.replies);
debug!("r={}", msg);
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 = crate::tr!(self, "mention_prefix", user = &self.status_acct);
self.send_reply_multipart(mention, msg).await?;
@ -281,11 +278,9 @@ impl<'a> ProcessMention<'a> {
if !self.announcements.is_empty() {
let mut msg = std::mem::take(&mut self.announcements);
debug!("a={}", msg);
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 = crate::tr!(self, "group_announcement", message = &msg);
self.send_announcement_multipart(&msg).await?;
@ -301,11 +296,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()
@ -349,24 +340,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);
}
@ -374,7 +386,7 @@ 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);
}
}
@ -402,25 +414,25 @@ impl<'a> ProcessMention<'a> {
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)
}
@ -446,7 +458,7 @@ impl<'a> ProcessMention<'a> {
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(())
}
@ -468,7 +480,7 @@ impl<'a> ProcessMention<'a> {
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(())
}
@ -488,7 +500,7 @@ impl<'a> ProcessMention<'a> {
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");
}
}
@ -507,7 +519,7 @@ impl<'a> ProcessMention<'a> {
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");
}
}
@ -526,7 +538,7 @@ impl<'a> ProcessMention<'a> {
}
}
} else {
warn!("Ignore cmd, user not admin");
grp_warn!(self, "Ignore cmd, user not admin");
}
Ok(())
}
@ -544,7 +556,7 @@ impl<'a> ProcessMention<'a> {
}
}
} else {
warn!("Ignore cmd, user not admin");
grp_warn!(self, "Ignore cmd, user not admin");
}
Ok(())
}
@ -558,7 +570,7 @@ impl<'a> ProcessMention<'a> {
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");
}
}
@ -571,7 +583,7 @@ impl<'a> ProcessMention<'a> {
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");
}
}
@ -594,7 +606,7 @@ impl<'a> ProcessMention<'a> {
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(())
}
@ -615,7 +627,7 @@ impl<'a> ProcessMention<'a> {
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(())
}
@ -629,7 +641,7 @@ impl<'a> ProcessMention<'a> {
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");
}
}
@ -642,13 +654,11 @@ impl<'a> ProcessMention<'a> {
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 {
crate::tr!(self, "help_membership_admin")
} else if self.config.is_member(&self.status_acct) {
@ -660,7 +670,11 @@ impl<'a> ProcessMention<'a> {
};
if self.config.is_member_only() {
self.add_reply(crate::tr!(self, "help_group_info_closed", membership = &membership_line));
self.add_reply(crate::tr!(
self,
"help_group_info_closed",
membership = &membership_line
));
} else {
self.add_reply(crate::tr!(self, "help_group_info_open", membership = &membership_line));
}
@ -677,7 +691,6 @@ impl<'a> ProcessMention<'a> {
}
async fn cmd_list_members(&mut self) {
self.want_markdown = true;
if self.is_admin {
self.add_reply(crate::tr!(self, "member_list_heading"));
self.append_member_list_to_reply();
@ -689,14 +702,13 @@ impl<'a> ProcessMention<'a> {
async fn cmd_list_tags(&mut self) {
self.add_reply(crate::tr!(self, "tag_list_heading"));
self.want_markdown = true;
let mut tags = self.config.get_tags().collect::<Vec<_>>();
tags.sort();
let mut to_add = String::new();
for t in tags {
to_add.push_str(&crate::tr!(self, "tag_list_entry", tag=t));
to_add.push_str(&crate::tr!(self, "tag_list_entry", tag = t));
}
self.add_reply(to_add);
}
@ -715,7 +727,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");
@ -761,11 +773,11 @@ impl<'a> ProcessMention<'a> {
// Try to unfollow
let account = self.client.get_account(id).await?;
let bio = utils::strip_html(&account.note);
if RE_NOBOT_TAG.is_match(&bio) {
// #nobot
Err(GroupError::UserOptedOut)
let normalized = normalize_acct(&account.acct, &self.group_acct)?;
if RE_NOBOT_TAG.is_match(&bio) && !self.config.is_member(&normalized) {
// #nobot in a non-member account
Err(GroupError::UserOptedOutNobot)
} else {
let normalized = normalize_acct(&account.acct, &self.group_acct)?;
if self.config.is_banned(&normalized) {
Err(GroupError::UserIsBanned)
} else if self.config.is_optout(&normalized) {
@ -779,13 +791,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(" .");
}
}
}
@ -799,29 +811,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());
}
}
@ -832,18 +844,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());
}
@ -855,7 +867,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());
}
}

View file

@ -42,11 +42,12 @@ pub struct GroupInternal {
impl Default for GroupInternal {
fn default() -> Self {
Self {
recently_seen_notif_statuses: VecDeque::new()
recently_seen_notif_statuses: VecDeque::new(),
}
}
}
#[macro_export]
macro_rules! grp_debug {
($self:ident, $f:expr) => {
::log::debug!(concat!("(@{}) ", $f), $self.config.get_acct());
@ -56,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());
@ -65,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());
@ -74,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());
@ -83,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());
@ -529,7 +534,7 @@ impl GroupHandle {
}
fn tr(&self) -> &TranslationTable {
self.cc.tr(self.config.get_locale())
self.config.tr()
}
async fn handle_new_follow(&mut self, notif_acct: &str, notif_user_id: &str) {
@ -547,8 +552,7 @@ impl GroupHandle {
self.config.set_member(notif_acct, true).log_error("Fail add a member");
crate::tr!(self, "mention_prefix", user = notif_acct)
+ &crate::tr!(self, "welcome_public")
crate::tr!(self, "mention_prefix", user = notif_acct) + &crate::tr!(self, "welcome_public")
};
let post = StatusBuilder::new()

View file

@ -18,6 +18,7 @@ use crate::utils::acct_to_server;
mod command;
mod error;
#[macro_use]
mod group_handler;
mod store;
mod utils;
@ -75,10 +76,8 @@ async fn main() -> anyhow::Result<()> {
let default_level = 3;
let level = (
default_level as isize
+ args.occurrences_of("verbose") as isize
- args.occurrences_of("quiet") as isize)
let level = (default_level as isize + args.occurrences_of("verbose") as isize
- args.occurrences_of("quiet") as isize)
.clamp(0, LEVELS.len() as isize) as usize;
env_logger::Builder::new()

View file

@ -1,12 +1,13 @@
use std::collections::HashMap;
use crate::store::DEFAULT_LOCALE_NAME;
use crate::tr::TranslationTable;
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct CommonConfig {
pub groups_dir: String,
pub locales_dir: String,
pub validate_locales: bool,
/// Max number of missed notifs to process after connect
pub max_catchup_notifs: usize,
/// Max number of missed statuses to process after connect
@ -28,7 +29,7 @@ pub struct CommonConfig {
/// 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>,
pub tr: HashMap<String, TranslationTable>,
}
impl Default for CommonConfig {
@ -36,6 +37,7 @@ impl Default for CommonConfig {
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,
@ -50,10 +52,10 @@ impl Default for CommonConfig {
}
impl CommonConfig {
pub fn tr(&self, lang : &str) -> &TranslationTable {
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")
None => self.tr.get(DEFAULT_LOCALE_NAME).expect("default locale is not loaded"),
}
}
}

View file

@ -4,7 +4,8 @@ use std::path::{Path, PathBuf};
use elefren::AppData;
use crate::error::GroupError;
use crate::store::DEFAULT_LOCALE_NAME;
use crate::store::{CommonConfig, DEFAULT_LOCALE_NAME};
use crate::tr::TranslationTable;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
@ -69,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 {
@ -155,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 {
@ -192,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 {
@ -209,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()
}
@ -251,7 +259,11 @@ 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?;
@ -267,7 +279,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;
@ -298,21 +310,27 @@ 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 */
@ -321,7 +339,20 @@ 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)
}
@ -369,10 +400,6 @@ impl GroupConfig {
&self.config.appdata
}
pub(crate) fn get_locale(&self) -> &str {
&self.config.locale
}
pub(crate) fn set_appdata(&mut self, appdata: AppData) {
if self.config.appdata != appdata {
self.config.mark_dirty();
@ -445,7 +472,7 @@ impl GroupConfig {
/// Check if the user's server is banned
fn is_users_server_banned(&self, acct: &str) -> bool {
let server = acct_to_server(acct);
self.is_server_banned(server)
self.is_server_banned(&server)
}
pub(crate) fn can_write(&self, acct: &str) -> bool {
@ -561,8 +588,8 @@ impl GroupConfig {
}
}
fn acct_to_server(acct: &str) -> &str {
acct.split('@').nth(1).unwrap_or_default()
fn acct_to_server(acct: &str) -> String {
crate::utils::acct_to_server(acct).unwrap_or_default()
}
#[cfg(test)]
@ -570,16 +597,25 @@ 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"));
assert_eq!("pikachu.rocks", acct_to_server("m@pikachu.rocks"));
assert_eq!("", acct_to_server("what"));
assert_eq!("pikachu.rocks".to_string(), acct_to_server("raichu@pikachu.rocks"));
assert_eq!("pikachu.rocks".to_string(), acct_to_server("m@pikachu.rocks"));
assert_eq!("".to_string(), acct_to_server("what"));
}
#[test]
fn test_default_rules() {
let group = 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"));
@ -588,7 +624,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);
@ -625,7 +661,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();
@ -635,7 +671,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();
@ -644,19 +680,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();
@ -676,7 +714,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!(

View file

@ -9,9 +9,9 @@ use crate::group_handler::{GroupHandle, GroupInternal};
pub mod common_config;
pub mod group_config;
use crate::tr::TranslationTable;
pub use common_config::CommonConfig;
pub use group_config::GroupConfig;
use crate::tr::TranslationTable;
#[derive(Debug, Default)]
pub struct ConfigStore {
@ -32,8 +32,8 @@ pub struct StoreOptions {
pub store_dir: String,
}
const DEFAULT_LOCALE_NAME : &str = "en";
const DEFAULT_LOCALE_JSON : &str = include_str!("../../locales/en.json");
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.
@ -71,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()
@ -127,7 +127,7 @@ 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
@ -151,7 +151,7 @@ impl ConfigStore {
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())
@ -214,7 +214,7 @@ impl ConfigStore {
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);
}
@ -225,7 +225,7 @@ impl ConfigStore {
}
fn load_locale(&mut self, locale_name: &str, locale_json: &str, is_default: bool) {
if let Ok(mut tr) = serde_json::from_str::<TranslationTable>(locale_json) {
if let Ok(mut tr) = json5::from_str::<TranslationTable>(locale_json) {
debug!("Loaded locale: {}", locale_name);
if !is_default {
@ -233,11 +233,14 @@ impl ConfigStore {
for (k, v) in def_tr.entries() {
if !tr.translation_exists(k) {
warn!("locale \"{}\" is missing \"{}\", default: {:?}",
locale_name,
k,
def_tr.get_translation_raw(k).unwrap());
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);
}
}
@ -261,7 +264,7 @@ impl ConfigStore {
.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());

View file

@ -2,7 +2,7 @@
use std::collections::HashMap;
#[derive(Debug,Clone,Serialize,Deserialize,Default)]
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TranslationTable {
#[serde(flatten)]
entries: HashMap<String, String>,
@ -15,23 +15,24 @@ impl TranslationTable {
}
/// Iterate all entries
pub fn entries(&self) -> impl Iterator<Item=(&String, &String)> {
pub fn entries(&self) -> impl Iterator<Item = (&String, &String)> {
self.entries.iter()
}
pub fn get_translation_raw(&self, key : &str) -> Option<&str> {
pub fn get_translation_raw(&self, key: &str) -> Option<&str> {
self.entries.get(key).map(|s| s.as_str())
}
pub fn add_translation(&mut self, key : impl ToString, subs : impl ToString) {
/// 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 {
pub fn translation_exists(&self, key: &str) -> bool {
self.entries.contains_key(key)
}
pub fn subs(&self, key : &str, substitutions: &[&str]) -> String {
pub fn subs(&self, key: &str, substitutions: &[&str]) -> String {
match self.entries.get(key) {
Some(s) => {
// TODO optimize
@ -44,7 +45,7 @@ impl TranslationTable {
}
s
}
None => key.to_owned()
None => key.to_owned(),
}
}
}
@ -55,12 +56,11 @@ mod tests {
#[test]
fn deser_tr_table() {
let tr : TranslationTable = serde_json::from_str(r#"{"foo":"bar"}"#).unwrap();
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();

View file

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