mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-04-30 12:26:48 +00:00
1264 lines
73 KiB
Haskell
1264 lines
73 KiB
Haskell
{-# LANGUAGE BangPatterns #-}
|
|
{-# LANGUAGE DataKinds #-}
|
|
{-# LANGUAGE DuplicateRecordFields #-}
|
|
{-# LANGUAGE GADTs #-}
|
|
{-# LANGUAGE LambdaCase #-}
|
|
{-# LANGUAGE MultiWayIf #-}
|
|
{-# LANGUAGE NamedFieldPuns #-}
|
|
{-# LANGUAGE OverloadedLists #-}
|
|
{-# LANGUAGE OverloadedStrings #-}
|
|
{-# LANGUAGE ScopedTypeVariables #-}
|
|
{-# LANGUAGE TupleSections #-}
|
|
{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-}
|
|
|
|
module Directory.Service
|
|
( welcomeGetOpts,
|
|
directoryService,
|
|
directoryServiceCLI,
|
|
)
|
|
where
|
|
|
|
import Control.Concurrent (forkIO)
|
|
import Control.Concurrent.STM
|
|
import Control.Exception (SomeException, try)
|
|
import Control.Logger.Simple
|
|
import Control.Monad
|
|
import Control.Monad.Except
|
|
import Control.Monad.IO.Class
|
|
import qualified Data.Attoparsec.Text as A
|
|
import Data.Bifunctor (first)
|
|
import Data.Either (fromRight)
|
|
import Data.List (find, intercalate)
|
|
import Data.List.NonEmpty (NonEmpty (..))
|
|
import qualified Data.Map.Strict as M
|
|
import Data.Maybe (fromMaybe, isJust, isNothing)
|
|
import qualified Data.Set as S
|
|
import Data.Text (Text)
|
|
import qualified Data.Text as T
|
|
import qualified Data.Text.IO as T
|
|
import Data.Time.Clock (NominalDiffTime, UTCTime, diffUTCTime, getCurrentTime)
|
|
import Data.Time.LocalTime (getCurrentTimeZone)
|
|
import Directory.BlockedWords
|
|
import Directory.Captcha
|
|
import Directory.Events
|
|
import Directory.Listing
|
|
import Directory.Options
|
|
import Directory.Search
|
|
import Directory.Store
|
|
import Directory.Store.Migrate
|
|
import Directory.Util
|
|
import Simplex.Chat.Bot
|
|
import Simplex.Chat.Bot.KnownContacts
|
|
import Simplex.Chat.Controller
|
|
import Simplex.Chat.Core
|
|
import Simplex.Chat.Markdown (Format (..), FormattedText (..), parseMaybeMarkdownList, viewName)
|
|
import Simplex.Chat.Messages
|
|
import Simplex.Chat.Options
|
|
import Simplex.Chat.Protocol (MsgContent (..), memberSupportVoiceVersion)
|
|
import Simplex.Chat.Store.Direct (getContact)
|
|
import Simplex.Chat.Store.Groups (getGroupLink, getGroupMember, setGroupCustomData) -- TODO remove setGroupCustomData
|
|
import Simplex.Chat.Store.Profiles (GroupLinkInfo (..), getGroupLinkInfo)
|
|
import Simplex.Chat.Store.Shared (StoreError (..))
|
|
import Simplex.Chat.Terminal (terminalChatConfig)
|
|
import Simplex.Chat.Terminal.Main (simplexChatCLI')
|
|
import Simplex.Chat.Types
|
|
import Simplex.Chat.Types.Preferences
|
|
import Simplex.Chat.Types.Shared
|
|
import Simplex.Chat.View (serializeChatError, serializeChatResponse, simplexChatContact, viewContactName, viewGroupName)
|
|
import Simplex.Messaging.Agent.Protocol (AConnectionLink (..), ConnectionLink (..), CreatedConnLink (..), SConnectionMode (..), sameConnReqContact, sameShortLinkContact)
|
|
import qualified Simplex.Messaging.Crypto.File as CF
|
|
import Simplex.Messaging.Encoding.String
|
|
import Simplex.Messaging.TMap (TMap)
|
|
import qualified Simplex.Messaging.TMap as TM
|
|
import Simplex.Messaging.Util (eitherToMaybe, raceAny_, safeDecodeUtf8, tshow, unlessM, (<$$>))
|
|
import System.Directory (getAppUserDataDirectory, removeFile)
|
|
import System.Exit (exitFailure)
|
|
import System.Process (readProcess)
|
|
import Text.Read (readMaybe)
|
|
|
|
data GroupProfileUpdate
|
|
= GPNoServiceLink
|
|
| GPServiceLinkAdded {linkNow :: Text}
|
|
| GPServiceLinkRemoved
|
|
| GPHasServiceLink {linkBefore :: Text, linkNow :: Text}
|
|
| GPServiceLinkError
|
|
|
|
data DuplicateGroup
|
|
= DGUnique -- display name or full name is unique
|
|
| DGRegistered -- the group with the same names is registered, additional confirmation is required
|
|
| DGReserved -- the group with the same names is listed, the registration is not allowed
|
|
|
|
data GroupRolesStatus
|
|
= GRSOk
|
|
| GRSServiceNotAdmin
|
|
| GRSContactNotOwner
|
|
| GRSBadRoles
|
|
deriving (Eq)
|
|
|
|
data ServiceState = ServiceState
|
|
{ searchRequests :: TMap ContactId SearchRequest,
|
|
blockedWordsCfg :: BlockedWordsConfig,
|
|
pendingCaptchas :: TMap GroupMemberId PendingCaptcha,
|
|
updateListingsJob :: TMVar ChatController
|
|
}
|
|
|
|
data CaptchaMode = CMText | CMAudio
|
|
|
|
data PendingCaptcha = PendingCaptcha
|
|
{ captchaText :: Text,
|
|
sentAt :: UTCTime,
|
|
attempts :: Int,
|
|
captchaMode :: CaptchaMode
|
|
}
|
|
|
|
captchaLength :: Int
|
|
captchaLength = 7
|
|
|
|
maxCaptchaAttempts :: Int
|
|
maxCaptchaAttempts = 5
|
|
|
|
captchaTTL :: NominalDiffTime
|
|
captchaTTL = 600 -- 10 minutes
|
|
|
|
newServiceState :: DirectoryOpts -> IO ServiceState
|
|
newServiceState opts = do
|
|
searchRequests <- TM.emptyIO
|
|
blockedWordsCfg <- readBlockedWordsConfig opts
|
|
pendingCaptchas <- TM.emptyIO
|
|
updateListingsJob <- newEmptyTMVarIO
|
|
pure ServiceState {searchRequests, blockedWordsCfg, pendingCaptchas, updateListingsJob}
|
|
|
|
welcomeGetOpts :: IO DirectoryOpts
|
|
welcomeGetOpts = do
|
|
appDir <- getAppUserDataDirectory "simplex"
|
|
opts@DirectoryOpts {coreOptions, testing, superUsers, adminUsers, ownersGroup} <- getDirectoryOpts appDir "simplex_directory_service"
|
|
unless testing $ do
|
|
putStrLn $ "SimpleX Directory Service Bot v" ++ versionNumber
|
|
printDbOpts coreOptions
|
|
putStrLn $ knownContacts "superuser" superUsers
|
|
putStrLn $ knownContacts "admin user" adminUsers
|
|
putStrLn $ case ownersGroup of
|
|
Nothing -> "No owner's group"
|
|
Just KnownGroup {groupId, localDisplayName = n} -> "Owners' group: " <> knownName groupId n
|
|
pure opts
|
|
where
|
|
knownContacts userType = \case
|
|
[] -> "No " <> userType <> "s"
|
|
cts -> show (length cts) <> " " <> userType <> "(s): " <> intercalate ", " (map knownContact cts)
|
|
knownContact KnownContact {contactId, localDisplayName = n} = knownName contactId n
|
|
knownName i n = show i <> ":" <> T.unpack (viewName n)
|
|
|
|
directoryServiceCLI :: DirectoryLog -> DirectoryOpts -> IO ()
|
|
directoryServiceCLI st opts = do
|
|
env <- newServiceState opts
|
|
eventQ <- newTQueueIO
|
|
let eventHook cc resp = atomically $ resp <$ writeTQueue eventQ (cc, resp)
|
|
chatHooks =
|
|
defaultChatHooks
|
|
{ preStartHook = Just $ directoryPreStartHook opts,
|
|
postStartHook = Just $ directoryPostStartHook opts env,
|
|
eventHook = Just eventHook,
|
|
acceptMember = Just $ acceptMemberHook opts env
|
|
}
|
|
raceAny_ $
|
|
[ simplexChatCLI' terminalChatConfig {chatHooks} (mkChatOpts opts) Nothing,
|
|
processEvents eventQ env
|
|
]
|
|
<> updateListingsThread_ opts env
|
|
where
|
|
processEvents eventQ env = forever $ do
|
|
(cc, resp) <- atomically $ readTQueue eventQ
|
|
u_ <- readTVarIO (currentUser cc)
|
|
forM_ u_ $ \user -> directoryServiceEvent st opts env user cc resp
|
|
|
|
updateListingDelay :: Int
|
|
updateListingDelay = 5 * 60 * 1000000 -- update every 5 minutes
|
|
|
|
updateListingsThread_ :: DirectoryOpts -> ServiceState -> [IO ()]
|
|
updateListingsThread_ opts env = maybe [] (\f -> [updateListingsThread f]) $ webFolder opts
|
|
where
|
|
updateListingsThread f = do
|
|
cc <- atomically $ takeTMVar $ updateListingsJob env
|
|
forever $ do
|
|
u <- readTVarIO $ currentUser cc
|
|
forM_ u $ \user -> updateGroupListingFiles cc user f
|
|
delay <- registerDelay updateListingDelay
|
|
atomically $ void (takeTMVar $ updateListingsJob env) `orElse` unlessM (readTVar delay) retry
|
|
|
|
listingsUpdated :: ServiceState -> ChatController -> IO ()
|
|
listingsUpdated env = void . atomically . tryPutTMVar (updateListingsJob env)
|
|
|
|
directoryPreStartHook :: DirectoryOpts -> ChatController -> IO ()
|
|
directoryPreStartHook opts ChatController {config, chatStore} = runDirectoryMigrations opts config chatStore
|
|
|
|
directoryPostStartHook :: DirectoryOpts -> ServiceState -> ChatController -> IO ()
|
|
directoryPostStartHook opts@DirectoryOpts {noAddress, testing} env cc =
|
|
readTVarIO (currentUser cc) >>= \case
|
|
Nothing -> putStrLn "No current user" >> exitFailure
|
|
Just User {userId, profile = p@LocalProfile {preferences}} -> do
|
|
unless noAddress $ initializeBotAddress' (not testing) cc
|
|
listingsUpdated env cc
|
|
let cmds = fromMaybe [] $ preferences >>= commands_
|
|
unless (cmds == directoryCommands) $ do
|
|
let prefs = (fromMaybe emptyChatPrefs preferences) {files = Just FilesPreference {allow = FANo}, commands = Just directoryCommands} :: Preferences
|
|
p' = (fromLocalProfile p) {displayName = serviceName opts, peerType = Just CPTBot, preferences = Just prefs} :: Profile
|
|
liftIO $
|
|
sendChatCmd cc (APIUpdateProfile userId p') >>= \case
|
|
Right CRUserProfileUpdated {} -> putStrLn "Updated directory commands"
|
|
Right r -> putStrLn ("Error: unexpected response " <> show r) >> exitFailure
|
|
Left e -> putStrLn ("Error: " <> show e) >> exitFailure
|
|
|
|
directoryCommands :: [ChatBotCommand]
|
|
directoryCommands =
|
|
[ CBCCommand "new" "New groups" Nothing,
|
|
CBCCommand "help" "How to submit your group" Nothing,
|
|
CBCCommand "list" "Your own groups" Nothing,
|
|
CBCMenu
|
|
"Group settings"
|
|
[ CBCCommand "role" "View new member role" idParam,
|
|
CBCCommand "filter" "Anti-spam filter" idParam,
|
|
CBCCommand "link" "View and upgrade group link" idParam,
|
|
CBCCommand "delete" "Remove a group from directory" (Just "<ID>:'<NAME>'")
|
|
]
|
|
]
|
|
where
|
|
idParam = Just "<ID>"
|
|
|
|
directoryService :: DirectoryLog -> DirectoryOpts -> ChatConfig -> IO ()
|
|
directoryService st opts cfg = do
|
|
env <- newServiceState opts
|
|
let chatHooks =
|
|
defaultChatHooks
|
|
{ preStartHook = Just $ directoryPreStartHook opts,
|
|
postStartHook = Just $ directoryPostStartHook opts env,
|
|
acceptMember = Just $ acceptMemberHook opts env
|
|
}
|
|
simplexChatCore cfg {chatHooks} (mkChatOpts opts) $ \user cc ->
|
|
raceAny_ $
|
|
[ forever $ void getLine,
|
|
forever $ do
|
|
(_, resp) <- atomically . readTBQueue $ outputQ cc
|
|
directoryServiceEvent st opts env user cc resp
|
|
]
|
|
<> updateListingsThread_ opts env
|
|
|
|
acceptMemberHook :: DirectoryOpts -> ServiceState -> GroupInfo -> GroupLinkInfo -> Profile -> IO (Either GroupRejectionReason (GroupAcceptance, GroupMemberRole))
|
|
acceptMemberHook
|
|
DirectoryOpts {profileNameLimit}
|
|
ServiceState {blockedWordsCfg}
|
|
g
|
|
GroupLinkInfo {memberRole}
|
|
Profile {displayName, image = img} = runExceptT $ do
|
|
let a = groupMemberAcceptance g
|
|
when (useMemberFilter img $ rejectNames a) checkName
|
|
pure $
|
|
if
|
|
| useMemberFilter img (passCaptcha a) -> (GAPendingApproval, GRMember)
|
|
| useMemberFilter img (makeObserver a) -> (GAAccepted, GRObserver)
|
|
| otherwise -> (GAAccepted, memberRole)
|
|
where
|
|
checkName :: ExceptT GroupRejectionReason IO ()
|
|
checkName
|
|
| T.length displayName > profileNameLimit = throwError GRRLongName
|
|
| otherwise = do
|
|
when (hasBlockedFragments blockedWordsCfg displayName) $ throwError GRRBlockedName
|
|
when (hasBlockedWords blockedWordsCfg displayName) $ throwError GRRBlockedName
|
|
|
|
groupMemberAcceptance :: GroupInfo -> DirectoryMemberAcceptance
|
|
groupMemberAcceptance GroupInfo {customData} = (\DirectoryGroupData {memberAcceptance = ma} -> ma) $ fromCustomData customData
|
|
|
|
useMemberFilter :: Maybe ImageData -> Maybe ProfileCondition -> Bool
|
|
useMemberFilter img_ = \case
|
|
Just PCAll -> True
|
|
Just PCNoImage -> maybe True (\(ImageData i) -> i == "") img_
|
|
Nothing -> False
|
|
|
|
readBlockedWordsConfig :: DirectoryOpts -> IO BlockedWordsConfig
|
|
readBlockedWordsConfig DirectoryOpts {blockedFragmentsFile, blockedWordsFile, nameSpellingFile, blockedExtensionRules, testing} = do
|
|
extensionRules <- maybe (pure []) (fmap read . readFile) blockedExtensionRules
|
|
spelling <- maybe (pure M.empty) (fmap (M.fromList . read) . readFile) nameSpellingFile
|
|
blockedFragments <- S.fromList <$> maybe (pure []) (fmap T.lines . T.readFile) blockedFragmentsFile
|
|
bws <- maybe (pure []) (fmap lines . readFile) blockedWordsFile
|
|
let blockedWords = S.fromList $ concatMap (wordVariants extensionRules) bws
|
|
unless testing $ putStrLn $ "Blocked fragments: " <> show (length blockedFragments) <> ", blocked words: " <> show (length blockedWords) <> ", spelling rules: " <> show (M.size spelling)
|
|
pure BlockedWordsConfig {blockedFragments, blockedWords, extensionRules, spelling}
|
|
|
|
directoryServiceEvent :: DirectoryLog -> DirectoryOpts -> ServiceState -> User -> ChatController -> Either ChatError ChatEvent -> IO ()
|
|
directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName, ownersGroup, searchResults} env@ServiceState {searchRequests} user@User {userId} cc event =
|
|
forM_ (crDirectoryEvent event) $ \case
|
|
DEContactConnected ct -> deContactConnected ct
|
|
DEGroupInvitation {contact = ct, groupInfo = g, fromMemberRole, memberRole} -> deGroupInvitation ct g fromMemberRole memberRole
|
|
DEServiceJoinedGroup ctId g owner -> deServiceJoinedGroup ctId g owner
|
|
DEGroupUpdated {member, fromGroup, toGroup} -> deGroupUpdated member fromGroup toGroup
|
|
DEPendingMember g m -> dePendingMember g m
|
|
DEPendingMemberMsg g m ciId t -> dePendingMemberMsg g m ciId t
|
|
DEContactRoleChanged g ctId role -> deContactRoleChanged g ctId role
|
|
DEServiceRoleChanged g role -> deServiceRoleChanged g role
|
|
DEContactRemovedFromGroup ctId g -> deContactRemovedFromGroup ctId g
|
|
DEContactLeftGroup ctId g -> deContactLeftGroup ctId g
|
|
DEServiceRemovedFromGroup g -> deServiceRemovedFromGroup g
|
|
DEGroupDeleted g -> deGroupDeleted g
|
|
DEUnsupportedMessage _ct _ciId -> pure ()
|
|
DEItemEditIgnored _ct -> pure ()
|
|
DEItemDeleteIgnored _ct -> pure ()
|
|
DEContactCommand ct ciId (ADC sUser cmd) -> do
|
|
logInfo $ "command received " <> directoryCmdTag cmd
|
|
case sUser of
|
|
SDRUser -> deUserCommand ct ciId cmd
|
|
SDRAdmin -> deAdminCommand ct ciId cmd
|
|
SDRSuperUser -> deSuperUserCommand ct ciId cmd
|
|
DELogChatResponse r -> logInfo r
|
|
where
|
|
groupLinkText (CCLink cReq sLnk_) = maybe (strEncodeTxt $ simplexChatContact cReq) strEncodeTxt sLnk_
|
|
withAdminUsers action = void . forkIO $ do
|
|
forM_ superUsers $ \KnownContact {contactId} -> action contactId
|
|
forM_ adminUsers $ \KnownContact {contactId} -> action contactId
|
|
withSuperUsers action = void . forkIO $ forM_ superUsers $ \KnownContact {contactId} -> action contactId
|
|
notifyAdminUsers s = withAdminUsers $ \contactId -> sendMessage' cc contactId s
|
|
notifyOwner = sendMessage' cc . dbContactId
|
|
ctId `isOwner` GroupReg {dbContactId} = ctId == dbContactId
|
|
withGroupReg :: GroupInfo -> Text -> (GroupReg -> IO ()) -> IO ()
|
|
withGroupReg GroupInfo {groupId, localDisplayName} err action =
|
|
getGroupReg cc groupId >>= \case
|
|
Right gr -> action gr
|
|
Left e -> do
|
|
let msg = "Error: " <> err <> ", group: " <> tshow groupId <> " " <> localDisplayName <> ", " <> T.pack e
|
|
notifyAdminUsers msg
|
|
logError msg
|
|
groupInfoText p@GroupProfile {description = d} = groupNameDescr p <> maybe "" ("\nWelcome message:\n" <>) d
|
|
knockingStr :: Maybe GroupMemberAdmission -> [Text]
|
|
knockingStr = \case
|
|
Just GroupMemberAdmission {review = Just MCAll} -> ["New members are reviewed by admins"]
|
|
_ -> []
|
|
groupNameDescr GroupProfile {displayName = n, fullName = fn, shortDescr = sd_} =
|
|
n <> maybe "" (\d' -> " (" <> d' <> ")") descr
|
|
where
|
|
descr
|
|
| n == fn || T.null fn = if sd_ == Just "" then Nothing else sd_
|
|
| otherwise = Just fn
|
|
userGroupReference gr GroupInfo {groupProfile = GroupProfile {displayName}} = userGroupReference' gr displayName
|
|
userGroupReference' GroupReg {userGroupRegId} displayName = groupReference' userGroupRegId displayName
|
|
groupReference GroupInfo {groupId, groupProfile = GroupProfile {displayName}} = groupReference' groupId displayName
|
|
groupReference' groupId displayName = "ID " <> tshow groupId <> " (" <> displayName <> ")"
|
|
groupAlreadyListed GroupInfo {groupProfile = p} =
|
|
"The group " <> groupNameDescr p <> " is already listed in the directory, please choose another name."
|
|
|
|
getDuplicateGroup :: GroupInfo -> IO (Either String DuplicateGroup)
|
|
getDuplicateGroup GroupInfo {groupId, groupProfile = GroupProfile {displayName}} =
|
|
duplicateGroup <$$> getDuplicateGroupRegs cc user displayName
|
|
where
|
|
duplicateGroup [] = DGUnique
|
|
duplicateGroup ((GroupInfo {groupId = gId, membership}, GroupReg {groupRegStatus = status}) : groups)
|
|
| gId == groupId || memberRemoved membership = duplicateGroup groups
|
|
| otherwise = case grDirectoryStatus status of
|
|
DSListed -> DGReserved
|
|
DSReserved -> DGReserved
|
|
DSRegistered -> case duplicateGroup groups of
|
|
DGReserved -> DGReserved
|
|
_ -> DGRegistered
|
|
DSRemoved -> duplicateGroup groups
|
|
|
|
processInvitation :: Contact -> GroupInfo -> Maybe GroupReg -> IO ()
|
|
processInvitation ct g@GroupInfo {groupId, groupProfile = GroupProfile {displayName}} = \case
|
|
Nothing -> addGroupReg notifyAdminUsers st cc ct g GRSProposed joinGroup
|
|
Just _gr -> setGroupStatus notifyAdminUsers st env cc groupId GRSProposed joinGroup
|
|
where
|
|
joinGroup _ = do
|
|
r <- sendChatCmd cc $ APIJoinGroup groupId MFNone
|
|
sendMessage cc ct $ case r of
|
|
Right CRUserAcceptedGroupSent {} -> "Joining the group " <> displayName <> "…"
|
|
_ -> "Error joining group " <> displayName <> ", please re-send the invitation!"
|
|
|
|
deContactConnected :: Contact -> IO ()
|
|
deContactConnected ct = when (contactDirect ct) $ do
|
|
logInfo $ (viewContactName ct) <> " connected"
|
|
sendMessage cc ct $
|
|
("Welcome to " <> serviceName <> "!\n\n")
|
|
<> "🔍 Send search string to find groups - try _security_.\n\
|
|
\/help - how to submit your group.\n\
|
|
\/new - recent groups.\n\n\
|
|
\[Directory rules](https://simplex.chat/docs/directory.html)."
|
|
|
|
deGroupInvitation :: Contact -> GroupInfo -> GroupMemberRole -> GroupMemberRole -> IO ()
|
|
deGroupInvitation ct g@GroupInfo {groupProfile = p@GroupProfile {displayName}} fromMemberRole memberRole = do
|
|
logInfo $ "invited to group " <> viewGroupName g <> " by " <> viewContactName ct
|
|
case badRolesMsg $ groupRolesStatus fromMemberRole memberRole of
|
|
Just msg -> sendMessage cc ct msg
|
|
Nothing ->
|
|
getDuplicateGroup g >>= \case
|
|
Right DGUnique -> processInvitation ct g Nothing
|
|
Right DGRegistered -> askConfirmation
|
|
Right DGReserved -> sendMessage cc ct $ groupAlreadyListed g
|
|
Left e -> sendMessage cc ct $ "Error: getDuplicateGroup. Please notify the developers.\n" <> T.pack e
|
|
where
|
|
askConfirmation =
|
|
addGroupReg notifyAdminUsers st cc ct g GRSPendingConfirmation $ \GroupReg {userGroupRegId} -> do
|
|
sendMessage cc ct $ "The group " <> groupNameDescr p <> " is already submitted to the directory.\nTo confirm the registration, please send:"
|
|
sendMessage cc ct $ "/confirm " <> tshow userGroupRegId <> ":" <> viewName displayName
|
|
|
|
badRolesMsg :: GroupRolesStatus -> Maybe Text
|
|
badRolesMsg = \case
|
|
GRSOk -> Nothing
|
|
GRSServiceNotAdmin -> Just "You must grant directory service *admin* role to register the group"
|
|
GRSContactNotOwner -> Just "You must have a group *owner* role to register the group"
|
|
GRSBadRoles -> Just "You must have a group *owner* role and you must grant directory service *admin* role to register the group"
|
|
|
|
getGroupRolesStatus :: GroupInfo -> GroupReg -> IO (Either String GroupRolesStatus)
|
|
getGroupRolesStatus GroupInfo {groupId, membership = GroupMember {memberRole = serviceRole}} gr =
|
|
rStatus <$$> getOwnerGroupMember groupId gr
|
|
where
|
|
rStatus GroupMember {memberRole} = groupRolesStatus memberRole serviceRole
|
|
|
|
groupRolesStatus :: GroupMemberRole -> GroupMemberRole -> GroupRolesStatus
|
|
groupRolesStatus contactRole serviceRole = case (contactRole, serviceRole) of
|
|
(GROwner, GRAdmin) -> GRSOk
|
|
(_, GRAdmin) -> GRSContactNotOwner
|
|
(GROwner, _) -> GRSServiceNotAdmin
|
|
_ -> GRSBadRoles
|
|
|
|
getOwnerGroupMember :: GroupId -> GroupReg -> IO (Either String GroupMember)
|
|
getOwnerGroupMember gId GroupReg {dbOwnerMemberId} = case dbOwnerMemberId of
|
|
Just mId -> withDB "getGroupMember" cc $ \db -> withExceptT show $ getGroupMember db (vr cc) user gId mId
|
|
Nothing -> pure $ Left "no owner member in group registration"
|
|
|
|
deServiceJoinedGroup :: ContactId -> GroupInfo -> GroupMember -> IO ()
|
|
deServiceJoinedGroup ctId g@GroupInfo {groupId} owner = do
|
|
logInfo $ "service joined group " <> viewGroupName g
|
|
withGroupReg g "joined group" $ \gr ->
|
|
when (ctId `isOwner` gr) $ do
|
|
let GroupInfo {groupProfile = GroupProfile {displayName}} = g
|
|
setGroupRegOwner cc groupId owner >>= \case
|
|
Left e -> do
|
|
let msg = "Error updating group " <> tshow groupId <> " owner: " <> T.pack e
|
|
logError msg
|
|
notifyOwner gr msg
|
|
Right () -> do
|
|
logGUpdateOwner st groupId $ groupMemberId' owner
|
|
notifyOwner gr $ "Joined the group " <> displayName <> ", creating the link…"
|
|
sendChatCmd cc (APICreateGroupLink groupId GRMember) >>= \case
|
|
Right CRGroupLinkCreated {groupLink = GroupLink {connLinkContact = gLink}} ->
|
|
setGroupStatus notifyAdminUsers st env cc groupId GRSPendingUpdate $ \gr' -> do
|
|
notifyOwner
|
|
gr'
|
|
"Created the public link to join the group via this directory service that is always online.\n\n\
|
|
\Please add it to the group welcome message.\n\
|
|
\For example, add:"
|
|
notifyOwner gr' $ "Link to join the group " <> displayName <> ": " <> groupLinkText gLink
|
|
Left (ChatError e) -> case e of
|
|
CEGroupUserRole {} -> notifyOwner gr "Failed creating group link, as service is no longer an admin."
|
|
CEGroupMemberUserRemoved -> notifyOwner gr "Failed creating group link, as service is removed from the group."
|
|
CEGroupNotJoined _ -> notifyOwner gr $ unexpectedError "group not joined"
|
|
CEGroupMemberNotActive -> notifyOwner gr $ unexpectedError "service membership is not active"
|
|
_ -> notifyOwner gr $ unexpectedError "can't create group link"
|
|
_ -> notifyOwner gr $ unexpectedError "can't create group link"
|
|
|
|
deGroupUpdated :: GroupMember -> GroupInfo -> GroupInfo -> IO ()
|
|
deGroupUpdated m@GroupMember {memberProfile = LocalProfile {displayName = mName}} fromGroup toGroup = do
|
|
logInfo $ "group updated " <> viewGroupName toGroup
|
|
unless (sameProfile p p') $ do
|
|
withGroupReg toGroup "group updated" $ \gr@GroupReg {groupRegStatus} -> do
|
|
let userGroupRef = userGroupReference gr toGroup
|
|
byMember = case memberContactId m of
|
|
Just ctId | ctId `isOwner` gr -> "" -- group registration owner, not any group owner.
|
|
_ -> " by " <> mName -- owner notification from directory will include the name.
|
|
case groupRegStatus of
|
|
GRSPendingConfirmation -> pure ()
|
|
GRSProposed -> pure ()
|
|
GRSPendingUpdate ->
|
|
groupProfileUpdate >>= \case
|
|
GPNoServiceLink ->
|
|
notifyOwner gr $ "The profile updated for " <> userGroupRef <> byMember <> ", but the group link is not added to the welcome message."
|
|
GPServiceLinkAdded _ -> groupLinkAdded gr byMember
|
|
GPServiceLinkRemoved ->
|
|
notifyOwner gr $
|
|
"The group link of " <> userGroupRef <> " is removed from the welcome message" <> byMember <> ", please add it."
|
|
GPHasServiceLink {} -> groupLinkAdded gr byMember
|
|
GPServiceLinkError -> do
|
|
notifyOwner gr $
|
|
("Error: " <> serviceName <> " has no group link for " <> userGroupRef)
|
|
<> " after profile was updated"
|
|
<> byMember
|
|
<> ". Please report the error to the developers."
|
|
logError $ "Error: no group link for " <> userGroupRef
|
|
GRSPendingApproval n -> processProfileChange gr byMember False $ n + 1
|
|
GRSActive -> processProfileChange gr byMember True 1
|
|
GRSSuspended -> processProfileChange gr byMember False 1
|
|
GRSSuspendedBadRoles -> processProfileChange gr byMember False 1
|
|
GRSRemoved -> pure ()
|
|
where
|
|
GroupInfo {groupId, groupProfile = p} = fromGroup
|
|
GroupInfo {groupProfile = p'} = toGroup
|
|
sameProfile
|
|
GroupProfile {displayName = n, fullName = fn, shortDescr = sd, image = i, description = d, memberAdmission = ma}
|
|
GroupProfile {displayName = n', fullName = fn', shortDescr = sd', image = i', description = d', memberAdmission = ma'} =
|
|
n == n' && fn == fn' && i == i' && sd == sd' && (T.words <$> d) == (T.words <$> d') && ma == ma'
|
|
groupLinkAdded gr byMember =
|
|
getDuplicateGroup toGroup >>= \case
|
|
Left e -> notifyOwner gr $ "Error: getDuplicateGroup. Please notify the developers.\n" <> T.pack e
|
|
Right DGReserved -> notifyOwner gr $ groupAlreadyListed toGroup
|
|
_ -> setGroupStatus notifyAdminUsers st env cc groupId (GRSPendingApproval gaId) $ \gr' -> do
|
|
notifyOwner gr' $
|
|
("Thank you! The group link for " <> userGroupReference gr' toGroup <> " is added to the welcome message" <> byMember)
|
|
<> ".\nYou will be notified once the group is added to the directory - it may take up to 48 hours."
|
|
checkRolesSendToApprove gr' gaId
|
|
where
|
|
gaId = 1
|
|
processProfileChange gr byMember isActive n' = do
|
|
let userGroupRef = userGroupReference gr toGroup
|
|
groupRef = groupReference toGroup
|
|
groupProfileUpdate >>= \case
|
|
GPNoServiceLink -> setGroupStatus notifyAdminUsers st env cc groupId GRSPendingUpdate $ \gr' -> do
|
|
notifyOwner gr' $
|
|
("The group profile is updated for " <> userGroupRef <> byMember <> ", but no link is added to the welcome message.\n\n")
|
|
<> "The group will remain hidden from the directory until the group link is added and the group is re-approved."
|
|
GPServiceLinkRemoved -> setGroupStatus notifyAdminUsers st env cc groupId GRSPendingUpdate $ \gr' -> do
|
|
notifyOwner gr' $
|
|
("The group link for " <> userGroupRef <> " is removed from the welcome message" <> byMember)
|
|
<> ".\n\nThe group is hidden from the directory until the group link is added and the group is re-approved."
|
|
notifyAdminUsers $ "The group link is removed from " <> groupRef <> ", de-listed."
|
|
GPServiceLinkAdded _ -> setGroupStatus notifyAdminUsers st env cc groupId (GRSPendingApproval n') $ \gr' -> do
|
|
notifyOwner gr' $
|
|
("The group link is added to " <> userGroupRef <> byMember)
|
|
<> "!\nIt is hidden from the directory until approved."
|
|
notifyAdminUsers $ "The group link is added to " <> groupRef <> byMember <> "."
|
|
checkRolesSendToApprove gr n'
|
|
GPHasServiceLink {linkBefore, linkNow}
|
|
| isActive && onlyLinkChanged p p' -> do
|
|
notifyOwner gr $
|
|
("The group " <> userGroupRef <> " is updated" <> byMember)
|
|
<> "!\nThe group is listed in directory."
|
|
notifyAdminUsers $ "The group " <> groupRef <> " is updated" <> byMember <> " - only link or whitespace changes.\nThe group remained listed in directory."
|
|
| otherwise -> setGroupStatus notifyAdminUsers st env cc groupId (GRSPendingApproval n') $ \gr' -> do
|
|
notifyOwner gr' $
|
|
("The group " <> userGroupRef <> " is updated" <> byMember)
|
|
<> "!\nIt is hidden from the directory until approved."
|
|
notifyAdminUsers $ "The group " <> groupRef <> " is updated" <> byMember <> "."
|
|
checkRolesSendToApprove gr' n'
|
|
where
|
|
onlyLinkChanged
|
|
GroupProfile {displayName = dn, fullName = fn, shortDescr = sd, image = i, description = d, memberAdmission = ma}
|
|
GroupProfile {displayName = dn', fullName = fn', shortDescr = sd', image = i', description = d', memberAdmission = ma'} =
|
|
dn == dn' && fn == fn' && i == i' && sd == sd' && ma == ma' && (T.words . T.replace linkBefore "" <$> d) == (T.words . T.replace linkNow "" <$> d')
|
|
GPServiceLinkError -> logError $ "Error: no group link for " <> groupRef <> " pending approval."
|
|
groupProfileUpdate = profileUpdate <$> sendChatCmd cc (APIGetGroupLink groupId)
|
|
where
|
|
profileUpdate = \case
|
|
Right CRGroupLink {groupLink = GroupLink {connLinkContact = CCLink cr sl_}} ->
|
|
let linkBefore_ = profileGroupLinkText fromGroup
|
|
linkNow_ = profileGroupLinkText toGroup
|
|
profileGroupLinkText GroupInfo {groupProfile = gp} =
|
|
maybe Nothing (fmap (\(FormattedText _ t) -> t) . find ftHasLink) $ parseMaybeMarkdownList =<< description gp
|
|
ftHasLink = \case
|
|
FormattedText (Just SimplexLink {simplexUri = ACL SCMContact cLink}) _ -> case cLink of
|
|
CLFull cr' -> sameConnReqContact cr' cr
|
|
CLShort sl' -> maybe False (sameShortLinkContact sl') sl_
|
|
_ -> False
|
|
in case (linkBefore_, linkNow_) of
|
|
(Just linkBefore, Just linkNow) -> GPHasServiceLink linkBefore linkNow
|
|
(Just _, Nothing) -> GPServiceLinkRemoved
|
|
(Nothing, Just linkNow) -> GPServiceLinkAdded linkNow
|
|
(Nothing, Nothing) -> GPNoServiceLink
|
|
_ -> GPServiceLinkError
|
|
checkRolesSendToApprove gr gaId = do
|
|
(badRolesMsg <$$> getGroupRolesStatus toGroup gr) >>= \case
|
|
Left e -> notifyOwner gr $ "Error: getGroupRolesStatus. Please notify the developers.\n" <> T.pack e
|
|
Right (Just msg) -> notifyOwner gr msg
|
|
Right Nothing -> sendToApprove toGroup gr gaId
|
|
|
|
dePendingMember :: GroupInfo -> GroupMember -> IO ()
|
|
dePendingMember g@GroupInfo {groupProfile = GroupProfile {displayName}} m
|
|
| memberRequiresCaptcha a m = sendMemberCaptcha g m Nothing captchaNotice 0 CMText
|
|
| otherwise = approvePendingMember a g m
|
|
where
|
|
a = groupMemberAcceptance g
|
|
captchaNotice =
|
|
"Captcha is generated by SimpleX Directory service.\n\n*Send captcha text* to join the group " <> displayName <> "."
|
|
<> if canSendVoiceCaptcha g m then "\nSend /audio to receive a voice captcha." else ""
|
|
|
|
sendMemberCaptcha :: GroupInfo -> GroupMember -> Maybe ChatItemId -> Text -> Int -> CaptchaMode -> IO ()
|
|
sendMemberCaptcha GroupInfo {groupId} m quotedId noticeText prevAttempts mode = do
|
|
s <- getCaptchaStr captchaLength ""
|
|
sentAt <- getCurrentTime
|
|
let captcha = PendingCaptcha {captchaText = T.pack s, sentAt, attempts = prevAttempts + 1, captchaMode = mode}
|
|
atomically $ TM.insert gmId captcha $ pendingCaptchas env
|
|
case mode of
|
|
CMAudio -> do
|
|
mc <- getCaptchaContent s
|
|
sendComposedMessages_ cc sendRef [(quotedId, MCText noticeText), (Nothing, mc)]
|
|
sendVoiceCaptcha sendRef s
|
|
CMText -> do
|
|
mc <- getCaptchaContent s
|
|
sendComposedMessages_ cc sendRef [(quotedId, MCText noticeText), (Nothing, mc)]
|
|
where
|
|
sendRef = SRGroup groupId (Just $ GCSMemberSupport (Just gmId)) False
|
|
gmId = groupMemberId' m
|
|
|
|
sendVoiceCaptcha :: SendRef -> String -> IO ()
|
|
sendVoiceCaptcha sendRef s =
|
|
forM_ (voiceCaptchaGenerator opts) $ \script ->
|
|
void . forkIO $ do
|
|
voiceResult <- try $ readProcess script [s] "" :: IO (Either SomeException String)
|
|
case voiceResult of
|
|
Right r -> case lines r of
|
|
(filePath : durationStr : _)
|
|
| not (null filePath), Just duration <- readMaybe durationStr -> do
|
|
sendComposedMessageFile cc sendRef Nothing (MCVoice "" duration) (CF.plain filePath)
|
|
void (try $ removeFile filePath :: IO (Either SomeException ()))
|
|
_ -> logError "voice captcha generator: unexpected output"
|
|
Left e -> logError $ "voice captcha generator error: " <> tshow e
|
|
|
|
getCaptchaContent :: String -> IO MsgContent
|
|
getCaptchaContent s = case captchaGenerator opts of
|
|
Nothing -> pure $ MCText $ T.pack s
|
|
Just script -> content <$> readProcess script [s] ""
|
|
where
|
|
content r = case T.lines $ T.pack r of
|
|
[] -> textMsg
|
|
"" : _ -> textMsg
|
|
img : _ -> MCImage "" $ ImageData img
|
|
textMsg = MCText $ T.pack s
|
|
|
|
canSendVoiceCaptcha :: GroupInfo -> GroupMember -> Bool
|
|
canSendVoiceCaptcha gInfo m =
|
|
isJust (voiceCaptchaGenerator opts)
|
|
&& (groupFeatureUserAllowed SGFVoice gInfo || supportsVersion m memberSupportVoiceVersion)
|
|
|
|
approvePendingMember :: DirectoryMemberAcceptance -> GroupInfo -> GroupMember -> IO ()
|
|
approvePendingMember a g@GroupInfo {groupId} m@GroupMember {memberProfile = LocalProfile {displayName, image}} = do
|
|
gli_ <- join . eitherToMaybe <$> withDB' "getGroupLinkInfo" cc (\db -> getGroupLinkInfo db userId groupId)
|
|
let role = if useMemberFilter image (makeObserver a) then GRObserver else maybe GRMember (\GroupLinkInfo {memberRole} -> memberRole) gli_
|
|
gmId = groupMemberId' m
|
|
sendChatCmd cc (APIAcceptMember groupId gmId role) >>= \case
|
|
Right CRMemberAccepted {member} -> do
|
|
atomically $ TM.delete gmId $ pendingCaptchas env
|
|
if memberStatus member == GSMemPendingReview
|
|
then logInfo $ "Member " <> viewName displayName <> " accepted and pending review, group " <> tshow groupId <> ":" <> viewGroupName g
|
|
else logInfo $ "Member " <> viewName displayName <> " accepted, group " <> tshow groupId <> ":" <> viewGroupName g
|
|
r -> logError $ "unexpected accept member response: " <> tshow r
|
|
|
|
dePendingMemberMsg :: GroupInfo -> GroupMember -> ChatItemId -> Text -> IO ()
|
|
dePendingMemberMsg g@GroupInfo {groupId, groupProfile = GroupProfile {displayName = n}} m@GroupMember {memberProfile = LocalProfile {displayName}} ciId msgText
|
|
| memberRequiresCaptcha a m = do
|
|
let gmId = groupMemberId' m
|
|
sendRef = SRGroup groupId (Just $ GCSMemberSupport (Just gmId)) False
|
|
-- /audio is matched as text, not as DirectoryCmd, because it is only valid
|
|
-- in group context at captcha stage, while DirectoryCmd is for DM commands.
|
|
isAudioCmd = T.strip msgText == "/audio"
|
|
cmd = fromRight (ADC SDRUser DCUnknownCommand) $ A.parseOnly (directoryCmdP <* A.endOfInput) $ T.strip msgText
|
|
atomically (TM.lookup gmId $ pendingCaptchas env) >>= \case
|
|
Nothing
|
|
| isAudioCmd && canSendVoiceCaptcha g m -> sendMemberCaptcha g m (Just ciId) noCaptcha 0 CMAudio
|
|
| isAudioCmd -> sendComposedMessages_ cc sendRef [(Just ciId, MCText voiceCaptchaUnavailable)]
|
|
| otherwise -> sendMemberCaptcha g m (Just ciId) noCaptcha 0 CMText
|
|
Just pc@PendingCaptcha {captchaText, sentAt, attempts, captchaMode}
|
|
| isAudioCmd ->
|
|
if canSendVoiceCaptcha g m
|
|
then case captchaMode of
|
|
CMText -> do
|
|
atomically $ TM.insert gmId pc {captchaMode = CMAudio} $ pendingCaptchas env
|
|
sendVoiceCaptcha sendRef (T.unpack captchaText)
|
|
CMAudio ->
|
|
sendComposedMessages_ cc sendRef [(Just ciId, MCText audioAlreadyEnabled)]
|
|
else sendComposedMessages_ cc sendRef [(Just ciId, MCText voiceCaptchaUnavailable)]
|
|
| otherwise -> case cmd of
|
|
ADC SDRUser (DCSearchGroup _) -> do
|
|
ts <- getCurrentTime
|
|
if
|
|
| ts `diffUTCTime` sentAt > captchaTTL -> sendMemberCaptcha g m (Just ciId) captchaExpired (attempts - 1) captchaMode
|
|
| matchCaptchaStr captchaText msgText -> do
|
|
sendComposedMessages_ cc sendRef [(Just ciId, MCText $ "Correct, you joined the group " <> n)]
|
|
approvePendingMember a g m
|
|
| attempts >= maxCaptchaAttempts -> rejectPendingMember tooManyAttempts
|
|
| otherwise -> sendMemberCaptcha g m (Just ciId) (wrongCaptcha attempts) attempts captchaMode
|
|
_ -> sendComposedMessages_ cc sendRef [(Just ciId, MCText unknownCommand)]
|
|
| otherwise = approvePendingMember a g m
|
|
where
|
|
a = groupMemberAcceptance g
|
|
rejectPendingMember rjctNotice = do
|
|
let gmId = groupMemberId' m
|
|
sendComposedMessages cc (SRGroup groupId (Just $ GCSMemberSupport (Just gmId)) False) [MCText rjctNotice]
|
|
sendChatCmd cc (APIRemoveMembers groupId [gmId] False) >>= \case
|
|
Right (CRUserDeletedMembers _ _ (_ : _) _ _) -> do
|
|
atomically $ TM.delete gmId $ pendingCaptchas env
|
|
logInfo $ "Member " <> viewName displayName <> " rejected, group " <> tshow groupId <> ":" <> viewGroupName g
|
|
r -> logError $ "unexpected remove member response: " <> tshow r
|
|
captchaExpired :: Text
|
|
captchaExpired = "Captcha expired, please try again."
|
|
wrongCaptcha :: Int -> Text
|
|
wrongCaptcha attempts
|
|
| attempts == maxCaptchaAttempts - 1 = "Incorrect text, please try again - this is your last attempt."
|
|
| otherwise = "Incorrect text, please try again."
|
|
noCaptcha :: Text
|
|
noCaptcha = "Unexpected message, please try again."
|
|
audioAlreadyEnabled :: Text
|
|
audioAlreadyEnabled = "Audio captcha is already enabled."
|
|
voiceCaptchaUnavailable :: Text
|
|
voiceCaptchaUnavailable = "Voice captcha is not available - please update SimpleX Chat to v6.5+ or use text captcha."
|
|
unknownCommand :: Text
|
|
unknownCommand = "Unknown command, please enter captcha text."
|
|
tooManyAttempts :: Text
|
|
tooManyAttempts = "Too many failed attempts, you can't join group."
|
|
|
|
memberRequiresCaptcha :: DirectoryMemberAcceptance -> GroupMember -> Bool
|
|
memberRequiresCaptcha a GroupMember {memberProfile = LocalProfile {image}} =
|
|
useMemberFilter image $ passCaptcha a
|
|
|
|
sendToApprove :: GroupInfo -> GroupReg -> GroupApprovalId -> IO ()
|
|
sendToApprove GroupInfo {groupId, groupProfile = p@GroupProfile {displayName, image = image'}, groupSummary} GroupReg {dbContactId, promoted} gaId = do
|
|
ct_ <- getContact' cc user dbContactId
|
|
let membersStr = "_" <> tshow (currentMembers groupSummary) <> " members_\n"
|
|
text =
|
|
either (\_ -> "The group ID " <> tshow groupId <> " submitted: ") (\c -> localDisplayName' c <> " submitted the group ID " <> tshow groupId <> ": ") ct_
|
|
<> ("\n" <> groupInfoText p <> "\n" <> membersStr <> "\nTo approve send:")
|
|
msg = maybe (MCText text) (\image -> MCImage {text, image}) image'
|
|
withAdminUsers $ \cId -> do
|
|
let approveCmd = MCText $ "/approve " <> tshow groupId <> ":" <> viewName displayName <> " " <> tshow gaId <> if promoted then " promote=on" else ""
|
|
sendComposedMessages cc (SRDirect cId) [msg, approveCmd]
|
|
|
|
deContactRoleChanged :: GroupInfo -> ContactId -> GroupMemberRole -> IO ()
|
|
deContactRoleChanged g@GroupInfo {groupId, membership = GroupMember {memberRole = serviceRole}} ctId contactRole = do
|
|
logInfo $ "contact ID " <> tshow ctId <> " role changed in group " <> viewGroupName g <> " to " <> tshow contactRole
|
|
withGroupReg g "contact role changed" $ \gr@GroupReg {groupRegStatus} -> do
|
|
let userGroupRef = userGroupReference gr g
|
|
uCtRole = "Your role in the group " <> userGroupRef <> " is changed to " <> ctRole
|
|
when (ctId `isOwner` gr) $
|
|
case groupRegStatus of
|
|
GRSSuspendedBadRoles | rStatus == GRSOk ->
|
|
setGroupStatus notifyAdminUsers st env cc groupId GRSActive $ \gr' -> do
|
|
notifyOwner gr' $ uCtRole <> ".\n\nThe group is listed in the directory again."
|
|
notifyAdminUsers $ "The group " <> groupRef <> " is listed " <> suCtRole
|
|
GRSPendingApproval gaId | rStatus == GRSOk -> do
|
|
sendToApprove g gr gaId
|
|
notifyOwner gr $ uCtRole <> ".\n\nThe group is submitted for approval."
|
|
GRSActive | rStatus /= GRSOk ->
|
|
setGroupStatus notifyAdminUsers st env cc groupId GRSSuspendedBadRoles $ \gr' -> do
|
|
notifyOwner gr' $ uCtRole <> ".\n\nThe group is no longer listed in the directory."
|
|
notifyAdminUsers $ "The group " <> groupRef <> " is de-listed " <> suCtRole
|
|
_ -> pure ()
|
|
where
|
|
rStatus = groupRolesStatus contactRole serviceRole
|
|
groupRef = groupReference g
|
|
ctRole = "*" <> textEncode contactRole <> "*"
|
|
suCtRole = "(user role is set to " <> ctRole <> ")."
|
|
|
|
deServiceRoleChanged :: GroupInfo -> GroupMemberRole -> IO ()
|
|
deServiceRoleChanged g@GroupInfo {groupId} serviceRole = do
|
|
logInfo $ "service role changed in group " <> viewGroupName g <> " to " <> tshow serviceRole
|
|
withGroupReg g "service role changed" $ \gr@GroupReg {groupRegStatus} -> do
|
|
let userGroupRef = userGroupReference gr g
|
|
uSrvRole = serviceName <> " role in the group " <> userGroupRef <> " is changed to " <> srvRole
|
|
case groupRegStatus of
|
|
GRSSuspendedBadRoles | serviceRole == GRAdmin ->
|
|
whenContactIsOwner gr $
|
|
setGroupStatus notifyAdminUsers st env cc groupId GRSActive $ \gr' -> do
|
|
notifyOwner gr' $ uSrvRole <> ".\n\nThe group is listed in the directory again."
|
|
notifyAdminUsers $ "The group " <> groupRef <> " is listed " <> suSrvRole
|
|
GRSPendingApproval gaId | serviceRole == GRAdmin ->
|
|
whenContactIsOwner gr $ do
|
|
sendToApprove g gr gaId
|
|
notifyOwner gr $ uSrvRole <> ".\n\nThe group is submitted for approval."
|
|
GRSActive | serviceRole /= GRAdmin ->
|
|
setGroupStatus notifyAdminUsers st env cc groupId GRSSuspendedBadRoles $ \gr' -> do
|
|
notifyOwner gr' $ uSrvRole <> ".\n\nThe group is no longer listed in the directory."
|
|
notifyAdminUsers $ "The group " <> groupRef <> " is de-listed " <> suSrvRole
|
|
_ -> pure ()
|
|
where
|
|
groupRef = groupReference g
|
|
srvRole = "*" <> textEncode serviceRole <> "*"
|
|
suSrvRole = "(" <> serviceName <> " role is changed to " <> srvRole <> ")."
|
|
whenContactIsOwner gr action =
|
|
getOwnerGroupMember groupId gr
|
|
>>= mapM_ (\cm@GroupMember {memberRole} -> when (memberRole == GROwner && memberActive cm) action)
|
|
|
|
deContactRemovedFromGroup :: ContactId -> GroupInfo -> IO ()
|
|
deContactRemovedFromGroup ctId g@GroupInfo {groupId} = do
|
|
logInfo $ "contact ID " <> tshow ctId <> " removed from group " <> viewGroupName g
|
|
withGroupReg g "contact removed" $ \gr -> do
|
|
when (ctId `isOwner` gr) $
|
|
setGroupStatus notifyAdminUsers st env cc groupId GRSRemoved $ \gr' -> do
|
|
notifyOwner gr' $ "You are removed from the group " <> userGroupReference gr' g <> ".\n\nThe group is no longer listed in the directory."
|
|
notifyAdminUsers $ "The group " <> groupReference g <> " is de-listed (group owner is removed)."
|
|
|
|
deContactLeftGroup :: ContactId -> GroupInfo -> IO ()
|
|
deContactLeftGroup ctId g@GroupInfo {groupId} = do
|
|
logInfo $ "contact ID " <> tshow ctId <> " left group " <> viewGroupName g
|
|
-- TODO combine
|
|
withGroupReg g "contact left" $ \gr ->
|
|
when (ctId `isOwner` gr) $
|
|
setGroupStatus notifyAdminUsers st env cc groupId GRSRemoved $ \gr' -> do
|
|
notifyOwner gr' $ "You left the group " <> userGroupReference gr g <> ".\n\nThe group is no longer listed in the directory."
|
|
notifyAdminUsers $ "The group " <> groupReference g <> " is de-listed (group owner left)."
|
|
|
|
deServiceRemovedFromGroup :: GroupInfo -> IO ()
|
|
deServiceRemovedFromGroup g@GroupInfo {groupId} = do
|
|
logInfo $ "service removed from group " <> viewGroupName g
|
|
setGroupStatus notifyAdminUsers st env cc groupId GRSRemoved $ \gr -> do
|
|
notifyOwner gr $ serviceName <> " is removed from the group " <> userGroupReference gr g <> ".\n\nThe group is no longer listed in the directory."
|
|
notifyAdminUsers $ "The group " <> groupReference g <> " is de-listed (directory service is removed)."
|
|
|
|
deGroupDeleted :: GroupInfo -> IO ()
|
|
deGroupDeleted g@GroupInfo {groupId} = do
|
|
logInfo $ "group removed " <> viewGroupName g
|
|
setGroupStatus notifyAdminUsers st env cc groupId GRSRemoved $ \gr -> do
|
|
notifyOwner gr $ "The group " <> userGroupReference gr g <> " is deleted.\n\nThe group is no longer listed in the directory."
|
|
notifyAdminUsers $ "The group " <> groupReference g <> " is de-listed (group is deleted)."
|
|
|
|
deUserCommand :: Contact -> ChatItemId -> DirectoryCmd 'DRUser -> IO ()
|
|
deUserCommand ct ciId = \case
|
|
DCHelp DHSRegistration ->
|
|
sendMessage cc ct $
|
|
"You must be the group owner to add it to the directory:\n\n\
|
|
\1️⃣ *Invite* "
|
|
<> serviceName
|
|
<> " bot to your group as *admin* - it will create a link for new members to join.\n\
|
|
\2️⃣ *Add* this link to the group's welcome message.\n\
|
|
\3️⃣ We *review* your group. Once *approved*, anybody can find it.\n\n\
|
|
\_We usually approve within a day, except holidays_. [More details](https://simplex.chat/docs/directory.html#adding-groups-to-the-directory)."
|
|
DCHelp DHSCommands ->
|
|
sendMessage cc ct $
|
|
"/'help commands' - receive this help message.\n\
|
|
\/help - how to register your group to be added to directory.\n\
|
|
\/list - list the groups you registered.\n\
|
|
\`/role <ID>` - view and set default member role for your group.\n\
|
|
\`/filter <ID>` - view and set spam filter settings for group.\n\
|
|
\`/link <ID>` - view and upgrade group link.\n\
|
|
\`/delete <ID>:<NAME>` - remove the group you submitted from directory, with _ID_ and _name_ as shown by /list command.\n\n\
|
|
\To search for groups, send the search text."
|
|
DCSearchGroup s ->
|
|
sendFoundListedGroups (STSearch s) Nothing "No groups found" $ \gs n -> -- $ sendSearchResults s
|
|
let more = if n > length gs then ", sending top " <> tshow (length gs) else ""
|
|
in "Found " <> tshow n <> " group(s)" <> more <> "."
|
|
DCSearchNext ->
|
|
atomically (TM.lookup (contactId' ct) searchRequests) >>= \case
|
|
Just SearchRequest {searchType, searchTime, lastGroup} -> do
|
|
currentTime <- getCurrentTime
|
|
if diffUTCTime currentTime searchTime > 300 -- 5 minutes
|
|
then do
|
|
atomically $ TM.delete (contactId' ct) searchRequests
|
|
showAllGroups
|
|
else
|
|
sendFoundListedGroups searchType (Just lastGroup) "No more groups" $ \gs _ ->
|
|
"Sending " <> tshow (length gs) <> " more group(s)."
|
|
Nothing -> showAllGroups
|
|
where
|
|
showAllGroups = deUserCommand ct ciId DCAllGroups
|
|
DCAllGroups -> sendFoundListedGroups STAll Nothing "No groups listed" $ allGroupsReply "top"
|
|
DCRecentGroups -> sendFoundListedGroups STRecent Nothing "No groups listed" $ allGroupsReply "the most recent"
|
|
DCSubmitGroup _link -> pure ()
|
|
DCConfirmDuplicateGroup ugrId gName ->
|
|
withUserGroupReg ugrId gName $ \g@GroupInfo {groupProfile = GroupProfile {displayName}} gr@GroupReg {groupRegStatus} -> case groupRegStatus of
|
|
GRSPendingConfirmation ->
|
|
getDuplicateGroup g >>= \case
|
|
Left e -> sendMessage cc ct $ "Error: getDuplicateGroup. Please notify the developers.\n" <> T.pack e
|
|
Right DGReserved -> sendMessage cc ct $ groupAlreadyListed g
|
|
_ -> processInvitation ct g $ Just gr
|
|
_ -> sendReply $ "Error: the group ID " <> tshow ugrId <> " (" <> displayName <> ") is not pending confirmation."
|
|
DCListUserGroups ->
|
|
getUserGroupRegs cc user (contactId' ct) >>= \case
|
|
Left e -> sendReply $ "Error reading groups: " <> T.pack e
|
|
Right gs -> sendGroupsInfo ct ciId isAdmin (gs, length gs)
|
|
DCDeleteGroup gId gName ->
|
|
(if isAdmin then withGroupAndReg sendReply else withUserGroupReg) gId gName $ \GroupInfo {groupProfile = GroupProfile {displayName}} GroupReg {dbGroupId} -> do
|
|
delGroupReg cc dbGroupId >>= \case
|
|
Right () -> do
|
|
logGDelete st dbGroupId
|
|
sendReply $ (if isAdmin then "The group " else "Your group ") <> displayName <> " is deleted from the directory"
|
|
Left e -> sendReply $ "Error deleting group " <> displayName <> ": " <> T.pack e
|
|
DCMemberRole gId gName_ mRole_ ->
|
|
(if isAdmin then withGroupAndReg_ sendReply else withUserGroupReg_) gId gName_ $ \g _gr -> do
|
|
let GroupInfo {groupProfile = GroupProfile {displayName = n}} = g
|
|
case mRole_ of
|
|
Nothing ->
|
|
getGroupLink' cc user g >>= \case
|
|
Right GroupLink {connLinkContact = gLink, acceptMemberRole} -> do
|
|
let anotherRole = case acceptMemberRole of GRObserver -> GRMember; _ -> GRObserver
|
|
sendReply $
|
|
initialRole n acceptMemberRole
|
|
<> ("Send /'role " <> tshow gId <> " " <> textEncode anotherRole <> "' to change it.\n\n")
|
|
<> onlyViaLink gLink
|
|
Left _ -> sendReply $ "Error: failed reading the initial member role for the group " <> n
|
|
Just mRole -> do
|
|
setGroupLinkRole cc g mRole >>= \case
|
|
Just gLink -> sendReply $ initialRole n mRole <> "\n" <> onlyViaLink gLink
|
|
Nothing -> sendReply $ "Error: the initial member role for the group " <> n <> " was NOT upgated."
|
|
where
|
|
initialRole n mRole = "The initial member role for the group " <> n <> " is set to *" <> textEncode mRole <> "*\n"
|
|
onlyViaLink gLink = "*Please note*: it applies only to members joining via this link: " <> groupLinkText gLink
|
|
DCGroupFilter gId gName_ acceptance_ ->
|
|
(if isAdmin then withGroupAndReg_ sendReply else withUserGroupReg_) gId gName_ $ \g _gr -> do
|
|
let GroupInfo {groupProfile = GroupProfile {displayName = n}} = g
|
|
a = groupMemberAcceptance g
|
|
case acceptance_ of
|
|
Just a' | a /= a' -> do
|
|
let d = toCustomData $ DirectoryGroupData a'
|
|
withDB' "setGroupCustomData" cc (\db -> setGroupCustomData db user g $ Just d) >>= \case
|
|
Right () -> sendSettigns n a' " set to"
|
|
Left e -> sendReply $ "Error changing spam filter settings for group " <> n <> ": " <> T.pack e
|
|
_ -> sendSettigns n a ""
|
|
where
|
|
sendSettigns n a setTo =
|
|
sendReply $
|
|
T.unlines $
|
|
[ "Spam filter settings for group " <> n <> setTo <> ":",
|
|
"- reject long/inappropriate names: " <> showCondition (rejectNames a),
|
|
"- pass captcha to join: " <> showCondition (passCaptcha a),
|
|
-- "- make observer: " <> showCondition (makeObserver a) <> (if isJust (makeObserver a) then "" else " (use default set with /role command)"),
|
|
""
|
|
-- "Use */filter " <> tshow gId <> " <level>* to change spam filter level: no (disable), basic, moderate, strong.",
|
|
-- "Or use */filter " <> tshow gId <> " [name[=noimage]] [captcha[=noimage]] [observer[=noimage]]* for advanced filter configuration."
|
|
]
|
|
<> ["/'filter " <> tshow gId <> " name' - enable name filter" | isNothing (rejectNames a)]
|
|
<> ["/'filter " <> tshow gId <> " captcha' - enable captcha challenge" | isNothing (passCaptcha a)]
|
|
<> ["/'filter " <> tshow gId <> " name captcha' - enable both" | isNothing (rejectNames a) || isNothing (passCaptcha a)]
|
|
<> ["/'filter " <> tshow gId <> " off' - disable filter" | isJust (rejectNames a) || isJust (passCaptcha a)]
|
|
showCondition = \case
|
|
Nothing -> "_disabled_"
|
|
Just PCAll -> "_enabled_"
|
|
Just PCNoImage -> "_enabled for profiles without image_"
|
|
DCShowUpgradeGroupLink gId gName_ ->
|
|
(if isAdmin then withGroupAndReg_ sendReply else withUserGroupReg_) gId gName_ $ \GroupInfo {groupId, localDisplayName = gName} _ -> do
|
|
let groupRef = groupReference' gId gName
|
|
withGroupLinkResult groupRef (sendChatCmd cc $ APIGetGroupLink groupId) $
|
|
\GroupLink {connLinkContact = gLink@(CCLink _ sLnk_), acceptMemberRole, shortLinkDataSet, shortLinkLargeDataSet = BoolDef slLargeDataSet} -> do
|
|
let shouldBeUpgraded = isNothing sLnk_ || not shortLinkDataSet || not slLargeDataSet
|
|
sendReply $
|
|
T.unlines $
|
|
[ "The link to join the group " <> groupRef <> ":",
|
|
groupLinkText gLink,
|
|
"New member role: " <> textEncode acceptMemberRole
|
|
]
|
|
<> ["The link is being upgraded..." | shouldBeUpgraded]
|
|
when shouldBeUpgraded $ do
|
|
let send = sendComposedMessage cc ct Nothing . MCText . T.unlines
|
|
withGroupLinkResult groupRef (sendChatCmd cc $ APIAddGroupShortLink groupId) $
|
|
\GroupLink {connLinkContact = CCLink _ sLnk_'} -> case (sLnk_, sLnk_') of
|
|
(Just _, Just _) ->
|
|
send ["The group link is upgraded for: " <> groupRef, "No changes to group needed."]
|
|
(Nothing, Just sLnk) ->
|
|
sendComposedMessages
|
|
cc
|
|
(SRDirect $ contactId' ct)
|
|
[ MCText $
|
|
T.unlines
|
|
[ "Please replace the old link in welcome message of your group " <> groupRef,
|
|
"If this is the only change, the group will remain listed in directory without re-approval.",
|
|
"",
|
|
"The new link:"
|
|
],
|
|
MCText $ strEncodeTxt sLnk
|
|
]
|
|
(_, Nothing) ->
|
|
send ["The short link is not created for " <> groupRef, "Please report it to the developers."]
|
|
where
|
|
withGroupLinkResult groupRef a cb =
|
|
a >>= \case
|
|
Right CRGroupLink {groupLink} -> cb groupLink
|
|
Left (ChatErrorStore (SEGroupLinkNotFound _)) ->
|
|
sendReply $ "The group " <> groupRef <> " has no public link."
|
|
Right r -> do
|
|
ts <- getCurrentTime
|
|
tz <- getCurrentTimeZone
|
|
let resp = T.pack $ serializeChatResponse (Nothing, Just user) (config cc) ts tz Nothing r
|
|
sendReply $ "Unexpected error:\n" <> resp
|
|
Left e -> do
|
|
let resp = T.pack $ serializeChatError True (config cc) e
|
|
sendReply $ "Unexpected error:\n" <> resp
|
|
DCUnknownCommand -> sendReply "Unknown command"
|
|
DCCommandError tag -> sendReply $ "Command error: " <> tshow tag
|
|
where
|
|
knownCt = knownContact ct
|
|
isAdmin = knownCt `elem` adminUsers || knownCt `elem` superUsers
|
|
withUserGroupReg ugrId = withUserGroupReg_ ugrId . Just
|
|
withUserGroupReg_ ugrId gName_ action =
|
|
getUserGroupReg cc user (contactId' ct) ugrId >>= \case
|
|
-- TODO differentiate group not found error
|
|
Left e -> sendReply $ "Group ID " <> tshow ugrId <> " error:" <> T.pack e
|
|
Right (g@GroupInfo {groupProfile = GroupProfile {displayName}}, gr)
|
|
| maybe True (displayName ==) gName_ -> action g gr
|
|
| otherwise -> sendReply $ "Group ID " <> tshow ugrId <> " has the display name " <> displayName
|
|
sendReply = mkSendReply ct ciId
|
|
sendFoundListedGroups searchType lastGroup_ notFound replyStr =
|
|
searchListedGroups cc user searchType lastGroup_ searchResults >>= \case
|
|
Right ([], _) -> do
|
|
atomically $ TM.delete (contactId' ct) searchRequests
|
|
sendReply notFound
|
|
Right (gs, n) -> do
|
|
let moreGroups = n - length gs
|
|
updateSearchRequest searchType $ last gs
|
|
sendFoundGroups (replyStr gs n) gs moreGroups
|
|
Left e -> sendReply $ "Error: searchListedGroups. Please notify the developers.\n" <> T.pack e
|
|
allGroupsReply sortName gs n =
|
|
let more = if n > length gs then ", sending " <> sortName <> " " <> tshow (length gs) else ""
|
|
in tshow n <> " group(s) listed" <> more <> "."
|
|
updateSearchRequest :: SearchType -> (GroupInfo, GroupReg) -> IO ()
|
|
updateSearchRequest searchType (GroupInfo {groupId}, _) = do
|
|
searchTime <- getCurrentTime
|
|
let search = SearchRequest {searchType, searchTime, lastGroup = groupId}
|
|
atomically $ TM.insert (contactId' ct) search searchRequests
|
|
sendFoundGroups reply gs moreGroups =
|
|
void . forkIO $ sendComposedMessages_ cc (SRDirect $ contactId' ct) msgs
|
|
where
|
|
msgs = replyMsg :| map foundGroup gs <> [moreMsg | moreGroups > 0]
|
|
replyMsg = (Just ciId, MCText reply)
|
|
foundGroup (GroupInfo {groupId, groupProfile = p@GroupProfile {image = image_, memberAdmission}, groupSummary = GroupSummary {currentMembers}}, _) =
|
|
let membersStr = "_" <> tshow currentMembers <> " members_"
|
|
showId = if isAdmin then tshow groupId <> ". " else ""
|
|
text = T.unlines $ [showId <> groupInfoText p, membersStr] ++ knockingStr memberAdmission
|
|
in (Nothing, maybe (MCText text) (\image -> MCImage {text, image}) image_)
|
|
moreMsg = (Nothing, MCText $ "Send /next for " <> tshow moreGroups <> " more result(s).")
|
|
|
|
deAdminCommand :: Contact -> ChatItemId -> DirectoryCmd 'DRAdmin -> IO ()
|
|
deAdminCommand ct ciId cmd
|
|
| knownCt `elem` adminUsers || knownCt `elem` superUsers = case cmd of
|
|
DCApproveGroup {groupId, displayName = n, groupApprovalId, promote} ->
|
|
withGroupAndReg sendReply groupId n $ \g gr@GroupReg {userGroupRegId = ugrId, promoted} ->
|
|
case groupRegStatus gr of
|
|
GRSPendingApproval gaId
|
|
| gaId == groupApprovalId ->
|
|
getDuplicateGroup g >>= \case
|
|
Left e -> sendReply $ "Error: getDuplicateGroup. Please notify the developers.\n" <> T.pack e
|
|
Right DGReserved -> sendReply $ "The group " <> groupRef <> " is already listed in the directory."
|
|
_ -> getGroupRolesStatus g gr >>= \case
|
|
Right GRSOk -> do
|
|
let grPromoted'
|
|
| promoted || knownCt `elem` superUsers = fromMaybe promoted promote
|
|
| otherwise = False
|
|
setGroupStatusPromo sendReply st env cc gr GRSActive grPromoted' $ do
|
|
let approved = "The group " <> userGroupReference' gr n <> " is approved"
|
|
notifyOwner gr $
|
|
(approved <> " and listed in directory - please moderate it!\n")
|
|
<> "_Please note_: if you change the group profile it will be hidden from directory until it is re-approved.\n\n"
|
|
<> "Supported commands:\n"
|
|
<> ("/'filter " <> tshow ugrId <> "' - to configure anti-spam filter.\n")
|
|
<> ("/'role " <> tshow ugrId <> "' - to set default member role.\n")
|
|
<> ("/'link " <> tshow ugrId <> "' - to view/upgrade group link.")
|
|
invited <-
|
|
forM ownersGroup $ \og@KnownGroup {localDisplayName = ogName} -> do
|
|
inviteToOwnersGroup og gr $ \case
|
|
Right () -> do
|
|
owner <- groupOwnerInfo groupRef $ dbContactId gr
|
|
pure $ "Invited " <> owner <> " to owners' group " <> viewName ogName
|
|
Left err -> pure err
|
|
sendReply $ "Group approved" <> (if grPromoted' then " (promoted)" else "") <> "!" <> maybe "" ("\n" <>) invited
|
|
notifyOtherSuperUsers $ approved <> " by " <> viewName (localDisplayName' ct) <> maybe "" ("\n" <>) invited
|
|
Right GRSServiceNotAdmin -> replyNotApproved serviceNotAdmin
|
|
Right GRSContactNotOwner -> replyNotApproved "user is not an owner."
|
|
Right GRSBadRoles -> replyNotApproved $ "user is not an owner, " <> serviceNotAdmin
|
|
Left e -> sendReply $ "Error: getGroupRolesStatus. Please notify the developers.\n" <> T.pack e
|
|
where
|
|
replyNotApproved reason = sendReply $ "Group is not approved: " <> reason
|
|
serviceNotAdmin = serviceName <> " is not an admin."
|
|
| otherwise -> sendReply "Incorrect approval code"
|
|
_ -> sendReply $ "Error: the group " <> groupRef <> " is not pending approval."
|
|
where
|
|
groupRef = groupReference' groupId n
|
|
DCRejectGroup _gaId _gName -> pure ()
|
|
DCSuspendGroup groupId gName -> do
|
|
let groupRef = groupReference' groupId gName
|
|
withGroupAndReg sendReply groupId gName $ \_ gr ->
|
|
case groupRegStatus gr of
|
|
GRSActive -> setGroupStatus sendReply st env cc groupId GRSSuspended $ \gr' -> do
|
|
let suspended = "The group " <> userGroupReference' gr gName <> " is suspended"
|
|
notifyOwner gr' $ suspended <> " and hidden from directory. Please contact the administrators."
|
|
sendReply "Group suspended!"
|
|
notifyOtherSuperUsers $ suspended <> " by " <> viewName (localDisplayName' ct)
|
|
_ -> sendReply $ "The group " <> groupRef <> " is not active, can't be suspended."
|
|
DCResumeGroup groupId gName -> do
|
|
let groupRef = groupReference' groupId gName
|
|
withGroupAndReg sendReply groupId gName $ \_ gr ->
|
|
case groupRegStatus gr of
|
|
GRSSuspended -> setGroupStatus sendReply st env cc groupId GRSActive $ \gr' -> do
|
|
let groupStr = "The group " <> userGroupReference' gr gName
|
|
notifyOwner gr' $ groupStr <> " is listed in the directory again!"
|
|
sendReply "Group listing resumed!"
|
|
notifyOtherSuperUsers $ groupStr <> " listing resumed by " <> viewName (localDisplayName' ct)
|
|
_ -> sendReply $ "The group " <> groupRef <> " is not suspended, can't be resumed."
|
|
DCListLastGroups count ->
|
|
listLastGroups cc user count >>= \case
|
|
Left e -> sendReply $ "Error reading groups: " <> T.pack e
|
|
Right gs -> sendGroupsInfo ct ciId True $ first reverse gs
|
|
DCListPendingGroups count ->
|
|
listPendingGroups cc user count >>= \case
|
|
Left e -> sendReply $ "Error reading groups: " <> T.pack e
|
|
Right gs -> sendGroupsInfo ct ciId True $ first reverse gs
|
|
DCSendToGroupOwner groupId gName msg -> do
|
|
let groupRef = groupReference' groupId gName
|
|
withGroupAndReg sendReply groupId gName $ \_ gr@GroupReg {dbContactId = ctId} -> do
|
|
notifyOwner gr msg
|
|
owner <- groupOwnerInfo groupRef ctId
|
|
sendReply $ "Forwarded to " <> owner
|
|
DCInviteOwnerToGroup groupId gName -> case ownersGroup of
|
|
Just og@KnownGroup {localDisplayName = ogName} ->
|
|
withGroupAndReg sendReply groupId gName $ \_ gr@GroupReg {dbContactId = ctId} -> do
|
|
inviteToOwnersGroup og gr $ \case
|
|
Right () -> do
|
|
let groupRef = groupReference' groupId gName
|
|
owner <- groupOwnerInfo groupRef ctId
|
|
let invited = " invited " <> owner <> " to owners' group " <> viewName ogName
|
|
notifyOtherSuperUsers $ viewName (localDisplayName' ct) <> invited
|
|
sendReply $ "you" <> invited
|
|
Left err -> sendReply err
|
|
Nothing -> sendReply "owners' group is not specified"
|
|
-- DCAddBlockedWord _word -> pure ()
|
|
-- DCRemoveBlockedWord _word -> pure ()
|
|
DCCommandError tag -> sendReply $ "Command error: " <> tshow tag
|
|
| otherwise = sendReply "You are not allowed to use this command"
|
|
where
|
|
knownCt = knownContact ct
|
|
sendReply = mkSendReply ct ciId
|
|
notifyOtherSuperUsers s = withSuperUsers $ \ctId -> unless (ctId == contactId' ct) $ sendMessage' cc ctId s
|
|
inviteToOwnersGroup :: KnownGroup -> GroupReg -> (Either Text () -> IO a) -> IO a
|
|
inviteToOwnersGroup KnownGroup {groupId = ogId} GroupReg {dbContactId = ctId} cont =
|
|
sendChatCmd cc (APIListMembers ogId) >>= \case
|
|
Right (CRGroupMembers _ (Group _ ms))
|
|
| alreadyMember ms -> cont $ Left "Owner is already a member of owners' group"
|
|
| otherwise -> do
|
|
sendChatCmd cc (APIAddMember ogId ctId GRMember) >>= \case
|
|
Right CRSentGroupInvitation {} -> do
|
|
printLog cc CLLInfo $ "invited contact ID " <> show ctId <> " to owners' group"
|
|
cont $ Right ()
|
|
r -> contErr r
|
|
r -> contErr r
|
|
where
|
|
alreadyMember = isJust . find ((Just ctId ==) . memberContactId)
|
|
contErr r = do
|
|
let err = "error inviting contact ID " <> tshow ctId <> " to owners' group: " <> tshow r
|
|
putStrLn $ T.unpack err
|
|
cont $ Left err
|
|
groupOwnerInfo groupRef dbContactId = do
|
|
owner_ <- getContact' cc user dbContactId
|
|
let ownerInfo = "the owner of the group " <> groupRef
|
|
ownerName ct' = "@" <> viewName (localDisplayName' ct') <> ", "
|
|
pure $ either (const "") ownerName owner_ <> ownerInfo
|
|
|
|
deSuperUserCommand :: Contact -> ChatItemId -> DirectoryCmd 'DRSuperUser -> IO ()
|
|
deSuperUserCommand ct ciId cmd
|
|
| knownContact ct `elem` superUsers = case cmd of
|
|
DCPromoteGroup groupId gName promote' ->
|
|
withGroupAndReg sendReply groupId gName $ \_ gr@GroupReg {groupRegStatus, promoted} -> do
|
|
let notify = sendReply $ "Group promotion " <> (if promote' then "enabled" <> (if groupRegStatus == GRSActive then "." else ", but the group is not listed.") else "disabled.")
|
|
if promote' /= promoted
|
|
then setGroupPromoted sendReply st env cc gr promote' notify
|
|
else notify
|
|
DCExecuteCommand cmdStr ->
|
|
sendChatCmdStr cc cmdStr >>= \case
|
|
Right r -> do
|
|
ts <- getCurrentTime
|
|
tz <- getCurrentTimeZone
|
|
sendReply $ T.pack $ serializeChatResponse (Nothing, Just user) (config cc) ts tz Nothing r
|
|
Left e ->
|
|
sendReply $ T.pack $ serializeChatError True (config cc) e
|
|
DCCommandError tag -> sendReply $ "Command error: " <> tshow tag
|
|
| otherwise = sendReply "You are not allowed to use this command"
|
|
where
|
|
sendReply = mkSendReply ct ciId
|
|
|
|
knownContact :: Contact -> KnownContact
|
|
knownContact ct = KnownContact {contactId = contactId' ct, localDisplayName = localDisplayName' ct}
|
|
|
|
mkSendReply :: Contact -> ChatItemId -> Text -> IO ()
|
|
mkSendReply ct ciId = sendComposedMessage cc ct (Just ciId) . MCText
|
|
|
|
withGroupAndReg :: (Text -> IO ()) -> GroupId -> GroupName -> (GroupInfo -> GroupReg -> IO ()) -> IO ()
|
|
withGroupAndReg sendReply gId = withGroupAndReg_ sendReply gId . Just
|
|
|
|
withGroupAndReg_ :: (Text -> IO ()) -> GroupId -> Maybe GroupName -> (GroupInfo -> GroupReg -> IO ()) -> IO ()
|
|
withGroupAndReg_ sendReply gId gName_ action =
|
|
getGroupAndReg cc user gId >>= \case
|
|
Left e -> sendReply $ "Group " <> tshow gId <> " error (getGroup): " <> T.pack e
|
|
Right (g@GroupInfo {groupProfile = GroupProfile {displayName}}, gr)
|
|
| maybe False (displayName ==) gName_ ->
|
|
action g gr
|
|
| otherwise ->
|
|
sendReply $ "Group ID " <> tshow gId <> " has the display name " <> displayName
|
|
|
|
getOwnersInfo :: [(GroupInfo, GroupReg)] -> IO [((GroupInfo, GroupReg), Maybe (Either String Contact))]
|
|
getOwnersInfo gs =
|
|
fmap (either (\e -> map (,Just (Left e)) gs) id) $ withDB' "getOwnersInfo" cc $ \db ->
|
|
mapM (\g@(_, gr) -> fmap ((g,) . Just . first show) $ runExceptT $ getContact db (vr cc) user $ dbContactId gr) gs
|
|
|
|
sendGroupsInfo :: Contact -> ChatItemId -> Bool -> ([(GroupInfo, GroupReg)], Int) -> IO ()
|
|
sendGroupsInfo ct ciId isAdmin (gs, n) = do
|
|
let more = if n > length gs then ", showing the last " <> tshow (length gs) else ""
|
|
replyMsg = (Just ciId, MCText $ tshow n <> " registered group(s)" <> more)
|
|
gs' <- if isAdmin then getOwnersInfo gs else pure $ map (,Nothing) gs
|
|
sendComposedMessages_ cc (SRDirect $ contactId' ct) $ replyMsg :| map groupMessage gs'
|
|
where
|
|
groupMessage ((g, gr), ct_) =
|
|
let GroupInfo {groupId, groupProfile = p@GroupProfile {image = image_, memberAdmission}, groupSummary} = g
|
|
GroupReg {userGroupRegId, groupRegStatus} = gr
|
|
useGroupId = if isAdmin then groupId else userGroupRegId
|
|
statusStr = "Status: " <> groupRegStatusText groupRegStatus
|
|
membersStr = "_" <> tshow (currentMembers groupSummary) <> " members_"
|
|
cmds = "/'role " <> tshow useGroupId <> "', /'filter " <> tshow useGroupId <> "'"
|
|
ownerStr = maybe "" (("Owner: " <>) . either (("getContact error: " <>) . T.pack) localDisplayName') ct_
|
|
text = T.unlines $ [tshow useGroupId <> ". " <> groupInfoText p] ++ [ownerStr | isAdmin] ++ [membersStr, statusStr] ++ knockingStr memberAdmission ++ [cmds]
|
|
msg = maybe (MCText text) (\image -> MCImage {text, image}) image_
|
|
in (Nothing, msg)
|
|
|
|
setGroupStatusPromo :: (Text -> IO ()) -> DirectoryLog -> ServiceState -> ChatController -> GroupReg -> GroupRegStatus -> Bool -> IO () -> IO ()
|
|
setGroupStatusPromo sendReply st env cc GroupReg {dbGroupId = gId} grStatus' grPromoted' continue = do
|
|
let status' = grDirectoryStatus grStatus'
|
|
setGroupStatusPromoStore cc gId grStatus' grPromoted' >>= \case
|
|
Left e -> sendReply $ "Error updating group " <> tshow gId <> " status: " <> T.pack e
|
|
Right (status, grPromoted) -> do
|
|
when ((status == DSListed || status' == DSListed) && (status /= status' || grPromoted /= grPromoted')) $
|
|
listingsUpdated env cc
|
|
logGUpdateStatus st gId grStatus'
|
|
logGUpdatePromotion st gId grPromoted'
|
|
continue
|
|
|
|
addGroupReg :: (Text -> IO ()) -> DirectoryLog -> ChatController -> Contact -> GroupInfo -> GroupRegStatus -> (GroupReg -> IO ()) -> IO ()
|
|
addGroupReg sendMsg st cc ct g@GroupInfo {groupId} grStatus continue =
|
|
addGroupRegStore cc ct g grStatus >>= \case
|
|
Left e -> sendMsg $ "Error creating group registation for group " <> tshow groupId <> ": " <> T.pack e
|
|
Right gr -> do
|
|
logGCreate st gr
|
|
continue gr
|
|
|
|
setGroupStatus :: (Text -> IO ()) -> DirectoryLog -> ServiceState -> ChatController -> GroupId -> GroupRegStatus -> (GroupReg -> IO ()) -> IO ()
|
|
setGroupStatus sendMsg st env cc gId grStatus' continue = do
|
|
let status' = grDirectoryStatus grStatus'
|
|
setGroupStatusStore cc gId grStatus' >>= \case
|
|
Left e -> sendMsg $ "Error updating group " <> tshow gId <> " status: " <> T.pack e
|
|
Right (grStatus, gr) -> do
|
|
let status = grDirectoryStatus grStatus
|
|
when ((status == DSListed || status' == DSListed) && status /= status') $ listingsUpdated env cc
|
|
logGUpdateStatus st gId grStatus'
|
|
continue gr
|
|
|
|
setGroupPromoted :: (Text -> IO ()) -> DirectoryLog -> ServiceState -> ChatController -> GroupReg -> Bool -> IO () -> IO ()
|
|
setGroupPromoted sendReply st env cc GroupReg {dbGroupId = gId} grPromoted' continue =
|
|
setGroupPromotedStore cc gId grPromoted' >>= \case
|
|
Left e -> sendReply $ "Error updating group " <> tshow gId <> " status: " <> T.pack e
|
|
Right (status, grPromoted) -> do
|
|
when (status == DSListed && grPromoted' /= grPromoted) $ listingsUpdated env cc
|
|
logGUpdatePromotion st gId grPromoted'
|
|
continue
|
|
|
|
updateGroupListingFiles :: ChatController -> User -> FilePath -> IO ()
|
|
updateGroupListingFiles cc u dir =
|
|
getAllListedGroups cc u >>= \case
|
|
Right gs -> generateListing dir gs
|
|
Left e -> logError $ "generateListing error: failed to read groups: " <> T.pack e
|
|
|
|
getContact' :: ChatController -> User -> ContactId -> IO (Either String Contact)
|
|
getContact' cc user ctId = withDB "getContact" cc $ \db -> withExceptT show $ getContact db (vr cc) user ctId
|
|
|
|
getGroupLink' :: ChatController -> User -> GroupInfo -> IO (Either String GroupLink)
|
|
getGroupLink' cc user gInfo =
|
|
withDB "getGroupLink" cc $ \db -> withExceptT groupDBError $ getGroupLink db user gInfo
|
|
|
|
setGroupLinkRole :: ChatController -> GroupInfo -> GroupMemberRole -> IO (Maybe CreatedLinkContact)
|
|
setGroupLinkRole cc GroupInfo {groupId} mRole = resp <$> sendChatCmd cc (APIGroupLinkMemberRole groupId mRole)
|
|
where
|
|
resp = \case
|
|
Right (CRGroupLink {groupLink = GroupLink {connLinkContact}}) -> Just connLinkContact
|
|
_ -> Nothing
|
|
|
|
unexpectedError :: Text -> Text
|
|
unexpectedError err = "Unexpected error: " <> err <> ", please notify the developers."
|
|
|
|
strEncodeTxt :: StrEncoding a => a -> Text
|
|
strEncodeTxt = safeDecodeUtf8 . strEncode
|