mirror of
https://git.ondrovo.com/MightyPork/group-actor.git
synced 2024-11-25 01:21:02 +00:00
Merge branch 'trans'
This commit is contained in:
commit
305d91d1dc
12 changed files with 418 additions and 206 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
@ -328,7 +328,7 @@ checksum = "e88a8acf291dafb59c2d96e8f59828f3838bb1a70398823ade51a84de6a6deed"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fedigroups"
|
name = "fedigroups"
|
||||||
version = "0.3.0"
|
version = "0.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "fedigroups"
|
name = "fedigroups"
|
||||||
version = "0.3.0"
|
version = "0.4.0"
|
||||||
authors = ["Ondřej Hruška <ondra@ondrovo.com>"]
|
authors = ["Ondřej Hruška <ondra@ondrovo.com>"]
|
||||||
edition = "2018"
|
edition = "2018"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
10
README.md
10
README.md
|
@ -36,7 +36,7 @@ You can also run the program using Cargo, that is handy for development: `cargo
|
||||||
3. **Make sure you auth as the correct user!**
|
3. **Make sure you auth as the correct user!**
|
||||||
4. Paste the Oauth2 token you got into the terminal, hit enter.
|
4. Paste the Oauth2 token you got into the terminal, hit enter.
|
||||||
|
|
||||||
The program now ends. The credentials are saved in the directory `groups.d/account@server/`, which is created if missing.
|
The program now ends. The credentials are saved in the directory `groups/account@server/`, which is created if missing.
|
||||||
|
|
||||||
You can repeat this for any number of groups.
|
You can repeat this for any number of groups.
|
||||||
|
|
||||||
|
@ -49,7 +49,7 @@ In case you need to re-authenticate an existing group, do the same but use `-A`
|
||||||
A typical setup could look like this:
|
A typical setup could look like this:
|
||||||
|
|
||||||
```
|
```
|
||||||
├── groups.d
|
├── groups
|
||||||
│ ├── betty@piggo.space
|
│ ├── betty@piggo.space
|
||||||
│ │ ├── config.json
|
│ │ ├── config.json
|
||||||
│ │ ├── control.json
|
│ │ ├── control.json
|
||||||
|
@ -97,8 +97,8 @@ There is one shared config file: `groups.json`
|
||||||
|
|
||||||
#### Per-group config
|
#### Per-group config
|
||||||
|
|
||||||
Each group is stored as a sub-directory of `groups.d/`. The sub-directories are normally named after their accounts,
|
Each group is stored as a sub-directory of `groups/`. The sub-directories are normally named after their accounts,
|
||||||
but this is not required. For example, `groups.d/betty@piggo.space/`.
|
but this is not required. For example, `groups/betty@piggo.space/`.
|
||||||
|
|
||||||
The group's config and state is split into three files in a way that minimizes the risk of data loss.
|
The group's config and state is split into three files in a way that minimizes the risk of data loss.
|
||||||
|
|
||||||
|
@ -180,7 +180,7 @@ Internal use, millisecond timestamps of the last-seen status and notification.
|
||||||
### Running
|
### Running
|
||||||
|
|
||||||
To run the group service, simply run it with no arguments.
|
To run the group service, simply run it with no arguments.
|
||||||
It will read the `groups.json` file (if present), find groups in `groups.d/` and start the services for you.
|
It will read the `groups.json` file (if present), find groups in `groups/` and start the services for you.
|
||||||
|
|
||||||
Note that the control and status files must be writable, they are updated at run-time.
|
Note that the control and status files must be writable, they are updated at run-time.
|
||||||
Config files can have limited permissions to avoid accidental overwrite.
|
Config files can have limited permissions to avoid accidental overwrite.
|
||||||
|
|
4
locales/cs.json
Normal file
4
locales/cs.json
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"welcome_public": "Ahoj",
|
||||||
|
"ping_response": "pong, toto je fedigroups verze {version}"
|
||||||
|
}
|
55
locales/en.json
Normal file
55
locales/en.json
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
{
|
||||||
|
"welcome_public": "Welcome to the group! The group user will now follow you back to complete the sign-up.\nTo share a post, @ the group user or use a group hashtag.\n\nUse /help for more info.",
|
||||||
|
"welcome_member_only": "Welcome to the group! This group has posting restricted to members.\nIf you'd like to join, please ask one of the group admins:\n{admins}",
|
||||||
|
"welcome_join_cmd": "Welcome to the group! The group user will now follow you to complete the sign-up. Make sure you follow back to receive shared posts!\n\nUse /help for more info.",
|
||||||
|
"welcome_closed": "Sorry, this group is closed to new sign-ups.\nPlease ask one of the group admins to add you:\n",
|
||||||
|
"user_list_entry": "- {user}\n",
|
||||||
|
"user_list_entry_admin": "- {user} [admin]\n",
|
||||||
|
"help_group_info_closed": "This is a member-only group. {membership}\n",
|
||||||
|
"help_group_info_open": "This is a public-access group. {membership}\n",
|
||||||
|
"help_membership_admin": "*You are an admin.*",
|
||||||
|
"help_membership_member": "*You are a member.*",
|
||||||
|
"help_membership_guest_closed": "*You are not a member, ask one of the admins to add you.*",
|
||||||
|
"help_membership_guest_open": "*You are not a member, follow or use /join to join the group.*",
|
||||||
|
"help_admin_commands": "\n\n**Admin commands:**\n`/ping` - check the group works\n`/members - show group members / admins\n`/add user` - add a member (user@domain)\n`/remove user` - remove a member\n`/add #hashtag` - add a group hashtag\n`/remove #hashtag` - remove a group hashtag\n`/undo` - un-boost a replied-to post, delete an announcement\n`/ban x` - ban a user or server\n`/unban x` - lift a ban\n`/admin user` - grant admin rights\n`/deadmin user` - revoke admin rights\n`/closegroup` - make member-only\n`/opengroup` - make public-access\n`/announce x` - make a public announcement",
|
||||||
|
"help_basic_commands": "To share a post, @ the group user or use a group hashtag.\n\n**Supported commands:**\n`/boost`, `/b` - boost the replied-to post into the group\n`/ignore`, `/i` - make the group ignore the post\n`/tags` - show group hashtags\n`/join` - (re-)join the group\n`/leave` - leave the group\n`/optout` - forbid sharing of your posts",
|
||||||
|
"help_member_commands": "\n`/admins` - show group admins\n`/undo` - un-boost your post (use in a reply)",
|
||||||
|
"cmd_leave_ok": "You're no longer a group member. Unfollow the group user to stop receiving group messages.",
|
||||||
|
"member_list_heading": "Group members:\n",
|
||||||
|
"admin_list_heading": "Group admins:\n",
|
||||||
|
"tag_list_heading": "Group tags:\n",
|
||||||
|
"tag_list_entry": "- {tag}\n",
|
||||||
|
"cmd_error": "Command failed: {cause}",
|
||||||
|
"cmd_close_ok": "Group changed to member-only",
|
||||||
|
"cmd_close_fail_already": "No action, group is member-only already",
|
||||||
|
"cmd_open_ok": "Group changed to open-access",
|
||||||
|
"cmd_open_fail_already": "No action, group is open-access already",
|
||||||
|
"cmd_optout_fail_admin_cant": "Group admins can't opt-out.",
|
||||||
|
"cmd_optout_fail_member_cant": "Group members can't opt-out. You have to leave first.",
|
||||||
|
"cmd_optout_ok": "Your posts will no longer be shared to the group.",
|
||||||
|
"cmd_optin_fail_admin_cant": "Opt-in has no effect for admins.",
|
||||||
|
"cmd_optin_fail_member_cant": "Opt-in has no effect for members.",
|
||||||
|
"cmd_optin_ok": "Your posts can now be shared to the group.",
|
||||||
|
"cmd_ban_user_ok": "User {user} banned from group!",
|
||||||
|
"cmd_ban_user_fail_already": "No action, user {user} is already banned",
|
||||||
|
"cmd_unban_user_ok": "User {user} un-banned!",
|
||||||
|
"cmd_unban_user_fail_already": "No action, user {user} is not banned",
|
||||||
|
"cmd_ban_server_ok": "Server {server} banned from group!",
|
||||||
|
"cmd_ban_server_fail_already": "No action, server {server} already banned",
|
||||||
|
"cmd_unban_server_ok": "Server {server} un-banned!",
|
||||||
|
"cmd_unban_server_fail_already": "No action, server {server} is not banned",
|
||||||
|
"cmd_add_user_ok": "User {user} added to the group!",
|
||||||
|
"cmd_remove_user_ok": "User {user} removed from the group.",
|
||||||
|
"cmd_add_tag_ok": "Tag #{tag} added to the group!",
|
||||||
|
"cmd_add_tag_fail_already": "No action, #{tag} is already a group tag",
|
||||||
|
"cmd_remove_tag_ok": "Tag #{tag} removed from the group!",
|
||||||
|
"cmd_remove_tag_fail_already": "No action, #{tag} is not a group tag",
|
||||||
|
"cmd_admin_ok": "User {user} is now a group admin!",
|
||||||
|
"cmd_admin_fail_already": "No action, user {user} is a group admin already",
|
||||||
|
"cmd_unadmin_ok": "User {user} is no longer a group admin!",
|
||||||
|
"cmd_unadmin_fail_already": "No action, user {user} is not a group admin",
|
||||||
|
|
||||||
|
"mention_prefix": "@{user} ",
|
||||||
|
"group_announcement": "**📢Group announcement**\n{message}",
|
||||||
|
"ping_response": "pong, this is fedigroups service v{version}"
|
||||||
|
}
|
|
@ -12,6 +12,7 @@ use crate::error::GroupError;
|
||||||
use crate::group_handler::GroupHandle;
|
use crate::group_handler::GroupHandle;
|
||||||
use crate::store::group_config::GroupConfig;
|
use crate::store::group_config::GroupConfig;
|
||||||
use crate::store::CommonConfig;
|
use crate::store::CommonConfig;
|
||||||
|
use crate::tr::TranslationTable;
|
||||||
use crate::utils;
|
use crate::utils;
|
||||||
use crate::utils::{normalize_acct, LogError};
|
use crate::utils::{normalize_acct, LogError};
|
||||||
|
|
||||||
|
@ -26,13 +27,17 @@ pub struct ProcessMention<'a> {
|
||||||
status_user_id: String,
|
status_user_id: String,
|
||||||
can_write: bool,
|
can_write: bool,
|
||||||
is_admin: bool,
|
is_admin: bool,
|
||||||
replies: Vec<String>,
|
replies: String,
|
||||||
announcements: Vec<String>,
|
announcements: String,
|
||||||
do_boost_prev_post: bool,
|
do_boost_prev_post: bool,
|
||||||
want_markdown: bool,
|
want_markdown: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> ProcessMention<'a> {
|
impl<'a> ProcessMention<'a> {
|
||||||
|
fn tr(&self) -> &TranslationTable {
|
||||||
|
self.cc.tr(self.config.get_locale())
|
||||||
|
}
|
||||||
|
|
||||||
async fn lookup_acct_id(&self, acct: &str, followed: bool) -> Result<Option<String>, GroupError> {
|
async fn lookup_acct_id(&self, acct: &str, followed: bool) -> Result<Option<String>, GroupError> {
|
||||||
debug!("Looking up user ID by acct: {}", acct);
|
debug!("Looking up user ID by acct: {}", acct);
|
||||||
|
|
||||||
|
@ -72,9 +77,11 @@ impl<'a> ProcessMention<'a> {
|
||||||
fn append_admin_list_to_reply(&mut self) {
|
fn append_admin_list_to_reply(&mut self) {
|
||||||
let mut admins = self.config.get_admins().collect::<Vec<_>>();
|
let mut admins = self.config.get_admins().collect::<Vec<_>>();
|
||||||
admins.sort();
|
admins.sort();
|
||||||
|
let mut to_add = String::new();
|
||||||
for a in admins {
|
for a in admins {
|
||||||
self.replies.push(format!("- {}", a));
|
to_add.push_str(&crate::tr!(self, "user_list_entry", user = a));
|
||||||
}
|
}
|
||||||
|
self.add_reply(&to_add);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn append_member_list_to_reply(&mut self) {
|
fn append_member_list_to_reply(&mut self) {
|
||||||
|
@ -83,13 +90,15 @@ impl<'a> ProcessMention<'a> {
|
||||||
members.extend(admins.iter());
|
members.extend(admins.iter());
|
||||||
members.sort();
|
members.sort();
|
||||||
members.dedup();
|
members.dedup();
|
||||||
|
let mut to_add = String::new();
|
||||||
for m in members {
|
for m in members {
|
||||||
self.replies.push(if admins.contains(&m) {
|
to_add.push_str(&if admins.contains(&m) {
|
||||||
format!("- {} [admin]", m)
|
crate::tr!(self, "user_list_entry_admin", user=m)
|
||||||
} else {
|
} else {
|
||||||
format!("- {}", m)
|
crate::tr!(self, "user_list_entry", user=m)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
self.add_reply(&to_add);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn follow_user_by_id(&self, id: &str) -> Result<(), GroupError> {
|
async fn follow_user_by_id(&self, id: &str) -> Result<(), GroupError> {
|
||||||
|
@ -122,8 +131,8 @@ impl<'a> ProcessMention<'a> {
|
||||||
cc: &gh.cc,
|
cc: &gh.cc,
|
||||||
can_write: gh.config.can_write(&status_acct),
|
can_write: gh.config.can_write(&status_acct),
|
||||||
is_admin: gh.config.is_admin(&status_acct),
|
is_admin: gh.config.is_admin(&status_acct),
|
||||||
replies: vec![],
|
replies: String::new(),
|
||||||
announcements: vec![],
|
announcements: String::new(),
|
||||||
do_boost_prev_post: false,
|
do_boost_prev_post: false,
|
||||||
want_markdown: false,
|
want_markdown: false,
|
||||||
group_acct,
|
group_acct,
|
||||||
|
@ -136,16 +145,17 @@ impl<'a> ProcessMention<'a> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn reblog_status(&self) {
|
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;
|
self.delay_after_post().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add_reply(&mut self, line: impl AsRef<str>) {
|
fn add_reply(&mut self, line: impl AsRef<str>) {
|
||||||
self.replies.push(line.as_ref().trim_matches(' ').to_string())
|
self.replies.push_str(line.as_ref())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add_announcement(&mut self, line: impl AsRef<str>) {
|
fn add_announcement(&mut self, line: impl AsRef<str>) {
|
||||||
self.announcements.push(line.as_ref().trim_matches(' ').to_string())
|
self.announcements.push_str(line.as_ref())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle(mut self) -> Result<(), GroupError> {
|
async fn handle(mut self) -> Result<(), GroupError> {
|
||||||
|
@ -160,6 +170,9 @@ impl<'a> ProcessMention<'a> {
|
||||||
}
|
}
|
||||||
|
|
||||||
for cmd in commands {
|
for cmd in commands {
|
||||||
|
if !self.replies.is_empty() {
|
||||||
|
self.replies.push('\n'); // make sure there's a newline between batched commands.
|
||||||
|
}
|
||||||
match cmd {
|
match cmd {
|
||||||
StatusCommand::Undo => {
|
StatusCommand::Undo => {
|
||||||
self.cmd_undo().await.log_error("Error handling undo cmd");
|
self.cmd_undo().await.log_error("Error handling undo cmd");
|
||||||
|
@ -207,7 +220,7 @@ impl<'a> ProcessMention<'a> {
|
||||||
self.cmd_grant_admin(&u).await.log_error("Error handling grant-admin cmd");
|
self.cmd_grant_admin(&u).await.log_error("Error handling grant-admin cmd");
|
||||||
}
|
}
|
||||||
StatusCommand::RemoveAdmin(u) => {
|
StatusCommand::RemoveAdmin(u) => {
|
||||||
self.cmd_revoke_member(&u).await.log_error("Error handling grant-admin cmd");
|
self.cmd_revoke_admin(&u).await.log_error("Error handling grant-admin cmd");
|
||||||
}
|
}
|
||||||
StatusCommand::OpenGroup => {
|
StatusCommand::OpenGroup => {
|
||||||
self.cmd_open_group().await;
|
self.cmd_open_group().await;
|
||||||
|
@ -255,26 +268,26 @@ impl<'a> ProcessMention<'a> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if !self.replies.is_empty() {
|
if !self.replies.is_empty() {
|
||||||
let mut msg = self.replies.join("\n");
|
let mut msg = std::mem::take(&mut self.replies);
|
||||||
debug!("r={}", msg);
|
debug!("r={}", msg);
|
||||||
|
|
||||||
if self.want_markdown {
|
if self.want_markdown {
|
||||||
apply_trailing_hashtag_pleroma_bug_workaround(&mut msg);
|
apply_trailing_hashtag_pleroma_bug_workaround(&mut msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mention = format!("@{user} ", user = self.status_acct);
|
let mention = crate::tr!(self, "mention_prefix", user = &self.status_acct);
|
||||||
self.send_reply_multipart(mention, msg).await?;
|
self.send_reply_multipart(mention, msg).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if !self.announcements.is_empty() {
|
if !self.announcements.is_empty() {
|
||||||
let mut msg = self.announcements.join("\n");
|
let mut msg = std::mem::take(&mut self.announcements);
|
||||||
debug!("a={}", msg);
|
debug!("a={}", msg);
|
||||||
|
|
||||||
if self.want_markdown {
|
if self.want_markdown {
|
||||||
apply_trailing_hashtag_pleroma_bug_workaround(&mut msg);
|
apply_trailing_hashtag_pleroma_bug_workaround(&mut msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
let msg = format!("**📢 Group announcement**\n{msg}", msg = msg);
|
let msg = crate::tr!(self, "group_announcement", message = &msg);
|
||||||
self.send_announcement_multipart(&msg).await?;
|
self.send_announcement_multipart(&msg).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -367,23 +380,23 @@ impl<'a> ProcessMention<'a> {
|
||||||
|
|
||||||
async fn cmd_optout(&mut self) {
|
async fn cmd_optout(&mut self) {
|
||||||
if self.is_admin {
|
if self.is_admin {
|
||||||
self.add_reply("Group admins can't opt-out.");
|
self.add_reply(crate::tr!(self, "cmd_optout_fail_admin_cant"));
|
||||||
} else if self.config.is_member(&self.status_acct) {
|
} else if self.config.is_member(&self.status_acct) {
|
||||||
self.add_reply("Group members can't opt-out. You have to leave first.");
|
self.add_reply(crate::tr!(self, "cmd_optout_fail_member_cant"));
|
||||||
} else {
|
} else {
|
||||||
self.config.set_optout(&self.status_acct, true);
|
self.config.set_optout(&self.status_acct, true);
|
||||||
self.add_reply("Your posts will no longer be shared to the group.");
|
self.add_reply(crate::tr!(self, "cmd_optout_ok"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cmd_optin(&mut self) {
|
async fn cmd_optin(&mut self) {
|
||||||
if self.is_admin {
|
if self.is_admin {
|
||||||
self.add_reply("Opt-in has no effect for admins.");
|
self.add_reply(crate::tr!(self, "cmd_optin_fail_admin_cant"));
|
||||||
} else if self.config.is_member(&self.status_acct) {
|
} else if self.config.is_member(&self.status_acct) {
|
||||||
self.add_reply("Opt-in has no effect for members.");
|
self.add_reply(crate::tr!(self, "cmd_optin_fail_member_cant"));
|
||||||
} else {
|
} else {
|
||||||
self.config.set_optout(&self.status_acct, false);
|
self.config.set_optout(&self.status_acct, false);
|
||||||
self.add_reply("Your posts can now be shared to the group.");
|
self.add_reply(crate::tr!(self, "cmd_optin_ok"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -422,15 +435,15 @@ impl<'a> ProcessMention<'a> {
|
||||||
if !self.config.is_banned(&u) {
|
if !self.config.is_banned(&u) {
|
||||||
match self.config.ban_user(&u, true) {
|
match self.config.ban_user(&u, true) {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
self.add_reply(format!("User {} banned from group!", u));
|
self.add_reply(crate::tr!(self, "cmd_ban_user_ok", user = &u));
|
||||||
self.unfollow_by_acct(&u).await.log_error("Failed to unfollow banned user");
|
self.unfollow_by_acct(&u).await.log_error("Failed to unfollow banned user");
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
self.add_reply(format!("Failed to ban user {}: {}", u, e));
|
self.add_reply(crate::tr!(self, "cmd_error", cause = &e.to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
self.add_reply(format!("No action, user {} is already banned", u));
|
self.add_reply(crate::tr!(self, "cmd_ban_user_fail_already", user = &u));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
warn!("Ignore cmd, user not admin");
|
warn!("Ignore cmd, user not admin");
|
||||||
|
@ -444,15 +457,15 @@ impl<'a> ProcessMention<'a> {
|
||||||
if self.config.is_banned(&u) {
|
if self.config.is_banned(&u) {
|
||||||
match self.config.ban_user(&u, false) {
|
match self.config.ban_user(&u, false) {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
self.add_reply(format!("User {} un-banned!", u));
|
self.add_reply(crate::tr!(self, "cmd_unban_user_ok", user = &u));
|
||||||
// no announcement here
|
// no announcement here
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(e) => {
|
||||||
unreachable!()
|
self.add_reply(crate::tr!(self, "cmd_error", cause = &e.to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
self.add_reply(format!("No action, user {} is not banned", u));
|
self.add_reply(crate::tr!(self, "cmd_unban_user_fail_already", user = &u));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
warn!("Ignore cmd, user not admin");
|
warn!("Ignore cmd, user not admin");
|
||||||
|
@ -465,14 +478,14 @@ impl<'a> ProcessMention<'a> {
|
||||||
if !self.config.is_server_banned(s) {
|
if !self.config.is_server_banned(s) {
|
||||||
match self.config.ban_server(s, true) {
|
match self.config.ban_server(s, true) {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
self.add_reply(format!("Server {} banned from group!", s));
|
self.add_reply(crate::tr!(self, "cmd_ban_server_ok", server = s));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
self.add_reply(format!("Failed to ban server {}: {}", s, e));
|
self.add_reply(crate::tr!(self, "cmd_error", cause = &e.to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
self.add_reply(format!("No action, server {} already banned", s));
|
self.add_reply(crate::tr!(self, "cmd_ban_server_fail_already", server = s));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
warn!("Ignore cmd, user not admin");
|
warn!("Ignore cmd, user not admin");
|
||||||
|
@ -484,14 +497,14 @@ impl<'a> ProcessMention<'a> {
|
||||||
if self.config.is_server_banned(s) {
|
if self.config.is_server_banned(s) {
|
||||||
match self.config.ban_server(s, false) {
|
match self.config.ban_server(s, false) {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
self.add_reply(format!("Server {} un-banned!", s));
|
self.add_reply(crate::tr!(self, "cmd_unban_server_ok", server = s));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
self.add_reply(format!("Unexpected error occured: {}", e));
|
self.add_reply(crate::tr!(self, "cmd_error", cause = &e.to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
self.add_reply(format!("No action, server {} is not banned", s));
|
self.add_reply(crate::tr!(self, "cmd_unban_server_fail_already", server = s));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
warn!("Ignore cmd, user not admin");
|
warn!("Ignore cmd, user not admin");
|
||||||
|
@ -504,11 +517,12 @@ impl<'a> ProcessMention<'a> {
|
||||||
// Allow even if the user is already a member - that will trigger re-follow
|
// Allow even if the user is already a member - that will trigger re-follow
|
||||||
match self.config.set_member(&u, true) {
|
match self.config.set_member(&u, true) {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
self.add_reply(format!("User {} added to the group!", u));
|
self.add_reply(crate::tr!(self, "cmd_add_user_ok", user = &u));
|
||||||
|
// marked as member, now also follow the user
|
||||||
self.follow_by_acct(&u).await.log_error("Failed to follow");
|
self.follow_by_acct(&u).await.log_error("Failed to follow");
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
self.add_reply(format!("Failed to add user {} to group: {}", u, e));
|
self.add_reply(crate::tr!(self, "cmd_error", cause = &e.to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
@ -522,11 +536,11 @@ impl<'a> ProcessMention<'a> {
|
||||||
if self.is_admin {
|
if self.is_admin {
|
||||||
match self.config.set_member(&u, false) {
|
match self.config.set_member(&u, false) {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
self.add_reply(format!("User {} removed from the group.", u));
|
self.add_reply(crate::tr!(self, "cmd_remove_user_ok", user = &u));
|
||||||
self.unfollow_by_acct(&u).await.log_error("Failed to unfollow removed user");
|
self.unfollow_by_acct(&u).await.log_error("Failed to unfollow removed user");
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
self.add_reply(format!("Unexpected error occured: {}", e));
|
self.add_reply(crate::tr!(self, "cmd_error", cause = &e.to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
@ -539,9 +553,9 @@ impl<'a> ProcessMention<'a> {
|
||||||
if self.is_admin {
|
if self.is_admin {
|
||||||
if !self.config.is_tag_followed(&tag) {
|
if !self.config.is_tag_followed(&tag) {
|
||||||
self.config.add_tag(&tag);
|
self.config.add_tag(&tag);
|
||||||
self.add_reply(format!("Tag \"{}\" added to the group!", tag));
|
self.add_reply(crate::tr!(self, "cmd_add_tag_ok", tag = &tag));
|
||||||
} else {
|
} else {
|
||||||
self.add_reply(format!("No action, \"{}\" is already a group tag", tag));
|
self.add_reply(crate::tr!(self, "cmd_add_tag_fail_already", tag = &tag));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
warn!("Ignore cmd, user not admin");
|
warn!("Ignore cmd, user not admin");
|
||||||
|
@ -552,9 +566,9 @@ impl<'a> ProcessMention<'a> {
|
||||||
if self.is_admin {
|
if self.is_admin {
|
||||||
if self.config.is_tag_followed(&tag) {
|
if self.config.is_tag_followed(&tag) {
|
||||||
self.config.remove_tag(&tag);
|
self.config.remove_tag(&tag);
|
||||||
self.add_reply(format!("Tag \"{}\" removed from the group!", tag));
|
self.add_reply(crate::tr!(self, "cmd_remove_tag_ok", tag = &tag));
|
||||||
} else {
|
} else {
|
||||||
self.add_reply(format!("No action, \"{}\" is not a group tag", tag));
|
self.add_reply(crate::tr!(self, "cmd_remove_tag_fail_already", tag = &tag));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
warn!("Ignore cmd, user not admin");
|
warn!("Ignore cmd, user not admin");
|
||||||
|
@ -570,14 +584,14 @@ impl<'a> ProcessMention<'a> {
|
||||||
// try to make the config a little more sane, admins should be members
|
// try to make the config a little more sane, admins should be members
|
||||||
let _ = self.config.set_member(&u, true);
|
let _ = self.config.set_member(&u, true);
|
||||||
|
|
||||||
self.add_reply(format!("User {} is now a group admin!", u));
|
self.add_reply(crate::tr!(self, "cmd_admin_ok", user = &u));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
self.add_reply(format!("Failed to make user {} a group admin: {}", u, e));
|
self.add_reply(crate::tr!(self, "cmd_error", cause = &e.to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
self.add_reply(format!("No action, \"{}\" is admin already", u));
|
self.add_reply(crate::tr!(self, "cmd_admin_fail_already", user = &u));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
warn!("Ignore cmd, user not admin");
|
warn!("Ignore cmd, user not admin");
|
||||||
|
@ -585,20 +599,20 @@ impl<'a> ProcessMention<'a> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cmd_revoke_member(&mut self, user: &str) -> Result<(), GroupError> {
|
async fn cmd_revoke_admin(&mut self, user: &str) -> Result<(), GroupError> {
|
||||||
let u = normalize_acct(user, &self.group_acct)?;
|
let u = normalize_acct(user, &self.group_acct)?;
|
||||||
if self.is_admin {
|
if self.is_admin {
|
||||||
if self.config.is_admin(&u) {
|
if self.config.is_admin(&u) {
|
||||||
match self.config.set_admin(&u, false) {
|
match self.config.set_admin(&u, false) {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
self.add_reply(format!("User {} is no longer a group admin!", u));
|
self.add_reply(crate::tr!(self, "cmd_unadmin_ok", user = &u));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
self.add_reply(format!("Failed to revoke {}'s group admin: {}", u, e));
|
self.add_reply(crate::tr!(self, "cmd_error", cause = &e.to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
self.add_reply(format!("No action, user {} is not admin", u));
|
self.add_reply(crate::tr!(self, "cmd_unadmin_fail_already", user = &u));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
warn!("Ignore cmd, user not admin");
|
warn!("Ignore cmd, user not admin");
|
||||||
|
@ -610,9 +624,9 @@ impl<'a> ProcessMention<'a> {
|
||||||
if self.is_admin {
|
if self.is_admin {
|
||||||
if self.config.is_member_only() {
|
if self.config.is_member_only() {
|
||||||
self.config.set_member_only(false);
|
self.config.set_member_only(false);
|
||||||
self.add_reply("Group changed to open-access");
|
self.add_reply(crate::tr!(self, "cmd_open_resp"));
|
||||||
} else {
|
} else {
|
||||||
self.add_reply("No action, group is open-access already");
|
self.add_reply(crate::tr!(self, "cmd_open_resp_already"));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
warn!("Ignore cmd, user not admin");
|
warn!("Ignore cmd, user not admin");
|
||||||
|
@ -623,9 +637,9 @@ impl<'a> ProcessMention<'a> {
|
||||||
if self.is_admin {
|
if self.is_admin {
|
||||||
if !self.config.is_member_only() {
|
if !self.config.is_member_only() {
|
||||||
self.config.set_member_only(true);
|
self.config.set_member_only(true);
|
||||||
self.add_reply("Group changed to member-only");
|
self.add_reply(crate::tr!(self, "cmd_close_resp"));
|
||||||
} else {
|
} else {
|
||||||
self.add_reply("No action, group is member-only already");
|
self.add_reply(crate::tr!(self, "cmd_close_resp_already"));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
warn!("Ignore cmd, user not admin");
|
warn!("Ignore cmd, user not admin");
|
||||||
|
@ -636,93 +650,62 @@ impl<'a> ProcessMention<'a> {
|
||||||
self.want_markdown = true;
|
self.want_markdown = true;
|
||||||
|
|
||||||
let membership_line = if self.is_admin {
|
let membership_line = if self.is_admin {
|
||||||
"*You are an admin.*"
|
crate::tr!(self, "help_membership_admin")
|
||||||
} else if self.config.is_member(&self.status_acct) {
|
} else if self.config.is_member(&self.status_acct) {
|
||||||
"*You are a member.*"
|
crate::tr!(self, "help_membership_member")
|
||||||
} else if self.config.is_member_only() {
|
} else if self.config.is_member_only() {
|
||||||
"*You are not a member, ask one of the admins to add you.*"
|
crate::tr!(self, "help_membership_guest_closed")
|
||||||
} else {
|
} else {
|
||||||
"*You are not a member, follow or use /join to join the group.*"
|
crate::tr!(self, "help_membership_guest_open")
|
||||||
};
|
};
|
||||||
|
|
||||||
if self.config.is_member_only() {
|
if self.config.is_member_only() {
|
||||||
self.add_reply(format!("This is a member-only group. {}", membership_line));
|
self.add_reply(crate::tr!(self, "help_group_info_closed", membership = &membership_line));
|
||||||
} else {
|
} else {
|
||||||
self.add_reply(format!("This is a public-access group. {}", membership_line));
|
self.add_reply(crate::tr!(self, "help_group_info_open", membership = &membership_line));
|
||||||
}
|
}
|
||||||
|
|
||||||
self.add_reply(
|
self.add_reply(crate::tr!(self, "help_basic_commands"));
|
||||||
"\
|
|
||||||
To share a post, @ the group user or use a group hashtag.\n\
|
|
||||||
\n\
|
|
||||||
**Supported commands:**\n\
|
|
||||||
`/boost`, `/b` - boost the replied-to post into the group\n\
|
|
||||||
`/ignore`, `/i` - make the group ignore the post\n\
|
|
||||||
`/tags` - show group hashtags\n\
|
|
||||||
`/join` - (re-)join the group\n\
|
|
||||||
`/leave` - leave the group\n\
|
|
||||||
`/optout` - forbid sharing of your posts",
|
|
||||||
);
|
|
||||||
|
|
||||||
if self.is_admin {
|
if !self.is_admin {
|
||||||
self.add_reply("`/members`, `/who` - show group members / admins");
|
self.add_reply(crate::tr!(self, "help_member_commands"));
|
||||||
// undo is listed as an admin command
|
|
||||||
} else {
|
|
||||||
self.add_reply("`/admins` - show group admins");
|
|
||||||
self.add_reply("`/undo` - un-boost your post (use in a reply)");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// XXX when used on instance with small character limit, this won't fit!
|
|
||||||
|
|
||||||
if self.is_admin {
|
if self.is_admin {
|
||||||
self.add_reply(
|
self.add_reply(crate::tr!(self, "help_admin_commands"));
|
||||||
"\n\
|
|
||||||
**Admin commands:**\n\
|
|
||||||
`/ping` - check the group works\n\
|
|
||||||
`/add user` - add a member (user@domain)\n\
|
|
||||||
`/remove user` - remove a member\n\
|
|
||||||
`/add #hashtag` - add a group hashtag\n\
|
|
||||||
`/remove #hashtag` - remove a group hashtag\n\
|
|
||||||
`/undo` - un-boost a replied-to post, delete an announcement\n\
|
|
||||||
`/ban x` - ban a user or server\n\
|
|
||||||
`/unban x` - lift a ban\n\
|
|
||||||
`/admin user` - grant admin rights\n\
|
|
||||||
`/deadmin user` - revoke admin rights\n\
|
|
||||||
`/closegroup` - make member-only\n\
|
|
||||||
`/opengroup` - make public-access\n\
|
|
||||||
`/announce x` - make a public announcement",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cmd_list_members(&mut self) {
|
async fn cmd_list_members(&mut self) {
|
||||||
self.want_markdown = true;
|
self.want_markdown = true;
|
||||||
if self.is_admin {
|
if self.is_admin {
|
||||||
self.add_reply("Group members:");
|
self.add_reply(crate::tr!(self, "member_list_heading"));
|
||||||
self.append_member_list_to_reply();
|
self.append_member_list_to_reply();
|
||||||
} else {
|
} else {
|
||||||
self.add_reply("Group admins:");
|
self.add_reply(crate::tr!(self, "admin_list_heading"));
|
||||||
self.append_admin_list_to_reply();
|
self.append_admin_list_to_reply();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cmd_list_tags(&mut self) {
|
async fn cmd_list_tags(&mut self) {
|
||||||
self.add_reply("Group tags:");
|
self.add_reply(crate::tr!(self, "tag_list_heading"));
|
||||||
self.want_markdown = true;
|
self.want_markdown = true;
|
||||||
let mut tags = self.config.get_tags().collect::<Vec<_>>();
|
let mut tags = self.config.get_tags().collect::<Vec<_>>();
|
||||||
tags.sort();
|
tags.sort();
|
||||||
|
|
||||||
|
let mut to_add = String::new();
|
||||||
|
|
||||||
for t in tags {
|
for t in tags {
|
||||||
self.replies.push(format!("- {}", t).to_string());
|
to_add.push_str(&crate::tr!(self, "tag_list_entry", tag=t));
|
||||||
}
|
}
|
||||||
|
self.add_reply(to_add);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cmd_leave(&mut self) {
|
async fn cmd_leave(&mut self) {
|
||||||
if self.config.is_member_or_admin(&self.status_acct) {
|
if self.config.is_member_or_admin(&self.status_acct) {
|
||||||
// admin can leave but that's a bad idea
|
// admin can leave but that's a bad idea
|
||||||
let _ = self.config.set_member(&self.status_acct, false);
|
let _ = self.config.set_member(&self.status_acct, false);
|
||||||
self.add_reply(
|
self.add_reply(crate::tr!(self, "cmd_leave_resp"));
|
||||||
"You're no longer a group member. Unfollow the group user to stop receiving group messages.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
self.unfollow_user_by_id(&self.status_user_id)
|
self.unfollow_user_by_id(&self.status_user_id)
|
||||||
|
@ -740,12 +723,7 @@ impl<'a> ProcessMention<'a> {
|
||||||
// Not a member yet
|
// Not a member yet
|
||||||
if self.config.is_member_only() {
|
if self.config.is_member_only() {
|
||||||
// No you can't
|
// No you can't
|
||||||
self.add_reply(
|
self.add_reply(crate::tr!(self, "welcome_closed"));
|
||||||
"\
|
|
||||||
Sorry, this group is closed to new sign-ups.\n\
|
|
||||||
Please ask one of the group admins to add you:",
|
|
||||||
);
|
|
||||||
|
|
||||||
self.append_admin_list_to_reply();
|
self.append_admin_list_to_reply();
|
||||||
} else {
|
} else {
|
||||||
// Open access, try to follow back
|
// Open access, try to follow back
|
||||||
|
@ -753,21 +731,13 @@ impl<'a> ProcessMention<'a> {
|
||||||
|
|
||||||
// This only fails if the user is banned, but that is filtered above
|
// This only fails if the user is banned, but that is filtered above
|
||||||
let _ = self.config.set_member(&self.status_acct, true);
|
let _ = self.config.set_member(&self.status_acct, true);
|
||||||
self.add_reply(
|
self.add_reply(crate::tr!(self, "welcome_join_cmd"));
|
||||||
"\
|
|
||||||
Welcome to the group! The group user will now follow you to complete the sign-up. \
|
|
||||||
Make sure you follow back to receive shared posts!\n\n\
|
|
||||||
Use /help for more info.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cmd_ping(&mut self) {
|
async fn cmd_ping(&mut self) {
|
||||||
self.add_reply(format!(
|
self.add_reply(crate::tr!(self, "ping_response", version = env!("CARGO_PKG_VERSION")));
|
||||||
"pong, this is fedigroups service v{}",
|
|
||||||
env!("CARGO_PKG_VERSION")
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn unfollow_by_acct(&self, acct: &str) -> Result<(), GroupError> {
|
async fn unfollow_by_acct(&self, acct: &str) -> Result<(), GroupError> {
|
||||||
|
@ -917,21 +887,21 @@ mod test {
|
||||||
let to_split = "foo\nbar\nbaz";
|
let to_split = "foo\nbar\nbaz";
|
||||||
|
|
||||||
let parts = super::smart_split(to_split, None, 1000);
|
let parts = super::smart_split(to_split, None, 1000);
|
||||||
assert_eq!(vec!["foo\nbar\nbaz".to_string(),], parts);
|
assert_eq!(vec!["foo\nbar\nbaz".to_string()], parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_smart_split_nosplit_prefix() {
|
fn test_smart_split_nosplit_prefix() {
|
||||||
let to_split = "foo\nbar\nbaz";
|
let to_split = "foo\nbar\nbaz";
|
||||||
let parts = super::smart_split(to_split, Some("PREFIX".to_string()), 1000);
|
let parts = super::smart_split(to_split, Some("PREFIX".to_string()), 1000);
|
||||||
assert_eq!(vec!["PREFIXfoo\nbar\nbaz".to_string(),], parts);
|
assert_eq!(vec!["PREFIXfoo\nbar\nbaz".to_string()], parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_smart_split_prefix_each() {
|
fn test_smart_split_prefix_each() {
|
||||||
let to_split = "1234\n56\n7";
|
let to_split = "1234\n56\n7";
|
||||||
let parts = super::smart_split(to_split, Some("PREFIX".to_string()), 10);
|
let parts = super::smart_split(to_split, Some("PREFIX".to_string()), 10);
|
||||||
assert_eq!(vec!["PREFIX1234".to_string(), "PREFIX56\n7".to_string(),], parts);
|
assert_eq!(vec!["PREFIX1234".to_string(), "PREFIX56\n7".to_string()], parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
@ -973,7 +943,7 @@ mod test {
|
||||||
let to_split = "one two threefourfive six";
|
let to_split = "one two threefourfive six";
|
||||||
let parts = super::smart_split(to_split, None, 10);
|
let parts = super::smart_split(to_split, None, 10);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
vec!["one two".to_string(), "threefourf".to_string(), "ive six".to_string(),],
|
vec!["one two".to_string(), "threefourf".to_string(), "ive six".to_string()],
|
||||||
parts
|
parts
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,6 +19,7 @@ use crate::command::StatusCommand;
|
||||||
use crate::error::GroupError;
|
use crate::error::GroupError;
|
||||||
use crate::store::CommonConfig;
|
use crate::store::CommonConfig;
|
||||||
use crate::store::GroupConfig;
|
use crate::store::GroupConfig;
|
||||||
|
use crate::tr::TranslationTable;
|
||||||
use crate::utils::{normalize_acct, LogError, VisExt};
|
use crate::utils::{normalize_acct, LogError, VisExt};
|
||||||
|
|
||||||
mod handle_mention;
|
mod handle_mention;
|
||||||
|
@ -46,14 +47,6 @@ impl Default for GroupInternal {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO move other options to common_config!
|
|
||||||
|
|
||||||
// // const DELAY_BEFORE_ACTION: Duration = Duration::from_millis(250);
|
|
||||||
// const DELAY_REOPEN_STREAM: Duration = Duration::from_millis(500);
|
|
||||||
// // higher because we can expect a lot of non-hashtag statuses here
|
|
||||||
// const SOCKET_ALIVE_TIMEOUT: Duration = Duration::from_secs(30);
|
|
||||||
// const SOCKET_RETIRE_TIME: Duration = Duration::from_secs(120);
|
|
||||||
|
|
||||||
macro_rules! grp_debug {
|
macro_rules! grp_debug {
|
||||||
($self:ident, $f:expr) => {
|
($self:ident, $f:expr) => {
|
||||||
::log::debug!(concat!("(@{}) ", $f), $self.config.get_acct());
|
::log::debug!(concat!("(@{}) ", $f), $self.config.get_acct());
|
||||||
|
@ -168,6 +161,7 @@ impl GroupHandle {
|
||||||
let socket_open_time = Instant::now();
|
let socket_open_time = Instant::now();
|
||||||
let mut last_rx = Instant::now();
|
let mut last_rx = Instant::now();
|
||||||
|
|
||||||
|
if self.cc.max_catchup_notifs > 0 {
|
||||||
match self.catch_up_with_missed_notifications().await {
|
match self.catch_up_with_missed_notifications().await {
|
||||||
Ok(true) => {
|
Ok(true) => {
|
||||||
grp_debug!(self, "Some missed notifs handled");
|
grp_debug!(self, "Some missed notifs handled");
|
||||||
|
@ -179,7 +173,9 @@ impl GroupHandle {
|
||||||
grp_error!(self, "Failed to handle missed notifs: {}", e);
|
grp_error!(self, "Failed to handle missed notifs: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.cc.max_catchup_statuses > 0 {
|
||||||
match self.catch_up_with_missed_statuses().await {
|
match self.catch_up_with_missed_statuses().await {
|
||||||
Ok(true) => {
|
Ok(true) => {
|
||||||
grp_debug!(self, "Some missed statuses handled");
|
grp_debug!(self, "Some missed statuses handled");
|
||||||
|
@ -191,6 +187,7 @@ impl GroupHandle {
|
||||||
grp_error!(self, "Failed to handle missed statuses: {}", e);
|
grp_error!(self, "Failed to handle missed statuses: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
self.save_if_needed().await.log_error("Failed to save");
|
self.save_if_needed().await.log_error("Failed to save");
|
||||||
|
|
||||||
|
@ -531,6 +528,10 @@ impl GroupHandle {
|
||||||
res
|
res
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn tr(&self) -> &TranslationTable {
|
||||||
|
self.cc.tr(self.config.get_locale())
|
||||||
|
}
|
||||||
|
|
||||||
async fn handle_new_follow(&mut self, notif_acct: &str, notif_user_id: &str) {
|
async fn handle_new_follow(&mut self, notif_acct: &str, notif_user_id: &str) {
|
||||||
let mut follow_back = false;
|
let mut follow_back = false;
|
||||||
let text = if self.config.is_member_only() {
|
let text = if self.config.is_member_only() {
|
||||||
|
@ -539,26 +540,15 @@ impl GroupHandle {
|
||||||
let mut admins = self.config.get_admins().cloned().collect::<Vec<_>>();
|
let mut admins = self.config.get_admins().cloned().collect::<Vec<_>>();
|
||||||
admins.sort();
|
admins.sort();
|
||||||
|
|
||||||
format!(
|
crate::tr!(self, "mention_prefix", user = notif_acct)
|
||||||
"\
|
+ &crate::tr!(self, "welcome_member_only", admins = &admins.join(", "))
|
||||||
@{user} Welcome to the group! This group has posting restricted to members. \
|
|
||||||
If you'd like to join, please ask one of the group admins:\n\
|
|
||||||
{admins}",
|
|
||||||
user = notif_acct,
|
|
||||||
admins = admins.join(", ")
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
follow_back = true;
|
follow_back = true;
|
||||||
|
|
||||||
self.config.set_member(notif_acct, true).log_error("Fail add a member");
|
self.config.set_member(notif_acct, true).log_error("Fail add a member");
|
||||||
|
|
||||||
format!(
|
crate::tr!(self, "mention_prefix", user = notif_acct)
|
||||||
"\
|
+ &crate::tr!(self, "welcome_public")
|
||||||
@{user} Welcome to the group! The group user will now follow you back to complete the sign-up. \
|
|
||||||
To share a post, @ the group user or use a group hashtag.\n\n\
|
|
||||||
Use /help for more info.",
|
|
||||||
user = notif_acct
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let post = StatusBuilder::new()
|
let post = StatusBuilder::new()
|
||||||
|
|
13
src/main.rs
13
src/main.rs
|
@ -22,6 +22,9 @@ mod group_handler;
|
||||||
mod store;
|
mod store;
|
||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
|
#[macro_use]
|
||||||
|
mod tr;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
let args = clap::App::new("groups")
|
let args = clap::App::new("groups")
|
||||||
|
@ -90,7 +93,7 @@ async fn main() -> anyhow::Result<()> {
|
||||||
.filter_module("mio", LevelFilter::Warn)
|
.filter_module("mio", LevelFilter::Warn)
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
let store = store::ConfigStore::load_from_fs(StoreOptions {
|
let mut store = store::ConfigStore::load_from_fs(StoreOptions {
|
||||||
store_dir: args.value_of("config").unwrap_or(".").to_string(),
|
store_dir: args.value_of("config").unwrap_or(".").to_string(),
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
@ -104,14 +107,14 @@ async fn main() -> anyhow::Result<()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(server) = acct_to_server(acct) {
|
if let Some(server) = acct_to_server(acct) {
|
||||||
let g = store
|
store
|
||||||
.auth_new_group(NewGroupOptions {
|
.auth_new_group(NewGroupOptions {
|
||||||
server: format!("https://{}", server),
|
server: format!("https://{}", server),
|
||||||
acct: acct.to_string(),
|
acct: acct.to_string(),
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
eprintln!("New group @{} added to config!", g.config.get_acct());
|
eprintln!("New group added to config!");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
} else {
|
} else {
|
||||||
anyhow::bail!("--auth handle must be username@server, got: \"{}\"", handle);
|
anyhow::bail!("--auth handle must be username@server, got: \"{}\"", handle);
|
||||||
|
@ -120,11 +123,13 @@ async fn main() -> anyhow::Result<()> {
|
||||||
|
|
||||||
if let Some(acct) = args.value_of("reauth") {
|
if let Some(acct) = args.value_of("reauth") {
|
||||||
let acct = acct.trim_start_matches('@');
|
let acct = acct.trim_start_matches('@');
|
||||||
let _ = store.reauth_group(acct).await?;
|
store.reauth_group(acct).await?;
|
||||||
eprintln!("Group @{} re-authed!", acct);
|
eprintln!("Group @{} re-authed!", acct);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
store.find_locales().await;
|
||||||
|
|
||||||
// Start
|
// Start
|
||||||
let groups = store.spawn_groups().await?;
|
let groups = store.spawn_groups().await?;
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,12 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use crate::store::DEFAULT_LOCALE_NAME;
|
||||||
|
use crate::tr::TranslationTable;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(default, deny_unknown_fields)]
|
#[serde(default, deny_unknown_fields)]
|
||||||
pub struct CommonConfig {
|
pub struct CommonConfig {
|
||||||
|
pub groups_dir: String,
|
||||||
|
pub locales_dir: String,
|
||||||
/// Max number of missed notifs to process after connect
|
/// Max number of missed notifs to process after connect
|
||||||
pub max_catchup_notifs: usize,
|
pub max_catchup_notifs: usize,
|
||||||
/// Max number of missed statuses to process after connect
|
/// Max number of missed statuses to process after connect
|
||||||
|
@ -21,11 +27,15 @@ pub struct CommonConfig {
|
||||||
/// Time after which a socket is always closed, even if seemingly alive.
|
/// Time after which a socket is always closed, even if seemingly alive.
|
||||||
/// This is a work-around for servers that stop sending notifs after a while.
|
/// This is a work-around for servers that stop sending notifs after a while.
|
||||||
pub socket_retire_time_s: f64,
|
pub socket_retire_time_s: f64,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub tr : HashMap<String, TranslationTable>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for CommonConfig {
|
impl Default for CommonConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
groups_dir: "groups".to_string(),
|
||||||
|
locales_dir: "locales".to_string(),
|
||||||
max_catchup_notifs: 30,
|
max_catchup_notifs: 30,
|
||||||
max_catchup_statuses: 50,
|
max_catchup_statuses: 50,
|
||||||
delay_fetch_page_s: 0.25,
|
delay_fetch_page_s: 0.25,
|
||||||
|
@ -34,6 +44,16 @@ impl Default for CommonConfig {
|
||||||
delay_reopen_error_s: 5.0,
|
delay_reopen_error_s: 5.0,
|
||||||
socket_alive_timeout_s: 30.0,
|
socket_alive_timeout_s: 30.0,
|
||||||
socket_retire_time_s: 120.0,
|
socket_retire_time_s: 120.0,
|
||||||
|
tr: Default::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommonConfig {
|
||||||
|
pub fn tr(&self, lang : &str) -> &TranslationTable {
|
||||||
|
match self.tr.get(lang) {
|
||||||
|
Some(tr) => tr,
|
||||||
|
None => self.tr.get(DEFAULT_LOCALE_NAME).expect("default locale is not loaded")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@ 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;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(default, deny_unknown_fields)]
|
#[serde(default, deny_unknown_fields)]
|
||||||
|
@ -13,6 +14,8 @@ struct FixedConfig {
|
||||||
acct: String,
|
acct: String,
|
||||||
/// elefren data
|
/// elefren data
|
||||||
appdata: AppData,
|
appdata: AppData,
|
||||||
|
/// configured locale to use
|
||||||
|
locale: String,
|
||||||
/// Server's character limit
|
/// Server's character limit
|
||||||
character_limit: usize,
|
character_limit: usize,
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
|
@ -73,6 +76,7 @@ impl Default for FixedConfig {
|
||||||
Self {
|
Self {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
acct: "".to_string(),
|
acct: "".to_string(),
|
||||||
|
locale: DEFAULT_LOCALE_NAME.to_string(),
|
||||||
appdata: AppData {
|
appdata: AppData {
|
||||||
base: Default::default(),
|
base: Default::default(),
|
||||||
client_id: Default::default(),
|
client_id: Default::default(),
|
||||||
|
@ -365,6 +369,10 @@ 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();
|
||||||
|
|
133
src/store/mod.rs
133
src/store/mod.rs
|
@ -11,12 +11,14 @@ pub mod common_config;
|
||||||
pub mod group_config;
|
pub mod group_config;
|
||||||
pub use common_config::CommonConfig;
|
pub use common_config::CommonConfig;
|
||||||
pub use group_config::GroupConfig;
|
pub use group_config::GroupConfig;
|
||||||
|
use crate::tr::TranslationTable;
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct ConfigStore {
|
pub struct ConfigStore {
|
||||||
store_path: PathBuf,
|
store_path: PathBuf,
|
||||||
groups_path: PathBuf,
|
groups_path: PathBuf,
|
||||||
config: Arc<CommonConfig>,
|
locales_path: PathBuf,
|
||||||
|
config: CommonConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
@ -30,10 +32,13 @@ pub struct StoreOptions {
|
||||||
pub store_dir: String,
|
pub store_dir: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_LOCALE_NAME : &str = "en";
|
||||||
|
const DEFAULT_LOCALE_JSON : &str = include_str!("../../locales/en.json");
|
||||||
|
|
||||||
impl ConfigStore {
|
impl ConfigStore {
|
||||||
/// Create a new instance of the store.
|
/// Create a new instance of the store.
|
||||||
/// If a path is given, it will try to load the content from a file.
|
/// If a path is given, it will try to load the content from a file.
|
||||||
pub async fn load_from_fs(options: StoreOptions) -> Result<Arc<Self>, GroupError> {
|
pub async fn load_from_fs(options: StoreOptions) -> Result<Self, GroupError> {
|
||||||
let given_path: &Path = options.store_dir.as_ref();
|
let given_path: &Path = options.store_dir.as_ref();
|
||||||
|
|
||||||
let mut common_file: Option<PathBuf> = None;
|
let mut common_file: Option<PathBuf> = None;
|
||||||
|
@ -74,21 +79,41 @@ impl ConfigStore {
|
||||||
|
|
||||||
debug!("Using common config:\n{:#?}", config);
|
debug!("Using common config:\n{:#?}", config);
|
||||||
|
|
||||||
let groups_path = base_dir.join("groups.d");
|
let groups_path = if config.groups_dir.starts_with('/') {
|
||||||
|
PathBuf::from(&config.groups_dir)
|
||||||
|
} else {
|
||||||
|
base_dir.join(&config.groups_dir)
|
||||||
|
};
|
||||||
|
|
||||||
if !groups_path.exists() {
|
if !groups_path.exists() {
|
||||||
debug!("Creating groups directory");
|
debug!("Creating groups directory");
|
||||||
tokio::fs::create_dir_all(&groups_path).await?;
|
tokio::fs::create_dir_all(&groups_path).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Arc::new(Self {
|
let locales_path = if config.locales_dir.starts_with('/') {
|
||||||
|
PathBuf::from(&config.locales_dir)
|
||||||
|
} else {
|
||||||
|
base_dir.join(&config.locales_dir)
|
||||||
|
};
|
||||||
|
|
||||||
|
// warn, this is usually not a good idea beside for testing
|
||||||
|
if config.max_catchup_notifs == 0 {
|
||||||
|
warn!("Missed notifications catch-up is disabled!");
|
||||||
|
}
|
||||||
|
if config.max_catchup_statuses == 0 {
|
||||||
|
warn!("Missed statuses catch-up is disabled!");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
store_path: base_dir.to_owned(),
|
store_path: base_dir.to_owned(),
|
||||||
groups_path,
|
groups_path,
|
||||||
config: Arc::new(config),
|
locales_path,
|
||||||
}))
|
config,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn a new group
|
/// Spawn a new group
|
||||||
pub async fn auth_new_group(self: &Arc<Self>, opts: NewGroupOptions) -> Result<GroupHandle, GroupError> {
|
pub async fn auth_new_group(&self, opts: NewGroupOptions) -> Result<(), GroupError> {
|
||||||
let registration = Registration::new(&opts.server)
|
let registration = Registration::new(&opts.server)
|
||||||
.client_name("group-actor")
|
.client_name("group-actor")
|
||||||
.force_login(true)
|
.force_login(true)
|
||||||
|
@ -102,17 +127,16 @@ impl ConfigStore {
|
||||||
|
|
||||||
let group_dir = self.groups_path.join(&opts.acct);
|
let group_dir = self.groups_path.join(&opts.acct);
|
||||||
|
|
||||||
let data = GroupConfig::from_appdata(opts.acct.clone(), appdata, group_dir).await?;
|
let _data = GroupConfig::from_appdata(opts.acct.clone(), appdata, group_dir).await?;
|
||||||
|
|
||||||
// save & persist
|
// save & persist
|
||||||
|
|
||||||
let group_account = match client.verify_credentials().await {
|
match client.verify_credentials().await {
|
||||||
Ok(account) => {
|
Ok(account) => {
|
||||||
info!(
|
info!(
|
||||||
"Group account verified: @{}, \"{}\"",
|
"Group account verified: @{}, \"{}\"",
|
||||||
account.acct, account.display_name
|
account.acct, account.display_name
|
||||||
);
|
);
|
||||||
account
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Group @{} auth error: {}", opts.acct, e);
|
error!("Group @{} auth error: {}", opts.acct, e);
|
||||||
|
@ -120,17 +144,11 @@ impl ConfigStore {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(GroupHandle {
|
Ok(())
|
||||||
group_account,
|
|
||||||
client,
|
|
||||||
config: data,
|
|
||||||
cc: self.config.clone(),
|
|
||||||
internal: GroupInternal::default(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-auth an existing group
|
/// Re-auth an existing group
|
||||||
pub async fn reauth_group(self: &Arc<Self>, acct: &str) -> Result<GroupHandle, GroupError> {
|
pub async fn reauth_group(&self, acct: &str) -> Result<(), GroupError> {
|
||||||
let group_dir = self.groups_path.join(&acct);
|
let group_dir = self.groups_path.join(&acct);
|
||||||
|
|
||||||
let mut config = GroupConfig::from_dir(group_dir).await?;
|
let mut config = GroupConfig::from_dir(group_dir).await?;
|
||||||
|
@ -151,7 +169,7 @@ impl ConfigStore {
|
||||||
config.set_appdata(appdata);
|
config.set_appdata(appdata);
|
||||||
config.save_if_needed(true).await?;
|
config.save_if_needed(true).await?;
|
||||||
|
|
||||||
let group_account = match client.verify_credentials().await {
|
let _group_account = match client.verify_credentials().await {
|
||||||
Ok(account) => {
|
Ok(account) => {
|
||||||
info!(
|
info!(
|
||||||
"Group account verified: @{}, \"{}\"",
|
"Group account verified: @{}, \"{}\"",
|
||||||
|
@ -165,20 +183,79 @@ impl ConfigStore {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(GroupHandle {
|
Ok(())
|
||||||
group_account,
|
}
|
||||||
client,
|
|
||||||
config,
|
pub async fn find_locales(&mut self) {
|
||||||
cc: self.config.clone(),
|
// Load the default locale, it will be used as fallback to fill-in missing keys
|
||||||
internal: GroupInternal::default(),
|
self.load_locale(DEFAULT_LOCALE_NAME, DEFAULT_LOCALE_JSON, true);
|
||||||
})
|
|
||||||
|
if !self.locales_path.is_dir() {
|
||||||
|
debug!("No locales path set!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries = match std::fs::read_dir(&self.locales_path) {
|
||||||
|
Ok(ee) => ee,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Error listing locales: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for e in entries {
|
||||||
|
if let Ok(e) = e {
|
||||||
|
let path = e.path();
|
||||||
|
if path.is_file() && path.extension().unwrap_or_default().to_string_lossy() == "json" {
|
||||||
|
let filename = path.file_name().unwrap_or_default().to_string_lossy();
|
||||||
|
debug!("Loading locale {}", filename);
|
||||||
|
|
||||||
|
match tokio::fs::read(&path).await {
|
||||||
|
Ok(f) => {
|
||||||
|
let locale_name = path.file_stem().unwrap_or_default().to_string_lossy();
|
||||||
|
self.load_locale(&locale_name, &String::from_utf8_lossy(&f), false);
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to read locale file {}: {}", path.display(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_locale(&mut self, locale_name: &str, locale_json: &str, is_default: bool) {
|
||||||
|
if let Ok(mut tr) = serde_json::from_str::<TranslationTable>(locale_json) {
|
||||||
|
debug!("Loaded locale: {}", locale_name);
|
||||||
|
|
||||||
|
if !is_default {
|
||||||
|
let def_tr = self.config.tr.get(DEFAULT_LOCALE_NAME).expect("Default locale not loaded!");
|
||||||
|
|
||||||
|
for (k, v) in def_tr.entries() {
|
||||||
|
if !tr.translation_exists(k) {
|
||||||
|
warn!("locale \"{}\" is missing \"{}\", default: {:?}",
|
||||||
|
locale_name,
|
||||||
|
k,
|
||||||
|
def_tr.get_translation_raw(k).unwrap());
|
||||||
|
|
||||||
|
tr.add_translation(k, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.config.tr.insert(locale_name.to_owned(), tr);
|
||||||
|
} else {
|
||||||
|
error!("Failed to parse locale {}", locale_name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn existing group using saved creds
|
/// Spawn existing group using saved creds
|
||||||
pub async fn spawn_groups(self: Arc<Self>) -> Result<Vec<GroupHandle>, GroupError> {
|
pub async fn spawn_groups(self) -> Result<Vec<GroupHandle>, GroupError> {
|
||||||
info!("Starting group services for groups in {}", self.groups_path.display());
|
info!("Starting group services for groups in {}", self.groups_path.display());
|
||||||
let dirs = std::fs::read_dir(&self.groups_path)?;
|
let dirs = std::fs::read_dir(&self.groups_path)?;
|
||||||
|
|
||||||
|
let config = Arc::new(self.config);
|
||||||
|
|
||||||
// Connect in parallel
|
// Connect in parallel
|
||||||
Ok(futures::stream::iter(dirs)
|
Ok(futures::stream::iter(dirs)
|
||||||
.map(|entry_maybe: Result<std::fs::DirEntry, std::io::Error>| async {
|
.map(|entry_maybe: Result<std::fs::DirEntry, std::io::Error>| async {
|
||||||
|
@ -213,7 +290,7 @@ impl ConfigStore {
|
||||||
group_account: my_account,
|
group_account: my_account,
|
||||||
client,
|
client,
|
||||||
config: gc,
|
config: gc,
|
||||||
cc: self.config.clone(),
|
cc: config.clone(),
|
||||||
internal: GroupInternal::default(),
|
internal: GroupInternal::default(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
83
src/tr.rs
Normal file
83
src/tr.rs
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
//! magic for custom translations and strings
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Debug,Clone,Serialize,Deserialize,Default)]
|
||||||
|
pub struct TranslationTable {
|
||||||
|
#[serde(flatten)]
|
||||||
|
entries: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TranslationTable {
|
||||||
|
#[allow(unused)]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iterate all entries
|
||||||
|
pub fn entries(&self) -> impl Iterator<Item=(&String, &String)> {
|
||||||
|
self.entries.iter()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_translation_raw(&self, key : &str) -> Option<&str> {
|
||||||
|
self.entries.get(key).map(|s| s.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_translation(&mut self, key : impl ToString, subs : impl ToString) {
|
||||||
|
self.entries.insert(key.to_string(), subs.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn translation_exists(&self, key : &str) -> bool {
|
||||||
|
self.entries.contains_key(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn subs(&self, key : &str, substitutions: &[&str]) -> String {
|
||||||
|
match self.entries.get(key) {
|
||||||
|
Some(s) => {
|
||||||
|
// TODO optimize
|
||||||
|
let mut s = s.clone();
|
||||||
|
for pair in substitutions.chunks(2) {
|
||||||
|
if pair.len() != 2 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
s = s.replace(&format!("{{{}}}", pair[0]), pair[1]);
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
None => key.to_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::tr::TranslationTable;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deser_tr_table() {
|
||||||
|
let tr : TranslationTable = serde_json::from_str(r#"{"foo":"bar"}"#).unwrap();
|
||||||
|
assert_eq!("bar", tr.subs("foo", &[]));
|
||||||
|
assert_eq!("xxx", tr.subs("xxx", &[]));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subs() {
|
||||||
|
let mut tr = TranslationTable::new();
|
||||||
|
tr.add_translation("hello_user", "Hello, {user}!");
|
||||||
|
assert_eq!("Hello, James!", tr.subs("hello_user", &["user", "James"]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! tr {
|
||||||
|
($tr_haver:expr, $key:literal) => {
|
||||||
|
$tr_haver.tr().subs($key, &[])
|
||||||
|
};
|
||||||
|
|
||||||
|
($tr_haver:expr, $key:literal, $($k:tt=$value:expr),*) => {
|
||||||
|
$tr_haver.tr().subs($key, &[
|
||||||
|
$(stringify!($k), $value),*
|
||||||
|
])
|
||||||
|
};
|
||||||
|
}
|
Loading…
Reference in a new issue