| 1 | import { asArray } from "../lib/array"; |
| 2 | import type { Translator } from "../lib/i18n"; |
| 3 | import type { BotConnectionView, BotRuntimeStatusView, BotSettingsView, SessionMeta } from "../lib/types"; |
| 4 | |
| 5 | export type SidebarImPlatform = "qq" | "feishu" | "lark" | "weixin"; |
| 6 | type SidebarImStatus = "connected" | "disabled" | "pending" | "error" | "disconnected"; |
| 7 | export type SidebarImConnection = { |
| 8 | id: string; |
| 9 | connectionId: string; |
| 10 | platform: SidebarImPlatform; |
| 11 | title: string; |
| 12 | platformLabel: string; |
| 13 | subtitle: string; |
| 14 | status: SidebarImStatus; |
| 15 | statusLabel: string; |
| 16 | remoteId: string; |
| 17 | sessionId: string; |
| 18 | sessionSource: string; |
| 19 | scope: "global" | "project"; |
| 20 | workspaceRoot: string; |
| 21 | allowAll: boolean; |
| 22 | allowlistEnabled: boolean; |
| 23 | allowlistUsers: string[]; |
| 24 | allowlistMatched: boolean; |
| 25 | }; |
| 26 | export type SidebarImTopicSource = { |
| 27 | platform: SidebarImPlatform; |
| 28 | label: string; |
| 29 | title: string; |
| 30 | remoteId: string; |
| 31 | connectionId: string; |
| 32 | }; |
| 33 | function isSidebarImConnection(connection: BotConnectionView): boolean { |
| 34 | return connection.provider === "feishu" || connection.provider === "weixin"; |
| 35 | } |
| 36 | |
| 37 | function sidebarImPlatform(connection: BotConnectionView): SidebarImPlatform { |
| 38 | if (connection.provider === "weixin") return "weixin"; |
| 39 | return connection.domain === "lark" ? "lark" : "feishu"; |
| 40 | } |
| 41 | |
| 42 | function sidebarImPlatformLabel(platform: SidebarImPlatform, translate: Translator): string { |
| 43 | if (platform === "qq") return "QQ"; |
| 44 | if (platform === "lark") return "Lark"; |
| 45 | if (platform === "weixin") return translate("settings.botWeixin"); |
| 46 | return translate("settings.botFeishu"); |
| 47 | } |
| 48 | |
| 49 | function botMappingScope(mapping: BotConnectionView["sessionMappings"][number] | null | undefined, connectionWorkspaceRoot: string): "global" | "project" { |
| 50 | if (mapping?.scope === "project") return "project"; |
| 51 | if ((mapping?.workspaceRoot ?? "").trim()) return "project"; |
| 52 | return connectionWorkspaceRoot.trim() ? "project" : "global"; |
| 53 | } |
| 54 | |
| 55 | function botMappingWorkspaceRoot( |
| 56 | mapping: BotConnectionView["sessionMappings"][number] | null | undefined, |
| 57 | connectionWorkspaceRoot: string, |
| 58 | ): string { |
| 59 | const workspaceRoot = (mapping?.workspaceRoot ?? "").trim() || connectionWorkspaceRoot.trim(); |
| 60 | return botMappingScope(mapping, connectionWorkspaceRoot) === "project" ? workspaceRoot : ""; |
| 61 | } |
| 62 | |
| 63 | function compactRemoteId(value: string): string { |
| 64 | const trimmed = value.trim(); |
| 65 | if (trimmed.length <= 28) return trimmed; |
| 66 | return `${trimmed.slice(0, 12)}…${trimmed.slice(-8)}`; |
| 67 | } |
| 68 | |
| 69 | function botMappingIdentityLabel(mapping: BotConnectionView["sessionMappings"][number] | null | undefined): string { |
| 70 | const chatType = (mapping?.chatType ?? "").trim(); |
| 71 | const userId = (mapping?.userId ?? "").trim(); |
| 72 | const threadId = (mapping?.threadId ?? "").trim(); |
| 73 | if (threadId) return compactRemoteId(threadId); |
| 74 | if ((chatType === "group" || chatType === "guild") && userId) return compactRemoteId(userId); |
| 75 | return ""; |
| 76 | } |
| 77 | |
| 78 | function sidebarImStatus(connection: BotConnectionView, botEnabled: boolean): SidebarImStatus { |
| 79 | if (!botEnabled || !connection.enabled) return "disabled"; |
| 80 | if (connection.status === "connected") return "connected"; |
| 81 | if (connection.status === "pending") return "pending"; |
| 82 | if (connection.status === "error") return "error"; |
| 83 | return "disconnected"; |
| 84 | } |
| 85 | |
| 86 | function sidebarImStatusLabel(status: SidebarImStatus, translate: Translator): string { |
| 87 | switch (status) { |
| 88 | case "connected": |
| 89 | return translate("sidebar.imConnected"); |
| 90 | case "disabled": |
| 91 | return translate("sidebar.imDisabled"); |
| 92 | case "pending": |
| 93 | return translate("sidebar.imPending"); |
| 94 | case "error": |
| 95 | return translate("sidebar.imError"); |
| 96 | default: |
| 97 | return translate("sidebar.imDisconnected"); |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | function uniqueTrimmedValues(values: string[]): string[] { |
| 102 | return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))); |
| 103 | } |
| 104 | |
| 105 | function sidebarImAllowlistUsers(bot: BotSettingsView, platform: SidebarImPlatform): string[] { |
| 106 | if (platform === "qq") return uniqueTrimmedValues(asArray(bot.allowlist.qqUsers)); |
| 107 | if (platform === "weixin") return uniqueTrimmedValues(asArray(bot.allowlist.weixinUsers)); |
| 108 | return uniqueTrimmedValues(asArray(bot.allowlist.feishuUsers)); |
| 109 | } |
| 110 | |
| 111 | function sidebarImQQAdded(qq: BotSettingsView["qq"]): boolean { |
| 112 | return Boolean(qq.enabled || qq.secretSet || qq.appId.trim()); |
| 113 | } |
| 114 | |
| 115 | function sidebarImQQStatus(bot: BotSettingsView, runtimeStatus: BotRuntimeStatusView | null | undefined, nativeRuntime: boolean): SidebarImStatus { |
| 116 | const appId = bot.qq.appId.trim(); |
| 117 | if (!bot.enabled || !bot.qq.enabled) return "disabled"; |
| 118 | if (!appId || !bot.qq.secretSet) return "disconnected"; |
| 119 | if (!nativeRuntime) return "pending"; |
| 120 | if (!runtimeStatus) return "pending"; |
| 121 | const status = runtimeStatus.status.trim().toLowerCase(); |
| 122 | if (runtimeStatus.running && runtimeStatus.connections > 0 && status === "running") { |
| 123 | return "connected"; |
| 124 | } |
| 125 | if (status === "error" || status === "blocked" || status === "degraded") return "error"; |
| 126 | if (status === "stopped") return "disconnected"; |
| 127 | return "pending"; |
| 128 | } |
| 129 | |
| 130 | function sidebarImQQConnection(bot: BotSettingsView, translate: Translator, runtimeStatus: BotRuntimeStatusView | null | undefined, nativeRuntime: boolean): SidebarImConnection | null { |
| 131 | if (!sidebarImQQAdded(bot.qq)) return null; |
| 132 | const remoteId = bot.qq.appId.trim(); |
| 133 | const status = sidebarImQQStatus(bot, runtimeStatus, nativeRuntime); |
| 134 | const statusLabel = sidebarImStatusLabel(status, translate); |
| 135 | const allowlistUsers = sidebarImAllowlistUsers(bot, "qq"); |
| 136 | const subtitleParts = [ |
| 137 | remoteId ? compactRemoteId(remoteId) : "QQ", |
| 138 | statusLabel, |
| 139 | ].filter(Boolean); |
| 140 | return { |
| 141 | id: "__qq_bot__", |
| 142 | connectionId: "__qq_bot__", |
| 143 | platform: "qq", |
| 144 | title: "QQ Bot", |
| 145 | platformLabel: "QQ", |
| 146 | subtitle: subtitleParts.join(" · "), |
| 147 | status, |
| 148 | statusLabel, |
| 149 | remoteId, |
| 150 | sessionId: "", |
| 151 | sessionSource: "", |
| 152 | scope: "global", |
| 153 | workspaceRoot: "", |
| 154 | allowAll: bot.allowlist.allowAll, |
| 155 | allowlistEnabled: bot.allowlist.enabled, |
| 156 | allowlistUsers, |
| 157 | allowlistMatched: remoteId ? allowlistUsers.includes(remoteId) : false, |
| 158 | }; |
| 159 | } |
| 160 | |
| 161 | export function sidebarImConnectionsFromBot( |
| 162 | bot: BotSettingsView | null | undefined, |
| 163 | translate: Translator, |
| 164 | runtimeStatus: BotRuntimeStatusView | null | undefined, |
| 165 | nativeRuntime: boolean, |
| 166 | ): SidebarImConnection[] { |
| 167 | if (!bot) return []; |
| 168 | const qqConnection = sidebarImQQConnection(bot, translate, runtimeStatus, nativeRuntime); |
| 169 | const connectionItems: SidebarImConnection[] = []; |
| 170 | for (const connection of asArray(bot.connections)) { |
| 171 | if (!isSidebarImConnection(connection)) continue; |
| 172 | const mappings = connection.sessionMappings.filter((mapping) => mapping.sessionId.trim() || mapping.remoteId.trim()); |
| 173 | const rowMappings = mappings.length > 0 ? mappings : [null]; |
| 174 | rowMappings.forEach((mapping, index) => { |
| 175 | const platform = sidebarImPlatform(connection); |
| 176 | const platformLabel = sidebarImPlatformLabel(platform, translate); |
| 177 | const remoteId = mapping?.remoteId.trim() ?? ""; |
| 178 | const sessionId = mapping?.sessionId.trim() ?? ""; |
| 179 | const sessionSource = mapping?.sessionSource.trim() ?? ""; |
| 180 | const scope = botMappingScope(mapping, connection.workspaceRoot); |
| 181 | const workspaceRoot = botMappingWorkspaceRoot(mapping, connection.workspaceRoot); |
| 182 | const status = sidebarImStatus(connection, bot.enabled); |
| 183 | const title = connection.label.trim() || platformLabel; |
| 184 | const allowlistUsers = sidebarImAllowlistUsers(bot, platform); |
| 185 | const identityLabel = botMappingIdentityLabel(mapping); |
| 186 | const mappedUserId = mapping?.userId.trim() ?? ""; |
| 187 | const subtitleParts = [ |
| 188 | remoteId ? compactRemoteId(remoteId) : platformLabel, |
| 189 | identityLabel, |
| 190 | connection.model.trim() || "", |
| 191 | sidebarImStatusLabel(status, translate), |
| 192 | ].filter(Boolean); |
| 193 | connectionItems.push({ |
| 194 | id: mapping ? `${connection.id}:mapping:${index}` : connection.id, |
| 195 | connectionId: connection.id, |
| 196 | platform, |
| 197 | title, |
| 198 | platformLabel, |
| 199 | subtitle: subtitleParts.join(" · "), |
| 200 | status, |
| 201 | statusLabel: sidebarImStatusLabel(status, translate), |
| 202 | remoteId, |
| 203 | sessionId, |
| 204 | sessionSource, |
| 205 | scope, |
| 206 | workspaceRoot, |
| 207 | allowAll: bot.allowlist.allowAll, |
| 208 | allowlistEnabled: bot.allowlist.enabled, |
| 209 | allowlistUsers, |
| 210 | allowlistMatched: remoteId |
| 211 | ? allowlistUsers.includes(remoteId) || (mappedUserId ? allowlistUsers.includes(mappedUserId) : false) |
| 212 | : false, |
| 213 | }); |
| 214 | }); |
| 215 | } |
| 216 | return qqConnection ? [qqConnection, ...connectionItems] : connectionItems; |
| 217 | } |
| 218 | |
| 219 | function mappedSessionTarget(sessionId: string): { kind: "path" | "topic"; value: string } | null { |
| 220 | const trimmed = sessionId.trim(); |
| 221 | if (!trimmed) return null; |
| 222 | const lower = trimmed.toLowerCase(); |
| 223 | if (lower.startsWith("path:")) { |
| 224 | const value = trimmed.slice(5).trim(); |
| 225 | return value ? { kind: "path", value } : null; |
| 226 | } |
| 227 | if (lower.startsWith("topic:")) { |
| 228 | const value = trimmed.slice(6).trim(); |
| 229 | return value ? { kind: "topic", value } : null; |
| 230 | } |
| 231 | if (trimmed.endsWith(".jsonl") || trimmed.includes("/") || trimmed.includes("\\") || trimmed.startsWith("~")) { |
| 232 | return { kind: "path", value: trimmed }; |
| 233 | } |
| 234 | return { kind: "topic", value: trimmed }; |
| 235 | } |
| 236 | |
| 237 | export function taskSessionIDFromPath(path: string): string { |
| 238 | const base = path.replace(/\\/g, "/").split("/").pop() || ""; |
| 239 | const extension = base.lastIndexOf("."); |
| 240 | return extension > 0 ? base.slice(0, extension) : base; |
| 241 | } |
| 242 | |
| 243 | export function sidebarImSessionTarget(connection: SidebarImConnection): { kind: "path" | "topic"; value: string } | null { |
| 244 | return mappedSessionTarget(connection.sessionId); |
| 245 | } |
| 246 | |
| 247 | export function isChannelSession(session: SessionMeta): boolean { |
| 248 | return session.kind === "channel" || session.sessionSource === "auto"; |
| 249 | } |
| 250 | |
| 251 | export function sidebarImTopicSourcesFromBot(bot: BotSettingsView | null | undefined, translate: Translator): Record<string, SidebarImTopicSource> { |
| 252 | if (!bot?.connections?.length) return {}; |
| 253 | const sources: Record<string, SidebarImTopicSource> = {}; |
| 254 | for (const connection of bot.connections) { |
| 255 | if (!isSidebarImConnection(connection)) continue; |
| 256 | const platform = sidebarImPlatform(connection); |
| 257 | const label = sidebarImPlatformLabel(platform, translate); |
| 258 | const title = connection.label.trim() || label; |
| 259 | for (const mapping of asArray(connection.sessionMappings)) { |
| 260 | const scope = botMappingScope(mapping, connection.workspaceRoot); |
| 261 | if (scope !== "global") continue; |
| 262 | const target = mappedSessionTarget(mapping.sessionId); |
| 263 | if (!target || target.kind !== "topic") continue; |
| 264 | if (sources[target.value]) continue; |
| 265 | sources[target.value] = { |
| 266 | platform, |
| 267 | label, |
| 268 | title, |
| 269 | remoteId: mapping.remoteId.trim(), |
| 270 | connectionId: connection.id, |
| 271 | }; |
| 272 | } |
| 273 | } |
| 274 | return sources; |
| 275 | } |
| 276 | |
| 277 | export function sidebarImScopeLabel(connection: SidebarImConnection, translate: Translator): string { |
| 278 | if (connection.scope === "project") return translate("botDetail.scopeProject", { name: connection.workspaceRoot || "Project" }); |
| 279 | return translate("botDetail.scopeGlobal"); |
| 280 | } |
| 281 |