{-# LANGUAGE OverloadedStrings #-} {-| Module: SignalDBus Description: Bindings for the DBus interface of signal-cli Copyright: (c) Lia Lenckowski, 2022 License: AGPL Maintainer: lialenck@protonmail.com Stability: stable Portability: GNU/Linux, MacOS This module is the main entry point for this library. To get started, install and set up [signal-cli](https://github.com/asamk/signal-cli), and make sure it's running in daemon mode, with the dbus interface enabled. Then, as an example, you could use the following code to react with "💀" to every message received from a specific user either via PM or in groups you're a member of: > import Control.Monad (forever, when) > import SignalDBus > > reactWithSkull :: Timestamp -> String -> Maybe Group -> SignalConn -> IO Timestamp > reactWithSkull ts n mayG sc = > case mayG of > Nothing -> sendMessageReaction "💀" False n ts n sc > Just g -> sendGroupMessageReaction "💀" False n ts g sc > > -- react with 💀 to every message received by a specific user > main :: IO () > main = do > let num = "[insert phone number with country code]" > withConn $ \sc -> do > n <- getSelfNumber sc > putStrLn $ "Running on: " ++ show n > withReceiveMessages sc $ \getMsg -> forever $ do > getMsg >>= \case > Receipt _ _ -> return () > SyncMessage _ _ _ _ _ -> return () > Message ts n g _ _ -> when (n == num) $ do > reactWithSkull ts num g sc > return () > > return () All of the functions exported may fail with a 'DBus.Client.ClientError' if something goes wrong. None of the functions should throw any other exception. Quite a few of these functions have a scary-looking \"UNTESTED\" in their documentation. If you end up using any of those, and they work, please tell me so I can remove that disclaimer. This package aims to provide somewhat direct bindings for all methods described by . In cases where methods are overloaded, this library almost always chooses to implement the more general ones. Some functionality of the dbus interface of signal-cli isn't implemented, as it's either not documented or not often useful; if you require any of that, I'd recommend looking at SignalDBus.Interface. -} module SignalDBus ( -- * Opening connections SignalDBus.Interface.withConn, SignalDBus.Interface.withConnNum, -- * Receiving Messages SignalDBus.Interface.withReceiveMessages, -- * Utility functions reactTo, replyTo, -- * Control interface -- $control link, listAccounts, register, registerWithCaptcha, verify, verifyWithPin, -- * Account interface -- $signal getContactName, getContactNumber, getSelfNumber, isContactBlocked, isRegistered, listNumbers, removePin, sendEndSessionMessage, sendMessage, sendMessageReaction, sendNoteToSelfMessage, sendReadReceipt, sendViewedReceipt, sendRemoteDeleteMessage, sendTyping, setContactBlocked, setContactName, deleteContact, deleteRecipient, setExpirationTimer, setPin, submitRateLimitChallenge, updateProfile, uploadStickerPack, version, createGroup, getGroup, joinGroup, listGroups, sendGroupMessage, sendGroupTyping, sendGroupMessageReaction, sendGroupRemoteDeleteMessage, addDevice, getDevice, listDevices, -- * Group interface getGroupId, getGroupName, getGroupDescription, getGroupIsBlocked, getGroupIsAdmin, getGroupMessageExpirationTimer, getGroupMembers, getGroupPendingMembers, getGroupRequestingMembers, getGroupAdmins, getGroupPermissionAddMember, getGroupPermissionEditDetails, getGroupPermissionSendMessage, -- could've been 'getGroupGroupInviteLink' for consistency, but eww. getGroupInviteLink, setGroupName, setGroupDescription, setGroupAvatar, setGroupIsBlocked, setGroupMessageExpirationTimer, setGroupPermissionAddMember, setGroupPermissionEditDetails, setGroupPermissionSendMessage, groupAddAdmins, groupAddMembers, groupDisableLink, groupEnableLink, groupQuit, groupRemoveAdmins, groupRemoveMembers, groupResetLink, -- TODO: * Device interface -- * Types SignalDBus.Types.SignalConn, SignalDBus.Types.Timestamp, SignalDBus.Types.Device, SignalDBus.Types.Group, SignalDBus.Types.ReceivedMessage(..), fromUTCTime, toUTCTime, ) where import Data.ByteString (ByteString) import Data.Int (Int32, Int64) import DBus.Internal.Types (toVariant, ObjectPath(..)) import DBus.Client (clientError) import SignalDBus.Interface import SignalDBus.Types import UnliftIO (MonadIO, throwIO) -- |Utility function for sending a message in the same chat as you received a message from replyTo :: MonadIO m => ReceivedMessage -- ^ Message in whoose chat the reply should be sent -> String -- ^ Message content -> [String] -- ^ Paths to attachments. Resolves using the working dir of the signal-cli daemon, not this process -> SignalConn -- ^ Connection object -> m Timestamp -- ^ Timestamp of sent message replyTo (Message _ _ (Just gId) _ _) msg as sc = sendGroupMessage msg as gId sc replyTo (Message _ n Nothing _ _) msg as sc = sendMessage msg as [n] sc replyTo (SyncMessage _ _ _ _ _) _ _ _ = throwIO $ clientError $ "replyTo called on SyncMessage" replyTo (Receipt _ _) _ _ _ = throwIO $ clientError $ "replyTo called on SyncMessage" -- |Utility function for reacting to a received message reactTo :: MonadIO m => ReceivedMessage -- ^ Message which to react to -> String -- ^ Unicode grapheme cluster. Only tested with "😂" -> Bool -- ^ whether to remove an existing reaction -> SignalConn -- ^ Connection object -> m Timestamp -- ^ Timestamp of sent message reactTo (Message ts n (Just gId) _ _) emoji rm sc = sendGroupMessageReaction emoji rm n ts gId sc reactTo (Message ts n Nothing _ _) emoji rm sc = sendMessageReaction emoji rm n ts n sc reactTo (SyncMessage _ _ _ _ _) _ _ _ = throwIO $ clientError $ "replyTo called on SyncMessage" reactTo (Receipt _ _) _ _ _ = throwIO $ clientError $ "replyTo called on SyncMessage" -- $control -- -- These methods are only available if the signal-cli daemon was started in -- multi-account mode. -- |UNTESTED. Link this as a new device, identified by the given string. link :: MonadIO m => String -> SignalConn -> m String link name = callControl "link" [toVariant name] -- |List accounts attached to this signal-cli instance listAccounts :: MonadIO m => SignalConn -> m [String] listAccounts sc = do obs <- callControl "listAccounts" [] sc return $! flip map obs $ \(ObjectPath s) -> '+' : drop (length ("/org/asamk/Signal/_" :: String)) s -- |UNTESTED. Register this as the primary device for the given number. Set the -- second argument to 'True' to request voice verification instead of SMS verification. register :: MonadIO m => String -> Bool -> SignalConn -> m () register name b = callControl_ "register" [toVariant name, toVariant b] -- |UNTESTED. Same as 'register', but include a Captcha string. registerWithCaptcha :: MonadIO m => String -> Bool -> String -> SignalConn -> m () registerWithCaptcha name b c = callControl_ "registerWithCaptcha" $ [toVariant name, toVariant b, toVariant c] -- | UNTESTED. Verify your phone number after requesting registration via 'register' -- or 'registerWithCaptcha' verify :: MonadIO m => String -- ^ Phone number -> String -- ^ Verification code -> SignalConn -- ^ Connection object -> m () verify n c = callControl_ "verify" [toVariant n, toVariant c] -- | UNTESTED. Same as 'verify', but include a registration pin for protected accounts verifyWithPin :: MonadIO m => String -- ^ Phone number -> String -- ^ Verification code -> String -- ^ Pin -> SignalConn -- ^ Connection object -> m () verifyWithPin n c p = callControl_ "verifyWithPin" $ [toVariant n, toVariant c, toVariant p] -- $signal -- -- These methods may only be called on a connection object that knows which account to -- use; that is, either the signal daemon has a default account, and you've used -- 'withConn', or it doesn't, and you've used 'withConnNum'. -- |Takes a number, and returns the name of the contact, empty if unknown getContactName :: MonadIO m => String -> SignalConn -> m String getContactName n = callSC "getContactName" [toVariant n] -- |Takes a contact name, and returns known numbers, both as strings getContactNumber :: MonadIO m => String -> SignalConn -> m [String] getContactNumber name = callSC "getContactNumber" [toVariant name] -- |Returns your own number getSelfNumber :: MonadIO m => SignalConn -> m String getSelfNumber = callSC "getSelfNumber" [] -- |Returns true if you blocked this number isContactBlocked :: MonadIO m => String -> SignalConn -> m Bool isContactBlocked number = callSC "isContactBlocked" [toVariant number] -- |For each given number, returns whether that user is registered on Signal isRegistered :: MonadIO m => [String] -> SignalConn -> m [Bool] isRegistered nums = callSC "isRegistered" [toVariant nums] -- |List all known numbers (e.g. group members and senders of received messages) listNumbers :: MonadIO m => SignalConn -> m [String] listNumbers = callSC "listNumbers" [] -- |UNTESTED. Removes registration pin protection. removePin :: MonadIO m => SignalConn -> m () removePin = callSC_ "removePin" [] -- |UNTESTED. No idea what this accomplishes, so hmu if you know. It's implemented though sendEndSessionMessage :: MonadIO m => SignalConn -> m () sendEndSessionMessage = callSC_ "sendEndSessionMessage" [] -- |Sends a message, possibly with attachments, to a number of recipients sendMessage :: MonadIO m => String -- ^ Message content -> [String] -- ^ Paths to attachments. Resolves using the working dir of the signal-cli daemon, not this process -> [String] -- ^ Recipients -> SignalConn -- ^ Connection object -> m Timestamp -- ^ Timestamp of sent message sendMessage m as rs = callSC "sendMessage" [toVariant m, toVariant as, toVariant rs] -- |Reacts to a message sendMessageReaction :: MonadIO m => String -- ^ Unicode grapheme cluster. Only tested with "😂" -> Bool -- ^ Whether to remove a previously sent reaction instead of adding one -> String -- ^ Author of the message you want to react to -> Timestamp -- ^ Timestamp of message to react to -> String -- ^ Phone numbers of recipient -> SignalConn -- ^ Connection object -> m Timestamp -- ^ Timestamp of reaction sendMessageReaction emoji rm n ts r = callSC "sendMessageReaction" $ [toVariant emoji, toVariant rm, toVariant n, toVariant ts, toVariant r] -- |Sends a message to yourself sendNoteToSelfMessage :: MonadIO m => String -- ^ Message -> [String] -- ^ Paths to attachments. Resolves using the working dir of the signal-cli daemon, not this process -> SignalConn -- ^ Connection object -> m Timestamp -- ^ Timestamp of sent message sendNoteToSelfMessage m as = callSC "sendNoteToSelfMessage" $ [toVariant m, toVariant as] -- |Sends read receipts for the messages with the specified timestamps to the given phone -- number. These timestamps must belong to messages you received from that number. sendReadReceipt :: MonadIO m => String -> [Timestamp] -> SignalConn -> m () sendReadReceipt r ts = callSC_ "sendReadReceipt" [toVariant r, toVariant ts] -- |Sends viewed receipts for the messages with the specified timestamps to the given phone -- number. This is probably not what you want; 'sendReadReceipt' seems to be the way to go. sendViewedReceipt :: MonadIO m => String -> [Timestamp] -> SignalConn -> m () sendViewedReceipt r ts = callSC_ "sendViewedReceipt" [toVariant r, toVariant ts] -- |Delete one of you own private messages; also deletes them remotely on supported -- clients (Signal-desktop, Android/IOS apps, ...) sendRemoteDeleteMessage :: MonadIO m => Timestamp -- ^ Timestamp of message to delete -> [String] -- ^ Recipients -> SignalConn -- ^ Connection object -> m Timestamp -- ^ Timestamp, can be used to identify corresponding replies sendRemoteDeleteMessage ts rs = callSC "sendRemoteDeleteMessage" $ [toVariant ts, toVariant rs] -- |Send updates about whether you're typing to the given number. -- NOTE: the boolean argument should be False to indicate you're typing, and True to -- clear the typing state sendTyping :: MonadIO m => String -> Bool -> SignalConn -> m () sendTyping n b = callSC_ "sendTyping" [toVariant n, toVariant b] -- |UNTESTED. Sets whether a contact is blocked. This is only done locally, so this just -- disabled messages from that number from being forwarded via DBus. setContactBlocked :: MonadIO m => String -> Bool -> SignalConn -> m () setContactBlocked n b = callSC_ "setContactBlocked" [toVariant n, toVariant b] -- |Not implemented, as the documentation doesn't state the type of the name -- (probably a String, but I dont feel like testing stuff rn) setContactName :: a setContactName = error "Not implemented due to documentation succ" -- |UNTESTED. Probably deletes a contact, given by a phone number, or something. deleteContact :: MonadIO m => String -> SignalConn -> m () deleteContact n = callSC_ "deleteContact" [toVariant n] -- |UNTESTED. Idk what this does, but tell me if you know/have used this. deleteRecipient :: MonadIO m => String -> SignalConn -> m () deleteRecipient n = callSC_ "deleteRecipient" [toVariant n] -- |Set seconds until messages to this recipient dissapear (on supported clients). Set to -- 0 to disable. setExpirationTimer :: (MonadIO m, Integral i) => String -> i -> SignalConn -> m () setExpirationTimer n i = callSC_ "setExpirationTimer" $ [toVariant n, toVariant (fromIntegral i :: Int32)] -- |UNTESTED. Set registration pin to prevent others from registering your number. setPin :: MonadIO m => String -> SignalConn -> m () setPin p = callSC_ "setPin" [toVariant p] -- |UNTESTED. Idk but seems to be useful for lifting rate limits submitRateLimitChallenge :: MonadIO m => String -> String -> SignalConn -> m () submitRateLimitChallenge ch cap = callSC_ "submitRateLimitChallenge" $ [toVariant ch, toVariant cap] -- |Update parts of your profile. You can leave any string field empty to keep the old -- value. updateProfile :: MonadIO m => String -- ^ Given name -> String -- ^ Family name -> String -- ^ About message -> String -- ^ Emoji for profile -> String -- ^ Avatar file name. Resolves using the working dir of the signal-cli daemon, not this process -> Bool -- ^ True if the avatar should be removed -> SignalConn -- ^ Connection object -> m () updateProfile gn fn ab em fp rm = callSC_ "updateProfile" $ [toVariant gn, toVariant fn, toVariant ab, toVariant em, toVariant fp, toVariant rm] -- |UNTESTED. Uploads a sticker pack, given by the path to a manifest.json or zip file, -- and return the URL of the pack. uploadStickerPack :: MonadIO m => String -> SignalConn -> m String uploadStickerPack fp = callSC "uploadStickerPack" [toVariant fp] -- |Return version string of signal-cli. This also works for multi-account connections. version :: MonadIO m => SignalConn -> m String version = callSC "version" [] -- |Create a group createGroup :: MonadIO m => String -- ^ Group name -> [String] -- ^ Initial members -> String -- ^ Avatar file name. Resolves using the working dir of the signal-cli daemon, not this process -> SignalConn -- ^ Connection object -> m Group -- ^ Group object createGroup n ms f sc = do gId <- callSC "createGroup" [toVariant n, toVariant ms, toVariant f] sc getGroup gId sc -- |Get DBus object path for a group, given by its signal-internal binary identifier. getGroup :: MonadIO m => ByteString -> SignalConn -> m Group getGroup g = callSC "getGroup" [toVariant g] -- |UNTESTED. Join a group given by an invite link. Behaviour of this depends on -- properties of the group; see "joinGroup" on -- https://github.com/AsamK/signal-cli/blob/master/man/signal-cli-dbus.5.adoc -- (latest commit while writing this: 34cc64f8ce97a63c859bd95faf6783422f14df61) joinGroup :: MonadIO m => String -> SignalConn -> m () joinGroup l = callSC_ "joinGroup" [toVariant l] -- |List known groups, represented as (group object, internal identifier, group name) listGroups :: MonadIO m => SignalConn -> m [(Group, ByteString, String)] listGroups = callSC "listGroups" [] -- |Sends a message, possibly with attachments, to a group sendGroupMessage :: MonadIO m => String -- ^ Message content -> [String] -- ^ Paths to attachments. Resolves using the working dir of the signal-cli daemon, not this process -> Group -- ^ Group in which to send a message -> SignalConn -- ^ Connection object -> m Timestamp -- ^ Timestamp of sent message sendGroupMessage m as g sc = do gId <- getGroupProp "Id" g sc callSC "sendGroupMessage" [toVariant m, toVariant as, toVariant (gId :: ByteString)] sc -- |Send updates about whether you're typing to the given group. -- NOTE: the boolean argument should be False to indicate you're typing, and True to -- clear the typing state sendGroupTyping :: MonadIO m => Group -> Bool -> SignalConn -> m () sendGroupTyping g b sc = do gId <- getGroupId g sc callSC_ "sendGroupTyping" [toVariant gId, toVariant b] sc -- |Reacts to a message sendGroupMessageReaction :: MonadIO m => String -- ^ Unicode grapheme cluster. Only tested with "😂" -> Bool -- ^ Whether to remove a previously sent reaction instead of adding one -> String -- ^ Author of the message you want to react to -> Timestamp -- ^ Timestamp of message to react to -> Group -- ^ Group in which to react -> SignalConn -- ^ Connection object -> m Timestamp -- ^ Timestamp of reaction sendGroupMessageReaction emoji rm n ts g sc = do gId <- getGroupId g sc callSC "sendGroupMessageReaction" [toVariant emoji, toVariant rm, toVariant n, toVariant ts, toVariant gId] sc -- |Delete one of you own group messages; also deletes them remotely on supported -- clients (Signal-desktop, Android/IOS apps, ...) sendGroupRemoteDeleteMessage :: MonadIO m => Timestamp -- ^ Timestamp of message to delete -> Group -- ^ Group in which to delete your message -> SignalConn -- ^ Connection object -> m Timestamp -- ^ Timestamp, can be used to identify corresponding replies sendGroupRemoteDeleteMessage ts g sc = do gId <- getGroupId g sc callSC "sendGroupRemoteDeleteMessage" [toVariant ts, toVariant gId] sc -- |UNTESTED. Add a device that wants to link to this account using a link addDevice :: MonadIO m => String -> SignalConn -> m () addDevice l = callSC_ "addDevice" [toVariant l] -- |Get DBus object path for a device given by a device ID getDevice :: MonadIO m => Int64 -> SignalConn -> m ObjectPath getDevice d = callSC "getDevice" [toVariant d] -- |List devices linked with this account, represented as -- (object path, device id, device name) listDevices :: MonadIO m => SignalConn -> m [(ObjectPath, Int64, String)] listDevices = callSC "listDevices" [] -- |Byte array representing the internal group identifier getGroupId :: MonadIO m => Group -> SignalConn -> m ByteString getGroupId = getGroupProp "Id" -- |Display name getGroupName :: MonadIO m => Group -> SignalConn -> m String getGroupName = getGroupProp "Name" -- |Description getGroupDescription :: MonadIO m => Group -> SignalConn -> m String getGroupDescription = getGroupProp "Description" -- |UNTESTED. If true, messages won't be forwarded via DBus getGroupIsBlocked :: MonadIO m => Group -> SignalConn -> m Bool getGroupIsBlocked = getGroupProp "IsBlocked" -- |Whether this account is a group admin getGroupIsAdmin :: MonadIO m => Group -> SignalConn -> m Bool getGroupIsAdmin = getGroupProp "IsAdmin" -- |Message expiration timer. 0 if disabled getGroupMessageExpirationTimer :: MonadIO m => Group -> SignalConn -> m Int getGroupMessageExpirationTimer g sc = do i <- getGroupProp "MessageExpirationTimer" g sc return $! fromIntegral (i :: Int32) -- |List of group members' phone numbers getGroupMembers :: MonadIO m => Group -> SignalConn -> m [String] getGroupMembers = getGroupProp "Members" -- |UNTESTED. List of pending group members; I don't know what this does, but I -- imagine you're probably looking for 'getGroupRequestingMembers' getGroupPendingMembers :: MonadIO m => Group -> SignalConn -> m [String] getGroupPendingMembers = getGroupProp "PendingMembers" -- |List of phone numbers requesting to join the group, if theres an invite link set -- to require admin approval getGroupRequestingMembers :: MonadIO m => Group -> SignalConn -> m [String] getGroupRequestingMembers = getGroupProp "RequestingMembers" -- |List of group admins' phone numbers getGroupAdmins :: MonadIO m => Group -> SignalConn -> m [String] getGroupAdmins = getGroupProp "Admins" -- |String representing who has permission to add members -- (one of "ONLY_ADMINS", "EVERY_MEMBER") getGroupPermissionAddMember :: MonadIO m => Group -> SignalConn -> m String getGroupPermissionAddMember = getGroupProp "PermissionAddMember" -- |String representing who has permission to edit group description -- (one of "ONLY_ADMINS", "EVERY_MEMBER") getGroupPermissionEditDetails :: MonadIO m => Group -> SignalConn -> m String getGroupPermissionEditDetails = getGroupProp "PermissionEditDetails" -- |String representing who has permission to send messages -- (one of "ONLY_ADMINS", "EVERY_MEMBER") getGroupPermissionSendMessage :: MonadIO m => Group -> SignalConn -> m String getGroupPermissionSendMessage = getGroupProp "PermissionSendMessage" -- |Group invitation link. Empty if disabled getGroupInviteLink :: MonadIO m => Group -> SignalConn -> m String getGroupInviteLink = getGroupProp "GroupInviteLink" -- |Display name setGroupName :: MonadIO m => String -> Group -> SignalConn -> m () setGroupName s = setGroupProp "Name" s -- |Description setGroupDescription :: MonadIO m => String -> Group -> SignalConn -> m () setGroupDescription s = setGroupProp "Description" s -- |Filename of group avatar. Resolves using the working dir of the signal-cli daemon, not this process setGroupAvatar :: MonadIO m => String -> Group -> SignalConn -> m () setGroupAvatar s = setGroupProp "Avatar" s -- |UNTESTED. If true, messages won't be forwarded via DBus setGroupIsBlocked :: MonadIO m => Bool -> Group -> SignalConn -> m () setGroupIsBlocked b = setGroupProp "IsBlocked" b -- |Message expiration timer. 0 to disable setGroupMessageExpirationTimer :: MonadIO m => Int -> Group -> SignalConn -> m () setGroupMessageExpirationTimer i = setGroupProp "MessageExpirationTimer" $ (fromIntegral i :: Int32) -- |String representing who has permission to add members -- (one of "ONLY_ADMINS", "EVERY_MEMBER") setGroupPermissionAddMember :: MonadIO m => String -> Group -> SignalConn -> m () setGroupPermissionAddMember s = setGroupProp "PermissionAddMember" s -- |String representing who has permission to edit group description -- (one of "ONLY_ADMINS", "EVERY_MEMBER") setGroupPermissionEditDetails :: MonadIO m => String -> Group -> SignalConn -> m () setGroupPermissionEditDetails s = setGroupProp "PermissionEditDetails" s -- |String representing who has permission to send messages -- (one of "ONLY_ADMINS", "EVERY_MEMBER") setGroupPermissionSendMessage :: MonadIO m => String -> Group -> SignalConn -> m () setGroupPermissionSendMessage s = setGroupProp "PermissionSendMessage" s -- |Add admins groupAddAdmins :: MonadIO m => [String] -> Group -> SignalConn -> m () groupAddAdmins ns = callGroup_ "addAdmins" [toVariant ns] -- |Add numbers who are pending members to the group, and other numbers to requesting -- members list groupAddMembers :: MonadIO m => [String] -> Group -> SignalConn -> m () groupAddMembers ns = callGroup_ "addMembers" [toVariant ns] -- |Disable group invitation link groupDisableLink :: MonadIO m => Group -> SignalConn -> m () groupDisableLink = callGroup_ "disableLink" [] -- |Enable group invitation link. If the argument is true, users cannot join directly, -- but are added to the requesting members list first. groupEnableLink :: MonadIO m => Bool -> Group -> SignalConn -> m () groupEnableLink b = callGroup_ "enableLink" [toVariant b] -- |Quit the group groupQuit :: MonadIO m => Group -> SignalConn -> m () groupQuit = callGroup_ "quitGroup" [] -- |Remove admins groupRemoveAdmins :: MonadIO m => [String] -> Group -> SignalConn -> m () groupRemoveAdmins ns = callGroup_ "removeAdmins" [toVariant ns] -- |Remove numbers from the group groupRemoveMembers :: MonadIO m => [String] -> Group -> SignalConn -> m () groupRemoveMembers ns = callGroup_ "removeMembers" [toVariant ns] -- |Reset the group invitation link groupResetLink :: MonadIO m => Group -> SignalConn -> m () groupResetLink = callGroup_ "resetLink" []