use std::panic::AssertUnwindSafe;
use std::sync::{Arc, Mutex, RwLock, RwLockReadGuard};
use std::time::Duration;
use futures_util::FutureExt;
use serde::de::DeserializeOwned;
use serde_json::Value;
use tokio::sync::{broadcast, mpsc, oneshot, watch};
use crate::bits::Intents;
use crate::cache::{Cache, CachedGuild};
use crate::error::{Error, Result};
use crate::events::{self, Event, EventHandler, ReconnectInfo};
use crate::gateway::{Gateway, GatewayEvent};
use crate::interaction::Interaction;
use crate::message::Message;
use crate::permissions::compute_permissions;
use crate::rest::{Rest, SendOptions};
use crate::types::*;
pub struct ClientBuilder {
token: String,
intents: u64,
api: Option<String>,
gateway: Option<String>,
retries: u32,
handler: Option<Arc<dyn EventHandler>>,
}
impl ClientBuilder {
pub fn intents(mut self, intents: u64) -> Self {
self.intents = intents;
self
}
pub fn api(mut self, api: impl Into<String>) -> Self {
self.api = Some(api.into());
self
}
pub fn gateway(mut self, url: impl Into<String>) -> Self {
self.gateway = Some(url.into());
self
}
pub fn retries(mut self, retries: u32) -> Self {
self.retries = retries;
self
}
pub fn event_handler(mut self, handler: impl EventHandler) -> Self {
self.handler = Some(Arc::new(handler));
self
}
pub fn build(self) -> Result<Client> {
if self.token.trim().is_empty() {
return Err(Error::Other(
"Falta o token do bot (crie um em dev.zynko.org)".into(),
));
}
let rest = Rest::new(&self.token, self.api.as_deref(), self.retries);
let url = self.gateway.unwrap_or_else(|| {
let api = rest.api();
let ws = if let Some(rest) = api.strip_prefix("https") {
format!("wss{rest}")
} else if let Some(rest) = api.strip_prefix("http") {
format!("ws{rest}")
} else {
api.to_string()
};
format!("{ws}/gateway")
});
let (events, _) = broadcast::channel(1024);
let (status, _) = watch::channel(Status::Idle);
Ok(Client {
inner: Arc::new(Inner {
gateway: Gateway::new(url, self.token, self.intents),
rest,
intents: self.intents,
cache: RwLock::new(Cache::default()),
handler: self.handler,
events,
status,
first_ready: Mutex::new(None),
#[cfg(feature = "voice")]
voice: Mutex::new(None),
}),
})
}
}
#[derive(Debug, Clone, PartialEq)]
enum Status {
Idle,
Running,
Closed(Option<String>),
}
struct Inner {
rest: Rest,
intents: u64,
gateway: Arc<Gateway>,
cache: RwLock<Cache>,
handler: Option<Arc<dyn EventHandler>>,
events: broadcast::Sender<Event>,
status: watch::Sender<Status>,
first_ready: Mutex<Option<oneshot::Sender<Result<User>>>>,
#[cfg(feature = "voice")]
voice: Mutex<Option<crate::voice::VoiceConnection>>,
}
#[derive(Clone)]
pub struct Client {
inner: Arc<Inner>,
}
impl std::fmt::Debug for Client {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Client")
.field("api", &self.inner.rest.api())
.field("intents", &self.inner.intents)
.finish_non_exhaustive()
}
}
fn parse<T: DeserializeOwned>(t: &str, d: &Value) -> Result<T> {
serde_json::from_value(d.clone()).map_err(|e| Error::Other(format!("{t} inesperado: {e}")))
}
impl Client {
pub fn builder(token: impl Into<String>) -> ClientBuilder {
ClientBuilder {
token: token.into(),
intents: Intents::GUILDS | Intents::MESSAGES,
api: None,
gateway: None,
retries: 3,
handler: None,
}
}
pub fn rest(&self) -> &Rest {
&self.inner.rest
}
pub fn intents(&self) -> u64 {
self.inner.intents
}
pub fn cache(&self) -> RwLockReadGuard<'_, Cache> {
self.inner.cache.read().unwrap_or_else(|e| e.into_inner())
}
fn cache_mut(&self) -> std::sync::RwLockWriteGuard<'_, Cache> {
self.inner.cache.write().unwrap_or_else(|e| e.into_inner())
}
pub fn user(&self) -> Option<User> {
self.cache().user.clone()
}
pub fn guild(&self, id: &str) -> Option<CachedGuild> {
self.cache().guilds.get(id).cloned()
}
pub fn channel(&self, id: &str) -> Option<Channel> {
self.cache().channels.get(id).cloned()
}
pub async fn login(&self) -> Result<User> {
if *self.inner.status.borrow() == Status::Running {
return self
.user()
.ok_or_else(|| Error::Other("já conectando".into()));
}
let (tx, rx) = oneshot::channel();
*self
.inner
.first_ready
.lock()
.unwrap_or_else(|e| e.into_inner()) = Some(tx);
self.inner.status.send_replace(Status::Running);
let (gtx, mut grx) = mpsc::unbounded_channel();
tokio::spawn(self.inner.gateway.clone().run(gtx));
let client = self.clone();
tokio::spawn(async move {
let mut fatal = None;
while let Some(ev) = grx.recv().await {
match ev {
GatewayEvent::Dispatch(t, d) => client.dispatch(&t, d),
GatewayEvent::Reconnecting { code, reason, wait } => {
client.emit(Event::Reconnecting(ReconnectInfo { code, reason, wait }))
}
GatewayEvent::Fatal(msg) => fatal = Some(msg),
}
}
let pending = client
.inner
.first_ready
.lock()
.unwrap_or_else(|e| e.into_inner())
.take();
let msg = fatal.clone().unwrap_or_else(|| "Gateway fechado".into());
match pending {
Some(tx) => {
let _ = tx.send(Err(Error::Gateway(msg)));
}
None => {
if fatal.is_some() {
client.emit(Event::Error(Arc::new(Error::Gateway(msg))));
}
}
}
client.inner.status.send_replace(Status::Closed(fatal));
});
rx.await
.unwrap_or_else(|_| Err(Error::Gateway("Gateway fechado".into())))
}
pub async fn closed(&self) -> Result<()> {
let mut rx = self.inner.status.subscribe();
let status = rx
.wait_for(|s| matches!(s, Status::Closed(_)))
.await
.map(|s| s.clone());
match status {
Ok(Status::Closed(Some(msg))) => Err(Error::Gateway(msg)),
_ => Ok(()),
}
}
pub async fn start(&self) -> Result<()> {
self.login().await?;
self.closed().await
}
pub async fn destroy(&self) {
#[cfg(feature = "voice")]
{
let voice = self.voice();
if let Some(v) = voice {
v.leave().await;
}
}
self.inner.gateway.close();
}
pub fn send_op(&self, op: u8, d: Value) -> bool {
self.inner.gateway.send(op, d)
}
pub fn subscribe(&self) -> broadcast::Receiver<Event> {
self.inner.events.subscribe()
}
pub async fn wait_for(
&self,
timeout: Duration,
filter: impl Fn(&Event) -> bool,
) -> Result<Event> {
let mut rx = self.subscribe();
let wait = async {
loop {
match rx.recv().await {
Ok(ev) if filter(&ev) => return Ok(ev),
Ok(_) | Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => {
return Err(Error::Gateway("cliente fechado".into()))
}
}
}
};
tokio::time::timeout(timeout, wait)
.await
.unwrap_or_else(|_| Err(Error::Timeout("esperando um evento".into())))
}
pub async fn wait_for_message(
&self,
timeout: Duration,
filter: impl Fn(&Message) -> bool,
) -> Result<Message> {
match self
.wait_for(
timeout,
|e| matches!(e, Event::MessageCreate(m) if filter(m)),
)
.await?
{
Event::MessageCreate(m) => Ok(m),
_ => unreachable!(),
}
}
fn emit(&self, event: Event) {
if self.inner.events.receiver_count() > 0 {
let _ = self.inner.events.send(event.clone());
}
let Some(handler) = self.inner.handler.clone() else {
return;
};
let client = self.clone();
tokio::spawn(async move {
let is_error = matches!(event, Event::Error(_));
let run = AssertUnwindSafe(events::call(handler.as_ref(), client.clone(), event))
.catch_unwind();
let err = match run.await {
Ok(Ok(())) => return,
Ok(Err(e)) => Error::Handler(e),
Err(panic) => {
let what = panic
.downcast_ref::<&str>()
.map(|s| s.to_string())
.or_else(|| panic.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "panic".into());
Error::Handler(format!("panic: {what}").into())
}
};
if !is_error {
client.emit(Event::Error(Arc::new(err)));
}
});
}
pub async fn send(&self, channel_id: &str, message: impl Into<SendOptions>) -> Result<Message> {
let d = self.inner.rest.send(channel_id, message).await?;
Ok(Message::new(self.clone(), d))
}
pub async fn send_dm(&self, user_id: &str, message: impl Into<SendOptions>) -> Result<Message> {
let dm = self.inner.rest.dm(user_id).await?;
let id = dm.id.clone();
self.cache_mut().channels.insert(id.clone(), dm);
self.send(&id, message).await
}
pub async fn set_commands(&self, commands: &[Command]) -> Result<Vec<Command>> {
self.inner.rest.set_commands(commands).await
}
pub fn permissions_in(&self, channel_id: &str, user_id: &str) -> u64 {
let cache = self.cache();
let Some(channel) = cache.channels.get(channel_id) else {
return 0;
};
let Some(guild) = channel.guild_id.as_ref().and_then(|g| cache.guilds.get(g)) else {
return 0;
};
compute_permissions(guild, channel, user_id)
}
pub fn can(&self, channel_id: &str, user_id: &str, permission: u64) -> bool {
self.permissions_in(channel_id, user_id) & permission == permission
}
pub fn link(&self, path: Option<&str>) -> Option<String> {
let path = path.filter(|p| !p.is_empty())?;
if path.starts_with("http:") || path.starts_with("https:") {
Some(path.to_string())
} else {
Some(format!("{}{}", self.inner.rest.base(), path))
}
}
#[cfg(feature = "voice")]
pub fn voice(&self) -> Option<crate::voice::VoiceConnection> {
self.inner
.voice
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
}
#[cfg(feature = "voice")]
pub(crate) fn set_voice(&self, v: Option<crate::voice::VoiceConnection>) {
*self.inner.voice.lock().unwrap_or_else(|e| e.into_inner()) = v;
}
#[cfg(feature = "voice")]
pub(crate) fn clear_voice(&self, conn: &crate::voice::VoiceConnection) {
let mut slot = self.inner.voice.lock().unwrap_or_else(|e| e.into_inner());
if slot.as_ref().is_some_and(|v| v.same(conn)) {
*slot = None;
}
}
#[cfg(feature = "voice")]
pub async fn join_voice(
&self,
channel_id: &str,
opts: crate::voice::JoinVoiceOptions,
) -> Result<crate::voice::VoiceConnection> {
if let Some(v) = self.voice() {
if v.channel_id() == channel_id && !v.is_closed() {
return Ok(v);
}
v.leave().await;
}
let conn = crate::voice::VoiceConnection::new(self.clone(), channel_id.to_string(), opts);
self.set_voice(Some(conn.clone()));
let connecting = conn.clone();
let result = tokio::spawn(async move { Box::pin(connecting.connect()).await })
.await
.unwrap_or_else(|e| Err(Error::Voice(format!("a conexão de voz falhou: {e}"))));
if let Err(e) = result {
self.clear_voice(&conn);
conn.leave().await;
return Err(e);
}
Ok(conn)
}
fn dispatch(&self, t: &str, d: Value) {
self.emit(Event::Raw {
kind: t.to_string(),
data: d.clone(),
});
if let Err(e) = self.apply(t, d) {
self.emit(Event::Error(Arc::new(e)));
}
}
fn apply(&self, t: &str, d: Value) -> Result<()> {
match t {
"READY" => {
let r: Ready = parse(t, &d)?;
let guilds: Vec<CachedGuild> = {
let mut c = self.cache_mut();
c.user = Some(r.user.clone());
c.session_id = r.session_id.clone();
c.guilds.clear();
c.channels.clear();
c.voice_states.clear();
let guilds = r.guilds.into_iter().map(|g| c.add_guild(g)).collect();
for ch in r.dm_channels {
for u in ch.recipients.iter().flatten() {
c.users.insert(u.id.clone(), u.clone());
}
c.channels.insert(ch.id.clone(), ch);
}
for v in r.voice_states {
if v.channel_id.is_some() {
c.voice_states.insert(v.user_id.clone(), v);
}
}
guilds
};
if let Some(tx) = self
.inner
.first_ready
.lock()
.unwrap_or_else(|e| e.into_inner())
.take()
{
let _ = tx.send(Ok(r.user.clone()));
}
#[cfg(feature = "voice")]
if let Some(v) = self.voice() {
v.resync();
}
self.emit(Event::Ready(r.user));
for g in guilds {
self.emit(Event::GuildCreate(g));
}
}
"GUILD_CREATE" => {
let g: GuildFull = parse(t, &d)?;
let cached = self.cache_mut().add_guild(g);
self.emit(Event::GuildCreate(cached));
}
"GUILD_UPDATE" => {
let u: Guild = parse(t, &d)?;
let g = {
let mut c = self.cache_mut();
let Some(g) = c.guilds.get_mut(&u.id) else {
return Ok(());
};
g.name = u.name;
g.icon = u.icon;
g.owner_id = u.owner_id;
g.clone()
};
self.emit(Event::GuildUpdate(g));
}
"GUILD_DELETE" => {
let id = d["id"].as_str().unwrap_or_default().to_string();
self.cache_mut().drop_guild(&id);
self.emit(Event::GuildDelete(id));
}
"GUILD_ROLE_CREATE" | "GUILD_ROLE_UPDATE" => {
let r: Role = parse(t, &d)?;
if let Some(g) = self.cache_mut().guilds.get_mut(&r.guild_id) {
g.roles.insert(r.id.clone(), r);
}
}
"GUILD_ROLE_DELETE" => {
let gid = d["guild_id"].as_str().unwrap_or_default();
let rid = d["role_id"]
.as_str()
.or(d["id"].as_str())
.unwrap_or_default();
if let Some(g) = self.cache_mut().guilds.get_mut(gid) {
g.roles.remove(rid);
}
}
"GUILD_EMOJIS_UPDATE" => {
let gid = d["guild_id"].as_str().unwrap_or_default().to_string();
let emojis: Vec<Emoji> = parse(t, &d["emojis"])?;
if let Some(g) = self.cache_mut().guilds.get_mut(&gid) {
g.emojis = emojis;
}
}
"CHANNEL_CREATE" | "CHANNEL_UPDATE" => {
let mut ch: Channel = parse(t, &d)?;
let previous = {
let mut c = self.cache_mut();
let previous = c.channels.get(&ch.id).cloned();
if ch.recipients.is_none() {
if let Some(r) = previous.as_ref().and_then(|p| p.recipients.clone()) {
ch.recipients = Some(r);
}
}
c.channels.insert(ch.id.clone(), ch.clone());
if let Some(gid) = &ch.guild_id {
if let Some(g) = c.guilds.get_mut(gid) {
g.channels.insert(ch.id.clone(), ch.clone());
}
}
for u in ch.recipients.iter().flatten() {
c.users.insert(u.id.clone(), u.clone());
}
previous
};
if t == "CHANNEL_CREATE" {
self.emit(Event::ChannelCreate(ch));
} else {
#[cfg(feature = "voice")]
if let Some(v) = self.voice() {
v.on_channel_update(&ch);
}
self.emit(Event::ChannelUpdate {
channel: ch,
previous,
});
}
}
"CHANNEL_DELETE" => {
let ch: Channel = parse(t, &d)?;
{
let mut c = self.cache_mut();
c.channels.remove(&ch.id);
if let Some(g) = ch.guild_id.as_ref().and_then(|g| c.guilds.get_mut(g)) {
g.channels.remove(&ch.id);
}
}
self.emit(Event::ChannelDelete(ch));
}
"GUILD_MEMBER_ADD" | "GUILD_MEMBER_UPDATE" => {
let m: Member = parse(t, &d)?;
let previous = {
let mut c = self.cache_mut();
let previous = c
.guilds
.get_mut(&m.guild_id)
.and_then(|g| g.members.insert(m.user.id.clone(), m.clone()));
c.users.insert(m.user.id.clone(), m.user.clone());
previous
};
if t == "GUILD_MEMBER_ADD" {
self.emit(Event::MemberAdd(m));
} else {
self.emit(Event::MemberUpdate {
member: m,
previous,
});
}
}
"GUILD_MEMBER_REMOVE" => {
let e: MemberRemoveEvent = parse(t, &d)?;
if let Some(g) = self.cache_mut().guilds.get_mut(&e.guild_id) {
g.members.remove(&e.user_id);
}
self.emit(Event::MemberRemove(e));
}
"PRESENCE_UPDATE" => {
let p: PresenceEvent = parse(t, &d)?;
for g in self.cache_mut().guilds.values_mut() {
if !g.members.contains_key(&p.user_id) {
continue;
}
if p.status == "online" {
g.online.insert(p.user_id.clone());
} else {
g.online.remove(&p.user_id);
}
}
self.emit(Event::PresenceUpdate(p));
}
"USER_UPDATE" => {
let u: User = parse(t, &d)?;
let mut c = self.cache_mut();
if c.user.as_ref().is_some_and(|me| me.id == u.id) {
c.user = Some(u.clone());
}
c.users.insert(u.id.clone(), u);
}
"MESSAGE_CREATE" => {
let m: MessageData = parse(t, &d)?;
{
let mut c = self.cache_mut();
if let Some(ch) = c.channels.get_mut(&m.channel_id) {
ch.last_message_id = Some(m.id.clone());
}
c.users.insert(m.author.id.clone(), m.author.clone());
}
self.emit(Event::MessageCreate(Message::new(self.clone(), m)));
}
"MESSAGE_UPDATE" => {
let m: MessageData = parse(t, &d)?;
self.emit(Event::MessageUpdate(Message::new(self.clone(), m)));
}
"MESSAGE_DELETE" => self.emit(Event::MessageDelete(parse(t, &d)?)),
"MESSAGE_REACTION_ADD" => self.emit(Event::ReactionAdd(parse(t, &d)?)),
"MESSAGE_REACTION_REMOVE" => self.emit(Event::ReactionRemove(parse(t, &d)?)),
"TYPING_START" => self.emit(Event::TypingStart(parse(t, &d)?)),
"INTERACTION_CREATE" => {
let i: InteractionData = parse(t, &d)?;
self.emit(Event::Interaction(Interaction::new(self.clone(), i)));
}
"VOICE_STATE_UPDATE" => {
let v: VoiceState = parse(t, &d)?;
let (previous, mine) = {
let mut c = self.cache_mut();
let previous = if v.channel_id.is_some() {
c.voice_states.insert(v.user_id.clone(), v.clone())
} else {
c.voice_states.remove(&v.user_id)
};
(previous, c.user.as_ref().is_some_and(|u| u.id == v.user_id))
};
#[cfg(feature = "voice")]
if mine {
if let Some(conn) = self.voice() {
conn.on_own_state(&v);
}
}
let _ = mine;
self.emit(Event::VoiceStateUpdate { state: v, previous });
}
_ => {}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bits::Permissions;
use serde_json::json;
fn ready() -> Value {
json!({
"user": { "id": "1", "username": "bot", "display_name": "Bot", "avatar": null, "bot": true },
"session_id": "s1",
"guilds": [{
"id": "10", "name": "Casa", "icon": null, "owner_id": "2",
"channels": [
{ "id": "20", "guild_id": "10", "type": 0, "name": "geral", "topic": null, "position": 0, "parent_id": null, "last_message_id": null,
"permission_overwrites": [{ "id": "10", "type": 0, "allow": 0, "deny": 512 }] },
{ "id": "21", "guild_id": "10", "type": 2, "name": "voz", "topic": null, "position": 1, "parent_id": null, "last_message_id": null }
],
"members": [
{ "guild_id": "10", "user": { "id": "1", "username": "bot", "display_name": "Bot", "avatar": null, "bot": true }, "nick": null, "is_admin": false, "joined_at": 0, "roles": [] },
{ "guild_id": "10", "user": { "id": "3", "username": "ana", "display_name": "Ana", "avatar": null, "bot": false }, "nick": null, "is_admin": false, "joined_at": 0, "roles": [] }
],
"presences": ["3"],
"roles": [{ "id": "10", "guild_id": "10", "name": "@everyone", "color": null, "position": 0, "permissions": 16384 | 512 | 2048, "hoist": false }],
"emojis": [],
"unknown_field": 1
}],
"dm_channels": [{ "id": "30", "guild_id": null, "type": 1, "name": "", "topic": null, "position": 0, "parent_id": null, "last_message_id": null,
"recipients": [{ "id": "4", "username": "bia", "display_name": "Bia", "avatar": null, "bot": false }] }],
"presences": [],
"voice_states": [{ "user_id": "3", "guild_id": "10", "channel_id": "21", "self_mute": false, "self_deaf": false, "self_stream": false, "self_video": false, "session_id": "x" }],
"read_states": []
})
}
#[tokio::test]
async fn events_keep_the_cache() {
let client = Client::builder("x")
.api("http://127.0.0.1:1/api")
.build()
.unwrap();
let mut rx = client.subscribe();
client.dispatch("READY", ready());
{
let c = client.cache();
assert_eq!(c.user.as_ref().unwrap().id, "1");
assert_eq!(c.guilds["10"].channels.len(), 2);
assert_eq!(c.channels.len(), 3);
assert!(c.users.contains_key("4"));
assert!(c.guilds["10"].online.contains("3"));
assert_eq!(c.voice_states["3"].channel_id.as_deref(), Some("21"));
}
assert!(!client.can("20", "3", Permissions::SEND_MESSAGES));
assert!(client.can("21", "3", Permissions::CONNECT));
client.dispatch("GUILD_MEMBER_UPDATE", json!({ "guild_id": "10", "user": { "id": "3", "username": "ana", "display_name": "Ana", "avatar": null, "bot": false }, "nick": "Aninha", "is_admin": true, "joined_at": 0, "roles": [] }));
assert!(client.can("20", "3", Permissions::SEND_MESSAGES));
client.dispatch(
"CHANNEL_UPDATE",
json!({ "id": "30", "guild_id": null, "type": 1, "name": "", "position": 0 }),
);
assert!(
client.channel("30").unwrap().recipients.is_some(),
"a DM keeps its recipients"
);
client.dispatch(
"VOICE_STATE_UPDATE",
json!({ "user_id": "3", "guild_id": "10", "channel_id": null, "session_id": "x" }),
);
assert!(client.cache().voice_states.is_empty());
client.dispatch("MESSAGE_CREATE", json!({ "id": "50", "channel_id": "20", "author": { "id": "3", "username": "ana", "display_name": "Ana", "avatar": null, "bot": false }, "content": "oi", "timestamp": 1 }));
client.dispatch(
"MESSAGE_UPDATE",
json!({ "id": "50", "channel_id": "20", "content": "oi!", "edited_timestamp": 2 }),
);
client.dispatch("GUILD_DELETE", json!({ "id": "10" }));
assert!(client.cache().guilds.is_empty());
assert_eq!(client.cache().channels.len(), 1);
let mut kinds = Vec::new();
while let Ok(ev) = rx.try_recv() {
match ev {
Event::Raw { .. } => {}
Event::MessageCreate(m) => {
assert_eq!(
m.guild_id().as_deref(),
None,
"the server was deleted after it"
);
assert!(!m.mine());
kinds.push("message_create")
}
Event::MessageUpdate(m) => {
assert_eq!((m.content.as_str(), m.edited_timestamp), ("oi!", Some(2)));
kinds.push("message_update")
}
Event::Ready(_) => kinds.push("ready"),
Event::GuildCreate(_) => kinds.push("guild_create"),
Event::MemberUpdate { previous, .. } => {
assert!(previous.is_some_and(|p| p.nick.is_none()));
kinds.push("member_update")
}
Event::ChannelUpdate { .. } => kinds.push("channel_update"),
Event::VoiceStateUpdate { previous, .. } => {
assert!(previous.is_some());
kinds.push("voice_state_update")
}
Event::GuildDelete(id) => {
assert_eq!(id, "10");
kinds.push("guild_delete")
}
other => panic!("unexpected {other:?}"),
}
}
assert_eq!(
kinds,
[
"ready",
"guild_create",
"member_update",
"channel_update",
"voice_state_update",
"message_create",
"message_update",
"guild_delete"
]
);
}
#[test]
fn gateway_url_comes_from_the_api() {
let c = Client::builder("x").build().unwrap();
assert_eq!(c.rest().api(), "https://zynko.org/api");
assert_eq!(
c.link(Some("/api/media/a.png")).as_deref(),
Some("https://zynko.org/api/media/a.png")
);
assert_eq!(
c.link(Some("https://x.y/z")).as_deref(),
Some("https://x.y/z")
);
assert!(Client::builder(" ").build().is_err());
}
}