Overview

twilight logo

Join us on Discord! :)

twilight is a powerful asynchronous, flexible, and scalable ecosystem of Rust libraries for the Discord API.

Check out the crates on crates.io.

Who Twilight is For

Twilight is meant for people who are very familiar with Rust and at least somewhat familiar with Discord bots. It aims to be the library you use when you want - or, maybe for scaling reasons, need - the freedom to structure things how you want and do things that other libraries may not strongly cater to.

If you're a beginner with Rust, then that's cool and we hope you like it! serenity is a great library for getting started and offers an opinionated, batteries-included approach to making bots. You'll probably have a better experience with it and we recommend you check it out.

The Guide

In this guide you'll learn about the core crates in the twilight ecosystem, useful first-party crates for more advanced use cases, and third-party crates giving you a tailored experience.

The organization for the project is on GitHub.

The crates are available on crates.io.

The API docs are also hosted for the latest version.

There is a community and support server on Discord.

A Quick Example

Below is a quick example of a program printing "Pong!" when a ping command comes in from a channel:

use futures::stream::StreamExt;
use std::{env, error::Error, sync::Arc};
use twilight_cache_inmemory::{InMemoryCache, ResourceType};
use twilight_gateway::{cluster::{Cluster, ShardScheme}, Event, Intents};
use twilight_http::Client as HttpClient;

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error + Send + Sync>> {
    let token = env::var("DISCORD_TOKEN")?;

    // This is also the default.
    let scheme = ShardScheme::Auto;

    // Specify intents requesting events about things like new and updated
    // messages in a guild and direct messages.
    let intents = Intents::GUILD_MESSAGES | Intents::DIRECT_MESSAGES;

    let (cluster, mut events) = Cluster::builder(&token, intents)
        .shard_scheme(scheme)
        .build()
        .await?;

    let cluster = Arc::new(cluster);

    // Start up the cluster
    let cluster_spawn = cluster.clone();

    tokio::spawn(async move {
        cluster_spawn.up().await;
    });

    // The http client is seperate from the gateway, so startup a new
    // one, also use Arc such that it can be cloned to other threads.
    let http = Arc::new(HttpClient::new(token));

    // Since we only care about messages, make the cache only process messages.
    let cache = InMemoryCache::builder()
        .resource_types(ResourceType::MESSAGE)
        .build();

    // Startup an event loop to process each event in the event stream as they
    // come in.
    while let Some((shard_id, event)) = events.next().await {
        // Update the cache.
        cache.update(&event);

        // Spawn a new task to handle the event
        tokio::spawn(handle_event(shard_id, event, Arc::clone(&http)));
    }

    Ok(())
}

async fn handle_event(
    shard_id: u64,
    event: Event,
    http: Arc<HttpClient>,
) -> Result<(), Box<dyn Error + Send + Sync>> {
    match event {
        Event::MessageCreate(msg) if msg.content == "!ping" => {
            http.create_message(msg.channel_id).content("Pong!")?.exec().await?;
        }
        Event::ShardConnected(_) => {
            println!("Connected on shard {}", shard_id);
        }
        _ => {}
    }

    Ok(())
}

Support

The guide, and Twilight as a whole, assume familiarity with Rust, Rust's asynchronous features, and bots in general. If you're new to Rust and/or new to bots, consider checking out serenity, which is a beginner-friendly, batteries-included approach to the Discord API.

Support for the library is provided through the GitHub issues section and the Discord server.

If you have a question, then the issues or the server are both good fits for it. If you find a bug, then the issues section is the best place.

The API documentation is also available.

Supported Rust Versions

Twilight currently supports an MSRV of Rust 1.53+.

Breaking Changes

Although Twilight aims to design APIs right the first time, that obviously won't happen. A lot of effort is spent designing clear and correct interfaces.

While Twilight takes care to avoid the need for breaking changes, it will be fearless when it needs to do so: they won't be avoided for the sake of avoiding a change. Breaking changes won't be piled up over time to make a single big release: major versions will be often and painless.

Crates

Twilight is, at heart, an ecosystem. These components of the ecosystem don't depend on each other in unnecessary ways, allowing you to pick and choose and combine the crates that you need for your use case. The crates for Twilight are categorised into three groups: the core crates, first-party crates, and third-party crates.

