Skip to content

Latest commit

 

History

History
349 lines (258 loc) · 18.2 KB

1.Plugins.md

File metadata and controls

349 lines (258 loc) · 18.2 KB

Writing Your First Plugin!

In this tutorial, we will be writing an overengineered Ping plugin to show how you can write and run your own plugins. We'll go over basic plugins, taking in arguments, IO operations and database operations.

Authors

  • Finnbar (original)
  • Benji (updates)

My First Ping

We are going to first focus on a basic Ping plugin, which responds to any message .ping with the message "pong". Amazing.

To create a plugin, you need to make a Haskell file in the Plugins directory. For now, call it MyPing.hs. At the top of this file, put the following boilerplate that defines this as a module and imports some necessary data structures and functions.

module Tablebot.Plugins.MyPing where

import Data.Text
import Tablebot.Utility
import Tablebot.Utility.Discord
import Tablebot.Utility.SmartParser

There are two parts that you need to write: any Commands you actually want your bot to implement, and the resulting Plugin object that combines your implementation.

A Command is a mapping from a name to code that takes in the rest of the message and performs some action. The former is simply a string that will be checked for at the start of a message (after the command prefix - we'll use . in this tutorial), so for this example "myping" will suffice. Here is the implementation so far:

myPing :: Command
myPing = Command "myping" _ []
-- Note: the [] represents subcommands, which are out of scope for this tutorial

If you were to compile this now, Haskell will point out the hole (_) and say that it needs to be of type Parser (Message -> DatabaseDiscord ()). There's a fair amount going on in that type, so let's discuss each part.

Parser refers to a parser that works with Text. We use the megaparsec library, which provides a variety of helpful functions for writing parsers of your own. These can be specified in do-notation, so allow for clear sets of steps describing your parser. For example, the below parser reads a number (consisting of some digits), then a space, then another number.

twoNumbers :: Parser (Int, Int)
twoNumbers = do
    num1 <- number
    space
    num2 <- number
    return (num1, num2)

At the end of the parser, we use return to give back the two numbers found - hence giving us a Parser (Int, Int) since we can run the parser to get a value of type (Int, Int) out. Note that number :: Parser Int is defined in Tablebot.Plugin.Parser, so you would have to import that if you wanted to use the above parser.

Later on we'll be seeing how you can avoid writing these parsers altogether using smart commands. As such, we won't discuss writing parsers in more detail in this tutorial - if you're interested then there are a good number of tutorials about megaparsec and its parent parsec online.

Our command parser requires us to return a function of type Message -> DatabaseDiscord (). Message is a type provided by the library discord-haskell, which contains a variety of information about the message that the command was contained in. You can find all of the data within it documented in the discord-haskell documentation, but for now we will just be using the input Message with some of the helper functions defined in this library.

Finally, DatabaseDiscord () is the type of a monadic action which provides us a few bits of functionality - namely Discord operations (such as sending messages), database operations and IO operations. We'll cover each of these as we build our more complex plugin. In general, you do not need to worry about the specifics of the underlying implementation (for those familiar with these kinds of libraries, DatabaseDiscord contains a stack of monads providing each bit of functionality) and instead stick with the library functions discussed in this tutorial.

With all of that out of the way, we can finally get to implementing our Ping plugin. We use noArguments :: (Message -> DatabaseDiscord ()) -> Parser (Message -> DatabaseDiscord ()) here, which provides us a parser for free that accepts commands with no arguments. We then need to actually respond - this is done using sendMessage :: Message -> Text -> DatabaseDiscord (), which sends a message in the same channel as the input Message, with content Text:

myPing :: Command
myPing = Command "myping" (noArguments $ \m -> do
    sendMessage m "pong") []

Now we just need to make a Plugin. This is done using plug (the default empty plugin) and a record update to add your commands in. To add commands to a plugin, simply update the commands field of the record as follows.

pingPlugin' :: Plugin
pingPlugin' = (plug "myping") {commands = [myPing]}

Echo

"But wait!" I hear you cry. "Echo isn't ping! You lied to us!"

"Yeah fair enough, but at least this is kind of like ping and will teach you how to use smart commands." I respond, sleepily.

"Touché."

The echo command takes in a single argument (some text), which is repeated back to the user. Thrilling, I know. We could define this with a parser that takes all text up to the end of the line, but instead we are going to use smart commands. Smart commands allow you to define a command via a more complex function type, which a parser is automatically generated for. For example, if we define a command that takes in a Text argument, then it will consume the first word of the message that it sees (after the command invocation of .myecho) and provide that as input to your function.

Smart commands are invocated through parseComm :: PComm ty => ty -> Parser (Message -> DatabaseDiscord ()), so slot in nicely to our existing command infrastructure. As such, a first pass of myecho could look like this.

myEcho :: Command
myEcho = Command "myecho" (parseComm echoHelp) []
    where echoHelp :: Text -> Message -> DatabaseDiscord ()
          echoHelp t m = sendMessage m t

As you can see, we didn't have to even mention parsers here - we defined the sort of command that we wanted, and parseComm built the parser for us. However, the parser for Text only parses a single word, which may not be the entire input. As such, we provide a collection of new types that have slightly different parsers. These are instances of CanParse in Tablebot.Plugin.SmartCommand so can be seen in that documentation, but we also list the new ones here for convenience.

  • newtype Quoted a = Qu a parses input within quote marks, giving a value of type a.
  • data Exactly (s :: Symbol) = Ex parses exactly the string provided by s - e.g. Exactly "add" parses the string "add".
  • newtype RestOfInput a = ROI a parses the entire rest of the input, giving a value of type a.
  • newtype WithError (err :: Symbol) x = WErr x throws a custom error err if the interior parser fails. For example, WithError "Can't find add" (Exactly "add") generates a parser that errors with "Can't find add" if the word "add" is not present.

RestOfInput is the correct type here, so we can modify our previous example to make a correct echo plugin:

myEcho :: Command
myEcho = Command "myecho" (parseComm echoHelp) []
    where echoHelp :: RestOfInput Text -> Message -> DatabaseDiscord ()
          echoHelp (ROI t) m = sendMessage m t

As a bonus, we also map common types to helpful parsers.

  • [a] parses a zero or more times and gives us the list of results that it got.
  • Maybe a optionally parses a, returning Just x if successful, else Nothing.
  • Either a b tries to parse a (giving Left x), and if that fails tries to parse b instead (giving Right y).
  • (a, b) parses a and then b. This is the same behaviour as having multiple function arguments (so (a, b) -> Message -> DatabaseDiscord () is equivalent to a -> b -> Message -> DatabaseDiscord ()) but can be used inside other types (so [(a, b)] -> ... makes sense while [a -> b] -> ... doesn't).

With this, we get a fairly expressive parsing language built from the types of the commands that we want to run - thus allowing you to avoid writing parsers. If you'd like to see a particularly complex example, look at Tablebot.Plugins.Quote.

Ping with Time

Now, I know what you're thinking - ping is good, but what if it also told you the time? I know, so exciting. Well this is easy enough, we have functions that get us the time right, let's use one of those... oh

getSystemTime :: IO SystemTime

We need to be able to do things in IO. Fortunately, as alluded to earlier, DatabaseDiscord provides this functionality - but we should briefly look at the actual definition of DatabaseDiscord to justify this further.

type EnvDatabaseDiscord d = ReaderT d (ReaderT (MVar TablebotCache) (SqlPersistT DiscordHandler))
type DatabaseDiscord = EnvDatabaseDiscord ()
type DiscordHandler = ReaderT DiscordHandle IO

This is called a monad transformer stack, which means that we get to combine the functionality of different monads by stacking them up. DatabaseDiscord holds the result of your plugin setup (within the ReaderT d), gives us database operations through SqlPersistT (from esquelito, more on that later), discord operations through DiscordHandler and IO operations through IO at the bottom of the stack (it also holds some internal information used by the backend of the bot (the ReaderT (MVar TablebotCache) bit), which you can safely ignore). The challenge is accessing each part of the stack. Rather than going on a long discussion about what a monad transformer is, here are the important facts you need to know when working within DatabaseDiscord:

  • Database operations (those with types like SqlPersistT a) may require liftSql.
  • Discord operations (those with types like DiscordHandler a) can be run using liftDiscord - e.g. liftDiscord . restCall ... lets you make direct Discord API requests.
  • IO operations can be run using liftIO from Control.Monad.IO.Class.

With this in mind, we have an easy solution to our timing problem.

myPing' :: Command
myPing' = Command "myping" (parseComm pingWithTime) []
    where pingWithTime :: Message -> DatabaseDiscord ()
          pingWithTime m = do
              now <- liftIO $ systemToUTCTime <$> getSystemTime
              sendMessage m $ pack $ "pong (" ++ show now ++ ")"

Great!

In our command, we get the current time by lifting the functions that get the system time into the DatabaseDiscord monad, and then we send a packaged up message back to the user showing the result.

Ping with Database

Okay so hear me out, what if myping also told you how many pings it had received so far from a given user. Good, you're already used to questionable extensions to this command, let's go.

First, we need a database schema. This is written using persistent. Rather than giving an entire persistent tutorial here, I'll provide the schema and go with a learn-by-example approach. Check out some of the other plugins, or other tutorials on persistent, if you need more help. The uid we're using is a Word64, which we import from GHC.Word.

share
  [mkPersist sqlSettings, mkMigrate "pingMigration"]
  [persistLowerCase|
PingCount
  Primary uid
  uid Word64
  counter Int
  deriving Show
|]

We now have to link this migration into the rest of the system. This is done by adding it to the plugin through the migrations field. Note the mkMigrate "pingMigration" in the above example - we have to use that same name in the plugin definition.

pingPlugin' :: Plugin
pingPlugin' = (plug "myping") {commands = [myPing'], migrations = [pingMigration]}

