use crate::ChannelMessage;
use crate::channels::telegram::{FORWARD_ATTRIBUTION_PREFIX, is_control_message};
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::time::Instant;
const QUIET_WINDOW: Duration = Duration::from_secs(1);
const EXTENDED_WINDOW: Duration = Duration::from_secs(3);
const CEILING: Duration = Duration::from_secs(5);
const MAX_PARTS: usize = 6;
const MAX_CHARS: usize = 4000;
const PART_SEPARATOR: &str = "\n\n--- next message ---\n\n";
pub(crate) const QUEUE_CAPACITY: usize = 100;
struct OpenGroup {
parts: Vec<ChannelMessage>,
chars: usize,
first_at: Instant,
last_at: Instant,
}
impl OpenGroup {
fn new(part: ChannelMessage, now: Instant) -> Self {
let chars = part.content.chars().count();
Self {
parts: vec![part],
chars,
first_at: now,
last_at: now,
}
}
fn key(&self) -> (&str, &str) {
let first = &self.parts[0];
(first.user_name.as_str(), first.reply_target.as_str())
}
fn matches(&self, part: &ChannelMessage) -> bool {
self.key() == (part.user_name.as_str(), part.reply_target.as_str())
}
fn add(&mut self, part: ChannelMessage, now: Instant) -> bool {
self.chars += part.content.chars().count();
self.last_at = now;
self.parts.push(part);
self.parts.len() >= MAX_PARTS || self.chars > MAX_CHARS
}
fn extended(&self) -> bool {
self.parts.len() > 1
|| self.parts[0]
.content
.starts_with(FORWARD_ATTRIBUTION_PREFIX)
}
fn deadline(&self) -> Instant {
let window = if self.extended() {
EXTENDED_WINDOW
} else {
QUIET_WINDOW
};
(self.last_at + window).min(self.first_at + CEILING)
}
}
#[derive(Default)]
struct Collector {
open: Vec<OpenGroup>,
}
impl Collector {
fn push(&mut self, part: ChannelMessage, now: Instant) -> Option<Vec<ChannelMessage>> {
let Some(at) = self.open.iter().position(|group| group.matches(&part)) else {
self.open.push(OpenGroup::new(part, now));
return None;
};
if !self.open[at].add(part, now) {
return None;
}
Some(self.open.remove(at).parts)
}
fn deadline(&self) -> Option<Instant> {
self.open.iter().map(OpenGroup::deadline).min()
}
fn release_due(&mut self, now: Instant) -> Vec<Vec<ChannelMessage>> {
self.take_where(|group| group.deadline() <= now)
}
fn release_chat(&mut self, user_name: &str, reply_target: &str) -> Vec<Vec<ChannelMessage>> {
self.take_where(|group| group.key() == (user_name, reply_target))
}
fn release_all(&mut self) -> Vec<Vec<ChannelMessage>> {
self.take_where(|_| true)
}
fn take_where(&mut self, pred: impl Fn(&OpenGroup) -> bool) -> Vec<Vec<ChannelMessage>> {
let mut ready = Vec::new();
let mut at = 0;
while at < self.open.len() {
if pred(&self.open[at]) {
ready.push(self.open.remove(at).parts);
} else {
at += 1;
}
}
ready
}
}
fn group_message(parts: Vec<ChannelMessage>) -> ChannelMessage {
let newest = parts
.last()
.expect("a released group holds at least one part");
let user_name = newest.user_name.clone();
let reply_target = newest.reply_target.clone();
let channel = newest.channel.clone();
ChannelMessage {
user_name,
reply_target,
channel,
parts,
..Default::default()
}
}
#[must_use]
pub fn compose_group_content(parts: Vec<ChannelMessage>) -> String {
match <[ChannelMessage; 1]>::try_from(parts) {
Ok([only]) => only.content,
Err(parts) => parts
.iter()
.map(|part| part.content.as_str())
.collect::<Vec<_>>()
.join(PART_SEPARATOR),
}
}
pub(crate) fn spawn_collector(
input: mpsc::Receiver<ChannelMessage>,
output: mpsc::Sender<ChannelMessage>,
) {
tokio::spawn(run_collector(input, output));
}
async fn run_collector(
mut input: mpsc::Receiver<ChannelMessage>,
output: mpsc::Sender<ChannelMessage>,
) {
let mut collector = Collector::default();
loop {
tokio::select! {
() = wait_until(collector.deadline()) => {
if !hand_over_all(&output, collector.release_due(Instant::now())).await { return; }
}
msg = input.recv() => {
let Some(msg) = msg else {
hand_over_all(&output, collector.release_all()).await;
return;
};
if is_control_message(&msg) {
if !hand_over_all(&output, collector.release_chat(&msg.user_name, &msg.reply_target)).await { return; }
if output.send(msg).await.is_err() { return; }
continue;
}
if let Some(group) = collector.push(msg, Instant::now())
&& !hand_over(&output, group).await { return; }
}
}
}
}
async fn wait_until(deadline: Option<Instant>) {
match deadline {
Some(at) => tokio::time::sleep_until(at).await,
None => std::future::pending().await,
}
}
async fn hand_over_all(
output: &mpsc::Sender<ChannelMessage>,
groups: Vec<Vec<ChannelMessage>>,
) -> bool {
for group in groups {
if !hand_over(output, group).await {
return false;
}
}
true
}
async fn hand_over(output: &mpsc::Sender<ChannelMessage>, group: Vec<ChannelMessage>) -> bool {
let msg = match <[ChannelMessage; 1]>::try_from(group) {
Ok([only]) => only,
Err(parts) => {
let merged = group_message(parts);
tracing::info!(
user_name = %merged.user_name,
parts = merged.parts.len(),
"Telegram burst: releasing collected messages as one request"
);
merged
}
};
if output.send(msg).await.is_ok() {
return true;
}
tracing::warn!("Telegram burst: the message pipeline is gone; dropped a released group");
false
}
#[cfg(test)]
mod tests {
use super::*;
fn part(user: &str, target: &str, content: &str) -> ChannelMessage {
ChannelMessage {
user_name: user.to_string(),
reply_target: target.to_string(),
channel: "telegram".to_string(),
content: content.to_string(),
..Default::default()
}
}
#[test]
fn lone_plain_part_releases_after_quiet_window() {
let mut collector = Collector::default();
let t0 = Instant::now();
assert!(collector.push(part("alice", "chat", "hello"), t0).is_none());
assert_eq!(collector.deadline(), Some(t0 + Duration::from_secs(1)));
assert!(
collector
.release_due(t0 + Duration::from_millis(999))
.is_empty()
);
let released = collector.release_due(t0 + Duration::from_secs(1));
assert_eq!(released.len(), 1);
assert_eq!(released[0].len(), 1);
assert_eq!(released[0][0].content, "hello");
}
#[test]
fn second_part_extends_the_window() {
let mut collector = Collector::default();
let t0 = Instant::now();
collector.push(part("alice", "chat", "one"), t0);
collector.push(
part("alice", "chat", "two"),
t0 + Duration::from_millis(500),
);
assert_eq!(
collector.deadline(),
Some(t0 + Duration::from_millis(500) + EXTENDED_WINDOW)
);
let mut forwarded = Collector::default();
forwarded.push(part("alice", "chat", "[Forwarded from @bob] hi"), t0);
assert_eq!(forwarded.deadline(), Some(t0 + EXTENDED_WINDOW));
}
#[test]
fn deadline_never_exceeds_the_ceiling() {
let mut collector = Collector::default();
let t0 = Instant::now();
collector.push(part("alice", "chat", "one"), t0);
collector.push(part("alice", "chat", "two"), t0 + Duration::from_secs(2));
collector.push(part("alice", "chat", "three"), t0 + Duration::from_secs(4));
assert_eq!(collector.deadline(), Some(t0 + CEILING));
}
#[test]
fn part_cap_releases_the_group() {
let mut collector = Collector::default();
let t0 = Instant::now();
for _ in 0..MAX_PARTS - 1 {
assert!(collector.push(part("alice", "chat", "x"), t0).is_none());
}
let released = collector
.push(part("alice", "chat", "x"), t0)
.expect("reaching the part cap must release");
assert_eq!(released.len(), MAX_PARTS);
}
#[test]
fn character_cap_releases_the_group() {
let mut collector = Collector::default();
let t0 = Instant::now();
let half = "x".repeat(MAX_CHARS / 2 + 1);
assert!(collector.push(part("alice", "chat", &half), t0).is_none());
let released = collector
.push(part("alice", "chat", &half), t0)
.expect("exceeding the character cap must release");
assert_eq!(released.len(), 2);
let half = "x".repeat(MAX_CHARS / 2);
let mut at_cap = Collector::default();
at_cap.push(part("alice", "chat", &half), t0);
assert!(at_cap.push(part("alice", "chat", &half), t0).is_none());
assert!(at_cap.deadline().is_some());
}
#[test]
fn groups_are_keyed_by_user_and_chat() {
let t0 = Instant::now();
let mut collector = Collector::default();
collector.push(part("alice", "chat-a", "a1"), t0);
collector.push(part("bob", "chat-a", "b1"), t0);
collector.push(part("alice", "chat-b", "a2"), t0);
collector.push(part("alice", "chat-a", "a3"), t0);
let released = collector.release_chat("alice", "chat-a");
assert_eq!(released.len(), 1);
assert_eq!(released[0].len(), 2);
assert_eq!(released[0][0].content, "a1");
assert_eq!(released[0][1].content, "a3");
assert_eq!(collector.release_all().len(), 2);
assert!(collector.release_all().is_empty());
assert_eq!(collector.deadline(), None);
}
#[test]
fn compose_group_content_joins_parts_in_order() {
let parts = vec![part("alice", "chat", "one"), part("alice", "chat", "two")];
assert_eq!(
compose_group_content(parts),
format!("one{PART_SEPARATOR}two")
);
let single = vec![part("alice", "chat", "solo")];
assert_eq!(compose_group_content(single), "solo");
}
async fn released(rx: &mut mpsc::Receiver<ChannelMessage>) -> ChannelMessage {
tokio::time::timeout(Duration::from_secs(5), rx.recv())
.await
.expect("the collector released nothing")
.expect("the collector stopped early")
}
#[tokio::test]
async fn collector_hands_a_command_over_after_the_open_group() {
let (input, input_rx) = mpsc::channel(QUEUE_CAPACITY);
let (output, mut output_rx) = mpsc::channel(8);
spawn_collector(input_rx, output);
input.send(part("alice", "chat", "one")).await.unwrap();
input.send(part("alice", "chat", "two")).await.unwrap();
input.send(part("alice", "chat", "/pause")).await.unwrap();
let group = released(&mut output_rx).await;
assert_eq!(group.parts.len(), 2);
assert!(group.content.is_empty());
let command = released(&mut output_rx).await;
assert_eq!(command.content, "/pause");
}
#[tokio::test]
async fn collector_releases_the_intact_group_when_the_listener_goes_away() {
let (input, input_rx) = mpsc::channel(QUEUE_CAPACITY);
let (output, mut output_rx) = mpsc::channel(8);
spawn_collector(input_rx, output);
let mut first = part("alice", "chat", "[Forwarded from @bob] one");
first.attachment_dirs = vec!["dir-one".to_string()];
let mut second = part("alice", "chat", "two");
second.attachment_dirs = vec!["dir-two".to_string()];
input.send(first).await.unwrap();
input.send(second).await.unwrap();
drop(input);
let group = released(&mut output_rx).await;
assert_eq!(group.parts.len(), 2);
assert_eq!(group.parts[0].content, "[Forwarded from @bob] one");
assert_eq!(group.parts[0].attachment_dirs, vec!["dir-one".to_string()]);
assert_eq!(group.parts[1].content, "two");
assert_eq!(group.parts[1].attachment_dirs, vec!["dir-two".to_string()]);
assert!(group.content.is_empty());
assert_eq!(group.user_name, "alice");
assert_eq!(group.reply_target, "chat");
assert_eq!(group.channel, "telegram");
assert_eq!(
compose_group_content(group.parts.clone()),
format!("[Forwarded from @bob] one{PART_SEPARATOR}two")
);
assert!(output_rx.recv().await.is_none());
}
}