Core Crates

Twilight includes a few crates which are the "building blocks" to most peoples' use cases. You might not need them all, but generally speaking you'll need most of them. Most of them wrap Discord's various APIs.

  • model: All of the structs, enums, and bitflags used by the Discord APIs.
  • http: HTTP client supporting all of the documented features of Discord's HTTP API, with support for ratelimiting, proxying, and more.
  • gateway: Clients supporting Discord's gateway API.
  • cache: Definitions for implementating a cache. An in-process memory implementation is included.
  • command-parser: Basic command parser for parsing commands and arguments out of messages.
  • standby: Utility for asynchronously waiting for certain events, like a new message in a channel or a new reaction to a message.

First-Party Crates

There are some first-party crates maintained by the Twilight organization, but not included in the core experience. These might be for more advanced or specific use cases or clients for third-party services. An example of a first-party crate is twilight-lavalink, a Client for interacting with Lavalink.

Third-Party Crates

Third-party crates are crates that aren't officially supported by the Twilight organization, but are recognised by it. An example is rarity-rs/permission-calculator, which has interfaces for things like calculating the permissions for a member in a channel.

Model

twilight-model is a crate of models for use with serde defining the Discord APIs with limited implementations on top of them.

These are in a single crate for ease of use, a single point of definition, and a sort of versioning of the Discord API. Similar to how a database schema progresses in versions, the definition of the API also progresses in versions.

Most other Twilight crates use types from this crate. For example, the Embed Builder crate primarily uses types having to do with channel message embeds, while the Lavalink crate works with a few of the events received from the gateway. These types being in a single versioned definition is beneficial because it removes the need for crates to rely on other large and unnecessary crates.

The types in this crate are reproducible: deserializing a payload into a type, serializing it, and then deserializing it again will result in the same instance.

Defined are a number of modules defining types returned by or owned by resource categories. For example, gateway contains types used to interact with and returned by the gateway API. guild contains types owned by the Guild resource category. These types may be directly returned by, built on top of, or extended by other Twilight crates.

source: https://github.com/twilight-rs/twilight/tree/main/model

docs: https://docs.rs/twilight-model

crates.io: https://crates.io/crates/twilight-model

HTTP

twilight-http is an HTTP client wrapping all of the documented Discord HTTP API. It is built on top of Reqwest, and supports taking any generic Reqwest client, allowing you to pick your own TLS backend. By default, it uses RusTLS a Rust TLS implementation, but it can be changed to use NativeTLS which uses the TLS native to the platform, and on Unix uses OpenSSL.

Ratelimiting is included out-of-the-box, along with support for proxies.

Features

Deserialization

twilight-gateway supports serde_json and simd-json for deserializing and serializing events.

SIMD

The simd-json feature enables usage of simd-json, which uses modern CPU features to more efficiently deserialize JSON data. It is not enabled by default.

In addition to enabling the feature, you will need to add the following to your <project_root>/.cargo/config:

[build]
rustflags = ["-C", "target-cpu=native"]

TLS

twilight-http has features to enable certain HTTPS TLS connectors.

These features are mutually exclusive. rustls is enabled by default.

Native

The native feature causes the client to use hyper-tls. This will use the native TLS backend, such as OpenSSL on Linux.

RusTLS

The rustls feature causes the client to use hyper-rustls. This enables usage of the RusTLS crate as the TLS backend.

This is enabled by default.

Example

A quick example showing how to get the current user's name:

use std::{env, error::Error};
use twilight_http::Client;

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error + Send + Sync>> {
    // Initialize the tracing subscriber.
    tracing_subscriber::fmt::init();

    let client = Client::new(env::var("DISCORD_TOKEN")?);

    let me = client.current_user().exec().await?.model().await?;
    println!("Current user: {}#{}", me.name, me.discriminator);

    Ok(())
}

source: https://github.com/twilight-rs/twilight/tree/main/http

docs: https://docs.rs/twilight-http

crates.io: https://crates.io/crates/twilight-http

Gateway

twilight-gateway is an implementation of a client over Discord's websocket gateway.

The main type is the Shard: it connects to the gateway, receives messages, parses and processes them, and then gives them to you. It will automatically reconnect, resume, and identify, as well as do some additional connectivity checks.

