use std::collections::HashMap;
use std::process::Stdio;
use std::sync::Arc;
use std::time::{Duration, Instant};
use serde_json::{json, Value};
use tokio::io::AsyncBufReadExt;
use zynko::voice::JoinVoiceOptions;
use zynko::{
async_trait, tone, Button, Card, Client, Command, CommandOption, EditOptions, Event,
EventHandler, FileInput, HandlerResult, Intents, Message, MessageData, OptionType, Permissions,
ReplyOptions, SendOptions, VoiceEvent,
};
type R<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
struct Cfg {
raw: Value,
}
impl Cfg {
fn s(&self, k: &str) -> String {
self.raw[k].as_str().unwrap_or_default().to_string()
}
}
struct Pinger;
#[async_trait]
impl EventHandler for Pinger {
async fn message_create(&self, _ctx: Client, msg: Message) -> HandlerResult {
if !msg.mine() && msg.content == "!ping" {
msg.reply("pong").await?;
}
Ok(())
}
}
struct Human {
http: reqwest::Client,
api: String,
auth: String,
}
impl Human {
async fn call(&self, method: reqwest::Method, path: &str, body: Option<Value>) -> R<Value> {
let mut req = self
.http
.request(method, format!("{}{}", self.api, path))
.header("Authorization", &self.auth);
if let Some(b) = body {
req = req.json(&b);
}
let res = req.send().await?;
let status = res.status();
let text = res.text().await?;
if !status.is_success() {
return Err(format!("{path}: {status} {text}").into());
}
Ok(if text.is_empty() {
Value::Null
} else {
serde_json::from_str(&text)?
})
}
async fn messages(&self, channel: &str) -> R<Vec<MessageData>> {
Ok(serde_json::from_value(
self.call(
reqwest::Method::GET,
&format!("/channels/{channel}/messages?limit=30"),
None,
)
.await?,
)?)
}
}
fn check(ok: bool, what: &str, failures: &mut Vec<String>) {
println!("{} {what}", if ok { "PASS" } else { "FAIL" });
if !ok {
failures.push(what.to_string());
}
}
async fn listen(script: &str, cfg_path: &str, secs: u32, user: &str) -> R<Option<(u64, u64)>> {
let mut child = tokio::process::Command::new("node")
.args([script, cfg_path, &secs.to_string()])
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()?;
let mut lines = tokio::io::BufReader::new(child.stdout.take().unwrap()).lines();
let mut found = None;
while let Some(line) = lines.next_line().await? {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() == 5 && parts[1] == "frames" {
println!(
" ouvinte: {}… frames {} rms {}",
&parts[0][..6.min(parts[0].len())],
parts[2],
parts[4]
);
if parts[0] == user {
found = Some((parts[2].parse()?, parts[4].parse()?));
}
} else if line.starts_with("no audio") {
println!(" ouvinte: nenhum áudio");
}
}
child.wait().await?;
Ok(found)
}
#[tokio::main]
async fn main() -> R<()> {
let args: Vec<String> = std::env::args().collect();
let cfg_path = args
.get(1)
.ok_or("uso: live_test <sdk-rs.json> [listen.mjs] [musica.mp3]")?
.clone();
let listen_js = args.get(2).cloned();
let mp3 = args.get(3).cloned();
let cfg = Cfg {
raw: serde_json::from_str(&std::fs::read_to_string(&cfg_path)?)?,
};
let (api, bot_id, guild, text, voice_ch) = (
cfg.s("api"),
cfg.s("bot_id"),
cfg.s("guild_id"),
cfg.s("text_channel_id"),
cfg.s("voice_channel_id"),
);
let human = Human {
http: reqwest::Client::new(),
api: api.trim_end_matches('/').to_string(),
auth: cfg.s("human_token"),
};
let mut failures = Vec::new();
let client = Client::builder(cfg.s("bot_token"))
.api(&api)
.intents(Intents::GUILDS | Intents::MESSAGES | Intents::VOICE_STATES)
.event_handler(Pinger)
.build()?;
let me = client.login().await?;
check(
me.id == bot_id,
"login: READY trouxe a conta do bot",
&mut failures,
);
let (has_guild, has_text, has_voice) = {
let c = client.cache();
let g = c.guilds.get(&guild);
(
g.is_some(),
g.is_some_and(|g| g.channels.contains_key(&text)),
c.channels.contains_key(&voice_ch),
)
};
check(
has_guild && has_text && has_voice,
"caches: servidor, canal de texto e sala de voz",
&mut failures,
);
check(
client.can(&text, &bot_id, Permissions::SEND_MESSAGES),
"permissões: o bot pode mandar mensagens",
&mut failures,
);
let sent = human
.call(
reqwest::Method::POST,
&format!("/channels/{text}/messages"),
Some(json!({ "content": "!ping" })),
)
.await?;
let ping_id = sent["id"].as_str().unwrap_or_default().to_string();
let mut pong = false;
for _ in 0..20 {
tokio::time::sleep(Duration::from_millis(250)).await;
let msgs = human.messages(&text).await?;
if msgs.iter().any(|m| {
m.author.id == bot_id
&& m.content == "pong"
&& m.reply_to.as_ref().is_some_and(|r| r.id == ping_id)
}) {
pong = true;
break;
}
}
check(
pong,
"!ping: o bot respondeu pong (visto pelo humano)",
&mut failures,
);
let m = client
.send(
&text,
SendOptions::new("um arquivo").file(
FileInput::new("teste.txt", b"ola zynko".to_vec()).content_type("text/plain"),
),
)
.await?;
check(
m.attachments.len() == 1
&& m.attachments[0].filename == "teste.txt"
&& m.attachments[0].size == 9,
"envio com arquivo",
&mut failures,
);
m.react("👍").await?;
let edited = m.edit("um arquivo (editado)").await?;
let seen = human.messages(&text).await?;
let mine = seen.iter().find(|x| x.id == m.id);
check(
edited.content == "um arquivo (editado)"
&& mine.is_some_and(|x| {
x.content == "um arquivo (editado)"
&& x.reactions.iter().any(|r| r.emoji == "👍" && r.count == 1)
}),
"reação e edição (visto pelo humano)",
&mut failures,
);
m.unreact("👍").await?;
m.delete().await?;
let seen = human.messages(&text).await?;
check(!seen.iter().any(|x| x.id == m.id), "delete", &mut failures);
let commands = client
.set_commands(&[
Command::new("eco", "Repete o texto só para você").option(
CommandOption::new("texto", "O que repetir", OptionType::String).required(),
),
Command::new("dado", "Rola um dado").option(
CommandOption::new("lados", "Quantos lados", OptionType::Integer)
.min(2.0)
.max(100.0),
),
])
.await?;
check(commands.len() == 2, "setCommands", &mut failures);
let c2 = client.clone();
let waiter = tokio::spawn(async move {
c2.wait_for(
Duration::from_secs(10),
|e| matches!(e, Event::Interaction(i) if i.command.as_deref() == Some("eco")),
)
.await
});
tokio::time::sleep(Duration::from_millis(100)).await;
human
.call(
reqwest::Method::POST,
&format!("/channels/{text}/interactions"),
Some(json!({ "type": "command", "bot_id": bot_id, "name": "eco", "options": [{ "name": "texto", "value": "olá" }] })),
)
.await?;
match waiter.await? {
Ok(Event::Interaction(i)) => {
check(
i.option_str("texto") == Some("olá"),
"interação: opção lida",
&mut failures,
);
let m = i
.reply(
ReplyOptions::new(format!("eco: {}", i.option_str("texto").unwrap_or("")))
.ephemeral(),
)
.await?;
check(
m.ephemeral && i.replied(),
"resposta efêmera",
&mut failures,
);
}
other => check(
false,
&format!("interação /eco não chegou: {other:?}"),
&mut failures,
),
}
let c2 = client.clone();
let waiter = tokio::spawn(async move {
c2.wait_for(
Duration::from_secs(10),
|e| matches!(e, Event::Interaction(i) if i.command.as_deref() == Some("dado")),
)
.await
});
tokio::time::sleep(Duration::from_millis(100)).await;
let used = human
.call(
reqwest::Method::POST,
&format!("/channels/{text}/interactions"),
Some(json!({ "type": "command", "bot_id": bot_id, "name": "dado", "options": [{ "name": "lados", "value": 20 }] })),
)
.await?;
let mut card_msg = None;
if let Ok(Event::Interaction(i)) = waiter.await? {
check(
i.option_i64("lados") == Some(20),
"interação: opção inteira",
&mut failures,
);
let m = i
.reply(
ReplyOptions::default()
.card(
Card::new()
.title("🎲 7")
.description("Rolou um d20")
.color(0xa594f9)
.field("Lados", "20", true),
)
.row(vec![Button::primary("Rolar de novo", "dado:20").emoji("🎲")]),
)
.await?;
card_msg = Some(m.id.clone());
} else {
check(false, "interação /dado não chegou", &mut failures);
}
if let Some(mid) = card_msg {
let msgs = human.messages(&text).await?;
let found = msgs.iter().find(|m| m.id == mid);
check(
found.is_some_and(|m| {
m.cards.first().and_then(|c| c.title.as_deref()) == Some("🎲 7")
&& m.components
.first()
.and_then(|r| r.first())
.and_then(|b| b.custom_id.as_deref())
== Some("dado:20")
&& m.interaction
.as_ref()
.is_some_and(|r| Some(r.id.as_str()) == used["id"].as_str())
}),
"cartão com botão no canal",
&mut failures,
);
let c2 = client.clone();
let waiter = tokio::spawn(async move {
c2.wait_for(
Duration::from_secs(10),
|e| matches!(e, Event::Interaction(i) if i.is_button()),
)
.await
});
tokio::time::sleep(Duration::from_millis(100)).await;
human
.call(
reqwest::Method::POST,
&format!("/channels/{text}/interactions"),
Some(json!({ "type": "button", "message_id": mid, "custom_id": "dado:20" })),
)
.await?;
if let Ok(Event::Interaction(i)) = waiter.await? {
check(
i.custom_id.as_deref() == Some("dado:20")
&& i.message.as_ref().is_some_and(|m| m.id == mid),
"clique: custom_id e mensagem",
&mut failures,
);
i.update(
EditOptions::new()
.content("rolado de novo")
.cards(vec![Card::new().title("🎲 13")])
.components(vec![]),
)
.await?;
let msgs = human.messages(&text).await?;
let m = msgs.iter().find(|m| m.id == mid);
check(
m.is_some_and(|m| {
m.content == "rolado de novo"
&& m.cards.first().and_then(|c| c.title.as_deref()) == Some("🎲 13")
&& m.components.is_empty()
}),
"update: a mensagem mudou (visto pelo humano)",
&mut failures,
);
} else {
check(false, "clique não chegou", &mut failures);
}
}
let t0 = Instant::now();
let voice = client
.join_voice(&voice_ch, JoinVoiceOptions::default())
.await?;
println!(
"voz: conectado em {:?} (servidor {:?})",
t0.elapsed(),
voice.server()
);
let player = voice.player().clone();
let mut pevents = player.events();
tokio::spawn(async move {
while let Ok(ev) = pevents.recv().await {
println!(" tocador: {ev:?}");
}
});
player.enqueue(tone(440.0, 10.0), json!("tom"));
if let Some(mp3) = &mp3 {
player.enqueue(mp3.as_str(), json!("musica-longa"));
}
if let Some(js) = &listen_js {
let r = listen(js, &cfg_path, 14, &bot_id).await?;
println!("fase A (tom + música): {r:?}");
check(
r.is_some_and(|(f, rms)| f > 900 && rms > 500),
"voz: o ouvinte recebeu o bot (~100 quadros/s, RMS > 0)",
&mut failures,
);
player.pause();
let r = listen(js, &cfg_path, 5, &bot_id).await?;
println!("fase B (pausado): {r:?}");
check(
r.is_none_or(|(_, rms)| rms < 50),
"pause: silêncio",
&mut failures,
);
player.resume();
player.set_volume(0.25);
player.enqueue(tone(440.0, 30.0), json!("tom baixo"));
player.skip();
tokio::time::sleep(Duration::from_millis(300)).await;
let current = player.current().map(|t| t.meta);
check(
current == Some(json!("tom baixo")),
"skip: foi para o próximo",
&mut failures,
);
let r = listen(js, &cfg_path, 6, &bot_id).await?;
println!("fase C (volume 0.25, tom 0.2 → RMS esperado ~1160): {r:?}");
check(
r.is_some_and(|(f, rms)| f > 200 && (700..1600).contains(&rms)),
"volume: RMS cai com volume 0.25",
&mut failures,
);
player.set_volume(1.0);
} else {
tokio::time::sleep(Duration::from_secs(12)).await;
}
player.stop();
player.enqueue(tone(330.0, 30.0), json!("tom para receber"));
let listener = Client::builder(cfg.s("listener_token"))
.api(&api)
.intents(Intents::GUILDS | Intents::VOICE_STATES)
.build()?;
listener.login().await?;
let lv = listener
.join_voice(
&voice_ch,
JoinVoiceOptions {
receive: true,
..Default::default()
},
)
.await?;
let mut ev = lv.events();
let mut stats: HashMap<String, (u64, f64, u64)> = HashMap::new();
let mut speaking_bot = false;
let end = Instant::now() + Duration::from_secs(8);
while let Ok(Ok(e)) = tokio::time::timeout_at(end.into(), ev.recv()).await {
match e {
VoiceEvent::Audio {
user_id, samples, ..
} => {
let st = stats.entry(user_id).or_default();
st.0 += 1;
st.1 += samples
.iter()
.map(|&s| (s as f64) * (s as f64))
.sum::<f64>();
st.2 += samples.len() as u64;
}
VoiceEvent::Speaking(ids) => speaking_bot |= ids.contains(&bot_id),
_ => {}
}
}
for (uid, (frames, sq, n)) in &stats {
println!(
" recebido: {}… frames {frames} rms {}",
&uid[..6.min(uid.len())],
(sq / (*n).max(1) as f64).sqrt().round()
);
}
let mine = stats
.get(&bot_id)
.map(|(f, sq, n)| (*f, (sq / (*n).max(1) as f64).sqrt()));
println!(
"fase D (recebendo com a biblioteca Rust): {mine:?}, speaking com o bot: {speaking_bot}"
);
check(
mine.is_some_and(|(f, rms)| f > 400 && rms > 500.0),
"receive: a biblioteca Rust ouviu o bot",
&mut failures,
);
check(
speaking_bot,
"speaking: o bot apareceu falando",
&mut failures,
);
lv.leave().await;
listener.destroy().await;
voice.leave().await;
check(client.voice().is_none(), "leave", &mut failures);
client.destroy().await;
let _ = Arc::new(());
println!();
if failures.is_empty() {
println!("TUDO CERTO");
Ok(())
} else {
Err(format!("falhou: {}", failures.join("; ")).into())
}
}