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 <noreply@anthropic.com>
This commit is contained in:
Jon
2026-06-03 00:39:08 +01:00
commit 5c80ac310f
33 changed files with 6780 additions and 0 deletions

12
.gitignore vendored Normal file
View File

@@ -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/

51
README.md Normal file
View File

@@ -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.

61
bots/haskell/README.md Normal file
View File

@@ -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
```

View File

@@ -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)

View File

@@ -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.")

View File

@@ -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

View File

@@ -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)

View File

@@ -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')
]

View File

@@ -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"

View File

@@ -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

View File

@@ -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"

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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.")

View File

@@ -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

View File

@@ -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;
|]

View File

@@ -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;
|]

View File

@@ -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_

View File

@@ -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"

View File

@@ -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 `<prefix>_chat.db`, `<prefix>_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.

View File

@@ -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"
}

View File

@@ -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<string> {
const out = new Set<string>()
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<string, number>()
// Buffered invitations that arrived before pendingGrokJoins was set (race condition)
private bufferedGrokInvitations = new Map<string, CEvt.ReceivedGroupInvitation>()
// mainGroupId → grokLocalGroupId
private grokGroupMap = new Map<number, number>()
// grokLocalGroupId → mainGroupId
private reverseGrokMap = new Map<number, number>()
// mainGroupId → resolve fn for grok join
private grokJoinResolvers = new Map<number, () => void>()
// mainGroupIds where Grok connectedToGroupMember fired
private grokFullyConnected = new Set<number>()
// Suppress per-message Grok responses while activateGrok sends the initial combined response
private grokInitialResponsePending = new Set<number>()
// Pending DMs for team group members (contactId → message)
private pendingTeamDMs = new Map<number, string>()
// Contacts that already received the team DM (dedup)
private sentTeamDMs = new Set<number>()
// Tracked fire-and-forget operations (for testing)
private _pendingOps: Promise<void>[] = []
// 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<number>()
// 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<string>
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<void> {
while (this._pendingOps.length > 0) {
const ops = this._pendingOps.splice(0)
await Promise.allSettled(ops)
}
}
private fireAndForget(op: Promise<void>): 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<R>(fn: () => Promise<R>): Promise<R> {
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<void> {
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<R>(fn: () => Promise<R>): Promise<R> {
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<void> {
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<void> {
// 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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
if (evt.user.userId !== this.mainUserId) return
await this.deliverPendingDM(evt.contact.contactId)
}
async onContactSndReady(evt: CEvt.ContactSndReady): Promise<void> {
if (evt.user.userId !== this.mainUserId) return
await this.deliverPendingDM(evt.contact.contactId)
}
private async deliverPendingDM(contactId: number): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<number, T.AChatItem>()
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
// 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<T.GroupMember | null> {
// 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<void> {
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<boolean> {
if (this.grokFullyConnected.has(groupId)) return Promise.resolve(true)
return new Promise<boolean>((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<void> {
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)
}
}

View File

@@ -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<space> 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<number>()
private flushInterval: NodeJS.Timeout
// Outer lock; profileMutex (via withMainProfile) is the inner lock.
private customDataMutexes = new Map<number, Mutex>()
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<R>(fn: () => Promise<R>): Promise<R> {
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<void> {
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<void> {
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<void> {
const groupInfo = await this.withMainProfile(() => getGroupInfo(this.chat, groupId))
if (!groupInfo) return
const data = groupInfo.customData as Record<string, unknown> | undefined
if (typeof data?.cardItemId === "number") {
await this.updateCard(groupId)
} else {
await this.createCard(groupId, groupInfo)
}
}
async refreshAllCards(): Promise<void> {
// 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<string, unknown> | 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<GroupComposition> {
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<ConversationState> {
const data = await this.getRawCustomData(groupId)
return data?.state ?? "WELCOME"
}
async getLastCustomerMessageTime(groupId: number, customerId: string): Promise<number | undefined> {
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<number | undefined> {
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<Partial<CardData> | null> {
const group = await this.withMainProfile(() => getGroupInfo(this.chat, groupId))
if (!group?.customData) return null
const data = group.customData as Record<string, unknown>
const result: Partial<CardData> = {}
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<CardData>): Promise<void> {
return this.getCustomDataMutex(groupId).runExclusive(async () => {
const current = (await this.getRawCustomData(groupId)) ?? {}
const merged: Partial<CardData> = {...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<void> {
return this.getCustomDataMutex(groupId).runExclusive(() =>
this.withMainProfile(() => this.chat.apiSetGroupCustomData(groupId))
)
}
// --- Chat history access ---
async getChat(groupId: number, count: number): Promise<T.AChat> {
return this.withMainProfile(() => this.chat.apiGetChat(T.ChatType.Group, groupId, count))
}
// --- Internal ---
private async updateCard(groupId: number): Promise<void> {
const groupInfo = await this.withMainProfile(() => getGroupInfo(this.chat, groupId))
if (!groupInfo) return
const customData = groupInfo.customData as Record<string, unknown> | 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<CardData> = {
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 <id>' 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<string> {
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<string> {
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`
}
}

View File

@@ -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 <name>", "team group display name")
.option("--state-file <path>", "state JSON path", "./data/state.json")
.option("--sqlite-file-prefix <path>", "SQLite DB file prefix", "./data/simplex")
.option("--sqlite-key <key>", "SQLCipher encryption key (default: unencrypted)")
.option("--pg-conn <conn>", "PostgreSQL connection string (required for postgres)")
.option("--pg-schema <prefix>", "PostgreSQL schema prefix (default: simplex_v1)")
.option("-a, --auto-add-team-members <list>", "comma-separated ID:name pairs (e.g. 1:Alice,2:Bob)")
.option("--timezone <iana>", "IANA timezone for weekend detection", "UTC")
.option("--complete-hours <n>", "auto-complete chats after N hours idle (0 disables)", parseNonNegativeInt("--complete-hours"), 3)
.option("--card-flush-seconds <n>", "debounce card state writes", parseNonNegativeInt("--card-flush-seconds"), 300)
.option("--context-file <path>", "text file with AI system context (required if AI_API_KEY set)")
.option("--ai-url <url>", "OpenAI-compatible API base URL", "https://api.x.ai/v1")
.option("--ai-model <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<RawOpts>()
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,
}
}

View File

@@ -0,0 +1,59 @@
import {readFileSync} from "fs"
import {parse as parseYaml} from "yaml"
import {GrokMessage} from "./grok.js"
const ALLOWED_ROLES: ReadonlySet<GrokMessage["role"]> = 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<GrokMessage["role"]> = 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
}

View File

@@ -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<string> {
const headers: Record<string, string> = {"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<string> {
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},
])
}
}

File diff suppressed because one or more lines are too long

View File

@@ -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?"

View File

@@ -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<T.GroupInfo | null> {
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<T.Contact | null> {
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)
}

View File

@@ -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"]
}
}

0
manager/.gitkeep Normal file
View File

0
web/data/.gitkeep Normal file
View File

794
web/index.html Normal file
View File

@@ -0,0 +1,794 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SimpleXXX Directory</title>
<meta name="description" content="Find communities on the SimpleXXX network">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' fill='%230053D0'/%3E%3Cg transform='translate(50,50) rotate(45)'%3E%3Crect x='-34' y='-9' width='68' height='18' fill='%2302C0FF'/%3E%3Crect x='-9' y='-34' width='18' height='68' fill='%2302C0FF'/%3E%3Crect x='-20' y='-5' width='40' height='10' fill='%230053D0'/%3E%3Crect x='-5' y='-20' width='10' height='40' fill='%230053D0'/%3E%3C/g%3E%3C/svg%3E">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f5f5f7;
--card-bg: #ffffff;
--text: #1d1d1f;
--muted: #6e6e73;
--accent: #0053D0;
--border: #e0e0e5;
--shadow: 0px 20px 30px rgba(0,0,0,0.12);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #111827;
--card-bg: #0B2A59;
--text: #f5f5f7;
--muted: #9ca3af;
--accent: #70F0F9;
--border: #1e3a5f;
--shadow: none;
}
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
header {
background: var(--card-bg);
border-bottom: 1px solid var(--border);
padding: 14px 24px;
position: sticky;
top: 0;
z-index: 10;
}
.header-inner {
max-width: 860px;
margin: 0 auto;
display: flex;
align-items: center;
gap: 10px;
}
.logo-text {
font-size: 18px;
font-weight: 700;
color: var(--accent);
letter-spacing: -0.5px;
}
.container {
max-width: 860px;
margin: 0 auto;
padding: 40px 20px 60px;
}
h1 {
font-size: clamp(28px, 5vw, 38px);
font-weight: 700;
text-align: center;
color: var(--accent);
margin-bottom: 20px;
}
/* ── Section tabs: Groups / Channels ── */
.section-tabs {
display: flex;
border-bottom: 2px solid var(--border);
margin-bottom: 20px;
}
.sec-btn {
padding: 10px 24px;
background: transparent;
border: none;
border-bottom: 3px solid transparent;
margin-bottom: -2px;
font-size: 15px;
font-weight: 600;
font-family: inherit;
color: var(--muted);
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.sec-btn:hover { color: var(--accent); }
.sec-btn.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.tab-count {
font-size: 11px;
font-weight: 600;
background: var(--border);
color: var(--muted);
border-radius: 10px;
padding: 1px 6px;
margin-left: 6px;
}
.sec-btn.active .tab-count {
background: var(--accent);
color: #fff;
}
@media (prefers-color-scheme: dark) {
.sec-btn.active .tab-count { color: #000; }
}
/* ── Search + sort controls (inline, matching original) ── */
.search-container {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
#search {
flex-grow: 1;
padding: 8px 12px 8px 36px;
font-size: 15px;
font-family: inherit;
border: none;
border-radius: 10px;
background: var(--card-bg);
color: var(--text);
outline: solid 1px rgba(0,0,0,0.08);
transition: box-shadow 0.2s;
background-image: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGcgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZT0iIzg4ODg4OCI+CjxjaXJjbGUgY3g9IjEwLjUiIGN5PSIxMC41IiByPSI3LjUiIC8+CjxsaW5lIHgxPSIxNiIgeTE9IjE2IiB4Mj0iMjEiIHkyPSIyMSIgLz4KPC9nPgo8L3N2Zz4=');
background-position: 8px center;
background-repeat: no-repeat;
background-size: 18px;
}
#search::placeholder { color: #8e8e93; }
@media (prefers-color-scheme: dark) {
#search {
background-color: #0B2A50;
color: #fff;
outline: none;
background-image: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGcgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZT0iI2JiYmJiYiI+CjxjaXJjbGUgY3g9IjEwLjUiIGN5PSIxMC41IiByPSI3LjUiIC8+CjxsaW5lIHgxPSIxNiIgeTE9IjE2IiB4Mj0iMjEiIHkyPSIyMSIgLz4KPC9nPgo8L3N2Zz4=');
}
#search::placeholder { color: #8e8e93; }
}
/* Sort buttons styled like the original pagination text-btns */
.sort-tabs {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.sort-tabs button {
padding: 8px 16px;
border: none;
background: transparent;
color: #374151;
cursor: pointer;
border-radius: 20px;
font-size: 14px;
font-family: inherit;
height: 40px;
display: flex;
align-items: center;
transition: background 0.2s;
}
.sort-tabs button:hover { background: #f3f4f6; }
@media (prefers-color-scheme: dark) {
.sort-tabs button { color: #70F0F9; }
.sort-tabs button:hover { background: #0B2A50; }
}
.sort-tabs button.active { font-weight: bold; }
/* ── Entry cards — matching original exactly ── */
#directory .entry {
display: flex;
align-items: flex-start;
margin-bottom: 32px;
padding: 16px;
word-break: break-word;
border-radius: 4px;
overflow: hidden;
box-shadow: var(--shadow);
background: var(--card-bg);
}
#directory .entry a.img-link {
order: -1;
flex-shrink: 0;
margin-right: 16px;
margin-bottom: 16px;
}
#directory .entry a.img-link img {
min-width: 104px;
min-height: 104px;
width: 104px;
height: 104px;
border-radius: 24px;
object-fit: cover;
display: block;
}
#directory .entry .text-container { flex: 1; min-width: 0; }
#directory .entry h2 {
font-size: 18px;
font-weight: 700;
margin: 0 0 5px 0;
}
#directory .entry p {
font-size: 14px;
line-height: 1.55;
margin: 0 0 5px 0;
color: var(--muted);
}
#directory .entry p a { color: var(--accent); }
#directory .entry .secret { filter: blur(5px); cursor: pointer; transition: filter 0.1s; user-select: none; }
#directory .entry .secret.visible { filter: none; user-select: auto; }
#directory .entry .read-more { color: #0053D0; text-decoration: underline; cursor: pointer; }
#directory .entry .read-less { color: darkgray; cursor: pointer; }
@media (prefers-color-scheme: dark) {
#directory .entry .read-more { color: #70F0F9; }
}
#directory .entry .small-text { font-size: 0.8em; color: #888; }
@media (prefers-color-scheme: dark) { #directory .entry .small-text { color: #999; } }
#directory .entry .red { color: #DD0000; }
#directory .entry .green { color: #20BD3D; }
#directory .entry .blue { color: #0053d0; }
#directory .entry .cyan { color: #0AC4D1; }
#directory .entry .yellow { color: #DEBD00; }
#directory .entry .magenta { color: magenta; }
@media (prefers-color-scheme: dark) {
#directory .entry .green { color: #4DDA67; }
#directory .entry .blue { color: #00A2FF; }
#directory .entry .cyan { color: #70F0F9; }
#directory .entry .yellow { color: #FFD700; }
}
#status {
text-align: center;
color: var(--muted);
font-size: 13px;
padding: 12px;
}
/* ── Pagination — matching original ── */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 4px;
margin-top: 20px;
padding: 10px 0;
}
.pagination button {
padding: 8px 12px;
border: none;
background: transparent;
color: #374151;
cursor: pointer;
border-radius: 50%;
font-size: 14px;
font-family: inherit;
min-width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.pagination button:hover { background: #f3f4f6; }
@media (prefers-color-scheme: dark) {
.pagination button { color: #70F0F9; }
.pagination button:hover { background: #0B2A50; }
}
.pagination button.active { font-weight: bold; color: #0B2A59; }
@media (prefers-color-scheme: dark) {
.pagination button.active { color: #70F0F9; }
}
.pagination button.text-btn {
border-radius: 20px;
min-width: auto;
height: 40px;
padding: 8px 16px;
}
.pagination button:disabled { opacity: 0.5; cursor: not-allowed; }
@media (max-width: 640px) {
#directory .entry { flex-direction: column; }
#directory .entry a.img-link { margin-right: 0; }
#directory .entry a.img-link img { width: 72px; height: 72px; min-width: 72px; min-height: 72px; border-radius: 16px; }
.search-container { flex-direction: column; align-items: stretch; }
.sort-tabs { justify-content: center; }
}
</style>
</head>
<body>
<header>
<div class="header-inner">
<span class="logo-text">SimpleXXX Directory</span>
</div>
</header>
<div class="container">
<h1>SimpleXXX Directory</h1>
<!-- Groups / Channels tabs -->
<div class="section-tabs">
<button class="sec-btn active" data-section="group">
Groups <span class="tab-count" id="count-group">0</span>
</button>
<button class="sec-btn" data-section="channel">
Channels <span class="tab-count" id="count-channel">0</span>
</button>
</div>
<!-- Search + sort inline -->
<div class="search-container">
<input id="search" autocomplete="off" placeholder="Search…">
<div class="sort-tabs">
<button class="live">Active</button>
<button class="new">New</button>
<button class="top">All</button>
</div>
</div>
<div id="status"></div>
<div id="directory"></div>
<div id="bottom-pagination" class="pagination"></div>
</div>
<script>
(function () {
const DATA_URL = './data/';
let allEntries = [];
let sectionEntries = [];
let filteredEntries = [];
let currentSection = 'group';
let currentSortMode = '';
let currentSearch = '';
let currentPage = 1;
async function init() {
const listing = await fetchJSON(DATA_URL + 'listing.json');
if (!listing) {
document.getElementById('status').textContent = 'Failed to load directory data.';
return;
}
allEntries = listing.entries || [];
const groupCount = allEntries.filter(e => entrySection(e) === 'group').length;
const channelCount = allEntries.filter(e => entrySection(e) === 'channel').length;
document.getElementById('count-group').textContent = groupCount;
document.getElementById('count-channel').textContent = channelCount;
document.querySelectorAll('.sec-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.sec-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentSection = btn.dataset.section;
currentSortMode = ''; currentSearch = ''; currentPage = 1;
document.getElementById('search').value = '';
topBtn.click();
});
});
const liveBtn = document.querySelector('.sort-tabs .live');
const newBtn = document.querySelector('.sort-tabs .new');
const topBtn = document.querySelector('.sort-tabs .top');
const searchInput = document.getElementById('search');
applyHash();
searchInput.addEventListener('input', e =>
renderEntries('top', bySortPriority, topBtn, e.target.value.trim(), true));
liveBtn.addEventListener('click', () => renderEntries('live', byActiveAtDesc, liveBtn));
newBtn.addEventListener('click', () => renderEntries('new', byCreatedAtDesc, newBtn));
topBtn.addEventListener('click', () => renderEntries('top', bySortPriority, topBtn));
window.addEventListener('popstate', applyHash);
function applyHash() {
const hash = location.hash;
let mode, cmp, btn, search = '';
switch (hash) {
case '#active': mode='live'; cmp=byActiveAtDesc; btn=liveBtn; break;
case '#new': mode='new'; cmp=byCreatedAtDesc; btn=newBtn; break;
default:
mode='top'; cmp=bySortPriority; btn=topBtn;
try {
if (hash.startsWith('#q=')) {
search = decodeURIComponent(hash.slice(3));
if (search) searchInput.value = search;
}
} catch(e) {}
}
currentSortMode = ''; currentSearch = ''; currentPage = 1;
renderEntries(mode, cmp, btn, search);
}
function renderEntries(mode, cmp, btn, search = '', fromInput = false) {
if (currentSortMode === mode && search === currentSearch && !fromInput) return;
currentSortMode = mode;
const hash = search ? '#q=' + encodeURIComponent(search)
: mode === 'live' ? '#active'
: mode === 'new' ? '#new' : '';
history.replaceState(null, '', hash || location.pathname + location.search);
document.querySelectorAll('.sort-tabs button').forEach(b => b.classList.remove('active'));
if (!search) {
currentSearch = ''; currentPage = 1;
searchInput.value = '';
btn.classList.add('active');
} else {
currentSearch = search; currentPage = 1;
}
sectionEntries = allEntries.filter(e => entrySection(e) === currentSection);
filteredEntries = filterEntries(sectionEntries, mode, search).sort(cmp);
renderPage();
}
}
function entrySection(e) {
const t = e.entryType;
if (!t) return 'group';
if (t.type === 'channel') return 'channel';
if (t.groupType === 'channel') return 'channel';
return 'group';
}
function renderPage() {
const entries = addPagination(filteredEntries);
displayEntries(entries);
}
function filterEntries(entries, mode, s) {
const q = s.toLowerCase();
return entries.filter(e =>
(mode === 'top' || (mode === 'new' && e.createdAt) || (mode === 'live' && e.activeAt)) &&
(q === '' ||
(e.displayName || '').toLowerCase().includes(q) ||
includesQuery(e.shortDescr, q) ||
includesQuery(e.welcomeMessage, q))
);
}
function includesQuery(field, q) {
if (!field || !Array.isArray(field)) return false;
return field.some(ft => {
switch (ft.format?.type) {
case 'uri': return !ft.text?.toLowerCase().includes('simplex') && ft.text?.toLowerCase().includes(q);
case 'hyperLink': return ft.format.showText?.toLowerCase().includes(q) || ft.format.linkUri?.toLowerCase().includes(q);
case 'simplexLink': return ft.format.showText?.toLowerCase().includes(q);
default: return ft.text?.toLowerCase().includes(q);
}
});
}
function entryMemberCount(e) {
return (e.entryType?.type === 'group' || e.entryType?.groupType)
? (e.entryType.summary?.publicMemberCount ?? e.entryType.summary?.currentMembers ?? 0)
: 0;
}
function bySortPriority(a, b) { return entryMemberCount(b) - entryMemberCount(a); }
function roundedTs(s) { try { return new Date(s).valueOf(); } catch { return 0; } }
function byActiveAtDesc(a, b) { return (roundedTs(b.activeAt) - roundedTs(a.activeAt)) * 10 + Math.sign(bySortPriority(a, b)); }
function byCreatedAtDesc(a, b) { return (roundedTs(b.createdAt) - roundedTs(a.createdAt)) * 10 + Math.sign(bySortPriority(a, b)); }
const now = new Date(), nowVal = now.valueOf();
const today = new Date(now); today.setHours(0,0,0,0);
const todayVal = today.valueOf(), todayYear = today.getFullYear();
const dateFmt = new Intl.DateTimeFormat(undefined, {month:'2-digit', day:'2-digit'});
const dateYearFmt = new Intl.DateTimeFormat(undefined, {year:'numeric', month:'2-digit', day:'2-digit'});
function showDate(d) { return d.getFullYear() === todayYear ? dateFmt.format(d) : dateYearFmt.format(d); }
function showCreatedOn(s) { const d = new Date(s); d.setHours(0,0,0,0); return 'Created' + (d.valueOf() === todayVal ? ' today' : ' on ' + showDate(d)); }
function showActiveOn(s) {
const d = new Date(s), ago = nowVal - d.valueOf();
if (ago <= 1200000) return 'Active now';
if (ago <= 10800000) return 'Active recently';
d.setHours(0,0,0,0);
return 'Active' + (d.valueOf() === todayVal ? ' today' : ' on ' + showDate(d));
}
function displayEntries(entries) {
const dir = document.getElementById('directory');
const status = document.getElementById('status');
dir.innerHTML = '';
const label = currentSection === 'channel' ? 'channels' : 'groups';
if (!filteredEntries.length) {
status.textContent = sectionEntries.length ? `No ${label} match your search.` : `No ${label} listed yet.`;
return;
}
status.textContent = '';
for (const entry of entries) {
try {
const { entryType, displayName, groupLink, shortDescr, welcomeMessage, imageFile, activeAt, createdAt } = entry;
const isChannel = entryType?.groupType === 'channel';
const entryDiv = document.createElement('div');
entryDiv.className = 'entry';
// Image link (left)
const imgLink = document.createElement('a');
imgLink.className = 'img-link';
const uri = groupLink?.connShortLink ?? groupLink?.connFullLink ?? '#';
try { imgLink.href = platformSimplexUri(uri); } catch(e) { imgLink.href = uri; }
imgLink.target = '_blank';
imgLink.title = `Join ${displayName}`;
const img = document.createElement('img');
img.src = imageFile ? DATA_URL + imageFile : fallbackSvg;
img.alt = displayName;
img.addEventListener('error', () => { img.src = fallbackSvg; });
imgLink.appendChild(img);
entryDiv.appendChild(imgLink);
// Text container (right)
const textContainer = document.createElement('div');
textContainer.className = 'text-container';
const nameEl = document.createElement('h2');
nameEl.textContent = displayName;
textContainer.appendChild(nameEl);
const welcomeMessageHTML = welcomeMessage ? renderMarkdown(welcomeMessage) : undefined;
const shortDescrHTML = shortDescr ? renderMarkdown(shortDescr) : undefined;
if (shortDescrHTML && welcomeMessageHTML?.includes(shortDescrHTML) !== true) {
const p = document.createElement('p');
p.innerHTML = shortDescrHTML;
textContainer.appendChild(p);
}
if (welcomeMessageHTML) {
const msgEl = document.createElement('p');
msgEl.innerHTML = welcomeMessageHTML;
textContainer.appendChild(msgEl);
const readMore = document.createElement('p');
readMore.textContent = 'Read more';
readMore.className = 'read-more';
readMore.style.display = 'none';
textContainer.appendChild(readMore);
setTimeout(() => {
const lh = parseFloat(getComputedStyle(msgEl).lineHeight);
const maxH = 5 * lh;
const maxHpx = maxH + 'px';
msgEl.style.maxHeight = maxHpx;
msgEl.style.overflow = 'hidden';
if (msgEl.scrollHeight > maxH + 4) {
readMore.style.display = 'block';
readMore.addEventListener('click', () => {
if (msgEl.style.maxHeight === maxHpx) {
msgEl.style.maxHeight = 'none';
readMore.className = 'read-less';
readMore.innerHTML = '&#9650;';
} else {
msgEl.style.maxHeight = maxHpx;
readMore.className = 'read-more';
readMore.textContent = 'Read more';
}
});
}
}, 0);
}
// "Requires v6.5" note for relay-based entries (groupType present)
if (entryType?.groupType) {
const noteEl = document.createElement('p');
noteEl.innerHTML = 'You need <a href="https://simplex.chat/downloads/" target="_blank">SimpleX Chat app v6.5</a> to join.';
noteEl.className = 'small-text';
textContainer.appendChild(noteEl);
}
const ts = currentSortMode === 'new' && createdAt
? showCreatedOn(createdAt)
: activeAt ? showActiveOn(activeAt) : '';
if (ts) {
const p = document.createElement('p');
p.textContent = ts;
p.className = 'small-text';
textContainer.appendChild(p);
}
const mc = entryMemberCount(entry);
if (mc > 0) {
const p = document.createElement('p');
p.textContent = `${mc} ${isChannel ? 'subscribers' : 'members'}`;
p.className = 'small-text';
textContainer.appendChild(p);
}
if (entryType?.admission?.review === 'all') {
const p = document.createElement('p');
p.textContent = 'New members are reviewed by admins';
p.className = 'small-text';
textContainer.appendChild(p);
}
entryDiv.appendChild(textContainer);
dir.appendChild(entryDiv);
} catch(e) { console.error(e); }
}
for (const el of document.querySelectorAll('.secret')) {
el.addEventListener('click', () => el.classList.toggle('visible'));
}
}
function addPagination(entries) {
const perPage = 10;
const total = Math.ceil(entries.length / perPage);
if (currentPage < 1) currentPage = 1;
if (currentPage > total) currentPage = Math.max(1, total);
const slice = entries.slice((currentPage - 1) * perPage, currentPage * perPage);
const pg = document.getElementById('bottom-pagination');
pg.innerHTML = '';
if (total <= 1) return slice;
const count = 8;
let s = Math.max(1, currentPage - 4);
let e = Math.min(total, s + count - 1);
if (e - s + 1 < count) s = Math.max(1, e - count + 1);
function mkBtn(label, page, cls) {
const b = document.createElement('button');
b.textContent = label;
if (cls) b.className = cls;
if (page === currentPage) b.classList.add('active');
else if (page === currentPage - 1 || page === currentPage + 1) b.classList.add('neighbor');
b.addEventListener('click', () => { currentPage = page; renderPage(); window.scrollTo(0, 0); });
return b;
}
if (currentPage > 1) pg.appendChild(mkBtn('Prev', currentPage - 1, 'text-btn'));
for (let p = s; p <= e; p++) pg.appendChild(mkBtn(p, p));
if (currentPage < total) pg.appendChild(mkBtn('Next', currentPage + 1, 'text-btn'));
return slice;
}
async function fetchJSON(url) {
try {
const r = await fetch(url);
if (!r.ok) throw new Error('HTTP ' + r.status);
return await r.json();
} catch(e) {
console.error('fetchJSON', url, e);
return null;
}
}
function escapeHtml(t) {
return (t ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;').replace(/'/g,'&#039;').replace(/\n/g,'<br>');
}
function getSimplexLinkDescr(linkType) {
switch (linkType) {
case 'contact': return 'SimpleX contact address';
case 'invitation': return 'SimpleX one-time invitation';
case 'group': return 'SimpleX group link';
case 'channel': return 'SimpleX channel link';
default: return 'SimpleX link';
}
}
function viaHost(smpHosts) { return `via ${smpHosts?.[0] ?? '?'}`; }
function isCurrentSite(uri) {
return uri.startsWith('https://simplex.chat') || uri.startsWith('https://www.simplex.chat');
}
function renderMarkdown(fts) {
if (!fts) return '';
let html = '';
for (const { format, text } of fts) {
if (!format) { html += escapeHtml(text); continue; }
try {
switch (format.type) {
case 'bold': html += `<strong>${escapeHtml(text)}</strong>`; break;
case 'italic': html += `<em>${escapeHtml(text)}</em>`; break;
case 'strikeThrough': html += `<s>${escapeHtml(text)}</s>`; break;
case 'snippet':
case 'command': html += `<span style="font-family:monospace">${escapeHtml(text)}</span>`; break;
case 'secret': html += `<span class="secret">${escapeHtml(text)}</span>`; break;
case 'small': html += `<span class="small-text">${escapeHtml(text)}</span>`; break;
case 'colored': html += `<span class="${escapeHtml(format.color)}">${escapeHtml(text)}</span>`; break;
case 'uri': {
const href = /^https?:|^simplex:/.test(text) ? text : 'https://' + text;
const tb = isCurrentSite(href) ? '' : ' target="_blank"';
html += `<a href="${href}"${tb}>${escapeHtml(text)}</a>`; break;
}
case 'hyperLink': {
const { showText, linkUri } = format;
const tb = isCurrentSite(linkUri) ? '' : ' target="_blank"';
html += `<a href="${linkUri}"${tb}>${escapeHtml(showText ?? linkUri)}</a>`; break;
}
case 'simplexLink': {
const { showText, linkType, simplexUri, smpHosts } = format;
const linkText = showText ? escapeHtml(showText) : getSimplexLinkDescr(linkType);
html += `<a href="${platformSimplexUri(simplexUri)}" target="_blank">${linkText} <em>(${viaHost(smpHosts)})</em></a>`; break;
}
case 'mention': html += `<strong>${escapeHtml(text)}</strong>`; break;
case 'email': html += `<a href="mailto:${escapeHtml(text)}">${escapeHtml(text)}</a>`; break;
case 'phone': html += `<a href="tel:${escapeHtml(text)}">${escapeHtml(text)}</a>`; break;
default: html += escapeHtml(text);
}
} catch(e) { html += escapeHtml(text); }
}
return html;
}
const simplexRe = /^simplex:\/([a-z]+)#(.+)/i;
const shortTypes = ['a','c','g','i','r'];
function platformSimplexUri(uri) {
if (!uri) return '#';
if (/Android|iPhone|iPad|iPod/i.test(navigator.userAgent)) return uri;
const m = uri.match(simplexRe);
if (!m) return uri;
const [, type, frag] = m;
if (shortTypes.includes(type)) {
const qi = frag.indexOf('?');
if (qi === -1) return uri;
const hash = frag.slice(0, qi);
const params = new URLSearchParams(frag.slice(qi + 1));
const host = params.get('h');
if (!host) return uri;
params.delete('h');
const rest = params.toString();
return `https://${host}:/${type}#${hash}${rest ? '?' + rest : ''}`;
}
return `https://simplex.chat/${type}#${frag}`;
}
const fallbackSvg = 'data:image/svg+xml,' + encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 104 104">` +
`<rect width="104" height="104" rx="24" fill="#e8eaf0"/>` +
`<circle cx="52" cy="40" r="16" fill="#bbbcc8"/>` +
`<path d="M20 80c0-17.7 14.3-32 32-32s32 14.3 32 32" fill="#bbbcc8"/>` +
`</svg>`
);
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
</script>
</body>
</html>