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:
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal 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
51
README.md
Normal 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
61
bots/haskell/README.md
Normal 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
|
||||
```
|
||||
201
bots/haskell/simplex-deadmans-bot/Main.hs
Normal file
201
bots/haskell/simplex-deadmans-bot/Main.hs
Normal 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)
|
||||
29
bots/haskell/simplex-deadmans-bot/mock_server.py
Normal file
29
bots/haskell/simplex-deadmans-bot/mock_server.py
Normal 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.")
|
||||
27
bots/haskell/simplexxx-directory/Main.hs
Normal file
27
bots/haskell/simplexxx-directory/Main.hs
Normal 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
|
||||
@@ -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)
|
||||
37
bots/haskell/simplexxx-directory/src/Directory/Captcha.hs
Normal file
37
bots/haskell/simplexxx-directory/src/Directory/Captcha.hs
Normal 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')
|
||||
]
|
||||
337
bots/haskell/simplexxx-directory/src/Directory/Events.hs
Normal file
337
bots/haskell/simplexxx-directory/src/Directory/Events.hs
Normal 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"
|
||||
171
bots/haskell/simplexxx-directory/src/Directory/Listing.hs
Normal file
171
bots/haskell/simplexxx-directory/src/Directory/Listing.hs
Normal 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
|
||||
236
bots/haskell/simplexxx-directory/src/Directory/Options.hs
Normal file
236
bots/haskell/simplexxx-directory/src/Directory/Options.hs
Normal 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"
|
||||
13
bots/haskell/simplexxx-directory/src/Directory/Search.hs
Normal file
13
bots/haskell/simplexxx-directory/src/Directory/Search.hs
Normal 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
|
||||
1538
bots/haskell/simplexxx-directory/src/Directory/Service.hs
Normal file
1538
bots/haskell/simplexxx-directory/src/Directory/Service.hs
Normal file
File diff suppressed because it is too large
Load Diff
587
bots/haskell/simplexxx-directory/src/Directory/Store.hs
Normal file
587
bots/haskell/simplexxx-directory/src/Directory/Store.hs
Normal 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.")
|
||||
149
bots/haskell/simplexxx-directory/src/Directory/Store/Migrate.hs
Normal file
149
bots/haskell/simplexxx-directory/src/Directory/Store/Migrate.hs
Normal 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
|
||||
@@ -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;
|
||||
|]
|
||||
@@ -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;
|
||||
|]
|
||||
31
bots/haskell/simplexxx-directory/src/Directory/Util.hs
Normal file
31
bots/haskell/simplexxx-directory/src/Directory/Util.hs
Normal 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_
|
||||
13
bots/haskell/simplexxx-directory/start.sh
Executable file
13
bots/haskell/simplexxx-directory/start.sh
Executable 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"
|
||||
101
bots/typescript/simplex-support-bot/README.md
Normal file
101
bots/typescript/simplex-support-bot/README.md
Normal 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.
|
||||
24
bots/typescript/simplex-support-bot/package.json
Normal file
24
bots/typescript/simplex-support-bot/package.json
Normal 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"
|
||||
}
|
||||
948
bots/typescript/simplex-support-bot/src/bot.ts
Normal file
948
bots/typescript/simplex-support-bot/src/bot.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
479
bots/typescript/simplex-support-bot/src/cards.ts
Normal file
479
bots/typescript/simplex-support-bot/src/cards.ts
Normal 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`
|
||||
}
|
||||
}
|
||||
152
bots/typescript/simplex-support-bot/src/config.ts
Normal file
152
bots/typescript/simplex-support-bot/src/config.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
59
bots/typescript/simplex-support-bot/src/context.ts
Normal file
59
bots/typescript/simplex-support-bot/src/context.ts
Normal 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
|
||||
}
|
||||
58
bots/typescript/simplex-support-bot/src/grok.ts
Normal file
58
bots/typescript/simplex-support-bot/src/grok.ts
Normal 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},
|
||||
])
|
||||
}
|
||||
}
|
||||
376
bots/typescript/simplex-support-bot/src/index.ts
Normal file
376
bots/typescript/simplex-support-bot/src/index.ts
Normal file
File diff suppressed because one or more lines are too long
44
bots/typescript/simplex-support-bot/src/messages.ts
Normal file
44
bots/typescript/simplex-support-bot/src/messages.ts
Normal 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?"
|
||||
51
bots/typescript/simplex-support-bot/src/util.ts
Normal file
51
bots/typescript/simplex-support-bot/src/util.ts
Normal 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)
|
||||
}
|
||||
23
bots/typescript/simplex-support-bot/tsconfig.json
Normal file
23
bots/typescript/simplex-support-bot/tsconfig.json
Normal 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
0
manager/.gitkeep
Normal file
0
web/data/.gitkeep
Normal file
0
web/data/.gitkeep
Normal file
794
web/index.html
Normal file
794
web/index.html
Normal 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 = '▲';
|
||||
} 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,'&').replace(/</g,'<').replace(/>/g,'>')
|
||||
.replace(/"/g,'"').replace(/'/g,''').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>
|
||||
Reference in New Issue
Block a user