use anyhow::Result;
use serde_json::Value;
use std::sync::Arc;
use crate::db;
crate::define_store! {
pub(crate) static TELEGRAM_INTAKE: TelegramIntakeStore,
expect = "TELEGRAM_INTAKE not initialized — call init_all_stores() first",
}
pub(crate) const RETENTION_HOURS: i64 = 48;
pub(crate) const IN_PROGRESS: &str = "in_progress";
const DONE: &str = "done";
const BURST_RETENTION_DAYS: i64 = 30;
pub(crate) struct BurstRecord {
pub(crate) user_name: String,
pub(crate) reply_target: String,
pub(crate) parts: usize,
pub(crate) released_by: &'static str,
pub(crate) released_at: String,
pub(crate) part_times: String,
}
impl TelegramIntakeStore {
pub(crate) async fn purge_all(&self) -> Result<()> {
self.conn
.execute("DELETE FROM telegram_intake", db::params![])
.await?;
self.conn
.execute("DELETE FROM telegram_bursts", db::params![])
.await?;
Ok(())
}
pub(crate) async fn age_out(&self) -> Result<u64> {
let cutoff = (chrono::Utc::now() - chrono::Duration::hours(RETENTION_HOURS)).to_rfc3339();
let events = self
.conn
.execute(
"DELETE FROM telegram_intake WHERE COALESCE(decided_at, created_at) < ?1",
db::params![cutoff],
)
.await?;
let bursts = self
.conn
.execute(
"DELETE FROM telegram_bursts WHERE released_at < ?1",
db::params![
(chrono::Utc::now() - chrono::Duration::days(BURST_RETENTION_DAYS))
.to_rfc3339()
],
)
.await?;
Ok(events + bursts)
}
pub(crate) async fn record_burst(&self, account_id: &str, record: &BurstRecord) -> Result<()> {
#[expect(clippy::cast_possible_wrap)] let parts = record.parts as i64;
self.conn
.execute(
"INSERT INTO telegram_bursts \
(account_id, user_name, reply_target, parts, released_by, released_at, part_times) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
db::params![
account_id,
record.user_name.as_str(),
record.reply_target.as_str(),
parts,
record.released_by,
record.released_at.as_str(),
record.part_times.as_str()
],
)
.await?;
Ok(())
}
}
pub(crate) async fn purge_all() -> Result<()> {
store().purge_all().await
}
#[derive(Clone)]
pub(crate) struct Intake {
store: TelegramIntakeStore,
claim: Arc<str>,
pub(crate) account_id: Arc<str>,
}
impl Intake {
pub(crate) fn new(store: TelegramIntakeStore, claim: Arc<str>, account_id: &str) -> Self {
Self {
store,
claim,
account_id: Arc::from(account_id),
}
}
pub(crate) async fn hold(&self, updates: &[&Value]) -> Result<Vec<i64>> {
if updates.is_empty() {
return Ok(Vec::new());
}
let mut values = Vec::with_capacity(updates.len() * 6);
for update in updates {
values.push(db::Value::from(self.account_id.as_ref()));
values.push(db::Value::from(update["update_id"].as_i64()));
values.push(db::Value::from(IN_PROGRESS));
values.push(db::Value::from(serde_json::to_string(update)?));
values.push(db::Value::from(db::now()));
values.push(db::Value::from(self.claim.as_ref()));
}
let rows = self
.store
.conn
.execute_rows(
&format!(
"INSERT INTO telegram_intake \
(account_id, update_id, state, payload, created_at, decided_at, claimed_by) \
VALUES {} ON CONFLICT(account_id, update_id) DO NOTHING \
RETURNING update_id",
vec!["(?, ?, ?, ?, ?, NULL, ?)"; updates.len()].join(", ")
),
values,
)
.await?;
rows.into_iter()
.map(|row| row.get::<i64>(0).map_err(anyhow::Error::from))
.collect()
}
pub(crate) async fn settle(&self, ids: &[i64]) {
if ids.is_empty() {
return;
}
if let Err(err) = self.resolve(ids).await {
tracing::warn!(?ids, %err, "Telegram intake: could not settle an event in the record");
}
}
pub(crate) async fn record_burst(&self, record: &BurstRecord) {
if let Err(err) = self.store.record_burst(&self.account_id, record).await {
tracing::warn!(%err, "Telegram burst: could not write the release record");
}
}
async fn resolve(&self, ids: &[i64]) -> Result<()> {
let mut values = Vec::with_capacity(ids.len() + 3);
values.push(db::Value::from(DONE));
values.push(db::Value::from(db::now()));
values.push(db::Value::from(self.account_id.as_ref()));
values.extend(ids.iter().copied().map(db::Value::from));
self.store
.conn
.execute(
&format!(
"UPDATE telegram_intake SET state = ?, payload = NULL, decided_at = ? \
WHERE account_id = ? AND update_id IN ({})",
db::sql_in_placeholders(ids.len())
),
values,
)
.await?;
Ok(())
}
pub(crate) async fn take_over(&self) -> Result<Vec<Value>> {
let rows = self
.store
.conn
.execute_rows(
"UPDATE telegram_intake SET claimed_by = ?1 \
WHERE account_id = ?2 AND state = ?3 \
AND (claimed_by IS NULL OR claimed_by <> ?1) \
RETURNING update_id, payload",
db::params![self.claim.as_ref(), self.account_id.as_ref(), IN_PROGRESS],
)
.await?;
let mut events = Vec::with_capacity(rows.len());
for row in rows {
let id = row.get::<i64>(0)?;
let takeable = row
.get::<Option<String>>(1)?
.and_then(|payload| serde_json::from_str::<Value>(&payload).ok())
.filter(|update| update["update_id"].as_i64().is_some());
if let Some(update) = takeable {
events.push(update);
} else {
tracing::warn!(
account_id = %self.account_id,
update_id = id,
"telegram_intake: a held event's payload cannot be taken up"
);
}
}
events.sort_by_key(|update: &Value| update["update_id"].as_i64());
Ok(events)
}
}
pub(crate) fn listener_claim() -> String {
format!("run-{}", crate::generate_id())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::params;
use serde_json::json;
async fn test_store() -> (TelegramIntakeStore, tempfile::TempDir) {
crate::open_test_store!(TelegramIntakeStore, "telegram_intake")
}
fn intake(store: &TelegramIntakeStore, claim: &str, account: &str) -> Intake {
Intake::new(store.clone(), Arc::from(claim), account)
}
#[tokio::test]
async fn hold_then_resolve_moves_event_out_of_take_over() {
let (store, _tmp) = test_store().await;
let update = json!({ "update_id": 7, "message": { "text": "hi" } });
intake(&store, "run-a", "1")
.hold(&[&update])
.await
.expect("hold");
assert_eq!(
intake(&store, "run-b", "1")
.take_over()
.await
.expect("take over"),
vec![update]
);
intake(&store, "run-a", "1").settle(&[7]).await;
assert!(
intake(&store, "run-c", "1")
.take_over()
.await
.expect("take over")
.is_empty()
);
}
#[tokio::test]
async fn take_over_claims_only_what_this_run_does_not_hold() {
let (store, _tmp) = test_store().await;
let run_a = intake(&store, "run-a", "1");
run_a
.hold(&[&json!({ "update_id": 2 })])
.await
.expect("hold 2");
run_a
.hold(&[&json!({ "update_id": 1 })])
.await
.expect("hold 1");
assert!(run_a.take_over().await.expect("own take-over").is_empty());
let ids: Vec<i64> = intake(&store, "run-b", "1")
.take_over()
.await
.expect("another run")
.iter()
.filter_map(|update| update["update_id"].as_i64())
.collect();
assert_eq!(ids, vec![1, 2]);
assert!(
intake(&store, "run-b", "1")
.take_over()
.await
.expect("run-b again")
.is_empty()
);
let reclaimed: Vec<i64> = run_a
.take_over()
.await
.expect("run-a again")
.iter()
.filter_map(|update| update["update_id"].as_i64())
.collect();
assert_eq!(reclaimed, vec![1, 2]);
}
#[tokio::test]
async fn same_update_id_is_distinct_per_account() {
let (store, _tmp) = test_store().await;
intake(&store, "run-a", "1")
.hold(&[&json!({ "update_id": 3 })])
.await
.expect("hold account 1");
intake(&store, "run-a", "2")
.hold(&[&json!({ "update_id": 3 })])
.await
.expect("hold account 2");
assert_eq!(
intake(&store, "run-b", "1")
.take_over()
.await
.expect("take over 1")
.len(),
1
);
assert_eq!(
intake(&store, "run-b", "2")
.take_over()
.await
.expect("take over 2")
.len(),
1
);
}
#[tokio::test]
async fn the_record_tells_events_apart_by_the_id_the_platform_gave_them() {
let (store, _tmp) = test_store().await;
let first = json!({ "update_id": 9, "message": { "from": { "id": 5 }, "text": "hi" } });
let second = json!({ "update_id": 10, "message": { "from": { "id": 5 }, "text": "hi" } });
assert_eq!(
intake(&store, "run-a", "1")
.hold(&[&first, &second])
.await
.expect("first hold"),
vec![9, 10]
);
assert!(
intake(&store, "run-b", "1")
.hold(&[&first])
.await
.expect("second hold")
.is_empty(),
"a second hold must report that the record already holds the event"
);
let recovered: Vec<i64> = intake(&store, "run-b", "1")
.take_over()
.await
.expect("take over")
.iter()
.filter_map(|update| update["update_id"].as_i64())
.collect();
assert_eq!(recovered, vec![9, 10]);
}
#[tokio::test]
async fn a_unit_is_recorded_whole_or_not_at_all() {
let (store, _tmp) = test_store().await;
let run_a = intake(&store, "run-a", "1");
let first = json!({ "update_id": 4, "message": { "media_group_id": "g" } });
let second = json!({ "update_id": 5, "message": { "media_group_id": "g" } });
let unidentified = json!({ "message": { "media_group_id": "g" } });
run_a
.hold(&[&first, &unidentified])
.await
.expect_err("an update without an id cannot be recorded");
assert!(
intake(&store, "run-b", "1")
.take_over()
.await
.expect("take over")
.is_empty()
);
assert_eq!(
run_a.hold(&[&first, &second]).await.expect("hold both"),
vec![4, 5]
);
}
#[tokio::test]
async fn age_out_drops_rows_past_the_retention_bound() {
let (store, _tmp) = test_store().await;
let run_a = intake(&store, "run-a", "1");
for id in [1, 2, 3] {
run_a
.hold(&[&json!({ "update_id": id })])
.await
.expect("hold");
}
run_a.settle(&[1]).await;
let old = (chrono::Utc::now() - chrono::Duration::hours(RETENTION_HOURS + 1)).to_rfc3339();
store
.conn
.execute(
"UPDATE telegram_intake SET decided_at = ?1 WHERE update_id = 1",
params![old.clone()],
)
.await
.expect("age the resolved row");
store
.conn
.execute(
"UPDATE telegram_intake SET created_at = ?1 WHERE update_id = 3",
params![old],
)
.await
.expect("age the held row");
assert_eq!(store.age_out().await.expect("age_out"), 2);
let held: Vec<i64> = intake(&store, "run-b", "1")
.take_over()
.await
.expect("take over")
.iter()
.filter_map(|update| update["update_id"].as_i64())
.collect();
assert_eq!(held, vec![2]);
}
#[tokio::test]
async fn age_out_drops_bursts_past_their_own_bound() {
let (store, _tmp) = test_store().await;
let run_a = intake(&store, "run-a", "1");
let record = |released_at: String| BurstRecord {
user_name: "alice".to_string(),
reply_target: "999".to_string(),
parts: 2,
released_by: "extended_window",
released_at,
part_times: "[]".to_string(),
};
let now = chrono::Utc::now();
let fresh = now.to_rfc3339();
run_a
.record_burst(&record(
(now - chrono::Duration::days(BURST_RETENTION_DAYS + 1)).to_rfc3339(),
))
.await;
run_a.record_burst(&record(fresh.clone())).await;
assert_eq!(store.age_out().await.expect("age_out"), 1);
let rows = store
.conn
.query("SELECT released_at FROM telegram_bursts", ())
.await
.expect("read the burst record");
let kept: Vec<String> = rows
.into_iter()
.map(|row| row.get::<String>(0).expect("released_at"))
.collect();
assert_eq!(kept, vec![fresh]);
}
}