koishi-plugin-chat-patch 3.0.2 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/client/vue/index.vue +60 -634
  2. package/client/web/env.d.ts +56 -0
  3. package/client/web/index.html +21 -0
  4. package/client/web/package-lock.json +3419 -0
  5. package/client/web/package.json +62 -0
  6. package/client/web/public/bcui/css/color-dark.css +35 -0
  7. package/client/web/public/bcui/css/color-light.css +35 -0
  8. package/client/web/public/bcui/css/style.css +468 -0
  9. package/client/web/public/bcui/js/main.js +193 -0
  10. package/client/web/public/css/append-dark.css +13 -0
  11. package/client/web/public/css/append-light.css +13 -0
  12. package/client/web/public/css/append-napcat-dark.css +14 -0
  13. package/client/web/public/css/append-napcat-light.css +14 -0
  14. package/client/web/public/favicon.ico +0 -0
  15. package/client/web/public/img/icons/icon-for-fuck-apple.png +0 -0
  16. package/client/web/public/img/icons/icon-maskable.png +0 -0
  17. package/client/web/public/img/icons/icon.png +0 -0
  18. package/client/web/public/img/icons/icon.svg +10 -0
  19. package/client/web/public/robots.txt +2 -0
  20. package/client/web/public/sw.js +58 -0
  21. package/client/web/scripts/copy-bcui.mjs +17 -0
  22. package/client/web/src/App.vue +1127 -0
  23. package/client/web/src/assets/css/append/append_darwin.css +68 -0
  24. package/client/web/src/assets/css/append/append_full_vibrancy.css +290 -0
  25. package/client/web/src/assets/css/append/append_linux.css +125 -0
  26. package/client/web/src/assets/css/append/append_linux_vibrancy.css +22 -0
  27. package/client/web/src/assets/css/append/append_napcat.css +35 -0
  28. package/client/web/src/assets/css/append/append_new.css +843 -0
  29. package/client/web/src/assets/css/append/append_vibrancy.css +113 -0
  30. package/client/web/src/assets/css/append/append_win32.css +109 -0
  31. package/client/web/src/assets/css/append/mobile/append_mobile_horizontal.css +349 -0
  32. package/client/web/src/assets/css/append/mobile/append_mobile_vertical.css +96 -0
  33. package/client/web/src/assets/css/chat.css +2104 -0
  34. package/client/web/src/assets/css/msg.css +941 -0
  35. package/client/web/src/assets/css/options.css +811 -0
  36. package/client/web/src/assets/css/sys_notice.css +62 -0
  37. package/client/web/src/assets/css/view.css +2105 -0
  38. package/client/web/src/assets/font//346/226/271/346/255/243/345/226/265/345/221/234/344/275/223.ttf +0 -0
  39. package/client/web/src/assets/img/Vue.png +0 -0
  40. package/client/web/src/assets/img/cakes.svg +189 -0
  41. package/client/web/src/assets/img/icons/AppIcon.png +0 -0
  42. package/client/web/src/assets/img/icons/AppIconDark.png +0 -0
  43. package/client/web/src/assets/img/icons/CapacitorAppIcon.png +0 -0
  44. package/client/web/src/assets/img/icons/CapacitorAppIconDark.png +0 -0
  45. package/client/web/src/assets/img/icons/NapcatAppIcon.png +0 -0
  46. package/client/web/src/assets/img/icons/NapcatAppIconDark.png +0 -0
  47. package/client/web/src/assets/img/icons/exit.png +0 -0
  48. package/client/web/src/assets/img/icons/pen.png +0 -0
  49. package/client/web/src/assets/img/stars.svg +89 -0
  50. package/client/web/src/assets/img//346/234/237/345/276/205.png +0 -0
  51. package/client/web/src/assets/l10n/_About I10n.md +35 -0
  52. package/client/web/src/assets/l10n/_l10nconfig.json +7 -0
  53. package/client/web/src/assets/l10n/zh-CN.po +1941 -0
  54. package/client/web/src/assets/logo.png +0 -0
  55. package/client/web/src/assets/pathMap/LLOneBot.yaml +2 -0
  56. package/client/web/src/assets/pathMap/Lagrange.OneBot.yaml +180 -0
  57. package/client/web/src/assets/pathMap/NapCat.Onebot.yaml +224 -0
  58. package/client/web/src/assets/pathMap/SnowLuma.yaml +80 -0
  59. package/client/web/src/assets/qed.txt +1 -0
  60. package/client/web/src/components/AboutPan.vue +197 -0
  61. package/client/web/src/components/BulletinBody.vue +129 -0
  62. package/client/web/src/components/DepPan.vue +310 -0
  63. package/client/web/src/components/EmojiFace.vue +32 -0
  64. package/client/web/src/components/FacePan.vue +461 -0
  65. package/client/web/src/components/FileBody.vue +129 -0
  66. package/client/web/src/components/FileManager.vue +532 -0
  67. package/client/web/src/components/FriendBody.vue +54 -0
  68. package/client/web/src/components/GlobalSessionSearchBar.vue +301 -0
  69. package/client/web/src/components/History.vue +49 -0
  70. package/client/web/src/components/LazyLottie.vue +82 -0
  71. package/client/web/src/components/MergePan.vue +127 -0
  72. package/client/web/src/components/MsgBody.vue +1478 -0
  73. package/client/web/src/components/MusicPlayer.vue +549 -0
  74. package/client/web/src/components/NoticeBody.vue +168 -0
  75. package/client/web/src/components/QzonePermissionPan.vue +150 -0
  76. package/client/web/src/components/RawMsgRenderPreviewPan.vue +190 -0
  77. package/client/web/src/components/ThemeColorPickerPan.vue +420 -0
  78. package/client/web/src/components/TinySessionBody.vue +51 -0
  79. package/client/web/src/components/UmamiInfoPan.vue +1549 -0
  80. package/client/web/src/components/UpdatePan.vue +263 -0
  81. package/client/web/src/components/ViewerCom.vue +1360 -0
  82. package/client/web/src/components/VoiceMsg.vue +626 -0
  83. package/client/web/src/components/WelPan.vue +894 -0
  84. package/client/web/src/components/msg-body/.gitkeep +0 -0
  85. package/client/web/src/components/msg-component/JsonSegComp.vue +138 -0
  86. package/client/web/src/components/msg-component/XmlSegComp.vue +132 -0
  87. package/client/web/src/components/msg-component/jsonComp/AutoReply.vue +97 -0
  88. package/client/web/src/components/msg-component/jsonComp/Contact.lua.vue +129 -0
  89. package/client/web/src/components/msg-component/jsonComp/Feed.lua.vue +94 -0
  90. package/client/web/src/components/msg-component/jsonComp/Forum.vue +84 -0
  91. package/client/web/src/components/msg-component/jsonComp/Mannounce.vue +111 -0
  92. package/client/web/src/components/msg-component/jsonComp/Map.vue +52 -0
  93. package/client/web/src/components/msg-component/jsonComp/Miniapp.lua.vue +53 -0
  94. package/client/web/src/components/msg-component/jsonComp/Miniapp.vue +69 -0
  95. package/client/web/src/components/msg-component/jsonComp/Music.lua.vue +135 -0
  96. package/client/web/src/components/msg-component/jsonComp/Tuwen.lua.vue +56 -0
  97. package/client/web/src/components/notice-component/.gitkeep +0 -0
  98. package/client/web/src/components/notice-component/MealHungryPan.vue +119 -0
  99. package/client/web/src/components/tooltip/CustomFaceTooltip.vue +22 -0
  100. package/client/web/src/components/tooltip/Tooltip.vue +50 -0
  101. package/client/web/src/components/tooltip/Tooltips.vue +14 -0
  102. package/client/web/src/components/tooltip/UserInfoTooltip.vue +69 -0
  103. package/client/web/src/function/base.ts +238 -0
  104. package/client/web/src/function/connect.ts +2042 -0
  105. package/client/web/src/function/debug.ts +10 -0
  106. package/client/web/src/function/elements/information.ts +141 -0
  107. package/client/web/src/function/elements/system.ts +101 -0
  108. package/client/web/src/function/elements/vueComp.ts +62 -0
  109. package/client/web/src/function/favicon.ts +76 -0
  110. package/client/web/src/function/input.ts +69 -0
  111. package/client/web/src/function/model/emoji.ts +127 -0
  112. package/client/web/src/function/model/img.ts +150 -0
  113. package/client/web/src/function/model/msg-body.ts +51 -0
  114. package/client/web/src/function/msg.ts +2674 -0
  115. package/client/web/src/function/notify.ts +225 -0
  116. package/client/web/src/function/option.ts +749 -0
  117. package/client/web/src/function/satori-model.ts +818 -0
  118. package/client/web/src/function/satori.ts +325 -0
  119. package/client/web/src/function/sender.ts +178 -0
  120. package/client/web/src/function/tooltip.ts +49 -0
  121. package/client/web/src/function/utils/appUtil.ts +2288 -0
  122. package/client/web/src/function/utils/avatarUtil.ts +31 -0
  123. package/client/web/src/function/utils/backgroundUtil.ts +110 -0
  124. package/client/web/src/function/utils/linkViewUtil.ts +101 -0
  125. package/client/web/src/function/utils/localHistoryUtil.ts +517 -0
  126. package/client/web/src/function/utils/msgUtil.ts +1242 -0
  127. package/client/web/src/function/utils/pinyin.ts +141 -0
  128. package/client/web/src/function/utils/recordUtil.ts +187 -0
  129. package/client/web/src/function/utils/sessionUtil.ts +114 -0
  130. package/client/web/src/function/utils/systemUtil.ts +664 -0
  131. package/client/web/src/main.ts +91 -0
  132. package/client/web/src/pages/Chat.vue +3453 -0
  133. package/client/web/src/pages/Friends.vue +636 -0
  134. package/client/web/src/pages/Info.vue +717 -0
  135. package/client/web/src/pages/Messages.vue +875 -0
  136. package/client/web/src/pages/Options.vue +82 -0
  137. package/client/web/src/pages/Qzone.vue +1178 -0
  138. package/client/web/src/pages/chat-view/ChatDanmaku.vue +759 -0
  139. package/client/web/src/pages/chat-view/ChatGlagame.vue +642 -0
  140. package/client/web/src/pages/chat-view/ChatTerminal.vue +1044 -0
  141. package/client/web/src/pages/chat-view/SystemNotice.vue +171 -0
  142. package/client/web/src/pages/options/OptAccount.vue +107 -0
  143. package/client/web/src/pages/options/OptAddon.vue +185 -0
  144. package/client/web/src/pages/options/OptDev.vue +704 -0
  145. package/client/web/src/pages/options/OptFunction.vue +403 -0
  146. package/client/web/src/pages/options/OptInfo.vue +165 -0
  147. package/client/web/src/pages/options/OptView.vue +846 -0
  148. package/client/web/src/runtime/backend.ts +310 -0
  149. package/client/web/src/state/auth.ts +14 -0
  150. package/client/web/src/state/chat.ts +36 -0
  151. package/client/web/src/state/connection.ts +20 -0
  152. package/client/web/src/state/contact.ts +37 -0
  153. package/client/web/src/state/qzone.ts +18 -0
  154. package/client/web/src/state/sessionHistory.ts +25 -0
  155. package/client/web/src/state/settings.ts +18 -0
  156. package/client/web/src/state/sticker.ts +10 -0
  157. package/client/web/src/state/ui.ts +56 -0
  158. package/client/web/tsconfig.json +27 -0
  159. package/client/web/tsconfig.tsbuildinfo +1 -0
  160. package/client/web/vite.config.ts +33 -0
  161. package/dist/index.js +1 -12
  162. package/dist/style.css +1 -1
  163. package/lib/index.js +2070 -2077
  164. package/package.json +15 -2
  165. package/readme.md +0 -22
  166. package/src/bootstrap.ts +71 -0
  167. package/src/cache.ts +319 -0
  168. package/src/config.ts +23 -35
  169. package/src/database.ts +650 -0
  170. package/src/index.ts +27 -59
  171. package/src/media.ts +302 -0
  172. package/src/recorder.ts +175 -0
  173. package/src/self-message.ts +456 -0
  174. package/src/server.d.ts +26 -0
  175. package/src/types.ts +108 -48
  176. package/src/web.ts +712 -0
  177. package/client/vue/chat-logic.ts +0 -683
  178. package/client/vue/composables/useChatActions.ts +0 -61
  179. package/client/vue/composables/useChatData.ts +0 -185
  180. package/client/vue/composables/useImageCache.ts +0 -281
  181. package/client/vue/composables/useVideoCache.ts +0 -125
  182. package/client/vue/types.ts +0 -72
  183. package/lib/api-handlers.d.ts +0 -25
  184. package/lib/config.d.ts +0 -14
  185. package/lib/file-manager.d.ts +0 -124
  186. package/lib/index.d.ts +0 -17
  187. package/lib/logger.d.ts +0 -9
  188. package/lib/message-handler.d.ts +0 -35
  189. package/lib/types.d.ts +0 -57
  190. package/lib/utils.d.ts +0 -18
  191. package/src/api-handlers.ts +0 -646
  192. package/src/file-manager.ts +0 -1436
  193. package/src/message-handler.ts +0 -507
  194. package/src/utils.ts +0 -189
