support-bot: /team should give owner to invited member

This commit is contained in:
Narasimha-sc
2026-04-23 15:39:19 +00:00
parent 231a71e3c2
commit d061760f4f
4 changed files with 65 additions and 21 deletions
+41
View File
@@ -948,6 +948,47 @@ describe("Team Member Lifecycle", () => {
expect(chat.roleChanges.some(r => r.groupId === CUSTOMER_GROUP_ID && r.memberIds.includes(5000 + TEAM_MEMBER_1_ID) && r.role === GroupMemberRole.Owner)).toBe(true)
})
test("/team invites team member → apiSetMembersRole(Owner) called at invite time", async () => {
await bot.onNewChatItems(customerMessage("/team"))
expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_1_ID)
expect(chat.roleChanges.some(r =>
r.groupId === CUSTOMER_GROUP_ID
&& r.memberIds.includes(5000 + TEAM_MEMBER_1_ID)
&& r.role === GroupMemberRole.Owner
)).toBe(true)
})
test("/join invites team member → apiSetMembersRole(Owner) called at invite time", async () => {
await bot.onNewChatItems(teamGroupMessage(`/join ${CUSTOMER_GROUP_ID}`))
expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_1_ID)
expect(chat.roleChanges.some(r =>
r.groupId === CUSTOMER_GROUP_ID
&& r.memberIds.includes(5000 + TEAM_MEMBER_1_ID)
&& r.role === GroupMemberRole.Owner
)).toBe(true)
})
test("/team when team member already in group (any non-terminal status) → apiSetMembersRole NOT re-called", async () => {
chat.members.set(CUSTOMER_GROUP_ID, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")])
await cards.mergeCustomData(CUSTOMER_GROUP_ID, {state: "TEAM-PENDING"})
chat.added.length = 0
chat.roleChanges.length = 0
await bot.onNewChatItems(customerMessage("/team"))
expect(chat.added.length).toBe(0)
expect(chat.roleChanges.length).toBe(0)
})
test("/join when team member already in group → apiSetMembersRole NOT re-called", async () => {
chat.members.set(CUSTOMER_GROUP_ID, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")])
chat.added.length = 0
chat.roleChanges.length = 0
await bot.onNewChatItems(teamGroupMessage(`/join ${CUSTOMER_GROUP_ID}`))
expect(chat.added.length).toBe(0)
expect(chat.roleChanges.length).toBe(0)
})
test("customer connected → NOT promoted to Owner", async () => {
await bot.onMemberConnected(connectedEvent(CUSTOMER_GROUP_ID, makeCustomerMember()))
expect(chat.roleChanges.length).toBe(0)
@@ -655,7 +655,7 @@ chat.on("connectedToGroupMember", (evt) => {
The gate is event-driven and persists its transitions. The initial `/team` guard reads `customData.state` AND group composition: if state is already `TEAM-PENDING`/`TEAM` **and** team members are still present, the bot replies `teamAlreadyInvitedMessage` without re-adding. If state is `TEAM-PENDING`/`TEAM` but all team members have left, the bot re-adds them (state stays `TEAM-PENDING`). The first-team-message detection writes `state: 'TEAM'` into customData at the moment the bot observes the message, then removes Grok and disables `/grok`.
1. User sends `/team` → ALL configured `--auto-add-team-members` (`-a`) added to group (promoted to Owner on connect) → Grok stays if present → TEAM-PENDING
1. User sends `/team` → ALL configured `--auto-add-team-members` (`-a`) added to group (each promoted to Owner at invite time via `apiSetMembersRole`, re-asserted on connect as fallback) → Grok stays if present → TEAM-PENDING
2. Repeat `/team` → detected via `customData.state ∈ {TEAM-PENDING, TEAM}` **and team members still present** → reply with `teamAlreadyInvitedMessage`. If team members have since left, re-add them silently (state stays `TEAM-PENDING`).
3. `/grok` still works in TEAM-PENDING (if Grok not present, invite it; if present, ignore — Grok responds to customer messages)
4. Any team member sends first text message in customer group → **gate triggers**:
@@ -839,10 +839,12 @@ Ordering guarantees preserved:
1. Extract `{keyword, params}` from the chat item with `util.ciBotCommand(chatItem)`. The framework already parses the leading `/keyword` and returns the trimmed remainder as `params` — the handler does not run its own regex over the message text. Cards emit `/'join <groupId>'`; a team-member tap delivers a chat item whose text is `/join <groupId>`, which `ciBotCommand` returns as `{keyword: "join", params: "<groupId>"}`.
2. Convert `params` to a number with `const targetGroupId = Number.parseInt(params, 10)`. If `Number.isNaN(targetGroupId) || targetGroupId <= 0`, reply in the team group with `Error: invalid group id "${params}"` and return. No regex, no `split(":")`, no legacy fallback — operators must use the numeric form (which is what the card always emits).
3. Validate target is a business group (has `businessChat` property) — error in team group if not.
4. Add requesting team member to customer group via `apiAddMember`.
5. Member promoted to Owner on `connectedToGroupMember` (see §8).
4. Add requesting team member to customer group via `addOrFindTeamMember` (which calls `apiAddMember` + immediately `apiSetMembersRole(Owner)`).
5. On connect, `connectedToGroupMember` re-asserts Owner as an idempotent fallback (see §8).
**Team member promotion:** On every `connectedToGroupMember` in a customer group, promote to Owner unless customer or Grok. Idempotent.
**Team member promotion:** Promotion happens at two points, both idempotent:
- **At invite time** — immediately after `apiAddMember`, `addOrFindTeamMember` calls `apiSetMembersRole(groupId, [memberId], Owner)`. The call is wrapped in try/catch: if the member is not yet connected and the API rejects, it's silently ignored (the connect-time promotion covers the fallback). SimpleX persists the role on `GSMemInvited` members so the role is active when they accept. This is only called for *newly invited* members — the pre-check in `addOrFindTeamMember` returns early for any member already in the group in a non-terminal status, so an already-invited member is not re-promoted.
- **On connect** — every `connectedToGroupMember` event in a customer group promotes to Owner unless the member is the customer or Grok. Idempotent.
**DM handshake:** When a team member joins or connects in the team group, the bot sends a DM with the member's contact ID. Four delivery paths, deduplicated via `sentTeamDMs` Set:
@@ -935,7 +937,7 @@ If a user contacts the bot via a regular direct-message address (not business ad
| Customer name | Group display name |
| `pendingGrokJoins` | In-flight during 120s window only |
| `grokInitialResponsePending` | In-flight during `activateGrok` initial response only |
| Owner promotion | Idempotent on every `memberConnected` |
| Owner promotion | Idempotent: fired at invite time in `addOrFindTeamMember` and again on every `memberConnected` |
**Failure modes:**
- State file deleted → new team group created, Grok contact re-established (60s delay)
@@ -958,7 +960,7 @@ If a user contacts the bot via a regular direct-message address (not business ad
| Team member leaves (message sent) | State stays `TEAM` (`customData.state` persists). Customer's next `/team` re-adds silently. |
| No `--auto-add-team-members` (`-a`) configured | `/team` → "no team members available yet" |
| `grokContactId` unavailable | `/grok` → "temporarily unavailable" |
| Member already in group when `/team` re-runs | `addOrFindTeamMember` pre-checks via `apiListMembers` and skips `apiAddMember` entirely if the contact is present in any non-terminal status (so an `Invited`-but-not-yet-accepted member is never re-invited — the SimpleX API would otherwise resend the invitation for `GSMemInvited`) |
| Member already in group when `/team` re-runs | `addOrFindTeamMember` pre-checks via `apiListMembers` and skips BOTH `apiAddMember` and the invite-time `apiSetMembersRole(Owner)` entirely if the contact is present in any non-terminal status (so an `Invited`-but-not-yet-accepted member is never re-invited — the SimpleX API would otherwise resend the invitation for `GSMemInvited` — and is never re-promoted) |
## 16. API Call Map
@@ -983,8 +985,8 @@ If a user contacts the bot via a regular direct-message address (not business ad
| 17 | Grok joins | grok | `apiJoinGroup(gId)` | `receivedGroupInvitation` |
| 18 | Grok reads history | grok | `apiGetChat([Group, gId], 100)` | After join + per message |
| 19 | Grok sends response | grok | `apiSendTextMessage([Group, gId], text)` | After API call |
| 20 | Add team member | main | `apiAddMember(gId, teamContactId, Member)` | `/team`, `/join` |
| 21 | Promote to Owner | main | `apiSetMembersRole(gId, [memberId], Owner)` | `connectedToGroupMember` |
| 20 | Add team member | main | `apiAddMember(gId, teamContactId, Member)` | `/team`, `/join` — only when not already in group |
| 21 | Promote to Owner | main | `apiSetMembersRole(gId, [memberId], Owner)` | Immediately after #20 (invite-time) AND `connectedToGroupMember` (fallback) |
| 22 | Remove Grok | main | `apiRemoveMembers(gId, [memberId])` | Gate trigger / timeout / leave |
| 23 | List members | main | `apiListMembers(gId)` | State derivation, duplicate check |
| 24 | Register team commands | main | `apiUpdateGroupProfile(teamGId, profile)` | Startup — register `/join` in team group |
@@ -88,7 +88,7 @@ Grok is prompted as a privacy expert and support assistant who knows SimpleX Cha
#### Step 4 — `/team` (Team mode, one-way gate)
Available in WELCOME, QUEUE, or GROK state. If `/team` is the customer's first message, the bot transitions directly from WELCOME → TEAM-PENDING — it creates the card with 👋 icon and does not send the queue message. Bot adds all configured `--auto-add-team-members` (`-a`) to the support group (promoted to Owner once connected — the bot promotes every non-customer, non-Grok member to Owner on `memberConnected`; safe to repeat). If team was already activated (`customData.state` is already `TEAM-PENDING` or `TEAM` **and** team members are still present), sends the "already invited" message instead. If the team was previously activated but all team members have since left, the bot re-adds them silently; state remains `TEAM-PENDING`.
Available in WELCOME, QUEUE, or GROK state. If `/team` is the customer's first message, the bot transitions directly from WELCOME → TEAM-PENDING — it creates the card with 👋 icon and does not send the queue message. Bot adds all configured `--auto-add-team-members` (`-a`) to the support group as Owners — immediately after `apiAddMember`, the bot calls `apiSetMembersRole(Owner)` so the role is set at invite time (SimpleX persists the role on pending invites), with a fallback re-promotion on `memberConnected` (every non-customer, non-Grok member gets promoted; safe to repeat). If team was already activated (`customData.state` is already `TEAM-PENDING` or `TEAM` **and** team members are still present), sends the "already invited" message instead. If the team was previously activated but all team members have since left, the bot re-adds them silently; state remains `TEAM-PENDING`.
Bot replies:
> We will reply within 24 hours.
@@ -312,13 +312,13 @@ Team members use these commands in the team group:
| Command | Effect |
|---------|--------|
| `/join <groupId>` | Join the specified customer group (promoted to Owner once connected). Card emits the clickable form `/'join <groupId>'`; the handler reads `groupId` from the framework's structured command (`util.ciBotCommand → {keyword, params}`), not from regex over the message text. |
| `/join <groupId>` | Join the specified customer group as Owner. Card emits the clickable form `/'join <groupId>'`; the handler reads `groupId` from the framework's structured command (`util.ciBotCommand → {keyword, params}`), not from regex over the message text. |
`/join` is **team-only** — it is registered as a bot command only in the team group. If a customer sends `/join` in a customer group, the bot treats it as an ordinary message (per the existing rule: unrecognized commands are treated as normal messages).
#### Joining a customer group
When a team member taps `/join`, the bot first verifies that the target `groupId` is a business group hosted by the main profile (i.e., has a `businessChat` property). If not, the bot replies with an error in the team group and does nothing. If valid, the bot adds the team member to the customer group (promoted to Owner once connected). From within the customer group, the team member chats directly with the customer. Their messages trigger card updates in the team group (icon change, wait time reset). The customer sees the team member as a real group participant.
When a team member taps `/join`, the bot first verifies that the target `groupId` is a business group hosted by the main profile (i.e., has a `businessChat` property). If not, the bot replies with an error in the team group and does nothing. If valid, the bot adds the team member to the customer group (via the shared `addOrFindTeamMember` helper, which promotes to Owner at invite time via `apiSetMembersRole(Owner)`, with a fallback re-promotion on connect). From within the customer group, the team member chats directly with the customer. Their messages trigger card updates in the team group (icon change, wait time reset). The customer sees the team member as a real group participant.
#### Edge cases
@@ -372,7 +372,7 @@ GROK_API_KEY=... node dist/index.js --team-group "Support Team" [options]
| Command | Effect |
|---------|--------|
| `/join <groupId>` | Join the specified customer group (promoted to Owner once connected). Card emits the clickable form `/'join <groupId>'`; the handler reads `groupId` from the framework's structured command (`util.ciBotCommand → {keyword, params}`), not from regex over the message text. |
| `/join <groupId>` | Join the specified customer group as Owner. Card emits the clickable form `/'join <groupId>'`; the handler reads `groupId` from the framework's structured command (`util.ciBotCommand → {keyword, params}`), not from regex over the message text. |
### 5.2 Bot Architecture
@@ -500,7 +500,7 @@ Per-group state (`state`, `cardItemId`, `complete`) lives in SimpleX's database
| Customer name | Always available from the group's display name |
| Who sent last message | Derived from recent chat history |
| `pendingGrokJoins` | In-flight during the 120-second join window only |
| Owner role promotion | Not tracked — on every `memberConnected` in a customer group, the bot promotes the member to Owner unless it's the customer or Grok. Idempotent, survives restarts. |
| Owner role promotion | Not tracked — the bot promotes team members to Owner at two idempotent points: (1) at invite time, immediately after `apiAddMember` in `addOrFindTeamMember` (skipped if the member is already in the group); (2) on every `memberConnected` in a customer group (unless the member is the customer or Grok). Survives restarts. |
| `pendingTeamDMs` | Messages queued to greet team members — simply not sent if lost |
| `grokJoinResolvers`, `grokFullyConnected` | Pure async synchronization primitives — always empty at startup |
+9 -8
View File
@@ -776,13 +776,6 @@ export class SupportBot {
try {
const member = await this.addOrFindTeamMember(targetGroupId, senderContactId)
if (member) {
try {
await this.withMainProfile(() =>
this.chat.apiSetMembersRole(targetGroupId, [member.groupMemberId], T.GroupMemberRole.Owner)
)
} catch {
// Not yet connected — will be promoted in onMemberConnected
}
log(`Team member ${senderContactId} joined group ${targetGroupId} via /join`)
}
} catch (err) {
@@ -801,9 +794,17 @@ export class SupportBot {
const members = await this.withMainProfile(() => this.chat.apiListMembers(groupId))
const existing = members.find(m => m.memberContactId === teamContactId && isInGroup(m))
if (existing) return existing
return await this.withMainProfile(() =>
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> {