Also provided is the Cluster, which will automatically manage a collection of shards and unify their messages into one stream. It doesn't have a large API, you usually want to spawn a task to bring it up such that you can begin to receive tasks as soon as they arrive.

use std::sync::Arc;
use futures::StreamExt;
use twilight_gateway::{Cluster, Intents};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
   let token = "dummy";
let intents = Intents::GUILD_MESSAGES | Intents::GUILDS;
let (cluster, mut events) = Cluster::new(token, intents).await?;
let cluster = Arc::new(cluster);     

let cluster_spawn = cluster.clone();

tokio::spawn(async move {
    cluster_spawn.up().await;
});
let _ = events.next().await;
    Ok(())
}

Features

twilight-gateway includes a number of features for things ranging from payload deserialization to TLS features.

Deserialization

twilight-gateway supports serde_json and simd-json for deserializing and serializing events.

SIMD

The simd-json feature enables usage of simd-json, which uses modern CPU features to more efficiently deserialize JSON data. It is not enabled by default.

In addition to enabling the feature, you will need to add the following to your <project_root>/.cargo/config:

[build]
rustflags = ["-C", "target-cpu=native"]

Metrics

The metrics feature provides metrics information via the metrics crate. Some of the metrics logged are counters about received event counts and their types and gauges about the capacity and efficiency of the inflater of each shard.

This is disabled by default.

TLS

twilight-gateway has features to enable async-tungstenite and twilight-http's TLS features. These features are mutually exclusive. rustls is enabled by default.

Native

The native feature enables async-tungstenite's tokio-native-tls feature as well as twilight-http's native feature which uses hyper-tls.

RusTLS

The rustls feature enables async-tungstenite's tokio-rustls feature and twilight-http's rustls feature, which use RusTLS as the TLS backend.

This is enabled by default.

Zlib

Stock

The zlib-stock feature makes flate2 use of the stock Zlib which is either upstream or the one included with the operating system.

SIMD

zlib-simd enables the use of zlib-ng which is a modern fork of zlib that in most cases will be more effective. However, this will add an externel dependency on cmake.

If both are enabled or if the zlib feature of flate2 is enabled anywhere in the dependency tree it will make use of that instead of zlib-ng.

Example

Starting a Shard and printing the contents of new messages as they come in:

use futures::StreamExt;
use std::{env, error::Error};
use twilight_gateway::{Intents, Shard};

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error + Send + Sync>> {
    // Initialize the tracing subscriber.
    tracing_subscriber::fmt::init();

    let token = env::var("DISCORD_TOKEN")?;
    let (shard, mut events) = Shard::new(token, Intents::GUILD_MESSAGES);

    shard.start().await?;
    println!("Created shard");

    while let Some(event) = events.next().await {
        println!("Event: {:?}", event);
    }

    Ok(())
}

source: https://github.com/twilight-rs/twilight/tree/main/gateway

docs: https://docs.rs/twilight-gateway

crates.io: https://crates.io/crates/twilight-gateway

Cache

Twilight includes an in-process-memory cache. It's responsible for processing events and caching things like guilds, channels, users, and voice states.

Examples

Process new messages that come over a shard into the cache:

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
use futures::StreamExt;
use std::env;
use twilight_cache_inmemory::InMemoryCache;
use twilight_gateway::{Intents, Shard};

let token = env::var("DISCORD_TOKEN")?;

let (shard, mut events) = Shard::new(token, Intents::GUILD_MESSAGES);
shard.start().await?;

let cache = InMemoryCache::new();

while let Some(event) = events.next().await {
    cache.update(&event);
}
    Ok(())
}

source: https://github.com/twilight-rs/twilight/tree/main/cache/in-memory

docs: https://docs.rs/twilight-cache-inmemory

crates.io: https://crates.io/crates/twilight-cache-inmemory

Command Parser

The Command Parser is a basic parser for the Twilight ecosystem. We'll get this out of the way first: it's not a framework, and it doesn't try to be.

The parser, for the most part, takes a configuration of prefixes and commands, and attempts to match it to provided strings, returning what command and prefix it matched, if any. The parser will also return a lazy iterator of arguments given to the command.

Included is a mutable configuration that allows you to specify the command names, prefixes, and ignored guilds and users. The parser parses out commands matching an available command and prefix and provides the command arguments to you.

