mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-05-23 13:16:36 +00:00
5a3dfdd2b4
* plans: 20260207-support-bot.md * Update 20260207-support-bot.md * plans: 20260207-support-bot-implementation.md * plans: Update 20260207-support-bot-implementation.md * Relocate plans * apps: support bot code & tests * apps: support bot relocate * support-bot: Fix basic functionality * apps: support-bot /add command & fixes * apps: simplex-support-bot: Change Grok logo * Further usability improvements * simplex-support-bot: Update support plan to reflect current flow * simplex-support-bot: update product design plan * support-bot: update plan * support-bot: review and refine product spec * support-bot: update product spec — complete state, /join team-only, card debouncing - Group preferences applied once at creation, not on every startup - /join restricted to team group only - Team/Grok reply or reaction auto-completes conversation (✅) - Customer message reverts to incomplete - Card updates debounced globally with 15-minute batch flush * support-bot: update implementation plan * support-bot: implement stateless bot with cards, Grok, team flow, hardening Complete rewrite of the support bot to stateless architecture: - State derived from group composition + chat history (survives restarts) - Card dashboard in team group with live status, preview, /join commands - Two-profile architecture (main + Grok) with profileMutex serialization - Grok join race condition fix via bufferedGrokInvitations - Card preview: newest-first truncation, newline sanitization, sender prefixes - Best-effort startup (invite link, group profile update) - Team group preferences: directMessages, fullDelete, commands - 122 tests across 27 suites * support-bot: use apiCreateMemberContact and apiSendMemberContactInvitation instead of raw commands Replace sendChatCmd("/_create member contact ...") and sendChatCmd("/_invite member contact ...") with the typed API methods added in simplex-chat-nodejs. Update plans and build script accordingly. * plans: 20260207-support-bot.md * Update 20260207-support-bot.md * plans: 20260207-support-bot-implementation.md * plans: Update 20260207-support-bot-implementation.md * Relocate plans * apps: support bot code & tests * apps: support bot relocate * support-bot: Fix basic functionality * apps: support-bot /add command & fixes * apps: simplex-support-bot: Change Grok logo * Further usability improvements * simplex-support-bot: Update support plan to reflect current flow * simplex-support-bot: update product design plan * support-bot: update plan * support-bot: review and refine product spec * support-bot: update product spec — complete state, /join team-only, card debouncing - Group preferences applied once at creation, not on every startup - /join restricted to team group only - Team/Grok reply or reaction auto-completes conversation (✅) - Customer message reverts to incomplete - Card updates debounced globally with 15-minute batch flush * support-bot: update implementation plan * support-bot: implement stateless bot with cards, Grok, team flow, hardening Complete rewrite of the support bot to stateless architecture: - State derived from group composition + chat history (survives restarts) - Card dashboard in team group with live status, preview, /join commands - Two-profile architecture (main + Grok) with profileMutex serialization - Grok join race condition fix via bufferedGrokInvitations - Card preview: newest-first truncation, newline sanitization, sender prefixes - Best-effort startup (invite link, group profile update) - Team group preferences: directMessages, fullDelete, commands - 122 tests across 27 suites * support-bot: use apiCreateMemberContact and apiSendMemberContactInvitation instead of raw commands Replace sendChatCmd("/_create member contact ...") and sendChatCmd("/_invite member contact ...") with the typed API methods added in simplex-chat-nodejs. Update plans and build script accordingly. * support-bot: more improvemets * support-bot: add tests for Grok batch dedup and initial response gating 7 new tests covering the duplicate Grok reply fix: - batch dedup: only last customer message per group triggers API call - batch dedup: multi-group batches handled independently - batch dedup: non-customer messages filtered from batch - initial response gating: per-message responses suppressed during activateGrok - gating clears: per-message responses resume after activation completes Update implementation plan test catalog (122 → 129 tests). * support-bot: load context from context file * Rename Grok AI -> Grok * Remove unused strings.ts * support-bot: change messages * cardFlushMinutes 15 -> cardFlushSeconds 300 * support-bot: /team message when grok present * support-bot: correct messages * support-bot: update plans to reflect latest changes * Update plan for state derivation * support-bot: Update state machine plans * support-bot: implement customData state * Fix Grok revertStateOnFail race condition * support-bot: plans adversarial review * support-bot: /join ID part of card in plan * support-bot: implement /join ID inside card * support-bot: plans use params instead of regex in /join * support-bot: Implement adversarial review changes * support-bot: no re-invite if already invited * support-bot: /team should give owner to invited member * Don't change username for existing database * support-bot: update bot commands before sending commands * support-bot: adversarial review fixes * support-bot: implement postgresql (#6876) * support-bot: sqlite/postgres backend via typed DbConfig and parseArgs flags * support-bot: add README with setup and flags reference * support-bot: use published simplex-chat, drop build.sh/start.sh * support-bot: switch CLI to commander, add --help * support-bot: update README --------- Co-authored-by: shum <github.shum@liber.li> Co-authored-by: sh <37271604+shumvgolove@users.noreply.github.com>
474 lines
17 KiB
TypeScript
474 lines
17 KiB
TypeScript
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} 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 groups = await this.withMainProfile(() => this.chat.apiListGroups(this.mainUserId))
|
||
const groupInfo = groups.find(g => g.groupId === 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> {
|
||
const groups = await this.withMainProfile(() => this.chat.apiListGroups(this.mainUserId))
|
||
const activeCards: {groupId: number; cardItemId: number}[] = []
|
||
for (const group of groups) {
|
||
const customData = group.customData as Record<string, unknown> | undefined
|
||
if (customData && typeof customData.cardItemId === "number" && !customData.complete) {
|
||
activeCards.push({groupId: group.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 groups = await this.withMainProfile(() => this.chat.apiListGroups(this.mainUserId))
|
||
const group = groups.find(g => g.groupId === 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> {
|
||
// Read customData and groupInfo in one apiListGroups call
|
||
const groups = await this.withMainProfile(() => this.chat.apiListGroups(this.mainUserId))
|
||
const groupInfo = groups.find(g => g.groupId === 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`
|
||
}
|
||
}
|