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

1019 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::utils;
use crate::utils::{normalize_acct, LogError};
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: Vec<String>,
announcements: Vec<String>,
do_boost_prev_post: bool,
want_markdown: bool,
}
impl<'a> ProcessMention<'a> {
async fn lookup_acct_id(&self, acct: &str, followed: bool) -> Result<Option<String>, GroupError> {
debug!("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(_) => {
warn!("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 {
debug!("Search done, account found: {}", item.acct);
return Ok(Some(item.id));
} else {
warn!("Found wrong account: {}", item.acct);
}
}
debug!("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();
for a in admins {
self.replies.push(format!("- {}", a));
}
}
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();
for m in members {
self.replies.push(if admins.contains(&m) {
format!("- {} [admin]", m)
} else {
format!("- {}", m)
});
}
}
async fn follow_user_by_id(&self, id: &str) -> Result<(), GroupError> {
debug!("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> {
debug!("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) {
warn!("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: vec![],
announcements: vec![],
do_boost_prev_post: false,
want_markdown: 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(line.as_ref().trim_matches(' ').to_string())
}
fn add_announcement(&mut self, line: impl AsRef<str>) {
self.announcements.push(line.as_ref().trim_matches(' ').to_string())
}
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) {
debug!("Notif ignored because of ignore command");
return Ok(());
}
for cmd in 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_member(&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) => {
warn!("Can't reblog: {}", e);
}
}
}
}
if !self.replies.is_empty() {
let mut msg = self.replies.join("\n");
debug!("r={}", msg);
if self.want_markdown {
apply_trailing_hashtag_pleroma_bug_workaround(&mut msg);
}
let mention = format!("@{user} ", user = self.status_acct);
self.send_reply_multipart(mention, msg).await?;
}
if !self.announcements.is_empty() {
let mut msg = self.announcements.join("\n");
debug!("a={}", msg);
if self.want_markdown {
apply_trailing_hashtag_pleroma_bug_workaround(&mut msg);
}
let msg = format!("**📢 Group announcement**\n{msg}", msg = 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(if self.want_markdown {
"text/markdown"
} else {
"text/plain"
})
.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) {
debug!("No commands in post");
if self.status.in_reply_to_id.is_none() {
if self.can_write {
// Someone tagged the group in OP, boost it.
info!("Boosting OP mention");
// tokio::time::sleep(DELAY_BEFORE_ACTION).await;
self.reblog_status().await;
} else {
warn!("User @{} can't post to group!", self.status_acct);
}
// Otherwise, don't react
} else {
debug!("Not OP, ignore mention");
}
}
async fn cmd_announce(&mut self, msg: String) {
info!("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 {
warn!("User @{} can't share to group!", self.status_acct);
}
}
async fn cmd_optout(&mut self) {
if self.is_admin {
self.add_reply("Group admins can't opt-out.");
} else if self.config.is_member(&self.status_acct) {
self.add_reply("Group members can't opt-out. You have to leave first.");
} else {
self.config.set_optout(&self.status_acct, true);
self.add_reply("Your posts will no longer be shared to the group.");
}
}
async fn cmd_optin(&mut self) {
if self.is_admin {
self.add_reply("Opt-in has no effect for admins.");
} else if self.config.is_member(&self.status_acct) {
self.add_reply("Opt-in has no effect for members.");
} else {
self.config.set_optout(&self.status_acct, false);
self.add_reply("Your posts can now be shared to the group.");
}
}
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 {
info!("Deleting group post #{}", parent_status_id);
self.client.delete_status(parent_status_id).await?;
self.delay_after_post().await;
} else {
warn!("Only admin can delete posts made by the group user");
}
} else if self.is_admin || parent_account_id == &self.status_user_id {
info!("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 {
warn!("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(format!("User {} banned from group!", u));
self.unfollow_by_acct(&u).await.log_error("Failed to unfollow banned user");
}
Err(e) => {
self.add_reply(format!("Failed to ban user {}: {}", u, e));
}
}
} else {
self.add_reply(format!("No action, user {} is already banned", u));
}
} else {
warn!("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(format!("User {} un-banned!", u));
// no announcement here
}
Err(_) => {
unreachable!()
}
}
} else {
self.add_reply(format!("No action, user {} is not banned", u));
}
} else {
warn!("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(format!("Server {} banned from group!", s));
}
Err(e) => {
self.add_reply(format!("Failed to ban server {}: {}", s, e));
}
}
} else {
self.add_reply(format!("No action, server {} already banned", s));
}
} else {
warn!("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(format!("Server {} un-banned!", s));
}
Err(e) => {
self.add_reply(format!("Unexpected error occured: {}", e));
}
}
} else {
self.add_reply(format!("No action, server {} is not banned", s));
}
} else {
warn!("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(format!("User {} added to the group!", u));
self.follow_by_acct(&u).await.log_error("Failed to follow");
}
Err(e) => {
self.add_reply(format!("Failed to add user {} to group: {}", u, e));
}
}
} else {
warn!("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(format!("User {} removed from the group.", u));
self.unfollow_by_acct(&u).await.log_error("Failed to unfollow removed user");
}
Err(e) => {
self.add_reply(format!("Unexpected error occured: {}", e));
}
}
} else {
warn!("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(format!("Tag \"{}\" added to the group!", tag));
} else {
self.add_reply(format!("No action, \"{}\" is already a group tag", tag));
}
} else {
warn!("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(format!("Tag \"{}\" removed from the group!", tag));
} else {
self.add_reply(format!("No action, \"{}\" is not a group tag", tag));
}
} else {
warn!("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(format!("User {} is now a group admin!", u));
}
Err(e) => {
self.add_reply(format!("Failed to make user {} a group admin: {}", u, e));
}
}
} else {
self.add_reply(format!("No action, \"{}\" is admin already", u));
}
} else {
warn!("Ignore cmd, user not admin");
}
Ok(())
}
async fn cmd_revoke_member(&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(format!("User {} is no longer a group admin!", u));
}
Err(e) => {
self.add_reply(format!("Failed to revoke {}'s group admin: {}", u, e));
}
}
} else {
self.add_reply(format!("No action, user {} is not admin", u));
}
} else {
warn!("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("Group changed to open-access");
} else {
self.add_reply("No action, group is open-access already");
}
} else {
warn!("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("Group changed to member-only");
} else {
self.add_reply("No action, group is member-only already");
}
} else {
warn!("Ignore cmd, user not admin");
}
}
async fn cmd_help(&mut self) {
self.want_markdown = true;
let membership_line = if self.is_admin {
"*You are an admin.*"
} else if self.config.is_member(&self.status_acct) {
"*You are a member.*"
} else if self.config.is_member_only() {
"*You are not a member, ask one of the admins to add you.*"
} else {
"*You are not a member, follow or use /join to join the group.*"
};
if self.config.is_member_only() {
self.add_reply(format!("This is a member-only group. {}", membership_line));
} else {
self.add_reply(format!("This is a public-access group. {}", membership_line));
}
self.add_reply(
"\
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 {
self.add_reply("`/members`, `/who` - show group members / admins");
// 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 {
self.add_reply(
"\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) {
self.want_markdown = true;
if self.is_admin {
self.add_reply("Group members:");
self.append_member_list_to_reply();
} else {
self.add_reply("Group admins:");
self.append_admin_list_to_reply();
}
}
async fn cmd_list_tags(&mut self) {
self.add_reply("Group tags:");
self.want_markdown = true;
let mut tags = self.config.get_tags().collect::<Vec<_>>();
tags.sort();
for t in tags {
self.replies.push(format!("- {}", t).to_string());
}
}
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(
"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)
.await
.log_error("Failed to unfollow");
}
async fn cmd_join(&mut self) {
if self.config.is_member_or_admin(&self.status_acct) {
debug!("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(
"\
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();
} 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(
"\
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) {
self.add_reply(format!(
"pong, this is fedigroups service v{}",
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(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
debug!("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);
}
}