Examples

A simple parser for a bot with one prefix ("!") and two commands, "echo" and "ping":

fn main() {
use twilight_command_parser::{Command, CommandParserConfig, Parser};

let mut config = CommandParserConfig::new();

// (Use `Config::add_command` to add a single command)
config.add_command("echo", true);
config.add_command("ping", true);

// Add the prefix `"!"`.
// (Use `Config::add_prefixes` to add multiple prefixes)
config.add_prefix("!");

let parser = Parser::new(config);

// Now pass a command to the parser
match parser.parse("!echo a message") {
    Some(Command { name: "echo", arguments, .. }) => {
        let content = arguments.as_str();

        println!("Got an echo request to send `{}`", content);
    },
    Some(Command { name: "ping", .. }) => {
        println!("Got a ping request");
    },
    // Ignore all other commands.
    Some(_) => {},
    None => println!("Message didn't match a prefix and command"),
}
}

source: https://github.com/twilight-rs/twilight/tree/main/command-parser

docs: https://docs.rs/twilight-command-parser

crates.io: https://crates.io/crates/twilight-command-parser

Standby

Standby is a utility to wait for an event to happen based on a predicate check. For example, you may have a command that makes a reaction menu of ✅ and ❌. If you want to handle a reaction to these, using something like an application-level state or event stream may not suit your use case. It may be cleaner to wait for a reaction inline to your function. This is where Standby comes in.

Examples

Wait for a message in channel 123 by user 456 with the content "test":

#[allow(unused_variables)]
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
use twilight_model::{
    gateway::payload::incoming::MessageCreate,
    id::{ChannelId, UserId},
};
use twilight_standby::Standby;

let standby = Standby::new();

// Later on in the application...
let message = standby
    .wait_for_message(
        ChannelId::new(123).expect("zero id"),
        |event: &MessageCreate| {
            event.author.id == UserId::new(456).expect("zero id") && event.content == "test"
        },
    )
    .await?;
    Ok(())
}

source: https://github.com/twilight-rs/twilight/tree/main/standby

docs: https://docs.rs/twilight-standby

crates.io: https://crates.io/crates/twilight-standby

First-party

Twilight includes crates maintained by the organization, but not included as part of the core experience. Just like all of the core crates these are entirely opt-in, but are for more advanced or specific use cases, such as integration with other software.

Although not a part of the core experience, these are given the same level of support as the core crates.

Embed Builder

twilight-embed-builder is a utility crate to create validated embeds, useful when creating or updating messages.

With this library, you can create mentions for various types, such as users, emojis, roles, members, or channels.

Examples

Build a simple embed:

#[allow(unused_variables)]
fn main() -> Result<(), Box<dyn std::error::Error>> {
use twilight_embed_builder::{EmbedBuilder, EmbedFieldBuilder};

let embed = EmbedBuilder::new()
    .description("Here's a list of reasons why Twilight is the best pony:")
    .field(EmbedFieldBuilder::new("Wings", "She has wings.").inline())
    .field(EmbedFieldBuilder::new("Horn", "She can do magic, and she's really good at it.").inline())
    .build()?;
    Ok(())
}

Build an embed with an image:

#[allow(unused_variables)]
fn main() -> Result<(), Box<dyn std::error::Error>> {
use twilight_embed_builder::{EmbedBuilder, ImageSource};

let embed = EmbedBuilder::new()
    .description("Here's a cool image of Twilight Sparkle")
    .image(ImageSource::attachment("bestpony.png")?)
    .build()?;
    Ok(())
}

source: https://github.com/twilight-rs/twilight/tree/main/utils/embed-builder

docs: https://docs.rs/twilight-embed-builder

crates.io: https://crates.io/crates/twilight-embed-builder

Mention

twilight-mention is a utility crate to mention model resources.

With this library, you can create mentions for various resources, such as users, emojis, roles, members, or channels.

Examples

Create a mention formatter for a user ID, and then format it in a message:

#[allow(unused_variables)]
fn main() {
use twilight_mention::Mention;
use twilight_model::id::UserId;

let user_id = UserId::new(123).expect("zero id");
let message = format!("Hey there, {}!", user_id.mention());
}

