fix: improved discord & signal comms

This commit is contained in:
lewis
2026-02-05 16:13:45 +00:00
committed by Tangled
parent b104dc938a
commit cd7400bc8b
71 changed files with 1596 additions and 514 deletions
+1 -1
View File
@@ -8,6 +8,6 @@ pub use locale::{
};
pub use sender::{
CommsSender, DiscordSender, EmailSender, SendError, SignalSender, TelegramSender,
is_valid_phone_number, mime_encode_header, sanitize_header_value,
is_valid_phone_number, is_valid_signal_username, mime_encode_header, sanitize_header_value,
};
pub use types::{CommsChannel, CommsStatus, CommsType, NewComms, QueuedComms};
+14
View File
@@ -33,6 +33,8 @@ pub struct NotificationStrings {
pub migration_verification_body: &'static str,
pub channel_verified_subject: &'static str,
pub channel_verified_body: &'static str,
pub channel_verification_subject: &'static str,
pub channel_verification_body: &'static str,
}
pub fn get_strings(locale: &str) -> &'static NotificationStrings {
@@ -70,6 +72,8 @@ static STRINGS_EN: NotificationStrings = NotificationStrings {
migration_verification_body: "Welcome to {hostname}!\n\nYour account has been migrated successfully. To complete the setup, please verify your email address.\n\nYour verification code is:\n{code}\n\nCopy the code above and enter it at:\n{verify_page}\n\nThis code will expire in 48 hours.\n\nOr if you like to live dangerously:\n{verify_link}\n\nIf you did not migrate your account, please ignore this email.",
channel_verified_subject: "Channel verified - {hostname}",
channel_verified_body: "Hello {handle},\n\n{channel} has been verified as a notification channel for your account on {hostname}.",
channel_verification_subject: "Verify your channel - {hostname}",
channel_verification_body: "Your verification code is:\n{code}\n\nOr verify directly:\n{verify_link}",
};
static STRINGS_ZH: NotificationStrings = NotificationStrings {
@@ -96,6 +100,8 @@ static STRINGS_ZH: NotificationStrings = NotificationStrings {
migration_verification_body: "欢迎来到 {hostname}!\n\n您的账户已成功迁移。要完成设置,请验证您的邮箱地址。\n\n您的验证码是:\n{code}\n\n复制上述验证码并在此输入:\n{verify_page}\n\n此验证码将在 48 小时后过期。\n\n或者直接点击链接:\n{verify_link}\n\n如果您没有迁移账户,请忽略此邮件。",
channel_verified_subject: "通知渠道已验证 - {hostname}",
channel_verified_body: "您好 {handle},\n\n{channel} 已被验证为您在 {hostname} 上的通知渠道。",
channel_verification_subject: "验证您的渠道 - {hostname}",
channel_verification_body: "您的验证码是:\n{code}\n\n或直接验证:\n{verify_link}",
};
static STRINGS_JA: NotificationStrings = NotificationStrings {
@@ -122,6 +128,8 @@ static STRINGS_JA: NotificationStrings = NotificationStrings {
migration_verification_body: "{hostname} へようこそ!\n\nアカウントの移行が完了しました。設定を完了するには、メールアドレスを認証してください。\n\n認証コードは:\n{code}\n\n上記のコードをコピーして、こちらで入力してください:\n{verify_page}\n\nこのコードは48時間後に期限切れとなります。\n\n自己責任でワンクリック認証:\n{verify_link}\n\nアカウントを移行していない場合は、このメールを無視してください。",
channel_verified_subject: "通知チャンネル認証完了 - {hostname}",
channel_verified_body: "{handle} 様\n\n{channel} が {hostname} の通知チャンネルとして認証されました。",
channel_verification_subject: "チャンネルを認証 - {hostname}",
channel_verification_body: "認証コードは:\n{code}\n\n直接認証:\n{verify_link}",
};
static STRINGS_KO: NotificationStrings = NotificationStrings {
@@ -148,6 +156,8 @@ static STRINGS_KO: NotificationStrings = NotificationStrings {
migration_verification_body: "{hostname}에 오신 것을 환영합니다!\n\n계정 마이그레이션이 완료되었습니다. 설정을 완료하려면 이메일 주소를 인증하세요.\n\n인증 코드는:\n{code}\n\n위 코드를 복사하여 여기에 입력하세요:\n{verify_page}\n\n이 코드는 48시간 후에 만료됩니다.\n\n위험을 감수하고 원클릭 인증:\n{verify_link}\n\n계정을 마이그레이션하지 않았다면 이 이메일을 무시하세요.",
channel_verified_subject: "알림 채널 인증 완료 - {hostname}",
channel_verified_body: "안녕하세요 {handle}님,\n\n{channel}이(가) {hostname}의 알림 채널로 인증되었습니다.",
channel_verification_subject: "채널 인증 - {hostname}",
channel_verification_body: "인증 코드:\n{code}\n\n직접 인증:\n{verify_link}",
};
static STRINGS_SV: NotificationStrings = NotificationStrings {
@@ -174,6 +184,8 @@ static STRINGS_SV: NotificationStrings = NotificationStrings {
migration_verification_body: "Välkommen till {hostname}!\n\nDitt konto har migrerats framgångsrikt. För att slutföra installationen, verifiera din e-postadress.\n\nDin verifieringskod är:\n{code}\n\nKopiera koden ovan och ange den på:\n{verify_page}\n\nDenna kod upphör om 48 timmar.\n\nEller om du gillar att leva farligt:\n{verify_link}\n\nOm du inte migrerade ditt konto kan du ignorera detta meddelande.",
channel_verified_subject: "Aviseringskanal verifierad - {hostname}",
channel_verified_body: "Hej {handle},\n\n{channel} har verifierats som aviseringskanal för ditt konto på {hostname}.",
channel_verification_subject: "Verifiera din kanal - {hostname}",
channel_verification_body: "Din verifieringskod är:\n{code}\n\nEller verifiera direkt:\n{verify_link}",
};
static STRINGS_FI: NotificationStrings = NotificationStrings {
@@ -200,6 +212,8 @@ static STRINGS_FI: NotificationStrings = NotificationStrings {
migration_verification_body: "Tervetuloa palveluun {hostname}!\n\nTilisi on siirretty onnistuneesti. Viimeistele asennus vahvistamalla sähköpostiosoitteesi.\n\nVahvistuskoodisi on:\n{code}\n\nKopioi koodi yllä ja syötä se osoitteessa:\n{verify_page}\n\nTämä koodi vanhenee 48 tunnissa.\n\nTai jos pidät vaarallisesta elämästä:\n{verify_link}\n\nJos et siirtänyt tiliäsi, voit jättää tämän viestin huomiotta.",
channel_verified_subject: "Ilmoituskanava vahvistettu - {hostname}",
channel_verified_body: "Hei {handle},\n\n{channel} on vahvistettu ilmoituskanavaksi tilillesi palvelussa {hostname}.",
channel_verification_subject: "Vahvista kanavasi - {hostname}",
channel_verification_body: "Vahvistuskoodisi on:\n{code}\n\nTai vahvista suoraan:\n{verify_link}",
};
pub fn format_message(template: &str, vars: &[(&str, &str)]) -> String {
+284 -30
View File
@@ -6,6 +6,7 @@ use std::process::Stdio;
use std::time::Duration;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use tokio::time::timeout;
use super::types::{CommsChannel, QueuedComms};
@@ -85,6 +86,25 @@ pub fn is_valid_phone_number(number: &str) -> bool {
!remaining.is_empty() && remaining.chars().all(|c| c.is_ascii_digit())
}
pub fn is_valid_signal_username(username: &str) -> bool {
if username.len() < 6 || username.len() > 35 {
return false;
}
let Some((base, discriminator)) = username.rsplit_once('.') else {
return false;
};
if base.len() < 3 || base.len() > 32 {
return false;
}
if !base.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return false;
}
if !base.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) {
return false;
}
discriminator.len() == 2 && discriminator.chars().all(|c| c.is_ascii_digit())
}
pub struct EmailSender {
from_address: String,
from_name: String,
@@ -154,22 +174,208 @@ impl CommsSender for EmailSender {
}
}
const DISCORD_API_BASE: &str = "https://discord.com/api/v10";
#[derive(Clone)]
pub struct DiscordSender {
webhook_url: String,
bot_token: String,
http_client: Client,
}
impl DiscordSender {
pub fn new(webhook_url: String) -> Self {
pub fn new(bot_token: String) -> Self {
Self {
webhook_url,
bot_token,
http_client: create_http_client(),
}
}
pub fn from_env() -> Option<Self> {
let webhook_url = std::env::var("DISCORD_WEBHOOK_URL").ok()?;
Some(Self::new(webhook_url))
let bot_token = std::env::var("DISCORD_BOT_TOKEN").ok()?;
Some(Self::new(bot_token))
}
fn auth_header(&self) -> String {
format!("Bot {}", self.bot_token)
}
pub async fn resolve_application_info(&self) -> Result<(String, String), SendError> {
let url = format!("{}/applications/@me", DISCORD_API_BASE);
let response = self
.http_client
.get(&url)
.header("Authorization", self.auth_header())
.send()
.await
.map_err(|e| {
SendError::ExternalService(format!(
"Discord application info request failed: {}",
e
))
})?;
if !response.status().is_success() {
let body = response.text().await.unwrap_or_default();
return Err(SendError::ExternalService(format!(
"Discord application info returned error: {}",
body
)));
}
let data: serde_json::Value = response.json().await.map_err(|e| {
SendError::ExternalService(format!("Failed to parse Discord application info: {}", e))
})?;
let app_id = data
.get("id")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| SendError::ExternalService("Application info missing id".to_string()))?;
let verify_key = data
.get("verify_key")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| {
SendError::ExternalService("Application info missing verify_key".to_string())
})?;
Ok((app_id, verify_key))
}
pub async fn register_slash_command(&self, app_id: &str) -> Result<(), SendError> {
let url = format!("{}/applications/{}/commands", DISCORD_API_BASE, app_id);
let payload = serde_json::json!({
"name": "start",
"description": "Verify your PDS account",
"type": 1,
"options": [{
"name": "handle",
"description": "Your PDS handle (e.g. alice.example.com)",
"type": 3,
"required": false
}]
});
let response = self
.http_client
.post(&url)
.header("Authorization", self.auth_header())
.json(&payload)
.send()
.await
.map_err(|e| {
SendError::ExternalService(format!("Register command request failed: {}", e))
})?;
if !response.status().is_success() {
let body = response.text().await.unwrap_or_default();
return Err(SendError::ExternalService(format!(
"Register command returned error: {}",
body
)));
}
Ok(())
}
pub async fn set_interactions_endpoint(
&self,
app_id: &str,
url: &str,
) -> Result<(), SendError> {
let patch_url = format!("{}/applications/{}", DISCORD_API_BASE, app_id);
let payload = serde_json::json!({
"interactions_endpoint_url": url
});
let response = self
.http_client
.patch(&patch_url)
.header("Authorization", self.auth_header())
.json(&payload)
.send()
.await
.map_err(|e| {
SendError::ExternalService(format!("Set interactions endpoint failed: {}", e))
})?;
if !response.status().is_success() {
let body = response.text().await.unwrap_or_default();
return Err(SendError::ExternalService(format!(
"Set interactions endpoint returned error: {}",
body
)));
}
Ok(())
}
pub async fn resolve_bot_username(&self) -> Result<String, SendError> {
let url = format!("{}/users/@me", DISCORD_API_BASE);
let response = self
.http_client
.get(&url)
.header("Authorization", self.auth_header())
.send()
.await
.map_err(|e| {
SendError::ExternalService(format!("Discord getMe request failed: {}", e))
})?;
if !response.status().is_success() {
let body = response.text().await.unwrap_or_default();
return Err(SendError::ExternalService(format!(
"Discord getMe returned error: {}",
body
)));
}
let data: serde_json::Value = response.json().await.map_err(|e| {
SendError::ExternalService(format!("Failed to parse Discord getMe response: {}", e))
})?;
data.get("username")
.and_then(|u| u.as_str())
.map(|s| s.to_string())
.ok_or_else(|| {
SendError::ExternalService("Discord getMe response missing username".to_string())
})
}
async fn open_dm_channel(&self, user_id: &str) -> Result<String, SendError> {
let url = format!("{}/users/@me/channels", DISCORD_API_BASE);
let payload = json!({ "recipient_id": user_id });
let response = self
.http_client
.post(&url)
.header("Authorization", self.auth_header())
.json(&payload)
.send()
.await
.map_err(|e| {
SendError::ExternalService(format!("Discord DM channel request failed: {}", e))
})?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(SendError::ExternalService(format!(
"Discord DM channel creation returned {}: {}",
status, body
)));
}
let data: serde_json::Value = response.json().await.map_err(|e| {
SendError::ExternalService(format!(
"Failed to parse Discord DM channel response: {}",
e
))
})?;
data.get("id")
.and_then(|id| id.as_str())
.map(|s| s.to_string())
.ok_or_else(|| {
SendError::ExternalService("Discord DM channel response missing id".to_string())
})
}
}
@@ -180,17 +386,19 @@ impl CommsSender for DiscordSender {
}
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> {
let channel_id = self.open_dm_channel(&notification.recipient).await?;
let subject = notification.subject.as_deref().unwrap_or("Notification");
let content = format!("**{}**\n\n{}", subject, notification.body);
let payload = json!({
"content": content,
"username": "Tranquil PDS"
});
let payload = json!({ "content": content });
let url = format!("{}/channels/{}/messages", DISCORD_API_BASE, channel_id);
let mut last_error = None;
for attempt in 0..MAX_RETRIES {
let result = self
.http_client
.post(&self.webhook_url)
.post(&url)
.header("Authorization", self.auth_header())
.json(&payload)
.send()
.await;
@@ -201,13 +409,13 @@ impl CommsSender for DiscordSender {
}
let status = response.status();
if is_retryable_status(status) && attempt < MAX_RETRIES - 1 {
last_error = Some(format!("Discord webhook returned {}", status));
last_error = Some(format!("Discord API returned {}", status));
retry_delay(attempt).await;
continue;
}
let body = response.text().await.unwrap_or_default();
return Err(SendError::ExternalService(format!(
"Discord webhook returned {}: {}",
"Discord API returned {}: {}",
status, body
)));
}
@@ -386,6 +594,19 @@ impl SignalSender {
}
}
const SIGNAL_TIMEOUT_SECS: u64 = 30;
fn is_retryable_signal_error(stderr: &str) -> bool {
let lower = stderr.to_lowercase();
lower.contains("timeout")
|| lower.contains("timed out")
|| lower.contains("connection refused")
|| lower.contains("network")
|| lower.contains("temporarily")
|| lower.contains("try again")
|| lower.contains("rate limit")
}
#[async_trait]
impl CommsSender for SignalSender {
fn channel(&self) -> CommsChannel {
@@ -394,30 +615,63 @@ impl CommsSender for SignalSender {
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> {
let recipient = &notification.recipient;
if !is_valid_phone_number(recipient) {
if !is_valid_signal_username(recipient) {
return Err(SendError::InvalidRecipient(format!(
"Invalid phone number format: {}",
"Invalid Signal username format: {}",
recipient
)));
}
let subject = notification.subject.as_deref().unwrap_or("Notification");
let message = format!("{}\n\n{}", subject, notification.body);
let output = Command::new(&self.signal_cli_path)
.arg("-u")
.arg(&self.sender_number)
.arg("send")
.arg("-m")
.arg(&message)
.arg(recipient)
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(SendError::ExternalService(format!(
"signal-cli failed: {}",
stderr
)));
let mut last_error = None;
for attempt in 0..MAX_RETRIES {
let cmd_future = Command::new(&self.signal_cli_path)
.arg("-u")
.arg(&self.sender_number)
.arg("send")
.arg("--username")
.arg(recipient)
.arg("-m")
.arg(&message)
.output();
let result = timeout(Duration::from_secs(SIGNAL_TIMEOUT_SECS), cmd_future).await;
match result {
Ok(Ok(output)) if output.status.success() => return Ok(()),
Ok(Ok(output)) => {
let stderr = String::from_utf8_lossy(&output.stderr);
if is_retryable_signal_error(&stderr) && attempt < MAX_RETRIES - 1 {
last_error = Some(format!("signal-cli failed: {}", stderr));
retry_delay(attempt).await;
continue;
}
return Err(SendError::ExternalService(format!(
"signal-cli failed: {}",
stderr
)));
}
Ok(Err(e)) => {
if attempt < MAX_RETRIES - 1 {
last_error = Some(format!("signal-cli spawn failed: {}", e));
retry_delay(attempt).await;
continue;
}
return Err(SendError::ProcessSpawn(e));
}
Err(_) => {
if attempt < MAX_RETRIES - 1 {
last_error = Some("signal-cli timed out".to_string());
retry_delay(attempt).await;
continue;
}
return Err(SendError::Timeout);
}
}
}
Ok(())
Err(SendError::MaxRetriesExceeded(
last_error.unwrap_or_else(|| "Unknown error".to_string()),
))
}
}