With that in place, we now need commands that work with this database. We use esquelito, which allows us to write in an SQL-like language. Its documentation has a variety of great examples which you should investigate if you're going to perform database operations! For now, let's look at the implementation of this command. First, we attempt to get the existing user info. We have to import Discord.Types, Database.Esqueleto, and fromJust from Data.Maybe to get some of the functions we need.

myPing'' :: Command
myPing'' = Command "cmyping" (parseComm pingDB) []
    where pingDB :: Message -> DatabaseDiscord ()
          pingDB m = do
              let uid = extractFromSnowflake $ userId $ messageAuthor m
              user <- liftSql $ select $ from $ \p -> do
                  where_ (p ^. PingCountUid ==. val uid)
                  return p
              ...

Note that the table we are selecting from is inferred by use of ^. PingCountUid, which gets the Uid field of the PingCount table from p.

This query will either get us a single record in a list, or the empty list. We use this information to create the record we would like to be present in the database - either a new record if this record was not present, or a record with incremented count otherwise. We finally then use repsert, which replaces or inserts the given data.

              ...
              c <- case user of
                [] -> do
                  _ <- liftSql $ insert (PingCount uid 1)
                  return 1
                (x : _) -> do
                  let (PingCount uid' count) = entityVal x
                      record' = PingCount uid' (count+1)
                  liftSql $ repsert (entityKey x) record'
                  return (count+1)
              ...

Finally, we need to provide functionality for reporting this information back to the user, which is a game of reading the record we just wrote to the database.

              ...
              sendMessage m (pack $ show c)

Hooray! We can now add this to the plugin as we have in the previous examples.

Adding Help

Finally, all good plugins should have help information. This is achieved by creating HelpPages. Here is a direct quote of Quote's help pages:

showQuoteHelp :: HelpPage
showQuoteHelp =
  HelpPage
    "show"
    []
    "show a quote by number"
    "**Show Quote**\nShows a quote by id\n\n*Usage:* `quote show <id>`"
    []
    None

randomQuoteHelp :: HelpPage
randomQuoteHelp =
  HelpPage
    "random"
    []
    "show a random quote"
    "**Random Quote**\nDisplays a random quote\n\n*Usage:* `quote random`"
    []
    None

A HelpPage consists of a short command name, other aliases for the command, a short summary, a longer summary, a list of subcommands, and the permissions needed to run the command. These are fairly self-explanatory. Note that you can use the [r| |] quoter for multiline strings, by importing Text.RawString.QQ.

These are then added to the plugin like everything else - through a field in the Plugin. You only need to add the highest parent - any other help pages referenced by any included page will be included as well. Here is Quote's plugin definition as an example.

quotePlugin :: Plugin
quotePlugin = plug {commands = [quote], migrations = [quoteMigration], helpPages = [quoteHelp]}

Running the Plugin

Ok, that's great and all, but how do we even run a plugin in the first place? (I know we said finally, but this is important too!)

This is actually the simplest part of the tutorial.

First, we want to limit the exports of the module so that only the plugin is revealed to the rest of the project - no need to leak implementation details!

module Tablebot.Plugins.MyPing (pingPlugin') where

Next, we need to add the plugin created to the list of plugins to add to the bot.

Go to ./src/Tablebot/Plugins.hs, and import the plugin you created, by adding the following line to the list of imports.

import Tablebot.Plugins.MyPing (pingPlugin')

Finally, at the end of the list of the plugins list in that file, add a comma to the last plugin and add your plugin to the list to be added.

Once you run stack run, the bot should compile, start up, and you-

tablebot-exe: DISCORD_TOKEN: getEnv: does not exist (no environment variable)

Oh right, we actually have to connect to a bot.

Having followed the instructions in the main README to set up an environment variable, we can now run the bot, and run our commands!

And that is how to create a plugin from scratch.

Here is the complete file if you need to check anything:

module Tablebot.Plugins.MyPing (pingPlugin') where

import Data.Text
import Tablebot.Utility
import Tablebot.Utility.Discord
import Tablebot.Utility.SmartParser
import Control.Monad.IO.Class
import Data.Time.Clock.System
import Database.Persist.TH
import Discord.Types
import Database.Esqueleto
import GHC.Word
import Data.Maybe (fromJust)

share
  [mkPersist sqlSettings, mkMigrate "pingMigration"]
  [persistLowerCase|
PingCount
  Primary uid
  uid Word64
  counter Int
  deriving Show
|]

myPing :: Command
myPing = Command "myping" (noArguments $ \m -> do
    sendMessage m "pong") []

myEcho :: Command
myEcho = Command "myecho" (parseComm echoHelp) []
    where echoHelp :: RestOfInput Text -> Message -> DatabaseDiscord ()
          echoHelp (ROI t) m = sendMessage m t

myPing' :: Command
myPing' = Command "myping" (parseComm pingWithTime) []
    where pingWithTime :: Message -> DatabaseDiscord ()
          pingWithTime m = do
              now <- liftIO $ systemToUTCTime <$> getSystemTime
              sendMessage m $ pack $ "pong (" ++ show now ++ ")"

myPing'' :: Command
myPing'' = Command "cmyping" (parseComm pingDB) []
    where pingDB :: Message -> DatabaseDiscord ()
          pingDB m = do
              let uid = extractFromSnowflake $ userId $ messageAuthor m
              user <- liftSql $ select $ from $ \p -> do
                  where_ (p ^. PingCountUid ==. val uid)
                  return p
              c <- case user of
                [] -> do
                  _ <- liftSql $ insert (PingCount uid 1)
                  return 1
                (x : _) -> do
                  let (PingCount uid' count) = entityVal x
                      record' = PingCount uid' (count+1)
                  liftSql $ repsert (entityKey x) record'
                  return (count+1)
              sendMessage m (pack $ show c)

pingPlugin' :: Plugin
pingPlugin' = (plug "myping") {commands = [myPing', myPing'', myEcho], migrations = [pingMigration]}