source: https://github.com/twilight-rs/twilight/tree/main/utils/mention

docs: https://docs.rs/twilight-mention

crates.io: https://crates.io/crates/twilight-mention

Lavalink

twilight-lavalink is a client for Lavalink for use with model events from the gateway.

It includes support for managing multiple nodes, a player manager for conveniently using players to send events and retrieve information for each guild, and an HTTP module for creating requests using the http crate and providing models to deserialize their responses.

Features

HTTP Support

The http-support feature adds types for creating requests and deserializing response bodies of Lavalink's HTTP routes via the http crate.

This is enabled by default.

TLS

twilight-lavalink has features to enable async-tungstenite's TLS features. These features are mutually exclusive.

rustls is enabled by default.

Native

The native feature enables async-tungstenite's tokio-native-tls feature. This will use native TLS support, for example OpenSSL on Linux.

RusTLS

The rustls feature enables async-tungstenite's tokio-rustls which uses the RusTLS crate as the TLS backend.

This is enabled by default.

Examples

Create a client, add a node, and give events to the client to process events:

use futures::StreamExt;
use std::{
    env,
    error::Error,
    net::SocketAddr,
    str::FromStr,
};
use twilight_gateway::{Intents, Shard};
use twilight_http::Client as HttpClient;
use twilight_lavalink::Lavalink;

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error + Send + Sync + 'static>> {
    let token = env::var("DISCORD_TOKEN")?;
    let lavalink_host = SocketAddr::from_str(&env::var("LAVALINK_HOST")?)?;
    let lavalink_auth = env::var("LAVALINK_AUTHORIZATION")?;
    let shard_count = 1_u64;

    let http = HttpClient::new(token.clone());
    let user_id = http.current_user().exec().await?.model().await?.id;

    let lavalink = Lavalink::new(user_id, shard_count);
    lavalink.add(lavalink_host, lavalink_auth).await?;

    let intents = Intents::GUILD_MESSAGES | Intents::GUILD_VOICE_STATES;
    let (shard, mut events) = Shard::new(token, intents);

    shard.start().await?;

    while let Some(event) = events.next().await {
        lavalink.process(&event).await?;
    }

    Ok(())
}

source: https://github.com/twilight-rs/twilight/tree/main/lavalink

docs: https://docs.rs/twilight-lavalink

crates.io: https://crates.io/crates/twilight-lavalink

Util

twilight-util is a utility crate that adds utilities to the twilight ecosystem that do not fit in any other crate. One example feature of the crate is a trait to make extracting data from Discord identifiers (snowflakes) easier.

Features

twilight-util by default exports nothing. Features must be individually enabled via feature flags.

Builder

The builder feature enables builders for large structs, at the moment only a builder for commands is availible.

Examples

Create a command that can be used to send a animal picture in a certain category:

fn main() {
use twilight_model::application::command::CommandType;
use twilight_util::builder::command::{BooleanBuilder, CommandBuilder, StringBuilder};

CommandBuilder::new(
    "blep".into(),
    "Send a random adorable animal photo".into(),
    CommandType::ChatInput,
)
.option(
    StringBuilder::new("animal".into(), "The type of animal".into())
        .required(true)
        .choices([
            ("Dog".into(), "animal_dog".into()),
            ("Cat".into(), "animal_cat".into()),
            ("Penguin".into(), "animal_penguin".into()),
        ]),
)
.option(BooleanBuilder::new(
    "only_smol".into(),
    "Whether to show only baby animals".into(),
));
}

The link feature enables the parsing and formatting of URLs to resources, such as parsing and formatting webhook links or links to a user's avatar.

Examples

Parse a webhook URL with a token:

#[allow(unused_variables)]
fn main() -> Result<(), Box<dyn std::error::Error>> {
use twilight_model::id::WebhookId;
use twilight_util::link::webhook;

let url = "https://discord.com/api/webhooks/794590023369752587/tjxHaPHLKp9aEdSwJuLeHhHHGEqIxt1aay4I67FOP9uzsYEWmj0eJmDn-2ZvCYLyOb_K";

let (id, token) = webhook::parse(url)?;
assert_eq!(WebhookId::new(794590023369752587).expect("zero id"), id);
assert_eq!(
    Some("tjxHaPHLKp9aEdSwJuLeHhHHGEqIxt1aay4I67FOP9uzsYEWmj0eJmDn-2ZvCYLyOb_K"),
    token,
);
Ok(()) }

