glirc-2.41: src/Client/Commands/Chat.hs
{-# Language BangPatterns, BlockArguments, OverloadedStrings, TemplateHaskell #-}
{-|
Module : Client.Commands.Chat
Description : Common user IRC commands
Copyright : (c) Eric Mertens, 2016-2020
License : ISC
Maintainer : emertens@gmail.com
-}
module Client.Commands.Chat (chatCommands, chatCommand', executeChat, cmdCtcp) where
import Client.Commands.Arguments.Spec
import Client.Commands.TabCompletion
import Client.Commands.Types
import Client.Commands.Window (parseFocus)
import Client.Message
import Client.State
import Client.State.Channel (chanJoined)
import Client.State.Extensions (clientChatExtension)
import Client.State.Focus (focusNetwork, Focus(ChannelFocus), Subfocus(FocusInfo, FocusUsers))
import Client.State.Network
import Control.Applicative (liftA2, liftA3)
import Control.Lens (filteredBy, has, ix, view, preview, views)
import Control.Monad (when, unless)
import Data.ByteString qualified as B
import Data.Char (toUpper)
import Data.Foldable (foldl', traverse_)
import Data.HashMap.Strict qualified as HashMap
import Data.List.NonEmpty (NonEmpty((:|)))
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Text
import Data.Time (getZonedTime)
import Irc.Commands
import Irc.Identifier (Identifier, idText, mkId)
import Irc.Message (IrcMsg(Privmsg, Notice, Ctcp), Source(Source))
import Irc.RawIrcMsg (RawIrcMsg, parseRawIrcMsg)
import Client.Commands.Docs (chatDocs, cmdDoc)
chatCommands :: CommandSection
chatCommands = CommandSection "IRC commands"
------------------------------------------------------------------------
[ Command
("join" :| ["j"])
(liftA2 (,) (simpleToken "channels") (optionalArg (simpleToken "[keys]")))
$(chatDocs `cmdDoc` "join")
$ NetworkCommand cmdJoin simpleNetworkTab
, Command
(pure "part")
(remainingArg "reason")
$(chatDocs `cmdDoc` "part")
$ ChannelCommand cmdPart simpleChannelTab
, Command
(pure "msg")
(liftA2 (,) (simpleToken "target") (remainingArg "message"))
$(chatDocs `cmdDoc` "msg")
$ NetworkCommand cmdMsg simpleNetworkTab
, Command
(pure "me")
(remainingArg "message")
$(chatDocs `cmdDoc` "me")
$ ChatCommand cmdMe simpleChannelTab
, Command
(pure "say")
(remainingArg "message")
$(chatDocs `cmdDoc` "say")
$ ChatCommand cmdSay simpleChannelTab
, Command
("query" :| ["q"])
(liftA2 (,) (simpleToken "target") (remainingArg "message"))
$(chatDocs `cmdDoc` "query")
$ ClientCommand cmdQuery simpleClientTab
, Command
(pure "notice")
(liftA2 (,) (simpleToken "target") (remainingArg "message"))
$(chatDocs `cmdDoc` "notice")
$ NetworkCommand cmdNotice simpleNetworkTab
, Command
(pure "wallops")
(remainingArg "message to +w users")
$(chatDocs `cmdDoc` "wallops")
$ NetworkCommand cmdWallops simpleNetworkTab
, Command
(pure "operwall")
(remainingArg "message to +z opers")
$(chatDocs `cmdDoc` "operwall")
$ NetworkCommand cmdOperwall simpleNetworkTab
, Command
(pure "ctcp")
(liftA3 (,,) (simpleToken "target") (simpleToken "command") (remainingArg "arguments"))
$(chatDocs `cmdDoc` "ctcp")
$ NetworkCommand cmdCtcp simpleNetworkTab
, Command
(pure "nick")
(simpleToken "nick")
$(chatDocs `cmdDoc` "nick")
$ NetworkCommand cmdNick simpleNetworkTab
, Command
(pure "away")
(remainingArg "message")
$(chatDocs `cmdDoc` "away")
$ NetworkCommand cmdAway simpleNetworkTab
, Command
(pure "names")
(pure ())
$(chatDocs `cmdDoc` "names")
$ ChannelCommand cmdChanNames noChannelTab
, Command
("channelinfo" :| ["cinfo"])
(pure ())
$(chatDocs `cmdDoc` "channelinfo")
$ ChannelCommand cmdChannelInfo noChannelTab
, Command
(pure "knock")
(liftA2 (,) (simpleToken "channel") (remainingArg "message"))
$(chatDocs `cmdDoc` "knock")
$ NetworkCommand cmdKnock simpleNetworkTab
, Command
("quote" :| ["/"])
(remainingArg "raw IRC command")
$(chatDocs `cmdDoc` "quote")
$ NetworkCommand cmdQuote simpleNetworkTab
, Command
(pure "monitor")
(extensionArg "[+-CLS]" monitorArgs)
$(chatDocs `cmdDoc` "monitor")
$ NetworkCommand cmdMonitor simpleNetworkTab
]
monitorArgs :: ArgsContext -> String -> Maybe (Args ArgsContext [String])
monitorArgs _ str =
case toUpper <$> str of
"+" -> Just (wrap '+' (simpleToken "target[,target2]*"))
"-" -> Just (wrap '-' (simpleToken "target[,target2]*"))
"C" -> Just (pure ["C"])
"L" -> Just (pure ["L"])
"S" -> Just (pure ["S"])
_ -> Nothing
where
wrap c = fmap (\s -> [[c], s])
cmdMonitor :: NetworkCommand [String]
cmdMonitor cs st args =
do sendMsg cs (ircMonitor (fmap Text.pack args))
commandSuccess st
cmdChanNames :: ChannelCommand ()
cmdChanNames chan cs st _ = do
-- TODO: Uncomment when RPL_NAMREPLY actually creates a ChannelState.
-- let connecting = has (csPingStatus . _PingConnecting) cs
-- isJoined = has (csChannels . ix chan . filteredBy chanJoined) cs
-- unless (connecting || isJoined)
-- (sendMsg cs (ircNames chan))
commandSuccess (changeSubfocus subfocus st)
where subfocus = FocusUsers (view csNetwork cs) chan
cmdChannelInfo :: ChannelCommand ()
cmdChannelInfo chan cs st _ = do
let connecting = has (csPingStatus . _PingConnecting) cs
isJoined = has (csChannels . ix chan . filteredBy chanJoined) cs
unless (connecting || isJoined)
(sendMsg cs (ircMode chan []) >> sendMsg cs (ircTopic chan ""))
commandSuccess (changeSubfocus subfocus st)
where subfocus = FocusInfo (view csNetwork cs) chan
cmdKnock :: NetworkCommand (String, String)
cmdKnock cs st (chan,message) =
do sendMsg cs (ircKnock (Text.pack chan) (Text.pack message))
commandSuccess st
cmdJoin :: NetworkCommand (String, Maybe String)
cmdJoin cs st (channelsStr, mbKeys) =
do let network = view csNetwork cs
let channelId = mkId (Text.pack (takeWhile (',' /=) channelsStr))
let channels = Text.split (',' ==) (Text.pack channelsStr)
let keys = maybe [] (Text.split (',' ==) . Text.pack) mbKeys
traverse_ (sendMsg cs) (chunkJoins channels keys)
commandSuccess (changeFocus (ChannelFocus network channelId) st)
chunkJoins :: [Text] -> [Text] -> [RawIrcMsg]
chunkJoins cs0 ks0 =
case (cs0, ks0) of
(c:cs, k:ks) -> go (cost c + cost k) [c] [k] cs ks
(c:cs, []) -> go (cost c) [c] [] cs []
_ -> []
where
limit = 500 -- "JOIN__:\r\n"
cost x = 1 + B.length (Text.encodeUtf8 x)
finishChannels = Text.intercalate "," . reverse
finishKeys [] = Nothing
finishKeys ks = Just (finishChannels ks)
go n acc1 acc2 (c:cs) []
| n + x > limit = ircJoin (finishChannels acc1) (finishKeys acc2) : go x [c] [] cs []
| otherwise = go (n + x) (c : acc1) acc2 cs []
where
x = cost c
go n acc1 acc2 (c:cs) (k:ks)
| n + x > limit = ircJoin (finishChannels acc1) (finishKeys acc2) : go x [c] [k] cs ks
| otherwise = go (n + x) (c : acc1) (k : acc2) cs ks
where
x = cost c + cost k
go _ acc1 acc2 [] _ = [ircJoin (finishChannels acc1) (finishKeys acc2)]
-- | @/query@ command. Takes a channel or nickname and switches
-- focus to that target on the current network.
cmdQuery :: ClientCommand (String, String)
cmdQuery st (target, msg) =
case parseFocus (views clientFocus focusNetwork st) target of
Just (ChannelFocus net tgt)
| null msg -> commandSuccess st'
| Just cs <- preview (clientConnection net) st ->
do let tgtTxt = idText tgt
msgTxt = Text.pack msg
chatCommand
(ircPrivmsg tgtTxt msgTxt)
(\src tgt1 -> Privmsg src tgt1 msgTxt)
tgtTxt cs st'
where
firstTgt = mkId (Text.takeWhile (','/=) (idText tgt))
st' = changeFocus (ChannelFocus net firstTgt) st
_ -> commandFailureMsg "Bad target" st
-- | Implementation of @/ctcp@
cmdCtcp :: NetworkCommand (String, String, String)
cmdCtcp cs st (target, cmd, args) =
do let cmdTxt = Text.toUpper (Text.pack cmd)
argTxt = Text.pack args
tgtTxt = Text.pack target
let msg = "\^A" <> cmdTxt <>
(if Text.null argTxt then "" else " " <> argTxt) <>
"\^A"
chatCommand
(ircPrivmsg tgtTxt msg)
(\src tgt -> Ctcp src tgt cmdTxt argTxt)
tgtTxt cs st
-- | Implementation of @/wallops@
cmdWallops :: NetworkCommand String
cmdWallops cs st rest
| null rest = commandFailureMsg "empty message" st
| otherwise =
do let restTxt = Text.pack rest
sendMsg cs (ircWallops restTxt)
commandSuccess st
-- | Implementation of @/operwall@
cmdOperwall :: NetworkCommand String
cmdOperwall cs st rest
| null rest = commandFailureMsg "empty message" st
| otherwise =
do let restTxt = Text.pack rest
sendMsg cs (ircOperwall restTxt)
commandSuccess st
-- | Implementation of @/notice@
cmdNotice :: NetworkCommand (String, String)
cmdNotice cs st (target, rest)
| null rest = commandFailureMsg "empty message" st
| otherwise =
do let restTxt = Text.pack rest
tgtTxt = Text.pack target
chatCommand
(ircNotice tgtTxt restTxt)
(\src tgt -> Notice src tgt restTxt)
tgtTxt cs st
-- | Implementation of @/msg@
cmdMsg :: NetworkCommand (String, String)
cmdMsg cs st (target, rest)
| null rest = commandFailureMsg "empty message" st
| otherwise =
do let restTxt = Text.pack rest
tgtTxt = Text.pack target
chatCommand
(ircPrivmsg tgtTxt restTxt)
(\src tgt -> Privmsg src tgt restTxt)
tgtTxt cs st
-- | Common logic for @/msg@ and @/notice@
chatCommand ::
RawIrcMsg {- ^ irc command -} ->
(Source -> Identifier -> IrcMsg) ->
Text {- ^ targets -} ->
NetworkState ->
ClientState ->
IO CommandResult
chatCommand ircMsg mkmsg tgtsTxt cs st
| any Text.null tgtTxts = commandFailureMsg "empty target" st
| otherwise =
do sendMsg cs ircMsg
st' <- chatCommand' mkmsg tgtTxts cs st
commandSuccess st'
where
tgtTxts = Text.split (','==) tgtsTxt
-- | Common logic for @/msg@ and @/notice@ returning the client state
chatCommand' ::
(Source -> Identifier -> IrcMsg) ->
[Text] {- ^ targets -} ->
NetworkState ->
ClientState ->
IO ClientState
chatCommand' con targetTxts cs st =
do now <- getZonedTime
let targetIds = mkId <$> targetTxts
!myNick = Source (view csUserInfo cs) ""
network = view csNetwork cs
entries = [ (targetId,
ClientMessage
{ _msgTime = now
, _msgNetwork = network
, _msgBody = IrcBody (con myNick targetId)
})
| targetId <- targetIds ]
return $! foldl' (\acc (targetId, entry) ->
recordChannelMessage network targetId entry acc)
st
entries
-- | Implementation of @/quote@. Parses arguments as a raw IRC command and
-- sends to the current network.
cmdQuote :: NetworkCommand String
cmdQuote cs st rest =
case parseRawIrcMsg (Text.pack (dropWhile (' '==) rest)) of
Nothing -> commandFailureMsg "failed to parse raw IRC command" st
Just raw ->
do sendMsg cs raw
commandSuccess st
cmdAway :: NetworkCommand String
cmdAway cs st rest =
do sendMsg cs (ircAway (Text.pack rest))
commandSuccess st
cmdNick :: NetworkCommand String
cmdNick cs st nick =
do sendMsg cs (ircNick (Text.pack nick))
commandSuccess st
cmdPart :: ChannelCommand String
cmdPart channelId cs st rest =
do let msg = rest
sendMsg cs (ircPart channelId (Text.pack msg))
commandSuccess st
-- | This command is equivalent to chatting without a command. The primary use
-- at the moment is to be able to send a leading @/@ to chat easily.
cmdSay :: ChannelCommand String
cmdSay focus cs st rest = executeChat (ChannelFocus (view csNetwork cs) focus) rest st
-- | Implementation of @/me@
cmdMe :: ChannelCommand String
cmdMe channelId cs st rest =
do now <- getZonedTime
let actionTxt = Text.pack ("\^AACTION " ++ rest ++ "\^A")
!myNick = Source (view csUserInfo cs) ""
network = view csNetwork cs
entry = ClientMessage
{ _msgTime = now
, _msgNetwork = network
, _msgBody = IrcBody (Ctcp myNick channelId "ACTION" (Text.pack rest))
}
sendMsg cs (ircPrivmsg (idText channelId) actionTxt)
commandSuccess
$! recordChannelMessage network channelId entry st
-- | Treat the current text input as a chat message and send it.
executeChat ::
Focus ->
String {- ^ chat message -} ->
ClientState {- ^ client state -} ->
IO CommandResult {- ^ command result -}
executeChat focus msg st =
case focus of
ChannelFocus network channel
| Just !cs <- preview (clientConnection network) st ->
do now <- getZonedTime
let msgTxt = Text.pack $ takeWhile (/='\n') msg
tgtTxt = idText channel
(st1,allow) <- clientChatExtension network tgtTxt msgTxt st
when allow (sendMsg cs (ircPrivmsg tgtTxt msgTxt))
let myNick = Source (view csUserInfo cs) ""
entry = ClientMessage
{ _msgTime = now
, _msgNetwork = network
, _msgBody = IrcBody (Privmsg myNick channel msgTxt) }
commandSuccess $! recordChannelMessage network channel entry st1
_ -> commandFailureMsg "cannot send chat messages to this window" st