use std::collections::HashSet;
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::sync::Arc;
use std::sync::Mutex;
use anyhow::Context;
use log::{error, info};
use whatsapp_rust::InboundDurabilityHook;
use whatsapp_rust::prelude::*;
type CommitKey = (String, String, String);
struct InboxArchiver {
file: Arc<Mutex<File>>,
seen: Mutex<HashSet<CommitKey>>,
}
impl InboxArchiver {
async fn open(path: &str) -> anyhow::Result<Self> {
let path = path.to_string();
let (file, seen) = tokio::task::spawn_blocking(move || -> anyhow::Result<_> {
let mut seen = HashSet::new();
match std::fs::read_to_string(&path) {
Ok(content) => {
for line in content.split_inclusive('\n') {
if !line.ends_with('\n') {
continue;
}
let mut parts = line.trim_end_matches('\n').splitn(4, '\t');
if let (Some(c), Some(s), Some(i)) =
(parts.next(), parts.next(), parts.next())
{
seen.insert((c.to_string(), s.to_string(), i.to_string()));
}
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
return Err(anyhow::Error::from(e).context(format!("reading archive {path}")));
}
}
let file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.with_context(|| format!("opening archive file {path}"))?;
Ok((file, seen))
})
.await
.map_err(|e| anyhow::anyhow!("archive open task failed: {e}"))??;
Ok(Self {
file: Arc::new(Mutex::new(file)),
seen: Mutex::new(seen),
})
}
}
#[async_trait::async_trait]
impl InboundDurabilityHook for InboxArchiver {
async fn on_messages(
&self,
_client: Arc<Client>,
batch: &[InboundMessage],
) -> anyhow::Result<()> {
let mut lines = String::new();
let mut keys: Vec<CommitKey> = Vec::with_capacity(batch.len());
{
let seen = self
.seen
.lock()
.map_err(|_| anyhow::anyhow!("seen lock poisoned"))?;
for m in batch {
let key: CommitKey = (
m.info.source.chat.to_string(),
m.info.source.sender.to_string(),
m.info.id.clone(),
);
if seen.contains(&key) || keys.contains(&key) {
info!("[{}] already committed, skipping (dedup)", m.info.id);
continue;
}
let preview = m
.message
.conversation
.as_deref()
.unwrap_or("<non-text>")
.replace(['\t', '\n'], " ");
lines.push_str(&format!("{}\t{}\t{}\t{preview}\n", key.0, key.1, key.2));
keys.push(key);
}
}
if !keys.is_empty() {
let file = Arc::clone(&self.file);
tokio::task::spawn_blocking(move || -> std::io::Result<()> {
let mut file = file.lock().expect("file lock poisoned");
file.write_all(lines.as_bytes())?;
file.sync_all()
})
.await
.map_err(|e| anyhow::anyhow!("archive write task failed: {e}"))??;
let mut seen = self
.seen
.lock()
.map_err(|_| anyhow::anyhow!("seen lock poisoned"))?;
let count = keys.len();
for key in keys {
seen.insert(key);
}
info!("committed {count} message(s) durably in one fsync");
}
Ok(())
}
}
fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("failed to build tokio runtime");
rt.block_on(async {
let store = match SqliteStore::new("whatsapp.db").await {
Ok(store) => store,
Err(e) => {
error!("failed to create SQLite backend: {e}");
return;
}
};
let archiver = match InboxArchiver::open("inbox.jsonl").await {
Ok(archiver) => archiver,
Err(e) => {
error!("failed to open archive file: {e}");
return;
}
};
let bot = Bot::builder()
.with_backend(store)
.with_inbound_durability_hook(archiver)
.on_qr_code(|code, _timeout| async move {
info!("scan to pair:\n{code}");
})
.on_connected(|_client| async {
info!("connected; inbound messages are now committed before ack");
})
.build()
.await
.expect("failed to build bot");
bot.run().await;
});
}