5 Commits
14 changed files with 215 additions and 85 deletions
+12
View File
@@ -1,5 +1,17 @@
# Changelog # Changelog
## 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.4.0" version = "0.4.2"
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.4.0" version = "0.4.2"
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"
+53 -18
View File
@@ -44,7 +44,7 @@ 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:
@@ -55,43 +55,75 @@ A typical setup could look like this:
│ │ ├── 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
} }
``` ```
@@ -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": "...",
-1
View File
@@ -1,4 +1,3 @@
{ {
"welcome_public": "Ahoj",
"ping_response": "pong, toto je fedigroups verze {version}" "ping_response": "pong, toto je fedigroups verze {version}"
} }
+1 -1
View File
@@ -11,7 +11,7 @@
"help_membership_member": "*You are a member.*", "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_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_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_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)", "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.", "cmd_leave_ok": "You're no longer a group member. Unfollow the group user to stop receiving group messages.",
+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),
} }
+4 -18
View File
@@ -30,12 +30,11 @@ pub struct ProcessMention<'a> {
replies: String, replies: String,
announcements: 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 { 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> { async fn lookup_acct_id(&self, acct: &str, followed: bool) -> Result<Option<String>, GroupError> {
@@ -134,7 +133,6 @@ impl<'a> ProcessMention<'a> {
replies: String::new(), replies: String::new(),
announcements: String::new(), 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,
@@ -271,9 +269,7 @@ impl<'a> ProcessMention<'a> {
let mut msg = std::mem::take(&mut self.replies); let mut msg = std::mem::take(&mut self.replies);
debug!("r={}", msg); debug!("r={}", msg);
if self.want_markdown { apply_trailing_hashtag_pleroma_bug_workaround(&mut msg);
apply_trailing_hashtag_pleroma_bug_workaround(&mut msg);
}
let mention = crate::tr!(self, "mention_prefix", 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?;
@@ -283,9 +279,7 @@ impl<'a> ProcessMention<'a> {
let mut msg = std::mem::take(&mut self.announcements); let mut msg = std::mem::take(&mut self.announcements);
debug!("a={}", msg); debug!("a={}", msg);
if self.want_markdown { apply_trailing_hashtag_pleroma_bug_workaround(&mut msg);
apply_trailing_hashtag_pleroma_bug_workaround(&mut msg);
}
let msg = crate::tr!(self, "group_announcement", message = &msg); let msg = crate::tr!(self, "group_announcement", message = &msg);
self.send_announcement_multipart(&msg).await?; self.send_announcement_multipart(&msg).await?;
@@ -301,11 +295,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()
@@ -647,8 +637,6 @@ impl<'a> ProcessMention<'a> {
} }
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 {
crate::tr!(self, "help_membership_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) {
@@ -677,7 +665,6 @@ impl<'a> ProcessMention<'a> {
} }
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(crate::tr!(self, "member_list_heading")); self.add_reply(crate::tr!(self, "member_list_heading"));
self.append_member_list_to_reply(); self.append_member_list_to_reply();
@@ -689,7 +676,6 @@ impl<'a> ProcessMention<'a> {
async fn cmd_list_tags(&mut self) { async fn cmd_list_tags(&mut self) {
self.add_reply(crate::tr!(self, "tag_list_heading")); 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();
+1 -1
View File
@@ -529,7 +529,7 @@ impl GroupHandle {
} }
fn tr(&self) -> &TranslationTable { 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) { async fn handle_new_follow(&mut self, notif_acct: &str, notif_user_id: &str) {
+2
View File
@@ -7,6 +7,7 @@ use crate::tr::TranslationTable;
pub struct CommonConfig { pub struct CommonConfig {
pub groups_dir: String, pub groups_dir: String,
pub locales_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
@@ -36,6 +37,7 @@ impl Default for CommonConfig {
Self { Self {
groups_dir: "groups".to_string(), groups_dir: "groups".to_string(),
locales_dir: "locales".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,
+52 -30
View File
@@ -4,7 +4,8 @@ use std::path::{Path, PathBuf};
use elefren::AppData; use elefren::AppData;
use crate::error::GroupError; use crate::error::GroupError;
use crate::store::DEFAULT_LOCALE_NAME; use crate::store::{DEFAULT_LOCALE_NAME, CommonConfig};
use crate::tr::TranslationTable;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
@@ -69,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 {
@@ -155,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 {
@@ -192,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 {
@@ -209,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()
} }
@@ -251,7 +259,7 @@ 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?;
@@ -267,7 +275,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;
@@ -298,21 +306,22 @@ 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 */
@@ -321,7 +330,15 @@ 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)
} }
@@ -369,10 +386,6 @@ impl GroupConfig {
&self.config.appdata &self.config.appdata
} }
pub(crate) fn get_locale(&self) -> &str {
&self.config.locale
}
pub(crate) fn set_appdata(&mut self, appdata: AppData) { pub(crate) fn set_appdata(&mut self, appdata: AppData) {
if self.config.appdata != appdata { if self.config.appdata != appdata {
self.config.mark_dirty(); self.config.mark_dirty();
@@ -570,6 +583,15 @@ 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", acct_to_server("raichu@pikachu.rocks"));
@@ -579,7 +601,7 @@ mod tests {
#[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"));
@@ -588,7 +610,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);
@@ -625,7 +647,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();
@@ -635,7 +657,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();
@@ -656,7 +678,7 @@ mod tests {
#[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();
@@ -676,7 +698,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!(
+11 -10
View File
@@ -71,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()
@@ -127,7 +127,7 @@ 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
@@ -151,7 +151,7 @@ impl ConfigStore {
pub async fn reauth_group(&self, acct: &str) -> Result<(), 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())
@@ -225,7 +225,7 @@ impl ConfigStore {
} }
fn load_locale(&mut self, locale_name: &str, locale_json: &str, is_default: bool) { 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); debug!("Loaded locale: {}", locale_name);
if !is_default { if !is_default {
@@ -233,11 +233,12 @@ impl ConfigStore {
for (k, v) in def_tr.entries() { for (k, v) in def_tr.entries() {
if !tr.translation_exists(k) { if !tr.translation_exists(k) {
warn!("locale \"{}\" is missing \"{}\", default: {:?}", if self.config.validate_locales {
locale_name, warn!("locale \"{}\" is missing \"{}\", default: {:?}",
k, locale_name,
def_tr.get_translation_raw(k).unwrap()); k,
def_tr.get_translation_raw(k).unwrap());
}
tr.add_translation(k, v); tr.add_translation(k, v);
} }
} }
@@ -261,7 +262,7 @@ impl ConfigStore {
.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());
+1
View File
@@ -23,6 +23,7 @@ impl TranslationTable {
self.entries.get(key).map(|s| s.as_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) { pub fn add_translation(&mut self, key : impl ToString, subs : impl ToString) {
self.entries.insert(key.to_string(), subs.to_string()); self.entries.insert(key.to_string(), subs.to_string());
} }