mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-25 19:54:14 +00:00
fix: improved discord & signal comms
This commit is contained in:
@@ -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};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(¬ification.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 = ¬ification.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()),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user