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.
- Finnbar (original)
- Benji (updates)
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 Command
s 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]}
"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 typea
.data Exactly (s :: Symbol) = Ex
parses exactly the string provided bys
- e.g.Exactly "add"
parses the string "add".newtype RestOfInput a = ROI a
parses the entire rest of the input, giving a value of typea
.newtype WithError (err :: Symbol) x = WErr x
throws a custom errorerr
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]
parsesa
zero or more times and gives us the list of results that it got.Maybe a
optionally parsesa
, returningJust x
if successful, elseNothing
.Either a b
tries to parsea
(givingLeft x
), and if that fails tries to parseb
instead (givingRight y
).(a, b)
parsesa
and thenb
. This is the same behaviour as having multiple function arguments (so(a, b) -> Message -> DatabaseDiscord ()
is equivalent toa -> 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
.
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 requireliftSql
. - Discord operations (those with types like
DiscordHandler a
) can be run usingliftDiscord
- e.g.liftDiscord . restCall ...
lets you make direct Discord API requests. - IO operations can be run using
liftIO
fromControl.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.
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.
Finally, all good plugins should have help information. This is achieved by creating HelpPage
s. 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]}
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]}