Antoine COMBET
3 years ago
7 changed files with 620 additions and 104 deletions
-
278app/Commands.hs
-
0app/Commands.hsCommands.hs
-
174app/Commands/EDT.hs
-
106app/Conf.hs
-
134app/Main.hs
-
17botiut.cabal
-
15stack.yaml
@ -1,5 +1,171 @@ |
|||||
module Commands.EDT where |
|
||||
|
{-# LANGUAGE RecordWildCards #-} |
||||
|
{-# LANGUAGE OverloadedStrings #-} |
||||
|
{-# LANGUAGE DataKinds #-} |
||||
|
{-# OPTIONS_GHC -Wno-deferred-out-of-scope-variables #-} |
||||
|
|
||||
import qualified Data.Map.Strict as Map |
|
||||
import qualified Text.ICalendar.Parser as IP |
|
||||
import qualified Text.ICalendar.Types as IT |
|
||||
|
module Commands.EDT (getEdt) where |
||||
|
|
||||
|
import qualified Data.Map.Strict as Map |
||||
|
import qualified Text.ICalendar.Parser as IP |
||||
|
import qualified Text.ICalendar.Types as IT |
||||
|
import qualified Data.Text as TS |
||||
|
import qualified Data.Text.Lazy.Encoding as TE |
||||
|
import qualified Data.CaseInsensitive as CI |
||||
|
import qualified Data.Text.Lazy as T |
||||
|
import qualified Text.URI as Uri |
||||
|
import qualified Network.HTTP.Simple as HTTP |
||||
|
import Conf ( Group(..), Config(..) ) |
||||
|
import Data.Maybe ( fromMaybe ) |
||||
|
import Control.Monad.IO.Class ( MonadIO, liftIO ) |
||||
|
import qualified Data.Time as DT |
||||
|
import qualified Data.Time.Calendar.Easter as DT |
||||
|
import Data.List ( sortOn ) |
||||
|
import Text.Printf ( printf ) |
||||
|
import qualified Text.Regex.PCRE as Re |
||||
|
import qualified Data.Array as Ar |
||||
|
|
||||
|
getEdt ::Config |
||||
|
-> Map.Map TS.Text TS.Text |
||||
|
-> IO TS.Text |
||||
|
getEdt conf@Config{..} opts = do |
||||
|
cal_req <- HTTP.httpLBS req |
||||
|
let cal = IP.parseICalendar (IP.DecodingFunctions |
||||
|
TE.decodeUtf8 |
||||
|
(CI.mk . TE.decodeUtf8) |
||||
|
) (TS.unpack group) (HTTP.getResponseBody cal_req) |
||||
|
dates <-readDate date |
||||
|
tz <- DT.getTimeZone |
||||
|
$ DT.UTCTime (head dates) |
||||
|
$ DT.secondsToDiffTime 43200 |
||||
|
let message = case cal of |
||||
|
Left _ -> "I don't know this group, alternatively ADE doesn't work" |
||||
|
Right (vcal@IT.VCalendar{IT.vcEvents = evm}:_, _) -> |
||||
|
TS.unlines . map (\d -> |
||||
|
if not (null (ev d)) then |
||||
|
"**" |
||||
|
`TS.append` TS.pack (show d) |
||||
|
`TS.append` "**\n" |
||||
|
`TS.append` renderEvents tz (ev d) |
||||
|
else "" |
||||
|
) |
||||
|
$ dates |
||||
|
where |
||||
|
ev :: DT.Day -> [IT.VEvent] |
||||
|
ev d = |
||||
|
sortOn IT.veDTStart |
||||
|
. filter (inDate d) |
||||
|
. map snd |
||||
|
. Map.toList |
||||
|
$ evm |
||||
|
_ -> "An unexpected error has occured" |
||||
|
return message |
||||
|
where |
||||
|
group :: TS.Text |
||||
|
group = fromMaybe "" $ Map.lookup "group" opts |
||||
|
date :: TS.Text |
||||
|
date = fromMaybe "week" $ Map.lookup "day" opts |
||||
|
url :: String |
||||
|
url = |
||||
|
TS.unpack |
||||
|
. groupAde |
||||
|
. fromMaybe (Group 0 0 dummyAddress) |
||||
|
. (`Map.lookup` configGroups) |
||||
|
. fromMaybe "" |
||||
|
. Map.lookup "group" |
||||
|
$ opts |
||||
|
req = HTTP.parseRequest_ url |
||||
|
|
||||
|
-- not a place where you'll find valid ICS so we know the group must be wrong |
||||
|
dummyAddress :: TS.Text |
||||
|
dummyAddress = "https://example.com" |
||||
|
|
||||
|
readDate :: TS.Text -> IO [DT.Day] |
||||
|
readDate dtT = do |
||||
|
DT.UTCTime today _ <- DT.getCurrentTime |
||||
|
return $ case TS.unpack dtT of |
||||
|
"today" -> [today] |
||||
|
"tomorrow" -> [DT.addDays 1 today] |
||||
|
"week" -> map (`DT.addDays` lastSunday) [1..7] where |
||||
|
lastweek :: DT.Day |
||||
|
lastweek = DT.addDays (-6) today |
||||
|
lastSunday = DT.sundayAfter lastweek |
||||
|
[dd, du, '/', md, mu, '/', yk, yh, yd, yu] -> |
||||
|
[ DT.fromGregorian |
||||
|
(read [yk,yh,yd,yu]) |
||||
|
(read [md,mu]) |
||||
|
(read [dd,du]) |
||||
|
] |
||||
|
_ -> [today] |
||||
|
|
||||
|
inDate :: DT.Day -> IT.VEvent -> Bool |
||||
|
inDate date ev@IT.VEvent{veDTStart = mdts} = evDay == date |
||||
|
where |
||||
|
evDay :: DT.Day |
||||
|
evDay = case mdts of |
||||
|
Just dts -> |
||||
|
case dts of |
||||
|
IT.DTStartDateTime dt _ -> |
||||
|
case dt of |
||||
|
IT.FloatingDateTime lt -> DT.localDay lt |
||||
|
IT.UTCDateTime ut -> DT.utctDay ut |
||||
|
IT.ZonedDateTime lt _ -> DT.localDay lt |
||||
|
IT.DTStartDate da _ -> IT.dateValue da |
||||
|
Nothing -> DT.fromGregorian 0 0 0 |
||||
|
|
||||
|
renderEvents :: DT.TimeZone -> [IT.VEvent] -> TS.Text |
||||
|
renderEvents tz = TS.unlines . map renderEvent |
||||
|
where |
||||
|
renderEvent :: IT.VEvent -> TS.Text |
||||
|
renderEvent ev = |
||||
|
TS.pack |
||||
|
$ printf |
||||
|
"*%02d:%02d → %02d:%02d* : **%s** with **%s** in **%s**" |
||||
|
(DT.todHour $ startT ev) (DT.todMin $ startT ev) |
||||
|
(DT.todHour $ endT ev) (DT.todMin $ endT ev) |
||||
|
(summary ev) (teacher ev) (room ev) |
||||
|
|
||||
|
startT :: IT.VEvent -> DT.TimeOfDay |
||||
|
startT IT.VEvent{veDTStart = Just (IT.DTStartDateTime dt _)} = case dt of |
||||
|
IT.FloatingDateTime lt -> DT.localTimeOfDay lt |
||||
|
IT.UTCDateTime ut -> |
||||
|
snd |
||||
|
. DT.utcToLocalTimeOfDay tz |
||||
|
. DT.timeToTimeOfDay |
||||
|
$ DT.utctDayTime ut |
||||
|
IT.ZonedDateTime lt _ -> DT.localTimeOfDay lt |
||||
|
startT _ = DT.TimeOfDay 0 0 0 |
||||
|
endT :: IT.VEvent -> DT.TimeOfDay |
||||
|
endT IT.VEvent{veDTEndDuration = Just (Left (IT.DTEndDateTime dt _))} = |
||||
|
case dt of |
||||
|
IT.FloatingDateTime lt -> DT.localTimeOfDay lt |
||||
|
IT.UTCDateTime ut -> |
||||
|
snd |
||||
|
. DT.utcToLocalTimeOfDay tz |
||||
|
. DT.timeToTimeOfDay |
||||
|
$ DT.utctDayTime ut |
||||
|
IT.ZonedDateTime lt txt -> DT.localTimeOfDay lt |
||||
|
endT _ = DT.TimeOfDay 0 0 0 |
||||
|
summary IT.VEvent{veSummary = Just IT.Summary{summaryValue = x}} = x |
||||
|
summary _ = "Unknown" |
||||
|
room IT.VEvent{veLocation = Just IT.Location{locationValue = x}} = x |
||||
|
room _ = "Unknown" |
||||
|
teacher :: IT.VEvent -> String |
||||
|
teacher IT.VEvent{veDescription = Just IT.Description{descriptionValue = val}} = |
||||
|
if '\n' `elem` teacher' then |
||||
|
"Unknown" |
||||
|
else |
||||
|
teacher' |
||||
|
where |
||||
|
teacher' = |
||||
|
if uncurry (>) (Ar.bounds marray) then |
||||
|
"Unknown" |
||||
|
else |
||||
|
fst . last . Ar.elems $ marray |
||||
|
|
||||
|
marray = if null matches then Ar.listArray (1,0) [] else head matches |
||||
|
matches = Re.matchAllText teacherRe $ T.unpack val |
||||
|
teacher _ = "Unknown" |
||||
|
teacherRe = Re.makeRegexOpts |
||||
|
Re.compMultiline |
||||
|
Re.execBlank |
||||
|
("^\\n\\n.+\\n(.+)\\n" :: String) |
@ -0,0 +1,106 @@ |
|||||
|
{-# LANGUAGE OverloadedStrings #-} |
||||
|
{-# LANGUAGE LambdaCase #-} |
||||
|
|
||||
|
module Conf ( Group(..) , Config(..) , readConfig ) where |
||||
|
|
||||
|
import qualified Data.Text as T |
||||
|
import qualified Discord.Types as D |
||||
|
import Data.Yaml ( Object |
||||
|
, decodeFileThrow |
||||
|
, (.:) |
||||
|
, parseMaybe |
||||
|
, Parser |
||||
|
, withObject |
||||
|
, FromJSON |
||||
|
, Value (Object, Number, String) |
||||
|
, parseJSON |
||||
|
) |
||||
|
import qualified Data.HashMap.Strict as H |
||||
|
import qualified Data.Map.Strict as Map |
||||
|
import Data.Maybe (fromMaybe) |
||||
|
import Control.Monad (mzero) |
||||
|
|
||||
|
import Data.Word (Word64) |
||||
|
import Data.Scientific (Scientific, floatingOrInteger) |
||||
|
import Data.Either (fromRight) |
||||
|
|
||||
|
data Group = Group |
||||
|
{ groupChannel :: D.Snowflake |
||||
|
, groupRole :: D.Snowflake |
||||
|
, groupAde :: T.Text |
||||
|
} |
||||
|
deriving (Show, Eq, Read) |
||||
|
|
||||
|
hmToGroup :: H.HashMap T.Text Value -> Group |
||||
|
hmToGroup hm = Group |
||||
|
{ groupChannel = scitosno |
||||
|
. valueNum "channel" |
||||
|
. errorRequired "channel" |
||||
|
$ H.lookup "channel" hm |
||||
|
, groupRole = scitosno |
||||
|
. valueNum "role" |
||||
|
. errorRequired "role" |
||||
|
$ H.lookup "role" hm |
||||
|
, groupAde = valueText "edturl" |
||||
|
. errorRequired "edturl" |
||||
|
$ H.lookup "edturl" hm |
||||
|
} |
||||
|
|
||||
|
data Config = Config |
||||
|
{ configServer :: D.Snowflake |
||||
|
, configGroups :: Map.Map T.Text Group |
||||
|
, configAutoEDTCronDay :: T.Text |
||||
|
, configAutoEDTCronWeek :: T.Text |
||||
|
} |
||||
|
deriving (Show, Eq, Read) |
||||
|
|
||||
|
readConfig :: FilePath -> IO Config |
||||
|
readConfig fp = do |
||||
|
conf_yml <- decodeFileThrow fp :: IO Object |
||||
|
-- print conf_yml |
||||
|
return $ ymlToConf conf_yml |
||||
|
|
||||
|
ymlToConf :: Object -> Config |
||||
|
ymlToConf v = Config |
||||
|
{ configServer = server |
||||
|
, configGroups = groups |
||||
|
, configAutoEDTCronDay = autoEdtCronDay |
||||
|
, configAutoEDTCronWeek = autoEdtCronWeek |
||||
|
} |
||||
|
where |
||||
|
server' = errorRequired "server" $ H.lookup "server" v |
||||
|
server = scitosno $ valueNum "server" server' |
||||
|
groups = parseGroups groupsObject |
||||
|
groupsObject = H.lookup "groups" v |
||||
|
parseGroups (Just (Object o)) = let groupList' :: H.HashMap T.Text Group |
||||
|
groupList' = fmap ( hmToGroup |
||||
|
. (\case |
||||
|
Object u -> u |
||||
|
_ -> error "groups are objects") |
||||
|
) o |
||||
|
in Map.fromList $ H.toList groupList' |
||||
|
parseGroups _ = error "Wrong format for groups" |
||||
|
autoEdtCronDay = |
||||
|
valueText "autoEdtCronDay" |
||||
|
$ fromMaybe "0 15 * * 0-4" |
||||
|
$ H.lookup "autoEdtCronDay" v |
||||
|
autoEdtCronWeek = |
||||
|
valueText "autoEdtCronWeek" |
||||
|
$ fromMaybe "30 9 * * 0" |
||||
|
$ H.lookup "autoEdtCronWeek" v |
||||
|
|
||||
|
|
||||
|
errorRequired :: [Char] -> Maybe a -> a |
||||
|
errorRequired name = fromMaybe (error $ "required field: " ++ name) |
||||
|
valueText :: [Char] -> Value -> T.Text |
||||
|
valueText _ (String t) = t |
||||
|
valueText name _ = error $ name ++ " should be a string" |
||||
|
valueNum :: [Char] -> Value -> Scientific |
||||
|
valueNum _ (Number n) = n |
||||
|
valueNum name _ = error $ name ++ " should be an integer" |
||||
|
|
||||
|
scitosno :: Scientific -> D.Snowflake |
||||
|
scitosno = D.Snowflake |
||||
|
. (fromInteger :: Integer -> Word64) |
||||
|
. fromRight (error "Cant read that as an int") |
||||
|
. floatingOrInteger |
@ -1,80 +1,134 @@ |
|||||
{-# LANGUAGE OverloadedStrings #-} |
{-# LANGUAGE OverloadedStrings #-} |
||||
{-# LANGUAGE RecordWildCards #-} |
{-# LANGUAGE RecordWildCards #-} |
||||
|
{-# LANGUAGE ScopedTypeVariables #-} |
||||
|
|
||||
module Main where |
module Main where |
||||
|
|
||||
import Control.Monad (when, void) |
|
||||
import qualified Data.Text as T |
|
||||
import qualified Data.Text.IO as TIO |
|
||||
import UnliftIO (liftIO) |
|
||||
import Discord |
|
||||
import Discord.Types |
|
||||
import Discord.Interactions |
|
||||
import qualified Discord.Requests as R |
|
||||
import Commands |
|
||||
import qualified Data.ByteString as BS |
|
||||
import qualified Data.Yaml as YAML |
|
||||
import qualified Data.HashMap.Strict as Map |
|
||||
|
import Control.Monad (when, void) |
||||
|
import qualified Data.Text as T |
||||
|
import qualified Data.Text.IO as TIO |
||||
|
import Control.Monad.IO.Unlift (toIO, liftIO, withRunInIO) |
||||
|
import Discord ( def, restCall |
||||
|
, runDiscord, sendCommand |
||||
|
, DiscordHandler |
||||
|
, RunDiscordOpts(..) |
||||
|
) |
||||
|
import Discord.Types ( Event(..), PartialApplication(..) |
||||
|
, GatewaySendable(..) |
||||
|
, UpdateStatusOpts(..) |
||||
|
, UpdateStatusType(..) |
||||
|
, Activity(..), ActivityType(..) |
||||
|
) |
||||
|
import Discord.Interactions ( interactionResponseBasic |
||||
|
, Interaction(..) |
||||
|
, InteractionDataApplicationCommand(..) |
||||
|
) |
||||
|
import qualified Discord.Requests as R |
||||
|
import Commands ( edtResponse, edtCommand |
||||
|
, pingResponse, pingCommand |
||||
|
, remindResponse, remindCommand, groupCommand, groupResponse |
||||
|
) |
||||
|
import Conf ( Config(..), Group(..), readConfig ) |
||||
|
import qualified System.Cron.Schedule as Cron |
||||
|
import qualified Data.Map.Strict as Map |
||||
|
import Commands.EDT (getEdt) |
||||
|
import qualified Control.Concurrent |
||||
|
import qualified Control.Event as E |
||||
|
|
||||
testServer :: Snowflake |
|
||||
testServer = 740862954454646814 |
|
||||
|
|
||||
main :: IO () |
main :: IO () |
||||
main = do |
main = do |
||||
tok <- TIO.readFile "./auth.secret" |
tok <- TIO.readFile "./auth.secret" |
||||
conf <- YAML.decodeFileThrow "./conf.yaml" :: IO YAML.Value |
|
||||
putStrLn $ show conf |
|
||||
|
conf <- readConfig "./conf.yaml" |
||||
|
eventSystem <- E.initEventSystem |
||||
|
print conf |
||||
err <- runDiscord $ def { discordToken = tok |
err <- runDiscord $ def { discordToken = tok |
||||
, discordOnStart = onDiscordStart conf |
, discordOnStart = onDiscordStart conf |
||||
, discordOnEnd = liftIO $ putStrLn "Ended" |
, discordOnEnd = liftIO $ putStrLn "Ended" |
||||
, discordOnEvent = onDiscordEvent conf |
|
||||
|
, discordOnEvent = onDiscordEvent conf eventSystem |
||||
, discordOnLog = |
, discordOnLog = |
||||
\s -> TIO.putStrLn s >> TIO.putStrLn "" |
\s -> TIO.putStrLn s >> TIO.putStrLn "" |
||||
} |
} |
||||
TIO.putStrLn err |
TIO.putStrLn err |
||||
|
|
||||
onDiscordStart :: YAML.Value -> DiscordHandler () |
|
||||
|
onDiscordStart :: Config -> DiscordHandler () |
||||
onDiscordStart conf = do |
onDiscordStart conf = do |
||||
let activity = Activity { activityName = "Doing stuff" |
|
||||
, activityType = ActivityTypeGame |
|
||||
, activityUrl = Nothing |
|
||||
} |
|
||||
let opts = UpdateStatusOpts { updateStatusOptsSince = Nothing |
|
||||
, updateStatusOptsGame = Just activity |
|
||||
, updateStatusOptsNewStatus = UpdateStatusOnline |
|
||||
, updateStatusOptsAFK = False |
|
||||
} |
|
||||
|
let |
||||
|
activity :: Activity |
||||
|
activity = def { activityName = "Doing stuff" |
||||
|
, activityType = ActivityTypeGame |
||||
|
, activityUrl = Nothing |
||||
|
} |
||||
|
opts :: UpdateStatusOpts |
||||
|
opts = UpdateStatusOpts { updateStatusOptsSince = Nothing |
||||
|
, updateStatusOptsGame = Just activity |
||||
|
, updateStatusOptsNewStatus = UpdateStatusOnline |
||||
|
, updateStatusOptsAFK = False |
||||
|
} |
||||
sendCommand (UpdateStatus opts) |
sendCommand (UpdateStatus opts) |
||||
|
|
||||
|
|
||||
onDiscordEvent :: YAML.Value -> Event -> DiscordHandler () |
|
||||
onDiscordEvent conf (Ready _ _ _ _ _ _ (PartialApplication i _)) = |
|
||||
|
onDiscordEvent :: Config -> E.EventSystem -> Event -> DiscordHandler () |
||||
|
onDiscordEvent conf@Config{..} eventSystem (Ready _ _ _ _ _ _ (PartialApplication i _)) = do |
||||
mapM_ (maybe ( return () ) |
mapM_ (maybe ( return () ) |
||||
( void |
( void |
||||
. restCall |
. restCall |
||||
. R.CreateGuildApplicationCommand i testServer |
|
||||
|
. R.CreateGuildApplicationCommand i configServer |
||||
) |
) |
||||
) |
) |
||||
[ Just pingCommand |
|
||||
, Just edtCommand |
|
||||
|
[ pingCommand |
||||
|
, edtCommand conf |
||||
|
, remindCommand |
||||
|
, groupCommand conf |
||||
] |
] |
||||
onDiscordEvent conf |
|
||||
|
let glist = Map.toList configGroups |
||||
|
withRunInIO $ \runInIO -> Cron.execSchedule $ do |
||||
|
Cron.addJob ( runInIO |
||||
|
$ pushgroupedt conf "tomorrow" glist |
||||
|
) configAutoEDTCronDay |
||||
|
Cron.addJob ( runInIO |
||||
|
$ pushgroupedt conf "week" glist |
||||
|
) configAutoEDTCronWeek |
||||
|
liftIO $ putStrLn "Started" |
||||
|
onDiscordEvent conf@Config{..} eventSystem |
||||
( InteractionCreate InteractionApplicationCommand |
( InteractionCreate InteractionApplicationCommand |
||||
{ interactionDataApplicationCommand = |
{ interactionDataApplicationCommand = |
||||
Just InteractionDataApplicationCommandChatInput |
|
||||
|
InteractionDataApplicationCommandChatInput |
||||
{ interactionDataApplicationCommandName = name |
{ interactionDataApplicationCommandName = name |
||||
, interactionDataApplicationCommandOptions = opts |
, interactionDataApplicationCommandOptions = opts |
||||
, .. |
, .. |
||||
} |
} |
||||
|
, interactionChannelId = Just channel |
||||
|
, interactionGuildId = Just guild |
||||
|
, interactionUser = user |
||||
, .. |
, .. |
||||
} |
} |
||||
) = do |
) = do |
||||
void $ restCall |
|
||||
|
response <- responseIO |
||||
|
void $ restCall |
||||
(R.CreateInteractionResponse interactionId interactionToken response) |
(R.CreateInteractionResponse interactionId interactionToken response) |
||||
where |
where |
||||
response = case name of |
|
||||
"ping" -> pingResponse |
|
||||
"edt" -> edtResponse opts |
|
||||
_ -> interactionResponseBasic $ "Unhandled Command: " `T.append` name |
|
||||
onDiscordEvent _ _ = return () |
|
||||
|
responseIO = case name of |
||||
|
"ping" -> liftIO $ pingResponse conf |
||||
|
"edt" -> liftIO $ edtResponse conf opts |
||||
|
"remind" -> remindResponse opts eventSystem channel user |
||||
|
"group" -> groupResponse conf user guild opts |
||||
|
_ -> return $ interactionResponseBasic $ "Unhandled Command: " `T.append` name |
||||
|
onDiscordEvent _ _ _ = return () |
||||
|
|
||||
|
pushgroupedt :: Config -> T.Text ->[(T.Text, Group)] -> DiscordHandler () |
||||
|
pushgroupedt conf@Config{..} day glist = do |
||||
|
mapM_ (\(gn, Group{groupChannel = gc}) -> do |
||||
|
edt <- liftIO $ getEdt conf $ Map.fromList [("group", gn), ("day", day )] |
||||
|
liftIO |
||||
|
$ TIO.putStrLn |
||||
|
$ "Putting out time table for group " |
||||
|
`T.append` gn |
||||
|
`T.append` " in " |
||||
|
`T.append` T.pack (show gc) |
||||
|
`T.append` ":\n" |
||||
|
`T.append` edt |
||||
|
restCall $ R.CreateMessage gc (edt `T.append` "\nAutomatic time table") |
||||
|
return () |
||||
|
) glist |
||||
|
|
@ -1,14 +1,17 @@ |
|||||
resolver: lts-18.24 |
|
||||
|
resolver: lts-18.27 |
||||
packages: |
packages: |
||||
- . |
- . |
||||
allow-newer: true |
allow-newer: true |
||||
extra-deps: |
extra-deps: |
||||
# Stuff not in stackage |
# Stuff not in stackage |
||||
- emoji-0.1.0.2 |
- emoji-0.1.0.2 |
||||
- control-event-1.3 |
|
||||
- iCalendar-0.4.0.5 |
- iCalendar-0.4.0.5 |
||||
- mime-0.4.0.2 |
- mime-0.4.0.2 |
||||
# My fork of discord-haskell to fix a bug |
|
||||
- github: Annwan/discord-haskell |
|
||||
commit: 830e3a0bcc2586e40e167a1ec14e357e6396a7d2 |
|
||||
|
|
||||
|
- discord-haskell-1.12.1 |
||||
|
- control-event-1.3 |
||||
|
## My fork of discord-haskell to PR bug fixes and have them before merge |
||||
|
# - github: Annwan/discord-haskell |
||||
|
# commit: <insert pactch commit> |
||||
|
# discord-haskell repo when there is a bug in hackage |
||||
|
# - github: aquarial/discord-haskell |
||||
|
# commit: <insert master commit> |
Write
Preview
Loading…
Cancel
Save
Reference in new issue