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
│ │ └── 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}"
}
```
- 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 +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 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!**
@ -127,9 +159,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": "...",

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

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

@ -529,7 +529,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) {

@ -7,6 +7,7 @@ use crate::tr::TranslationTable;
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
@ -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,

@ -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::{DEFAULT_LOCALE_NAME, CommonConfig};
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,16 +158,6 @@ 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;
@ -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 = serde_json::from_slice(&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,7 @@ impl GroupConfig {
}
/// (re)init using new authorization
pub(crate) async fn from_appdata(acct: String, appdata: AppData, group_dir: PathBuf) -> Result<Self, GroupError> {
pub(crate) async fn initialize_by_appdata(acct: String, appdata: AppData, group_dir: PathBuf) -> Result<(), GroupError> {
if !group_dir.is_dir() {
debug!("Creating group directory");
tokio::fs::create_dir_all(&group_dir).await?;
@ -298,15 +306,16 @@ 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
@ -321,7 +330,15 @@ impl GroupConfig {
/* state */
let state = load_or_create_state_file(state_path).await?;
let g = GroupConfig { config, control, state };
/* translation table */
let mut tr = cc.tr(&config.locale).clone();
if let Some(locale_overrides) = load_locale_override_file(locale_path).await? {
for (k, v) in locale_overrides.entries() {
tr.add_translation(k, v);
}
}
let g = GroupConfig { config, control, state, _group_tr: tr };
g.warn_of_bad_config();
Ok(g)
}
@ -369,10 +386,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();

@ -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())
@ -233,11 +233,12 @@ 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 +262,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());

@ -23,6 +23,7 @@ impl TranslationTable {
self.entries.get(key).map(|s| s.as_str())
}
/// Add or update a translation
pub fn add_translation(&mut self, key : impl ToString, subs : impl ToString) {
self.entries.insert(key.to_string(), subs.to_string());
}

Loading…
Cancel
Save