diff --git a/.env.example b/.env.example index 31f56b71..dbb2ab7d 100644 --- a/.env.example +++ b/.env.example @@ -7,4 +7,5 @@ EXEC_GROUP=123456789123456789 MODERATOR_GROUP=321654987321654987 SUPERUSER_GROUP=147258369147258369 ALLOW_GIT_UPDATE=False +VERBOSITY=0 # NOTE: YOU MUST HAVE A NEWLINE AT THE END OF THE FILE diff --git a/.gitignore b/.gitignore index 6f78a42e..b31c80ea 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,5 @@ .env db.* database* -*.cabal stack.yaml.lock .gitattributes diff --git a/README.md b/README.md index f2da23d1..9864ceac 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Create a `.env` file containing the following keys. Consult `.env.example` if yo * `EXEC_GROUP` (optional) - the group ID assigned to exec members. * `MODERATOR_GROUP` (optional) - the group ID assigned to moderator members. * `SUPERUSER_GROUP` (optional) - the group ID assigned to the superuser. Strongly recommended +* `VERBOSITY` (optional) - how loud the bot should be about things. 0 is loudest. currently only used in erroring command The three Group settings are optional, but without them any commands that require elevated permissions will not be able to be called when DEBUG is false. Users with the superuser group are able to run every command (including some dangerous diff --git a/app/Main.hs b/app/Main.hs index 25607066..e7488609 100644 --- a/app/Main.hs +++ b/app/Main.hs @@ -8,7 +8,7 @@ import Tablebot.Plugins (allPlugins) -- @main@ runs forever. This allows bot reloading by fully shutting down the bot and letting it restart. main :: IO () -main = runTablebotWithEnv allPlugins $ BotConfig {gamePlaying = "with dice", rootHelpText = rootBody} +main = runTablebotWithEnv allPlugins $ BotConfig {gamePlaying = "with dice", rootHelpText = rootBody, botName = "Tablebot"} rootBody :: Text rootBody = diff --git a/package.yaml b/package.yaml deleted file mode 100644 index 7b891610..00000000 --- a/package.yaml +++ /dev/null @@ -1,125 +0,0 @@ -name: tablebot -version: 0.3.3 -github: "WarwickTabletop/tablebot" -license: MIT -author: "Warwick Tabletop" -maintainer: "tagarople@gmail.com" -copyright: "2021 Warwick Tabletop" - -extra-source-files: - - README.md - - ChangeLog.md - -# Metadata used when publishing your package -# synopsis: Short description of your package -# category: Web - -# To avoid duplicated efforts in documentation and dealing with the -# complications of embedding Haddock markup inside cabal files, it is -# common to point users to the README.md file. -description: Please see the README on GitHub at - -dependencies: - - base >= 4.7 && < 5 - - extra - - discord-haskell - - emoji - - text - - text-icu - - transformers - - load-env - - megaparsec - - persistent - - persistent-sqlite - - persistent-template - - random - - esqueleto - - monad-logger - - time - - aeson - - bytestring - - yaml - - http-conduit - - raw-strings-qq - - template-haskell - - timezone-olson - - duckling - - unordered-containers - - bytestring - - req - - http-client - - data-default - - exception-transformers - - resourcet - - resource-pool - - containers - - th-printf - - mtl - - safe - - edit-distance - - unliftio - - process - - Chart - - Chart-diagrams - - diagrams-core - - diagrams-lib - - diagrams-rasterific - - JuicyPixels - - split - - regex-pcre - - distribution - - -library: - source-dirs: src - default-extensions: - - OverloadedStrings - - LambdaCase - - EmptyDataDecls - - FlexibleContexts - - GADTs - - GeneralizedNewtypeDeriving - - MultiParamTypeClasses - - QuasiQuotes - - TemplateHaskell - - TypeFamilies - - DerivingStrategies - - StandaloneDeriving - - UndecidableInstances - - DataKinds - - FlexibleInstances - - DeriveGeneric - - TypeApplications - - MultiWayIf - - TupleSections - - ConstraintKinds - - RecordWildCards - - ScopedTypeVariables - - TypeOperators - - RankNTypes - - BangPatterns - ghc-options: - - -Wall - - -executables: - tablebot-exe: - main: Main.hs - source-dirs: app - ghc-options: - - -threaded - - -rtsopts - - -with-rtsopts=-N - dependencies: - - tablebot - -tests: - tablebot-test: - main: Spec.hs - source-dirs: test - ghc-options: - - -threaded - - -rtsopts - - -with-rtsopts=-N - dependencies: - - tablebot diff --git a/src/Tablebot.hs b/src/Tablebot.hs index 07fec893..f3af65ae 100644 --- a/src/Tablebot.hs +++ b/src/Tablebot.hs @@ -48,6 +48,7 @@ import Tablebot.Plugins (addAdministrationPlugin) import Tablebot.Utility import Tablebot.Utility.Help import Text.Regex.PCRE ((=~)) +import UnliftIO (TVar, newTVarIO) -- | runTablebotWithEnv @plugins@ runs the bot using data found in the .env -- file with the @[CompiledPlugin]@ given. If you're looking to run the bot as @@ -110,7 +111,7 @@ runTablebot vinfo dToken prefix dbpath plugins config = mapM_ (\migration -> runSqlPool (runMigration migration) pool) $ combinedMigrations plugin -- Create a var to kill any ongoing tasks. mvar <- newEmptyMVar :: IO (MVar [ThreadId]) - cacheMVar <- newMVar (TCache M.empty vinfo) :: IO (MVar TablebotCache) + cacheMVar <- newTVarIO (TCache M.empty vinfo config) :: IO (TVar TablebotCache) userFacingError <- runDiscord $ def diff --git a/src/Tablebot/Handler.hs b/src/Tablebot/Handler.hs index 8dbb004c..042773c4 100644 --- a/src/Tablebot/Handler.hs +++ b/src/Tablebot/Handler.hs @@ -16,7 +16,6 @@ module Tablebot.Handler ) where -import Control.Concurrent (MVar) import Control.Monad (unless) import Control.Monad.Exception import Control.Monad.IO.Class (MonadIO (liftIO)) @@ -40,6 +39,7 @@ import Tablebot.Internal.Types import Tablebot.Utility.Discord (sendEmbedMessage) import Tablebot.Utility.Exception import Tablebot.Utility.Types (TablebotCache) +import UnliftIO (TVar) import UnliftIO.Concurrent ( ThreadId, forkIO, @@ -85,12 +85,12 @@ eventHandler pl prefix = \case runCron :: Pool SqlBackend -> CompiledCronJob -> - ReaderT (MVar TablebotCache) DiscordHandler ThreadId + ReaderT (TVar TablebotCache) DiscordHandler ThreadId runCron pool (CCronJob delay fn) = do cache <- ask lift . forkIO $ withDelay cache where - withDelay :: MVar TablebotCache -> DiscordHandler () + withDelay :: TVar TablebotCache -> DiscordHandler () withDelay cache = do catchAny (runSqlPool (runReaderT fn cache) pool) (liftIO . print) liftIO $ threadDelay delay diff --git a/src/Tablebot/Internal/Cache.hs b/src/Tablebot/Internal/Cache.hs index 707a6e3d..316d9d22 100644 --- a/src/Tablebot/Internal/Cache.hs +++ b/src/Tablebot/Internal/Cache.hs @@ -10,34 +10,33 @@ -- Not intended for use by plugins directly, if you need to do that create a separate cache in your setup phase. module Tablebot.Internal.Cache where -import Control.Concurrent.MVar (putMVar, readMVar, takeMVar) -import Control.Monad.IO.Class (liftIO) import Control.Monad.Trans.Reader (ask) import qualified Data.Map as M import Data.Text (Text) import Discord.Types import Tablebot.Utility.Types +import UnliftIO (atomically, readTVarIO, writeTVar) lookupEmojiCache :: Text -> EnvDatabaseDiscord s (Maybe Emoji) lookupEmojiCache t = do mcache <- liftCache ask - cache <- liftIO $ readMVar mcache + cache <- readTVarIO mcache pure $ M.lookup t $ cacheKnownEmoji cache insertEmojiCache :: Text -> Emoji -> EnvDatabaseDiscord s () insertEmojiCache t e = do mcache <- liftCache ask - cache <- liftIO $ takeMVar mcache + cache <- readTVarIO mcache let new = cache {cacheKnownEmoji = M.insert t e (cacheKnownEmoji cache)} - liftIO $ putMVar mcache new + atomically $ writeTVar mcache new addNewEmojiCache :: Text -> Emoji -> EnvDatabaseDiscord s () addNewEmojiCache t e = do mcache <- liftCache ask - cache <- liftIO $ takeMVar mcache + cache <- readTVarIO mcache let emap = cacheKnownEmoji cache new = cache {cacheKnownEmoji = if M.member t emap then emap else M.insert t e emap} - liftIO $ putMVar mcache new + atomically $ writeTVar mcache new fillEmojiCache :: Guild -> EnvDatabaseDiscord s () fillEmojiCache guild = do @@ -47,5 +46,5 @@ fillEmojiCache guild = do getVersionInfo :: EnvDatabaseDiscord s VersionInfo getVersionInfo = do mcache <- liftCache ask - cache <- liftIO $ readMVar mcache + cache <- readTVarIO mcache pure $ cacheVersionInfo cache diff --git a/src/Tablebot/Internal/Handler/Command.hs b/src/Tablebot/Internal/Handler/Command.hs index 4e7575ce..9b376783 100644 --- a/src/Tablebot/Internal/Handler/Command.hs +++ b/src/Tablebot/Internal/Handler/Command.hs @@ -17,7 +17,7 @@ module Tablebot.Internal.Handler.Command where import qualified Data.List.NonEmpty as NE -import Data.Maybe (catMaybes) +import Data.Maybe (catMaybes, fromMaybe) import Data.Set (singleton, toList) import Data.Text (Text) import Data.Void (Void) @@ -29,6 +29,8 @@ import Tablebot.Utility.Exception (BotException (ParserException), embedError) import Tablebot.Utility.Parser (skipSpace1, space, word) import Tablebot.Utility.Types (Parser) import Text.Megaparsec +import Text.Read (readMaybe) +import UnliftIO.Environment (lookupEnv) import qualified UnliftIO.Exception as UIOE (tryAny) -- | @parseNewMessage@ parses a new message, first by attempting to match the @@ -58,17 +60,22 @@ parseNewMessage pl prefix m = -- If the parser errors, the last error (which is hopefully one created by -- '') is sent to the user as a Discord message. parseCommands :: [CompiledCommand] -> Message -> Text -> CompiledDatabaseDiscord () -parseCommands cs m prefix = case parse (parser cs) "" (messageText m) of - Right p -> p m - Left e -> - let (errs, title) = makeBundleReadable e - in changeAction () . sendEmbedMessage m "" $ embedError $ ParserException title $ "```\n" ++ errorBundlePretty errs ++ "```" +parseCommands cs m prefix = do + shouldError <- errorOnNoCommand + case parse (parser shouldError cs) "" (messageText m) of + Right p -> p m + Left e -> + let (errs, title) = makeBundleReadable e + in changeAction () . sendEmbedMessage m "" $ embedError $ ParserException title $ "```\n" ++ errorBundlePretty errs ++ "```" where - parser :: [CompiledCommand] -> Parser (Message -> CompiledDatabaseDiscord ()) - parser cs' = + errorOnNoCommand :: CompiledDatabaseDiscord Bool = (== 0) . fromMaybe (0 :: Int) . (>>= readMaybe) <$> lookupEnv "VERBOSITY" + onError True = ( "No command with that name was found!") + onError False = (<|> pure (const (pure ()))) + parser :: Bool -> [CompiledCommand] -> Parser (Message -> CompiledDatabaseDiscord ()) + parser shouldError cs' = do _ <- chunk prefix - choice (map toErroringParser cs') "No command with that name was found!" + onError shouldError $ choice (map toErroringParser cs') toErroringParser :: CompiledCommand -> Parser (Message -> CompiledDatabaseDiscord ()) toErroringParser c = try (chunk $ commandName c) *> (skipSpace1 <|> eof) *> (try (choice $ map toErroringParser $ commandSubcommands c) <|> commandParser c) diff --git a/src/Tablebot/Internal/Types.hs b/src/Tablebot/Internal/Types.hs index 9bdbd4d1..7ded3404 100644 --- a/src/Tablebot/Internal/Types.hs +++ b/src/Tablebot/Internal/Types.hs @@ -11,16 +11,15 @@ -- allow homogeneous storage throughout the rest of the implementation. module Tablebot.Internal.Types where -import Control.Concurrent.MVar (MVar) import Control.Monad.Reader (ReaderT) -import Data.Default import Data.Text (Text) import Database.Persist.Sqlite (Migration, SqlPersistT) import Discord import Discord.Types import Tablebot.Utility.Types +import UnliftIO (TVar) -type CompiledDatabaseDiscord = ReaderT (MVar TablebotCache) (SqlPersistT DiscordHandler) +type CompiledDatabaseDiscord = ReaderT (TVar TablebotCache) (SqlPersistT DiscordHandler) -- | @CompiledPlugin@ represents the internal format of the plugins. -- Its main job is to convert all the plugins into one type by collapsing @@ -82,19 +81,3 @@ data CompiledCronJob = CCronJob { timeframe :: Int, onCron :: CompiledDatabaseDiscord () } - --- * Configuration type - --- Allows others to configure the bot. - -data BotConfig = BotConfig - { rootHelpText :: Text, - gamePlaying :: Text - } - -instance Default BotConfig where - def = - BotConfig - { rootHelpText = "This bot is built off the Tablebot framework ().", - gamePlaying = "Kirby: Planet Robobot" - } diff --git a/src/Tablebot/Utility/Embed.hs b/src/Tablebot/Utility/Embed.hs index 7330ddf3..ad42c2fe 100644 --- a/src/Tablebot/Utility/Embed.hs +++ b/src/Tablebot/Utility/Embed.hs @@ -21,6 +21,9 @@ import Tablebot.Utility.Types (DiscordColour) simpleEmbed :: Text -> Embed simpleEmbed t = createEmbed $ CreateEmbed "" "" Nothing "" "" Nothing t [] Nothing "" Nothing Nothing +basicEmbed :: Text -> Text -> Embed +basicEmbed title body = createEmbed $ CreateEmbed "" "" Nothing title "" Nothing body [] Nothing "" Nothing Nothing + addTitle :: Embeddable e => Text -> e -> Embed addTitle t e = (asEmbed e) diff --git a/src/Tablebot/Utility/Help.hs b/src/Tablebot/Utility/Help.hs index 9eecf7c7..d5f70c53 100644 --- a/src/Tablebot/Utility/Help.hs +++ b/src/Tablebot/Utility/Help.hs @@ -9,17 +9,21 @@ -- This module creates functions and data structures to help generate help text for commands module Tablebot.Utility.Help where +import Control.Monad.Reader (MonadReader (ask)) import Data.Functor (($>)) import Data.Text (Text) import qualified Data.Text as T +import Discord.Types import Tablebot.Internal.Permission (getSenderPermission, userHasPermission) import Tablebot.Internal.Plugins (changeAction) import Tablebot.Internal.Types -import Tablebot.Utility.Discord (Message, sendMessage) +import Tablebot.Utility.Discord +import Tablebot.Utility.Embed (addColour) import Tablebot.Utility.Parser (skipSpace) import Tablebot.Utility.Permission (requirePermission) import Tablebot.Utility.Types hiding (helpPages) import Text.Megaparsec (choice, chunk, eof, try, (), (<|>)) +import UnliftIO (readTVarIO) helpHelpPage :: HelpPage helpHelpPage = HelpPage "help" [] "show information about commands" "**Help**\nShows information about bot commands\n\n*Usage:* `help `" [] None @@ -31,27 +35,35 @@ generateHelp rootText p = } handleHelp :: Text -> [HelpPage] -> Parser (Message -> CompiledDatabaseDiscord ()) -handleHelp rootText hp = parseHelpPage root +handleHelp rootText hp = parseHelpPage True root where root = HelpPage "" [] "" rootText hp None -parseHelpPage :: HelpPage -> Parser (Message -> CompiledDatabaseDiscord ()) -parseHelpPage hp = do +parseHelpPage :: Bool -> HelpPage -> Parser (Message -> CompiledDatabaseDiscord ()) +parseHelpPage isRoot hp = do _ <- choice (map chunk (helpName hp : helpAliases hp)) skipSpace - (try eof $> displayHelp hp) <|> choice (map parseHelpPage $ helpSubpages hp) "Unknown Subcommand" + (try eof $> displayHelp isRoot hp) <|> choice (map (parseHelpPage False) $ helpSubpages hp) "Unknown Subcommand" -displayHelp :: HelpPage -> Message -> CompiledDatabaseDiscord () -displayHelp hp m = changeAction () . requirePermission (helpPermission hp) m $ do +displayHelp :: Bool -> HelpPage -> Message -> CompiledDatabaseDiscord () +displayHelp isRoot hp m = changeAction () . requirePermission (helpPermission hp) m $ do uPerm <- getSenderPermission m - sendMessage m $ formatHelp uPerm hp + cache <- liftCache ask + botName' <- botName . cacheBotInfo <$> readTVarIO cache + sendEmbedMessage m "" $ addColour Aqua $ createEmbed $ CreateEmbed "" "" Nothing (formatHelpTitle hp botName') "" Nothing (formatHelp isRoot uPerm hp) [] Nothing "" Nothing Nothing -formatHelp :: UserPermission -> HelpPage -> Text -formatHelp up hp = helpBody hp <> formatSubpages hp +formatHelpTitle :: HelpPage -> Text -> Text +formatHelpTitle hp botName' = ":scroll: " <> if helpName hp == "" then botName' else "Help: `$" <> helpName hp <> "`" + +formatHelp :: Bool -> UserPermission -> HelpPage -> Text +formatHelp isRoot up hp = helpBody hp <> formatSubpages hp where formatSubpages :: HelpPage -> Text formatSubpages (HelpPage _ _ _ _ [] _) = "" - formatSubpages hp' = if T.null sp then "" else "\n\n*Subcommands*" <> sp + formatSubpages hp' = + if T.null sp + then "" + else (if isRoot then "\n\n*Commands*" else "\n\n*Subcommands*") <> sp where sp = T.concat (map formatSubpage (helpSubpages hp')) formatSubpage :: HelpPage -> Text diff --git a/src/Tablebot/Utility/Parser.hs b/src/Tablebot/Utility/Parser.hs index 5bf874cf..0cc0232d 100644 --- a/src/Tablebot/Utility/Parser.hs +++ b/src/Tablebot/Utility/Parser.hs @@ -82,6 +82,10 @@ word = some letter nonSpaceWord :: Parser String nonSpaceWord = some notSpace +-- | @underscoreWord@ parses a single word of letters or underscores. +underscoreWord :: Parser String +underscoreWord = some (letter <|> single '_') + -- | @untilEnd@ gets all of the characters up to the end of the input. untilEnd :: Parser String untilEnd = manyTill anySingle eof @@ -109,7 +113,7 @@ keyValue = many $ try $ skipManyTill anySingle pair where pair :: Parser (String, String) pair = do - cat <- word + cat <- underscoreWord _ <- ":" content <- quoted <|> nonSpaceWord return (cat, content) @@ -121,7 +125,7 @@ keyValueSepOn seps = many $ try $ skipManyTill anySingle pair where pair :: Parser (String, Char, String) pair = do - cat <- word + cat <- underscoreWord sep <- satisfy (`elem` seps) content <- quoted <|> nonSpaceWord return (cat, sep, content) @@ -133,7 +137,7 @@ keyValuesSepOn seps ors = many $ try $ skipManyTill anySingle pair where pair :: Parser (String, Char, [String]) pair = do - cat <- word + cat <- underscoreWord sep <- satisfy (`elem` seps) content <- (quotedWithout ors <|> nonSpaceWord') `sepBy` satisfy (`elem` ors) return (cat, sep, content) @@ -144,11 +148,20 @@ keyValuesSepOn seps ors = many $ try $ skipManyTill anySingle pair sp :: Parser () sp = space <|> pure () --- | @posInteger@ parses an integer with no "-". +-- | @posInteger@ parses a non-zero integer with no "-". posInteger :: (Integral a, Read a) => Parser a posInteger = do digits <- some digit - return (read digits) + let ds :: Integer = read digits + if ds > 0 + then return (fromInteger ds) + else fail "Zero is not positive" + +-- | @nonNegativeInteger@ parses an integer with no "-". +nonNegativeInteger :: (Integral a, Read a) => Parser a +nonNegativeInteger = do + digits <- some digit + return $ read digits -- | @integer@ parses any whole number. integer :: (Integral a, Read a) => Parser a diff --git a/src/Tablebot/Utility/Random.hs b/src/Tablebot/Utility/Random.hs index 68dac0a6..43db4714 100644 --- a/src/Tablebot/Utility/Random.hs +++ b/src/Tablebot/Utility/Random.hs @@ -7,25 +7,74 @@ -- Portability : POSIX -- -- A collection of utility functions for generating randomness. -module Tablebot.Utility.Random (chooseOne, chooseOneWithDefault, chooseOneWeighted, chooseOneWeightedWithDefault) where +module Tablebot.Utility.Random + ( randomRange, + randomRangeSeeded, + shuffle, + chooseOne, + chooseOneWithDefault, + chooseOneSeeded, + chooseOneWeighted, + chooseOneWeightedWithDefault, + chooseN, + ) +where +import Control.Monad (foldM) import Control.Monad.Exception (MonadException (throw)) import Data.List (find) import Data.Maybe (fromJust) -import System.Random (randomRIO) +import System.Random (mkStdGen, randomR, randomRIO) import Tablebot.Utility.Exception (BotException (RandomException), catchBot) +-- | @randomRange@ gets a random number between lower (inclusive) and upper +-- (exclusive). +-- Behaviour is undefined for lower >= upper. +randomRange :: Int -> Int -> IO Int +randomRange lower upper = randomRIO (lower, upper - 1) + +-- | @randomRangeSeeded@ gets a random number between lower (inclusive) and +-- upper (exclusive), based on a single seed. +-- Behaviour is undefined for lower >= upper. +randomRangeSeeded :: Int -> Int -> Int -> Int +randomRangeSeeded seed lower upper = fst $ randomR (lower, upper - 1 :: Int) $ mkStdGen seed + +-- | @shuffle@ randomly reorders a list. +shuffle :: [a] -> IO [a] +shuffle ls = foldM swap ls [0 .. length ls - 1] + where + swap :: [a] -> Int -> IO [a] + swap xs a = do + b <- randomRange 0 $ length xs + let lo = if a > b then b else a + hi = if a < b then b else a + loVal = xs !! lo + hiVal = xs !! hi + bottom = take lo xs + middle = take (hi - lo - 1) $ drop (lo + 1) xs + top = drop (hi + 1) xs + return $ + if a == b + then xs + else bottom ++ [hiVal] ++ middle ++ [loVal] ++ top + -- | @chooseOne@ chooses a single random element from a given list with uniform -- distribution. chooseOne :: [a] -> IO a chooseOne [] = throw $ RandomException "Cannot choose from empty list." -chooseOne xs = (xs !!) <$> randomRIO (0, length xs - 1 :: Int) +chooseOne xs = (xs !!) <$> randomRange 0 (length xs) -- | @chooseOneWithDefault@ chooses a single random element from a given list -- with uniform distribution, or a given default value if the list is empty. chooseOneWithDefault :: a -> [a] -> IO a chooseOneWithDefault x xs = chooseOne xs `catchBot` \_ -> return x +-- | @chooseOneSeeded@ chooses a single random element from a given list +-- with uniform distribution, based on a single seed. +chooseOneSeeded :: Int -> [a] -> IO a +chooseOneSeeded _ [] = throw $ RandomException "Cannot choose from empty list." +chooseOneSeeded seed xs = return $ (!!) xs $ randomRangeSeeded seed 0 (length xs) + -- | @chooseOneWeighted@ chooses a single random element from a given list with -- weighted distribution as defined by a given weighting function. -- The function works by zipping each element with its cumulative weight, then @@ -36,11 +85,16 @@ chooseOneWeighted weight xs | any ((< 0) . weight) xs = throw $ RandomException "Probability weightings cannot be negative." | all ((== 0) . weight) xs = throw $ RandomException "At least one weighting must be positive." | otherwise = - fst . fromJust . (\i -> find ((> i) . snd) (zip xs' $ scanl1 (+) $ weight <$> xs')) <$> randomRIO (0, totalWeight - 1) + fst . fromJust . (\i -> find ((> i) . snd) (zip xs' $ scanl1 (+) $ weight <$> xs')) <$> randomRange 0 totalWeight where xs' = filter ((> 0) . weight) xs -- removes elements with a weight of zero totalWeight = sum $ weight <$> xs' +-- | Choose N elements from a list. +chooseN :: Int -> [a] -> IO [a] +chooseN _ [] = throw $ RandomException "Cannot choose from empty list." +chooseN n xs = take n <$> shuffle xs + -- | @chooseOneWeightedWithDefault@ chooses a single random element from a given -- list with weighted distribution as defined by a given weighting function, or -- a given default if the list is empty diff --git a/src/Tablebot/Utility/Search.hs b/src/Tablebot/Utility/Search.hs index 02bd0832..c0663e5e 100644 --- a/src/Tablebot/Utility/Search.hs +++ b/src/Tablebot/Utility/Search.hs @@ -11,18 +11,22 @@ module Tablebot.Utility.Search ( FuzzyCosts (..), closestMatch, closestMatchWithCosts, + sortMatchesWithCosts, closestPair, closestPairWithCosts, + sortPairsWithCosts, closestValue, closestValueWithCosts, + sortValuesWithCosts, shortestSuperString, autocomplete, ) where import Data.Char (toLower) -import Data.List (minimumBy) -import Data.Text (Text, isInfixOf, length, take) +import Data.List (minimumBy, sortBy) +import Data.Text (Text, isInfixOf, length) +import qualified Data.Text (take) import Text.EditDistance -- | @compareOn@ is a helper function for comparing types that aren't ord. @@ -72,6 +76,14 @@ closestMatchWithCosts editCosts strings query = minimumBy (compareOn score) stri score :: String -> Int score = levenshteinDistance (convertCosts editCosts) (map toLower query) +-- | @sortMatchesWithCosts@ sorts a list of strings based on how well they match +-- the query under the given costs. +sortMatchesWithCosts :: FuzzyCosts -> [String] -> String -> [String] +sortMatchesWithCosts editCosts strings query = sortBy (compareOn score) strings + where + score :: String -> Int + score = levenshteinDistance (convertCosts editCosts) (map toLower query) + -- | @closestPair@ takes a set of pairs and a query and finds the pair whose key -- most closely matches the query. closestPair :: [(String, a)] -> String -> (String, a) @@ -84,15 +96,29 @@ closestPairWithCosts editCosts pairs query = minimumBy (compareOn $ score . fst) score :: String -> Int score = levenshteinDistance (convertCosts editCosts) (map toLower query) +-- | @sortPairsWithCosts@ sorts the a list of pairs based on how well their keys +-- match the query under the given costs. +sortPairsWithCosts :: FuzzyCosts -> [(String, a)] -> String -> [(String, a)] +sortPairsWithCosts editCosts pairs query = sortBy (compareOn $ score . fst) pairs + where + score :: String -> Int + score = levenshteinDistance (convertCosts editCosts) (map toLower query) + -- | @closestValue@ is @closestPair@ but it only returns the value of the -- matched pair. closestValue :: [(String, a)] -> String -> a closestValue = closestValueWithCosts defaultFuzzyCosts --- | @closestValueWithCosts@ is @closestValue@ with customisable edit costs. +-- | @closestValueWithCosts@ finds the n-closest matches with customisable +-- edit costs. closestValueWithCosts :: FuzzyCosts -> [(String, a)] -> String -> a closestValueWithCosts editCosts pairs query = snd $ closestPairWithCosts editCosts pairs query +-- | @sortValuesWithCosts@ sorts the values of a list of pairs based on how well +-- they match the query under the given costs. +sortValuesWithCosts :: FuzzyCosts -> [(String, a)] -> String -> [a] +sortValuesWithCosts editCosts pairs query = map snd $ sortPairsWithCosts editCosts pairs query + -- | @shortestSuperString@ takes a list of strings and matches the shortest one -- that contains the given query as a strict substring. -- Note that if a string in the list is a superstring of another element of the diff --git a/src/Tablebot/Utility/SmartParser.hs b/src/Tablebot/Utility/SmartParser.hs index bcaf6b5f..a190378e 100644 --- a/src/Tablebot/Utility/SmartParser.hs +++ b/src/Tablebot/Utility/SmartParser.hs @@ -172,6 +172,19 @@ newtype RestOfInput1 a = ROI1 a instance IsString a => CanParse (RestOfInput1 a) where pars = ROI1 . fromString <$> untilEnd1 +-- | @PosInteger a@ parses a sequence of digits with no preceeding "-" as an int. +newtype PosInt = PosInt Int + +instance CanParse PosInt where + pars = PosInt <$> posInteger + +-- | @NonNegativeInt a@ parses a non-zero sequence of digits with no preceeding +-- "-" as an int. +newtype NonNegativeInt = NonNegativeInt Int + +instance CanParse NonNegativeInt where + pars = NonNegativeInt <$> nonNegativeInteger + -- | @noArguments@ is a type-specific alias for @parseComm@ for commands that -- have no arguments (thus making it extremely clear). noArguments :: (Message -> EnvDatabaseDiscord d ()) -> Parser (Message -> EnvDatabaseDiscord d ()) diff --git a/src/Tablebot/Utility/Types.hs b/src/Tablebot/Utility/Types.hs index 2bb83d11..1ecb06d6 100644 --- a/src/Tablebot/Utility/Types.hs +++ b/src/Tablebot/Utility/Types.hs @@ -12,10 +12,10 @@ -- database and Discord operations within your features. module Tablebot.Utility.Types where -import Control.Concurrent.MVar (MVar) import Control.Monad.Trans.Class (lift) import Control.Monad.Trans.Reader (ReaderT) import Data.Char (toLower) +import Data.Default (Default (..)) import Data.Map (Map) import Data.Maybe (fromMaybe) import Data.Text (Text) @@ -34,6 +34,7 @@ import Discord.Types import Safe.Exact (dropExactMay, takeExactMay) import Text.Megaparsec (Parsec) import Text.Read (readMaybe) +import UnliftIO (TVar) -- * DatabaseDiscord @@ -44,7 +45,7 @@ import Text.Read (readMaybe) -- -- "Tablebot.Plugin.Discord" provides some helper functions for -- running Discord operations without excessive use of @lift@. -type EnvDatabaseDiscord d = ReaderT d (ReaderT (MVar TablebotCache) (SqlPersistT DiscordHandler)) +type EnvDatabaseDiscord d = ReaderT d (ReaderT (TVar TablebotCache) (SqlPersistT DiscordHandler)) type DatabaseDiscord = EnvDatabaseDiscord () @@ -54,7 +55,8 @@ type Database d = SqlPersistM d data TablebotCache = TCache { cacheKnownEmoji :: Map Text Emoji, - cacheVersionInfo :: VersionInfo + cacheVersionInfo :: VersionInfo, + cacheBotInfo :: BotConfig } data VersionInfo = VInfo @@ -68,13 +70,13 @@ data VersionInfo = VInfo -- | A simple definition for parsers on Text. type Parser = Parsec Void Text -liftCache :: ReaderT (MVar TablebotCache) (SqlPersistT DiscordHandler) a -> ReaderT d (ReaderT (MVar TablebotCache) (SqlPersistT DiscordHandler)) a +liftCache :: ReaderT (TVar TablebotCache) (SqlPersistT DiscordHandler) a -> EnvDatabaseDiscord d a liftCache = lift -liftSql :: SqlPersistT DiscordHandler a -> ReaderT d (ReaderT (MVar TablebotCache) (SqlPersistT DiscordHandler)) a +liftSql :: SqlPersistT DiscordHandler a -> EnvDatabaseDiscord d a liftSql = lift . lift -liftDiscord :: DiscordHandler a -> ReaderT d (ReaderT (MVar TablebotCache) (SqlPersistT DiscordHandler)) a +liftDiscord :: DiscordHandler a -> EnvDatabaseDiscord d a liftDiscord = lift . lift . lift -- * Features @@ -324,3 +326,21 @@ plug name' = Pl name' (StartUp (return ())) [] [] [] [] [] [] [] [] [] envPlug :: Text -> StartUp d -> EnvPlugin d envPlug name' startup = Pl name' startup [] [] [] [] [] [] [] [] [] + +-- * Configuration type + +-- Allows others to configure the bot. + +data BotConfig = BotConfig + { rootHelpText :: Text, + gamePlaying :: Text, + botName :: Text + } + +instance Default BotConfig where + def = + BotConfig + { rootHelpText = "This bot is built off the Tablebot framework ().", + gamePlaying = "Kirby: Planet Robobot", + botName = "Tablebot" + } diff --git a/tablebot.cabal b/tablebot.cabal new file mode 100644 index 00000000..48878f1b --- /dev/null +++ b/tablebot.cabal @@ -0,0 +1,291 @@ +cabal-version: 1.12 + +-- This file is not autogenerated. + +name: tablebot +version: 0.3.3 +description: Please see the README on GitHub at +homepage: https://github.com/WarwickTabletop/tablebot#readme +bug-reports: https://github.com/WarwickTabletop/tablebot/issues +author: Warwick Tabletop +maintainer: tagarople@gmail.com +copyright: 2021 Warwick Tabletop +license: MIT +license-file: LICENSE +build-type: Simple +extra-source-files: + README.md + ChangeLog.md + +source-repository head + type: git + location: https://github.com/WarwickTabletop/tablebot + +library + exposed-modules: + Tablebot + Tablebot.Handler + Tablebot.Internal.Administration + Tablebot.Internal.Cache + Tablebot.Internal.Embed + Tablebot.Internal.Handler.Command + Tablebot.Internal.Handler.Event + Tablebot.Internal.Permission + Tablebot.Internal.Plugins + Tablebot.Internal.Types + Tablebot.Plugins + Tablebot.Plugins.Administration + Tablebot.Plugins.Basic + Tablebot.Plugins.Cats + Tablebot.Plugins.Dogs + Tablebot.Plugins.Flip + Tablebot.Plugins.Fox + Tablebot.Plugins.Netrunner + Tablebot.Plugins.Netrunner.Command.BanList + Tablebot.Plugins.Netrunner.Command.Custom + Tablebot.Plugins.Netrunner.Command.Find + Tablebot.Plugins.Netrunner.Command.Help + Tablebot.Plugins.Netrunner.Command.Rules + Tablebot.Plugins.Netrunner.Command.Search + Tablebot.Plugins.Netrunner.Plugin + Tablebot.Plugins.Netrunner.Type.BanList + Tablebot.Plugins.Netrunner.Type.Card + Tablebot.Plugins.Netrunner.Type.Cycle + Tablebot.Plugins.Netrunner.Type.Faction + Tablebot.Plugins.Netrunner.Type.NrApi + Tablebot.Plugins.Netrunner.Type.Pack + Tablebot.Plugins.Netrunner.Type.Type + Tablebot.Plugins.Netrunner.Utility.BanList + Tablebot.Plugins.Netrunner.Utility.Card + Tablebot.Plugins.Netrunner.Utility.Cycle + Tablebot.Plugins.Netrunner.Utility.Embed + Tablebot.Plugins.Netrunner.Utility.Faction + Tablebot.Plugins.Netrunner.Utility.Misc + Tablebot.Plugins.Netrunner.Utility.NrApi + Tablebot.Plugins.Netrunner.Utility.Pack + Tablebot.Plugins.Ping + Tablebot.Plugins.Quote + Tablebot.Plugins.Reminder + Tablebot.Plugins.Roll + Tablebot.Plugins.Roll.Dice + Tablebot.Plugins.Roll.Dice.DiceData + Tablebot.Plugins.Roll.Dice.DiceEval + Tablebot.Plugins.Roll.Dice.DiceFunctions + Tablebot.Plugins.Roll.Dice.DiceParsing + Tablebot.Plugins.Roll.Dice.DiceStats + Tablebot.Plugins.Roll.Dice.DiceStatsBase + Tablebot.Plugins.Roll.Plugin + Tablebot.Plugins.Say + Tablebot.Plugins.Shibe + Tablebot.Plugins.Suggest + Tablebot.Plugins.Welcome + Tablebot.Utility + Tablebot.Utility.Database + Tablebot.Utility.Discord + Tablebot.Utility.Embed + Tablebot.Utility.Exception + Tablebot.Utility.Help + Tablebot.Utility.Parser + Tablebot.Utility.Permission + Tablebot.Utility.Random + Tablebot.Utility.Search + Tablebot.Utility.SmartParser + Tablebot.Utility.Types + Tablebot.Utility.Utils + other-modules: + Paths_tablebot + hs-source-dirs: + src + default-extensions: + OverloadedStrings + LambdaCase + EmptyDataDecls + FlexibleContexts + GADTs + GeneralizedNewtypeDeriving + MultiParamTypeClasses + QuasiQuotes + TemplateHaskell + TypeFamilies + DerivingStrategies + StandaloneDeriving + UndecidableInstances + DataKinds + FlexibleInstances + DeriveGeneric + TypeApplications + MultiWayIf + TupleSections + ConstraintKinds + RecordWildCards + ScopedTypeVariables + TypeOperators + RankNTypes + BangPatterns + ghc-options: -Wall + build-depends: + Chart + , Chart-diagrams + , JuicyPixels + , aeson + , base >=4.7 && <5 + , bytestring + , containers + , data-default + , diagrams-core + , diagrams-lib + , diagrams-rasterific + , discord-haskell + , distribution + , duckling + , edit-distance + , emoji + , esqueleto + , exception-transformers + , extra + , http-client + , http-conduit + , load-env + , megaparsec + , monad-logger + , mtl + , persistent + , persistent-sqlite + , persistent-template + , process + , random + , raw-strings-qq + , regex-pcre + , req + , resource-pool + , resourcet + , safe + , split + , template-haskell + , text + , text-icu + , th-printf + , time + , timezone-olson + , transformers + , unliftio + , unordered-containers + , yaml + default-language: Haskell2010 + +executable tablebot-exe + main-is: Main.hs + other-modules: + Paths_tablebot + hs-source-dirs: + app + ghc-options: -threaded -rtsopts -with-rtsopts=-N + build-depends: + Chart + , Chart-diagrams + , JuicyPixels + , aeson + , base >=4.7 && <5 + , bytestring + , containers + , data-default + , diagrams-core + , diagrams-lib + , diagrams-rasterific + , discord-haskell + , distribution + , duckling + , edit-distance + , emoji + , esqueleto + , exception-transformers + , extra + , http-client + , http-conduit + , load-env + , megaparsec + , monad-logger + , mtl + , persistent + , persistent-sqlite + , persistent-template + , process + , random + , raw-strings-qq + , regex-pcre + , req + , resource-pool + , resourcet + , safe + , split + , tablebot + , template-haskell + , text + , text-icu + , th-printf + , time + , timezone-olson + , transformers + , unliftio + , unordered-containers + , yaml + default-language: Haskell2010 + +test-suite tablebot-test + type: exitcode-stdio-1.0 + main-is: Spec.hs + other-modules: + Paths_tablebot + hs-source-dirs: + test + ghc-options: -threaded -rtsopts -with-rtsopts=-N + build-depends: + Chart + , Chart-diagrams + , JuicyPixels + , aeson + , base >=4.7 && <5 + , bytestring + , containers + , data-default + , diagrams-core + , diagrams-lib + , diagrams-rasterific + , discord-haskell + , distribution + , duckling + , edit-distance + , emoji + , esqueleto + , exception-transformers + , extra + , http-client + , http-conduit + , load-env + , megaparsec + , monad-logger + , mtl + , persistent + , persistent-sqlite + , persistent-template + , process + , random + , raw-strings-qq + , regex-pcre + , req + , resource-pool + , resourcet + , safe + , split + , tablebot + , template-haskell + , text + , text-icu + , th-printf + , time + , timezone-olson + , transformers + , unliftio + , unordered-containers + , yaml + default-language: Haskell2010