use std::time::Duration;
use navi_notifier_core::model::{Actor, Event, EventKind, PullRequest, Repo, ViewerRelationship};
use navi_notifier_core::traits::Destination;
use navi_notifier_discord::{DiscordDestination, DiscordDestinationConfig};
use serde_json::{json, Value};
use time::OffsetDateTime;
#[path = "../e2e_common.rs"]
mod e2e_common;
use e2e_common::{env, env_or, json_ok, MemState};
#[tokio::main]
async fn main() {
match run().await {
Ok(()) => println!("e2e-discord: PASSED"),
Err(e) => {
eprintln!("e2e-discord: FAILED: {e}");
std::process::exit(1);
}
}
}
async fn run() -> Result<(), String> {
let token = env("E2E_DISCORD_TOKEN")?;
let dm_to = env("E2E_DISCORD_DM_TO")?;
let api = env_or("E2E_DISCORD_API", "https://discord.com/api/v10");
let http = reqwest::Client::new();
let marker = format!("navi-e2e-discord-{}", std::process::id());
preflight(&http, &api, &token, &dm_to).await?;
let channel = open_dm(&http, &api, &token, &dm_to).await?;
println!("e2e-discord: DM channel {channel}, marker {marker}");
let destination = DiscordDestination::new(DiscordDestinationConfig {
token: Some(token.clone()),
dm_to,
api_base: Some(api.clone()),
})
.map_err(|e| format!("build discord destination: {e}"))?;
destination
.send(&sample_event(&marker), &MemState::default())
.await
.map_err(|e| format!("discord send failed: {e}"))?;
for attempt in 1..=10 {
if history_contains(&http, &api, &token, &channel, &marker).await? {
println!("e2e-discord: read back the delivered message (marker {marker})");
return Ok(());
}
if attempt % 3 == 0 {
println!("e2e-discord: still waiting for read-back (attempt {attempt})…");
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
Err(format!(
"posted, but never read back a message containing {marker} from {channel}"
))
}
async fn preflight(
http: &reqwest::Client,
api: &str,
token: &str,
dm_to: &str,
) -> Result<(), String> {
let me = get(http, &format!("{api}/users/@me"), token).await?;
let bot = me["username"].as_str().unwrap_or("?");
let guilds = get(http, &format!("{api}/users/@me/guilds"), token).await?;
let guilds = guilds.as_array().cloned().unwrap_or_default();
let names: Vec<String> = guilds
.iter()
.filter_map(|g| g["name"].as_str().map(str::to_string))
.collect();
println!(
"e2e-discord: bot={bot} is in {} server(s): [{}]",
guilds.len(),
names.join(", ")
);
if guilds.is_empty() {
return Err("the bot is in no servers — invite it to a server you're in".into());
}
for g in &guilds {
let Some(gid) = g["id"].as_str() else {
continue;
};
if get_opt(http, &format!("{api}/guilds/{gid}/members/{dm_to}"), token)
.await?
.is_some()
{
let gname = g["name"].as_str().unwrap_or(gid);
println!("e2e-discord: recipient shares server '{gname}' with the bot");
return Ok(());
}
}
Err(format!(
"recipient {dm_to} is not a member of any server the bot is in ([{}]); \
invite the bot to a server you're in, and confirm E2E_DISCORD_DM_TO is your user id",
names.join(", ")
))
}
async fn open_dm(
http: &reqwest::Client,
api: &str,
token: &str,
user_id: &str,
) -> Result<String, String> {
let opened = post(
http,
&format!("{api}/users/@me/channels"),
token,
&json!({ "recipient_id": user_id }),
)
.await?;
opened["id"]
.as_str()
.map(str::to_string)
.ok_or_else(|| "users/@me/channels returned no channel id".into())
}
async fn history_contains(
http: &reqwest::Client,
api: &str,
token: &str,
channel: &str,
marker: &str,
) -> Result<bool, String> {
let value = get(
http,
&format!("{api}/channels/{channel}/messages?limit=30"),
token,
)
.await?;
let hit = value
.as_array()
.into_iter()
.flatten()
.any(|m| m.to_string().contains(marker));
Ok(hit)
}
fn sample_event(marker: &str) -> Event {
Event {
source_id: "github".into(),
kind: EventKind::ReviewRequested,
pull_request: PullRequest {
repo: Repo::new("navi", "e2e"),
number: 1,
title: format!("navi e2e discord read-back {marker}"),
url: "https://github.com/navi/e2e/pull/1".into(),
author: Actor::new("navi-e2e"),
draft: false,
},
viewer: ViewerRelationship {
is_author: false,
is_reviewer: true,
actor_is_viewer: false,
},
actor: Actor::new("navi-e2e"),
occurred_at: OffsetDateTime::now_utc(),
target_url: Some("https://github.com/navi/e2e/pull/1".into()),
excerpt: Some(format!("read-back probe {marker}")),
dedup_key: format!("navi:e2e:discord:{marker}"),
}
}
async fn get(http: &reqwest::Client, url: &str, token: &str) -> Result<Value, String> {
let resp = http
.get(url)
.header("Authorization", format!("Bot {token}"))
.send()
.await
.map_err(|e| format!("GET {url}: {e}"))?;
json_ok(resp, &format!("GET {url}")).await
}
async fn get_opt(http: &reqwest::Client, url: &str, token: &str) -> Result<Option<Value>, String> {
let resp = http
.get(url)
.header("Authorization", format!("Bot {token}"))
.send()
.await
.map_err(|e| format!("GET {url}: {e}"))?;
if resp.status().as_u16() == 404 {
return Ok(None);
}
json_ok(resp, &format!("GET {url}")).await.map(Some)
}
async fn post(
http: &reqwest::Client,
url: &str,
token: &str,
body: &Value,
) -> Result<Value, String> {
let resp = http
.post(url)
.header("Authorization", format!("Bot {token}"))
.json(body)
.send()
.await
.map_err(|e| format!("POST {url}: {e}"))?;
json_ok(resp, &format!("POST {url}")).await
}