add option to override locale messages per-group, update readme

pull/14/head
Ondřej Hruška 3 years ago
parent 305d91d1dc
commit 63c4c5f2e8
  1. 66
      README.md
  2. 1
      locales/cs.json
  3. 2
      src/group_handler/handle_mention.rs
  4. 2
      src/group_handler/mod.rs
  5. 2
      src/store/common_config.rs
  6. 53
      src/store/group_config.rs
  7. 17
      src/store/mod.rs
  8. 1
      src/tr.rs

@ -55,43 +55,74 @@ 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}"
}
```
- 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 +142,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!**
@ -127,9 +159,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,4 +1,3 @@
{ {
"welcome_public": "Ahoj",
"ping_response": "pong, toto je fedigroups verze {version}" "ping_response": "pong, toto je fedigroups verze {version}"
} }

@ -35,7 +35,7 @@ pub struct ProcessMention<'a> {
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> {

@ -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) {

@ -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,

@ -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,16 +158,6 @@ 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;
@ -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 = serde_json::from_slice(&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?;
@ -298,15 +306,16 @@ 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
@ -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();

@ -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())
@ -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());

@ -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());
} }

Loading…
Cancel
Save