| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | | use crate::db::{self, accounts::Account, notify as ndb}; |
| 5 | | use crate::Shared; |
| 6 | | use std::collections::BTreeSet; |
| 7 | |
|
| 8 | | tokio::task_local! { |
| 9 | | |
| 10 | | |
| 11 | | |
| 12 | | pub static AGENT: Option<String>; |
| 13 | | } |
| 14 | |
|
| 15 | |
|
| 16 | 121× | pub fn agent_acting() -> Option<String> { |
| 17 | 121× | AGENT.try_with(|a| a.clone()).ok().flatten() |
| 18 | 121× | } |
| 19 | |
|
| 20 | |
|
| 21 | | pub struct Event<'a> { |
| 22 | | pub kind: &'a str, |
| 23 | | pub title: String, |
| 24 | | pub url: String, |
| 25 | | pub actor: &'a Account, |
| 26 | | pub repo_id: i64, |
| 27 | | |
| 28 | | pub thread: Option<(&'a str, i64)>, |
| 29 | | |
| 30 | | pub mention_text: &'a str, |
| 31 | | |
| 32 | | pub direct: Vec<i64>, |
| 33 | | } |
| 34 | |
|
| 35 | |
|
| 36 | 123× | pub fn mentions(text: &str) -> Vec<String> { |
| 37 | 123× | let re = regex::Regex::new(r"(?:^|[^\w/])@([A-Za-z0-9][A-Za-z0-9._-]{0,63})").unwrap(); |
| 38 | 123× | let mut out = BTreeSet::new(); |
| 39 | 123× | for c in re.captures_iter(text) { |
| 40 | 3× | out.insert(c[1].trim_end_matches('.').to_string()); |
| 41 | 3× | } |
| 42 | 123× | out.into_iter().collect() |
| 43 | 123× | } |
| 44 | |
|
| 45 | |
|
| 46 | |
|
| 47 | 121× | pub async fn deliver(app: &Shared, ev: Event<'_>) { |
| 48 | 121× | let pool = &app.db; |
| 49 | | |
| 50 | | |
| 51 | 121× | let mut participants: BTreeSet<i64> = BTreeSet::new(); |
| 52 | 121× | if let Some((kind, id)) = ev.thread { |
| 53 | 99× | if let Ok(subs) = ndb::subscribers(pool, kind, id).await { |
| 54 | 99× | participants.extend(subs); |
| 55 | 99× | } |
| 56 | | |
| 57 | 99× | ndb::subscribe(pool, ev.actor.id, kind, id).await.ok(); |
| 58 | 22× | } |
| 59 | 121× | for name in mentions(ev.mention_text) { |
| 60 | 1× | if let Ok(Some(a)) = db::accounts::account_by_name(pool, &name).await { |
| 61 | 1× | participants.insert(a.id); |
| 62 | 1× | } |
| 63 | | } |
| 64 | 121× | participants.extend(ev.direct.iter().copied()); |
| 65 | 121× | let mut targets: Vec<(i64, ndb::Reason)> = participants.iter().map(|id| (*id, ndb::Reason::Participating)).collect(); |
| 66 | 121× | if let Ok(w) = ndb::watchers(pool, ev.repo_id).await { |
| 67 | 127× | targets.extend(w.into_iter().filter(|id| !participants.contains(id)).map(|id| (id, ndb::Reason::Watching))); |
| 68 | 0× | } |
| 69 | | |
| 70 | | |
| 71 | 121× | let agent = agent_acting(); |
| 72 | 121× | if agent.is_none() { |
| 73 | 134× | targets.retain(|(id, _)| *id != ev.actor.id); |
| 74 | 3× | } |
| 75 | 121× | let actor = match &agent { |
| 76 | 3× | Some(token) => format!("{} (agent token {token})", ev.actor.username), |
| 77 | 118× | None => ev.actor.username.clone(), |
| 78 | | }; |
| 79 | 121× | tell(app, ev.kind, &ev.title, &ev.url, &actor, (ev.repo_id > 0).then_some(ev.repo_id), targets).await; |
| 80 | 121× | } |
| 81 | |
|
| 82 | |
|
| 83 | |
|
| 84 | 174× | pub async fn tell(app: &Shared, kind: &str, title: &str, url: &str, actor: &str, repo_id: Option<i64>, targets: Vec<(i64, ndb::Reason)>) { |
| 85 | 174× | let pool = &app.db; |
| 86 | 174× | for (id, reason) in targets { |
| 87 | 106× | ndb::add(pool, ndb::New { account_id: id, repo_id, reason, kind, title, url, actor }).await.ok(); |
| 88 | 106× | app.bus.live(&crate::events::topic::user(id), "notification"); |
| 89 | | |
| 90 | 106× | let link = format!("{}{url}", app.config.base_url().trim_end_matches('/')); |
| 91 | 106× | crate::push::send(app, id, crate::push::Alert { kind: kind.to_string(), title: subject(kind, title), url: link }); |
| 92 | 106× | if let Ok(Some(a)) = db::accounts::account_by_id(pool, id).await { |
| 93 | | |
| 94 | 106× | if a.email_notifications && a.email_verified && !a.email.is_empty() && crate::mail::enabled(app) { |
| 95 | 0× | email(app, &a, kind, title, url, actor).await; |
| 96 | 106× | } |
| 97 | 0× | } |
| 98 | | } |
| 99 | 174× | } |
| 100 | |
|
| 101 | |
|
| 102 | |
|
| 103 | 125× | pub fn label(kind: &str) -> Option<&'static str> { |
| 104 | 125× | Some(match kind { |
| 105 | 125× | "flow.failed" => "💥 Build Broken", |
| 106 | 114× | "flow.fixed" => "🎉 Build Success", |
| 107 | 112× | "flow.runner_drained" => "🛑 Runner Drained", |
| 108 | 111× | "backup" => "⚠️ Backup Failed", |
| 109 | 108× | "backup.recovered" => "✅ Backup Recovered", |
| 110 | 107× | "backup.summary" => "📦 Daily Backup Summary", |
| 111 | 105× | "proposal.opened" => "🔀 New Proposal", |
| 112 | 89× | "proposal.review_requested" => "👀 Review Requested", |
| 113 | 85× | "proposal.review" => "📝 New Review", |
| 114 | 80× | "proposal.landed" => "🚀 Proposal Landed", |
| 115 | 77× | "proposal.comment" | "issue.comment" | "drop.comment" => "💬 New Comment", |
| 116 | 66× | "issue.opened" => "🐛 New Issue", |
| 117 | 54× | "issue.assigned" => "📌 Issue Assigned", |
| 118 | 51× | "issue.closed" => "✔️ Issue Closed", |
| 119 | 50× | "account.registered" => "👋 New Sign-up", |
| 120 | 11× | _ => return None, |
| 121 | | }) |
| 122 | 125× | } |
| 123 | |
|
| 124 | |
|
| 125 | 110× | pub fn subject(kind: &str, title: &str) -> String { |
| 126 | 110× | match label(kind) { |
| 127 | 103× | Some(l) => format!("{l}: {title}"), |
| 128 | 7× | None => title.to_string(), |
| 129 | | } |
| 130 | 110× | } |
| 131 | |
|
| 132 | 0× | async fn email(app: &Shared, to: &Account, kind: &str, title: &str, url: &str, actor: &str) { |
| 133 | 0× | let unsubscribe = format!("{}/settings/notifications?unsubscribe={}", app.config.base_url(), to.unsubscribe_key); |
| 134 | 0× | let link = format!("{}{url}", app.config.base_url()); |
| 135 | 0× | let body = format!("{actor}: {title}\n\n{link}\n\nStop these emails: {unsubscribe}\n"); |
| 136 | 0× | let action = crate::mail::Action { label: "Open it", url: &link }; |
| 137 | 0× | let subject = subject(kind, title); |
| 138 | 0× | if let Err(e) = crate::mail::send_letter(app, to.name(), &to.email, &subject, &body, Some(action), Some(&unsubscribe)).await { |
| 139 | 0× | tracing::warn!("email to {}: {e}", to.email); |
| 140 | | |
| 141 | 0× | if crate::mail::enabled(app) { |
| 142 | 0× | crate::queue::queue_mail(app, to.name(), &to.email, &subject, &body, Some(("Open it", &link)), Some(&unsubscribe)).await; |
| 143 | 0× | } |
| 144 | 0× | } |
| 145 | 0× | } |
| 146 | |
|
| 147 | | #[cfg(test)] |
| 148 | | mod tests { |
| 149 | | use super::*; |
| 150 | |
|
| 151 | | #[test] |
| 152 | 1× | fn subjects_say_what_happened() { |
| 153 | 1× | assert_eq!( |
| 154 | 1× | subject("flow.failed", "andy/demo: run #4 on main failed (test)"), |
| 155 | | "💥 Build Broken: andy/demo: run #4 on main failed (test)" |
| 156 | | ); |
| 157 | 1× | assert_eq!( |
| 158 | 1× | subject("flow.fixed", "andy/demo: run #5 on main passed: fixed"), |
| 159 | | "🎉 Build Success: andy/demo: run #5 on main passed: fixed" |
| 160 | | ); |
| 161 | 1× | assert_eq!(subject("backup.summary", "x"), "📦 Daily Backup Summary: x"); |
| 162 | 1× | assert_eq!(subject("something.new", "x"), "x", "a kind without a label keeps its title"); |
| 163 | 1× | } |
| 164 | |
|
| 165 | | #[test] |
| 166 | 1× | fn finds_mentions() { |
| 167 | 1× | assert_eq!(mentions("cc @andy and @bob. not an email [email protected], nor /@path"), vec!["andy", "bob"]); |
| 168 | 1× | assert!(mentions("nothing here").is_empty()); |
| 169 | 1× | } |
| 170 | | } |