pleroma groups!!!!!! try it -> https://piggo.space/hob
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
group-actor/src/group_handler/handle_mention.rs

1001 lines
37 KiB

use std::cmp::Ordering;
use std::collections::HashSet;
use std::time::Duration;
use elefren::entities::account::Account;
use elefren::entities::prelude::Status;
use elefren::status_builder::Visibility;
use elefren::{FediClient, SearchType, StatusBuilder};
use crate::command::{StatusCommand, RE_NOBOT_TAG};
use crate::error::GroupError;
use crate::group_handler::GroupHandle;
use crate::store::group_config::GroupConfig;
use crate::store::CommonConfig;
use crate::tr::TranslationTable;
use crate::utils;
use crate::utils::{normalize_acct, LogError, VisExt};
use crate::{grp_debug, grp_info, grp_warn};
pub struct ProcessMention<'a> {
status: Status,
group_account: &'a Account,
config: &'a mut GroupConfig,
cc: &'a CommonConfig,
client: &'a mut FediClient,
group_acct: String,
status_acct: String,
status_user_id: String,
can_write: bool,
is_admin: bool,
replies: String,
announcements: String,
do_boost_prev_post: bool,
}
impl<'a> ProcessMention<'a> {
fn tr(&self) -> &TranslationTable {
self.config.tr()
}
async fn lookup_acct_id(&self, acct: &str, followed: bool) -> Result<Option<String>, GroupError> {
grp_debug!(self, "Looking up user ID by acct: {}", acct);
match tokio::time::timeout(
Duration::from_secs(5),
self.client
.search_v2(acct, !followed, Some(SearchType::Accounts), Some(1), followed),
)
.await
{
Err(_) => {
grp_warn!(self, "Account lookup timeout!");
Err(GroupError::ApiTimeout)
}
Ok(Err(e)) => {
// Elefren error
Err(e.into())
}
Ok(Ok(res)) => {
for item in res.accounts {
// XXX limit is 1!
let acct_normalized = normalize_acct(&item.acct, &self.group_acct)?;
if acct_normalized == acct {
grp_debug!(self, "Search done, account found: {}", item.acct);
return Ok(Some(item.id));
} else {
grp_warn!(self, "Found wrong account: {}", item.acct);
}
}
grp_debug!(self, "Search done, nothing found");
Ok(None)
}
}
}
fn append_admin_list_to_reply(&mut self) {
let mut admins = self.config.get_admins().collect::<Vec<_>>();
admins.sort();
let mut to_add = String::new();
for a in admins {
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) {
let admins = self.config.get_admins().collect::<HashSet<_>>();
let mut members = self.config.get_members().collect::<Vec<_>>();
members.extend(admins.iter());
members.sort();
members.dedup();
let mut to_add = String::new();
for m in members {
to_add.push_str(&if admins.contains(&m) {
crate::tr!(self, "user_list_entry_admin", user = m)
} else {
crate::tr!(self, "user_list_entry", user = m)
});
}
self.add_reply(&to_add);
}
async fn follow_user_by_id(&self, id: &str) -> Result<(), GroupError> {
grp_debug!(self, "Trying to follow user #{}", id);
self.client.follow(id).await?;
self.delay_after_post().await;
Ok(())
}
async fn unfollow_user_by_id(&self, id: &str) -> Result<(), GroupError> {
grp_debug!(self, "Trying to unfollow user #{}", id);
self.client.unfollow(id).await?;
self.delay_after_post().await;
Ok(())
}
pub(crate) async fn run(gh: &'a mut GroupHandle, status: Status) -> Result<(), GroupError> {
let group_acct = gh.config.get_acct().to_string();
let status_acct = normalize_acct(&status.account.acct, &group_acct)?.to_string();
if gh.config.is_banned(&status_acct) {
grp_warn!(gh, "Status author {} is banned!", status_acct);
return Ok(());
}
let pm = Self {
group_account: &gh.group_account,
status_user_id: status.account.id.to_string(),
client: &mut gh.client,
cc: &gh.cc,
can_write: gh.config.can_write(&status_acct),
is_admin: gh.config.is_admin(&status_acct),
replies: String::new(),
announcements: String::new(),
do_boost_prev_post: false,
group_acct,
status_acct,
status,
config: &mut gh.config,
};
pm.handle().await
}
async fn reblog_status(&self) {
self.client.reblog(&self.status.id).await.log_error("Failed to reblog status");
self.delay_after_post().await;
}
fn add_reply(&mut self, line: impl AsRef<str>) {
self.replies.push_str(line.as_ref())
}
fn add_announcement(&mut self, line: impl AsRef<str>) {
self.announcements.push_str(line.as_ref())
}
async fn handle(mut self) -> Result<(), GroupError> {
let commands = crate::command::parse_slash_commands(&self.status.content);
if commands.is_empty() {
self.handle_post_with_no_commands().await;
} else {
if commands.contains(&StatusCommand::Ignore) {
grp_debug!(self, "Notif ignored because of ignore command");
return Ok(());
}
for cmd in commands {
if !self.replies.is_empty() {
self.replies.push('\n'); // make sure there's a newline between batched commands.
}
match cmd {
StatusCommand::Undo => {
self.cmd_undo().await.log_error("Error handling undo cmd");
}
StatusCommand::Ignore => {
unreachable!(); // Handled above
}
StatusCommand::Announce(a) => {
self.cmd_announce(a).await;
}
StatusCommand::Boost => {
self.cmd_boost().await;
}
StatusCommand::BanUser(u) => {
self.cmd_ban_user(&u).await.log_error("Error handling ban-user cmd");
}
StatusCommand::UnbanUser(u) => {
self.cmd_unban_user(&u).await.log_error("Error handling unban-user cmd");
}
StatusCommand::OptOut => {
self.cmd_optout().await;
}
StatusCommand::OptIn => {
self.cmd_optin().await;
}
StatusCommand::BanServer(s) => {
self.cmd_ban_server(&s).await;
}
StatusCommand::UnbanServer(s) => {
self.cmd_unban_server(&s).await;
}
StatusCommand::AddMember(u) => {
self.cmd_add_member(&u).await.log_error("Error handling add-member cmd");
}
StatusCommand::RemoveMember(u) => {
self.cmd_remove_member(&u).await.log_error("Error handling remove-member cmd");
}
StatusCommand::AddTag(tag) => {
self.cmd_add_tag(tag).await;
}
StatusCommand::RemoveTag(tag) => {
self.cmd_remove_tag(tag).await;
}
StatusCommand::GrantAdmin(u) => {
self.cmd_grant_admin(&u).await.log_error("Error handling grant-admin cmd");
}
StatusCommand::RemoveAdmin(u) => {
self.cmd_revoke_admin(&u).await.log_error("Error handling grant-admin cmd");
}
StatusCommand::OpenGroup => {
self.cmd_open_group().await;
}
StatusCommand::CloseGroup => {
self.cmd_close_group().await;
}
StatusCommand::Help => {
self.cmd_help().await;
}
StatusCommand::ListMembers => {
self.cmd_list_members().await;
}
StatusCommand::ListTags => {
self.cmd_list_tags().await;
}
StatusCommand::Leave => {
self.cmd_leave().await;
}
StatusCommand::Join => {
self.cmd_join().await;
}
StatusCommand::Ping => {
self.cmd_ping().await;
}
}
}
}
if self.do_boost_prev_post {
if let (Some(prev_acct_id), Some(prev_status_id)) = (
self.status.in_reply_to_account_id.as_ref(),
self.status.in_reply_to_id.as_ref(),
) {
match self.id_to_acct_check_boostable(prev_acct_id).await {
Ok(_acct) => {
self.client.reblog(prev_status_id).await.log_error("Failed to boost");
self.delay_after_post().await;
}
Err(e) => {
grp_warn!(self, "Can't reblog: {}", e);
}
}
}
}
if !self.replies.is_empty() {
let mut msg = std::mem::take(&mut self.replies);
grp_debug!(self, "r={}", msg);
self.apply_trailing_hashtag_pleroma_bug_workaround(&mut msg);
let mention = crate::tr!(self, "mention_prefix", user = &self.status_acct);
self.send_reply_multipart(mention, msg).await?;
}
if !self.announcements.is_empty() {
let mut msg = std::mem::take(&mut self.announcements);
grp_debug!(self, "a={}", msg);
self.apply_trailing_hashtag_pleroma_bug_workaround(&mut msg);
let msg = crate::tr!(self, "group_announcement", message = &msg);
self.send_announcement_multipart(&msg).await?;
}
Ok(())
}
async fn send_reply_multipart(&self, mention: String, msg: String) -> Result<(), GroupError> {
let parts = smart_split(&msg, Some(mention), self.config.get_character_limit());
let mut parent = self.status.id.clone();
for p in parts {
if let Ok(post) = StatusBuilder::new()
.status(p)
.content_type("text/markdown")
.in_reply_to(&parent)
.visibility(Visibility::Direct)
.build()
{
let status = self.client.new_status(post).await?;
self.delay_after_post().await;
parent = status.id;
}
// Sleep a bit to avoid throttling
tokio::time::sleep(Duration::from_secs(1)).await;
}
Ok(())
}
async fn send_announcement_multipart(&self, msg: &str) -> Result<(), GroupError> {
let parts = smart_split(msg, None, self.config.get_character_limit());
let mut parent = None;
for p in parts {
let mut builder = StatusBuilder::new();
builder.status(p).content_type("text/markdown").visibility(Visibility::Public);
if let Some(p) = parent.as_ref() {
builder.in_reply_to(p);
}
let post = builder.build().expect("error build status");
let status = self.client.new_status(post).await?;
self.delay_after_post().await;
parent = Some(status.id);
// Sleep a bit to avoid throttling
tokio::time::sleep(Duration::from_secs(1)).await;
}
Ok(())
}
async fn handle_post_with_no_commands(&mut self) {
grp_debug!(self, "No commands in post");
if self.status.visibility.is_private() {
grp_debug!(self, "Mention is private, discard");
return;
}
if self.can_write {
if self.status.in_reply_to_id.is_none() {
// Someone tagged the group in OP, boost it.
grp_info!(self, "Boosting OP mention");
// tokio::time::sleep(DELAY_BEFORE_ACTION).await;
self.reblog_status().await;
// Otherwise, don't react
} else {
// Check for tags
let tags = crate::command::parse_status_tags(&self.status.content);
grp_debug!(self, "Tags in mention: {:?}", tags);
for t in tags {
if self.config.is_tag_followed(&t) {
grp_info!(self, "REBLOG #{} STATUS", t);
self.client.reblog(&self.status.id).await.log_error("Failed to reblog");
self.delay_after_post().await;
return;
} else {
grp_debug!(self, "#{} is not a group tag", t);
}
}
grp_debug!(self, "Not OP & no tags, ignore mention");
}
} else {
grp_warn!(self, "User @{} can't post to group!", self.status_acct);
}
}
async fn cmd_announce(&mut self, msg: String) {
grp_info!(self, "Sending PSA");
self.add_announcement(msg);
}
async fn cmd_boost(&mut self) {
if self.can_write {
self.do_boost_prev_post = self.status.in_reply_to_id.is_some();
} else {
grp_warn!(self, "User @{} can't share to group!", self.status_acct);
}
}
async fn cmd_optout(&mut self) {
if self.is_admin {
self.add_reply(crate::tr!(self, "cmd_optout_fail_admin_cant"));
} else if self.config.is_member(&self.status_acct) {
self.add_reply(crate::tr!(self, "cmd_optout_fail_member_cant"));
} else {
self.config.set_optout(&self.status_acct, true);
self.add_reply(crate::tr!(self, "cmd_optout_ok"));
}
}
async fn cmd_optin(&mut self) {
if self.is_admin {
self.add_reply(crate::tr!(self, "cmd_optin_fail_admin_cant"));
} else if self.config.is_member(&self.status_acct) {
self.add_reply(crate::tr!(self, "cmd_optin_fail_member_cant"));
} else {
self.config.set_optout(&self.status_acct, false);
self.add_reply(crate::tr!(self, "cmd_optin_ok"));
}
}
async fn cmd_undo(&mut self) -> Result<(), GroupError> {
if let (Some(ref parent_account_id), Some(ref parent_status_id)) =
(&self.status.in_reply_to_account_id, &self.status.in_reply_to_id)
{
if parent_account_id == &self.group_account.id {
// This is a post sent by the group user, likely an announcement.
// Undo here means delete it.
if self.is_admin {
grp_info!(self, "Deleting group post #{}", parent_status_id);
self.client.delete_status(parent_status_id).await?;
self.delay_after_post().await;
} else {
grp_warn!(self, "Only admin can delete posts made by the group user");
}
} else if self.is_admin || parent_account_id == &self.status_user_id {
grp_info!(self, "Un-reblogging post #{}", parent_status_id);
// User unboosting own post boosted by accident, or admin doing it
self.client.unreblog(parent_status_id).await?;
self.delay_after_post().await;
} else {
grp_warn!(self, "Only the author and admins can undo reblogs");
// XXX this means when someone /b's someone else's post to a group,
// they then can't reverse that (only admin or the post's author can)
}
}
Ok(())
}
async fn cmd_ban_user(&mut self, user: &str) -> Result<(), GroupError> {
let u = normalize_acct(user, &self.group_acct)?;
if self.is_admin {
if !self.config.is_banned(&u) {
match self.config.ban_user(&u, true) {
Ok(_) => {
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");
}
Err(e) => {
self.add_reply(crate::tr!(self, "cmd_error", cause = &e.to_string()));
}
}
} else {
self.add_reply(crate::tr!(self, "cmd_ban_user_fail_already", user = &u));
}
} else {
grp_warn!(self, "Ignore cmd, user not admin");
}
Ok(())
}
async fn cmd_unban_user(&mut self, user: &str) -> Result<(), GroupError> {
let u = normalize_acct(user, &self.group_acct)?;
if self.is_admin {
if self.config.is_banned(&u) {
match self.config.ban_user(&u, false) {
Ok(_) => {
self.add_reply(crate::tr!(self, "cmd_unban_user_ok", user = &u));
// no announcement here
}
Err(e) => {
self.add_reply(crate::tr!(self, "cmd_error", cause = &e.to_string()));
}
}
} else {
self.add_reply(crate::tr!(self, "cmd_unban_user_fail_already", user = &u));
}
} else {
grp_warn!(self, "Ignore cmd, user not admin");
}
Ok(())
}
async fn cmd_ban_server(&mut self, s: &str) {
if self.is_admin {
if !self.config.is_server_banned(s) {
match self.config.ban_server(s, true) {
Ok(_) => {
self.add_reply(crate::tr!(self, "cmd_ban_server_ok", server = s));
}
Err(e) => {
self.add_reply(crate::tr!(self, "cmd_error", cause = &e.to_string()));
}
}
} else {
self.add_reply(crate::tr!(self, "cmd_ban_server_fail_already", server = s));
}
} else {
grp_warn!(self, "Ignore cmd, user not admin");
}
}
async fn cmd_unban_server(&mut self, s: &str) {
if self.is_admin {
if self.config.is_server_banned(s) {
match self.config.ban_server(s, false) {
Ok(_) => {
self.add_reply(crate::tr!(self, "cmd_unban_server_ok", server = s));
}
Err(e) => {
self.add_reply(crate::tr!(self, "cmd_error", cause = &e.to_string()));
}
}
} else {
self.add_reply(crate::tr!(self, "cmd_unban_server_fail_already", server = s));
}
} else {
grp_warn!(self, "Ignore cmd, user not admin");
}
}
async fn cmd_add_member(&mut self, user: &str) -> Result<(), GroupError> {
let u = normalize_acct(user, &self.group_acct)?;
if self.is_admin {
// Allow even if the user is already a member - that will trigger re-follow
match self.config.set_member(&u, true) {
Ok(_) => {
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");
}
Err(e) => {
self.add_reply(crate::tr!(self, "cmd_error", cause = &e.to_string()));
}
}
} else {
grp_warn!(self, "Ignore cmd, user not admin");
}
Ok(())
}
async fn cmd_remove_member(&mut self, user: &str) -> Result<(), GroupError> {
let u = normalize_acct(user, &self.group_acct)?;
if self.is_admin {
match self.config.set_member(&u, false) {
Ok(_) => {
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");
}
Err(e) => {
self.add_reply(crate::tr!(self, "cmd_error", cause = &e.to_string()));
}
}
} else {
grp_warn!(self, "Ignore cmd, user not admin");
}
Ok(())
}
async fn cmd_add_tag(&mut self, tag: String) {
if self.is_admin {
if !self.config.is_tag_followed(&tag) {
self.config.add_tag(&tag);
self.add_reply(crate::tr!(self, "cmd_add_tag_ok", tag = &tag));
} else {
self.add_reply(crate::tr!(self, "cmd_add_tag_fail_already", tag = &tag));
}
} else {
grp_warn!(self, "Ignore cmd, user not admin");
}
}
async fn cmd_remove_tag(&mut self, tag: String) {
if self.is_admin {
if self.config.is_tag_followed(&tag) {
self.config.remove_tag(&tag);
self.add_reply(crate::tr!(self, "cmd_remove_tag_ok", tag = &tag));
} else {
self.add_reply(crate::tr!(self, "cmd_remove_tag_fail_already", tag = &tag));
}
} else {
grp_warn!(self, "Ignore cmd, user not admin");
}
}
async fn cmd_grant_admin(&mut self, user: &str) -> Result<(), GroupError> {
let u = normalize_acct(user, &self.group_acct)?;
if self.is_admin {
if !self.config.is_admin(&u) {
match self.config.set_admin(&u, true) {
Ok(_) => {
// try to make the config a little more sane, admins should be members
let _ = self.config.set_member(&u, true);
self.add_reply(crate::tr!(self, "cmd_admin_ok", user = &u));
}
Err(e) => {
self.add_reply(crate::tr!(self, "cmd_error", cause = &e.to_string()));
}
}
} else {
self.add_reply(crate::tr!(self, "cmd_admin_fail_already", user = &u));
}
} else {
grp_warn!(self, "Ignore cmd, user not admin");
}
Ok(())
}
async fn cmd_revoke_admin(&mut self, user: &str) -> Result<(), GroupError> {
let u = normalize_acct(user, &self.group_acct)?;
if self.is_admin {
if self.config.is_admin(&u) {
match self.config.set_admin(&u, false) {
Ok(_) => {
self.add_reply(crate::tr!(self, "cmd_unadmin_ok", user = &u));
}
Err(e) => {
self.add_reply(crate::tr!(self, "cmd_error", cause = &e.to_string()));
}
}
} else {
self.add_reply(crate::tr!(self, "cmd_unadmin_fail_already", user = &u));
}
} else {
grp_warn!(self, "Ignore cmd, user not admin");
}
Ok(())
}
async fn cmd_open_group(&mut self) {
if self.is_admin {
if self.config.is_member_only() {
self.config.set_member_only(false);
self.add_reply(crate::tr!(self, "cmd_open_resp"));
} else {
self.add_reply(crate::tr!(self, "cmd_open_resp_already"));
}
} else {
grp_warn!(self, "Ignore cmd, user not admin");
}
}
async fn cmd_close_group(&mut self) {
if self.is_admin {
if !self.config.is_member_only() {
self.config.set_member_only(true);
self.add_reply(crate::tr!(self, "cmd_close_resp"));
} else {
self.add_reply(crate::tr!(self, "cmd_close_resp_already"));
}
} else {
grp_warn!(self, "Ignore cmd, user not admin");
}
}
async fn cmd_help(&mut self) {
let membership_line = if self.is_admin {
crate::tr!(self, "help_membership_admin")
} else if self.config.is_member(&self.status_acct) {
crate::tr!(self, "help_membership_member")
} else if self.config.is_member_only() {
crate::tr!(self, "help_membership_guest_closed")
} else {
crate::tr!(self, "help_membership_guest_open")
};
if self.config.is_member_only() {
self.add_reply(crate::tr!(
self,
"help_group_info_closed",
membership = &membership_line
));
} else {
self.add_reply(crate::tr!(self, "help_group_info_open", membership = &membership_line));
}
self.add_reply(crate::tr!(self, "help_basic_commands"));
if !self.is_admin {
self.add_reply(crate::tr!(self, "help_member_commands"));
}
if self.is_admin {
self.add_reply(crate::tr!(self, "help_admin_commands"));
}
}
async fn cmd_list_members(&mut self) {
if self.is_admin {
self.add_reply(crate::tr!(self, "member_list_heading"));
self.append_member_list_to_reply();
} else {
self.add_reply(crate::tr!(self, "admin_list_heading"));
self.append_admin_list_to_reply();
}
}
async fn cmd_list_tags(&mut self) {
self.add_reply(crate::tr!(self, "tag_list_heading"));
let mut tags = self.config.get_tags().collect::<Vec<_>>();
tags.sort();
let mut to_add = String::new();
for t in tags {
to_add.push_str(&crate::tr!(self, "tag_list_entry", tag = t));
}
self.add_reply(to_add);
}
async fn cmd_leave(&mut self) {
if self.config.is_member_or_admin(&self.status_acct) {
// admin can leave but that's a bad idea
let _ = self.config.set_member(&self.status_acct, false);
self.add_reply(crate::tr!(self, "cmd_leave_resp"));
}
self.unfollow_user_by_id(&self.status_user_id)
.await
.log_error("Failed to unfollow");
}
async fn cmd_join(&mut self) {
if self.config.is_member_or_admin(&self.status_acct) {
grp_debug!(self, "Already member or admin, try to follow-back again");
// Already a member, so let's try to follow the user
// again, maybe first time it failed
self.follow_user_by_id(&self.status_user_id).await.log_error("Failed to follow");
} else {
// Not a member yet
if self.config.is_member_only() {
// No you can't
self.add_reply(crate::tr!(self, "welcome_closed"));
self.append_admin_list_to_reply();
} else {
// Open access, try to follow back
self.follow_user_by_id(&self.status_user_id).await.log_error("Failed to follow");
// This only fails if the user is banned, but that is filtered above
let _ = self.config.set_member(&self.status_acct, true);
self.add_reply(crate::tr!(self, "welcome_join_cmd"));
}
}
}
async fn cmd_ping(&mut self) {
self.add_reply(crate::tr!(self, "ping_response", version = env!("CARGO_PKG_VERSION")));
}
async fn unfollow_by_acct(&self, acct: &str) -> Result<(), GroupError> {
// Try to unfollow
if let Ok(Some(id)) = self.lookup_acct_id(acct, true).await {
self.unfollow_user_by_id(&id).await?;
}
Ok(())
}
async fn follow_by_acct(&self, acct: &str) -> Result<(), GroupError> {
// Try to unfollow
if let Ok(Some(id)) = self.lookup_acct_id(acct, false).await {
self.follow_user_by_id(&id).await?;
}
Ok(())
}
/// Convert ID to account, checking if the user is boostable
async fn id_to_acct_check_boostable(&self, id: &str) -> Result<String, GroupError> {
// Try to unfollow
let account = self.client.get_account(id).await?;
let bio = utils::strip_html(&account.note);
if RE_NOBOT_TAG.is_match(&bio) {
// #nobot
Err(GroupError::UserOptedOut)
} else {
let normalized = normalize_acct(&account.acct, &self.group_acct)?;
if self.config.is_banned(&normalized) {
Err(GroupError::UserIsBanned)
} else if self.config.is_optout(&normalized) {
Err(GroupError::UserOptedOut)
} else {
Ok(normalized)
}
}
}
async fn delay_after_post(&self) {
tokio::time::sleep(Duration::from_secs_f64(self.cc.delay_after_post_s)).await;
}
fn apply_trailing_hashtag_pleroma_bug_workaround(&self, msg: &mut String) {
if crate::command::RE_HASHTAG_TRIGGERING_PLEROMA_BUG.is_match(msg) {
// if a status ends with a hashtag, pleroma will fuck it up
grp_debug!(self, "Adding \" .\" to fix pleroma hashtag eating bug!");
msg.push_str(" .");
}
}
}
fn smart_split(msg: &str, prefix: Option<String>, limit: usize) -> Vec<String> {
let prefix = prefix.unwrap_or_default();
if msg.len() + prefix.len() < limit {
return vec![format!("{}{}", prefix, msg)];
}
let mut parts_to_send = vec![];
let mut this_piece = prefix.clone();
for l in msg.split('\n') {
// println!("* Line: {:?}", l);
match (this_piece.len() + l.len()).cmp(&limit) {
Ordering::Less => {
// println!("append line");
// this line still fits comfortably
this_piece.push_str(l);
this_piece.push('\n');
}
Ordering::Equal => {
// println!("exactly fits within limit");
// this line exactly reaches the limit
this_piece.push_str(l);
parts_to_send.push(std::mem::take(&mut this_piece).trim().to_owned());
this_piece.push_str(&prefix);
}
Ordering::Greater => {
// println!("too long to append (already {} + new {})", this_piece.len(), l.len());
// line too long to append
if this_piece != prefix {
let trimmed = this_piece.trim();
if !trimmed.is_empty() {
// println!("flush buffer: {:?}", trimmed);
parts_to_send.push(trimmed.to_owned());
}
}
// start new piece with the line. If the line is too long, break it up.
this_piece = format!("{}{}", prefix, l);
while this_piece.len() > limit {
// line too long, try splitting at the last space, if any
let to_send = if let Some(last_space) = (&this_piece[..=limit]).rfind(' ') {
// println!("line split at word boundary");
let mut p = this_piece.split_off(last_space + 1);
std::mem::swap(&mut p, &mut this_piece);
p
} else {
// println!("line split at exact len (no word boundary found)");
let mut p = this_piece.split_off(limit);
std::mem::swap(&mut p, &mut this_piece);
p
};
let part_trimmed = to_send.trim();
// println!("flush buffer: {:?}", part_trimmed);
parts_to_send.push(part_trimmed.to_owned());
this_piece = format!("{}{}", prefix, this_piece.trim());
}
this_piece.push('\n');
}
}
}
if this_piece != prefix {
let leftover_trimmed = this_piece.trim();
if !leftover_trimmed.is_empty() {
// println!("flush buffer: {:?}", leftover_trimmed);
parts_to_send.push(leftover_trimmed.to_owned());
}
}
parts_to_send
}
#[cfg(test)]
mod test {
#[test]
fn test_smart_split_lines() {
let to_split = "a234567890\nb234567890\nc234567890\nd234\n67890\ne234567890\n";
let parts = super::smart_split(to_split, None, 10);
assert_eq!(
vec![
"a234567890".to_string(),
"b234567890".to_string(),
"c234567890".to_string(),
"d234\n67890".to_string(),
"e234567890".to_string(),
],
parts
);
}
#[test]
fn test_smart_split_nosplit() {
let to_split = "foo\nbar\nbaz";
let parts = super::smart_split(to_split, None, 1000);
assert_eq!(vec!["foo\nbar\nbaz".to_string()], parts);
}
#[test]
fn test_smart_split_nosplit_prefix() {
let to_split = "foo\nbar\nbaz";
let parts = super::smart_split(to_split, Some("PREFIX".to_string()), 1000);
assert_eq!(vec!["PREFIXfoo\nbar\nbaz".to_string()], parts);
}
#[test]
fn test_smart_split_prefix_each() {
let to_split = "1234\n56\n7";
let parts = super::smart_split(to_split, Some("PREFIX".to_string()), 10);
assert_eq!(vec!["PREFIX1234".to_string(), "PREFIX56\n7".to_string()], parts);
}
#[test]
fn test_smart_split_words() {
let to_split = "one two three four five six seven eight nine ten";
let parts = super::smart_split(to_split, None, 10);
assert_eq!(
vec![
"one two".to_string(),
"three four".to_string(),
"five six".to_string(),
"seven".to_string(),
"eight nine".to_string(),
"ten".to_string(),
],
parts
);
}
#[test]
fn test_smart_split_words_multispace() {
let to_split = "one two three four five six seven eight nine ten ";
let parts = super::smart_split(to_split, None, 10);
assert_eq!(
vec![
"one two".to_string(),
"three four".to_string(),
"five six".to_string(),
"seven".to_string(),
"eight nine".to_string(),
"ten".to_string(),
],
parts
);
}
#[test]
fn test_smart_split_words_longword() {
let to_split = "one two threefourfive six";
let parts = super::smart_split(to_split, None, 10);
assert_eq!(
vec!["one two".to_string(), "threefourf".to_string(), "ive six".to_string()],
parts
);
}
#[test]
fn test_smart_split_words_prefix() {
let to_split = "one two three four five six seven eight nine ten";
let parts = super::smart_split(to_split, Some("PREFIX".to_string()), 15);
assert_eq!(
vec![
"PREFIXone two".to_string(),
"PREFIXthree".to_string(),
"PREFIXfour five".to_string(),
"PREFIXsix seven".to_string(),
"PREFIXeight".to_string(),
"PREFIXnine ten".to_string(),
],
parts
);
}
#[test]
fn test_smart_split_realistic() {
let to_split = "\
Lorem ipsum dolor sit amet, consectetur adipiscing elit.\n\
Aenean venenatis libero ac ex suscipit, nec efficitur arcu convallis.\n\
Nulla ante neque, efficitur nec fermentum a, fermentum nec nisl.\n\
Sed dolor ex, vestibulum at malesuada ut, faucibus ac ante.\n\
Nullam scelerisque magna dui, id tempor purus faucibus sit amet.\n\
Curabitur pretium condimentum pharetra.\n\
Aenean dictum, tortor et ultrices fermentum, mauris erat vehicula lectus.\n\
Nec varius mauris sem sollicitudin dolor. Nunc porta in urna nec vulputate.";
let parts = super::smart_split(to_split, Some("@pepa@pig.club ".to_string()), 140);
assert_eq!(vec![
"@pepa@pig.club Lorem ipsum dolor sit amet, consectetur adipiscing elit.".to_string(),
"@pepa@pig.club Aenean venenatis libero ac ex suscipit, nec efficitur arcu convallis.".to_string(),
"@pepa@pig.club Nulla ante neque, efficitur nec fermentum a, fermentum nec nisl.\nSed dolor ex, vestibulum at malesuada ut, faucibus ac ante.".to_string(),
"@pepa@pig.club Nullam scelerisque magna dui, id tempor purus faucibus sit amet.\nCurabitur pretium condimentum pharetra.".to_string(),
"@pepa@pig.club Aenean dictum, tortor et ultrices fermentum, mauris erat vehicula lectus.".to_string(),
"@pepa@pig.club Nec varius mauris sem sollicitudin dolor. Nunc porta in urna nec vulputate.".to_string(),
], parts);
}
}