mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-06-07 21:53:29 +00:00
apps: support bot relocate
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,143 @@
|
||||
# SimpleX Chat — Context for AI Assistant
|
||||
|
||||
## What is SimpleX Chat?
|
||||
|
||||
SimpleX Chat is a private and secure messaging platform. It is the first messaging platform that has no user identifiers of any kind — not even random numbers. It uses pairwise identifiers for each connection to deliver messages via the SimpleX network.
|
||||
|
||||
### Core Privacy Guarantees
|
||||
|
||||
- **No user identifiers**: No phone numbers, usernames, or account IDs. Users connect via one-time invitation links or QR codes.
|
||||
- **End-to-end encryption**: All messages use double ratchet protocol with post-quantum key exchange (ML-KEM). Even if encryption keys are compromised in the future, past messages remain secure.
|
||||
- **No metadata access**: Relay servers cannot correlate senders and receivers — each conversation uses separate unidirectional messaging queues with different addresses on each side.
|
||||
- **Decentralized**: No central server. Messages are relayed through SMP (SimpleX Messaging Protocol) servers. Users can choose or self-host their own servers.
|
||||
- **Open source**: All client and server code is available on GitHub under AGPL-3.0 license. The protocol design is published and peer-reviewed.
|
||||
- **No global identity**: There is no way to discover users on the platform — you can only connect to someone if they share a link or QR code with you.
|
||||
|
||||
## Available Platforms
|
||||
|
||||
- **Mobile**: iOS (App Store), Android (Google Play, F-Droid, APK)
|
||||
- **Desktop**: macOS, Windows, Linux (AppImage, deb, Flatpak)
|
||||
- All platforms support the same features and can be used simultaneously with linked devices
|
||||
|
||||
## Key Features
|
||||
|
||||
### Messaging
|
||||
- Text messages with markdown formatting
|
||||
- Voice messages
|
||||
- Images and videos
|
||||
- File sharing (any file type, up to 1GB via XFTP)
|
||||
- Message reactions and replies
|
||||
- Message editing and deletion
|
||||
- Disappearing messages (configurable per contact/group)
|
||||
- Live messages (recipient sees you typing in real-time)
|
||||
- Message delivery receipts
|
||||
|
||||
### Calls
|
||||
- End-to-end encrypted audio and video calls
|
||||
- Calls work peer-to-peer when possible, relayed through TURN servers otherwise
|
||||
- WebRTC-based
|
||||
|
||||
### Groups
|
||||
- Group chats with roles: owner, admin, moderator, member, observer
|
||||
- Groups can have hundreds of members
|
||||
- Group links for easy joining
|
||||
- Group moderation tools
|
||||
- Business chat groups for customer support
|
||||
|
||||
### Privacy Features
|
||||
- **Incognito mode**: Use a random profile name per contact — your real profile is never shared
|
||||
- **Multiple chat profiles**: Maintain separate identities
|
||||
- **Hidden profiles**: Protect profiles with a password
|
||||
- **Contact verification**: Verify contacts via security code comparison
|
||||
- **SimpleX Lock**: App lock with passcode or biometric
|
||||
- **Private routing**: Route messages through multiple servers to hide your IP from destination servers
|
||||
- **No tracking or analytics**: The app does not collect or send any telemetry
|
||||
|
||||
### Device & Data Management
|
||||
- **Database export/import**: Migrate to a new device by exporting the database (encrypted or unencrypted)
|
||||
- **Database passphrase**: Encrypt the local database with a passphrase
|
||||
- **Linked devices**: Use SimpleX on multiple devices simultaneously (mobile + desktop)
|
||||
- **Chat archive**: Export and import full chat history
|
||||
|
||||
## SimpleX Network Architecture
|
||||
|
||||
### SMP (SimpleX Messaging Protocol)
|
||||
- Asynchronous message delivery via relay servers
|
||||
- Each conversation uses **separate unidirectional messaging queues**
|
||||
- Queues have different addresses on sender and receiver sides — servers cannot correlate them
|
||||
- Messages are end-to-end encrypted; servers only see encrypted blobs
|
||||
- Servers do not store any user profiles or contact lists
|
||||
- Messages are deleted from servers once delivered
|
||||
|
||||
### XFTP (SimpleX File Transfer Protocol)
|
||||
- Used for large files (images, videos, documents)
|
||||
- Files are encrypted, split into chunks, and sent through multiple relay servers
|
||||
- Temporary file storage — files are deleted after download or expiry
|
||||
|
||||
### Server Architecture
|
||||
- **Preset servers**: SimpleX Chat Inc. operates preset relay servers, but they can be changed
|
||||
- **Self-hosting**: Users can run their own SMP and XFTP servers
|
||||
- **No federation**: Servers don't communicate with each other. Each message queue is independent
|
||||
- **Tor support**: SimpleX supports connecting through Tor for additional IP privacy
|
||||
|
||||
## Comparison with Other Messengers
|
||||
|
||||
### vs Signal
|
||||
- SimpleX requires no phone number or any identifier to register
|
||||
- SimpleX is decentralized — Signal has a central server
|
||||
- SimpleX relay servers cannot access metadata (who talks to whom) — Signal's server knows your contacts
|
||||
- Both use strong end-to-end encryption
|
||||
|
||||
### vs Telegram
|
||||
- SimpleX is fully end-to-end encrypted for all chats — Telegram only encrypts "secret chats"
|
||||
- SimpleX has no phone number requirement
|
||||
- SimpleX is fully open source (clients and servers) — Telegram server is closed source
|
||||
- SimpleX collects no metadata
|
||||
|
||||
### vs Matrix/Element
|
||||
- SimpleX has better metadata privacy — Matrix servers see who is in which room
|
||||
- SimpleX is simpler to use — no server selection or account creation
|
||||
- SimpleX does not use federated identity
|
||||
|
||||
### vs Session
|
||||
- SimpleX doesn't use a blockchain or cryptocurrency
|
||||
- SimpleX has better group support and more features
|
||||
- Both have no phone number requirement
|
||||
|
||||
## Common User Questions & Troubleshooting
|
||||
|
||||
### Getting Started
|
||||
- **How do I add contacts?** Create a one-time invitation link (or QR code) and share it with your contact. They open it in their SimpleX app to connect. Links are single-use by default for maximum privacy, but you can create reusable address links.
|
||||
- **Can I use SimpleX without a phone number?** Yes, SimpleX requires no phone number, email, or any identifier. Just install the app and start chatting.
|
||||
- **How do I join a group?** Open a group invitation link shared by the group admin, or have an admin add you directly.
|
||||
|
||||
### Device Migration
|
||||
- **How do I move to a new phone?** Go to Settings > Database > Export database. Transfer the file to your new device, install SimpleX, and import the database. Note: you should stop using the old device after export to avoid message duplication.
|
||||
- **Can I use SimpleX on multiple devices?** Yes, link a desktop app to your mobile app. Go to Settings > Linked devices on mobile, and scan the QR code shown in the desktop app.
|
||||
|
||||
### Privacy & Security
|
||||
- **Can SimpleX servers read my messages?** No. All messages are end-to-end encrypted. Servers only relay encrypted data and cannot decrypt it.
|
||||
- **Can SimpleX see who I'm talking to?** No. Each conversation uses separate queues with different addresses. Servers cannot correlate senders and receivers.
|
||||
- **How do I verify my contact?** Open the contact's profile, tap "Verify security code", and compare the code with your contact (in person or via another channel).
|
||||
- **What is incognito mode?** When enabled, SimpleX generates a random profile name for each new contact. Your real profile name is never shared. Enable it in Settings > Incognito.
|
||||
|
||||
### Servers
|
||||
- **How do I self-host a server?** Follow the guide at https://simplex.chat/docs/server.html. You need a Linux server with a public IP. Install the SMP server package and configure it.
|
||||
- **How do I change relay servers?** Go to Settings > Network & servers. You can add your own server addresses and disable preset servers.
|
||||
- **Do I need to use SimpleX's servers?** No. You can use any SMP/XFTP servers, including your own. However, you and your contacts need to be able to reach each other's servers.
|
||||
|
||||
### Troubleshooting
|
||||
- **Messages not delivering?** Check your internet connection. Try switching between WiFi and mobile data. Go to Settings > Network & servers and check server status. You can also try restarting the app.
|
||||
- **Cannot connect to a contact?** The invitation link may have expired or already been used. Create a new invitation link and share it again.
|
||||
- **App is slow?** Large databases can slow down the app. Consider archiving old chats or deleting unused contacts/groups.
|
||||
- **Notifications not working (Android)?** SimpleX needs to run a background service for notifications. Go to Settings > Notifications and enable background service. You may need to disable battery optimization for the app.
|
||||
- **Notifications not working (iOS)?** Ensure notifications are enabled in iOS Settings > SimpleX Chat. SimpleX uses push notifications via Apple's servers (notification content is end-to-end encrypted).
|
||||
|
||||
## Links
|
||||
|
||||
- Website: https://simplex.chat
|
||||
- GitHub: https://github.com/simplex-chat
|
||||
- Documentation: https://simplex.chat/docs
|
||||
- Server setup: https://simplex.chat/docs/server.html
|
||||
- Protocol whitepaper: https://github.com/simplex-chat/simplexmq/blob/stable/protocol/overview-tjr.md
|
||||
- Security audit: https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html
|
||||
+1498
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "simplex-chat-support-bot",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@simplex-chat/types": "^0.3.0",
|
||||
"simplex-chat": "^6.5.0-beta.4.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.5",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^2.1.9"
|
||||
},
|
||||
"author": "SimpleX Chat",
|
||||
"license": "AGPL-3.0"
|
||||
}
|
||||
@@ -112,7 +112,7 @@ type ConversationState =
|
||||
| {type: "welcome"}
|
||||
| {type: "teamQueue"; userMessages: string[]}
|
||||
| {type: "grokMode"; grokMemberGId: number; history: GrokMessage[]}
|
||||
| {type: "teamPending"; teamMemberGId: number; grokMemberGId?: number; history?: GrokMessage[]}
|
||||
| {type: "teamPending"; teamMemberGId: number}
|
||||
| {type: "teamLocked"; teamMemberGId: number}
|
||||
```
|
||||
|
||||
@@ -123,12 +123,11 @@ type ConversationState =
|
||||
welcome ──(1st user msg)──> teamQueue
|
||||
teamQueue ──(user msg)──> teamQueue (append to userMessages)
|
||||
teamQueue ──(/grok)──> grokMode (userMessages → initial Grok history)
|
||||
teamQueue ──(/team)──> teamPending
|
||||
teamQueue ──(/team)──> teamPending (add team member)
|
||||
grokMode ──(user msg)──> grokMode (forward to Grok API, append to history)
|
||||
grokMode ──(/team)──> teamPending (carry grokMemberGId + history)
|
||||
teamPending ──(team member msg)──> teamLocked (remove Grok if present)
|
||||
teamPending ──(/grok, grok present)──> teamPending (forward to Grok, still usable)
|
||||
teamPending ──(/grok, no grok)──> reply "team mode"
|
||||
grokMode ──(/team)──> teamPending (remove Grok immediately, add team member)
|
||||
teamPending ──(team member msg)──> teamLocked
|
||||
teamPending ──(/grok)──> reply "team mode"
|
||||
teamLocked ──(/grok)──> reply "team mode", stay locked
|
||||
teamLocked ──(any)──> no action (team sees directly)
|
||||
```
|
||||
@@ -227,11 +226,13 @@ await grokChat.startChat()
|
||||
|-------|---------|--------|
|
||||
| `acceptingBusinessRequest` | `onBusinessRequest` | `conversations.set(groupInfo.groupId, {type: "welcome"})` |
|
||||
| `newChatItems` | `onNewChatItems` | For each chatItem: identify sender, extract text, dispatch to routing |
|
||||
| `leftMember` | `onLeftMember` | If customer left → delete state. If team member left → revert to teamQueue. If Grok left → clear grokMemberGId. |
|
||||
| `leftMember` | `onLeftMember` | If customer left → delete state. If team member left → revert to teamQueue. If Grok left during grokMode → revert to teamQueue. |
|
||||
| `deletedMemberUser` | `onDeletedMemberUser` | Bot removed from group → delete state |
|
||||
| `groupDeleted` | `onGroupDeleted` | Delete state, delete grokGroupMap entry |
|
||||
| `connectedToGroupMember` | `onMemberConnected` | Log for debugging |
|
||||
|
||||
We do NOT use `onMessage`/`onCommands` from `bot.run()` — all routing is done in the `newChatItems` event handler for full control over state-dependent command handling.
|
||||
|
||||
**Sender identification in `newChatItems`:**
|
||||
```typescript
|
||||
for (const ci of evt.chatItems) {
|
||||
@@ -248,10 +249,9 @@ for (const ci of evt.chatItems) {
|
||||
const sender = chatItem.chatDir.groupMember
|
||||
|
||||
const isCustomer = sender.memberId === groupInfo.businessChat.customerId
|
||||
const isTeamMember = state.type === "teamPending" || state.type === "teamLocked"
|
||||
? sender.groupMemberId === state.teamMemberGId
|
||||
: false
|
||||
const isGrok = (state.type === "grokMode" || state.type === "teamPending")
|
||||
const isTeamMember = (state.type === "teamPending" || state.type === "teamLocked")
|
||||
&& sender.groupMemberId === state.teamMemberGId
|
||||
const isGrok = state.type === "grokMode"
|
||||
&& state.grokMemberGId === sender.groupMemberId
|
||||
|
||||
if (isGrok) continue // skip Grok messages (we sent them via grokChat)
|
||||
@@ -260,12 +260,18 @@ for (const ci of evt.chatItems) {
|
||||
}
|
||||
```
|
||||
|
||||
**Text extraction:**
|
||||
**Command detection** — use `util.ciBotCommand()` for `/grok` and `/team`; all other text (including unrecognized `/commands`) is routed as "other text" per spec ("Unrecognized commands: treated as normal messages in the current mode"):
|
||||
```typescript
|
||||
function extractText(chatItem: T.ChatItem): string | null {
|
||||
const text = util.ciContentText(chatItem)
|
||||
return text?.trim() || null
|
||||
}
|
||||
|
||||
// In onCustomerMessage:
|
||||
const cmd = util.ciBotCommand(chatItem)
|
||||
if (cmd?.keyword === "grok") { /* handle /grok */ }
|
||||
else if (cmd?.keyword === "team") { /* handle /team */ }
|
||||
else { /* handle as normal text message, including unrecognized /commands */ }
|
||||
```
|
||||
|
||||
## 9. Message Routing Table
|
||||
@@ -279,10 +285,9 @@ function extractText(chatItem: T.ChatItem): string | null {
|
||||
| `teamQueue` | `/team` | Add team member | `mainChat.apiAddMember(groupId, teamContactId, "member")` + `mainChat.apiSendTextMessage([Group, groupId], teamAddedMsg)` | `teamPending` |
|
||||
| `teamQueue` | other text | Forward to team, append to userMessages | `mainChat.apiSendTextMessage([Group, teamGroupId], fwd)` | `teamQueue` |
|
||||
| `grokMode` | `/grok` | Ignore (already in grok mode) | — | `grokMode` |
|
||||
| `grokMode` | `/team` | Add team member (keep Grok for now) | `mainChat.apiAddMember(groupId, teamContactId, "member")` + `mainChat.apiSendTextMessage([Group, groupId], teamAddedMsg)` | `teamPending` (carry grokMemberGId + history) |
|
||||
| `grokMode` | other text | Forward to Grok API + team | Grok API call + `grokChat.apiSendTextMessage([Group, grokLocalGId], response)` + `mainChat.apiSendTextMessage([Group, teamGroupId], fwd)` | `grokMode` (append history) |
|
||||
| `teamPending` (grok present) | `/grok` | Forward to Grok (still usable) | Grok API call + `grokChat.apiSendTextMessage(...)` | `teamPending` |
|
||||
| `teamPending` (no grok) | `/grok` | Reply "team mode" | `mainChat.apiSendTextMessage([Group, groupId], teamLockedMsg)` | `teamPending` |
|
||||
| `grokMode` | `/team` | Remove Grok, add team member | `mainChat.apiRemoveMembers(groupId, [grokMemberGId])` + `mainChat.apiAddMember(groupId, teamContactId, "member")` + `mainChat.apiSendTextMessage([Group, groupId], teamAddedMsg)` | `teamPending` |
|
||||
| `grokMode` | other text | Forward to Grok API + forward to team | Grok API call + `grokChat.apiSendTextMessage([Group, grokLocalGId], response)` + `mainChat.apiSendTextMessage([Group, teamGroupId], fwd)` | `grokMode` (append history) |
|
||||
| `teamPending` | `/grok` | Reply "team mode" | `mainChat.apiSendTextMessage([Group, groupId], teamLockedMsg)` | `teamPending` |
|
||||
| `teamPending` | `/team` | Ignore (already team) | — | `teamPending` |
|
||||
| `teamPending` | other text | No forwarding (team sees directly in group) | — | `teamPending` |
|
||||
| `teamLocked` | `/grok` | Reply "team mode" | `mainChat.apiSendTextMessage([Group, groupId], teamLockedMsg)` | `teamLocked` |
|
||||
@@ -302,13 +307,18 @@ async forwardToTeam(groupId: number, groupInfo: T.GroupInfo, text: string): Prom
|
||||
}
|
||||
|
||||
async activateTeam(groupId: number, state: ConversationState): Promise<void> {
|
||||
// Remove Grok immediately if present (per spec: "When switching to team mode, Grok is removed")
|
||||
if (state.type === "grokMode") {
|
||||
try { await this.mainChat.apiRemoveMembers(groupId, [state.grokMemberGId]) } catch {}
|
||||
const grokLocalGId = grokGroupMap.get(groupId)
|
||||
grokGroupMap.delete(groupId)
|
||||
if (grokLocalGId) reverseGrokMap.delete(grokLocalGId)
|
||||
}
|
||||
const teamContactId = this.config.teamMembers[0].id // round-robin or first available
|
||||
const member = await this.mainChat.apiAddMember(groupId, teamContactId, "member")
|
||||
this.conversations.set(groupId, {
|
||||
type: "teamPending",
|
||||
teamMemberGId: member.groupMemberId,
|
||||
grokMemberGId: state.type === "grokMode" ? state.grokMemberGId : undefined,
|
||||
history: state.type === "grokMode" ? state.history : undefined,
|
||||
})
|
||||
await this.mainChat.apiSendTextMessage(
|
||||
[T.ChatType.Group, groupId],
|
||||
@@ -358,25 +368,20 @@ class GrokApiClient {
|
||||
|
||||
## 12. One-Way Gate Logic
|
||||
|
||||
Per spec: "When switching to team mode, Grok is removed" and "once the user switches to team mode, /grok command is permanently disabled." Grok removal happens immediately in `activateTeam` (section 10). The one-way gate locks the state after team member engages:
|
||||
|
||||
```typescript
|
||||
async onTeamMemberMessage(groupId: number, state: ConversationState): Promise<void> {
|
||||
if (state.type !== "teamPending") return
|
||||
|
||||
// Remove Grok if present
|
||||
if (state.grokMemberGId) {
|
||||
try { await this.mainChat.apiRemoveMembers(groupId, [state.grokMemberGId]) } catch {}
|
||||
grokGroupMap.delete(groupId)
|
||||
reverseGrokMap.delete(/* grokLocalGroupId */)
|
||||
}
|
||||
|
||||
this.conversations.set(groupId, {type: "teamLocked", teamMemberGId: state.teamMemberGId})
|
||||
}
|
||||
```
|
||||
|
||||
Timeline per spec:
|
||||
1. User sends `/team` → `apiAddMember` → state = `teamPending` (Grok still usable if present)
|
||||
2. Team member sends message → `onTeamMemberMessage` → state = `teamLocked`, Grok removed via `apiRemoveMembers`
|
||||
3. Any `/grok` → reply "You are now in team mode. A team member will reply to your message."
|
||||
1. User sends `/team` → Grok removed immediately (if present) → team member added → state = `teamPending`
|
||||
2. `/grok` in `teamPending` → reply "team mode" (Grok already gone, command disabled)
|
||||
3. Team member sends message → `onTeamMemberMessage` → state = `teamLocked`
|
||||
4. Any subsequent `/grok` → reply "You are now in team mode. A team member will reply to your message."
|
||||
|
||||
## 13. Message Templates (verbatim from spec)
|
||||
|
||||
@@ -417,7 +422,7 @@ function isWeekend(timezone: string): boolean {
|
||||
|
||||
| # | Operation | When | ChatApi Instance | Method | Parameters | Response Type | Error Handling |
|
||||
|---|-----------|------|-----------------|--------|------------|---------------|----------------|
|
||||
| 1 | Init main bot | Startup | mainChat | `bot.run()` (wraps `ChatApi.init`) | dbFilePrefix, profile, addressSettings | `[ChatApi, User, UserContactLink]` | Exit on failure |
|
||||
| 1 | Init main bot | Startup | mainChat | `bot.run()` (wraps `ChatApi.init`) | dbFilePrefix, profile, addressSettings | `[ChatApi, User, UserContactLink \| undefined]` | Exit on failure |
|
||||
| 2 | Init Grok agent | Startup | grokChat | `ChatApi.init(grokDbPrefix)` | dbFilePrefix | `ChatApi` | Exit on failure |
|
||||
| 3 | Get/create Grok user | Startup | grokChat | `apiGetActiveUser()` / `apiCreateActiveUser(profile)` | profile: {displayName: "Grok AI"} | `User` | Exit on failure |
|
||||
| 4 | Start Grok chat | Startup | grokChat | `startChat()` | — | void | Exit on failure |
|
||||
@@ -427,12 +432,12 @@ function isWeekend(timezone: string): boolean {
|
||||
| 8 | First-run: connect | First-run | grokChat | `apiConnectActiveUser(invLink)` | connLink | `ConnReqType` | Exit on failure |
|
||||
| 9 | First-run: wait | First-run | mainChat | `wait("contactConnected", 60000)` | event, timeout | `ChatEvent \| undefined` | Exit on timeout |
|
||||
| 10 | Send msg to customer | Various | mainChat | `apiSendTextMessage([Group, groupId], text)` | chat, text | `AChatItem[]` | Log error |
|
||||
| 11 | Forward to team | welcome→teamQueue, teamQueue msg | mainChat | `apiSendTextMessage([Group, teamGroupId], fwd)` | chat, formatted text | `AChatItem[]` | Log error |
|
||||
| 11 | Forward to team | welcome→teamQueue, teamQueue msg, grokMode msg | mainChat | `apiSendTextMessage([Group, teamGroupId], fwd)` | chat, formatted text | `AChatItem[]` | Log error |
|
||||
| 12 | Invite Grok to group | /grok in teamQueue | mainChat | `apiAddMember(groupId, grokContactId, "member")` | groupId, contactId, role | `GroupMember` | Send error msg, stay in teamQueue |
|
||||
| 13 | Grok joins group | receivedGroupInvitation | grokChat | `apiJoinGroup(groupId)` | groupId | `GroupInfo` | Log error |
|
||||
| 14 | Grok sends response | After Grok API reply | grokChat | `apiSendTextMessage([Group, grokLocalGId], text)` | chat, text | `AChatItem[]` | Send error msg via mainChat |
|
||||
| 15 | Invite team member | /team | mainChat | `apiAddMember(groupId, teamContactId, "member")` | groupId, contactId, role | `GroupMember` | Send error msg to customer |
|
||||
| 16 | Remove Grok | Team member msg in teamPending | mainChat | `apiRemoveMembers(groupId, [grokMemberGId])` | groupId, memberIds | `GroupMember[]` | Ignore (may have left) |
|
||||
| 16 | Remove Grok | /team from grokMode | mainChat | `apiRemoveMembers(groupId, [grokMemberGId])` | groupId, memberIds | `GroupMember[]` | Ignore (may have left) |
|
||||
| 17 | Update bot profile | Startup (via bot.run) | mainChat | `apiUpdateProfile(userId, profile)` | userId, profile with peerType+commands | `UserProfileUpdateSummary` | Log warning |
|
||||
| 18 | Set address settings | Startup (via bot.run) | mainChat | `apiSetAddressSettings(userId, settings)` | userId, {businessAddress, autoAccept, welcomeMessage} | void | Exit on failure |
|
||||
|
||||
@@ -448,7 +453,7 @@ function isWeekend(timezone: string): boolean {
|
||||
| Grok join timeout (30s) | `mainChat.apiSendTextMessage` "Grok unavailable", stay in `teamQueue` |
|
||||
| Customer leaves (`leftMember` where member is customer) | Delete conversation state, delete grokGroupMap entry |
|
||||
| Group deleted | Delete conversation state, delete grokGroupMap entry |
|
||||
| Grok leaves during `teamPending` | Clear `grokMemberGId` from state, keep `teamPending` |
|
||||
| Grok leaves during `grokMode` | Revert to `teamQueue`, delete grokGroupMap entry |
|
||||
| Team member leaves | Revert to `teamQueue` (accumulate messages again) |
|
||||
| Bot removed from group (`deletedMemberUser`) | Delete conversation state |
|
||||
| Grok agent connection lost | Log error; Grok features unavailable until restart |
|
||||
@@ -481,10 +486,10 @@ function isWeekend(timezone: string): boolean {
|
||||
- **Verify:** `/grok` → Grok joins as separate participant → Grok responses appear from Grok profile
|
||||
|
||||
**Phase 4: Team mode + one-way gate**
|
||||
- Implement `activateTeam`: add team member
|
||||
- Implement `onTeamMemberMessage`: detect team msg → lock → remove Grok
|
||||
- Implement `/grok` rejection in `teamLocked`
|
||||
- **Verify:** Full flow including: teamQueue → /grok → grokMode → /team → teamPending → team msg → teamLocked → /grok rejected
|
||||
- Implement `activateTeam`: remove Grok if present, add team member
|
||||
- Implement `onTeamMemberMessage`: detect team msg → lock state
|
||||
- Implement `/grok` rejection in `teamPending` and `teamLocked`
|
||||
- **Verify:** Full flow: teamQueue → /grok → grokMode → /team → Grok removed + teamPending → /grok rejected → team msg → teamLocked
|
||||
|
||||
**Phase 5: Polish + first-run**
|
||||
- Implement `--first-run` auto-contact establishment
|
||||
@@ -533,9 +538,9 @@ npx ts-node src/index.ts \
|
||||
2. Send question → verify forwarded to team group with `[CustomerName #groupId]` prefix, queue reply received
|
||||
3. Send `/grok` → verify Grok joins as separate participant, responses appear from "Grok AI" profile
|
||||
4. Send text in grokMode → verify Grok response + forwarded to team
|
||||
5. Send `/team` → verify team member added, team added message
|
||||
6. Send team member message → verify Grok removed, state locked
|
||||
7. Send `/grok` after lock → verify "team mode" reply
|
||||
5. Send `/team` → verify Grok removed, team member added, team added message
|
||||
6. Send `/grok` after `/team` (before team member message) → verify "team mode" reply
|
||||
7. Send team member message → verify state locked, `/grok` still rejected
|
||||
8. Test weekend: set timezone to weekend timezone → verify "48 hours" in messages
|
||||
9. Customer disconnects → verify state cleanup
|
||||
10. Grok API failure → verify error message, graceful fallback to teamQueue
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
A SimpleX Chat bot that monitors public groups, summarizes conversations using
|
||||
Grok LLM, moderates content, and forwards important messages to a private
|
||||
staff group.
|
||||
|
||||
Core Features
|
||||
|
||||
1. Message Summarization
|
||||
- Periodically summarizes public group messages using Grok API
|
||||
- Posts summaries to the group on a configurable schedule (e.g. daily/hourly)
|
||||
- Summaries capture key topics, decisions, and action items
|
||||
|
||||
2. Moderation
|
||||
- Detects spam, abuse, and policy violations using Grok
|
||||
- Configurable actions per severity: flag-only, auto-delete, or remove member
|
||||
- All moderation events are forwarded to the staff group for visibility
|
||||
|
||||
3. Important Message Forwarding
|
||||
- Grok classifies messages by importance (urgency, issues, support requests)
|
||||
- Forwards important messages to a designated private staff group
|
||||
- Includes context: sender, group, timestamp, and reason for flagging
|
||||
|
||||
Configuration
|
||||
|
||||
- GROK_API_KEY — Grok API credentials
|
||||
- PUBLIC_GROUPS — list of monitored public groups
|
||||
- STAFF_GROUP — private group for forwarded alerts
|
||||
- SUMMARY_INTERVAL — how often summaries are generated
|
||||
- MODERATION_RULES — content policy and action thresholds
|
||||
|
||||
Non-Goals
|
||||
|
||||
- No interactive Q&A or general chatbot behavior in groups
|
||||
- No direct user communication from the bot (all escalation goes to staff
|
||||
group)
|
||||
@@ -0,0 +1,430 @@
|
||||
import {api, util} from "simplex-chat"
|
||||
import {T, CEvt} from "@simplex-chat/types"
|
||||
import {Config} from "./config.js"
|
||||
import {ConversationState, GrokMessage} from "./state.js"
|
||||
import {GrokApiClient} from "./grok.js"
|
||||
import {teamQueueMessage, grokActivatedMessage, teamAddedMessage, teamLockedMessage} from "./messages.js"
|
||||
import {log, logError} from "./util.js"
|
||||
|
||||
export class SupportBot {
|
||||
private conversations = new Map<number, ConversationState>()
|
||||
private pendingGrokJoins = new Map<string, number>() // memberId → mainGroupId
|
||||
private grokGroupMap = new Map<number, number>() // mainGroupId → grokLocalGroupId
|
||||
private reverseGrokMap = new Map<number, number>() // grokLocalGroupId → mainGroupId
|
||||
private grokJoinResolvers = new Map<number, () => void>() // mainGroupId → resolve fn
|
||||
|
||||
constructor(
|
||||
private mainChat: api.ChatApi,
|
||||
private grokChat: api.ChatApi,
|
||||
private grokApi: GrokApiClient,
|
||||
private config: Config,
|
||||
) {}
|
||||
|
||||
// --- Event Handlers (main bot) ---
|
||||
|
||||
onBusinessRequest(evt: CEvt.AcceptingBusinessRequest): void {
|
||||
const groupId = evt.groupInfo.groupId
|
||||
log(`New business request: groupId=${groupId}`)
|
||||
this.conversations.set(groupId, {type: "welcome"})
|
||||
}
|
||||
|
||||
async onNewChatItems(evt: CEvt.NewChatItems): Promise<void> {
|
||||
for (const ci of evt.chatItems) {
|
||||
try {
|
||||
await this.processChatItem(ci)
|
||||
} catch (err) {
|
||||
logError(`Error processing chat item in group`, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async onLeftMember(evt: CEvt.LeftMember): Promise<void> {
|
||||
const groupId = evt.groupInfo.groupId
|
||||
const state = this.conversations.get(groupId)
|
||||
if (!state) return
|
||||
|
||||
const member = evt.member
|
||||
const bc = evt.groupInfo.businessChat
|
||||
if (!bc) return
|
||||
|
||||
// Customer left
|
||||
if (member.memberId === bc.customerId) {
|
||||
log(`Customer left group ${groupId}, cleaning up`)
|
||||
this.conversations.delete(groupId)
|
||||
this.cleanupGrokMaps(groupId)
|
||||
return
|
||||
}
|
||||
|
||||
// Team member left — teamPending: gate not yet triggered, revert to teamQueue
|
||||
if (state.type === "teamPending" && member.groupMemberId === state.teamMemberGId) {
|
||||
log(`Team member left group ${groupId} (teamPending), reverting to teamQueue`)
|
||||
this.conversations.set(groupId, {type: "teamQueue", userMessages: []})
|
||||
return
|
||||
}
|
||||
|
||||
// Team member left — teamLocked: one-way gate triggered, stay in team mode (add another member)
|
||||
if (state.type === "teamLocked" && member.groupMemberId === state.teamMemberGId) {
|
||||
log(`Team member left group ${groupId} (teamLocked), adding replacement team member`)
|
||||
await this.addReplacementTeamMember(groupId)
|
||||
return
|
||||
}
|
||||
|
||||
// Grok left during grokMode
|
||||
if (state.type === "grokMode" && member.groupMemberId === state.grokMemberGId) {
|
||||
log(`Grok left group ${groupId} during grokMode, reverting to teamQueue`)
|
||||
this.conversations.set(groupId, {type: "teamQueue", userMessages: []})
|
||||
this.cleanupGrokMaps(groupId)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
onDeletedMemberUser(evt: CEvt.DeletedMemberUser): void {
|
||||
const groupId = evt.groupInfo.groupId
|
||||
log(`Bot removed from group ${groupId}`)
|
||||
this.conversations.delete(groupId)
|
||||
this.cleanupGrokMaps(groupId)
|
||||
}
|
||||
|
||||
onGroupDeleted(evt: CEvt.GroupDeleted): void {
|
||||
const groupId = evt.groupInfo.groupId
|
||||
log(`Group ${groupId} deleted`)
|
||||
this.conversations.delete(groupId)
|
||||
this.cleanupGrokMaps(groupId)
|
||||
}
|
||||
|
||||
onMemberConnected(evt: CEvt.ConnectedToGroupMember): void {
|
||||
log(`Member connected in group ${evt.groupInfo.groupId}: ${evt.member.memberProfile.displayName}`)
|
||||
}
|
||||
|
||||
// --- Event Handler (Grok agent) ---
|
||||
|
||||
async onGrokGroupInvitation(evt: CEvt.ReceivedGroupInvitation): Promise<void> {
|
||||
const memberId = evt.groupInfo.membership.memberId
|
||||
const mainGroupId = this.pendingGrokJoins.get(memberId)
|
||||
if (mainGroupId === undefined) {
|
||||
log(`Grok received unexpected group invitation (memberId=${memberId}), ignoring`)
|
||||
return
|
||||
}
|
||||
log(`Grok joining group: mainGroupId=${mainGroupId}, grokGroupId=${evt.groupInfo.groupId}`)
|
||||
this.pendingGrokJoins.delete(memberId)
|
||||
try {
|
||||
await this.grokChat.apiJoinGroup(evt.groupInfo.groupId)
|
||||
} catch (err) {
|
||||
logError(`Grok failed to join group ${evt.groupInfo.groupId}`, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Join succeeded — set maps and resolve waiter
|
||||
this.grokGroupMap.set(mainGroupId, evt.groupInfo.groupId)
|
||||
this.reverseGrokMap.set(evt.groupInfo.groupId, mainGroupId)
|
||||
const resolver = this.grokJoinResolvers.get(mainGroupId)
|
||||
if (resolver) {
|
||||
this.grokJoinResolvers.delete(mainGroupId)
|
||||
resolver()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Internal Processing ---
|
||||
|
||||
private async processChatItem(ci: T.AChatItem): Promise<void> {
|
||||
const {chatInfo, chatItem} = ci
|
||||
if (chatInfo.type !== "group") return
|
||||
const groupInfo = chatInfo.groupInfo
|
||||
if (!groupInfo.businessChat) return
|
||||
const groupId = groupInfo.groupId
|
||||
const state = this.conversations.get(groupId)
|
||||
if (!state) return
|
||||
|
||||
if (chatItem.chatDir.type === "groupSnd") return
|
||||
if (chatItem.chatDir.type !== "groupRcv") return
|
||||
const sender = chatItem.chatDir.groupMember
|
||||
|
||||
const isCustomer = sender.memberId === groupInfo.businessChat.customerId
|
||||
const isTeamMember = (state.type === "teamPending" || state.type === "teamLocked")
|
||||
&& sender.groupMemberId === state.teamMemberGId
|
||||
const isGrok = state.type === "grokMode"
|
||||
&& state.grokMemberGId === sender.groupMemberId
|
||||
|
||||
if (isGrok) return
|
||||
if (isCustomer) await this.onCustomerMessage(groupId, groupInfo, chatItem, state)
|
||||
else if (isTeamMember) await this.onTeamMemberMessage(groupId, state)
|
||||
}
|
||||
|
||||
private async onCustomerMessage(
|
||||
groupId: number,
|
||||
groupInfo: T.GroupInfo,
|
||||
chatItem: T.ChatItem,
|
||||
state: ConversationState,
|
||||
): Promise<void> {
|
||||
const cmd = util.ciBotCommand(chatItem)
|
||||
const text = util.ciContentText(chatItem)?.trim() || null
|
||||
|
||||
switch (state.type) {
|
||||
case "welcome": {
|
||||
if (!text) return
|
||||
await this.forwardToTeam(groupId, groupInfo, text)
|
||||
await this.sendToGroup(groupId, teamQueueMessage(this.config.timezone))
|
||||
this.conversations.set(groupId, {type: "teamQueue", userMessages: [text]})
|
||||
break
|
||||
}
|
||||
|
||||
case "teamQueue": {
|
||||
if (cmd?.keyword === "grok") {
|
||||
await this.activateGrok(groupId, state)
|
||||
return
|
||||
}
|
||||
if (cmd?.keyword === "team") {
|
||||
await this.activateTeam(groupId, state)
|
||||
return
|
||||
}
|
||||
if (!text) return
|
||||
await this.forwardToTeam(groupId, groupInfo, text)
|
||||
state.userMessages.push(text)
|
||||
break
|
||||
}
|
||||
|
||||
case "grokMode": {
|
||||
if (cmd?.keyword === "grok") return
|
||||
if (cmd?.keyword === "team") {
|
||||
await this.activateTeam(groupId, state)
|
||||
return
|
||||
}
|
||||
if (!text) return
|
||||
await this.forwardToTeam(groupId, groupInfo, text)
|
||||
await this.forwardToGrok(groupId, text, state)
|
||||
break
|
||||
}
|
||||
|
||||
case "teamPending": {
|
||||
if (cmd?.keyword === "grok") {
|
||||
await this.sendToGroup(groupId, teamLockedMessage)
|
||||
return
|
||||
}
|
||||
// /team → ignore (already team). Other text → no forwarding (team sees directly).
|
||||
break
|
||||
}
|
||||
|
||||
case "teamLocked": {
|
||||
if (cmd?.keyword === "grok") {
|
||||
await this.sendToGroup(groupId, teamLockedMessage)
|
||||
return
|
||||
}
|
||||
// No action — team sees directly
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async onTeamMemberMessage(groupId: number, state: ConversationState): Promise<void> {
|
||||
if (state.type !== "teamPending") return
|
||||
log(`Team member engaged in group ${groupId}, locking to teamLocked`)
|
||||
this.conversations.set(groupId, {type: "teamLocked", teamMemberGId: state.teamMemberGId})
|
||||
}
|
||||
|
||||
// --- Grok Activation ---
|
||||
|
||||
private async activateGrok(
|
||||
groupId: number,
|
||||
state: {type: "teamQueue"; userMessages: string[]},
|
||||
): Promise<void> {
|
||||
const grokContactId = this.config.grokContact!.id
|
||||
let member: T.GroupMember | undefined
|
||||
try {
|
||||
member = await this.mainChat.apiAddMember(groupId, grokContactId, T.GroupMemberRole.Member)
|
||||
} catch (err) {
|
||||
logError(`Failed to invite Grok to group ${groupId}`, err)
|
||||
await this.sendToGroup(groupId, "Grok is temporarily unavailable. Please try again or click /team for a team member.")
|
||||
return
|
||||
}
|
||||
|
||||
this.pendingGrokJoins.set(member.memberId, groupId)
|
||||
await this.sendToGroup(groupId, grokActivatedMessage)
|
||||
|
||||
// Wait for Grok agent to join the group
|
||||
const joined = await this.waitForGrokJoin(groupId, 30000)
|
||||
if (!joined) {
|
||||
this.pendingGrokJoins.delete(member.memberId)
|
||||
await this.sendToGroup(groupId, "Grok is temporarily unavailable. Please try again or click /team for a team member.")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify state hasn't changed while awaiting (e.g., user sent /team concurrently)
|
||||
const currentState = this.conversations.get(groupId)
|
||||
if (!currentState || currentState.type !== "teamQueue") {
|
||||
log(`State changed during Grok activation for group ${groupId} (now ${currentState?.type}), aborting`)
|
||||
try {
|
||||
await this.mainChat.apiRemoveMembers(groupId, [member.groupMemberId])
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
this.cleanupGrokMaps(groupId)
|
||||
return
|
||||
}
|
||||
|
||||
// Grok joined — call API with accumulated messages
|
||||
try {
|
||||
const initialUserMsg = state.userMessages.join("\n")
|
||||
const response = await this.grokApi.chat([], initialUserMsg)
|
||||
|
||||
// Re-check state after async API call — another event may have changed it
|
||||
const postApiState = this.conversations.get(groupId)
|
||||
if (!postApiState || postApiState.type !== "teamQueue") {
|
||||
log(`State changed during Grok API call for group ${groupId} (now ${postApiState?.type}), aborting`)
|
||||
try {
|
||||
await this.mainChat.apiRemoveMembers(groupId, [member.groupMemberId])
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
this.cleanupGrokMaps(groupId)
|
||||
return
|
||||
}
|
||||
|
||||
const history: GrokMessage[] = [
|
||||
{role: "user", content: initialUserMsg},
|
||||
{role: "assistant", content: response},
|
||||
]
|
||||
|
||||
const grokLocalGId = this.grokGroupMap.get(groupId)
|
||||
if (grokLocalGId === undefined) {
|
||||
log(`Grok map entry missing after join for group ${groupId}`)
|
||||
return
|
||||
}
|
||||
await this.grokChat.apiSendTextMessage([T.ChatType.Group, grokLocalGId], response)
|
||||
|
||||
this.conversations.set(groupId, {
|
||||
type: "grokMode",
|
||||
grokMemberGId: member.groupMemberId,
|
||||
history,
|
||||
})
|
||||
} catch (err) {
|
||||
logError(`Grok API/send failed for group ${groupId}`, err)
|
||||
// Remove Grok since activation failed after join
|
||||
try {
|
||||
await this.mainChat.apiRemoveMembers(groupId, [member.groupMemberId])
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
this.cleanupGrokMaps(groupId)
|
||||
await this.sendToGroup(groupId, "Grok is temporarily unavailable. Please try again or click /team for a team member.")
|
||||
// Stay in teamQueue
|
||||
}
|
||||
}
|
||||
|
||||
// --- Grok Message Forwarding ---
|
||||
|
||||
private async forwardToGrok(
|
||||
groupId: number,
|
||||
text: string,
|
||||
state: {type: "grokMode"; grokMemberGId: number; history: GrokMessage[]},
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await this.grokApi.chat(state.history, text)
|
||||
state.history.push({role: "user", content: text})
|
||||
state.history.push({role: "assistant", content: response})
|
||||
|
||||
const grokLocalGId = this.grokGroupMap.get(groupId)
|
||||
if (grokLocalGId !== undefined) {
|
||||
await this.grokChat.apiSendTextMessage([T.ChatType.Group, grokLocalGId], response)
|
||||
}
|
||||
} catch (err) {
|
||||
logError(`Grok API error for group ${groupId}`, err)
|
||||
// Per plan: revert to teamQueue on Grok API failure — remove Grok, clean up
|
||||
try {
|
||||
await this.mainChat.apiRemoveMembers(groupId, [state.grokMemberGId])
|
||||
} catch {
|
||||
// ignore — may have already left
|
||||
}
|
||||
this.cleanupGrokMaps(groupId)
|
||||
this.conversations.set(groupId, {type: "teamQueue", userMessages: []})
|
||||
await this.sendToGroup(groupId, "Grok is temporarily unavailable. Please try again or click /team for a team member.")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Team Actions ---
|
||||
|
||||
private async forwardToTeam(groupId: number, groupInfo: T.GroupInfo, text: string): Promise<void> {
|
||||
const name = groupInfo.groupProfile.displayName || `group-${groupId}`
|
||||
const fwd = `[${name} #${groupId}]\n${text}`
|
||||
try {
|
||||
await this.mainChat.apiSendTextMessage(
|
||||
[T.ChatType.Group, this.config.teamGroup.id],
|
||||
fwd,
|
||||
)
|
||||
} catch (err) {
|
||||
logError(`Failed to forward to team for group ${groupId}`, err)
|
||||
}
|
||||
}
|
||||
|
||||
private async activateTeam(groupId: number, state: ConversationState): Promise<void> {
|
||||
// Remove Grok immediately if present (per spec: "When switching to team mode, Grok is removed")
|
||||
const wasGrokMode = state.type === "grokMode"
|
||||
if (wasGrokMode) {
|
||||
try {
|
||||
await this.mainChat.apiRemoveMembers(groupId, [state.grokMemberGId])
|
||||
} catch {
|
||||
// ignore — may have already left
|
||||
}
|
||||
this.cleanupGrokMaps(groupId)
|
||||
}
|
||||
try {
|
||||
const teamContactId = this.config.teamMembers[0].id
|
||||
const member = await this.mainChat.apiAddMember(groupId, teamContactId, T.GroupMemberRole.Member)
|
||||
this.conversations.set(groupId, {
|
||||
type: "teamPending",
|
||||
teamMemberGId: member.groupMemberId,
|
||||
})
|
||||
await this.sendToGroup(groupId, teamAddedMessage(this.config.timezone))
|
||||
} catch (err) {
|
||||
logError(`Failed to add team member to group ${groupId}`, err)
|
||||
// If Grok was removed, state is stale (grokMode but Grok gone) — revert to teamQueue
|
||||
if (wasGrokMode) {
|
||||
this.conversations.set(groupId, {type: "teamQueue", userMessages: []})
|
||||
}
|
||||
await this.sendToGroup(groupId, "Sorry, there was an error adding a team member. Please try again.")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
private async addReplacementTeamMember(groupId: number): Promise<void> {
|
||||
try {
|
||||
const teamContactId = this.config.teamMembers[0].id
|
||||
const member = await this.mainChat.apiAddMember(groupId, teamContactId, T.GroupMemberRole.Member)
|
||||
this.conversations.set(groupId, {type: "teamLocked", teamMemberGId: member.groupMemberId})
|
||||
} catch (err) {
|
||||
logError(`Failed to add replacement team member to group ${groupId}`, err)
|
||||
// Stay in teamLocked with stale teamMemberGId — one-way gate must hold
|
||||
// Team will see the message in team group and can join manually
|
||||
}
|
||||
}
|
||||
|
||||
private async sendToGroup(groupId: number, text: string): Promise<void> {
|
||||
try {
|
||||
await this.mainChat.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.grokGroupMap.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 cleanupGrokMaps(groupId: number): void {
|
||||
const grokLocalGId = this.grokGroupMap.get(groupId)
|
||||
this.grokGroupMap.delete(groupId)
|
||||
if (grokLocalGId !== undefined) {
|
||||
this.reverseGrokMap.delete(grokLocalGId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
export interface IdName {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
dbPrefix: string
|
||||
grokDbPrefix: string
|
||||
teamGroup: IdName
|
||||
teamMembers: IdName[]
|
||||
grokContact: IdName | null // null during first-run
|
||||
groupLinks: string
|
||||
timezone: string
|
||||
grokApiKey: string
|
||||
firstRun: boolean
|
||||
}
|
||||
|
||||
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 requiredArg(args: string[], flag: string): string {
|
||||
const i = args.indexOf(flag)
|
||||
if (i < 0 || i + 1 >= args.length) throw new Error(`Missing required argument: ${flag}`)
|
||||
return args[i + 1]
|
||||
}
|
||||
|
||||
function optionalArg(args: string[], flag: string, defaultValue: string): string {
|
||||
const i = args.indexOf(flag)
|
||||
if (i < 0 || i + 1 >= args.length) return defaultValue
|
||||
return args[i + 1]
|
||||
}
|
||||
|
||||
export function parseConfig(args: string[]): Config {
|
||||
const firstRun = args.includes("--first-run")
|
||||
|
||||
const grokApiKey = process.env.GROK_API_KEY
|
||||
if (!grokApiKey) throw new Error("Missing environment variable: GROK_API_KEY")
|
||||
|
||||
const dbPrefix = optionalArg(args, "--db-prefix", "./data/bot")
|
||||
const grokDbPrefix = optionalArg(args, "--grok-db-prefix", "./data/grok")
|
||||
const teamGroup = parseIdName(requiredArg(args, "--team-group"))
|
||||
const teamMembers = requiredArg(args, "--team-members").split(",").map(parseIdName)
|
||||
if (teamMembers.length === 0) throw new Error("--team-members must have at least one member")
|
||||
|
||||
let grokContact: IdName | null = null
|
||||
if (!firstRun) {
|
||||
grokContact = parseIdName(requiredArg(args, "--grok-contact"))
|
||||
} else {
|
||||
const i = args.indexOf("--grok-contact")
|
||||
if (i >= 0 && i + 1 < args.length) {
|
||||
grokContact = parseIdName(args[i + 1])
|
||||
}
|
||||
}
|
||||
|
||||
const groupLinks = optionalArg(args, "--group-links", "")
|
||||
const timezone = optionalArg(args, "--timezone", "UTC")
|
||||
|
||||
return {
|
||||
dbPrefix,
|
||||
grokDbPrefix,
|
||||
teamGroup,
|
||||
teamMembers,
|
||||
grokContact,
|
||||
groupLinks,
|
||||
timezone,
|
||||
grokApiKey,
|
||||
firstRun,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import {GrokMessage} from "./state.js"
|
||||
import {log} from "./util.js"
|
||||
|
||||
interface GrokApiMessage {
|
||||
role: "system" | "user" | "assistant"
|
||||
content: string
|
||||
}
|
||||
|
||||
interface GrokApiResponse {
|
||||
choices: {message: {content: string}}[]
|
||||
}
|
||||
|
||||
export class GrokApiClient {
|
||||
constructor(private apiKey: string, private docsContext: string) {}
|
||||
|
||||
async chat(history: GrokMessage[], userMessage: string): Promise<string> {
|
||||
const messages: GrokApiMessage[] = [
|
||||
{role: "system", content: this.systemPrompt()},
|
||||
...history.slice(-20),
|
||||
{role: "user", content: userMessage},
|
||||
]
|
||||
log(`Grok API call: ${history.length} history msgs + new user msg (${userMessage.length} chars)`)
|
||||
const resp = await fetch("https://api.x.ai/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({model: "grok-3", messages, max_tokens: 2048}),
|
||||
})
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text()
|
||||
throw new Error(`Grok API ${resp.status}: ${body}`)
|
||||
}
|
||||
const data = (await resp.json()) as GrokApiResponse
|
||||
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
|
||||
}
|
||||
|
||||
private systemPrompt(): string {
|
||||
return `You are a privacy expert and SimpleX Chat evangelist. You know everything about SimpleX Chat apps, network, design choices, and trade-offs. Be helpful, accurate, and concise. If you don't know something, say so honestly rather than guessing. For every criticism, explain why the team made that design choice.\n\n${this.docsContext}`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import {readFileSync} from "fs"
|
||||
import {join} from "path"
|
||||
import {bot, api} from "simplex-chat"
|
||||
import {parseConfig} from "./config.js"
|
||||
import {SupportBot} from "./bot.js"
|
||||
import {GrokApiClient} from "./grok.js"
|
||||
import {welcomeMessage} from "./messages.js"
|
||||
import {log, logError} from "./util.js"
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const config = parseConfig(process.argv.slice(2))
|
||||
log("Config parsed", {
|
||||
dbPrefix: config.dbPrefix,
|
||||
grokDbPrefix: config.grokDbPrefix,
|
||||
teamGroup: config.teamGroup,
|
||||
teamMembers: config.teamMembers,
|
||||
grokContact: config.grokContact,
|
||||
firstRun: config.firstRun,
|
||||
timezone: config.timezone,
|
||||
})
|
||||
|
||||
// --- Init Grok agent (direct ChatApi) ---
|
||||
log("Initializing Grok agent...")
|
||||
const grokChat = await api.ChatApi.init(config.grokDbPrefix)
|
||||
let grokUser = await grokChat.apiGetActiveUser()
|
||||
if (!grokUser) {
|
||||
log("No Grok user, creating...")
|
||||
grokUser = await grokChat.apiCreateActiveUser({displayName: "Grok AI", fullName: ""})
|
||||
}
|
||||
log(`Grok user: ${grokUser.profile.displayName}`)
|
||||
await grokChat.startChat()
|
||||
|
||||
// --- First-run mode: establish contact between bot and Grok agent ---
|
||||
if (config.firstRun) {
|
||||
log("First-run mode: establishing bot↔Grok contact...")
|
||||
// We need to init the main bot first to create the invitation link
|
||||
const mainChat = await api.ChatApi.init(config.dbPrefix)
|
||||
let mainUser = await mainChat.apiGetActiveUser()
|
||||
if (!mainUser) {
|
||||
log("No main bot user, creating...")
|
||||
mainUser = await mainChat.apiCreateActiveUser({displayName: "SimpleX Support", fullName: ""})
|
||||
}
|
||||
await mainChat.startChat()
|
||||
|
||||
const invLink = await mainChat.apiCreateLink(mainUser.userId)
|
||||
log(`Invitation link created: ${invLink}`)
|
||||
|
||||
await grokChat.apiConnectActiveUser(invLink)
|
||||
log("Grok agent connecting...")
|
||||
|
||||
const evt = await mainChat.wait("contactConnected", 60000)
|
||||
if (!evt) {
|
||||
console.error("Timeout waiting for Grok agent to connect (60s). Exiting.")
|
||||
process.exit(1)
|
||||
}
|
||||
const contactId = evt.contact.contactId
|
||||
const displayName = evt.contact.profile.displayName
|
||||
log(`Grok contact established. ContactId=${contactId}`)
|
||||
console.log(`\nGrok contact established. Use: --grok-contact ${contactId}:${displayName}\n`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// --- Normal mode: validate config, init main bot ---
|
||||
if (!config.grokContact) {
|
||||
console.error("--grok-contact is required (unless --first-run)")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// SupportBot forward-reference: assigned after bot.run returns.
|
||||
// Events use optional chaining so any events during init are safely skipped.
|
||||
let supportBot: SupportBot | undefined
|
||||
|
||||
const events: api.EventSubscribers = {
|
||||
acceptingBusinessRequest: (evt) => supportBot?.onBusinessRequest(evt),
|
||||
newChatItems: (evt) => supportBot?.onNewChatItems(evt),
|
||||
leftMember: (evt) => supportBot?.onLeftMember(evt),
|
||||
deletedMemberUser: (evt) => supportBot?.onDeletedMemberUser(evt),
|
||||
groupDeleted: (evt) => supportBot?.onGroupDeleted(evt),
|
||||
connectedToGroupMember: (evt) => supportBot?.onMemberConnected(evt),
|
||||
}
|
||||
|
||||
log("Initializing main bot...")
|
||||
const [mainChat, mainUser, _mainAddress] = await bot.run({
|
||||
profile: {displayName: "SimpleX Support", fullName: ""},
|
||||
dbOpts: {dbFilePrefix: config.dbPrefix},
|
||||
options: {
|
||||
addressSettings: {
|
||||
businessAddress: true,
|
||||
autoAccept: true,
|
||||
welcomeMessage: welcomeMessage(config.groupLinks),
|
||||
},
|
||||
commands: [
|
||||
{type: "command", keyword: "grok", label: "Ask Grok AI"},
|
||||
{type: "command", keyword: "team", label: "Switch to team"},
|
||||
],
|
||||
useBotProfile: true,
|
||||
},
|
||||
events,
|
||||
})
|
||||
log(`Main bot user: ${mainUser.profile.displayName}`)
|
||||
|
||||
// --- Startup validation ---
|
||||
log("Validating config against live data...")
|
||||
|
||||
// Validate team group
|
||||
const groups = await mainChat.apiListGroups(mainUser.userId)
|
||||
const teamGroup = groups.find(g => g.groupId === config.teamGroup.id)
|
||||
if (!teamGroup) {
|
||||
console.error(`Team group not found: ID=${config.teamGroup.id}. Available groups: ${groups.map(g => `${g.groupId}:${g.groupProfile.displayName}`).join(", ") || "(none)"}`)
|
||||
process.exit(1)
|
||||
}
|
||||
if (teamGroup.groupProfile.displayName !== config.teamGroup.name) {
|
||||
console.error(`Team group name mismatch: expected "${config.teamGroup.name}", got "${teamGroup.groupProfile.displayName}" (ID=${config.teamGroup.id})`)
|
||||
process.exit(1)
|
||||
}
|
||||
log(`Team group validated: ${config.teamGroup.id}:${config.teamGroup.name}`)
|
||||
|
||||
// Validate contacts (team members + Grok)
|
||||
const contacts = await mainChat.apiListContacts(mainUser.userId)
|
||||
for (const member of config.teamMembers) {
|
||||
const contact = contacts.find(c => c.contactId === member.id)
|
||||
if (!contact) {
|
||||
console.error(`Team member not found: ID=${member.id}. Available contacts: ${contacts.map(c => `${c.contactId}:${c.profile.displayName}`).join(", ") || "(none)"}`)
|
||||
process.exit(1)
|
||||
}
|
||||
if (contact.profile.displayName !== member.name) {
|
||||
console.error(`Team member name mismatch: expected "${member.name}", got "${contact.profile.displayName}" (ID=${member.id})`)
|
||||
process.exit(1)
|
||||
}
|
||||
log(`Team member validated: ${member.id}:${member.name}`)
|
||||
}
|
||||
|
||||
const grokContact = contacts.find(c => c.contactId === config.grokContact!.id)
|
||||
if (!grokContact) {
|
||||
console.error(`Grok contact not found: ID=${config.grokContact.id}. Available contacts: ${contacts.map(c => `${c.contactId}:${c.profile.displayName}`).join(", ") || "(none)"}`)
|
||||
process.exit(1)
|
||||
}
|
||||
if (grokContact.profile.displayName !== config.grokContact.name) {
|
||||
console.error(`Grok contact name mismatch: expected "${config.grokContact.name}", got "${grokContact.profile.displayName}" (ID=${config.grokContact.id})`)
|
||||
process.exit(1)
|
||||
}
|
||||
log(`Grok contact validated: ${config.grokContact.id}:${config.grokContact.name}`)
|
||||
|
||||
log("All config validated.")
|
||||
|
||||
// Load Grok context docs
|
||||
let docsContext = ""
|
||||
try {
|
||||
docsContext = readFileSync(join(process.cwd(), "docs", "simplex-context.md"), "utf-8")
|
||||
log(`Loaded Grok context docs: ${docsContext.length} chars`)
|
||||
} catch {
|
||||
log("Warning: docs/simplex-context.md not found, Grok will operate without context docs")
|
||||
}
|
||||
const grokApi = new GrokApiClient(config.grokApiKey, docsContext)
|
||||
|
||||
// Create SupportBot — event handlers now route through it
|
||||
supportBot = new SupportBot(mainChat, grokChat, grokApi, config)
|
||||
log("SupportBot initialized. Bot running.")
|
||||
|
||||
// Subscribe Grok agent event handlers
|
||||
grokChat.on("receivedGroupInvitation", async (evt) => {
|
||||
await supportBot?.onGrokGroupInvitation(evt)
|
||||
})
|
||||
|
||||
// Keep process alive
|
||||
process.on("SIGINT", () => {
|
||||
log("Received SIGINT, shutting down...")
|
||||
process.exit(0)
|
||||
})
|
||||
process.on("SIGTERM", () => {
|
||||
log("Received SIGTERM, shutting down...")
|
||||
process.exit(0)
|
||||
})
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
logError("Fatal error", err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import {isWeekend} from "./util.js"
|
||||
|
||||
export function welcomeMessage(groupLinks: string): string {
|
||||
return `Hello! Feel free to ask any question about SimpleX Chat.\n*Only SimpleX Chat team has access to your messages.* This is a SimpleX Chat team bot — it is not any LLM or AI.${groupLinks ? `\n*Join public groups*: ${groupLinks}` : ""}\nPlease send questions in English, you can use translator.`
|
||||
}
|
||||
|
||||
export function teamQueueMessage(timezone: string): string {
|
||||
const hours = isWeekend(timezone) ? "48" : "24"
|
||||
return `Thank you for your message, it is forwarded to the team.\nIt may take a team member up to ${hours} hours to reply.\n\nClick /grok if your question is about SimpleX apps or network, is not sensitive, and you want Grok LLM to answer it right away. *Your previous message and all subsequent messages will be forwarded to Grok* until you click /team. You can ask Grok questions in any language and it will not see your profile name.\n\nWe appreciate if you try Grok: you can learn a lot about SimpleX Chat from it. It is objective, answers the way our team would, and it saves our team time.`
|
||||
}
|
||||
|
||||
export const grokActivatedMessage = `*You are now chatting with Grok. You can send questions in any language.* Your message(s) have been forwarded.\nSend /team at any time to switch to a human team member.`
|
||||
|
||||
export function teamAddedMessage(timezone: string): string {
|
||||
const hours = isWeekend(timezone) ? "48" : "24"
|
||||
return `A team member has been added and will reply within ${hours} hours. You can keep describing your issue — they will see the full conversation.`
|
||||
}
|
||||
|
||||
export const teamLockedMessage = "You are now in team mode. A team member will reply to your message."
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface GrokMessage {
|
||||
role: "user" | "assistant"
|
||||
content: string
|
||||
}
|
||||
|
||||
export type ConversationState =
|
||||
| {type: "welcome"}
|
||||
| {type: "teamQueue"; userMessages: string[]}
|
||||
| {type: "grokMode"; grokMemberGId: number; history: GrokMessage[]}
|
||||
| {type: "teamPending"; teamMemberGId: number}
|
||||
| {type: "teamLocked"; teamMemberGId: number}
|
||||
@@ -0,0 +1,14 @@
|
||||
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()
|
||||
console.log(`[${ts}] ${msg}`, ...args)
|
||||
}
|
||||
|
||||
export function logError(msg: string, err: unknown): void {
|
||||
const ts = new Date().toISOString()
|
||||
console.error(`[${ts}] ${msg}`, err)
|
||||
}
|
||||
@@ -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,0 +1,10 @@
|
||||
import {defineConfig} from "vitest/config"
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["bot.test.ts"],
|
||||
typecheck: {
|
||||
include: ["bot.test.ts"],
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user