Permission Calculator

The permission-calculator feature is used for calculating the permissions of a member in a channel, taking into account its roles and permission overwrites.

Snowflake

The snowflake feature calculates information out of snowflakes, such as the timestamp or the ID of the worker that created it.

Examples

Retrieve the timestamp of a snowflake in milliseconds from the Unix epoch as a 64-bit integer:

#[allow(unused_variables)]
fn main() {
use twilight_util::snowflake::Snowflake;
use twilight_model::id::UserId;

let user = UserId::new(123456).expect("zero id");
let timestamp = user.timestamp();
}

source: https://github.com/twilight-rs/twilight/tree/main/util

docs: https://docs.rs/twilight-util

crates.io: https://crates.io/crates/twilight-util

Gateway queue

twilight-gateway-queue is a trait and some implementations that are used by the gateway to ratelimit identify calls. Developers should prefer to use the re-exports of these crates through the gateway.

source: https://github.com/twilight-rs/twilight/tree/main/gateway/queue

docs: https://docs.rs/twilight-gateway-queue

crates.io: https://crates.io/crates/twilight-gateway-queue

Third-party

Third-party crates are crates that aren't supported by the Twilight organization but are recognised by it. Of course, use these at your own risk. :)

Third-party crates may become first-party crates if they end up becoming useful enough for a large number of users.

List of Crates

Below is a list of crates. If you want yours added, feel free to ask!

baptiste0928/twilight-interactions

twilight-interactions provides macros and utilities to make interactions easier to use. Its features include slash command parsing and creation from structs with derive macros.

GitHub repository - Documentation

Services

Twilight is built with a service-minded approach. This means that it caters to both monolithic and multi-serviced applications equally. If you have a very large bot and have a multi-serviced application and feel like Rust is a good language to use for some of your services, then Twilight is a great choice. If you have a small bot and just want to get it going in a monolithic application, then it's also a good choice. It's easy to split off parts of your application into other services as your application grows.

Gateway clusters

One of the popular design choices when creating a multi-serviced application is to have a service that simply connects shards to the gateway and sends the events to a broker to be processed. As bots grow into hundreds or thousands of shards, multiple instances of the application can be created and clusters - groups of shards - can be managed by each. Twilight is a good choice for this use case: you can receive either events that come in in a loop and send the payloads to the appropriate broker stream, or you can loop over received payloads' bytes to send off.

Gateway session ratelimiting

If you have multiple clusters, then you need to queue and ratelimit your initialized sessions. The Gateway includes a Queue trait which you can implement, and the gateway will submit a request to the queue before starting a session. Twilight comes with a queue that supports sharding and Large Bot sharding, but when you start to have multiple clusters you'll want to implement your own. Our gateway-queue is an example of this.

HTTP proxy ratelimiting

If you have multiple services or lambda functions that can make HTTP requests, then you'll run into ratelimiting issues. Twilight's HTTP client supports proxying, and can be combined with something like our very own http-proxy to proxy requests and ratelimit them.

The sky is the limit

You can do so much more than just this, and that's the beauty of the ecosystem: it's flexible enough to do anything you need, and if you find something it can't then we'll fix it. The goal is to remove all limitations on designs and allow you to do what you need.

Bots using Twilight

Below is a list of bots known to be using the Twilight ecosystem. The use could be as small as only the gateway or HTTP client, or as large as all of the core crates.

Want your bot added? Feel free to send a PR to the repo!

Open-Source

Gearbot

The GearBot team are rewriting their bot to use Twilight, with a need for performance and scalability in mind.

Source: GitHub

Lasagne bot

Lasagne bot is a bot that posts garfield comics.

Source: Sr.ht

HarTex

HarTex is a Discord bot built and optimized for server administration and moderation needs in mind.

Source: GitHub

interchannel message mover

a discord bot to move messages between channels easily

Source: GitHub

Timezoner

Timezoner is a bot that lets people send a date/time that appears in everyone's own timezone.

Source: GitHub

Changelogs

0.1.0 - 2020-09-13

Initial release.

Check out the crates on crates.io.