Lingo

An Elixir library for interacting with the Discord API.

Currently covers:

No required dependencies: just OTP and Elixir 1.18+. Metrics go through :telemetry when your app has it.

Check out the docs for the API reference.

Install

{:lingo, "~> 0.4.2"}

Quick start

A bot is a module that declares its commands and implements the Lingo.Bot callbacks it needs:

defmodule MyBot do
  use Lingo.Bot

  command "ping", "Responds with pong"

  @impl true
  def handle_command("ping", ctx), do: reply!(ctx, "Pong!")

  @impl true
  def handle_event(:message_create, %{content: "hello"} = msg) do
    Lingo.Api.Message.create(msg.channel_id, "Hello back!")
  end
end

Add it to your supervision tree:

children = [
  {MyBot,
   token: System.fetch_env!("BOT_TOKEN"),
   intents: [:guilds, :guild_messages, :message_content],
   register_commands: :global}
]

Supervisor.start_link(children, strategy: :one_for_one)

register_commands: :global sends your commands to Discord once the bot is ready. During development, {:guild, guild_id} updates instantly instead. You can also call Lingo.register_commands/1 yourself.

Where things are

Module What it does
Lingo Starting bots, picking one, registering commands
Lingo.Bot The behaviour your bot module implements
Lingo.Context The interaction a callback handles: reading it and replying
Lingo.Api.* The REST API, one module per resource (Lingo.Api.Message, Lingo.Api.Guild...)
Lingo.Cache.* What the gateway and REST API have told the bot, one module per resource (Lingo.Cache.Role.list/1...); opt-in, configured in Lingo.Cache
Lingo.Gateway Presence, shards and gateway requests
Lingo.Voice Joining voice channels, playing and receiving audio
Lingo.Component, Lingo.Embed, Lingo.Poll Building message content
Lingo.Collector Awaiting clicks and reactions inline
Lingo.Endpoint Interactions and webhook events over HTTP
Lingo.OAuth2 Authorizing users and calling the API as them
Lingo.Testing Testing a bot without Discord
Lingo.Telemetry Events for metrics
Lingo.Helper.*, Lingo.Permissions, Lingo.Flags, Lingo.Format, Lingo.CDN Utilities

Commands with options

command "greet", "Greet someone",
  options: [
    user(:target, "Who to greet", required: true),
    string(:message, "What to say")
  ]

@impl true
def handle_command("greet", ctx) do
  user = get_user(ctx, :target)
  reply!(ctx, "#{option(ctx, :message) || "Hey"} #{user.username}!")
end

Every callback is an ordinary function, so you match on whatever you like and everything else is ignored.

Components and collectors

command "confirm", "Ask for confirmation"

@impl true
def handle_command("confirm", ctx) do
  reply!(ctx,
    content: "Are you sure?",
    components: [
      Lingo.Component.action_row([
        Lingo.Component.button(custom_id: "confirm:yes", label: "Yes", style: :success),
        Lingo.Component.button(custom_id: "confirm:no", label: "No", style: :danger)
      ])
    ]
  )
end

@impl true
def handle_component("confirm:" <> answer, ctx) do
  update!(ctx, content: "You said #{answer}.", components: [])
end

Or use collectors to await interactions inline:

case Lingo.Collector.await_component(msg.id, timeout: 30_000) do
  {:ok, interaction} -> # handle the click
  :timeout -> # no response
end

Events

Every event Discord sends reaches handle_event/2, named as Discord names it, lowercased. Creates carry the resource, updates %{old: old, new: new}, and everything else a struct of its own (Lingo.Event lists them all):

@impl true
def handle_event(:guild_member_add, member) do
  Lingo.Api.Message.create(welcome_channel_id, "Welcome #{Lingo.Format.mention_user(member.user.id)}!")
end

def handle_event(:message_delete, %Lingo.Event.MessageDelete{channel_id: channel_id, old: old}) do
  if old, do: Lingo.Api.Message.create(channel_id, "Deleted: #{old.content}")
end

# old comes from the cache: nil unless messages are cached (see Lingo.Cache)
def handle_event(:message_update, %{old: %{content: before}, new: msg}) do
  IO.puts("#{msg.author.username} changed #{inspect(before)} to #{inspect(msg.content)}")
end

Errors

API calls return {:ok, result}, :ok or {:error, %Lingo.Error{}}, and the ! helpers raise Lingo.Error:

case Lingo.Api.Message.get(channel_id, message_id) do
  {:ok, message} -> message
  {:error, %Lingo.Error{status: 404}} -> nil
end

Embeds

Lingo.Embed.build(
  title: "Server Info",
  color: 0x5865F2,
  fields: [
    %{name: "Members", value: "1,234", inline: true},
    %{name: "Joined", value: Lingo.Format.timestamp(member.joined_at, :relative), inline: true}
  ]
)