@@ -0,0 +1,141 @@
1
+ import { isDebugMode } from '../debug'
2
+
3
+ export type PinYinData = {
4
+ main: string[]
5
+ short: string[]
6
+ }
7
+
8
+ /* eslint-disable no-console */
9
+
10
+ const PINYIN_SCRIPT_SRC = 'https://lib.stapxs.cn/modules/pinyin.min.js'
11
+
12
+ let pinyinLoadPromise: Promise<boolean> | null = null
13
+
14
+ function createEmptyPinyinData(): PinYinData {
15
+ return {
16
+ main: [],
17
+ short: []
18
+ }
19
+ }
20
+
21
+ function hasPinyinLib() {
22
+ return typeof window !== 'undefined' && typeof window.pinyin !== 'undefined'
23
+ }
24
+
25
+ function scheduleIdleTask(task: () => void) {
26
+ if (typeof window === 'undefined') return
27
+
28
+ const idleWindow = window as Window & {
29
+ requestIdleCallback?: (
30
+ callback: IdleRequestCallback,
31
+ options?: IdleRequestOptions
32
+ ) => number
33
+ }
34
+
35
+ if (typeof idleWindow.requestIdleCallback === 'function') {
36
+ idleWindow.requestIdleCallback(() => task(), { timeout: 1500 })
37
+ return
38
+ }
39
+
40
+ window.setTimeout(task, 300)
41
+ }
42
+
43
+ export function isPinyinReady() {
44
+ return hasPinyinLib()
45
+ }
46
+
47
+ export function ensurePinyinLoaded(): Promise<boolean> {
48
+ if (hasPinyinLib()) return Promise.resolve(true)
49
+ if (typeof window === 'undefined' || typeof document === 'undefined') {
50
+ return Promise.resolve(false)
51
+ }
52
+ if (pinyinLoadPromise !== null) return pinyinLoadPromise
53
+
54
+ pinyinLoadPromise = new Promise((resolve) => {
55
+ let script = document.querySelector(
56
+ 'script[data-ssqq-pinyin-lib="true"]',
57
+ ) as HTMLScriptElement | null
58
+
59
+ const finish = (success: boolean) => {
60
+ if (!success) {
61
+ pinyinLoadPromise = null
62
+ }
63
+ resolve(success)
64
+ }
65
+
66
+ const handleLoad = () => {
67
+ if (script) {
68
+ script.dataset.loaded = 'true'
69
+ }
70
+ finish(hasPinyinLib())
71
+ }
72
+
73
+ const handleError = () => {
74
+ if (isDebugMode()) console.warn('拼音库加载失败')
75
+ script?.remove()
76
+ finish(false)
77
+ }
78
+
79
+ if (script?.dataset.loaded === 'true') {
80
+ finish(hasPinyinLib())
81
+ return
82
+ }
83
+
84
+ if (!script) {
85
+ script = document.createElement('script')
86
+ script.src = PINYIN_SCRIPT_SRC
87
+ script.async = true
88
+ script.dataset.ssqqPinyinLib = 'true'
89
+ document.body.appendChild(script)
90
+ }
91
+
92
+ script.addEventListener('load', handleLoad, { once: true })
93
+ script.addEventListener('error', handleError, { once: true })
94
+ })
95
+
96
+ return pinyinLoadPromise
97
+ }
98
+
99
+ export function preloadPinyin() {
100
+ scheduleIdleTask(() => {
101
+ void ensurePinyinLoaded()
102
+ })
103
+ }
104
+
105
+ export function getPinyin(name: string): PinYinData {
106
+ if (!hasPinyinLib()) return createEmptyPinyinData()
107
+
108
+ try {
109
+ const pinyinLib = window.pinyin
110
+ if (!pinyinLib) return createEmptyPinyinData()
111
+ return {
112
+ main: pinyinLib.pinyin(name, {
113
+ heteronym: true,
114
+ compact: true,
115
+ style: 'normal',
116
+ }).map((item: string[]) => item.join('').toLowerCase()),
117
+ short: pinyinLib.pinyin(name, {
118
+ heteronym: true,
119
+ compact: true,
120
+ style: 'first_letter',
121
+ }).map((item: string[]) => item.join('').toLowerCase()),
122
+ }
123
+ } catch (error) {
124
+ if (isDebugMode()) console.warn('拼音转换失败:', error)
125
+ return createEmptyPinyinData()
126
+ }
127
+ }
128
+
129
+ export function matchPinyin(
130
+ pinyinData: PinYinData,
131
+ matchStr: string
132
+ ): boolean {
133
+ const str = matchStr.toLowerCase()
134
+ for (const py of pinyinData.main) {
135
+ if (py.includes(str)) return true
136
+ }
137
+ for (const pyShort of pinyinData.short) {
138
+ if (pyShort.includes(str)) return true
139
+ }
140
+ return false
141
+ }
@@ -0,0 +1,187 @@
1
+ /*
2
+ * @FileDescription: 语音消息处理工具
3
+ * @Author: FuQuan233
4
+ * @Date: 2026/06/04
5
+ * @Version: 1.0
6
+ * @Description: 通过 OneBot get_record API 加载语音消息。
7
+ * 以 out_format=mp3 请求格式转换,响应 base64 数据直接用于播放。
8
+ */
9
+
10
+ import { Logger } from '../../function/base'
11
+ import { Connector } from '../../function/connect'
12
+ import { getBootstrap } from '../../function/satori'
13
+
14
+ const logger = new Logger()
15
+
16
+ /** 语音消息原始数据(来自 OneBot record 消息段) */
17
+ export interface RecordMsgData {
18
+ file?: string
19
+ url?: string
20
+ path?: string
21
+ webUrl?: string
22
+ magic?: boolean
23
+ duration?: number
24
+ }
25
+
26
+ /** 语音加载结果 */
27
+ export interface RecordLoadResult {
28
+ src: string
29
+ duration: number
30
+ }
31
+
32
+ /** 加载缓存 */
33
+ const loadCache = new Map<string, RecordLoadResult>()
34
+
35
+ /**
36
+ * base64 字符串转 Blob URL
37
+ */
38
+ function base64ToBlobUrl(base64: string): string {
39
+ if (base64.startsWith('base64://')) base64 = base64.substring(9)
40
+ const binary = atob(base64)
41
+ const bytes = new Uint8Array(binary.length)
42
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
43
+ return URL.createObjectURL(new Blob([bytes], { type: 'audio/mpeg' }))
44
+ }
45
+
46
+ /**
47
+ * 获取音频时长
48
+ */
49
+ function getAudioDuration(url: string): Promise<number> {
50
+ return new Promise((resolve) => {
51
+ const audio = new Audio()
52
+ let resolved = false
53
+ const done = (val: number) => {
54
+ if (!resolved) {
55
+ resolved = true
56
+ audio.removeEventListener('loadedmetadata', onMeta)
57
+ audio.removeEventListener('error', onErr)
58
+ audio.src = ''
59
+ resolve(val)
60
+ }
61
+ }
62
+ const onMeta = () => done(audio.duration || 0)
63
+ const onErr = () => done(0)
64
+ audio.addEventListener('loadedmetadata', onMeta)
65
+ audio.addEventListener('error', onErr)
66
+ setTimeout(() => done(0), 3000)
67
+ audio.src = url
68
+ })
69
+ }
70
+
71
+ function isDirectAudioSource(value: string): boolean {
72
+ return /^(https?:|data:|blob:)/i.test(value) || value.startsWith('/')
73
+ }
74
+
75
+ function isLocalAudioPath(value: string): boolean {
76
+ return value.startsWith('file:') ||
77
+ /^[a-z]:[\\/]/i.test(value) ||
78
+ value.startsWith('\\\\')
79
+ }
80
+
81
+ async function resolveLocalAudioUrl(value: string): Promise<string> {
82
+ if (isDirectAudioSource(value)) return value
83
+ if (!isLocalAudioPath(value)) return ''
84
+ const info = await getBootstrap()
85
+ const basePath = info.basePath || '/chat-patch'
86
+ const fileName = value
87
+ .replace(/^file:\/\//i, '')
88
+ .split(/[\\/]/)
89
+ .pop() || value
90
+ return `${location.origin}${basePath}/api/media?file=${encodeURIComponent(fileName)}`
91
+ }
92
+
93
+ // ============================================================================
94
+ // 公开 API
95
+ // ============================================================================
96
+
97
+ /**
98
+ * 加载语音消息为可播放的音频 URL
99
+ *
100
+ * 通过 OneBot get_record API (out_format=mp3) 获取语音,
101
+ * 响应中的 base64 字段直接包含 MP3 数据。
102
+ *
103
+ * @param data 语音消息数据
104
+ * @param msgId 消息 ID(用于缓存键)
105
+ * @returns 加载结果
106
+ * @throws 无法加载时抛出错误
107
+ */
108
+ export async function loadRecord(data: RecordMsgData, msgId?: string): Promise<RecordLoadResult> {
109
+ const cacheKey = msgId || data.file!
110
+
111
+ // 缓存检查
112
+ const cached = loadCache.get(cacheKey)
113
+ if (cached) return cached
114
+
115
+ if (!data.file) {
116
+ throw new Error('语音消息缺少 file 字段')
117
+ }
118
+
119
+ const directSource = String(data.webUrl || data.url || data.file || data.path || '')
120
+ if (directSource) {
121
+ const directUrl = await resolveLocalAudioUrl(directSource)
122
+ if (directUrl) {
123
+ const duration = data.duration || (await getAudioDuration(directUrl))
124
+ const loadResult: RecordLoadResult = { src: directUrl, duration }
125
+ loadCache.set(cacheKey, loadResult)
126
+ return loadResult
127
+ }
128
+ }
129
+
130
+ // 调用 get_record API
131
+ const raw = await Connector.callApi('get_record', {
132
+ file: data.file,
133
+ out_format: 'mp3',
134
+ })
135
+
136
+ if (!raw) throw new Error('get_record API 返回空')
137
+
138
+ // result 兼容数组和单个对象
139
+ const result: any = Array.isArray(raw) ? raw[0] : raw
140
+
141
+ if (!result?.base64 || typeof result.base64 !== 'string') {
142
+ logger.debug('get_record 响应中没有 base64: ' + JSON.stringify(result).substring(0, 200))
143
+ throw new Error('OneBot 端不支持语音格式转换')
144
+ }
145
+
146
+ const blobUrl = base64ToBlobUrl(result.base64)
147
+ const duration = data.duration || (await getAudioDuration(blobUrl))
148
+
149
+ logger.debug(`语音加载完成: ${duration.toFixed(1)}s`)
150
+
151
+ const loadResult: RecordLoadResult = { src: blobUrl, duration }
152
+ loadCache.set(cacheKey, loadResult)
153
+ return loadResult
154
+ }
155
+
156
+ /**
157
+ * 释放语音资源
158
+ */
159
+ export function revokeRecord(msgId?: string): void {
160
+ if (msgId) {
161
+ const cached = loadCache.get(msgId)
162
+ if (cached?.src.startsWith('blob:')) URL.revokeObjectURL(cached.src)
163
+ loadCache.delete(msgId)
164
+ }
165
+ }
166
+
167
+ /**
168
+ * 清除所有缓存
169
+ */
170
+ export function clearRecordCache(): void {
171
+ for (const [, r] of loadCache) {
172
+ if (r.src.startsWith('blob:')) URL.revokeObjectURL(r.src)
173
+ }
174
+ loadCache.clear()
175
+ }
176
+
177
+ /**
178
+ * 格式化时长显示
179
+ * @param seconds 秒数
180
+ * @returns 格式化的时长字符串 (m:ss)
181
+ */
182
+ export function formatRecordDuration(seconds: number): string {
183
+ if (!seconds || seconds <= 0) return '0:00'
184
+ const m = Math.floor(seconds / 60)
185
+ const s = Math.floor(seconds % 60)
186
+ return `${m}:${s.toString().padStart(2, '0')}`
187
+ }
@@ -0,0 +1,114 @@
1
+ import type { Session } from '../elements/information'
2
+
3
+ export function normalizeSessionId(value: number | string): string {
4
+ return String(value)
5
+ }
6
+
7
+ export function getSessionId(item: Session): number | string {
8
+ return item.user_id ?? item.group_id ?? 0
9
+ }
10
+
11
+ export function findSessionContact(
12
+ contacts: Session[],
13
+ sessionId: number | string,
14
+ ) {
15
+ return contacts.find((item) => {
16
+ return normalizeSessionId(getSessionId(item)) === normalizeSessionId(sessionId)
17
+ })
18
+ }
19
+
20
+ export function getMissingGroupPreviewSessions(
21
+ contacts: Session[],
22
+ knownSessions: ReadonlyMap<number | string, Session>,
23
+ ) {
24
+ return contacts.filter((item) => {
25
+ const sessionId = getSessionId(item)
26
+ return Boolean(item.group_id) &&
27
+ sessionId !== 0 &&
28
+ sessionId !== '' &&
29
+ !item.time &&
30
+ !item.raw_msg &&
31
+ !knownSessions.has(normalizeSessionId(sessionId))
32
+ })
33
+ }
34
+
35
+ export function resolveIncomingSession(
36
+ contacts: Session[],
37
+ sessionId: number | string,
38
+ isGroup: boolean,
39
+ senderName?: string,
40
+ ) {
41
+ const contact = findSessionContact(contacts, sessionId)
42
+ if (contact) return contact
43
+
44
+ const normalizedId = normalizeSessionId(sessionId)
45
+
46
+ // 消息事件可能早于好友/群列表返回。先保留会话动态状态,列表加载后再合并真实资料。
47
+ if (isGroup) {
48
+ return {
49
+ group_id: normalizedId,
50
+ group_name: normalizedId,
51
+ } as Session
52
+ }
53
+ return {
54
+ user_id: normalizedId,
55
+ nickname: senderName || normalizedId,
56
+ remark: '',
57
+ } as Session
58
+ }
59
+
60
+ const SESSION_STATE_KEYS = [
61
+ 'new_msg',
62
+ 'raw_msg',
63
+ 'raw_msg_base',
64
+ 'time',
65
+ 'always_top',
66
+ 'message_id',
67
+ 'highlight',
68
+ ] as const
69
+
70
+ type SessionStateKey = (typeof SESSION_STATE_KEYS)[number]
71
+
72
+ function copyDefinedSessionState<K extends SessionStateKey>(
73
+ contact: Session,
74
+ currentSession: Session,
75
+ key: K,
76
+ ) {
77
+ const value = currentSession[key]
78
+ if (value !== undefined) {
79
+ contact[key] = value
80
+ }
81
+ }
82
+
83
+ export function mergeSessionState(
84
+ contact: Session,
85
+ currentSession: Session,
86
+ ) {
87
+ SESSION_STATE_KEYS.forEach((key) =>
88
+ copyDefinedSessionState(contact, currentSession, key),
89
+ )
90
+ return contact
91
+ }
92
+
93
+ /**
94
+ * 让真实联系人接管消息早到时创建的占位会话。
95
+ * 返回 true 表示 Map 中的对象引用已替换,调用方需要重建派生会话列表。
96
+ */
97
+ export function mergeEarlySessionContacts(
98
+ contacts: Session[],
99
+ sessions: Map<number | string, Session>,
100
+ ) {
101
+ let didMerge = false
102
+ contacts.forEach((contact) => {
103
+ const sessionId = normalizeSessionId(getSessionId(contact))
104
+ const currentSession = sessions.get(sessionId)
105
+ if (currentSession && currentSession !== contact) {
106
+ sessions.set(
107
+ sessionId,
108
+ mergeSessionState(contact, currentSession),
109
+ )
110
+ didMerge = true
111
+ }
112
+ })
113
+ return didMerge
114
+ }