commit 5c80ac310ff8dad2a751877da369f7a78bd61705 Author: Jon Date: Wed Jun 3 00:39:08 2026 +0100 Initial commit: bots, AI-parameterised support bot, web frontend - simplex-deadmans-bot: Dead Man's Switch Haskell bot - simplexxx-directory: private SimpleXXX directory bot (fork of simplex-directory-service) - simplex-support-bot: support triage bot with configurable AI backend - --ai-url and --ai-model flags for any OpenAI-compatible provider - works with Grok, Ollama, OpenAI, LM Studio, etc. - AI_API_KEY env var (GROK_API_KEY still accepted as alias) - web: SimpleXXX directory frontend (Groups/Channels tabs, matches simplex.chat/directory style) - manager/: placeholder for Python profile manager (coming soon) Co-Authored-By: Claude Sonnet 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dec65ff --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +node_modules/ +dist/ +*.db +*.db-wal +*.db-shm +data/state.json +web/data/listing.json +web/data/promoted.json +__pycache__/ +*.pyc +.venv/ +*.egg-info/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..76b95d4 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# SimpleX Manager + +A collection of SimpleX Chat bots and tools, plus a Python-based profile manager (in development). + +## Structure + +``` +bots/ + haskell/ + simplex-deadmans-bot/ Dead Man's Switch bot + simplexxx-directory/ Private SimpleXXX directory bot (fork of simplex-directory-service) + typescript/ + simplex-support-bot/ Support triage bot with AI (Grok / Ollama / OpenAI-compatible) +manager/ Python profile manager (coming soon) +web/ SimpleXXX directory web frontend +``` + +## Support Bot — AI Configuration + +The support bot supports any OpenAI-compatible AI backend. + +```bash +# xAI Grok (default) +AI_API_KEY=xai-xxx npm start -- --team-group "Support" --context-file ctx.txt + +# Ollama (local, no key needed) +npm start -- --team-group "Support" --context-file ctx.txt \ + --ai-url http://localhost:11434/v1 \ + --ai-model llama3.2 + +# OpenAI +AI_API_KEY=sk-xxx npm start -- --team-group "Support" --context-file ctx.txt \ + --ai-url https://api.openai.com/v1 \ + --ai-model gpt-4o +``` + +`GROK_API_KEY` is still accepted as a legacy alias for `AI_API_KEY`. + +## Web Frontend + +Static site for the SimpleXXX directory. Reads from `web/data/listing.json` and `web/data/promoted.json` written by the `simplexxx-directory` bot (`--web-folder` flag). + +Serve with any static file server: +```bash +cd web && python3 -m http.server 8080 +# or nginx pointing at this directory +``` + +## Python Manager + +Coming soon — FastAPI-based manager to create and manage multiple SimpleX bot profiles from a web UI. diff --git a/bots/haskell/README.md b/bots/haskell/README.md new file mode 100644 index 0000000..12770c2 --- /dev/null +++ b/bots/haskell/README.md @@ -0,0 +1,61 @@ +# Haskell Bots + +These bots must be built as part of the `simplex-chat` cabal project. + +## Setup + +1. Clone simplex-chat (stable branch): + ```bash + git clone https://github.com/simplex-chat/simplex-chat.git + cd simplex-chat + git checkout stable + ``` + +2. Copy bot directories into `apps/`: + ```bash + cp -r simplex-deadmans-bot simplex-chat/apps/ + cp -r simplexxx-directory simplex-chat/apps/ + ``` + +3. Add the following executables to `simplex-chat.cabal`: + +### simplex-deadmans-bot +```cabal +executable simplex-deadmans-bot + main-is: Main.hs + hs-source-dirs: apps/simplex-deadmans-bot + build-depends: + base, simplex-chat, simplexmq, text, stm, time, http-conduit + default-language: Haskell2010 + ghc-options: -threaded +``` + +### simplexxx-directory +```cabal +executable simplexxx-directory + main-is: Main.hs + hs-source-dirs: apps/simplexxx-directory + other-modules: + Directory.BlockedWords, Directory.Captcha, Directory.Events, + Directory.Listing, Directory.Options, Directory.Search, + Directory.Service, Directory.Store, Directory.Store.Migrate, + Directory.Store.Postgres.Migrations, Directory.Store.SQLite.Migrations, + Directory.Util + build-depends: + -- same as simplex-directory-service in simplex-chat.cabal + default-language: Haskell2010 + ghc-options: -threaded +``` + +4. On macOS, add to `cabal.project`: + ``` + package * + extra-lib-dirs: /opt/homebrew/opt/openssl@3/lib + extra-include-dirs: /opt/homebrew/opt/openssl@3/include + ``` + +5. Build: + ```bash + cabal update + cabal build simplex-deadmans-bot simplexxx-directory + ``` diff --git a/bots/haskell/simplex-deadmans-bot/Main.hs b/bots/haskell/simplex-deadmans-bot/Main.hs new file mode 100644 index 0000000..e73abc5 --- /dev/null +++ b/bots/haskell/simplex-deadmans-bot/Main.hs @@ -0,0 +1,201 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module Main where + +import Control.Concurrent (forkIO, threadDelay) +import Control.Concurrent.STM +import Control.Monad (forever, void, when) +import Data.Text (Text) +import qualified Data.Text as T +import qualified Data.Text.Read as T +import Data.Time.Clock +import Network.HTTP.Simple +import Simplex.Chat.Bot +import Simplex.Chat.Controller +import Simplex.Chat.Core +import Simplex.Chat.Messages +import Simplex.Chat.Messages.CIContent +import Simplex.Chat.Options +import Simplex.Chat.Protocol (MsgContent (..)) +import Simplex.Chat.Store.Profiles (AddressSettings (..), AutoAccept (..)) +import Simplex.Chat.Terminal (terminalChatConfig) +import Simplex.Chat.Types +import System.Directory (getAppUserDataDirectory) + +defaultSwitchDuration :: Int +defaultSwitchDuration = 1 + +notificationThresholds :: [Int] +notificationThresholds = [99, 90, 80, 70, 60, 50, 40, 30, 20, 15, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1] + +deadMansSwitchUrl :: String +deadMansSwitchUrl = "http://localhost:8080/deadmanswitch" + +main :: IO () +main = do + opts <- welcomeGetOpts + expiryVar <- newTVarIO Nothing + lastNotifyVar <- newTVarIO (100 :: Int) + durationVar <- newTVarIO defaultSwitchDuration + simplexChatCore terminalChatConfig opts (deadmansBot expiryVar lastNotifyVar durationVar) + +welcomeGetOpts :: IO ChatOpts +welcomeGetOpts = do + appDir <- getAppUserDataDirectory "simplex" + opts@ChatOpts {coreOptions} <- getChatOpts appDir "simplex_v1" + putStrLn $ "SimpleX Dead Man's Switch Bot v1.0 (default " ++ show defaultSwitchDuration ++ " days)" + printDbOpts coreOptions + pure opts + +welcomeMessage :: Int -> Text +welcomeMessage dur = + "Dead Man's Switch controls:\n\ + \- Send 'arm' to start the timer.\n\ + \- Send 'rearm' to restart it.\n\ + \- Send 'status' to check remaining time.\n\ + \- Send 'reset N' to change timer and reset it.\n\ + \Current arm duration: " <> T.pack (show dur) <> " days." + +expiryFromNow :: Int -> IO UTCTime +expiryFromNow days = do + now <- getCurrentTime + pure $ addUTCTime (fromIntegral (days * 24 * 60 * 60)) now + +deadmansBot :: TVar (Maybe UTCTime) -> TVar Int -> TVar Int -> User -> ChatController -> IO () +deadmansBot expiryVar lastNotifyVar durationVar _user cc = do + initializeBotAddress cc + dur <- readTVarIO durationVar + void $ sendChatCmd cc $ SetAddressSettings + AddressSettings + { businessAddress = True + , autoAccept = Just AutoAccept {acceptIncognito = False} + , autoReply = Just $ MCText (welcomeMessage dur) + } + contactVar <- newTVarIO Nothing + void . forkIO $ monitorExpiry expiryVar lastNotifyVar durationVar contactVar cc + forever $ do + (_, evt) <- atomically . readTBQueue $ outputQ cc + case evt of + Right (CEvtContactConnected _ contact _) -> do + putStrLn "A contact just connected." + atomically $ writeTVar contactVar (Just contact) + dur <- readTVarIO durationVar + sendMessage cc contact (welcomeMessage dur) + Right CEvtNewChatItems {chatItems = (AChatItem _ _ (DirectChat contact) ChatItem {content = mc}) : _} + | let msg = T.toLower (ciContentToText mc) -> + do + atomically $ writeTVar contactVar (Just contact) + dur <- readTVarIO durationVar + case T.words msg of + ["arm"] -> do + expiry <- expiryFromNow dur + atomically $ writeTVar expiryVar (Just expiry) + atomically $ writeTVar lastNotifyVar 100 + putStrLn $ "Timer ARMED until: " ++ show expiry + sendMessage cc contact $ "Dead Man's Switch ARMED! You have " <> T.pack (show dur) <> " days." + ["rearm"] -> do + expiry <- expiryFromNow dur + atomically $ writeTVar expiryVar (Just expiry) + atomically $ writeTVar lastNotifyVar 100 + putStrLn $ "Timer RE-ARMED until: " ++ show expiry + sendMessage cc contact $ "Dead Man's Switch RE-ARMED! You have " <> T.pack (show dur) <> " days." + ["status"] -> do + mExpiry <- readTVarIO expiryVar + now <- getCurrentTime + msgToSend <- case mExpiry of + Nothing -> pure "Switch not armed." + Just expiry -> do + let secondsLeft = round $ realToFrac (diffUTCTime expiry now) :: Int + daysLeft = secondsLeft `div` (24 * 60 * 60) + hoursLeft = (secondsLeft `mod` (24 * 60 * 60)) `div` (60 * 60) + minutesLeft = (secondsLeft `mod` (60 * 60)) `div` 60 + secsLeft = secondsLeft `mod` 60 + pure $ + "Time remaining: " + <> T.pack (show daysLeft) <> " days, " + <> T.pack (show hoursLeft) <> " hours, " + <> T.pack (show minutesLeft) <> " minutes, " + <> T.pack (show secsLeft) <> " seconds." + sendMessage cc contact msgToSend + ["reset"] -> do + expiry <- expiryFromNow dur + atomically $ writeTVar expiryVar (Just expiry) + atomically $ writeTVar lastNotifyVar 100 + sendMessage cc contact $ "Timer reset to " <> T.pack (show dur) <> " days." + ["reset", daysTxt] -> + case T.decimal daysTxt of + Right (newDur, _) | newDur > 0 -> do + atomically $ writeTVar durationVar newDur + expiry <- expiryFromNow newDur + atomically $ writeTVar expiryVar (Just expiry) + atomically $ writeTVar lastNotifyVar 100 + sendMessage cc contact $ "Timer reset to " <> T.pack (show newDur) <> " days." + _ -> sendMessage cc contact "Please provide a valid integer > 0 for days." + _ -> pure () + _ -> pure () + +findTriggeredThreshold :: Int -> Int -> Maybe Int +findTriggeredThreshold currentPercent lastNotifyPercent = + let candidates = filter (\t -> t < lastNotifyPercent && t >= currentPercent) notificationThresholds + in if null candidates then Nothing else Just (maximum candidates) + +monitorExpiry :: TVar (Maybe UTCTime) -> TVar Int -> TVar Int -> TVar (Maybe Contact) -> ChatController -> IO () +monitorExpiry expiryVar lastNotifyVar durationVar contactVar cc = forever $ do + mExpiry <- readTVarIO expiryVar + mContact <- readTVarIO contactVar + dur <- readTVarIO durationVar + now <- getCurrentTime + case mExpiry of + Nothing -> threadDelay (15 * 1000000) + Just expiry -> + if now >= expiry + then do + putStrLn $ "No activity for " ++ show dur ++ " days! Triggering dead man's switch." + case mContact of + Just contact -> + sendMessage cc contact $ + "Dead man's switch activated! No response received in " <> T.pack (show dur) <> " days." + Nothing -> putStrLn "No contact available to notify on deadline." + triggerSwitch + atomically $ writeTVar expiryVar Nothing + atomically $ writeTVar lastNotifyVar 100 + else do + let secondsLeft = round $ realToFrac (diffUTCTime expiry now) :: Int + totalSeconds = dur * 24 * 60 * 60 + percentRemaining = round ((fromIntegral secondsLeft / fromIntegral totalSeconds :: Double) * 100) + daysLeft = secondsLeft `div` (24 * 60 * 60) + hoursLeft = (secondsLeft `mod` (24 * 60 * 60)) `div` (60 * 60) + minutesLeft = (secondsLeft `mod` (60 * 60)) `div` 60 + secsLeft = secondsLeft `mod` 60 + lastNotifyPercent <- readTVarIO lastNotifyVar + case findTriggeredThreshold percentRemaining lastNotifyPercent of + Just threshold -> do + let message = + "Warning: " <> T.pack (show threshold) <> "% time remaining (" + <> T.pack (show daysLeft) <> " days, " + <> T.pack (show hoursLeft) <> " hours, " + <> T.pack (show minutesLeft) <> " minutes, " + <> T.pack (show secsLeft) <> " seconds)" + putStrLn $ "Sending " ++ show threshold ++ "% warning" + case mContact of + Just contact -> sendMessage cc contact message + Nothing -> putStrLn "No contact available to notify at threshold." + atomically $ writeTVar lastNotifyVar threshold + Nothing -> pure () + putStrLn $ + "Dead man's switch: " ++ show percentRemaining ++ "% remaining - " + ++ show daysLeft ++ " days, " + ++ show hoursLeft ++ " hours, " + ++ show minutesLeft ++ " minutes, " + ++ show secsLeft ++ " seconds." + threadDelay (60 * 1000000) + +triggerSwitch :: IO () +triggerSwitch = do + req <- parseRequest deadMansSwitchUrl + response <- httpLBS (setRequestMethod "POST" req) + putStrLn $ "Switch triggered, server responded: " ++ show (getResponseStatus response) diff --git a/bots/haskell/simplex-deadmans-bot/mock_server.py b/bots/haskell/simplex-deadmans-bot/mock_server.py new file mode 100644 index 0000000..b225b4d --- /dev/null +++ b/bots/haskell/simplex-deadmans-bot/mock_server.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +from http.server import BaseHTTPRequestHandler, HTTPServer +from datetime import datetime + +PORT = 8080 + +class Handler(BaseHTTPRequestHandler): + def do_POST(self): + if self.path == "/deadmanswitch": + length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(length) if length else b"" + print(f"[{datetime.now()}] TRIGGERED — body: {body!r}") + self.send_response(200) + self.end_headers() + self.wfile.write(b"OK") + else: + self.send_response(404) + self.end_headers() + + def log_message(self, fmt, *args): + pass # silence default access log; we print our own above + +if __name__ == "__main__": + server = HTTPServer(("", PORT), Handler) + print(f"Mock dead man's switch listening on http://localhost:{PORT}/deadmanswitch") + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nStopped.") diff --git a/bots/haskell/simplexxx-directory/Main.hs b/bots/haskell/simplexxx-directory/Main.hs new file mode 100644 index 0000000..3314549 --- /dev/null +++ b/bots/haskell/simplexxx-directory/Main.hs @@ -0,0 +1,27 @@ +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} + +module Main where + +import Directory.Options +import Directory.Service +import Directory.Store +import Directory.Store.Migrate +import Simplex.Chat.Terminal (terminalChatConfig) + +main :: IO () +main = do + opts@DirectoryOpts {directoryLog, migrateDirectoryLog, runCLI} <- welcomeGetOpts + case migrateDirectoryLog of + Just cmd -> migrate cmd opts terminalChatConfig + Nothing -> do + st <- openDirectoryLog directoryLog + if runCLI + then directoryServiceCLI st opts + else directoryService st opts terminalChatConfig + where + migrate = \case + MLCheck -> checkDirectoryLog + MLImport -> importDirectoryLogToDB + MLExport -> exportDBToDirectoryLog + MLListing -> saveGroupListingFiles diff --git a/bots/haskell/simplexxx-directory/src/Directory/BlockedWords.hs b/bots/haskell/simplexxx-directory/src/Directory/BlockedWords.hs new file mode 100644 index 0000000..a29e2c9 --- /dev/null +++ b/bots/haskell/simplexxx-directory/src/Directory/BlockedWords.hs @@ -0,0 +1,77 @@ +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module Directory.BlockedWords where + +import Data.Char (isMark, isPunctuation, isSpace) +import Data.List (isPrefixOf) +import Data.Maybe (fromMaybe) +import Data.Map.Strict (Map) +import qualified Data.Map.Strict as M +import Data.Set (Set) +import qualified Data.Set as S +import Data.Text (Text) +import qualified Data.Text as T +import qualified Data.Text.Normalize as TN + +data BlockedWordsConfig = BlockedWordsConfig + { blockedWords :: Set Text, + blockedFragments :: Set Text, + extensionRules :: [(String, [String])], + spelling :: Map Char [Char] + } + +hasBlockedFragments :: BlockedWordsConfig -> Text -> Bool +hasBlockedFragments BlockedWordsConfig {spelling, blockedFragments} s = + any (\w -> any (`T.isInfixOf` w) blockedFragments) ws + where + ws = S.fromList $ filter (not . T.null) $ normalizeText spelling s + +hasBlockedWords :: BlockedWordsConfig -> Text -> Bool +hasBlockedWords BlockedWordsConfig {spelling, blockedWords} s = + not $ ws1 `S.disjoint` blockedWords && (length ws <= 1 || ws2 `S.disjoint` blockedWords) + where + ws = T.words s + ws1 = normalizeWords ws + ws2 = normalizeWords $ T.splitOn " " s + normalizeWords = S.fromList . filter (not . T.null) . concatMap (normalizeText spelling) + +normalizeText :: Map Char [Char] -> Text -> [Text] +normalizeText spelling' = + map (T.pack . filter (\c -> not $ isSpace c || isPunctuation c || isMark c)) + . allSubstitutions spelling' + . removeTriples + . T.unpack + . T.toLower + . TN.normalize TN.NFKD + +-- replaces triple and larger occurences with doubles +removeTriples :: String -> String +removeTriples xs = go xs '\0' False + where + go [] _ _ = [] + go (c : cs) prev samePrev + | prev /= c = c : go cs c False + | samePrev = go cs c True + | otherwise = c : go cs c True + +-- Generate all possible strings by substituting each character +allSubstitutions :: Map Char [Char] -> String -> [String] +allSubstitutions spelling' = sequence . map substs + where + substs c = fromMaybe [c] $ M.lookup c spelling' + +wordVariants :: [(String, [String])] -> String -> [Text] +wordVariants [] s = [T.pack s] +wordVariants (sub : subs) s = concatMap (wordVariants subs) (replace sub) + where + replace (pat, tos) = go s + where + go [] = [""] + go s'@(c : rest) + | pat `isPrefixOf` s' = + let s'' = drop (length pat) s' + restVariants = go s'' + in map (pat <>) restVariants + <> concatMap (\to -> map (to <>) restVariants) tos + | otherwise = map (c :) (go rest) diff --git a/bots/haskell/simplexxx-directory/src/Directory/Captcha.hs b/bots/haskell/simplexxx-directory/src/Directory/Captcha.hs new file mode 100644 index 0000000..d60b09d --- /dev/null +++ b/bots/haskell/simplexxx-directory/src/Directory/Captcha.hs @@ -0,0 +1,37 @@ +module Directory.Captcha (getCaptchaStr, matchCaptchaStr) where + +import qualified Data.Map.Strict as M +import Data.Maybe (fromMaybe) +import qualified Data.Text as T +import System.Random (randomRIO) + +getCaptchaStr :: Int -> String -> IO String +getCaptchaStr 0 s = pure s +getCaptchaStr n s = do + i <- randomRIO (0, length captchaChars - 1) + let c = captchaChars !! i + getCaptchaStr (n - 1) (c : s) + where + captchaChars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + +matchCaptchaStr :: T.Text -> T.Text -> Bool +matchCaptchaStr captcha guess = T.length captcha == T.length guess && matchChars (T.zip captcha guess) + where + matchChars [] = True + matchChars ((c, g) : cs) = matchChar c == matchChar g && matchChars cs + matchChar c = fromMaybe c $ M.lookup c captchaMatches + captchaMatches = + M.fromList + [ ('0', 'O'), + ('1', 'I'), + ('c', 'C'), + ('l', 'I'), + ('o', 'O'), + ('p', 'P'), + ('s', 'S'), + ('u', 'U'), + ('v', 'V'), + ('w', 'W'), + ('x', 'X'), + ('z', 'Z') + ] diff --git a/bots/haskell/simplexxx-directory/src/Directory/Events.hs b/bots/haskell/simplexxx-directory/src/Directory/Events.hs new file mode 100644 index 0000000..bfbc025 --- /dev/null +++ b/bots/haskell/simplexxx-directory/src/Directory/Events.hs @@ -0,0 +1,337 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE KindSignatures #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE StandaloneDeriving #-} + +module Directory.Events + ( DirectoryEvent (..), + DirectoryCmd (..), + DirectoryCmdTag (..), + ADirectoryCmd (..), + DirectoryHelpSection (..), + DirectoryRole (..), + SDirectoryRole (..), + crDirectoryEvent, + directoryCmdP, + directoryCmdTag, + ) +where + +import Control.Applicative (optional, (<|>)) +import Data.Attoparsec.Text (Parser) +import qualified Data.Attoparsec.Text as A +import Data.Char (isSpace) +import Data.Either (fromRight) +import Data.Functor (($>)) +import Data.Maybe (fromMaybe) +import Data.Text (Text) +import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) +import Directory.Store +import Simplex.Chat.Controller +import Simplex.Chat.Markdown (MarkdownList, displayNameTextP) +import Simplex.Chat.Messages +import Simplex.Chat.Messages.CIContent +import Simplex.Chat.Protocol (LinkOwnerSig, MsgChatLink, MsgContent (..)) +import Simplex.Chat.Types +import Simplex.Chat.Types.Shared +import Simplex.Messaging.Agent.Protocol (AgentErrorType (..)) +import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Protocol (BrokerErrorType (..)) +import Simplex.Messaging.Util (tshow, (<$?>)) + +data DirectoryEvent + = DEContactConnected Contact + | DEGroupInvitation {contact :: Contact, groupInfo :: GroupInfo, fromMemberRole :: GroupMemberRole, memberRole :: GroupMemberRole} + | DEServiceJoinedGroup {contactId :: ContactId, groupInfo :: GroupInfo, hostMember :: GroupMember} + | DEGroupUpdated {member :: GroupMember, fromGroup :: GroupInfo, toGroup :: GroupInfo} + | DEGroupLinkCheck GroupInfo + | DEPendingMember GroupInfo GroupMember + | DEPendingMemberMsg GroupInfo GroupMember ChatItemId Text + | DEContactRoleChanged GroupInfo ContactId GroupMemberRole -- contactId here is the contact whose role changed + | DEServiceRoleChanged GroupInfo GroupMemberRole + | DEContactRemovedFromGroup ContactId GroupInfo + | DEContactLeftGroup ContactId GroupInfo + | DEServiceRemovedFromGroup GroupInfo + | DEGroupDeleted GroupInfo + | DEChatLinkReceived {contact :: Contact, chatItemId :: ChatItemId, chatLink :: MsgChatLink, ownerSig :: Maybe LinkOwnerSig} + | DEMemberUpdated {groupInfo :: GroupInfo, fromMember :: GroupMember, toMember :: GroupMember} + | DEUnsupportedMessage Contact ChatItemId + | DEItemEditIgnored Contact + | DEItemDeleteIgnored Contact + | DEContactCommand Contact ChatItemId ADirectoryCmd + | DELogChatResponse Text + deriving (Show) + +crDirectoryEvent :: Either ChatError ChatEvent -> Maybe DirectoryEvent +crDirectoryEvent = \case + Right evt -> crDirectoryEvent_ evt + Left e -> case e of + ChatErrorAgent {agentError = BROKER _ (NETWORK _)} -> Nothing + ChatErrorAgent {agentError = BROKER _ TIMEOUT} -> Nothing + _ -> Just $ DELogChatResponse $ "chat error: " <> tshow e + +crDirectoryEvent_ :: ChatEvent -> Maybe DirectoryEvent +crDirectoryEvent_ = \case + CEvtContactConnected {contact} -> Just $ DEContactConnected contact + CEvtReceivedGroupInvitation {contact, groupInfo, fromMemberRole, memberRole} -> Just $ DEGroupInvitation {contact, groupInfo, fromMemberRole, memberRole} + CEvtUserJoinedGroup {groupInfo, hostMember} -> (\contactId -> DEServiceJoinedGroup {contactId, groupInfo, hostMember}) <$> memberContactId hostMember + CEvtGroupUpdated {fromGroup, toGroup, member_} -> (\member -> DEGroupUpdated {member, fromGroup, toGroup}) <$> member_ + CEvtJoinedGroupMember {groupInfo, member = m} + | pending m -> Just $ DEPendingMember groupInfo m + | otherwise -> Nothing + CEvtNewChatItems {chatItems = AChatItem _ _ (GroupChat g _scopeInfo) ci : _} -> case ci of + ChatItem {chatDir = CIGroupRcv m, content = CIRcvMsgContent (MCText t)} | pending m -> Just $ DEPendingMemberMsg g m (chatItemId' ci) t + _ -> Nothing + CEvtMemberRole {groupInfo, member, toRole} + | groupMemberId' member == groupMemberId' (membership groupInfo) -> Just $ DEServiceRoleChanged groupInfo toRole + | otherwise -> (\ctId -> DEContactRoleChanged groupInfo ctId toRole) <$> memberContactId member + CEvtDeletedMember {groupInfo, deletedMember} -> (`DEContactRemovedFromGroup` groupInfo) <$> memberContactId deletedMember + CEvtLeftMember {groupInfo, member} -> (`DEContactLeftGroup` groupInfo) <$> memberContactId member + CEvtDeletedMemberUser {groupInfo} -> Just $ DEServiceRemovedFromGroup groupInfo + CEvtGroupDeleted {groupInfo} -> Just $ DEGroupDeleted groupInfo + CEvtUnknownMemberAnnounced {groupInfo, unknownMember, announcedMember} -> Just $ DEMemberUpdated {groupInfo, fromMember = unknownMember, toMember = announcedMember} + CEvtGroupMemberUpdated {groupInfo, fromMember, toMember} -> Just $ DEMemberUpdated {groupInfo, fromMember, toMember} + CEvtChatItemUpdated {chatItem = AChatItem _ SMDRcv (DirectChat ct) _} -> Just $ DEItemEditIgnored ct + CEvtChatItemsDeleted {chatItemDeletions = ((ChatItemDeletion (AChatItem _ SMDRcv (DirectChat ct) _) _) : _), byUser = False} -> Just $ DEItemDeleteIgnored ct + CEvtNewChatItems {chatItems = (AChatItem _ SMDRcv (DirectChat ct) ci@ChatItem {content = CIRcvMsgContent mc, formattedText = ft, meta = CIMeta {itemLive}}) : _} -> + Just $ case (mc, itemLive) of + (MCText t, Nothing) -> DEContactCommand ct ciId $ fromRight err $ A.parseOnly (directoryCmdP ft <* A.endOfInput) $ T.dropWhileEnd isSpace t + (MCChat {chatLink, ownerSig}, Nothing) -> DEChatLinkReceived {contact = ct, chatItemId = ciId, chatLink, ownerSig} + _ -> DEUnsupportedMessage ct ciId + where + ciId = chatItemId' ci + err = ADC SDRUser DCUnknownCommand + CEvtMessageError {severity, errorMessage} -> Just $ DELogChatResponse $ "message error: " <> severity <> ", " <> errorMessage + CEvtChatErrors {chatErrors} -> Just $ DELogChatResponse $ "chat errors: " <> T.intercalate ", " (map tshow chatErrors) + _ -> Nothing + where + pending m = memberStatus m == GSMemPendingApproval + +data DirectoryRole = DRUser | DRAdmin | DRSuperUser + +data SDirectoryRole (r :: DirectoryRole) where + SDRUser :: SDirectoryRole 'DRUser + SDRAdmin :: SDirectoryRole 'DRAdmin + SDRSuperUser :: SDirectoryRole 'DRSuperUser + +deriving instance Show (SDirectoryRole r) + +data DirectoryCmdTag (r :: DirectoryRole) where + DCHelp_ :: DirectoryCmdTag 'DRUser + DCSearchNext_ :: DirectoryCmdTag 'DRUser + DCAllGroups_ :: DirectoryCmdTag 'DRUser + DCRecentGroups_ :: DirectoryCmdTag 'DRUser + DCSubmitGroup_ :: DirectoryCmdTag 'DRUser + DCConfirmDuplicateGroup_ :: DirectoryCmdTag 'DRUser + DCListUserGroups_ :: DirectoryCmdTag 'DRUser + DCDeleteGroup_ :: DirectoryCmdTag 'DRUser + DCMemberRole_ :: DirectoryCmdTag 'DRUser + DCGroupFilter_ :: DirectoryCmdTag 'DRUser + DCShowUpgradeGroupLink_ :: DirectoryCmdTag 'DRUser + DCApproveGroup_ :: DirectoryCmdTag 'DRAdmin + DCRejectGroup_ :: DirectoryCmdTag 'DRAdmin + DCSuspendGroup_ :: DirectoryCmdTag 'DRAdmin + DCResumeGroup_ :: DirectoryCmdTag 'DRAdmin + DCListLastGroups_ :: DirectoryCmdTag 'DRAdmin + DCListPendingGroups_ :: DirectoryCmdTag 'DRAdmin + DCSendToGroupOwner_ :: DirectoryCmdTag 'DRAdmin + DCInviteOwnerToGroup_ :: DirectoryCmdTag 'DRAdmin + -- DCAddBlockedWord_ :: DirectoryCmdTag 'DRAdmin + -- DCRemoveBlockedWord_ :: DirectoryCmdTag 'DRAdmin + DCPromoteGroup_ :: DirectoryCmdTag 'DRSuperUser + DCExecuteCommand_ :: DirectoryCmdTag 'DRSuperUser + +deriving instance Show (DirectoryCmdTag r) + +data ADirectoryCmdTag = forall r. ADCT (SDirectoryRole r) (DirectoryCmdTag r) + +data DirectoryHelpSection = DHSRegistration | DHSCommands + deriving (Show) + +data DirectoryCmd (r :: DirectoryRole) where + DCHelp :: DirectoryHelpSection -> DirectoryCmd 'DRUser + DCSearchGroup :: Text -> Maybe MarkdownList -> DirectoryCmd 'DRUser + DCSearchNext :: DirectoryCmd 'DRUser + DCAllGroups :: DirectoryCmd 'DRUser + DCRecentGroups :: DirectoryCmd 'DRUser + DCSubmitGroup :: ConnReqContact -> DirectoryCmd 'DRUser + DCConfirmDuplicateGroup :: UserGroupRegId -> GroupName -> DirectoryCmd 'DRUser + DCListUserGroups :: DirectoryCmd 'DRUser + DCDeleteGroup :: UserGroupRegId -> GroupName -> DirectoryCmd 'DRUser + DCMemberRole :: UserGroupRegId -> Maybe GroupName -> Maybe GroupMemberRole -> DirectoryCmd 'DRUser + DCGroupFilter :: UserGroupRegId -> Maybe GroupName -> Maybe DirectoryMemberAcceptance -> DirectoryCmd 'DRUser + DCShowUpgradeGroupLink :: GroupId -> Maybe GroupName -> DirectoryCmd 'DRUser + DCApproveGroup :: {groupId :: GroupId, displayName :: GroupName, groupApprovalId :: GroupApprovalId, promote :: Maybe Bool} -> DirectoryCmd 'DRAdmin + DCRejectGroup :: GroupId -> GroupName -> DirectoryCmd 'DRAdmin + DCSuspendGroup :: GroupId -> GroupName -> DirectoryCmd 'DRAdmin + DCResumeGroup :: GroupId -> GroupName -> DirectoryCmd 'DRAdmin + DCListLastGroups :: Int -> DirectoryCmd 'DRAdmin + DCListPendingGroups :: Int -> DirectoryCmd 'DRAdmin + DCSendToGroupOwner :: GroupId -> GroupName -> Text -> DirectoryCmd 'DRAdmin + DCInviteOwnerToGroup :: GroupId -> GroupName -> DirectoryCmd 'DRAdmin + -- DCAddBlockedWord :: Text -> DirectoryCmd 'DRAdmin + -- DCRemoveBlockedWord :: Text -> DirectoryCmd 'DRAdmin + DCPromoteGroup :: GroupId -> GroupName -> Bool -> DirectoryCmd 'DRSuperUser + DCExecuteCommand :: String -> DirectoryCmd 'DRSuperUser + DCUnknownCommand :: DirectoryCmd 'DRUser + DCCommandError :: DirectoryCmdTag r -> DirectoryCmd r + +deriving instance Show (DirectoryCmd r) + +data ADirectoryCmd = forall r. ADC (SDirectoryRole r) (DirectoryCmd r) + +deriving instance Show ADirectoryCmd + +directoryCmdP :: Maybe MarkdownList -> Parser ADirectoryCmd +directoryCmdP ft = + (A.char '/' *> cmdStrP) + <|> (A.char '.' $> ADC SDRUser DCSearchNext) + <|> (ADC SDRUser . (`DCSearchGroup` ft) <$> A.takeText) + where + cmdStrP = + (tagP >>= \(ADCT u t) -> ADC u <$> (cmdP t <|> pure (DCCommandError t))) + <|> pure (ADC SDRUser DCUnknownCommand) + tagP = + A.takeTill isSpace >>= \case + "help" -> u DCHelp_ + "h" -> u DCHelp_ + "next" -> u DCSearchNext_ + "all" -> u DCAllGroups_ + "new" -> u DCRecentGroups_ + "submit" -> u DCSubmitGroup_ + "confirm" -> u DCConfirmDuplicateGroup_ + "list" -> u DCListUserGroups_ + "ls" -> u DCListUserGroups_ + "delete" -> u DCDeleteGroup_ + "role" -> u DCMemberRole_ + "filter" -> u DCGroupFilter_ + "link" -> u DCShowUpgradeGroupLink_ + "approve" -> au DCApproveGroup_ + "reject" -> au DCRejectGroup_ + "suspend" -> au DCSuspendGroup_ + "resume" -> au DCResumeGroup_ + "last" -> au DCListLastGroups_ + "pending" -> au DCListPendingGroups_ + "owner" -> au DCSendToGroupOwner_ + "invite" -> au DCInviteOwnerToGroup_ + -- "block_word" -> au DCAddBlockedWord_ + -- "unblock_word" -> au DCRemoveBlockedWord_ + "promote" -> su DCPromoteGroup_ + "exec" -> su DCExecuteCommand_ + "x" -> su DCExecuteCommand_ + _ -> fail "bad command tag" + where + u = pure . ADCT SDRUser + au = pure . ADCT SDRAdmin + su = pure . ADCT SDRSuperUser + cmdP :: DirectoryCmdTag r -> Parser (DirectoryCmd r) + cmdP = \case + DCHelp_ -> DCHelp . fromMaybe DHSRegistration <$> optional (A.takeWhile isSpace *> helpSectionP) + where + helpSectionP = + A.takeText >>= \case + "registration" -> pure DHSRegistration + "r" -> pure DHSRegistration + "commands" -> pure DHSCommands + "c" -> pure DHSCommands + _ -> fail "bad help section" + DCSearchNext_ -> pure DCSearchNext + DCAllGroups_ -> pure DCAllGroups + DCRecentGroups_ -> pure DCRecentGroups + DCSubmitGroup_ -> fmap DCSubmitGroup . strDecode . encodeUtf8 <$?> (spacesP *> A.takeText) + DCConfirmDuplicateGroup_ -> gc DCConfirmDuplicateGroup + DCListUserGroups_ -> pure DCListUserGroups + DCDeleteGroup_ -> gc DCDeleteGroup + DCMemberRole_ -> do + (groupId, displayName_) <- gc_ (,) + memberRole_ <- optional $ spacesP *> ("member" $> GRMember <|> "observer" $> GRObserver) + pure $ DCMemberRole groupId displayName_ memberRole_ + DCGroupFilter_ -> do + (groupId, displayName_) <- gc_ (,) + acceptance_ <- + (A.takeWhile isSpace >> A.endOfInput) $> Nothing + <|> Just <$> (acceptancePresetsP <|> acceptanceFiltersP) + pure $ DCGroupFilter groupId displayName_ acceptance_ + where + acceptancePresetsP = + spacesP + *> A.choice + [ "off" $> noJoinFilter, + "basic" $> basicJoinFilter, + ("moderate" <|> "mod") $> moderateJoinFilter, + "strong" $> strongJoinFilter + ] + acceptanceFiltersP = do + rejectNames <- filterP "name" + passCaptcha <- filterP "captcha" + makeObserver <- filterP "observer" + pure DirectoryMemberAcceptance {rejectNames, passCaptcha, makeObserver} + filterP :: Text -> Parser (Maybe ProfileCondition) + filterP s = Just <$> (spacesP *> A.string s *> conditionP) <|> pure Nothing + conditionP = + "=all" $> PCAll + <|> ("=noimage" <|> "=no_image" <|> "=no-image") $> PCNoImage + <|> pure PCAll + DCShowUpgradeGroupLink_ -> gc_ DCShowUpgradeGroupLink + DCApproveGroup_ -> do + (groupId, displayName) <- gc (,) + groupApprovalId <- A.space *> A.decimal + promote <- Just <$> (" promote=" *> onOffP) <|> pure Nothing + pure DCApproveGroup {groupId, displayName, groupApprovalId, promote} + DCRejectGroup_ -> gc DCRejectGroup + DCSuspendGroup_ -> gc DCSuspendGroup + DCResumeGroup_ -> gc DCResumeGroup + DCListLastGroups_ -> DCListLastGroups <$> (A.space *> A.decimal <|> pure 10) + DCListPendingGroups_ -> DCListPendingGroups <$> (A.space *> A.decimal <|> pure 10) + DCSendToGroupOwner_ -> do + (groupId, displayName) <- gc (,) + msg <- A.space *> A.takeText + pure $ DCSendToGroupOwner groupId displayName msg + DCInviteOwnerToGroup_ -> gc DCInviteOwnerToGroup + -- DCAddBlockedWord_ -> DCAddBlockedWord <$> wordP + -- DCRemoveBlockedWord_ -> DCRemoveBlockedWord <$> wordP + DCPromoteGroup_ -> do + (groupId, displayName) <- gc (,) + promote <- A.space *> onOffP + pure $ DCPromoteGroup groupId displayName promote + DCExecuteCommand_ -> DCExecuteCommand . T.unpack <$> (spacesP *> A.takeText) + where + gc f = f <$> (spacesP *> A.decimal) <*> (A.char ':' *> displayNameTextP) + gc_ f = f <$> (spacesP *> A.decimal) <*> optional (A.char ':' *> displayNameTextP) + -- wordP = spacesP *> A.takeTill isSpace + spacesP = A.takeWhile1 isSpace + onOffP = (A.string "on" $> True) <|> (A.string "off" $> False) + +directoryCmdTag :: DirectoryCmd r -> Text +directoryCmdTag = \case + DCHelp _ -> "help" + DCSearchGroup {} -> "search" + DCSearchNext -> "next" + DCAllGroups -> "all" + DCRecentGroups -> "new" + DCSubmitGroup _ -> "submit" + DCConfirmDuplicateGroup {} -> "confirm" + DCListUserGroups -> "list" + DCDeleteGroup {} -> "delete" + DCApproveGroup {} -> "approve" + DCMemberRole {} -> "role" + DCGroupFilter {} -> "filter" + DCShowUpgradeGroupLink {} -> "link" + DCRejectGroup {} -> "reject" + DCSuspendGroup {} -> "suspend" + DCResumeGroup {} -> "resume" + DCListLastGroups _ -> "last" + DCListPendingGroups _ -> "pending" + DCSendToGroupOwner {} -> "owner" + DCInviteOwnerToGroup {} -> "invite" + -- DCAddBlockedWord _ -> "block_word" + -- DCRemoveBlockedWord _ -> "unblock_word" + DCPromoteGroup {} -> "promote" + DCExecuteCommand _ -> "exec" + DCUnknownCommand -> "unknown" + DCCommandError _ -> "error" diff --git a/bots/haskell/simplexxx-directory/src/Directory/Listing.hs b/bots/haskell/simplexxx-directory/src/Directory/Listing.hs new file mode 100644 index 0000000..ef09302 --- /dev/null +++ b/bots/haskell/simplexxx-directory/src/Directory/Listing.hs @@ -0,0 +1,171 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TupleSections #-} +{-# LANGUAGE TypeApplications #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} + +module Directory.Listing where + +import Control.Applicative ((<|>)) +import Control.Monad +import Crypto.Hash (Digest, MD5) +import qualified Crypto.Hash as CH +import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ +import qualified Data.ByteArray as BA +import Data.ByteString (ByteString) +import qualified Data.ByteString.Base64 as B64 +import qualified Data.ByteString.Base64.URL as B64URL +import qualified Data.ByteString.Char8 as B +import qualified Data.ByteString.Lazy as LB +import Data.Int (Int64) +import Data.List (isPrefixOf) +import Data.Maybe (catMaybes, fromMaybe) +import Data.Text (Text) +import qualified Data.Text as T +import Data.Text.Encoding (decodeUtf8, encodeUtf8) +import Data.Time.Clock +import Data.Time.Clock.System +import Data.Time.Format.ISO8601 (iso8601Show) +import Directory.Store +import Simplex.Chat.Markdown +import Simplex.Chat.Types +import Simplex.Messaging.Agent.Protocol +import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, taggedObjectJSON) +import System.Directory +import System.FilePath + +directoryDataPath :: String +directoryDataPath = "data" + +listingFileName :: String +listingFileName = "listing.json" + +promotedFileName :: String +promotedFileName = "promoted.json" + +listingImageFolder :: String +listingImageFolder = "images" + +data DirectoryEntryType = DETGroup + { groupType :: Maybe GroupType, + admission :: Maybe GroupMemberAdmission, + summary :: GroupSummary + } + +$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "DET") ''DirectoryEntryType) + +data PublicLink = PublicLink + { connFullLink :: Maybe ConnReqContact, + connShortLink :: Maybe ShortLinkContact + } + +$(JQ.deriveJSON defaultJSON ''PublicLink) + +data DirectoryEntry = DirectoryEntry + { entryType :: DirectoryEntryType, + displayName :: Text, + groupLink :: PublicLink, + shortDescr :: Maybe MarkdownList, + welcomeMessage :: Maybe MarkdownList, + imageFile :: Maybe String, + activeAt :: Maybe UTCTime, + createdAt :: Maybe UTCTime + } + +$(JQ.deriveJSON defaultJSON ''DirectoryEntry) + +data DirectoryListing = DirectoryListing {entries :: [DirectoryEntry]} + +$(JQ.deriveJSON defaultJSON ''DirectoryListing) + +type ImageFileData = ByteString + +newOrActive :: NominalDiffTime +newOrActive = 30 * nominalDay + +recentRoundedTime :: Int64 -> UTCTime -> UTCTime -> Maybe UTCTime +recentRoundedTime roundTo now t + | diffUTCTime now t > newOrActive = Nothing + | otherwise = + let secs = (systemSeconds (utcToSystemTime t) `div` roundTo) * roundTo + in Just $ systemToUTCTime $ MkSystemTime secs 0 + +groupDirectoryEntry :: UTCTime -> GroupInfo -> Maybe GroupLink -> Maybe (DirectoryEntry, Maybe (FilePath, ImageFileData)) +groupDirectoryEntry now GroupInfo {groupProfile, chatTs, createdAt, groupSummary} gLink_ = + let GroupProfile {displayName, shortDescr, description, image, memberAdmission, publicGroup} = groupProfile + gt = (\PublicGroupProfile {groupType} -> groupType) <$> publicGroup + entryType = DETGroup gt memberAdmission groupSummary + description' = case publicGroup of + Just PublicGroupProfile {groupType = gt', groupLink = sLnk} -> + let gtStr = case gt' of GTChannel -> "channel"; _ -> "group" + linkLine = "Link to join the " <> gtStr <> " " <> displayName <> ": " <> decodeUtf8 (strEncode sLnk) + in Just $ maybe linkLine (<> "\n\n" <> linkLine) description + Nothing -> description + entry groupLink = + let de = + DirectoryEntry + { entryType, + displayName, + groupLink, + shortDescr = toFormattedText <$> shortDescr, + welcomeMessage = toFormattedText <$> description', + imageFile = fst <$> imgData, + activeAt = recentRoundedTime 900 now $ fromMaybe createdAt chatTs, + createdAt = recentRoundedTime 86400 now createdAt + } + imgData = imgFileData groupLink =<< image + in (de, imgData) + in case publicGroup of + Just PublicGroupProfile {groupLink = sLnk} -> + Just $ entry $ PublicLink Nothing (Just sLnk) + Nothing -> + entry . toPublicLink . connLinkContact <$> gLink_ + where + toPublicLink (CCLink fullLink shortLink) = PublicLink (Just fullLink) shortLink + imgFileData :: PublicLink -> ImageData -> Maybe (FilePath, ByteString) + imgFileData PublicLink {connFullLink, connShortLink} (ImageData img) = + let (img', imgExt) = + fromMaybe (img, ".jpg") $ + (,".jpg") <$> T.stripPrefix "data:image/jpg;base64," img + <|> (,".png") <$> T.stripPrefix "data:image/png;base64," img + linkHash = case connFullLink of + Just fl -> strEncode fl + Nothing -> maybe "" strEncode connShortLink + imgName = B.unpack $ B64URL.encodeUnpadded $ BA.convert $ (CH.hash :: ByteString -> Digest MD5) linkHash + imgFile = listingImageFolder imgName <> imgExt + in case B64.decode $ encodeUtf8 img' of + Right img'' -> Just (imgFile, img'') + Left _ -> Nothing + +generateListing :: FilePath -> [(GroupInfo, GroupReg, Maybe GroupLink)] -> IO () +generateListing dir gs = do + createDirectoryIfMissing True dir + oldDirs <- filter ((directoryDataPath <> ".") `isPrefixOf`) <$> listDirectory dir + ts <- getCurrentTime + let newDirPath = directoryDataPath <> "." <> iso8601Show ts <> "/" + newDir = dir newDirPath + createDirectoryIfMissing True (newDir listingImageFolder) + gs' <- + fmap catMaybes $ forM gs $ \(g, gr, link_) -> + forM (groupDirectoryEntry ts g link_) $ \(g', img) -> do + forM_ img $ \(imgFile, imgData) -> B.writeFile (newDir imgFile) imgData + pure (g', gr) + saveListing newDir listingFileName gs' + saveListing newDir promotedFileName $ filter (\(_, GroupReg {promoted}) -> promoted) gs' + -- atomically update the link + let newSymLink = newDir <> ".link" + symLink = dir directoryDataPath + createDirectoryLink newDirPath newSymLink + renamePath newSymLink symLink + mapM_ (removePathForcibly . (dir )) oldDirs + where + saveListing newDir f = LB.writeFile (newDir f) . J.encode . DirectoryListing . map fst + +toFormattedText :: Text -> MarkdownList +toFormattedText t = fromMaybe [FormattedText Nothing t] $ parseMaybeMarkdownList t diff --git a/bots/haskell/simplexxx-directory/src/Directory/Options.hs b/bots/haskell/simplexxx-directory/src/Directory/Options.hs new file mode 100644 index 0000000..f271431 --- /dev/null +++ b/bots/haskell/simplexxx-directory/src/Directory/Options.hs @@ -0,0 +1,236 @@ +{-# LANGUAGE ApplicativeDo #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} + +module Directory.Options + ( DirectoryOpts (..), + MigrateLog (..), + getDirectoryOpts, + directoryOpts, + mkChatOpts, + ) +where + +import qualified Data.Attoparsec.ByteString.Char8 as A +import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) +import Options.Applicative +import Simplex.Chat.Bot.KnownContacts +import Simplex.Chat.Controller (updateStr, versionNumber, versionString) +import Simplex.Chat.Options (ChatCmdLog (..), ChatOpts (..), CoreChatOpts, CreateBotOpts (..), coreChatOptsP) +import Simplex.Messaging.Parsers (parseAll) + +data DirectoryOpts = DirectoryOpts + { coreOptions :: CoreChatOpts, + adminUsers :: [KnownContact], + superUsers :: [KnownContact], + ownersGroup :: Maybe KnownGroup, + noAddress :: Bool, -- skip creating address + blockedWordsFile :: Maybe FilePath, + blockedFragmentsFile :: Maybe FilePath, + blockedExtensionRules :: Maybe FilePath, + nameSpellingFile :: Maybe FilePath, + profileNameLimit :: Int, + captchaGenerator :: Maybe FilePath, + voiceCaptchaGenerator :: Maybe FilePath, + directoryLog :: Maybe FilePath, + migrateDirectoryLog :: Maybe MigrateLog, + serviceName :: T.Text, + runCLI :: Bool, + searchResults :: Int, + webFolder :: Maybe FilePath, + linkCheckInterval :: Int, + testing :: Bool + } + +data MigrateLog = MLCheck | MLImport | MLExport | MLListing + +directoryOpts :: FilePath -> FilePath -> Parser DirectoryOpts +directoryOpts appDir defaultDbName = do + coreOptions <- coreChatOptsP appDir defaultDbName + adminUsers <- + option + parseKnownContacts + ( long "admin-users" + <> metavar "ADMIN_USERS" + <> value [] + <> help "Comma-separated list of admin-users in the format CONTACT_ID:DISPLAY_NAME who will be allowed to manage the directory" + ) + superUsers <- + option + parseKnownContacts + ( long "super-users" + <> metavar "SUPER_USERS" + <> help "Comma-separated list of super-users in the format CONTACT_ID:DISPLAY_NAME who will be allowed to manage the directory" + ) + ownersGroup <- + optional $ + option + parseKnownGroup + ( long "owners-group" + <> metavar "OWNERS_GROUP" + <> help "The group of group owners in the format GROUP_ID:DISPLAY_NAME - owners of listed groups will be invited automatically" + ) + noAddress <- + switch + ( long "no-address" + <> help "skip checking and creating service address" + ) + blockedWordsFile <- + optional $ + strOption + ( long "blocked-words-file" + <> metavar "BLOCKED_WORDS_FILE" + <> help "File with the basic forms of words not allowed in profiles" + ) + blockedFragmentsFile <- + optional $ + strOption + ( long "blocked-fragments-file" + <> metavar "BLOCKED_WORDS_FILE" + <> help "File with the basic forms of word fragments not allowed in profiles" + ) + blockedExtensionRules <- + optional $ + strOption + ( long "blocked-extenstion-rules" + <> metavar "BLOCKED_EXTENSION_RULES" + <> help "Substitions to extend the list of blocked words" + ) + nameSpellingFile <- + optional $ + strOption + ( long "name-spelling-file" + <> metavar "NAME_SPELLING_FILE" + <> help "File with the character substitions to match in profile names" + ) + profileNameLimit <- + option + auto + ( long "profile-name-limit" + <> metavar "PROFILE_NAME_LIMIT" + <> help "Max length of profile name that will be allowed to connect and to join groups" + <> value maxBound + ) + captchaGenerator <- + optional $ + strOption + ( long "captcha-generator" + <> metavar "CAPTCHA_GENERATOR" + <> help "Executable to generate captcha files, must accept text as parameter and save file to stdout as base64 up to 12500 bytes" + ) + voiceCaptchaGenerator <- + optional $ + strOption + ( long "voice-captcha-generator" + <> metavar "VOICE_CAPTCHA_GENERATOR" + <> help "Executable to generate voice captcha, accepts text as parameter, writes audio file, outputs file_path and duration_seconds to stdout" + ) + directoryLog <- + optional $ + strOption + ( long "directory-file" + <> metavar "DIRECTORY_FILE" + <> help "Append only log for directory state" + ) + migrateDirectoryLog <- + optional $ + option + parseMigrateLog + ( long "migrate-directory-file" + <> metavar "MIGRATE_COMMAND" + <> help "Command to import/export directory log file" + ) + serviceName <- + strOption + ( long "service-name" + <> metavar "SERVICE_NAME" + <> help "The display name of the directory service bot, without *'s and spaces (SimpleXXX)" + <> value "SimpleXXX" + ) + runCLI <- + switch + ( long "run-cli" + <> help "Run directory service as CLI" + ) + webFolder <- + optional $ + strOption + ( long "web-folder" + <> metavar "WEB_FOLDER" + <> help "Folder to store static web assets" + ) + linkCheckInterval <- + option + auto + ( long "link-check-interval" + <> metavar "SECONDS" + <> help "Interval in seconds to check public group link data (default: 1800)" + <> value 1800 + ) + pure + DirectoryOpts + { coreOptions, + adminUsers, + superUsers, + ownersGroup, + noAddress, + blockedWordsFile, + blockedFragmentsFile, + blockedExtensionRules, + nameSpellingFile, + profileNameLimit, + captchaGenerator, + voiceCaptchaGenerator, + directoryLog, + migrateDirectoryLog, + serviceName = T.pack serviceName, + runCLI, + searchResults = 10, + webFolder, + linkCheckInterval, + testing = False + } + +getDirectoryOpts :: FilePath -> FilePath -> IO DirectoryOpts +getDirectoryOpts appDir defaultDbName = + execParser $ + info + (helper <*> versionOption <*> directoryOpts appDir defaultDbName) + (header versionStr <> fullDesc <> progDesc "Start SimpleXXX Directory Service with DB_FILE, DIRECTORY_FILE and SUPER_USERS options") + where + versionStr = versionString versionNumber + versionOption = infoOption versionAndUpdate (long "version" <> short 'v' <> help "Show version") + versionAndUpdate = versionStr <> "\n" <> updateStr + +mkChatOpts :: DirectoryOpts -> ChatOpts +mkChatOpts DirectoryOpts {coreOptions, serviceName} = + ChatOpts + { coreOptions, + chatCmd = "", + chatCmdDelay = 3, + chatCmdLog = CCLNone, + chatServerPort = Nothing, + optFilesFolder = Nothing, + optTempDirectory = Nothing, + showReactions = False, + allowInstantFiles = True, + autoAcceptFileSize = 0, + muteNotifications = True, + markRead = False, + createBot = Just CreateBotOpts {botDisplayName = serviceName, allowFiles = False} + } + +parseMigrateLog :: ReadM MigrateLog +parseMigrateLog = eitherReader $ parseAll mlP . encodeUtf8 . T.pack + where + mlP = + A.takeTill (== ' ') >>= \case + "check" -> pure MLCheck + "import" -> pure MLImport + "export" -> pure MLExport + "listing" -> pure MLListing + _ -> fail "bad MigrateLog" diff --git a/bots/haskell/simplexxx-directory/src/Directory/Search.hs b/bots/haskell/simplexxx-directory/src/Directory/Search.hs new file mode 100644 index 0000000..d71c128 --- /dev/null +++ b/bots/haskell/simplexxx-directory/src/Directory/Search.hs @@ -0,0 +1,13 @@ +module Directory.Search where + +import Data.Text (Text) +import Data.Time.Clock (UTCTime) +import Simplex.Chat.Types + +data SearchRequest = SearchRequest + { searchType :: SearchType, + searchTime :: UTCTime, + lastGroup :: GroupId -- cursor for search + } + +data SearchType = STAll | STRecent | STSearch Text diff --git a/bots/haskell/simplexxx-directory/src/Directory/Service.hs b/bots/haskell/simplexxx-directory/src/Directory/Service.hs new file mode 100644 index 0000000..d727e44 --- /dev/null +++ b/bots/haskell/simplexxx-directory/src/Directory/Service.hs @@ -0,0 +1,1538 @@ +{-# 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, threadDelay) +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, maybeToList) +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 (..), SimplexLinkType (..), parseMaybeMarkdownList, viewName) +import Simplex.Chat.Messages +import Simplex.Chat.Options +import Simplex.Chat.Protocol (GroupShortLinkData (..), LinkOwnerSig (..), MsgChatLink (..), MsgContent (..), memberSupportVoiceVersion) +import Simplex.Chat.Store.Direct (getContact) +import Simplex.Chat.Store.Groups (getGroupLink, getGroupMember, getGroupMemberByMemberId, 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 (..), ACreatedConnLink (..), AgentErrorType (..), ConnectionLink (..), CreatedConnLink (..), SConnectionMode (..), sameConnReqContact, sameShortLinkContact) +import qualified Simplex.Messaging.Crypto.File as CF +import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Protocol (ErrorType (..)) +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, + serviceCC :: TMVar ChatController, + eventQ :: TQueue DirectoryEvent, + updateListingsJob :: TMVar () + } + +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 + serviceCC <- newEmptyTMVarIO + eventQ <- newTQueueIO + updateListingsJob <- newEmptyTMVarIO + pure ServiceState {searchRequests, blockedWordsCfg, pendingCaptchas, serviceCC, eventQ, 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 $ "SimpleXXX 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@ServiceState {eventQ} <- newServiceState opts + let eventHook _cc resp = atomically $ resp <$ mapM_ (writeTQueue eventQ) (crDirectoryEvent 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 env + ] + <> maybeToList (updateListingsThread_ opts env) + <> maybeToList (linkCheckThread_ opts env) + where + processEvents env@ServiceState {eventQ} = do + cc <- atomically $ readTMVar $ serviceCC env + u_ <- readTVarIO (currentUser cc) + forM_ u_ $ \user -> + forever $ do + event <- atomically $ readTQueue eventQ + directoryServiceEvent st opts env user cc event + +updateListingDelay :: Int +updateListingDelay = 5 * 60 * 1000000 -- update every 5 minutes + +updateListingsThread_ :: DirectoryOpts -> ServiceState -> Maybe (IO ()) +updateListingsThread_ opts env = updateListingsThread <$> webFolder opts + where + updateListingsThread f = do + cc <- atomically $ readTMVar $ serviceCC 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 -> IO () +listingsUpdated env = void $ atomically $ tryPutTMVar (updateListingsJob env) () + +linkCheckThread_ :: DirectoryOpts -> ServiceState -> Maybe (IO ()) +linkCheckThread_ opts env@ServiceState {eventQ} + | linkCheckInterval opts > 0 = Just $ do + cc <- atomically $ readTMVar $ serviceCC env + forever $ do + threadDelay $ linkCheckInterval opts * 1000000 + u <- readTVarIO $ currentUser cc + forM_ u $ \user -> + withDB' "linkCheckThread" cc (\db -> getAllGroupRegs_ db user) >>= \case + Left e -> logError $ "linkCheckThread error: " <> T.pack e + Right grs -> forM_ grs $ \(gInfo, gr) -> + unless (groupRemoved $ groupRegStatus gr) $ + atomically $ writeTQueue eventQ $ DEGroupLinkCheck gInfo + | otherwise = Nothing + +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 + void $ atomically $ tryPutTMVar (serviceCC env) cc + listingsUpdated env + 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 ":''") + ] + ] + where + idParam = Just "" + +directoryService :: DirectoryLog -> DirectoryOpts -> ChatConfig -> IO () +directoryService st opts cfg = do + env@ServiceState {eventQ} <- 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 $ do + (_, resp) <- atomically . readTBQueue $ outputQ cc + mapM_ (atomically . writeTQueue eventQ) $ crDirectoryEvent resp, + forever $ do + event <- atomically $ readTQueue eventQ + directoryServiceEvent st opts env user cc event + ] + <> maybeToList (updateListingsThread_ opts env) + <> maybeToList (linkCheckThread_ 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 -> DirectoryEvent -> IO () +directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName, ownersGroup, searchResults} env@ServiceState {searchRequests} user@User {userId} cc = \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 + DEGroupLinkCheck g -> deGroupLinkCheck g + 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 + DEChatLinkReceived {contact = ct, chatLink, ownerSig} -> deChatLinkReceived ct chatLink ownerSig + DEMemberUpdated {groupInfo = g, fromMember, toMember} -> deMemberUpdated g fromMember toMember + 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, publicGroup} = groupNameDescr p <> maybe "" ("\nWelcome message:\n" <>) d <> linkToJoin + where + linkToJoin = case publicGroup of + Just pg@PublicGroupProfile {groupLink} -> + "\nLink to join " <> groupTypeStr' pg <> ": " <> strEncodeTxt groupLink + <> "\nYou need SimpleX Chat app v6.5 to join." + Nothing -> "" + membersCountStr GroupProfile {publicGroup} GroupSummary {currentMembers, publicMemberCount} = + let count = fromMaybe currentMembers publicMemberCount + label = case publicGroup of + Just PublicGroupProfile {groupType = GTChannel} -> " subscribers" + _ -> " members" + in tshow count <> label + 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." + ifPublicGroup :: GroupInfo -> IO () -> IO () -> IO () + ifPublicGroup GroupInfo {groupProfile = GroupProfile {publicGroup}} reject action = + if isJust publicGroup then reject else action + + 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 or channel.\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 publicGroup p' of + Just pg -> case groupRegStatus of + GRSPendingApproval n -> publicGroupProfileChange pg gr byMember $ n + 1 + GRSActive -> publicGroupProfileChange pg gr byMember 1 + _ -> pure () + Nothing -> 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, publicGroup = pg} + GroupProfile {displayName = n', fullName = fn', shortDescr = sd', image = i', description = d', memberAdmission = ma', publicGroup = pg'} = + n == n' && fn == fn' && i == i' && sd == sd' && (T.words <$> d) == (T.words <$> d') && ma == ma' && pg == pg' + publicGroupProfileChange pg@PublicGroupProfile {groupLink} gr byMember n' = do + let gt = groupTypeStr' pg + userGroupRef = userGroupReference gr toGroup + groupRef = groupReference toGroup + link = ACL SCMContact $ CLShort groupLink + updatedNotification gr' g' = do + notifyOwner gr' $ + ("The " <> gt <> " " <> userGroupRef <> " is updated" <> byMember) + <> ".\nIt is hidden from the directory until approved." + notifyAdminUsers $ "The " <> gt <> " " <> groupRef <> " is updated" <> byMember <> "." + sendToApprove g' gr' n' + sendChatCmd cc (APIConnectPlan userId (Just link) True Nothing) >>= \case + Right (CRConnectionPlan _ _ (CPGroupLink (GLPKnown {groupInfo = g'}))) -> + case dbOwnerMemberId gr of + Just ownerGMId -> + withDB "getGroupMember" cc (\db -> withExceptT show $ getGroupMember db (vr cc) user groupId ownerGMId) >>= \case + Right ownerMember + | let GroupMember {memberRole = role} = ownerMember, role >= GROwner -> + setGroupStatus notifyAdminUsers st env cc groupId (GRSPendingApproval n') (`updatedNotification` g') + | otherwise -> do + setGroupStatus notifyAdminUsers st env cc groupId GRSSuspendedBadRoles $ \_ -> pure () + notifyOwner gr $ "The registration owner is no longer an owner. Registration suspended." + Left _ -> logError $ "could not find owner member for " <> groupRef + Nothing -> logError $ "no owner member set for " <> groupRef + _ -> + setGroupStatus notifyAdminUsers st env cc groupId (GRSPendingApproval n') (`updatedNotification` toGroup) + 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 SimpleXXX 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 Nothing <* 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', publicGroup = pg_}, groupSummary} GroupReg {dbContactId, promoted} gaId = do + ct_ <- getContact' cc user dbContactId + let gt = maybe "group" groupTypeStr' pg_ + membersStr = "_" <> membersCountStr p groupSummary <> "_\n" + text = + either (\_ -> "The " <> gt <> " ID " <> tshow groupId <> " submitted: ") (\c -> localDisplayName' c <> " submitted the " <> gt <> " 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] + + deGroupLinkCheck :: GroupInfo -> IO () + deGroupLinkCheck gInfo@GroupInfo {groupId, groupProfile = GroupProfile {publicGroup = pg_}, groupSummary = summary} = + withGroupReg gInfo "link check" $ \gr@GroupReg {groupRegStatus, dbOwnerMemberId} -> + forM_ pg_ $ \pg@PublicGroupProfile {groupLink} -> + when (groupRegStatus == GRSActive || pendingApproval groupRegStatus) $ do + let link = ACL SCMContact $ CLShort groupLink + sendChatCmd cc (APIConnectPlan userId (Just link) True Nothing) >>= \case + Right (CRConnectionPlan _ _ (CPGroupLink (GLPKnown {groupInfo = g', groupUpdated = BoolDef updated, linkOwners = ListDef owners}))) -> + checkValidOwner dbOwnerMemberId owners $ do + when updated $ reapprove pg gr groupRegStatus g' + when (updated || summary /= groupSummary g') $ listingsUpdated env + Left (ChatErrorAgent {agentError = SMP _ err}) | linkDeleted err -> + setGroupStatus logError st env cc groupId GRSRemoved $ \gr' -> + notifyOwner gr' "The channel link is no longer valid.\nThe channel is removed from the directory." + _ -> pure () + where + linkDeleted = \case + AUTH -> True + BLOCKED {} -> True + _ -> False + checkValidOwner dbOwnerMemberId owners onValid = case dbOwnerMemberId of + Just ownerGMId -> + withDB "checkGroupLink" cc (\db -> withExceptT show $ getGroupMember db (vr cc) user groupId ownerGMId) >>= \case + Right GroupMember {memberId, memberPubKey} + | any (\GroupLinkOwner {memberId = mId, memberKey} -> memberId == mId && memberPubKey == Just memberKey) owners -> onValid + _ -> setGroupStatus logError st env cc groupId GRSSuspendedBadRoles $ \gr' -> + notifyOwner gr' "The registration owner is no longer a channel owner.\nThe channel is no longer listed in the directory." + Nothing -> onValid + reapprove pg gr groupRegStatus g' = do + let gt = groupTypeStr' pg + groupRef = groupReference gInfo + notifyAdminUsers $ "The " <> gt <> " " <> groupRef <> " profile changed." + case groupRegStatus of + GRSActive -> + setGroupStatus notifyAdminUsers st env cc groupId (GRSPendingApproval 1) $ \gr' -> do + notifyOwner gr' $ "The " <> gt <> " profile has changed.\nIt is hidden from the directory until approved." + sendToApprove g' gr' 1 + GRSPendingApproval n -> + sendToApprove g' gr (n + 1) + _ -> pure () + + 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, groupProfile = GroupProfile {publicGroup = pg_}} = do + let gt = maybe "group" groupTypeStr' pg_ + logInfo $ "contact ID " <> tshow ctId <> " removed from group " <> viewGroupName g + withGroupReg g "contact removed" $ \gr -> + when (ctId `isOwner` gr) $ + setGroupStatus notifyAdminUsers st env cc groupId GRSRemoved $ \gr' -> do + notifyOwner gr' $ "You are removed from the " <> gt <> " " <> userGroupReference gr' g <> ".\n\nThe " <> gt <> " is no longer listed in the directory." + notifyAdminUsers $ "The " <> gt <> " " <> groupReference g <> " is de-listed (" <> gt <> " owner is removed)." + when (isJust pg_) $ leavePublicGroup g + + deContactLeftGroup :: ContactId -> GroupInfo -> IO () + deContactLeftGroup ctId g@GroupInfo {groupId, groupProfile = GroupProfile {publicGroup = pg_}} = do + let gt = maybe "group" groupTypeStr' pg_ + logInfo $ "contact ID " <> tshow ctId <> " left group " <> viewGroupName g + withGroupReg g "contact left" $ \gr -> + when (ctId `isOwner` gr) $ + setGroupStatus notifyAdminUsers st env cc groupId GRSRemoved $ \gr' -> do + notifyOwner gr' $ "You left the " <> gt <> " " <> userGroupReference gr' g <> ".\n\nThe " <> gt <> " is no longer listed in the directory." + notifyAdminUsers $ "The " <> gt <> " " <> groupReference g <> " is de-listed (" <> gt <> " owner left)." + when (isJust pg_) $ leavePublicGroup g + + deServiceRemovedFromGroup :: GroupInfo -> IO () + deServiceRemovedFromGroup g@GroupInfo {groupId, groupProfile = GroupProfile {publicGroup = pg_}} = do + let gt = maybe "group" groupTypeStr' pg_ + logInfo $ "service removed from group " <> viewGroupName g + setGroupStatus notifyAdminUsers st env cc groupId GRSRemoved $ \gr -> do + notifyOwner gr $ serviceName <> " is removed from the " <> gt <> " " <> userGroupReference gr g <> ".\n\nThe " <> gt <> " is no longer listed in the directory." + notifyAdminUsers $ "The " <> gt <> " " <> groupReference g <> " is de-listed (directory service is removed)." + + deGroupDeleted :: GroupInfo -> IO () + deGroupDeleted g@GroupInfo {groupId, groupProfile = GroupProfile {publicGroup = pg_}} = do + let gt = maybe "group" groupTypeStr' pg_ + logInfo $ "group removed " <> viewGroupName g + setGroupStatus notifyAdminUsers st env cc groupId GRSRemoved $ \gr -> do + notifyOwner gr $ "The " <> gt <> " " <> userGroupReference gr g <> " is deleted.\n\nThe " <> gt <> " is no longer listed in the directory." + notifyAdminUsers $ "The " <> gt <> " " <> groupReference g <> " is de-listed (" <> gt <> " is deleted)." + + deChatLinkReceived :: Contact -> MsgChatLink -> Maybe LinkOwnerSig -> IO () + deChatLinkReceived ct (MCLGroup {connLink, groupProfile = GroupProfile {publicGroup = Just PublicGroupProfile {groupType}}}) (Just ownerSig@LinkOwnerSig {ownerId = Just (B64UrlByteString oIdBytes)}) = + case groupType of + GTUnknown tag -> sendMessage cc ct $ "Unsupported group type: " <> T.pack (show tag) + gt -> do + let link = ACL SCMContact $ CLShort connLink + mId = MemberId oIdBytes + gt' = groupTypeStr gt + sendChatCmd cc (APIConnectPlan userId (Just link) True (Just ownerSig)) >>= \case + Right (CRConnectionPlan _ (ACCL SCMContact ccLink) plan) -> + handleGroupLinkPlan ct ccLink mId ownerSig gt' plan + _ -> sendMessage cc ct "Error: could not connect. Please report it to directory admins." + deChatLinkReceived ct (MCLGroup {groupProfile = GroupProfile {publicGroup = Just pg}}) _ = + sendMessage cc ct $ "To add a " <> groupTypeStr' pg <> " to directory you must be the owner." + deChatLinkReceived ct _ _ = + sendMessage cc ct "Only channels can be added to directory via link." + + groupTypeStr :: GroupType -> Text + groupTypeStr = \case + GTChannel -> "channel" + GTGroup -> "group" + GTUnknown _ -> "group" + + groupTypeStr' :: PublicGroupProfile -> Text + groupTypeStr' PublicGroupProfile {groupType} = groupTypeStr groupType + + leavePublicGroup :: GroupInfo -> IO () + leavePublicGroup GroupInfo {groupId} = + void $ sendChatCmd cc (APILeaveGroup groupId) + + handleGroupLinkPlan :: Contact -> CreatedLinkContact -> MemberId -> LinkOwnerSig -> Text -> ConnectionPlan -> IO () + handleGroupLinkPlan ct ccLink mId ownerSig gt = \case + CPGroupLink glp -> case glp of + GLPOk {groupSLinkData_, ownerVerification} -> case (groupSLinkData_, ownerVerification) of + (Just groupSLinkData, Just OVVerified) -> joinAndRegisterPublicGroup ct ccLink mId gt groupSLinkData + (_, Just (OVFailed reason)) -> sendMessage cc ct $ "Link signature verification failed: " <> reason <> ".\nYou must be the " <> gt <> " owner to register it." + (Nothing, _) -> sendMessage cc ct $ "Error: no " <> gt <> " information available via the link." + _ -> sendMessage cc ct $ "Error: could not verify " <> gt <> " ownership. Please report it to directory admins." + GLPKnown {groupInfo = g, groupUpdated = BoolDef updated, ownerVerification} -> case ownerVerification of + Just OVVerified -> deReregistration ct g updated ownerSig + Just (OVFailed reason) -> sendMessage cc ct $ "Link signature verification failed: " <> reason <> ".\nYou must be the " <> gt <> " owner to register it." + Nothing -> sendMessage cc ct $ "Error: could not verify " <> gt <> " ownership." + GLPConnectingProhibit _ -> sendMessage cc ct $ "Already connecting to this " <> gt <> "." + GLPConnectingConfirmReconnect -> sendMessage cc ct $ "Already connecting to this " <> gt <> "." + GLPNoRelays _ -> sendMessage cc ct $ T.toTitle gt <> " has no active relays. Please try again later." + GLPOwnLink _ -> sendMessage cc ct "Unexpected error. Please report it to directory admins." + _ -> sendMessage cc ct "Unexpected error. Please report it to directory admins." + + joinAndRegisterPublicGroup :: Contact -> CreatedLinkContact -> MemberId -> Text -> GroupShortLinkData -> IO () + joinAndRegisterPublicGroup ct ccLink mId gt groupSLinkData = do + let GroupShortLinkData {groupProfile = GroupProfile {displayName}} = groupSLinkData + ownerContact = GroupOwnerContact {contactId = contactId' ct, memberId = mId} + sendMessage cc ct $ "Joining the " <> gt <> " " <> displayName <> "…" + sendChatCmd cc (APIPrepareGroup userId ccLink False groupSLinkData) >>= \case + Right (CRNewPreparedChat _ (AChat SCTGroup (Chat (GroupChat gInfo _) _ _))) -> do + let gId = groupId' gInfo + addGroupReg notifyAdminUsers st cc ct gInfo GRSProposed $ \_ -> pure () + sendChatCmd cc (APIConnectPreparedGroup gId False (Just ownerContact) Nothing) >>= \case + Right CRStartedConnectionToGroup {groupInfo = gInfo'} -> + withDB "getGroupMember" cc (\db -> withExceptT show $ getGroupMemberByMemberId db (vr cc) user gInfo' mId) >>= \case + Right ownerMember -> + void $ setGroupRegOwner cc gId ownerMember + Left e -> do + logError $ "could not find owner member: " <> T.pack e + sendMessage cc ct "Error: could not find owner member after joining. Please report it to directory admins." + _ -> sendMessage cc ct $ "Error joining " <> gt <> " " <> displayName <> ", please re-send the link!" + _ -> sendMessage cc ct $ "Error joining " <> gt <> " " <> displayName <> ", please re-send the link!" + + deReregistration :: Contact -> GroupInfo -> Bool -> LinkOwnerSig -> IO () + deReregistration ct g@GroupInfo {groupId, groupProfile = GroupProfile {publicGroup = pg_}} profileChanged LinkOwnerSig {ownerId = Just (B64UrlByteString oIdBytes)} = do + let mId = MemberId oIdBytes + gt = maybe "group" groupTypeStr' pg_ + withDB "getGroupMemberByMemberId" cc (\db -> withExceptT show $ getGroupMemberByMemberId db (vr cc) user g mId) >>= \case + Right ownerMember@GroupMember {memberRole = role, memberStatus} -> + if + | role >= GROwner && memberStatus /= GSMemUnknown -> + getGroupReg cc groupId >>= \case + Right gr + | contactId' ct `isOwner` gr -> sameOwnerReregistration gr gt + | otherwise -> sendMessage cc ct $ "This " <> gt <> " is registered by another owner." + Left _ -> + addGroupReg notifyAdminUsers st cc ct g (GRSPendingApproval 1) $ \gr -> do + void $ setGroupRegOwner cc groupId ownerMember + sendToApprove g gr 1 + | role < GROwner -> sendMessage cc ct $ "You must be the " <> gt <> " owner to register it." + | otherwise -> sendMessage cc ct $ "Waiting for the owner member to be connected to the " <> gt <> "." + Left _ -> sendMessage cc ct $ "Error: could not verify " <> gt <> " ownership. Please report it to directory admins." + where + sameOwnerReregistration gr gt = case groupRegStatus gr of + GRSProposed -> sendMessage cc ct $ "Registration is in progress, waiting for the owner member to be connected to the " <> gt <> "." + GRSPendingConfirmation -> pendingApprovalTransition gr gt 1 + GRSPendingUpdate -> pendingApprovalTransition gr gt 1 + GRSPendingApproval n + | profileChanged -> pendingApprovalTransition gr gt $ n + 1 + | otherwise -> sendMessage cc ct $ T.toTitle gt <> " is already pending approval." + GRSActive + | profileChanged -> pendingApprovalTransition gr gt 1 + | otherwise -> sendMessage cc ct $ T.toTitle gt <> " is already listed in the directory." + GRSSuspended -> sendMessage cc ct $ T.toTitle gt <> " is suspended by admin. Please contact support." + GRSSuspendedBadRoles -> pendingApprovalTransition gr gt 1 + GRSRemoved -> pendingApprovalTransition gr gt 1 + pendingApprovalTransition gr gt n = do + let userGroupRef = userGroupReference gr g + setGroupStatus notifyAdminUsers st env cc groupId (GRSPendingApproval n) $ \gr' -> do + notifyOwner gr' $ + "The " <> gt <> " " <> userGroupRef <> " is submitted for approval.\nIt is hidden from the directory until approved." + sendToApprove g gr' n + deReregistration ct _ _ _ = + sendMessage cc ct "Error: could not verify ownership. Please report it to directory admins." + + deMemberUpdated :: GroupInfo -> GroupMember -> GroupMember -> IO () + deMemberUpdated g@GroupInfo {groupId, groupProfile = GroupProfile {displayName, publicGroup}} fromMember toMember = + withGroupReg g "owner member announced" $ \gr@GroupReg {groupRegStatus, dbOwnerMemberId} -> + when (groupRegStatus == GRSProposed && (dbOwnerMemberId == Just (groupMemberId' fromMember) || dbOwnerMemberId == Just (groupMemberId' toMember))) $ + let GroupMember {memberRole = role} = toMember + gt = maybe "group" groupTypeStr' publicGroup + in if role >= GROwner + then setGroupStatus notifyAdminUsers st env cc groupId (GRSPendingApproval 1) $ \gr' -> do + notifyOwner gr' $ "Joined the " <> gt <> " " <> displayName <> ". Registration is pending approval — it may take up to 48 hours." + sendToApprove g gr' 1 + else do + setGroupStatus notifyAdminUsers st env cc groupId GRSRemoved $ \_ -> pure () + sendMessage' cc (dbContactId gr) "The signing key does not belong to a current owner. Registration cancelled." + + deUserCommand :: Contact -> ChatItemId -> DirectoryCmd 'DRUser -> IO () + deUserCommand ct ciId = \case + DCHelp DHSRegistration -> + sendMessage cc ct $ + "You must be the group or channel owner to add it to the directory.\n\n\ + \*To register a channel*, use _Share via chat_ to send its link to " + <> serviceName + <> " bot.\n\n\ + \*To register a group*:\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\n\ + \Once your group or channel *approved*, it can be found here or at [simplex.chat/directory](https://simplex.chat/directory).\n\n\ + \_We usually review 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 or channel to be added to directory.\n\ + \/list - list the groups you registered.\n\ + \`/role ` - view and set default member role for your group.\n\ + \`/filter ` - view and set spam filter settings for group.\n\ + \`/link ` - view and upgrade group link.\n\ + \`/delete :` - 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 ft -> + sendFoundListedGroups (STSearch s) Nothing notFound $ \gs n -> + let more = if n > length gs then ", sending top " <> tshow (length gs) else "" + in "Found " <> tshow n <> " group(s)" <> more <> "." + where + notFound + | hasSimplexGroupLink ft = "No groups found.\nTo register a group or a channel, please use \"Share via chat\" feature." + | otherwise = "No groups found" + hasSimplexGroupLink = \case + Just fts -> any isGroupLink fts + Nothing -> False + isGroupLink (FormattedText (Just SimplexLink {linkType}) _) = linkType == XLGroup || linkType == XLChannel + isGroupLink _ = False + 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 $ \g@GroupInfo {groupProfile = GroupProfile {displayName, publicGroup = pg_}} GroupReg {dbGroupId} -> do + let gt = maybe "group" groupTypeStr' pg_ + delGroupReg cc dbGroupId >>= \case + Right () -> do + logGDelete st dbGroupId + sendReply $ (if isAdmin then "The " <> gt <> " " else "Your " <> gt <> " ") <> displayName <> " is deleted from the directory" + when (isJust pg_) $ leavePublicGroup g + Left e -> sendReply $ "Error deleting " <> gt <> " " <> displayName <> ": " <> T.pack e + DCMemberRole gId gName_ mRole_ -> + (if isAdmin then withGroupAndReg_ sendReply else withUserGroupReg_) gId gName_ $ \g _gr -> + ifPublicGroup g (sendReply "This command is not available for public groups.") $ 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 -> + ifPublicGroup g (sendReply "This command is not available for public groups.") $ 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 <> " * 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, groupProfile = GroupProfile {publicGroup = pg_}, localDisplayName = gName} _ -> case pg_ of + Just pg@PublicGroupProfile {groupLink} -> + sendReply $ "The link to join the " <> groupTypeStr' pg <> " " <> groupReference' gId gName <> ":\n" <> strEncodeTxt groupLink + Nothing -> 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}, _) = + let membersStr = "_" <> membersCountStr p groupSummary <> "_" + 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 -> do + let GroupInfo {groupProfile = GroupProfile {publicGroup = pg_}} = g + isPublicGroup_ = isJust pg_ + gt = maybe "group" groupTypeStr' pg_ + getDuplicateGroup g >>= \case + Left e -> sendReply $ "Error: getDuplicateGroup. Please notify the developers.\n" <> T.pack e + Right DGReserved -> sendReply $ "The " <> gt <> " " <> groupRef <> " is already listed in the directory." + _ -> do + rolesOk <- if isPublicGroup_ then pure (Right GRSOk) else getGroupRolesStatus g gr + case rolesOk of + 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 " <> gt <> " " <> userGroupReference' gr n <> " is approved" + let commands + | isPublicGroup_ = "" + | otherwise = + "\n\nSupported 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.") + notifyOwner gr $ + (approved <> " and listed in directory - please moderate it!\n") + <> "_Please note_: if you change the " <> gt <> " profile it will be hidden from directory until it is re-approved." + <> commands + 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 $ T.toTitle gt <> " 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 = any (\m -> memberContactId m == Just ctId && memberCurrent m) + 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 = "_" <> membersCountStr p groupSummary <> "_" + 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 + 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 + 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 + 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 diff --git a/bots/haskell/simplexxx-directory/src/Directory/Store.hs b/bots/haskell/simplexxx-directory/src/Directory/Store.hs new file mode 100644 index 0000000..b5f7220 --- /dev/null +++ b/bots/haskell/simplexxx-directory/src/Directory/Store.hs @@ -0,0 +1,587 @@ +{-# LANGUAGE BangPatterns #-} +{-# LANGUAGE CPP #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} +{-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TupleSections #-} +{-# LANGUAGE TypeOperators #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} + +module Directory.Store + ( DirectoryLog (..), + GroupReg (..), + GroupRegStatus (..), + UserGroupRegId, + GroupApprovalId, + DirectoryGroupData (..), + DirectoryMemberAcceptance (..), + DirectoryStatus (..), + ProfileCondition (..), + DirectoryLogRecord (..), + openDirectoryLog, + readDirectoryLogData, + addGroupRegStore, + insertGroupReg, + delGroupReg, + deleteGroupReg, + setGroupStatusStore, + setGroupStatusPromoStore, + setGroupPromotedStore, + grDirectoryStatus, + setGroupRegOwner, + getUserGroupReg, + getUserGroupRegs, + getAllGroupRegs_, + getDuplicateGroupRegs, + getGroupReg, + getGroupAndReg, + listLastGroups, + listPendingGroups, + getAllListedGroups, + getAllListedGroups_, + searchListedGroups, + groupRegStatusText, + pendingApproval, + groupRemoved, + fromCustomData, + toCustomData, + noJoinFilter, + basicJoinFilter, + moderateJoinFilter, + strongJoinFilter, + groupDBError, + logGCreate, + logGDelete, + logGUpdateOwner, + logGUpdateStatus, + logGUpdatePromotion, + ) +where + +import Control.Applicative ((<|>)) +import Control.Monad +import Control.Monad.Except +import Control.Monad.IO.Class +import Data.Aeson ((.:), (.=)) +import qualified Data.Aeson.KeyMap as JM +import qualified Data.Aeson.TH as JQ +import qualified Data.Aeson.Types as JT +import qualified Data.Attoparsec.ByteString.Char8 as A +import Data.ByteString.Char8 (ByteString) +import qualified Data.ByteString.Char8 as B +import Data.Int (Int64) +import Data.List (sortOn) +import Data.Map (Map) +import qualified Data.Map.Strict as M +import Data.Maybe (fromMaybe, isJust) +import Data.Text (Text) +import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) +import Data.Time.Clock (UTCTime (..), getCurrentTime) +import Data.Time.Clock.System (systemEpochDay) +import Directory.Search +import Directory.Util +import Simplex.Chat.Controller +import Simplex.Chat.Protocol (supportedChatVRange) +import Simplex.Chat.Options.DB (FromField (..), ToField (..)) +import Simplex.Chat.Store +import Simplex.Chat.Store.Groups +import Simplex.Chat.Store.Shared (groupInfoQueryFields, groupInfoQueryFrom) +import Simplex.Chat.Types +import Simplex.Messaging.Agent.Store.DB (BoolInt (..), fromTextField_) +import qualified Simplex.Messaging.Agent.Store.DB as DB +import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON) +import Simplex.Messaging.Util (eitherToMaybe, firstRow, maybeFirstRow', safeDecodeUtf8) +import System.IO (BufferMode (..), Handle, IOMode (..), hSetBuffering, openFile) + +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..), Query, (:.) (..)) +import Database.PostgreSQL.Simple.SqlQQ (sql) +#else +import Database.SQLite.Simple (Only (..), Query, (:.) (..)) +import Database.SQLite.Simple.QQ (sql) +#endif + +data DirectoryLog = DirectoryLog + { directoryLogFile :: Maybe Handle + } + +data GroupReg = GroupReg + { dbGroupId :: GroupId, + userGroupRegId :: UserGroupRegId, + dbContactId :: ContactId, + dbOwnerMemberId :: Maybe GroupMemberId, + groupRegStatus :: GroupRegStatus, + promoted :: Bool, + createdAt :: UTCTime + } + +data DirectoryGroupData = DirectoryGroupData + { memberAcceptance :: DirectoryMemberAcceptance + } + +-- these filters are applied in the order of fields, depending on ProfileCondition: +-- Nothing - do not apply +-- Just +-- PCAll - apply to all profiles +-- PCNoImage - apply to profiles without images +data DirectoryMemberAcceptance = DirectoryMemberAcceptance + { rejectNames :: Maybe ProfileCondition, -- reject long names and names with profanity + passCaptcha :: Maybe ProfileCondition, -- run captcha challenge with joining members + makeObserver :: Maybe ProfileCondition -- the role assigned in the end, after captcha challenge + } + deriving (Eq, Show) + +data ProfileCondition = PCAll | PCNoImage deriving (Eq, Show) + +noJoinFilter :: DirectoryMemberAcceptance +noJoinFilter = DirectoryMemberAcceptance Nothing Nothing Nothing + +basicJoinFilter :: DirectoryMemberAcceptance +basicJoinFilter = + DirectoryMemberAcceptance + { rejectNames = Just PCNoImage, + passCaptcha = Nothing, + makeObserver = Nothing + } + +moderateJoinFilter :: DirectoryMemberAcceptance +moderateJoinFilter = + DirectoryMemberAcceptance + { rejectNames = Just PCAll, + passCaptcha = Just PCNoImage, + makeObserver = Nothing + } + +strongJoinFilter :: DirectoryMemberAcceptance +strongJoinFilter = + DirectoryMemberAcceptance + { rejectNames = Just PCAll, + passCaptcha = Just PCAll, + makeObserver = Nothing + } + +type UserGroupRegId = Int64 + +type GroupApprovalId = Int64 + +data GroupRegStatus + = GRSPendingConfirmation + | GRSProposed + | GRSPendingUpdate + | GRSPendingApproval GroupApprovalId + | GRSActive + | GRSSuspended + | GRSSuspendedBadRoles + | GRSRemoved + deriving (Eq, Show) + +pendingApproval :: GroupRegStatus -> Bool +pendingApproval = \case + GRSPendingApproval _ -> True + _ -> False + +groupRemoved :: GroupRegStatus -> Bool +groupRemoved = \case + GRSRemoved -> True + _ -> False + +data DirectoryStatus = DSListed | DSReserved | DSRegistered | DSRemoved + deriving (Eq) + +groupRegStatusText :: GroupRegStatus -> Text +groupRegStatusText = \case + GRSPendingConfirmation -> "pending confirmation (duplicate names)" + GRSProposed -> "proposed" + GRSPendingUpdate -> "pending profile update" + GRSPendingApproval _ -> "pending admin approval" + GRSActive -> "active" + GRSSuspended -> "suspended by admin" + GRSSuspendedBadRoles -> "suspended because roles changed" + GRSRemoved -> "removed" + +grDirectoryStatus :: GroupRegStatus -> DirectoryStatus +grDirectoryStatus = \case + GRSActive -> DSListed + GRSSuspended -> DSReserved + GRSSuspendedBadRoles -> DSReserved + GRSRemoved -> DSRemoved + _ -> DSRegistered + +$(JQ.deriveJSON (enumJSON $ dropPrefix "PC") ''ProfileCondition) + +$(JQ.deriveJSON defaultJSON ''DirectoryMemberAcceptance) + +$(JQ.deriveJSON defaultJSON ''DirectoryGroupData) + +fromCustomData :: Maybe CustomData -> DirectoryGroupData +fromCustomData cd_ = + let memberAcceptance = fromMaybe noJoinFilter $ cd_ >>= \(CustomData o) -> JT.parseMaybe (.: "memberAcceptance") o + in DirectoryGroupData {memberAcceptance} + +toCustomData :: DirectoryGroupData -> CustomData +toCustomData DirectoryGroupData {memberAcceptance} = + CustomData $ JM.fromList ["memberAcceptance" .= memberAcceptance] + +addGroupRegStore :: ChatController -> Contact -> GroupInfo -> GroupRegStatus -> IO (Either String GroupReg) +addGroupRegStore cc Contact {contactId = dbContactId} GroupInfo {groupId = dbGroupId} groupRegStatus = + withDB' "addGroupRegStore" cc $ \db -> do + createdAt <- getCurrentTime + maxUgrId <- + maybeFirstRow' 0 (fromMaybe 0 . fromOnly) $ + DB.query db "SELECT MAX(user_group_reg_id) FROM sx_directory_group_regs WHERE contact_id = ?" (Only dbContactId) + let gr = GroupReg {dbGroupId, userGroupRegId = maxUgrId + 1, dbContactId, dbOwnerMemberId = Nothing, groupRegStatus, promoted = False, createdAt} + insertGroupReg db gr + pure gr + +insertGroupReg :: DB.Connection -> GroupReg -> IO () +insertGroupReg db GroupReg {dbGroupId, userGroupRegId, dbContactId, dbOwnerMemberId, groupRegStatus, promoted, createdAt} = do + DB.execute + db + [sql| + INSERT INTO sx_directory_group_regs + (group_id, user_group_reg_id, contact_id, owner_member_id, group_reg_status, group_promoted, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?) + |] + (dbGroupId, userGroupRegId, dbContactId, dbOwnerMemberId, groupRegStatus, BI promoted, createdAt, createdAt) + +delGroupReg :: ChatController -> GroupId -> IO (Either String ()) +delGroupReg cc gId = withDB' "delGroupReg" cc (`deleteGroupReg` gId) + +deleteGroupReg :: DB.Connection -> GroupId -> IO () +deleteGroupReg db gId = DB.execute db "DELETE FROM sx_directory_group_regs WHERE group_id = ?" (Only gId) + +setGroupStatusStore :: ChatController -> GroupId -> GroupRegStatus -> IO (Either String (GroupRegStatus, GroupReg)) +setGroupStatusStore cc gId grStatus' = + withDB "setGroupStatusStore" cc $ \db -> do + gr <- getGroupReg_ db gId + ts <- liftIO getCurrentTime + liftIO $ DB.execute db "UPDATE sx_directory_group_regs SET group_reg_status = ?, updated_at = ? WHERE group_id = ?" (grStatus', ts, gId) + pure (groupRegStatus gr, gr {groupRegStatus = grStatus'}) + +setGroupStatusPromoStore :: ChatController -> GroupId -> GroupRegStatus -> Bool -> IO (Either String (DirectoryStatus, Bool)) +setGroupStatusPromoStore cc gId grStatus' grPromoted' = + withDB "setGroupStatusPromoStore" cc $ \db -> do + GroupReg {groupRegStatus, promoted} <- getGroupReg_ db gId + ts <- liftIO getCurrentTime + liftIO $ DB.execute db "UPDATE sx_directory_group_regs SET group_reg_status = ?, group_promoted = ?, updated_at = ? WHERE group_id = ?" (grStatus', BI grPromoted', ts, gId) + pure (grDirectoryStatus groupRegStatus, promoted) + +setGroupPromotedStore :: ChatController -> GroupId -> Bool -> IO (Either String (DirectoryStatus, Bool)) +setGroupPromotedStore cc gId grPromoted' = + withDB "setGroupPromotedStore" cc $ \db -> do + GroupReg {groupRegStatus, promoted} <- getGroupReg_ db gId + ts <- liftIO getCurrentTime + liftIO $ DB.execute db "UPDATE sx_directory_group_regs SET group_promoted = ?, updated_at = ? WHERE group_id = ?" (BI grPromoted', ts, gId) + pure (grDirectoryStatus groupRegStatus, promoted) + +groupDBError :: StoreError -> String +groupDBError = \case + SEGroupNotFound _ -> "group not found" + e -> show e + +setGroupRegOwner :: ChatController -> GroupId -> GroupMember -> IO (Either String ()) +setGroupRegOwner cc gId owner = do + ts <- getCurrentTime + withDB' "setGroupRegOwner" cc $ \db -> + DB.execute + db + [sql| + UPDATE sx_directory_group_regs + SET owner_member_id = ?, updated_at = ? + WHERE group_id = ? + |] + (groupMemberId' owner, ts, gId) + +getGroupReg :: ChatController -> GroupId -> IO (Either String GroupReg) +getGroupReg cc gId = withDB "getGroupReg" cc (`getGroupReg_` gId) + +getGroupReg_ :: DB.Connection -> GroupId -> ExceptT String IO GroupReg +getGroupReg_ db gId = + ExceptT $ firstRow rowToGroupReg "group registration not found" $ + DB.query + db + [sql| + SELECT group_id, user_group_reg_id, contact_id, owner_member_id, group_reg_status, group_promoted, created_at + FROM sx_directory_group_regs + WHERE group_id = ? + |] + (Only gId) + +getGroupAndReg :: ChatController -> User -> GroupId -> IO (Either String (GroupInfo, GroupReg)) +getGroupAndReg cc user@User {userId, userContactId} gId = + withDB "getGroupAndReg" cc $ \db -> + ExceptT $ firstRow (toGroupInfoReg (vr cc) user) ("group " ++ show gId ++ " not found") $ + DB.query db (groupReqQuery <> " AND g.group_id = ?") (userId, userContactId, gId) + +getUserGroupReg :: ChatController -> User -> ContactId -> UserGroupRegId -> IO (Either String (GroupInfo, GroupReg)) +getUserGroupReg cc user@User {userId, userContactId} ctId ugrId = + withDB "getUserGroupReg" cc $ \db -> + ExceptT $ firstRow (toGroupInfoReg (vr cc) user) ("group " ++ show ugrId ++ " not found") $ + DB.query db (groupReqQuery <> " AND r.contact_id = ? AND r.user_group_reg_id = ?") (userId, userContactId, ctId, ugrId) + +getUserGroupRegs :: ChatController -> User -> ContactId -> IO (Either String [(GroupInfo, GroupReg)]) +getUserGroupRegs cc user@User {userId, userContactId} ctId = + withDB' "getUserGroupRegs" cc $ \db -> + map (toGroupInfoReg (vr cc) user) + <$> DB.query db (groupReqQuery <> " AND r.contact_id = ? ORDER BY r.user_group_reg_id") (userId, userContactId, ctId) + +getAllListedGroups :: ChatController -> User -> IO (Either String [(GroupInfo, GroupReg, Maybe GroupLink)]) +getAllListedGroups cc user = withDB' "getAllListedGroups" cc $ \db -> getAllListedGroups_ db (vr cc) user + +getAllListedGroups_ :: DB.Connection -> VersionRangeChat -> User -> IO [(GroupInfo, GroupReg, Maybe GroupLink)] +getAllListedGroups_ db vr' user@User {userId, userContactId} = + DB.query db (groupReqQuery <> " AND r.group_reg_status = ?") (userId, userContactId, GRSActive) + >>= mapM (withGroupLink . toGroupInfoReg vr' user) + where + withGroupLink (g, gr) = (g,gr,) . eitherToMaybe <$> runExceptT (getGroupLink db user g) + +searchListedGroups :: ChatController -> User -> SearchType -> Maybe GroupId -> Int -> IO (Either String ([(GroupInfo, GroupReg)], Int)) +searchListedGroups cc user@User {userId, userContactId} searchType lastGroup_ pageSize = + withDB' "searchListedGroups" cc $ \db -> + case searchType of + STAll -> case lastGroup_ of + Nothing -> do + gs <- groups $ DB.query db (listedGroupQuery <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, pageSize) + n <- count $ DB.query db countQuery' (Only GRSActive) + pure (gs, n) + Just gId -> do + gs <- groups $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, pageSize) + n <- count $ DB.query db (countQuery' <> " AND r.group_id > ?") (GRSActive, gId) + pure (gs, n) + where + countQuery' = countQuery <> " WHERE r.group_reg_status = ? " + orderBy = " ORDER BY g.summary_current_members_count DESC, r.group_reg_id ASC " + STRecent -> case lastGroup_ of + Nothing -> do + gs <- groups $ DB.query db (listedGroupQuery <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, pageSize) + n <- count $ DB.query db countQuery' (Only GRSActive) + pure (gs, n) + Just gId -> do + gs <- groups $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, pageSize) + n <- count $ DB.query db (countQuery' <> " AND r.group_id > ?") (GRSActive, gId) + pure (gs, n) + where + countQuery' = countQuery <> " WHERE r.group_reg_status = ? " + orderBy = " ORDER BY r.created_at DESC, r.group_reg_id ASC " + STSearch search -> case lastGroup_ of + Nothing -> do + gs <- groups $ DB.query db (listedGroupQuery <> searchCond <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, s, s, s, s, pageSize) + n <- count $ DB.query db (countQuery' <> searchCond) (GRSActive, s, s, s, s) + pure (gs, n) + Just gId -> do + gs <- groups $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> searchCond <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, s, s, s, s, pageSize) + n <- count $ DB.query db (countQuery' <> " AND r.group_id > ? " <> searchCond) (GRSActive, gId, s, s, s, s) + pure (gs, n) + where + s = T.toLower search + countQuery' = countQuery <> " JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id WHERE r.group_reg_status = ? " + orderBy = " ORDER BY g.summary_current_members_count DESC, r.group_reg_id ASC " + where + groups = (map (toGroupInfoReg (vr cc) user) <$>) + count = maybeFirstRow' 0 fromOnly + listedGroupQuery = groupReqQuery <> " AND r.group_reg_status = ? " + countQuery = "SELECT COUNT(1) FROM groups g JOIN sx_directory_group_regs r ON g.group_id = r.group_id " + searchCond = + [sql| + AND (LOWER(gp.display_name) LIKE '%' || ? || '%' + OR LOWER(gp.full_name) LIKE '%' || ? || '%' + OR LOWER(gp.short_descr) LIKE '%' || ? || '%' + OR LOWER(gp.description) LIKE '%' || ? || '%' + ) + |] + +getAllGroupRegs_ :: DB.Connection -> User -> IO [(GroupInfo, GroupReg)] +getAllGroupRegs_ db user@User {userId, userContactId} = + map (toGroupInfoReg supportedChatVRange user) + <$> DB.query db groupReqQuery (userId, userContactId) + +getDuplicateGroupRegs :: ChatController -> User -> Text -> IO (Either String [(GroupInfo, GroupReg)]) +getDuplicateGroupRegs cc user@User {userId, userContactId} displayName = + withDB' "getDuplicateGroupRegs" cc $ \db -> + map (toGroupInfoReg (vr cc) user) + <$> DB.query db (groupReqQuery <> " AND gp.display_name = ?") (userId, userContactId, displayName) + +listLastGroups :: ChatController -> User -> Int -> IO (Either String ([(GroupInfo, GroupReg)], Int)) +listLastGroups cc user@User {userId, userContactId} count = + withDB' "getUserGroupRegs" cc $ \db -> do + gs <- + map (toGroupInfoReg (vr cc) user) + <$> DB.query db (groupReqQuery <> " ORDER BY group_reg_id DESC LIMIT ?") (userId, userContactId, count) + n <- maybeFirstRow' 0 fromOnly $ DB.query_ db "SELECT COUNT(1) FROM sx_directory_group_regs" + pure (gs, n) + +listPendingGroups :: ChatController -> User -> Int -> IO (Either String ([(GroupInfo, GroupReg)], Int)) +listPendingGroups cc user@User {userId, userContactId} count = + withDB' "getUserGroupRegs" cc $ \db -> do + gs <- + map (toGroupInfoReg (vr cc) user) + <$> DB.query db (groupReqQuery <> " AND r.group_reg_status LIKE 'pending_approval%' ORDER BY group_reg_id DESC LIMIT ?") (userId, userContactId, count) + n <- maybeFirstRow' 0 fromOnly $ DB.query_ db "SELECT COUNT(1) FROM sx_directory_group_regs WHERE group_reg_status LIKE 'pending_approval%'" + pure (gs, n) + +toGroupInfoReg :: VersionRangeChat -> User -> (GroupInfoRow :. GroupRegRow) -> (GroupInfo, GroupReg) +toGroupInfoReg vr' User {userContactId} (groupRow :. grRow) = + (toGroupInfo vr' userContactId [] groupRow, rowToGroupReg grRow) + +type GroupRegRow = (GroupId, UserGroupRegId, ContactId, Maybe GroupMemberId, GroupRegStatus, BoolInt, UTCTime) + +rowToGroupReg :: GroupRegRow -> GroupReg +rowToGroupReg (dbGroupId, userGroupRegId, dbContactId, dbOwnerMemberId, groupRegStatus, BI promoted, createdAt) = + GroupReg {dbGroupId, userGroupRegId, dbContactId, dbOwnerMemberId, groupRegStatus, promoted, createdAt} + +groupReqQuery :: Query +groupReqQuery = groupInfoQueryFields <> groupRegFields <> groupInfoQueryFrom <> groupRegFromCond + where + groupRegFields = ", r.group_id, r.user_group_reg_id, r.contact_id, r.owner_member_id, r.group_reg_status, r.group_promoted, r.created_at " + groupRegFromCond = " JOIN sx_directory_group_regs r ON r.group_id = g.group_id WHERE g.user_id = ? AND mu.contact_id = ? " + +data DirectoryLogRecord + = GRCreate GroupReg + | GRDelete GroupId + | GRUpdateStatus GroupId GroupRegStatus + | GRUpdatePromotion GroupId Bool + | GRUpdateOwner GroupId GroupMemberId + +data DLRTag + = GRCreate_ + | GRDelete_ + | GRUpdateStatus_ + | GRUpdatePromotion_ + | GRUpdateOwner_ + +logDLR :: DirectoryLog -> DirectoryLogRecord -> IO () +logDLR st r = forM_ (directoryLogFile st) $ \h -> B.hPutStrLn h (strEncode r) + +logGCreate :: DirectoryLog -> GroupReg -> IO () +logGCreate st = logDLR st . GRCreate + +logGDelete :: DirectoryLog -> GroupId -> IO () +logGDelete st = logDLR st . GRDelete + +logGUpdateStatus :: DirectoryLog -> GroupId -> GroupRegStatus -> IO () +logGUpdateStatus st gId = logDLR st . GRUpdateStatus gId + +logGUpdatePromotion :: DirectoryLog -> GroupId -> Bool -> IO () +logGUpdatePromotion st gId = logDLR st . GRUpdatePromotion gId + +logGUpdateOwner :: DirectoryLog -> GroupId -> GroupMemberId -> IO () +logGUpdateOwner st gId = logDLR st . GRUpdateOwner gId + +instance StrEncoding DLRTag where + strEncode = \case + GRCreate_ -> "GCREATE" + GRDelete_ -> "GDELETE" + GRUpdateStatus_ -> "GSTATUS" + GRUpdatePromotion_ -> "GPROMOTE" + GRUpdateOwner_ -> "GOWNER" + strP = + A.takeTill (== ' ') >>= \case + "GCREATE" -> pure GRCreate_ + "GDELETE" -> pure GRDelete_ + "GSTATUS" -> pure GRUpdateStatus_ + "GPROMOTE" -> pure GRUpdatePromotion_ + "GOWNER" -> pure GRUpdateOwner_ + _ -> fail "invalid DLRTag" + +instance StrEncoding DirectoryLogRecord where + strEncode = \case + GRCreate gr -> strEncode (GRCreate_, gr) + GRDelete gId -> strEncode (GRDelete_, gId) + GRUpdateStatus gId grStatus -> strEncode (GRUpdateStatus_, gId, grStatus) + GRUpdatePromotion gId promoted -> strEncode (GRUpdatePromotion_, gId, promoted) + GRUpdateOwner gId grOwnerId -> strEncode (GRUpdateOwner_, gId, grOwnerId) + strP = + strP_ >>= \case + GRCreate_ -> GRCreate <$> strP + GRDelete_ -> GRDelete <$> strP + GRUpdateStatus_ -> GRUpdateStatus <$> A.decimal <*> _strP + GRUpdatePromotion_ -> GRUpdatePromotion <$> A.decimal <*> _strP + GRUpdateOwner_ -> GRUpdateOwner <$> A.decimal <* A.space <*> A.decimal + +instance StrEncoding GroupReg where + strEncode GroupReg {dbGroupId, userGroupRegId, dbContactId, dbOwnerMemberId, groupRegStatus, promoted} = + B.unwords $ + [ "group_id=" <> strEncode dbGroupId, + "user_group_id=" <> strEncode userGroupRegId, + "contact_id=" <> strEncode dbContactId, + "owner_member_id=" <> strEncode dbOwnerMemberId, + "status=" <> strEncode groupRegStatus + ] + <> ["promoted=" <> strEncode promoted | promoted] + strP = do + dbGroupId <- "group_id=" *> strP_ + userGroupRegId <- "user_group_id=" *> strP_ + dbContactId <- "contact_id=" *> strP_ + dbOwnerMemberId <- "owner_member_id=" *> strP_ + groupRegStatus <- "status=" *> strP + promoted <- (" promoted=" *> strP) <|> pure False + let createdAt = UTCTime systemEpochDay 0 + pure GroupReg {dbGroupId, userGroupRegId, dbContactId, dbOwnerMemberId, groupRegStatus, promoted, createdAt} + +instance StrEncoding GroupRegStatus where + strEncode = \case + GRSPendingConfirmation -> "pending_confirmation" + GRSProposed -> "proposed" + GRSPendingUpdate -> "pending_update" + GRSPendingApproval gaId -> "pending_approval:" <> strEncode gaId + GRSActive -> "active" + GRSSuspended -> "suspended" + GRSSuspendedBadRoles -> "suspended_bad_roles" + GRSRemoved -> "removed" + strP = + A.takeTill (\c -> c == ' ' || c == ':') >>= \case + "pending_confirmation" -> pure GRSPendingConfirmation + "proposed" -> pure GRSProposed + "pending_update" -> pure GRSPendingUpdate + "pending_approval" -> GRSPendingApproval <$> (A.char ':' *> A.decimal) + "active" -> pure GRSActive + "suspended" -> pure GRSSuspended + "suspended_bad_roles" -> pure GRSSuspendedBadRoles + "removed" -> pure GRSRemoved + _ -> fail "invalid GroupRegStatus" + +instance ToField GroupRegStatus where toField = toField . safeDecodeUtf8 . strEncode + +instance FromField GroupRegStatus where fromField = fromTextField_ $ eitherToMaybe . strDecode . encodeUtf8 + +openDirectoryLog :: Maybe FilePath -> IO DirectoryLog +openDirectoryLog = \case + Just f -> DirectoryLog . Just <$> openLogFile f + Nothing -> pure $ DirectoryLog Nothing + where + openLogFile f = do + h <- openFile f AppendMode + hSetBuffering h LineBuffering + pure h + +readDirectoryLogData :: FilePath -> IO [GroupReg] +readDirectoryLogData f = + sortOn dbGroupId . M.elems + <$> (foldM processDLR M.empty . B.lines =<< B.readFile f) + where + processDLR :: Map GroupId GroupReg -> ByteString -> IO (Map GroupId GroupReg) + processDLR m l = case strDecode l of + Left e -> m <$ putStrLn ("Error parsing log record: " <> e <> ", " <> B.unpack (B.take 80 l)) + Right r -> case r of + GRCreate gr@GroupReg {dbGroupId = gId} -> do + when (isJust $ M.lookup gId m) $ + putStrLn $ + "Warning: duplicate group with ID " <> show gId <> ", group replaced." + pure $ M.insert gId gr m + GRDelete gId -> case M.lookup gId m of + Just _ -> pure $ M.delete gId m + Nothing -> m <$ putStrLn ("Warning: no group with ID " <> show gId <> ", deletion ignored.") + GRUpdateStatus gId groupRegStatus -> case M.lookup gId m of + Just gr -> pure $ M.insert gId gr {groupRegStatus} m + Nothing -> m <$ putStrLn ("Warning: no group with ID " <> show gId <> ", status update ignored.") + GRUpdatePromotion gId promoted -> case M.lookup gId m of + Just gr -> pure $ M.insert gId gr {promoted} m + Nothing -> m <$ putStrLn ("Warning: no group with ID " <> show gId <> ", promotion update ignored.") + GRUpdateOwner gId grOwnerId -> case M.lookup gId m of + Just gr -> pure $ M.insert gId gr {dbOwnerMemberId = Just grOwnerId} m + Nothing -> m <$ putStrLn ("Warning: no group with ID " <> show gId <> ", owner update ignored.") diff --git a/bots/haskell/simplexxx-directory/src/Directory/Store/Migrate.hs b/bots/haskell/simplexxx-directory/src/Directory/Store/Migrate.hs new file mode 100644 index 0000000..aa101d7 --- /dev/null +++ b/bots/haskell/simplexxx-directory/src/Directory/Store/Migrate.hs @@ -0,0 +1,149 @@ +{-# LANGUAGE CPP #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Directory.Store.Migrate where + +import Control.Concurrent.STM +import Control.Monad +import Control.Monad.Except +import qualified Data.ByteString.Char8 as B +import Data.List (find) +import Data.Maybe (fromMaybe) +import qualified Data.Text as T +import Directory.Listing +import Directory.Options +import Directory.Store +import Simplex.Chat (createChatDatabase) +import Simplex.Chat.Controller (ChatConfig (..), ChatDatabase (..)) +import Simplex.Chat.Options (CoreChatOpts (..)) +import Simplex.Chat.Options.DB +import Simplex.Chat.Protocol (supportedChatVRange) +import Simplex.Chat.Store.Groups (getHostMember) +import Simplex.Chat.Store.Profiles (getUsers) +import Simplex.Chat.Store.Shared (getGroupInfo) +import Simplex.Chat.Types +import Simplex.Messaging.Agent.Store.Common +import qualified Simplex.Messaging.Agent.Store.DB as DB +import Simplex.Messaging.Agent.Store.Interface (closeDBStore, migrateDBSchema) +import Simplex.Messaging.Agent.Store.Shared (MigrationConfig (..), MigrationConfirmation (..)) +import Simplex.Messaging.Encoding.String +import qualified Simplex.Messaging.TMap as TM +import Simplex.Messaging.Util (whenM) +import System.Directory (doesFileExist, renamePath) +import System.Exit (exitFailure) +import System.IO (IOMode (..), withFile) + +#if defined(dbPostgres) +import Directory.Store.Postgres.Migrations +#else +import Directory.Store.SQLite.Migrations +#endif + +runDirectoryMigrations :: DirectoryOpts -> ChatConfig -> DBStore -> IO () +runDirectoryMigrations opts ChatConfig {confirmMigrations} chatStore = + migrateDBSchema + chatStore + (toDBOpts dbOptions chatSuffix False []) + (Just "sx_directory_migrations") + directorySchemaMigrations + MigrationConfig {confirm, backupPath = Nothing} + >>= either (exit . ("directory migrations " <>) . show) pure + where + DirectoryOpts {coreOptions = CoreChatOpts {dbOptions, yesToUpMigrations}} = opts + confirm = if confirmMigrations == MCConsole && yesToUpMigrations then MCYesUp else confirmMigrations + +checkDirectoryLog :: DirectoryOpts -> ChatConfig -> IO () +checkDirectoryLog opts cfg = + withDirectoryLog opts $ \logFile -> withChatStore opts $ \st -> do + runDirectoryMigrations opts cfg st + gs <- readDirectoryLogData logFile + withActiveUser st $ \user -> withTransaction st $ \db -> do + mapM_ (verifyGroupRegistration db user) gs + putStrLn $ show (length gs) <> " group registrations OK" + +importDirectoryLogToDB :: DirectoryOpts -> ChatConfig -> IO () +importDirectoryLogToDB opts cfg = do + withDirectoryLog opts $ \logFile -> withChatStore opts $ \st -> do + runDirectoryMigrations opts cfg st + gs <- readDirectoryLogData logFile + ctRegs <- TM.emptyIO + withActiveUser st $ \user -> withTransaction st $ \db -> do + forM_ gs $ \gr -> + whenM (verifyGroupRegistration db user gr) $ do + putStrLn $ "importing group " <> show (dbGroupId gr) + insertGroupReg db =<< fixUserGroupRegId ctRegs gr + renamePath logFile (logFile ++ ".bak") + putStrLn $ show (length gs) <> " group registrations imported" + where + fixUserGroupRegId ctRegs gr@GroupReg {dbGroupId, dbContactId} = do + ugIds <- fromMaybe [] <$> TM.lookupIO dbContactId ctRegs + gr' <- + if userGroupRegId gr `elem` ugIds + then do + let ugId = maximum ugIds + 1 + putStrLn $ "Warning: updating userGroupRegId for group " <> show dbGroupId <> ", contact " <> show dbContactId + pure gr {userGroupRegId = ugId} + else pure gr + atomically $ TM.insert dbContactId (userGroupRegId gr' : ugIds) ctRegs + pure gr' + +exit :: String -> IO a +exit err = putStrLn ("Error: " <> err) >> exitFailure + +exportDBToDirectoryLog :: DirectoryOpts -> ChatConfig -> IO () +exportDBToDirectoryLog opts cfg = + withDirectoryLog opts $ \logFile -> withChatStore opts $ \st -> do + whenM (doesFileExist logFile) $ exit $ "directory log file " ++ logFile ++ " already exists" + runDirectoryMigrations opts cfg st + withActiveUser st $ \user -> do + gs <- withFile logFile WriteMode $ \h -> withTransaction st $ \db -> do + gs <- getAllGroupRegs_ db user + forM_ gs $ \(_, gr) -> + whenM (verifyGroupRegistration db user gr) $ + B.hPutStrLn h $ strEncode $ GRCreate gr + pure gs + putStrLn $ show (length gs) <> " group registrations exported" + +saveGroupListingFiles :: DirectoryOpts -> ChatConfig -> IO () +saveGroupListingFiles opts _cfg = case webFolder opts of + Nothing -> exit "use --web-folder to generate listings" + Just dir -> + withChatStore opts $ \st -> withActiveUser st $ \user -> + withTransaction st $ \db -> + getAllListedGroups_ db supportedChatVRange user >>= generateListing dir + +verifyGroupRegistration :: DB.Connection -> User -> GroupReg -> IO Bool +verifyGroupRegistration db user GroupReg {dbGroupId = gId, dbContactId = ctId, dbOwnerMemberId, groupRegStatus} = + runExceptT (getGroupInfo db supportedChatVRange user gId) >>= \case + Left e -> False <$ putStrLn ("Error: loading group " <> show gId <> " (skipping): " <> show e) + Right GroupInfo {localDisplayName} -> do + let groupRef = show gId <> " " <> T.unpack localDisplayName + runExceptT (getHostMember db supportedChatVRange user gId) >>= \case + Left e -> False <$ putStrLn ("Error: loading host member of group " <> groupRef <> " (skipping): " <> show e) + Right GroupMember {groupMemberId = mId', memberContactId = ctId'} -> case dbOwnerMemberId of + Nothing -> True <$ putStrLn ("Warning: group " <> groupRef <> " has no owner member ID, host member ID is " <> show mId' <> ", registration status: " <> B.unpack (strEncode groupRegStatus)) + Just mId + | mId /= mId' -> False <$ putStrLn ("Error: different host member ID of " <> groupRef <> " (skipping): " <> show mId') + | otherwise -> True <$ unless (Just ctId == ctId') (putStrLn $ "Warning: bad group " <> groupRef <> " contact ID: " <> show ctId') + +withDirectoryLog :: DirectoryOpts -> (FilePath -> IO ()) -> IO () +withDirectoryLog DirectoryOpts {directoryLog} action = + maybe (exit "directory log file not specified") action directoryLog + +withChatStore :: DirectoryOpts -> (DBStore -> IO ()) -> IO () +withChatStore DirectoryOpts {coreOptions = CoreChatOpts {dbOptions, yesToUpMigrations, migrationBackupPath}} action = + createChatDatabase dbOptions migrationConfig >>= \case + Left e -> exit $ show e + Right ChatDatabase {chatStore, agentStore} -> do + action chatStore + closeDBStore chatStore + closeDBStore agentStore + where + migrationConfig = MigrationConfig (if yesToUpMigrations then MCYesUp else MCConsole) migrationBackupPath + +withActiveUser :: DBStore -> (User -> IO ()) -> IO () +withActiveUser st action = withTransaction st getUsers >>= maybe (exit "no active user") action . find activeUser diff --git a/bots/haskell/simplexxx-directory/src/Directory/Store/Postgres/Migrations.hs b/bots/haskell/simplexxx-directory/src/Directory/Store/Postgres/Migrations.hs new file mode 100644 index 0000000..4a801fe --- /dev/null +++ b/bots/haskell/simplexxx-directory/src/Directory/Store/Postgres/Migrations.hs @@ -0,0 +1,52 @@ +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE QuasiQuotes #-} + +module Directory.Store.Postgres.Migrations where + +import Data.List (sortOn) +import Data.Text (Text) +import qualified Data.Text as T +import Simplex.Messaging.Agent.Store.Shared (Migration (..)) +import Text.RawString.QQ (r) + +directorySchemaMigrations :: [Migration] +directorySchemaMigrations = sortOn name $ map migration schemaMigrations + where + migration (name, up, down) = Migration {name, up, down} + +schemaMigrations :: [(String, Text, Maybe Text)] +schemaMigrations = + [ ("20250924_directory_schema", m20250924_directory_schema, Just down_m20250924_directory_schema) + ] + +m20250924_directory_schema :: Text +m20250924_directory_schema = + T.pack + [r| +CREATE TABLE sx_directory_group_regs( + group_reg_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + group_id BIGINT NOT NULL REFERENCES groups ON UPDATE RESTRICT ON DELETE CASCADE, + user_group_reg_id BIGINT NOT NULL, + contact_id BIGINT NOT NULL REFERENCES contacts(contact_id) ON UPDATE RESTRICT ON DELETE CASCADE, + owner_member_id BIGINT REFERENCES group_members(group_member_id) ON UPDATE RESTRICT ON DELETE CASCADE, + group_reg_status TEXT NOT NULL, + group_promoted SMALLINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (now()) +); + +CREATE UNIQUE INDEX idx_sx_directory_group_regs_group_id ON sx_directory_group_regs(group_id); +CREATE UNIQUE INDEX idx_sx_directory_group_regs_owner_member_id ON sx_directory_group_regs(owner_member_id); +CREATE UNIQUE INDEX idx_sx_directory_group_regs_owner_contact_id_user_group_reg_id ON sx_directory_group_regs(contact_id, user_group_reg_id); + |] + +down_m20250924_directory_schema :: Text +down_m20250924_directory_schema = + T.pack + [r| +DROP INDEX idx_sx_directory_group_regs_group_id; +DROP INDEX idx_sx_directory_group_regs_owner_member_id; +DROP INDEX idx_sx_directory_group_regs_owner_contact_id_user_group_reg_id; + +DROP TABLE sx_directory_group_regs; + |] diff --git a/bots/haskell/simplexxx-directory/src/Directory/Store/SQLite/Migrations.hs b/bots/haskell/simplexxx-directory/src/Directory/Store/SQLite/Migrations.hs new file mode 100644 index 0000000..f35f9e2 --- /dev/null +++ b/bots/haskell/simplexxx-directory/src/Directory/Store/SQLite/Migrations.hs @@ -0,0 +1,49 @@ +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE QuasiQuotes #-} + +module Directory.Store.SQLite.Migrations (directorySchemaMigrations) where + +import Data.List (sortOn) +import Database.SQLite.Simple (Query (..)) +import Database.SQLite.Simple.QQ (sql) +import Simplex.Messaging.Agent.Store.Shared (Migration (..)) + +directorySchemaMigrations :: [Migration] +directorySchemaMigrations = sortOn name $ map migration schemaMigrations + where + migration (name, up, down) = Migration {name, up = fromQuery up, down = fromQuery <$> down} + +schemaMigrations :: [(String, Query, Maybe Query)] +schemaMigrations = + [ ("20250924_directory_schema", m20250924_directory_schema, Just down_m20250924_directory_schema) + ] + +m20250924_directory_schema :: Query +m20250924_directory_schema = + [sql| +CREATE TABLE sx_directory_group_regs( + group_reg_id INTEGER PRIMARY KEY AUTOINCREMENT, + group_id INTEGER NOT NULL REFERENCES groups ON UPDATE RESTRICT ON DELETE CASCADE, + user_group_reg_id INTEGER NOT NULL, + contact_id INTEGER NOT NULL REFERENCES contacts(contact_id) ON UPDATE RESTRICT ON DELETE CASCADE, + owner_member_id INTEGER REFERENCES group_members(group_member_id) ON UPDATE RESTRICT ON DELETE CASCADE, + group_reg_status TEXT NOT NULL, + group_promoted INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')) +); + +CREATE UNIQUE INDEX idx_sx_directory_group_regs_group_id ON sx_directory_group_regs(group_id); +CREATE UNIQUE INDEX idx_sx_directory_group_regs_owner_member_id ON sx_directory_group_regs(owner_member_id); +CREATE UNIQUE INDEX idx_sx_directory_group_regs_owner_contact_id_user_group_reg_id ON sx_directory_group_regs(contact_id, user_group_reg_id); + |] + +down_m20250924_directory_schema :: Query +down_m20250924_directory_schema = + [sql| +DROP INDEX idx_sx_directory_group_regs_group_id; +DROP INDEX idx_sx_directory_group_regs_owner_member_id; +DROP INDEX idx_sx_directory_group_regs_owner_contact_id_user_group_reg_id; + +DROP TABLE sx_directory_group_regs; + |] diff --git a/bots/haskell/simplexxx-directory/src/Directory/Util.hs b/bots/haskell/simplexxx-directory/src/Directory/Util.hs new file mode 100644 index 0000000..a4b79a1 --- /dev/null +++ b/bots/haskell/simplexxx-directory/src/Directory/Util.hs @@ -0,0 +1,31 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} + +module Directory.Util where + +import Control.Logger.Simple +import Control.Monad.Except +import Data.Text (Text) +import qualified Data.Text as T +import Simplex.Chat.Controller +import Simplex.Chat.Types +import Simplex.Messaging.Agent.Store.Common (withTransaction) +import qualified Simplex.Messaging.Agent.Store.DB as DB +import Simplex.Messaging.Util (catchAll) + +vr :: ChatController -> VersionRangeChat +vr ChatController {config = ChatConfig {chatVRange}} = chatVRange +{-# INLINE vr #-} + +withDB' :: Text -> ChatController -> (DB.Connection -> IO a) -> IO (Either String a) +withDB' cxt cc a = withDB cxt cc $ ExceptT . fmap Right . a + +withDB :: Text -> ChatController -> (DB.Connection -> ExceptT String IO a) -> IO (Either String a) +withDB cxt ChatController {chatStore} action = do + r_ <- withTransaction chatStore (runExceptT . action) `catchAll` (pure . Left . show) + case r_ of + Left e -> logError $ "Database error: " <> cxt <> " " <> T.pack e + Right _ -> pure () + pure r_ diff --git a/bots/haskell/simplexxx-directory/start.sh b/bots/haskell/simplexxx-directory/start.sh new file mode 100755 index 0000000..617bfe8 --- /dev/null +++ b/bots/haskell/simplexxx-directory/start.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -e + +# export PATH="$HOME/.ghcup/bin:$HOME/.cabal/bin:$PATH" + +#cd "$(dirname "$0")/../.." + + +cabal run simplexxx-directory -- \ + --super-users "1:ADMIN" \ + --service-name "SimpleXXX" \ + --admin-users "4:xXx" \ + --web-folder "../simplex-chat-web-folder" diff --git a/bots/typescript/simplex-support-bot/README.md b/bots/typescript/simplex-support-bot/README.md new file mode 100644 index 0000000..19b9ab8 --- /dev/null +++ b/bots/typescript/simplex-support-bot/README.md @@ -0,0 +1,101 @@ +# SimpleX Support Bot + +A business-address bot that triages incoming support chats, optionally runs them through Grok, and routes handoffs to a team group. + +## Prerequisites + +- Node.js v18 or newer (v24 tested) +- `GROK_API_KEY` env var (xAI) — optional; the bot runs without it +- For the PostgreSQL backend: Linux x86_64, `libpq5` installed on the host, and a reachable PostgreSQL server + +## Install & build + +```bash +cd apps/simplex-support-bot +npm install # downloads native libs + transitive deps +npm run build # tsc +``` + +By default this installs the **SQLite** backend. + +To use **PostgreSQL** instead, drop a `.npmrc` next to `package.json` *before* `npm install`: + +```bash +echo 'simplex_backend=postgres' > .npmrc +npm install # now pulls postgres-flavored native libs +npm run build +``` + +`.npmrc` lives next to the package — npm reads it natively, no extra setup. + +### Switching backends + +`npm install` is a no-op for already-installed deps, so editing `.npmrc` and re-running `npm install` will *not* re-trigger `simplex-chat`'s preinstall. To switch backends, force a clean install: + +```bash +rm -rf node_modules +npm install # download-libs.js re-runs and pulls the right native lib +``` + +## Run + +```bash +mkdir -p data # state file lives here by default + +# SQLite (default) +npm start -- --team-group "Support Team" + +# PostgreSQL +npm start -- --team-group "Support Team" \ + --pg-conn "postgres://user:pass@host/db" +``` + +The bot runs via `npm start` so npm can expose `.npmrc` settings to the process — `detectBackend()` reads `npm_config_simplex_backend` to know which backend was installed. + +## Flags + +Run `npm start -- --help` for the auto-generated reference. Summary: + +| Flag | Backend | Required | Default | Description | +|---|---|---|---|---| +| `--team-group` | both | yes | — | team group display name | +| `--state-file` | both | no | `./data/state.json` | path to bot state JSON | +| `--sqlite-file-prefix` | sqlite | no | `./data/simplex` | DB file prefix (creates `_chat.db`, `_agent.db`) | +| `--sqlite-key` | sqlite | no | (unencrypted) | SQLCipher encryption key | +| `--pg-conn` | postgres | yes | — | PostgreSQL connection string | +| `--pg-schema` | postgres | no | `simplex_v1` | schema prefix used for bot tables | +| `-a` / `--auto-add-team-members` | both | no | | comma-separated `ID:name` pairs (e.g. `1:Alice,2:Bob`) | +| `--timezone` | both | no | `UTC` | IANA zone for weekend detection | +| `--complete-hours` | both | no | `3` | auto-complete chats after N hours idle (`0` disables) | +| `--card-flush-seconds` | both | no | `300` | debounce card state writes | +| `--context-file` | both | required with `GROK_API_KEY` | | text file with Grok system context | +| `-h` / `--help` | both | no | | show usage and exit | + +## Environment variables + +| Var | Purpose | +|---|---| +| `GROK_API_KEY` | xAI API key; enables Grok replies | +| `SIMPLEX_BACKEND` | alternative to `.npmrc` for selecting the install backend (`sqlite` or `postgres`) | + +## Local development against unreleased lib changes + +This package depends on `simplex-chat` from npm. To test against an in-tree version: + +```bash +# In packages/simplex-chat-nodejs +npm link + +# In apps/simplex-support-bot +npm link simplex-chat +``` + +`npm unlink simplex-chat && npm install` reverts to the registry version. + +## Troubleshooting + +- **`--pg-conn is required when backend is postgres`** — the postgres backend is installed but you didn't pass a connection string. +- **`libpq5` errors at startup** — install `libpq5` on the host (`apt install libpq5` on Debian/Ubuntu). +- **`ENOENT: no such file or directory, open './data/state.json'`** — the parent directory of `--state-file` must exist; `mkdir -p data` before starting. +- **Wrong backend installed** — check `node_modules/simplex-chat/libs/installed.txt`. Edit `.npmrc`, then `rm -rf node_modules && npm install` to switch (`npm install` alone won't re-run the dep's preinstall). +- **`libpq` connection error** at startup with sqlite-flavored config (or vice versa) — `.npmrc` was changed but libs weren't reinstalled. See "Switching backends" above. diff --git a/bots/typescript/simplex-support-bot/package.json b/bots/typescript/simplex-support-bot/package.json new file mode 100644 index 0000000..8541056 --- /dev/null +++ b/bots/typescript/simplex-support-bot/package.json @@ -0,0 +1,24 @@ +{ + "name": "simplex-chat-support-bot", + "version": "0.1.0", + "private": true, + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js" + }, + "dependencies": { + "@simplex-chat/types": "^0.6.0", + "async-mutex": "^0.5.0", + "commander": "^14.0.3", + "simplex-chat": "^6.5.1", + "yaml": "^2.8.4" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.9.3", + "vitest": "^1.6.1" + }, + "author": "SimpleX Chat", + "license": "AGPL-3.0" +} diff --git a/bots/typescript/simplex-support-bot/src/bot.ts b/bots/typescript/simplex-support-bot/src/bot.ts new file mode 100644 index 0000000..9b53438 --- /dev/null +++ b/bots/typescript/simplex-support-bot/src/bot.ts @@ -0,0 +1,948 @@ +import {api, util} from "simplex-chat" +import {T, CEvt} from "@simplex-chat/types" +import {Config} from "./config.js" +import {GrokMessage, GrokApiClient} from "./grok.js" +import {CardManager, ConversationState} from "./cards.js" +import { + queueMessage, grokInvitingMessage, grokActivatedMessage, teamAddedMessage, + teamAlreadyInvitedMessage, teamLockedMessage, noTeamMembersMessage, + grokUnavailableMessage, grokErrorMessage, grokNoHistoryMessage, +} from "./messages.js" +import {profileMutex, log, logError, getGroupInfo} from "./util.js" + +// Collects the keyword of every "command" entry in the bot's registered +// commands tree, descending into "menu" entries. Used to distinguish real +// commands from arbitrary text that happens to start with `/` (e.g. URLs, +// "/help" the user invented). +function commandKeywords(commands: T.ChatBotCommand[]): Set { + const out = new Set() + const visit = (cmds: T.ChatBotCommand[]): void => { + for (const c of cmds) { + if (c.type === "command") out.add(c.keyword) + else if (c.type === "menu") visit(c.commands) + } + } + visit(commands) + return out +} + +// True for any non-terminal status — invited but not yet accepted, through +// connected. Used to decide whether a contact is already in the group so we +// don't trigger a re-invite (the SimpleX API resends the invitation for a +// member in GSMemInvited). +function isInGroup(m: T.GroupMember): boolean { + switch (m.memberStatus) { + case T.GroupMemberStatus.Rejected: + case T.GroupMemberStatus.Removed: + case T.GroupMemberStatus.Left: + case T.GroupMemberStatus.Deleted: + case T.GroupMemberStatus.Unknown: + return false + default: + return true + } +} + +export class SupportBot { + // Card manager + cards: CardManager + + // Grok group mapping: memberId → mainGroupId (for pending joins) + private pendingGrokJoins = new Map() + // Buffered invitations that arrived before pendingGrokJoins was set (race condition) + private bufferedGrokInvitations = new Map() + // mainGroupId → grokLocalGroupId + private grokGroupMap = new Map() + // grokLocalGroupId → mainGroupId + private reverseGrokMap = new Map() + // mainGroupId → resolve fn for grok join + private grokJoinResolvers = new Map void>() + // mainGroupIds where Grok connectedToGroupMember fired + private grokFullyConnected = new Set() + // Suppress per-message Grok responses while activateGrok sends the initial combined response + private grokInitialResponsePending = new Set() + + // Pending DMs for team group members (contactId → message) + private pendingTeamDMs = new Map() + // Contacts that already received the team DM (dedup) + private sentTeamDMs = new Set() + + // Tracked fire-and-forget operations (for testing) + private _pendingOps: Promise[] = [] + + // Bot's business address link + businessAddress: string | null = null + + // Groups whose groupPreferences.commands we've already verified/synced + // in this process. Populated lazily by syncGroupCommands() on the first + // send to each group. + private syncedGroups = new Set() + + // Keywords from desiredCommands. A customer message is treated as a + // command only when its parsed keyword is in this set; anything else + // (URLs, "/help", arbitrary slashes) is routed as plain text. + private readonly customerKeywords: ReadonlySet + + constructor( + private chat: api.ChatApi, + private grokApi: GrokApiClient | null, + private config: Config, + private mainUserId: number, + private grokUserId: number | null, + private desiredCommands: T.ChatBotCommand[], + ) { + this.cards = new CardManager(chat, config, mainUserId, config.cardFlushSeconds * 1000) + this.customerKeywords = commandKeywords(desiredCommands) + } + + private customerCommand(chatItem: T.ChatItem): util.BotCommand | undefined { + const cmd = util.ciBotCommand(chatItem) + return cmd && this.customerKeywords.has(cmd.keyword) ? cmd : undefined + } + + private get grokEnabled(): boolean { + return this.grokApi !== null + } + + // Wait for all fire-and-forget operations to settle (for testing) + async flush(): Promise { + while (this._pendingOps.length > 0) { + const ops = this._pendingOps.splice(0) + await Promise.allSettled(ops) + } + } + + private fireAndForget(op: Promise): void { + const tracked = op.catch(err => logError("async operation error", err)) + this._pendingOps.push(tracked) + tracked.finally(() => { + const idx = this._pendingOps.indexOf(tracked) + if (idx >= 0) this._pendingOps.splice(idx, 1) + }) + } + + // --- Profile-switching helpers --- + + private async withMainProfile(fn: () => Promise): Promise { + return profileMutex.runExclusive(async () => { + await this.chat.apiSetActiveUser(this.mainUserId) + return fn() + }) + } + + // Ensure this group's groupPreferences.commands match desiredCommands, + // so commands in outgoing messages render as clickable for members of + // this group. Scoped to the group (apiUpdateGroupProfile broadcasts + // XGrpInfo/XGrpPrefs to group members only), and cached so we don't + // re-check on every send. Pre-checks local state via apiGetChat so we + // don't issue a no-op broadcast when the group already has the + // commands. + private async syncGroupCommands(groupId: number): Promise { + if (this.syncedGroups.has(groupId)) return + const desiredJSON = JSON.stringify(this.desiredCommands) + const chat = await this.chat.apiGetChat(T.ChatType.Group, groupId, 0) + const info = chat.chatInfo + if (info.type !== "group") return + const gp = info.groupInfo.groupProfile + const currentPrefs = gp.groupPreferences ?? {} + if (JSON.stringify(currentPrefs.commands ?? []) !== desiredJSON) { + await this.chat.apiUpdateGroupProfile(groupId, { + ...gp, + groupPreferences: {...currentPrefs, commands: this.desiredCommands}, + }) + log(`Pushed commands to group ${groupId}`) + } + this.syncedGroups.add(groupId) + } + + private async withGrokProfile(fn: () => Promise): Promise { + if (this.grokUserId === null) throw new Error("Grok is disabled (no GROK_API_KEY)") + const grokUserId = this.grokUserId + return profileMutex.runExclusive(async () => { + await this.chat.apiSetActiveUser(grokUserId) + return fn() + }) + } + + // --- Main profile event handlers --- + + async onBusinessRequest(evt: CEvt.AcceptingBusinessRequest): Promise { + const groupId = evt.groupInfo.groupId + try { + const profile = evt.groupInfo.groupProfile + await this.withMainProfile(() => + this.chat.apiUpdateGroupProfile(groupId, { + displayName: profile.displayName, + fullName: profile.fullName, + groupPreferences: { + ...profile.groupPreferences, + files: {enable: T.GroupFeatureEnabled.On}, + history: {enable: T.GroupFeatureEnabled.On}, + }, + }) + ) + // file uploads + history enabled + } catch (err) { + logError(`Failed to update business group ${groupId} preferences`, err) + } + } + + async onNewChatItems(evt: CEvt.NewChatItems): Promise { + // Only process events for main profile + if (evt.user.userId !== this.mainUserId) return + for (const ci of evt.chatItems) { + try { + await this.processMainChatItem(ci) + } catch (err) { + logError("Error processing chat item", err) + } + } + } + + async onChatItemUpdated(evt: CEvt.ChatItemUpdated): Promise { + if (evt.user.userId !== this.mainUserId) return + const {chatInfo} = evt.chatItem + if (chatInfo.type !== "group") return + const groupInfo = chatInfo.groupInfo + if (!groupInfo.businessChat) return + this.cards.scheduleUpdate(groupInfo.groupId) + } + + async onChatItemReaction(evt: CEvt.ChatItemReaction): Promise { + if (evt.user.userId !== this.mainUserId) return + if (!evt.added) return + const chatInfo = evt.reaction.chatInfo + if (chatInfo.type !== "group") return + const groupInfo = chatInfo.groupInfo + if (!groupInfo.businessChat) return + this.cards.scheduleUpdate(groupInfo.groupId) + } + + async onLeftMember(evt: CEvt.LeftMember): Promise { + if (evt.user.userId !== this.mainUserId) return + const groupId = evt.groupInfo.groupId + const member = evt.member + const bc = evt.groupInfo.businessChat + if (!bc) return + + if (member.memberId === bc.customerId) { + log(`Customer left group ${groupId}`) + this.cleanupGrokMaps(groupId) + try { await this.cards.clearCustomData(groupId) } catch {} + return + } + + if (this.config.grokContactId !== null && member.memberContactId === this.config.grokContactId) { + log(`Grok left group ${groupId}`) + this.cleanupGrokMaps(groupId) + return + } + + if (this.config.teamMembers.some(tm => tm.id === member.memberContactId)) { + log(`Team member left group ${groupId}`) + } + } + + async onJoinedGroupMember(evt: CEvt.JoinedGroupMember): Promise { + if (evt.user.userId !== this.mainUserId) return + if (evt.groupInfo.groupId === this.config.teamGroup.id) { + await this.sendTeamMemberDM(evt.member) + } + } + + async onMemberConnected(evt: CEvt.ConnectedToGroupMember): Promise { + if (evt.user.userId !== this.mainUserId) return + const groupId = evt.groupInfo.groupId + + // Team group → send DM (if not already sent by onJoinedGroupMember) + if (groupId === this.config.teamGroup.id) { + await this.sendTeamMemberDM(evt.member, evt.memberContact) + return + } + + // Customer group → promote to Owner (unless customer or Grok). Idempotent per plan §11. + const bc = evt.groupInfo.businessChat + if (bc) { + const isCustomer = evt.member.memberId === bc.customerId + const isGrok = this.config.grokContactId !== null + && evt.member.memberContactId === this.config.grokContactId + if (!isCustomer && !isGrok) { + try { + await this.withMainProfile(() => + this.chat.apiSetMembersRole(groupId, [evt.member.groupMemberId], T.GroupMemberRole.Owner) + ) + log(`Promoted member ${evt.member.groupMemberId} to Owner in group ${groupId}`) + } catch (err) { + logError(`Failed to promote member in group ${groupId}`, err) + } + } + } + } + + async onMemberContactReceivedInv(evt: CEvt.NewMemberContactReceivedInv): Promise { + if (evt.user.userId !== this.mainUserId) return + const {contact, groupInfo, member} = evt + if (groupInfo.groupId === this.config.teamGroup.id) { + if (this.sentTeamDMs.has(contact.contactId)) return + log(`DM contact from team group member: ${contact.contactId}:${member.memberProfile.displayName}`) + const name = member.memberProfile.displayName + const formatted = name.includes(" ") ? `'${name}'` : name + const msg = `Added you to be able to invite you to customer chats later, keep this contact. Your contact ID is ${contact.contactId}:${formatted}` + // Try sending immediately — contact may already be usable + try { + await this.withMainProfile(() => + this.chat.apiSendTextMessage([T.ChatType.Direct, contact.contactId], msg) + ) + this.sentTeamDMs.add(contact.contactId) + log(`Sent DM to team member ${contact.contactId}:${name}`) + } catch { + // Not ready yet — queue for contactConnected / contactSndReady + this.pendingTeamDMs.set(contact.contactId, msg) + log(`Queued DM for team member ${contact.contactId}:${name}`) + } + } + } + + async onContactConnected(evt: CEvt.ContactConnected): Promise { + if (evt.user.userId !== this.mainUserId) return + await this.deliverPendingDM(evt.contact.contactId) + } + + async onContactSndReady(evt: CEvt.ContactSndReady): Promise { + if (evt.user.userId !== this.mainUserId) return + await this.deliverPendingDM(evt.contact.contactId) + } + + private async deliverPendingDM(contactId: number): Promise { + if (this.sentTeamDMs.has(contactId)) { + this.pendingTeamDMs.delete(contactId) + return + } + const pendingMsg = this.pendingTeamDMs.get(contactId) + if (pendingMsg === undefined) return + this.pendingTeamDMs.delete(contactId) + try { + await this.withMainProfile(() => + this.chat.apiSendTextMessage([T.ChatType.Direct, contactId], pendingMsg) + ) + this.sentTeamDMs.add(contactId) + log(`Sent DM to team member ${contactId}`) + } catch (err) { + logError(`Failed to send DM to team member ${contactId}`, err) + } + } + + // --- Grok profile event handlers --- + + async onGrokGroupInvitation(evt: CEvt.ReceivedGroupInvitation): Promise { + if (evt.user.userId !== this.grokUserId) return + const memberId = evt.groupInfo.membership.memberId + const mainGroupId = this.pendingGrokJoins.get(memberId) + if (mainGroupId === undefined) { + // Buffer: invitation may arrive before pendingGrokJoins is set (race with apiAddMember) + this.bufferedGrokInvitations.set(memberId, evt) + return + } + this.pendingGrokJoins.delete(memberId) + this.bufferedGrokInvitations.delete(memberId) + await this.processGrokInvitation(evt, mainGroupId) + } + + private async processGrokInvitation(evt: CEvt.ReceivedGroupInvitation, mainGroupId: number): Promise { + log(`Grok joining group: mainGroupId=${mainGroupId}, grokGroupId=${evt.groupInfo.groupId}`) + try { + await this.withGrokProfile(() => this.chat.apiJoinGroup(evt.groupInfo.groupId)) + } catch (err) { + logError(`Grok failed to join group ${evt.groupInfo.groupId}`, err) + return + } + this.grokGroupMap.set(mainGroupId, evt.groupInfo.groupId) + this.reverseGrokMap.set(evt.groupInfo.groupId, mainGroupId) + } + + async onGrokMemberConnected(evt: CEvt.ConnectedToGroupMember): Promise { + if (evt.user.userId !== this.grokUserId) return + const grokGroupId = evt.groupInfo.groupId + const mainGroupId = this.reverseGrokMap.get(grokGroupId) + if (mainGroupId === undefined) return + this.grokFullyConnected.add(mainGroupId) + const resolver = this.grokJoinResolvers.get(mainGroupId) + if (resolver) { + this.grokJoinResolvers.delete(mainGroupId) + log(`Grok fully connected: mainGroupId=${mainGroupId}, grokGroupId=${grokGroupId}`) + resolver() + } + } + + async onGrokNewChatItems(evt: CEvt.NewChatItems): Promise { + if (evt.user.userId !== this.grokUserId) return + // When multiple customer messages arrive in one batch, only respond to the + // last per group — earlier messages are included in its history context. + const lastPerGroup = new Map() + for (const ci of evt.chatItems) { + const {chatInfo, chatItem} = ci + if (chatInfo.type !== "group") continue + if (chatItem.chatDir.type !== "groupRcv") continue + if (!util.ciContentText(chatItem)?.trim()) continue + if (this.customerCommand(chatItem)) continue + const bc = chatInfo.groupInfo.businessChat + if (!bc) continue + if (chatItem.chatDir.groupMember.memberId !== bc.customerId) continue + lastPerGroup.set(chatInfo.groupInfo.groupId, ci) + } + // Groups are independent — avoid serializing one group's xAI latency across the others. + await Promise.allSettled( + [...lastPerGroup.values()].map((ci) => this.processGrokChatItem(ci)), + ) + } + + // --- Main profile message routing --- + + private async processMainChatItem(ci: T.AChatItem): Promise { + const {chatInfo, chatItem} = ci + + // 1. Direct text message → reply with business address + if (chatInfo.type === "direct" && chatItem.chatDir.type === "directRcv" + && (chatItem.content as any).type === "rcvMsgContent") { + if (this.businessAddress) { + const contactId = chatInfo.contact.contactId + try { + await this.withMainProfile(() => + this.chat.apiSendTextMessage( + [T.ChatType.Direct, contactId], + `Please re-connect to this address for any questions: ${this.businessAddress}`, + ) + ) + } catch (err) { + logError(`Failed to reply to direct message from contact ${contactId}`, err) + } + } + return + } + + if (chatInfo.type !== "group") return + const groupInfo = chatInfo.groupInfo + const groupId = groupInfo.groupId + + // 2. Team group → handle /join + if (groupId === this.config.teamGroup.id) { + await this.processTeamGroupMessage(chatItem) + return + } + + // 3. Skip non-business groups + if (!groupInfo.businessChat) return + + // 4. Skip own messages + if (chatItem.chatDir.type === "groupSnd") return + if (chatItem.chatDir.type !== "groupRcv") return + + const sender = chatItem.chatDir.groupMember + const bc = groupInfo.businessChat + const isCustomer = sender.memberId === bc.customerId + + // 6. Non-customer message → one-way gate check + card update + if (!isCustomer) { + const isTeam = this.config.teamMembers.some(tm => tm.id === sender.memberContactId) + + if (isTeam && util.ciContentText(chatItem)?.trim()) { + // One-way gate: first team text → transition to TEAM + remove Grok + const data = await this.cards.getRawCustomData(groupId) + if (data?.state !== "TEAM") { + await this.cards.mergeCustomData(groupId, {state: "TEAM"}) + const {grokMember} = await this.cards.getGroupComposition(groupId) + if (grokMember) { + log(`One-way gate: team message in group ${groupId}, removing Grok`) + try { + await this.withMainProfile(() => + this.chat.apiRemoveMembers(groupId, [grokMember.groupMemberId]) + ) + } catch { + // may have already left + } + this.cleanupGrokMaps(groupId) + } + } + } + // Schedule card update for any non-customer message (team or Grok) + this.cards.scheduleUpdate(groupId) + return + } + + // 8. Customer message → derive state and dispatch + const state = await this.cards.deriveState(groupId) + const cmd = this.customerCommand(chatItem) + const text = util.ciContentText(chatItem)?.trim() || null + + switch (state) { + case "WELCOME": + if (cmd?.keyword === "grok") { + // WELCOME → GROK (skip queue msg). Write state optimistically so the + // card renders with GROK icon/label; activateGrok will revert via + // setStateOnFail if activation fails. + // Fire-and-forget: activateGrok awaits future events (waitForGrokJoin) + // which would deadlock the sequential event loop if awaited here. + await this.cards.mergeCustomData(groupId, {state: "GROK"}) + await this.cards.createCard(groupId, groupInfo) + this.fireAndForget(this.activateGrok(groupId, {sendQueueOnFail: true, setStateOnFail: "QUEUE"})) + return + } + if (cmd?.keyword === "team") { + // activateTeam writes state=TEAM-PENDING before the add loop + await this.activateTeam(groupId) + await this.cards.createCard(groupId, groupInfo) + return + } + // First regular message → QUEUE + if (text) { + await this.cards.mergeCustomData(groupId, {state: "QUEUE"}) + await this.sendToGroup(groupId, queueMessage(this.config.timezone, this.grokEnabled)) + await this.cards.createCard(groupId, groupInfo) + } + break + + case "QUEUE": + if (cmd?.keyword === "grok") { + // Write state optimistically; activateGrok reverts to QUEUE on failure + await this.cards.mergeCustomData(groupId, {state: "GROK"}) + this.fireAndForget(this.activateGrok(groupId, {setStateOnFail: "QUEUE"})) + } else if (cmd?.keyword === "team") { + await this.activateTeam(groupId) + } + this.cards.scheduleUpdate(groupId) + break + + case "GROK": + if (cmd?.keyword === "team") { + await this.activateTeam(groupId) + } else if (cmd?.keyword === "grok") { + // Already in grok mode — ignore + } else if (text) { + // Customer text → Grok responds (handled by Grok profile's onGrokNewChatItems) + // Just schedule card update for the customer message + } + this.cards.scheduleUpdate(groupId) + break + + case "TEAM-PENDING": + if (cmd?.keyword === "grok") { + // Invite Grok if not present; state stays TEAM-PENDING + const {grokMember} = await this.cards.getGroupComposition(groupId) + if (!grokMember) { + this.fireAndForget(this.activateGrok(groupId)) + } + // else: already present, ignore + } else if (cmd?.keyword === "team") { + // activateTeam handles "already invited" reply (team still present) + // or silent re-add (team has all left) + await this.activateTeam(groupId) + } + this.cards.scheduleUpdate(groupId) + break + + case "TEAM": + if (cmd?.keyword === "grok") { + await this.sendToGroup(groupId, teamLockedMessage) + } else if (cmd?.keyword === "team") { + // Team still present → "already invited"; team all left → silent re-add + await this.activateTeam(groupId) + } + this.cards.scheduleUpdate(groupId) + break + } + } + + // --- Grok profile message processing --- + + private async processGrokChatItem(ci: T.AChatItem): Promise { + if (!this.grokApi) return + const grokApi = this.grokApi + const {chatInfo, chatItem} = ci + if (chatInfo.type !== "group") return + const groupInfo = chatInfo.groupInfo + const grokGroupId = groupInfo.groupId + + // Skip while activateGrok is sending the initial combined response + const mainGroupId = this.reverseGrokMap.get(grokGroupId) + if (mainGroupId !== undefined && this.grokInitialResponsePending.has(mainGroupId)) return + + // Only process received text messages from customer + if (chatItem.chatDir.type !== "groupRcv") return + const text = util.ciContentText(chatItem)?.trim() + if (!text) return // ignore non-text + + // Ignore bot commands + if (this.customerCommand(chatItem)) return + + // Only respond in business groups (survives restart without in-memory maps) + const bc = groupInfo.businessChat + if (!bc) return + + // Only respond to customer messages, not bot or team messages + if (chatItem.chatDir.groupMember.memberId !== bc.customerId) return + + // Read history from Grok's own view + try { + const chat = await this.withGrokProfile(() => + this.chat.apiGetChat(T.ChatType.Group, grokGroupId, 100) + ) + const history: GrokMessage[] = [] + for (const histCi of chat.chatItems) { + const histText = util.ciContentText(histCi)?.trim() + if (!histText) continue + if (histCi.chatDir.type === "groupSnd") { + history.push({role: "assistant", content: histText}) + } else if (histCi.chatDir.type === "groupRcv" + && histCi.chatDir.groupMember.memberId === bc.customerId + && !this.customerCommand(histCi)) { + history.push({role: "user", content: histText}) + } + } + + // Don't include the current message in history — it's the userMessage + if (history.length > 0 && history[history.length - 1].role === "user" + && history[history.length - 1].content === text) { + history.pop() + } + + // Call Grok API (outside mutex) + const response = await grokApi.chat(history, text) + + // Send response via Grok profile + await this.withGrokProfile(() => + this.chat.apiSendTextMessage([T.ChatType.Group, grokGroupId], response) + ) + + // Grok asked for the team → escalate as if the customer sent /team + if (mainGroupId !== undefined && response.includes("/team")) await this.activateTeam(mainGroupId) + } catch (err) { + logError(`Grok per-message error for grokGroup ${grokGroupId}`, err) + try { + await this.withGrokProfile(() => + this.chat.apiSendTextMessage([T.ChatType.Group, grokGroupId], grokErrorMessage) + ) + } catch {} + } + + // Card update scheduled by main profile seeing the groupRcv events + } + + // --- Grok activation --- + + private async activateGrok( + groupId: number, + opts: {sendQueueOnFail?: boolean; setStateOnFail?: ConversationState} = {}, + ): Promise { + if (!this.grokApi) return + const grokApi = this.grokApi + const revertStateOnFail = async () => { + if (!opts.setStateOnFail) return + const current = await this.cards.getRawCustomData(groupId) + if (current?.state !== "GROK") return + await this.cards.mergeCustomData(groupId, {state: opts.setStateOnFail}) + } + if (this.config.grokContactId === null) { + await revertStateOnFail() + await this.sendToGroup(groupId, grokUnavailableMessage) + if (opts.sendQueueOnFail) await this.sendToGroup(groupId, queueMessage(this.config.timezone, this.grokEnabled)) + this.cards.scheduleUpdate(groupId) + return + } + + // Pre-check: silent return if Grok is already in the group in any + // non-terminal status. The apiAddMember/groupDuplicateMember catch below + // handles Connected/etc. but the SimpleX API resends the invitation for + // GSMemInvited (no error thrown), so without this check a /grok issued + // while a previous activation is still pending would re-trigger the invite. + const grokMembers = await this.withMainProfile(() => this.chat.apiListMembers(groupId)) + if (grokMembers.some(m => m.memberContactId === this.config.grokContactId && isInGroup(m))) { + return + } + + // Gate MUST be up before apiAddMember / pendingGrokJoins / reverseGrokMap — + // any later and onGrokNewChatItems can fire a duplicate per-message reply. + this.grokInitialResponsePending.add(groupId) + try { + await this.sendToGroup(groupId, grokInvitingMessage) + + let member: T.GroupMember + try { + member = await this.withMainProfile(() => + this.chat.apiAddMember(groupId, this.config.grokContactId!, T.GroupMemberRole.Member) + ) + } catch (err: unknown) { + const chatErr = err as {chatError?: {errorType?: {type?: string}}} + if (chatErr?.chatError?.errorType?.type === "groupDuplicateMember") { + // Grok already in group (e.g. customer sent /grok again before join completed) — + // the in-flight activation will handle the outcome, just return silently + return + } + logError(`Failed to invite Grok to group ${groupId}`, err) + await revertStateOnFail() + await this.sendToGroup(groupId, grokUnavailableMessage) + if (opts.sendQueueOnFail) await this.sendToGroup(groupId, queueMessage(this.config.timezone, this.grokEnabled)) + this.cards.scheduleUpdate(groupId) + return + } + + this.pendingGrokJoins.set(member.memberId, groupId) + + // Drain buffered invitation that arrived during the apiAddMember await + const buffered = this.bufferedGrokInvitations.get(member.memberId) + if (buffered) { + this.bufferedGrokInvitations.delete(member.memberId) + this.pendingGrokJoins.delete(member.memberId) + await this.processGrokInvitation(buffered, groupId) + } + + const joined = await this.waitForGrokJoin(groupId, 120_000) + if (!joined) { + this.pendingGrokJoins.delete(member.memberId) + try { + await this.withMainProfile(() => + this.chat.apiRemoveMembers(groupId, [member.groupMemberId]) + ) + } catch {} + this.cleanupGrokMaps(groupId) + await revertStateOnFail() + await this.sendToGroup(groupId, grokUnavailableMessage) + if (opts.sendQueueOnFail) await this.sendToGroup(groupId, queueMessage(this.config.timezone, this.grokEnabled)) + this.cards.scheduleUpdate(groupId) + return + } + + await this.sendToGroup(groupId, grokActivatedMessage) + + // Grok joined — send initial response based on customer's accumulated messages + try { + const grokLocalGId = this.grokGroupMap.get(groupId) + if (grokLocalGId === undefined) { + await this.sendToGroup(groupId, grokUnavailableMessage) + return + } + + // Read history from Grok's own view — only customer messages. + // The previous `grokBc && ...` short-circuit let bot and team + // messages through when Grok's view had no businessChat; require + // grokBc.customerId to be present and match strictly. + const chat = await this.withGrokProfile(() => + this.chat.apiGetChat(T.ChatType.Group, grokLocalGId, 100) + ) + const grokBc = chat.chatInfo.type === "group" ? chat.chatInfo.groupInfo.businessChat : null + const customerMessages: string[] = [] + for (const ci of chat.chatItems) { + if (ci.chatDir.type !== "groupRcv") continue + if (!grokBc || ci.chatDir.groupMember.memberId !== grokBc.customerId) continue + const t = util.ciContentText(ci)?.trim() + if (t && !this.customerCommand(ci)) customerMessages.push(t) + } + + if (customerMessages.length === 0) { + await this.withGrokProfile(() => + this.chat.apiSendTextMessage([T.ChatType.Group, grokLocalGId], grokNoHistoryMessage) + ) + return + } + + const initialMsg = customerMessages.join("\n") + const response = await grokApi.chat([], initialMsg) + + await this.withGrokProfile(() => + this.chat.apiSendTextMessage([T.ChatType.Group, grokLocalGId], response) + ) + + // Grok asked for the team → escalate as if the customer sent /team + if (response.includes("/team")) await this.activateTeam(groupId) + } catch (err) { + logError(`Grok initial response failed for group ${groupId}`, err) + await this.sendToGroup(groupId, grokUnavailableMessage) + } + } finally { + this.grokInitialResponsePending.delete(groupId) + } + } + + // --- Team activation --- + + private async activateTeam(groupId: number): Promise { + if (this.config.teamMembers.length === 0) { + await this.sendToGroup(groupId, noTeamMembersMessage(this.grokEnabled)) + return + } + + const data = await this.cards.getRawCustomData(groupId) + const alreadyActivated = data?.state === "TEAM-PENDING" || data?.state === "TEAM" + if (alreadyActivated) { + const {teamMembers} = await this.cards.getGroupComposition(groupId) + if (teamMembers.length > 0) { + await this.sendToGroup(groupId, teamAlreadyInvitedMessage) + return + } + // Team previously activated but all team members have since left — + // re-add silently (no teamAddedMessage). State stays TEAM-PENDING/TEAM. + for (const tm of this.config.teamMembers) { + try { + await this.addOrFindTeamMember(groupId, tm.id) + } catch (err) { + logError(`Failed to add team member ${tm.id} to group ${groupId}`, err) + } + } + return + } + + // First activation — write state BEFORE add loop so concurrent customer + // events observing mid-flight see TEAM-PENDING rather than stale state. + await this.cards.mergeCustomData(groupId, {state: "TEAM-PENDING"}) + + for (const tm of this.config.teamMembers) { + try { + await this.addOrFindTeamMember(groupId, tm.id) + } catch (err) { + logError(`Failed to add team member ${tm.id} to group ${groupId}`, err) + } + } + + const {grokMember} = await this.cards.getGroupComposition(groupId) + await this.sendToGroup(groupId, teamAddedMessage(this.config.timezone, !!grokMember)) + } + + // --- Team group commands --- + + private async processTeamGroupMessage(chatItem: T.ChatItem): Promise { + if (chatItem.chatDir.type !== "groupRcv") return + const senderContactId = chatItem.chatDir.groupMember.memberContactId + if (!senderContactId) return + + const cmd = util.ciBotCommand(chatItem) + if (cmd?.keyword !== "join") return + + const targetGroupId = Number.parseInt(cmd.params, 10) + if (Number.isNaN(targetGroupId) || targetGroupId <= 0) { + await this.sendToGroup(this.config.teamGroup.id, `Error: invalid group id "${cmd.params}"`) + return + } + await this.handleJoinCommand(targetGroupId, senderContactId) + } + + private async handleJoinCommand(targetGroupId: number, senderContactId: number): Promise { + // Validate target is a business group + const targetGroup = await this.withMainProfile(() => getGroupInfo(this.chat, targetGroupId)) + if (!targetGroup?.businessChat) { + await this.sendToGroup(this.config.teamGroup.id, `Error: group ${targetGroupId} is not a business chat`) + return + } + + try { + const member = await this.addOrFindTeamMember(targetGroupId, senderContactId) + if (member) { + log(`Team member ${senderContactId} joined group ${targetGroupId} via /join`) + } + } catch (err) { + logError(`/join failed for group ${targetGroupId}`, err) + await this.sendToGroup(this.config.teamGroup.id, `Error joining group ${targetGroupId}`) + } + } + + // --- Helpers --- + + private async addOrFindTeamMember(groupId: number, teamContactId: number): Promise { + // Pre-check membership: skip apiAddMember entirely if the contact is in + // the group in any non-terminal status. The SimpleX API resends the + // invitation for a member in GSMemInvited, so calling apiAddMember on a + // pending invitee would re-trigger an invite notification. + const members = await this.withMainProfile(() => this.chat.apiListMembers(groupId)) + const existing = members.find(m => m.memberContactId === teamContactId && isInGroup(m)) + if (existing) return existing + const member = await this.withMainProfile(() => + this.chat.apiAddMember(groupId, teamContactId, T.GroupMemberRole.Member) + ) + try { + await this.withMainProfile(() => + this.chat.apiSetMembersRole(groupId, [member.groupMemberId], T.GroupMemberRole.Owner) + ) + } catch { + // Not yet connected — will be promoted in onMemberConnected + } + return member + } + + async sendToGroup(groupId: number, text: string): Promise { + try { + await this.withMainProfile(async () => { + await this.syncGroupCommands(groupId) + await this.chat.apiSendTextMessage([T.ChatType.Group, groupId], text) + }) + } catch (err) { + logError(`Failed to send message to group ${groupId}`, err) + } + } + + private waitForGrokJoin(groupId: number, timeout: number): Promise { + if (this.grokFullyConnected.has(groupId)) return Promise.resolve(true) + return new Promise((resolve) => { + const timer = setTimeout(() => { + this.grokJoinResolvers.delete(groupId) + resolve(false) + }, timeout) + this.grokJoinResolvers.set(groupId, () => { + clearTimeout(timer) + resolve(true) + }) + }) + } + + private async sendTeamMemberDM(member: T.GroupMember, memberContact?: T.Contact): Promise { + const name = member.memberProfile.displayName + const formatted = name.includes(" ") ? `'${name}'` : name + + let contactId = memberContact?.contactId ?? member.memberContactId + if (!contactId) { + // No DM contact yet — create one and send invitation with message + try { + const contact = await this.withMainProfile(() => + this.chat.apiCreateMemberContact(this.config.teamGroup.id, member.groupMemberId) + ) + contactId = contact.contactId as number + log(`Created DM contact ${contactId} for team member ${name}`) + } catch (err) { + logError(`Failed to create member contact for ${name}`, err) + return + } + if (this.sentTeamDMs.has(contactId)) return + const msg = `Added you to be able to invite you to customer chats later, keep this contact. Your contact ID is ${contactId}:${formatted}` + try { + await this.withMainProfile(() => + this.chat.apiSendMemberContactInvitation(contactId!, msg) + ) + this.sentTeamDMs.add(contactId) + this.pendingTeamDMs.delete(contactId) + log(`Sent DM invitation to team member ${contactId}:${name}`) + } catch { + this.pendingTeamDMs.set(contactId, msg) + } + return + } + // Contact already exists — send via normal DM + if (this.sentTeamDMs.has(contactId)) return + const msg = `Added you to be able to invite you to customer chats later, keep this contact. Your contact ID is ${contactId}:${formatted}` + try { + await this.withMainProfile(() => + this.chat.apiSendTextMessage([T.ChatType.Direct, contactId], msg) + ) + this.sentTeamDMs.add(contactId) + this.pendingTeamDMs.delete(contactId) + log(`Sent DM to team member ${contactId}:${name}`) + } catch { + this.pendingTeamDMs.set(contactId, msg) + } + } + + private cleanupGrokMaps(groupId: number): void { + const grokLocalGId = this.grokGroupMap.get(groupId) + this.grokFullyConnected.delete(groupId) + this.grokInitialResponsePending.delete(groupId) + if (grokLocalGId === undefined) return + this.grokGroupMap.delete(groupId) + this.reverseGrokMap.delete(grokLocalGId) + } +} diff --git a/bots/typescript/simplex-support-bot/src/cards.ts b/bots/typescript/simplex-support-bot/src/cards.ts new file mode 100644 index 0000000..feea986 --- /dev/null +++ b/bots/typescript/simplex-support-bot/src/cards.ts @@ -0,0 +1,479 @@ +import {T} from "@simplex-chat/types" +import {api, util} from "simplex-chat" +import {Mutex} from "async-mutex" +import {Config} from "./config.js" +import {profileMutex, log, logError, getGroupInfo} from "./util.js" + +// State derivation types +export type ConversationState = "WELCOME" | "QUEUE" | "GROK" | "TEAM-PENDING" | "TEAM" + +function isConversationState(x: unknown): x is ConversationState { + return x === "WELCOME" || x === "QUEUE" || x === "GROK" || x === "TEAM-PENDING" || x === "TEAM" +} + +export interface GroupComposition { + grokMember: T.GroupMember | undefined + teamMembers: T.GroupMember[] +} + +interface CardData { + state?: ConversationState + cardItemId?: number + complete?: boolean +} + +function isActiveMember(m: T.GroupMember): boolean { + return m.memberStatus === T.GroupMemberStatus.Connected + || m.memberStatus === T.GroupMemberStatus.Complete + || m.memberStatus === T.GroupMemberStatus.Announced +} + +// Prevent ! from triggering SimpleX markdown styled text (color/small). +// The parser treats !N as color markup (N: 1-6, r, g, b, y, c, m, -) +// and closes at the next !. No escape mechanism exists in the parser, +// so we insert a zero-width space to break the trigger pattern. +function escapeStyledMarkdown(text: string): string { + return text.replace(/!([1-6rgbycm-])/g, "!\u200B$1") +} + +// Truncate a single message to ~maxChars, appending [truncated] if needed +function truncateMsg(text: string, maxChars: number): string { + if (text.length <= maxChars) return text + return text.slice(0, maxChars) + "… [truncated]" +} + +// Describe non-text content types +function contentTypeLabel(ci: T.ChatItem): string | null { + const content = ci.content as T.CIContent + if (content.type !== "rcvMsgContent" && content.type !== "sndMsgContent") return null + const mc = content.msgContent + switch (mc.type) { + case "image": return "[image]" + case "video": return "[video]" + case "voice": return "[voice]" + case "file": return "[file]" + default: return null + } +} + +export class CardManager { + private pendingUpdates = new Set() + private flushInterval: NodeJS.Timeout + // Outer lock; profileMutex (via withMainProfile) is the inner lock. + private customDataMutexes = new Map() + + constructor( + private chat: api.ChatApi, + private config: Config, + private mainUserId: number, + flushIntervalMs = 300 * 1000, + ) { + this.flushInterval = setInterval(() => this.flush(), flushIntervalMs) + this.flushInterval.unref() + } + + private async withMainProfile(fn: () => Promise): Promise { + return profileMutex.runExclusive(async () => { + await this.chat.apiSetActiveUser(this.mainUserId) + return fn() + }) + } + + private getCustomDataMutex(groupId: number): Mutex { + let m = this.customDataMutexes.get(groupId) + if (!m) { + m = new Mutex() + this.customDataMutexes.set(groupId, m) + } + return m + } + + scheduleUpdate(groupId: number): void { + this.pendingUpdates.add(groupId) + } + + async createCard(groupId: number, groupInfo: T.GroupInfo): Promise { + const {text} = await this.composeCard(groupId, groupInfo) + const chatRef: T.ChatRef = {chatType: T.ChatType.Group, chatId: this.config.teamGroup.id} + const items = await this.withMainProfile(() => + this.chat.apiSendMessages(chatRef, [ + {msgContent: {type: "text", text}, mentions: {}}, + ]) + ) + await this.mergeCustomData(groupId, {cardItemId: items[0].chatItem.meta.itemId}) + } + + async flush(): Promise { + const groups = [...this.pendingUpdates] + this.pendingUpdates.clear() + for (const groupId of groups) { + try { + await this.flushOne(groupId) + } catch (err) { + logError(`Card flush failed for group ${groupId}`, err) + } + } + } + + // Dispatches to create-path when cardItemId is absent so a failed createCard retries. + private async flushOne(groupId: number): Promise { + const groupInfo = await this.withMainProfile(() => getGroupInfo(this.chat, groupId)) + if (!groupInfo) return + const data = groupInfo.customData as Record | undefined + if (typeof data?.cardItemId === "number") { + await this.updateCard(groupId) + } else { + await this.createCard(groupId, groupInfo) + } + } + + async refreshAllCards(): Promise { + // Scan the most recently active 1000 chats. Active cards live on + // recently-active customer chats by definition — a card stays open + // while the conversation is in flight. If the bot has been offline + // long enough that an active card has fallen outside this window, the + // card refreshes lazily on the next customer message (which moves the + // chat back into the recent window). + const chats = await this.withMainProfile(() => + this.chat.apiGetChats(this.mainUserId, {type: "last", count: 1000}) + ) + const activeCards: {groupId: number; cardItemId: number}[] = [] + for (const c of chats) { + if (c.chatInfo.type !== "group") continue + const groupInfo = c.chatInfo.groupInfo + const customData = groupInfo.customData as Record | undefined + if (customData && typeof customData.cardItemId === "number" && !customData.complete) { + activeCards.push({groupId: groupInfo.groupId, cardItemId: customData.cardItemId}) + } + } + if (activeCards.length === 0) return + + // Sort ascending by cardItemId — higher ID = more recently updated card. + // Oldest-updated cards refresh first; newest-updated refresh last, + // so the most recent cards end up at the bottom of the team group. + activeCards.sort((a, b) => a.cardItemId - b.cardItemId) + + log(`Startup: refreshing ${activeCards.length} card(s)`) + + for (const {groupId} of activeCards) { + try { + await this.updateCard(groupId) + } catch (err) { + logError(`Startup card refresh failed for group ${groupId}`, err) + } + } + } + + destroy(): void { + clearInterval(this.flushInterval) + } + + // --- State derivation --- + + async getGroupComposition(groupId: number): Promise { + const members = await this.withMainProfile(() => this.chat.apiListMembers(groupId)) + return { + grokMember: members.find(m => + this.config.grokContactId !== null + && m.memberContactId === this.config.grokContactId + && isActiveMember(m)), + teamMembers: members.filter(m => + this.config.teamMembers.some(tm => tm.id === m.memberContactId) + && isActiveMember(m)), + } + } + + async deriveState(groupId: number): Promise { + const data = await this.getRawCustomData(groupId) + return data?.state ?? "WELCOME" + } + + async getLastCustomerMessageTime(groupId: number, customerId: string): Promise { + const chat = await this.getChat(groupId, 20) + for (let i = chat.chatItems.length - 1; i >= 0; i--) { + const ci = chat.chatItems[i] + if (ci.chatDir.type === "groupRcv" && ci.chatDir.groupMember.memberId === customerId) { + return new Date(ci.meta.createdAt).getTime() + } + } + return undefined + } + + async getLastTeamOrGrokMessageTime(groupId: number): Promise { + const chat = await this.getChat(groupId, 20) + for (let i = chat.chatItems.length - 1; i >= 0; i--) { + const ci = chat.chatItems[i] + if (ci.chatDir.type === "groupRcv") { + const contactId = ci.chatDir.groupMember.memberContactId + const isTeam = this.config.teamMembers.some(tm => tm.id === contactId) + const isGrok = this.config.grokContactId !== null && contactId === this.config.grokContactId + if (isTeam || isGrok) return new Date(ci.meta.createdAt).getTime() + } + if (ci.chatDir.type === "groupSnd") { + // Bot's own messages don't count + } + } + return undefined + } + + // --- Custom data --- + + async getRawCustomData(groupId: number): Promise | null> { + const group = await this.withMainProfile(() => getGroupInfo(this.chat, groupId)) + if (!group?.customData) return null + const data = group.customData as Record + const result: Partial = {} + if (isConversationState(data.state)) result.state = data.state + if (typeof data.cardItemId === "number") result.cardItemId = data.cardItemId + if (data.complete === true) result.complete = true + return result + } + + async mergeCustomData(groupId: number, patch: Partial): Promise { + return this.getCustomDataMutex(groupId).runExclusive(async () => { + const current = (await this.getRawCustomData(groupId)) ?? {} + const merged: Partial = {...current, ...patch} + for (const key of Object.keys(merged) as (keyof CardData)[]) { + if (merged[key] === undefined) delete merged[key] + } + await this.withMainProfile(() => this.chat.apiSetGroupCustomData(groupId, merged)) + }) + } + + async clearCustomData(groupId: number): Promise { + return this.getCustomDataMutex(groupId).runExclusive(() => + this.withMainProfile(() => this.chat.apiSetGroupCustomData(groupId)) + ) + } + + // --- Chat history access --- + + async getChat(groupId: number, count: number): Promise { + return this.withMainProfile(() => this.chat.apiGetChat(T.ChatType.Group, groupId, count)) + } + + // --- Internal --- + + private async updateCard(groupId: number): Promise { + const groupInfo = await this.withMainProfile(() => getGroupInfo(this.chat, groupId)) + if (!groupInfo) return + + const customData = groupInfo.customData as Record | undefined + const cardItemId = customData?.cardItemId + if (typeof cardItemId !== "number") return + + try { + await this.withMainProfile(() => + this.chat.apiDeleteChatItems( + T.ChatType.Group, this.config.teamGroup.id, [cardItemId], T.CIDeleteMode.Broadcast + ) + ) + } catch { + // card may already be deleted + } + + const {text, complete} = await this.composeCard(groupId, groupInfo) + const chatRef: T.ChatRef = {chatType: T.ChatType.Group, chatId: this.config.teamGroup.id} + const items = await this.withMainProfile(() => + this.chat.apiSendMessages(chatRef, [ + {msgContent: {type: "text", text}, mentions: {}}, + ]) + ) + const patch: Partial = { + cardItemId: items[0].chatItem.meta.itemId, + complete: complete ? true : undefined, + } + await this.mergeCustomData(groupId, patch) + } + + private async composeCard(groupId: number, groupInfo: T.GroupInfo): Promise<{text: string, complete: boolean}> { + const rawName = groupInfo.groupProfile.displayName || `group-${groupId}` + const customerName = rawName.replace(/\n+/g, " ") + const bc = groupInfo.businessChat + const customerId = bc?.customerId + + const state = await this.deriveState(groupId) + const {teamMembers} = await this.getGroupComposition(groupId) + + const icon = await this.computeIcon(groupId, state, customerId ?? undefined) + const waitStr = await this.computeWaitTime(groupId, state, customerId ?? undefined) + + const chat = await this.getChat(groupId, 100) + const msgCount = chat.chatItems.filter((ci: T.ChatItem) => ci.chatDir.type !== "groupSnd").length + + const stateLabel = this.stateLabel(state) + + const agentNames = teamMembers.map(m => m.memberProfile.displayName) + const agentStr = agentNames.length > 0 ? ` · ${agentNames.join(", ")}` : "" + + const preview = this.buildPreview(chat.chatItems, customerName, customerId) + + // Final line uses /'join ' quoting so SimpleX clients render the full + // command (including the argument) as a single clickable token. + const joinCmd = `/'join ${groupId}'` + + const line1 = `${icon} *${customerName}* · ${waitStr} · ${msgCount} msgs` + const line2 = `${stateLabel}${agentStr}` + return {text: `${line1}\n${line2}\n${preview}\n${joinCmd}`, complete: icon === "✅"} + } + + private async computeIcon( + groupId: number, state: ConversationState, customerId?: string, + ): Promise { + const now = Date.now() + const completeMs = this.config.completeHours * 3600_000 + + // Check auto-complete: last team/Grok message time vs customer silence + const lastTeamGrokTime = await this.getLastTeamOrGrokMessageTime(groupId) + if (lastTeamGrokTime) { + const lastCustTime = customerId + ? await this.getLastCustomerMessageTime(groupId, customerId) + : undefined + // Auto-complete if team/grok replied and customer hasn't responded since, for completeHours + if (!lastCustTime || lastCustTime < lastTeamGrokTime) { + if (now - lastTeamGrokTime >= completeMs) return "✅" + } + } + + switch (state) { + case "QUEUE": { + const lastCustTime = customerId + ? await this.getLastCustomerMessageTime(groupId, customerId) + : undefined + if (!lastCustTime) return "🟡" + const waitMs = now - lastCustTime + if (waitMs < 5 * 60_000) return "🆕" + if (waitMs < 2 * 3600_000) return "🟡" + return "🔴" + } + case "GROK": + return "🤖" + case "TEAM-PENDING": + return "👋" + case "TEAM": { + // Check if customer follow-up unanswered > 2h + const lastCustTime = customerId + ? await this.getLastCustomerMessageTime(groupId, customerId) + : undefined + if (lastCustTime && lastTeamGrokTime && lastCustTime > lastTeamGrokTime) { + return (now - lastCustTime > 2 * 3600_000) ? "⏰" : "💬" + } + return "💬" + } + default: + return "🟡" + } + } + + private async computeWaitTime( + groupId: number, _state: ConversationState, customerId?: string, + ): Promise { + const now = Date.now() + const completeMs = this.config.completeHours * 3600_000 + + const lastTeamGrokTime = await this.getLastTeamOrGrokMessageTime(groupId) + if (lastTeamGrokTime) { + const lastCustTime = customerId + ? await this.getLastCustomerMessageTime(groupId, customerId) + : undefined + if (!lastCustTime || lastCustTime < lastTeamGrokTime) { + if (now - lastTeamGrokTime >= completeMs) return "done" + } + } + + const lastCustTime = customerId + ? await this.getLastCustomerMessageTime(groupId, customerId) + : undefined + if (!lastCustTime) return "<1m" + return this.formatDuration(now - lastCustTime) + } + + private stateLabel(state: ConversationState): string { + switch (state) { + case "QUEUE": return "Queue" + case "GROK": return "Grok" + case "TEAM-PENDING": return "Team – pending" + case "TEAM": return "Team" + default: return "Queue" + } + } + + private buildPreview(chatItems: T.ChatItem[], customerName: string, customerId?: string): string { + const maxTotal = 500 + const maxPer = 200 + + // Collect entries in chronological order (oldest first) + const entries: {senderId: string; name: string; text: string}[] = [] + for (const ci of chatItems) { + if (ci.chatDir.type === "groupSnd") continue + + let text = (util.ciContentText(ci)?.trim() || "").replace(/\n+/g, " ") + const mediaLabel = contentTypeLabel(ci) + if (mediaLabel && !text) text = mediaLabel + else if (mediaLabel) text = `${mediaLabel} ${text}` + if (!text) continue + + let senderId = "" + let name = "" + if (ci.chatDir.type === "groupRcv") { + const member = ci.chatDir.groupMember + const contactId = member.memberContactId + senderId = member.memberId + if (this.config.grokContactId !== null && contactId === this.config.grokContactId) { + name = "Grok" + } else if (customerId && member.memberId === customerId) { + name = customerName + } else { + name = member.memberProfile.displayName + } + } + + entries.push({senderId, name, text: truncateMsg(text, maxPer)}) + } + + // Compute prefixed lines in chronological order (sender prefix on first msg of each run) + const lines: {line: string; senderId: string; name: string}[] = [] + let lastSenderId = "" + for (const entry of entries) { + let line = entry.text + if (entry.senderId !== lastSenderId && entry.name) { + line = `${entry.name}: ${line}` + lastSenderId = entry.senderId + } + lines.push({line, senderId: entry.senderId, name: entry.name}) + } + + // Take from the end (newest) until maxTotal exceeded — oldest messages are truncated + const selected: string[] = [] + let totalLen = 0 + let firstSelectedIdx = lines.length + for (let i = lines.length - 1; i >= 0; i--) { + if (totalLen + lines[i].line.length > maxTotal && selected.length > 0) { + break + } + selected.push(lines[i].line) + totalLen += lines[i].line.length + firstSelectedIdx = i + } + selected.reverse() + + // If truncation happened, ensure the first visible message has a sender prefix + if (firstSelectedIdx > 0 && selected.length > 0) { + const first = lines[firstSelectedIdx] + if (first.name && !selected[0].startsWith(`${first.name}: `)) { + selected[0] = `${first.name}: ${selected[0]}` + } + selected.unshift("[truncated]") + } + + const preview = selected.map(escapeStyledMarkdown).join(" !3 /! ") + return preview ? `"${preview}"` : '""' + } + + private formatDuration(ms: number): string { + if (ms < 60_000) return "<1m" + if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m` + if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)}h` + return `${Math.floor(ms / 86_400_000)}d` + } +} diff --git a/bots/typescript/simplex-support-bot/src/config.ts b/bots/typescript/simplex-support-bot/src/config.ts new file mode 100644 index 0000000..19a40d9 --- /dev/null +++ b/bots/typescript/simplex-support-bot/src/config.ts @@ -0,0 +1,152 @@ +import {Command} from "commander" +import {api} from "simplex-chat" + +export interface IdName { + id: number + name: string +} + +export type Backend = "sqlite" | "postgres" + +export interface Config { + stateFile: string // local path to the bot's state JSON + db: api.DbConfig // passed to ChatApi.init / bot.run + teamGroup: IdName // name from CLI, id resolved at startup from state file + teamMembers: IdName[] // optional, empty if not provided + grokContactId: number | null // resolved at startup + timezone: string + completeHours: number + cardFlushSeconds: number + contextFile: string | null + grokApiKey: string | null + aiUrl: string + aiModel: string +} + +// Mirrors packages/simplex-chat-nodejs/src/download-libs.js so runtime detection +// matches what was used at install time. Works whether the user installed via +// SIMPLEX_BACKEND env var, .npmrc (→ npm_config_simplex_backend), or the +// --simplex_backend=postgres CLI flag (also surfaced as npm_config_*). +export function detectBackend(): Backend { + const raw = (process.env.SIMPLEX_BACKEND || process.env.npm_config_simplex_backend || "sqlite").toLowerCase() + if (raw !== "sqlite" && raw !== "postgres") { + throw new Error(`Invalid SIMPLEX_BACKEND: "${raw}". Must be "sqlite" or "postgres".`) + } + return raw +} + +export function parseIdName(s: string): IdName { + const i = s.indexOf(":") + if (i < 1) throw new Error(`Invalid ID:name format: "${s}"`) + const id = parseInt(s.slice(0, i), 10) + if (isNaN(id)) throw new Error(`Invalid ID:name format (non-numeric ID): "${s}"`) + return {id, name: s.slice(i + 1)} +} + +function parseNonNegativeInt(flag: string) { + return (raw: string): number => { + const n = parseInt(raw, 10) + if (!Number.isFinite(n) || n < 0) { + throw new Error(`${flag} must be a non-negative integer, got "${raw}"`) + } + return n + } +} + +function buildCommand(): Command { + return new Command() + .name("simplex-chat-support-bot") + .description("business-address triage bot") + .requiredOption("--team-group ", "team group display name") + .option("--state-file ", "state JSON path", "./data/state.json") + .option("--sqlite-file-prefix ", "SQLite DB file prefix", "./data/simplex") + .option("--sqlite-key ", "SQLCipher encryption key (default: unencrypted)") + .option("--pg-conn ", "PostgreSQL connection string (required for postgres)") + .option("--pg-schema ", "PostgreSQL schema prefix (default: simplex_v1)") + .option("-a, --auto-add-team-members ", "comma-separated ID:name pairs (e.g. 1:Alice,2:Bob)") + .option("--timezone ", "IANA timezone for weekend detection", "UTC") + .option("--complete-hours ", "auto-complete chats after N hours idle (0 disables)", parseNonNegativeInt("--complete-hours"), 3) + .option("--card-flush-seconds ", "debounce card state writes", parseNonNegativeInt("--card-flush-seconds"), 300) + .option("--context-file ", "text file with AI system context (required if AI_API_KEY set)") + .option("--ai-url ", "OpenAI-compatible API base URL", "https://api.x.ai/v1") + .option("--ai-model ", "model name to use", "grok-latest") + .addHelpText("after", "\nEnvironment:\n AI_API_KEY API key for the AI provider (xAI, OpenAI, Ollama, etc.)\n GROK_API_KEY legacy alias for AI_API_KEY\n SIMPLEX_BACKEND sqlite | postgres — alternative to .npmrc for backend selection\n") +} + +interface RawOpts { + teamGroup: string + stateFile: string + sqliteFilePrefix: string + sqliteKey?: string + pgConn?: string + pgSchema?: string + autoAddTeamMembers?: string + timezone: string + completeHours: number + cardFlushSeconds: number + contextFile?: string + aiUrl: string + aiModel: string +} + +export function parseConfig(args: string[]): Config { + const cmd = buildCommand().exitOverride() + try { + cmd.parse(args, {from: "user"}) + } catch (err) { + const code = (err as {code?: string}).code + if (code === "commander.helpDisplayed" || code === "commander.version") process.exit(0) + throw err + } + const opts = cmd.opts() + + const grokApiKey = process.env.AI_API_KEY || process.env.GROK_API_KEY || null + + const backend = detectBackend() + let db: api.DbConfig + if (backend === "sqlite") { + db = opts.sqliteKey + ? {type: "sqlite", filePrefix: opts.sqliteFilePrefix, encryptionKey: opts.sqliteKey} + : {type: "sqlite", filePrefix: opts.sqliteFilePrefix} + } else { + if (!opts.pgConn) { + throw new Error("--pg-conn is required when backend is postgres (PostgreSQL connection string)") + } + db = opts.pgSchema + ? {type: "postgres", connectionString: opts.pgConn, schemaPrefix: opts.pgSchema} + : {type: "postgres", connectionString: opts.pgConn} + } + + const teamGroup: IdName = {id: 0, name: opts.teamGroup} + + const teamMembersRaw = opts.autoAddTeamMembers ?? "" + const teamMembers = teamMembersRaw + ? teamMembersRaw.split(",").map(parseIdName) + : [] + + try { + new Intl.DateTimeFormat("en-US", {timeZone: opts.timezone, weekday: "short"}) + } catch (err) { + throw new Error(`--timezone "${opts.timezone}" is not a valid IANA time zone: ${(err as Error).message}`) + } + + const contextFile = opts.contextFile ?? null + if (grokApiKey && !contextFile) { + throw new Error("AI_API_KEY is set but --context-file is not provided. AI requires a context file.") + } + + return { + stateFile: opts.stateFile, + db, + teamGroup, + teamMembers, + grokContactId: null, + timezone: opts.timezone, + completeHours: opts.completeHours, + cardFlushSeconds: opts.cardFlushSeconds, + contextFile, + grokApiKey, + aiUrl: opts.aiUrl, + aiModel: opts.aiModel, + } +} diff --git a/bots/typescript/simplex-support-bot/src/context.ts b/bots/typescript/simplex-support-bot/src/context.ts new file mode 100644 index 0000000..81f3011 --- /dev/null +++ b/bots/typescript/simplex-support-bot/src/context.ts @@ -0,0 +1,59 @@ +import {readFileSync} from "fs" +import {parse as parseYaml} from "yaml" +import {GrokMessage} from "./grok.js" + +const ALLOWED_ROLES: ReadonlySet = new Set(["system", "user", "assistant"]) +// Roles surfaced from a YAML transcript. `user` entries from the file are +// validated but dropped — the customer's runtime message is the only +// `user` content sent to Grok. +const PREPEND_ROLES: ReadonlySet = new Set(["system", "assistant"]) + +// Loads --context-file. The flag is documented as "text file with Grok +// system context"; a `.yaml` / `.yml` extension is an undocumented +// alternative that switches to a multi-turn transcript in the harness +// format (a flat list of `{role, message}` entries). +export function loadGrokContext(path: string): GrokMessage[] { + const text = readFileSync(path, "utf-8") + return isYamlPath(path) ? parseYamlTranscript(path, text) : [{role: "system", content: text}] +} + +function isYamlPath(path: string): boolean { + const lower = path.toLowerCase() + return lower.endsWith(".yaml") || lower.endsWith(".yml") +} + +// Parses the harness transcript format. Returns only `system` and +// `assistant` turns; `user` entries are intentionally excluded so they +// don't merge with the customer's runtime message. Malformed YAML, +// unknown roles, or non-string messages throw — operator-supplied +// configuration should fail-fast at startup, not silently degrade. +function parseYamlTranscript(path: string, text: string): GrokMessage[] { + let raw: unknown + try { + raw = parseYaml(text) + } catch (e) { + throw new Error(`${path}: failed to parse YAML: ${(e as Error).message}`) + } + if (raw === null || raw === undefined) return [] + if (!Array.isArray(raw)) { + throw new Error(`${path}: top-level must be a list, got ${typeof raw}`) + } + const context: GrokMessage[] = [] + for (let i = 0; i < raw.length; i++) { + const entry = raw[i] + if (entry === null || typeof entry !== "object" || Array.isArray(entry)) { + throw new Error(`${path}: entry ${i} is not a mapping`) + } + const {role, message} = entry as {role?: unknown; message?: unknown} + if (typeof role !== "string" || !ALLOWED_ROLES.has(role as GrokMessage["role"])) { + throw new Error(`${path}: entry ${i} has invalid role: ${JSON.stringify(role)}`) + } + if (typeof message !== "string") { + throw new Error(`${path}: entry ${i} has non-string message`) + } + if (PREPEND_ROLES.has(role as GrokMessage["role"])) { + context.push({role: role as GrokMessage["role"], content: message}) + } + } + return context +} diff --git a/bots/typescript/simplex-support-bot/src/grok.ts b/bots/typescript/simplex-support-bot/src/grok.ts new file mode 100644 index 0000000..e820588 --- /dev/null +++ b/bots/typescript/simplex-support-bot/src/grok.ts @@ -0,0 +1,58 @@ +import {log, logError} from "./util.js" + +export interface GrokMessage { + role: "system" | "user" | "assistant" + content: string +} + +export class GrokApiClient { + private readonly apiKey: string | null + private readonly baseUrl: string + private readonly model: string + private readonly initialContext: readonly GrokMessage[] + + constructor(apiKey: string | null, baseUrl: string, model: string, initialContext: readonly GrokMessage[]) { + this.apiKey = apiKey + this.baseUrl = baseUrl.replace(/\/$/, "") + this.model = model + this.initialContext = initialContext + } + + async chatRaw(messages: GrokMessage[]): Promise { + const headers: Record = {"Content-Type": "application/json"} + if (this.apiKey) headers["Authorization"] = `Bearer ${this.apiKey}` + const response = await fetch(`${this.baseUrl}/chat/completions`, { + method: "POST", + headers, + body: JSON.stringify({ + model: this.model, + messages, + temperature: 0.3, + max_tokens: 1024, + }), + signal: AbortSignal.timeout(60_000), + }) + + if (!response.ok) { + const body = await response.text() + logError(`Grok API HTTP ${response.status}`, body) + throw new Error(`Grok API error: HTTP ${response.status}`) + } + + const data = await response.json() as {choices: {message: {content: string}}[]} + const content = data.choices?.[0]?.message?.content + if (!content) throw new Error("Grok API returned empty response") + + log(`Grok API response: ${content.length} chars`) + return content + } + + async chat(history: GrokMessage[], userMessage: string): Promise { + log(`Grok API call: ${this.initialContext.length} context msgs, ${history.length} history msgs, user msg ${userMessage.length} chars`) + return this.chatRaw([ + ...this.initialContext, + ...history, + {role: "user", content: userMessage}, + ]) + } +} diff --git a/bots/typescript/simplex-support-bot/src/index.ts b/bots/typescript/simplex-support-bot/src/index.ts new file mode 100644 index 0000000..e932f0c --- /dev/null +++ b/bots/typescript/simplex-support-bot/src/index.ts @@ -0,0 +1,376 @@ +import {readFileSync, writeFileSync, existsSync} from "fs" +import {api, bot, util} from "simplex-chat" +import {T} from "@simplex-chat/types" +import {parseConfig} from "./config.js" +import {SupportBot} from "./bot.js" +import {GrokApiClient, GrokMessage} from "./grok.js" +import {loadGrokContext} from "./context.js" +import {welcomeMessage} from "./messages.js" +import {profileMutex, log, logError, getGroupInfo, getContact} from "./util.js" + +interface BotState { + teamGroupId?: number + grokContactId?: number + grokUserId?: number +} + +function readState(path: string): BotState { + if (!existsSync(path)) return {} + try { return JSON.parse(readFileSync(path, "utf-8")) } catch { return {} } +} + +function writeState(path: string, state: BotState): void { + writeFileSync(path, JSON.stringify(state), "utf-8") +} + +async function main(): Promise { + const config = parseConfig(process.argv.slice(2)) + // Do not log config.db.connectionString — typically contains credentials. + log("Config parsed", { + stateFile: config.stateFile, + backend: config.db.type, + teamGroup: config.teamGroup, + teamMembers: config.teamMembers, + timezone: config.timezone, + completeHours: config.completeHours, + }) + const grokEnabled = config.grokApiKey !== null + if (!grokEnabled) log("No GROK_API_KEY provided, disabling Grok support") + + const stateFilePath = config.stateFile + const state = readState(stateFilePath) + + // Forward-reference for event handlers during init + let supportBot: SupportBot | undefined + + // On restart, the active user may be Grok (if the previous run was killed + // mid-profile-switch). bot.run() uses apiGetActiveUser() and would then + // operate against the Grok userId as if it were the main user. Restore + // the main user as active before bot.run(). Grok is identified by the + // userId persisted in state.json on first resolution — comparing by + // profile name is fragile to renames. + if (state.grokUserId !== undefined) { + const preChat = await api.ChatApi.init(config.db) + try { + const activeUser = await preChat.apiGetActiveUser() + if (activeUser && activeUser.userId === state.grokUserId) { + const users = await preChat.apiListUsers() + const mainCandidates = users.filter(u => u.user.userId !== state.grokUserId) + if (mainCandidates.length === 0) { + throw new Error( + `DB has only the Grok user (userId=${state.grokUserId}); no main user to restore. ` + + `Likely a corrupted migration or partial restore.` + ) + } + if (mainCandidates.length > 1) { + const names = mainCandidates.map(u => `${u.user.userId}:${u.user.profile.displayName}`).join(", ") + throw new Error( + `Ambiguous DB state: multiple non-Grok users [${names}]. ` + + `Refusing to guess which is main — remove extras manually.` + ) + } + const mainUserInfo = mainCandidates[0] + await preChat.apiSetActiveUser(mainUserInfo.user.userId) + log(`Restored active user to ${mainUserInfo.user.profile.displayName} (userId=${mainUserInfo.user.userId})`) + } + } finally { + await preChat.close() + } + } + + // Profile images (base64-encoded JPEG) + const supportImage = "data:image/jpg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCACAAIADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD6pooooAKKKKACiignAyelABRQCGAIIIPIIooAKKKKACikjdZEDxsGU8gqcg0tAk01dBRRRQMKKKKACiiigAooooAK898ZeKftBew058Qj5ZZR/H7D29+9ehVxHjTwt5++/wBMT9996WFR9/8A2h7+3f69e/LnRVZe1+Xa587xNTxtTBNYP/t627Xl+vVr8c/wf4oNkyWWoPm1PCSH/ln7H/Z/lXo6kMAVIIPIIrwTdiuw8GeKjYsljqDk2h4SQ/8ALP2P+z/KvSzDLua9WkteqPmOGeJHQtg8Y/d+zLt5Py7Pp6bel1wXjHxRv32GmyfJ92WZT97/AGV9vU1H4z8ViTfYaZJ+7+7LMp+9/sqfT1NcOGqMvy61qtVeiNeJuJea+Dwb02lJfkv1Z1PhTxI+lSiC5JeyY8jqYz6j29RXp6MHRWU5VhkGuG8F+F8eXqGpx8/eihYdP9ph/IV3VcWZTpSq/u9+p7fCdDG0cHbFP3X8Ke6X+XZdAooorzj6kKKKKACiikYhVJYgAckmgBTxRXzJ8dPi6dUNx4d8LXGNPGY7u8jP+v8AVEP9z1P8XQcddL4E/F7/AI9/Dfiu49I7K+kbr2Ech/QN+B7Gu95dWVH2tvl1scqxdN1OQ+iaKKK4DqOG8b+FPPEmoaYn7770sKj7/wDtD39u/wBevnAas346/F77X9o8N+FLj/R+Y7y+jb/WdjHGf7vYt36DjJPnvgPxibXy9M1aT/R+FhnY/wCr9FY/3fQ9vp0+ty32qpJVvl3sfnPEmS051HiMItftJfmv1PVN1eheCPCvEeo6mmScNDC36M39BXm+6u18EeLTYMljqTk2h4jkP/LL2P8As/yrTMIVnRfsfn3t5Hh8PPB08ZF4xadOyfn/AF6nqNFIrBlDKQQeQR3pa+OP2IKRHV1DIwZT0IORXn/jjxdt8zTtLk+b7s0ynp6qp/maxPB3il9HmFvdFnsHPI6mM+o9vUV6cMqrTo+169F5HzNfinCUcYsM9Y7OXRP/AC7voeuUU2KRZY0kjIZGAZSO4NOrzD6VO+qCkZQylWAKkYIPelooGfMHxz+EZ0Zp/EPheAnTDl7q0jH/AB7eroP7nqP4fp08Lr9EmUMpVgCDwQa+Yfjn8Im0dp/EPhe3LaaSXurOMZNue7oP7nqP4fp09/L8w5rUqr16M8vF4S3vwNb4FfF7/j38N+K7jniOyvpG69hHIT+QY/Q9jVb47fF03RufDfhS4xbjMd7exn/WdjHGf7vYt36DjJPz/RXZ/Z9H23tbfLpfuc/1up7PkE6D0FfRnwK+EOw2/iTxXb/PxJZ2Mi/d7iSQevcL26nnAB8C/hD5Zt/Efiy3xJxJZ2Mq/d7iSQHv3C9up5wB9D1wZhmG9Kk/VnVhMJ9uZwPjvwj9o8zUtKj/AH33poVH3/8AaX39R3+vXzLdX0XXn3j3wd9o8zUtJj/f/emgUff/ANpR6+o7/XrpleZ2tRrPTo/0Z8xxFw5z3xeEWvVd/NfqjL8DeLzp7JYam5NmTiOQ/wDLL2P+z/KtDx14xAD6dpEuT0mnQ9P9lT/M15nu5pd1etLLKMq3tmvl0v3Pm4Z9jIYP6mpad+qXYn3V6D4E8ImXy9S1WP8Ad/ehgYfe9GYenoKj8A+EPOEWp6tH+74aCBh970Zh6eg716ZXl5nmVr0aL9X+iPe4d4cvbF4tecY/q/0QUUUV86ffhRRRQAV82/HX4vfa/tHhvwpcf6NzHeX0bf6zsY4z/d7Fu/QcZJPjr8XvtRuPDfhS4/0fmO8vo2/1nYxxkfw9i3foOMk/P/8AKvdy/L7Wq1V6I8zF4v7EBOn0pa+i/gX8INot/Efiy2+fiSzsZV+76SSA9/RT06nnAGP8dPhGdHa48Q+F4CdMJL3Vogybc93Qf3PUfw/Tp3rH0XV9lf59L9jleFqKn7Q1vgV8Xjm38N+LLnJ4js76VuvYRyE/kGP0PY19E1+dlfRXwJ+L3Nv4b8V3HPEdlfSN17COQn8g34Hsa8/MMv3q0l6o68Ji/sTPomvNfiB412mTS9Hl+blZ7hT09VU+vqaj+InjfYZdK0eX5uVnuFPT1VT6+p/CvMN1dOVZTe1euvRfqz5riDP98LhX6v8ARfqybdS7q9E+HngszeVqmsRfu+Ggt2H3vRmHp6DvVz4heC/tAk1PR4v3/wB6aBR9/wD2lHr6jv8AXr6TzTDqv7C/z6X7Hgx4dxcsJ9aS/wC3etu//AMrwD4zOnMmn6pITZE4jlY5MXsf9n+X0r1pWDKGUgqRkEd6+Zd2K7z4f+NDprR6dqrk2JOI5T/yx9j/ALP8vpXFmuU8961Ba9V3815/mevw/n7o2wuKfu9H28n5fl6bev0UisGUMpBUjII70tfKn3wVHdQRXVtLb3CCSGVCjoejKRgg/hUlFAHx98Z/hbceCrttQ0tXm8PTNhWPLWrHojn09G/A89e7+BXwh8v7P4k8V2/z8SWdjIv3e4kkB79wvbqecAfQc0Mc8TRzRpJG3VXUEH8DT69GeZVZ0vZ9e5yRwcI1Of8AAKRlDKVYAg8EGlorzjrPmD45/CM6O0/iHwvATphJe6tIx/x7+roP7nqP4fp04Hwh4aB2X+pR8feihYdf9ph/IV9EfErx2B52kaLKCeUuLhT09UU/zP4V5Tur7jKaFaVFTxHy728z4LPcxgpujhX6v9F+pPur074c+CDN5Wq6zF+64aC3cfe9GYenoO9eV7q9d+G/joXXlaVrUv8ApHCwXDH/AFnorH+96Hv9eumb/WI4duh8+9vI87IaeFeKX1n5dr+f6HptFFFfBn6ceb/ETwT9pEuqaNH/AKR96eBR/rPVlH971Hf69c34d+CTdmPU9ZiIth80MDj/AFn+0w/u+g7/AE6+tUV6kc2rxw/sE/n1t2PEnkGEnivrTXy6X7/8AAAAABgCiiivLPbCiiigAooooAK8n+Jnj7YZdI0OX5uUuLlD09UU+vqfwFerSossbxuMowKkeoNeBfETwTL4cuDd2QaTSpG4PUwk/wALe3ofwPPX2sjpYepiLVnr0XRv+uh4Wf1cTTw37hadX1S/rdnG7q9U+GngPzxFq2uRfueGt7Zx9/0dh6eg79TTPhj4B87ytY1yL91w9vbOPv8Ao7D09B36mvYK9POc4tfD4d+r/RHlZJkV7YnEr0X6v/I8U+JPgZtKaTVNIjLaeTuliXkwH1H+z/L6V52GxX1c6q6lWAKkYIIyDXiXxL8CNpLSapo8ZbTyd0sK9YPcf7P8vpV5PnHtLYfEPXo+/k/P8/XfLO8i9nfE4ZadV2815fl6bb/w18eC68rSdbl/0j7sFw5/1norH+96Hv8AXr6fXjXwy8Bm9MWr61ERajDQW7D/AFvozD+76Dv9OvsteLnMcPHENYf59r+R72RyxMsMnifl3t5/oFFFFeSeyFFFFABRRRQAUUUUAFMmijmjaOZFkjYYZXGQR7in0UJ2Bq+4UUUUAFIyh1KsAVIwQRwaWigAAAAAGAKKKKACiiigAooooA//2Q==" + const grokImage = "data:image/jpg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4gKgSUNDX1BST0ZJTEUAAQEAAAKQbGNtcwQwAABtbnRyUkdCIFhZWiAAAAAAAAAAAAAAAABhY3NwQVBQTAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9tYAAQAAAADTLWxjbXMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAtkZXNjAAABCAAAADhjcHJ0AAABQAAAAE53dHB0AAABkAAAABRjaGFkAAABpAAAACxyWFlaAAAB0AAAABRiWFlaAAAB5AAAABRnWFlaAAAB+AAAABRyVFJDAAACDAAAACBnVFJDAAACLAAAACBiVFJDAAACTAAAACBjaHJtAAACbAAAACRtbHVjAAAAAAAAAAEAAAAMZW5VUwAAABwAAAAcAHMAUgBHAEIAIABiAHUAaQBsAHQALQBpAG4AAG1sdWMAAAAAAAAAAQAAAAxlblVTAAAAMgAAABwATgBvACAAYwBvAHAAeQByAGkAZwBoAHQALAAgAHUAcwBlACAAZgByAGUAZQBsAHkAAAAAWFlaIAAAAAAAAPbWAAEAAAAA0y1zZjMyAAAAAAABDEoAAAXj///zKgAAB5sAAP2H///7ov///aMAAAPYAADAlFhZWiAAAAAAAABvlAAAOO4AAAOQWFlaIAAAAAAAACSdAAAPgwAAtr5YWVogAAAAAAAAYqUAALeQAAAY3nBhcmEAAAAAAAMAAAACZmYAAPKnAAANWQAAE9AAAApbcGFyYQAAAAAAAwAAAAJmZgAA8qcAAA1ZAAAT0AAACltwYXJhAAAAAAADAAAAAmZmAADypwAADVkAABPQAAAKW2Nocm0AAAAAAAMAAAAAo9cAAFR7AABMzQAAmZoAACZmAAAPXP/bAEMABQMEBAQDBQQEBAUFBQYHDAgHBwcHDwsLCQwRDxISEQ8RERMWHBcTFBoVEREYIRgaHR0fHx8TFyIkIh4kHB4fHv/bAEMBBQUFBwYHDggIDh4UERQeHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHv/AABEIAIAAgAMBIgACEQEDEQH/xAAdAAAABgMBAAAAAAAAAAAAAAAAAQQHCAkCAwUG/8QAOxAAAQIEBAQDBgUCBgMAAAAAAQIDAAQFEQYHITEIEkFRE2FxFCJCgZGhFTJSgrEJIzNDYnKS4VOy8P/EABYBAQEBAAAAAAAAAAAAAAAAAAABAv/EABsRAQEBAAMBAQAAAAAAAAAAAAABEQISIRMx/9oADAMBAAIRAxEAPwCINoAv3gCD3BjTIhpprA3gdTB2F9NoAE9oGm194IWMAQB/WB3gD7QNIAbwBtvA2PnBjtAF08oHe/ygDUwfTf6QA6wRsNYMX84NKSYDEHXYQY1jc5Kvty7Uw42pDTpUG1H4+U2JHcA6X76RpSn7bwBQB1gesADXvAD0gAaX0gDY2gWgCEGBcHSM2m1LNrR7LLrLvE+NqsKbhukTE++LFwpHK20D8S1n3Uj1OvS8B45DK1dI3JknSNEk/KJoZfcH0gyy3MY1xA666RdUpTEhKU+RdWCT8kiHbpHD3lHTmUtpwhLTShu5NvOPKPrdVvtE1cVrGReA/IfpGlcstF7pMWeTOReUswjkXgWkpHdtKmz9UkGPBYx4Tcv6o04uhTdSoUwfyAOe0Mj9q/e+ihDTFfikLSSCLQBa58+gh684uH7G+X8u7UH5NFVpCNTPyIKktju4gjmR66p84Z1EupblgmKjUy2pZ01hy8s8u2qjQ6hjjFSn5HBtII9odQeV2fe+GVl7/Go2BVskEnfb1vDVkXPZj1NNRqQeksMyrlpmYAsqYUN2mj3/AFK+H1hfxfYzkZrEUtl7hppqTwzhYezty7As2qZtZZ035fyi/XnPWIpjsW1h+t1l6oOsMSyFAIYlWE2almU6IabHRKRp3JuTckmOMBvGx1dyTbaMQSTfYHvFRqG0DWDtpA9IAvWM2Wys2jG149blphSoYuxbTcPUxF5uffS0gkXCBupZ8kpBUfIQHvuHHJepZmVwqWpySoUmoe2zoTrffwm76FZHySNT0BsBwVhSg4PoLFEw9TmZGTZGiUD3lq6qWrdSj1J1jXl9hSk4KwlIYcozIblZRvl5iPedWdVOK7qUbk/9Q2PExnhL5cyP4LRCzM4lmW+YBfvIk0G9nFjqo/Cn5nTfLRyscY6wngqRE3iauSlOSr/DQtV3HP8AagXUr5CGSxDxd4QlHy3RsPVapJH+Y84iXSfQe8r6gRC7FmKarXqs/U6tPzE9Ovqu4++sqWryv0HkNB0jgLmnCd4uJqbklxi0lbwTN4Km22ydVM1BCyB6FI/mPZJ4psrjQXKh49VTNIICZBUmfGWSOir8lvMqiu9Mw4NeYwol3XlmwJ1hhqRuanE7jTFCXqfh0Iw3TnAUnwD4kytJ6KcIsn9oHqY5PDdkfPZjVb8TqSXZTDUs5aZmBoqYUN2mj3/Ur4fWC4ackqjmNVBUakHpTDMq5aYmALKmFDdpo9/1K+H1ifFEpdPotJlqVSpRmTkpVsNsMtJslCR0H/2u8BwcUTNMy+yvqU1S5RiSkqLTXFyzDSLITyJPIkDzVb1vFXdemnpibdefcLjziytxZ3Uom5J9STFknFEVjIPFhRv7IkfLxUX+0Vp1TV5R13hAh6awBB2Fu5gCxiow+UEN9TB9ILQ9TAZsJ5nABEwv6fuEW3alXMYzDXMZRCZGVKhstfvOEefKED9xiIMgLvA+cWJ8E1PTJZEST4A5p2dmX1H0X4Y+yBEWHQzAxJKYQwVVsSzo5mafLKd5P1q2Sj9yiB84q+x7iSpYhxDP1mqTBfnZ19Tzyyd1HoOwAsAOgAibXHfWVyOVUhS21lJqNSQF2P5kNoUu3/Ll+kQEnlFTh1trCBOpRUreCFzA0vGxlsrVFRlLsqcVoIffhmyOn8xqt+IVAOyeGZRwCZmQLKfUP8lo9+6tkjzsI0cNGSU9mPVfxGoeJI4YknLTc2PdU8oalponrb8ytkjzsIezOLiFw7gajpwRlSxJKXJN+zicaSFS0oBpytDZ1d/iPu31PMbxFOxmJmPgXJvDMrSkNMpeaZCJCjydgvkGxP6E33Urc33MYZC5wyGaDFRaFONMqEhyKWx43ihbargLSbA6EWII007xXRX8QVGr1R+oVCcfnJyYWVvPvLKlrV3JO8PfwRVp6TzrkZXnIbqMpMSyx3sjxB92/vATMzmpBruVGKKUnVb9Lf8ADFt1hBUn7gRVxVB/cJI31EW5OJSttSFpCkqFiD1BiqPH0imn4lqkgkWTLTjzIHklxSR/EIPNA2Gt4I6bWN/tAG+kFtFQUA2gDaAOusAokD/eGoixrgym0TOQdJbQdZaYmWVeR8ZSv4UIrhl1crmneJt/0/sToeolfwk64A4y8ifl090qAQ59ClH/ACiLHT4/pB17L+gVBAJblqkptZ7c7Srf+kQSmxZ0xaTnrg046yurGH2kpM4toPSZV0fbPMj625f3RWPXZF6Vm3WnmlNOtqKVoULFKgbEEdCDpCDlNpufKHSyay6k68y9inGFSFBwTTnOWcn1aLmXN/ZpcbrcPWwPKPOwhvaEKaidS9Vg+5Kt+8phhXKt/sgK1CAeqrEgbAm0dfF+MKriVyWTOKal5GRb8Gn0+VSUSsk3+ltF9L7lRupR1USYqHSzgz2ma7R0YMwRJHDGC5VsMMybJ5XphA/8pB0B35AdSTzFRhjn5lTht0jUtalExjy7xFBIJVoYf3gqkXZvPOjLSklEozMPuEdAGlJH3WIYiTaUt0C0TZ4CsDOyNKqmN5xko9sAkpEkfmbSq7ix5FQSn9pgRKTpFVGZs0icxlW5ps3S/UZhxNuxdUR/MWW5vYkawllnX6+4vlXKyS/B11Lqhytj5qUmKtas4Vum6iT1N94Qc/vbeBt5QQMH6jSKgbwAL6wYFxeM0pBMASLhUPtwc1GYkM8aCGlK5ZrxZZ1I+JCmlHX5pSflDIMNErAF4k7wMYOfqWYLuKHGiJKisKCVkaKmHU8qU/JJUfp3gqb3w6xBHjepuCmMw/aMPTgNbfuqsSrSQWm19FlXRxXxJ9CbE6uxxH8QjVITM4VwLNpcnxducqbZBTL9Cho7FfdWyelztFGh0Ku4vrPsVIp07VZ51XMpDKC4o3OqlHprupR+cJDXiCyq5sD6wXgq1uDEucAcI9TnZMzOMK0ikrWj+3KyaA84k9OdR93Tsm/qIS17hExQw6s0au0ifav7vjhbC/mLKH3i+CKCWVfpjexKLWqwB17RJaQ4T8fuupS/MUKWRfVaptavsEQ6OX3ClhulvtzeLKq7WlpIPsrCCwxfso3K1D5pieCPPD3kvWMw662stuytCl3B7bPFNhbq23+pZ+idz0BsHodLkKJRpSk0yWblZKTZSyw0gWCEJFgP++sZ0qnSFJpzMhTZRiTlGEcjTLKAhCE9gBoIZ7iPzukMB0t+iUN9qaxM8jlABCkyQI/Ov/V+lHzOm8/Q1HHPmS1NzjGAKZMBbMksTFSUg3BeseRr9oJUfMjtEQ5lXOsnXeOxXJ5+em3pmYeceedWVuOOKupaibkk9STrHHKd4uDRbvpBRmU77aQQGveCM0J1jey2VaRggXjuYWos3Wqo1T5INBxV1LceXyNMtpF1OOKOiUJGpJ+5sDYOvlpgyr4zxLL0SjtJLzl1uvOaNS7Q/M64rolI+uw1MPLmDmnS8K4MRlflXNOIpTAUmpVpPuu1B0/4hQRsknQq6iwHui5b+v4skKVhp3BWCFuJpT1jVKmpBQ/WHB3G7cuPhb3O6tTYeHJKusWQ0TzqnDoIczJHN3E2W77jNMUxN0uYc55iQmE+4s7cyVD3kqt11HcGG0Si+to2oBTtGsY1PfA3EVl/X2EIqU07QJsgczc6Lt38nU6W9eWHRpldotTbDlNq8hOIOymJlDgP0MVgMzDiNlfO8KWZ9aNUmx7jQxn5tTktAfmpZhBW/MMtJHVawkfePG4rzay+w02v8QxPIuPJF/AlV+O6fLlRe3ztFerlTfcFlrUr/cSf5hM5NrUCL2HlDod0jc2OJ2q1Jl6m4LlnKRLKukzrpBmVD/SBdLfrqfSIzVWcenHnHnnVuuOKKlrWoqUpROpJOpJ7mMnFKXurbaNC081o1OOJ2c5xF7nWE6kWBjprb3hM63oTa0TFlc1aI1KBF4WuotftCVabEiM2K2N6bWjqSc0+1KuyrbikNPlJdSk25+U3APcA622vr0Ecxu28KmCBbpFgXNnmNjG9sX1hKyrvClCtI3GK3oAAt1gwBGKVXg0nS14qMgB1+0HbcQQMAGwMAYAtqNYHKTrGN/rBBWkAZA20jEp3tB30OojFStb94DW4nS94SuiFKyLbwldVeIsJHQLGEi0+kKnlXuN4SOG+/wBoxW4//9k=" + + const desiredCommands: T.ChatBotCommand[] = [ + ...(grokEnabled ? [{type: "command" as const, keyword: "grok", label: "Ask Grok"}] : []), + {type: "command", keyword: "team", label: "Switch to team"}, + ] + + // Step 1: Init main bot via bot.run() + log("Initializing main bot...") + const [chat, mainUser, mainAddress] = await bot.run({ + profile: {displayName: "Ask SimpleX Team", fullName: "", image: supportImage}, + dbOpts: config.db, + options: { + addressSettings: { + businessAddress: true, + autoAccept: true, + welcomeMessage, + }, + commands: desiredCommands, + useBotProfile: true, + updateProfile: false, + }, + events: { + acceptingBusinessRequest: (evt) => supportBot?.onBusinessRequest(evt), + newChatItems: (evt) => supportBot?.onNewChatItems(evt), + chatItemUpdated: (evt) => supportBot?.onChatItemUpdated(evt), + chatItemReaction: (evt) => supportBot?.onChatItemReaction(evt), + leftMember: (evt) => supportBot?.onLeftMember(evt), + joinedGroupMember: (evt) => supportBot?.onJoinedGroupMember(evt), + connectedToGroupMember: (evt) => supportBot?.onMemberConnected(evt), + newMemberContactReceivedInv: (evt) => supportBot?.onMemberContactReceivedInv(evt), + contactConnected: (evt) => supportBot?.onContactConnected(evt), + contactSndReady: (evt) => supportBot?.onContactSndReady(evt), + }, + }) + log(`Main bot user: ${mainUser.profile.displayName} (userId=${mainUser.userId})`) + + // Step 2: Resolve Grok profile from same ChatApi instance. + // Identify Grok strictly by the persisted userId in state.json. If no ID + // is persisted, this is a first-time run — create the user and persist. + let grokUser: T.User | null = null + if (grokEnabled) { + log("Resolving Grok profile...") + if (state.grokUserId !== undefined) { + const users = await chat.apiListUsers() + grokUser = users.find(u => u.user.userId === state.grokUserId)?.user ?? null + if (!grokUser) { + throw new Error( + `Persisted Grok userId=${state.grokUserId} not found in DB. ` + + `Either restore the user or delete state.json to re-create Grok.` + ) + } + } else { + log("Creating Grok profile...") + grokUser = await chat.apiCreateActiveUser({displayName: "Grok", fullName: "", image: grokImage}) + // apiCreateActiveUser sets Grok as active — switch back to main + await chat.apiSetActiveUser(mainUser.userId) + state.grokUserId = grokUser.userId + writeState(stateFilePath, state) + log(`Persisted Grok userId=${grokUser.userId}`) + } + + // Refresh Grok's profile if it has drifted from the canonical values. + const grokProfile: T.Profile = {displayName: "Grok", fullName: "", image: grokImage} + const currentProfile = util.fromLocalProfile(grokUser.profile) + if (currentProfile.image !== grokProfile.image || currentProfile.displayName !== grokProfile.displayName || currentProfile.fullName !== grokProfile.fullName) { + log("Grok profile changed, updating...") + await chat.apiSetActiveUser(grokUser.userId) + const summary = await chat.apiUpdateProfile(grokUser.userId, grokProfile) + await chat.apiSetActiveUser(mainUser.userId) + if (summary) { + log(`Grok profile updated: ${summary.updateSuccesses} contact(s) updated, ${summary.updateFailures} failed`) + } else { + log("Unexpected: Grok profile did not change") + } + } + log(`Grok profile: ${grokUser.profile.displayName} (userId=${grokUser.userId})`) + } + + // Step 3: Read state file + // Step 4: Enable auto-accept DM contacts + await chat.apiSetAutoAcceptMemberContacts(mainUser.userId, true) + log("Auto-accept member contacts enabled") + + // Step 5: Resolve Grok contact by ID. Avoid apiListContacts — it loads + // every contact in one response and OOMs the native binding on large DBs. + // Always restore grokContactId so the one-way gate can find and remove + // Grok members even when Grok API is disabled. + if (typeof state.grokContactId === "number") { + const found = await getContact(chat, state.grokContactId) + if (found) { + config.grokContactId = found.contactId + log(`Grok contact from state: ID=${config.grokContactId}`) + } else { + log(`Persisted Grok contact ID=${state.grokContactId} not found`) + } + } + + if (grokEnabled) { + if (config.grokContactId === null) { + log("Establishing bot↔Grok contact...") + const invLink = await chat.apiCreateLink(mainUser.userId) + // Switch to Grok profile to connect + await profileMutex.runExclusive(async () => { + await chat.apiSetActiveUser(grokUser!.userId) + await chat.apiConnectActiveUser(invLink) + await chat.apiSetActiveUser(mainUser.userId) + }) + log("Grok connecting...") + + const grokProfileName = grokUser!.profile.displayName + const evt = await chat.wait( + "contactConnected", + (e) => + e.user.userId === mainUser.userId + && e.contact.profile.displayName === grokProfileName, + 60_000, + ) + if (!evt) { + console.error(`Timeout waiting for Grok contact (60s, displayName="${grokProfileName}"). Exiting.`) + process.exit(1) + } + config.grokContactId = evt.contact.contactId + state.grokContactId = config.grokContactId + writeState(stateFilePath, state) + log(`Grok contact established: ID=${config.grokContactId}`) + } + } + + // Step 6: Resolve team group by ID. Avoid apiListGroups — it loads every + // group in one response and OOMs the native binding on large DBs. + log("Resolving team group...") + let existingGroup: T.GroupInfo | null = null + + if (typeof state.teamGroupId === "number") { + existingGroup = await getGroupInfo(chat, state.teamGroupId) + if (existingGroup) { + config.teamGroup.id = existingGroup.groupId + log(`Team group from state: ${config.teamGroup.id}:${existingGroup.groupProfile.displayName}`) + } else { + log(`Persisted team group ID=${state.teamGroupId} not found, will create`) + } + } + + const teamGroupPreferences: T.GroupPreferences = { + directMessages: {enable: T.GroupFeatureEnabled.On}, + fullDelete: {enable: T.GroupFeatureEnabled.On}, + commands: [ + {type: "command", keyword: "join", label: "Join customer chat", params: "groupId"}, + ], + } + + if (config.teamGroup.id === 0) { + log(`Creating team group "${config.teamGroup.name}"...`) + const newGroup = await chat.apiNewGroup(mainUser.userId, { + displayName: config.teamGroup.name, + fullName: "", + groupPreferences: teamGroupPreferences, + }) + config.teamGroup.id = newGroup.groupId + state.teamGroupId = config.teamGroup.id + writeState(stateFilePath, state) + log(`Team group created: ${config.teamGroup.id}:${config.teamGroup.name}`) + } else if (existingGroup) { + // Only update profile if preferences or name changed + const prefs = existingGroup.fullGroupPreferences + const needsUpdate = + existingGroup.groupProfile.displayName !== config.teamGroup.name || + prefs.directMessages?.enable !== T.GroupFeatureEnabled.On || + prefs.fullDelete?.enable !== T.GroupFeatureEnabled.On || + JSON.stringify(prefs.commands) !== JSON.stringify(teamGroupPreferences.commands) + if (needsUpdate) { + await chat.apiUpdateGroupProfile(config.teamGroup.id, { + displayName: config.teamGroup.name, + fullName: "", + groupPreferences: teamGroupPreferences, + }) + log("Team group profile updated") + } + } + + // Step 7: Ensure direct messages enabled (done via groupPreferences above) + + // Step 8: Create team group invite link (best-effort — bot works without it) + let inviteLinkCreated = false + try { + try { await chat.apiDeleteGroupLink(config.teamGroup.id) } catch {} + const teamGroupInviteLink = await chat.apiCreateGroupLink( + config.teamGroup.id, T.GroupMemberRole.Member + ) + inviteLinkCreated = true + log("Team group invite link created") + console.log(`\nTeam group invite link (expires in 10 min):\n${teamGroupInviteLink}\n`) + } catch (err) { + logError("Failed to create team group invite link (SMP relay may be unreachable). Bot will continue without it.", err) + } + + let inviteLinkDeleted = false + async function deleteInviteLink(): Promise { + if (inviteLinkDeleted) return + inviteLinkDeleted = true + try { + await profileMutex.runExclusive(async () => { + await chat.apiSetActiveUser(mainUser.userId) + await chat.apiDeleteGroupLink(config.teamGroup.id) + }) + log("Team group invite link deleted") + } catch (err) { + logError("Failed to delete invite link", err) + } + } + let inviteLinkTimer: ReturnType | undefined + if (inviteLinkCreated) { + inviteLinkTimer = setTimeout(async () => { + log("10 minutes elapsed, deleting invite link...") + await deleteInviteLink() + }, 10 * 60 * 1000) + inviteLinkTimer.unref() + } + + // Step 9: Validate team members (lookup by ID, one round-trip per member) + if (config.teamMembers.length > 0) { + log("Validating team members...") + for (const member of config.teamMembers) { + const contact = await getContact(chat, member.id) + if (!contact) { + console.error(`Team member not found: ID=${member.id}`) + process.exit(1) + } + if (contact.profile.displayName !== member.name) { + console.error(`Team member name mismatch: expected "${member.name}", got "${contact.profile.displayName}" (ID=${member.id})`) + process.exit(1) + } + log(`Team member validated: ${member.id}:${member.name}`) + } + } + + // Load Grok context and build API client only if enabled + let grokApi: GrokApiClient | null = null + if (grokEnabled) { + let initialContext: GrokMessage[] = [] + if (config.contextFile) { + try { + initialContext = loadGrokContext(config.contextFile) + log(`Loaded Grok context: ${initialContext.length} message(s) from ${config.contextFile}`) + } catch (err) { + const e = err as NodeJS.ErrnoException + if (e.code === "ENOENT") { + log(`Warning: context file not found: ${config.contextFile}`) + } else { + logError(`Failed to load Grok context file ${config.contextFile}`, err) + throw err + } + } + } + grokApi = new GrokApiClient(config.grokApiKey, config.aiUrl, config.aiModel, initialContext) + } + + // Create SupportBot + supportBot = new SupportBot(chat, grokApi, config, mainUser.userId, grokUser?.userId ?? null, desiredCommands) + + if (mainAddress) { + supportBot.businessAddress = util.contactAddressStr(mainAddress.connLinkContact) + log(`Business address: ${supportBot.businessAddress}`) + } + + // Step 10: Register Grok event handlers (filtered by profile in handler) + if (grokEnabled) { + chat.on("receivedGroupInvitation", (evt) => supportBot?.onGrokGroupInvitation(evt)) + chat.on("connectedToGroupMember", (evt) => supportBot?.onGrokMemberConnected(evt)) + chat.on("newChatItems", (evt) => supportBot?.onGrokNewChatItems(evt)) + } + + // Step 10b: Refresh stale cards from before restart + await supportBot.cards.refreshAllCards() + + log("SupportBot initialized. Bot running.") + + // Step 11: Graceful shutdown + async function shutdown(signal: string): Promise { + log(`Received ${signal}, shutting down...`) + clearTimeout(inviteLinkTimer) + supportBot?.cards.destroy() + await deleteInviteLink() + process.exit(0) + } + process.on("SIGINT", () => shutdown("SIGINT")) + process.on("SIGTERM", () => shutdown("SIGTERM")) +} + +main().catch(err => { + logError("Fatal error", err) + process.exit(1) +}) diff --git a/bots/typescript/simplex-support-bot/src/messages.ts b/bots/typescript/simplex-support-bot/src/messages.ts new file mode 100644 index 0000000..c35789d --- /dev/null +++ b/bots/typescript/simplex-support-bot/src/messages.ts @@ -0,0 +1,44 @@ +import {isWeekend} from "./util.js" + +export const welcomeMessage = `Hello! This is a *SimpleX team* support bot - not an AI. +*Join public groups* at https://simplex.chat/directory or [via directory bot](https://smp4.simplex.im/a#lXUjJW5vHYQzoLYgmi8GbxkGP41_kjefFvBrdwg-0Ok) +Please ask any questions about SimpleX Chat.` + +export function queueMessage(timezone: string, grokEnabled: boolean): string { + const hours = isWeekend(timezone) ? "48" : "24" + const base = `The team will reply to your message within ${hours} hours.` + if (!grokEnabled) return base + return `${base} + +If your question is about SimpleX, click /grok for an *instant Grok answer*. + +Send /team to switch back.` +} + +export const grokActivatedMessage = `*You are now chatting with Grok* - use any language.` + +export function teamAddedMessage(timezone: string, grokPresent: boolean): string { + const hours = isWeekend(timezone) ? "48" : "24" + const base = `We will reply within ${hours} hours.` + if (!grokPresent) return base + return `${base} +Grok will be answering your questions until then.` +} + +export const teamAlreadyInvitedMessage = "A team member was invited to this conversation and will reply when available." + +export const teamLockedMessage = "Only the team will now receive your messages." + +export function noTeamMembersMessage(grokEnabled: boolean): string { + return grokEnabled + ? "No team members are available yet. Please try again later or click /grok." + : "No team members are available yet. Please try again later." +} + +export const grokInvitingMessage = "Inviting Grok, please wait..." + +export const grokUnavailableMessage = "Grok is temporarily unavailable. Please try again later or send /team for a human team member." + +export const grokErrorMessage = "Sorry, I couldn't process that. Please try again or send /team for a human team member." + +export const grokNoHistoryMessage = "I just joined but couldn't see your earlier messages. Could you repeat your question?" diff --git a/bots/typescript/simplex-support-bot/src/util.ts b/bots/typescript/simplex-support-bot/src/util.ts new file mode 100644 index 0000000..f9a2319 --- /dev/null +++ b/bots/typescript/simplex-support-bot/src/util.ts @@ -0,0 +1,51 @@ +import {Mutex} from "async-mutex" +import {api, core} from "simplex-chat" +import {T} from "@simplex-chat/types" + +export const profileMutex = new Mutex() + +export function isChatNotFound(err: unknown, kind: "group" | "contact"): boolean { + if (!(err instanceof core.ChatAPIError)) return false + if (err.chatError?.type !== "errorStore") return false + const seType = err.chatError.storeError.type + return kind === "group" ? seType === "groupNotFound" : seType === "contactNotFound" +} + +export async function getGroupInfo(chat: api.ChatApi, groupId: number): Promise { + try { + const c = await chat.apiGetChat(T.ChatType.Group, groupId, 0) + return c.chatInfo.type === "group" ? c.chatInfo.groupInfo : null + } catch (err) { + if (isChatNotFound(err, "group")) return null + throw err + } +} + +export async function getContact(chat: api.ChatApi, contactId: number): Promise { + try { + const c = await chat.apiGetChat(T.ChatType.Direct, contactId, 0) + return c.chatInfo.type === "direct" ? c.chatInfo.contact : null + } catch (err) { + if (isChatNotFound(err, "contact")) return null + throw err + } +} + +export function isWeekend(timezone: string): boolean { + const day = new Intl.DateTimeFormat("en-US", {timeZone: timezone, weekday: "short"}).format(new Date()) + return day === "Sat" || day === "Sun" +} + +export function log(msg: string, ...args: unknown[]): void { + const ts = new Date().toISOString() + if (args.length > 0) { + console.log(`[${ts}] ${msg}`, ...args) + } else { + console.log(`[${ts}] ${msg}`) + } +} + +export function logError(msg: string, err: unknown): void { + const ts = new Date().toISOString() + console.error(`[${ts}] ERROR: ${msg}`, err) +} diff --git a/bots/typescript/simplex-support-bot/tsconfig.json b/bots/typescript/simplex-support-bot/tsconfig.json new file mode 100644 index 0000000..821fa66 --- /dev/null +++ b/bots/typescript/simplex-support-bot/tsconfig.json @@ -0,0 +1,23 @@ +{ + "include": ["src"], + "compilerOptions": { + "declaration": true, + "forceConsistentCasingInFileNames": true, + "lib": ["ES2022"], + "module": "Node16", + "moduleResolution": "Node16", + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noEmitOnError": true, + "outDir": "dist", + "sourceMap": true, + "strict": true, + "strictNullChecks": true, + "target": "ES2022", + "types": ["node"] + } +} diff --git a/manager/.gitkeep b/manager/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/web/data/.gitkeep b/web/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..1a916fd --- /dev/null +++ b/web/index.html @@ -0,0 +1,794 @@ + + + + + + SimpleXXX Directory + + + + + + +
+
+ SimpleXXX Directory +
+
+ +
+

SimpleXXX Directory

+ + +
+ + +
+ + +
+ +
+ + + +
+
+ +
+
+ +
+ + + +