koishi-plugin-chat-patch 5.3.0 → 5.5.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 (77) hide show
  1. package/client/web/dist/assets/Chat-Clqxn7CB.js +28 -0
  2. package/client/web/dist/assets/{Chat-CTdyeaX2.css → Chat-DTEsebnY.css} +1 -1
  3. package/client/web/dist/assets/{MsgBody-DDacjWJ9.js → MsgBody-C0TKsXhC.js} +1 -1
  4. package/client/web/dist/assets/{MsgBody.vue_vue_type_script_setup_true_lang-B1dWyCB0.js → MsgBody.vue_vue_type_script_setup_true_lang-CCdBwlN_.js} +2 -2
  5. package/client/web/dist/assets/{append_darwin-CqBNU62J.css → append_darwin-Cql-dV6d.css} +1 -1
  6. package/client/web/dist/assets/{append_full_vibrancy-j0D2LM9m.css → append_full_vibrancy-ih3loy1O.css} +1 -1
  7. package/client/web/dist/assets/{append_linux-uhxeBqeC.css → append_linux-B3LvR8KB.css} +1 -1
  8. package/client/web/dist/assets/{append_new-407Lo5_V.css → append_new-DfhN_NX9.css} +1 -1
  9. package/client/web/dist/assets/{append_vibrancy-6FHctkOY.css → append_vibrancy-D3pVsmAa.css} +1 -1
  10. package/client/web/dist/assets/{append_win32-srmD-78y.css → append_win32-Dh9JlmlE.css} +1 -1
  11. package/client/web/dist/assets/index-B71pPwae.css +1 -0
  12. package/client/web/dist/assets/{index-BlLut5xn.js → index-BA37EQGa.js} +97 -76
  13. package/client/web/dist/index.html +2 -2
  14. package/client/web/src/App.vue +2 -2
  15. package/client/web/src/assets/css/append/append_darwin.css +2 -2
  16. package/client/web/src/assets/css/append/append_full_vibrancy.css +2 -2
  17. package/client/web/src/assets/css/append/append_linux.css +2 -2
  18. package/client/web/src/assets/css/append/append_new.css +2 -2
  19. package/client/web/src/assets/css/append/append_vibrancy.css +1 -1
  20. package/client/web/src/assets/css/append/append_win32.css +2 -2
  21. package/client/web/src/assets/css/append/mobile/append_mobile_horizontal.css +1 -1
  22. package/client/web/src/assets/css/chat.css +4 -3
  23. package/client/web/src/assets/css/msg.css +7 -3
  24. package/client/web/src/assets/css/options.css +2 -2
  25. package/client/web/src/assets/css/view.css +14 -3
  26. package/client/web/src/assets/l10n/zh-CN.po +21 -0
  27. package/client/web/src/components/AboutPan.vue +7 -4
  28. package/client/web/src/components/BulletinBody.vue +1 -1
  29. package/client/web/src/components/DepPan.vue +1 -1
  30. package/client/web/src/components/FriendBody.vue +1 -0
  31. package/client/web/src/components/MsgBody.vue +1 -3
  32. package/client/web/src/components/ThemeColorPickerPan.vue +1 -1
  33. package/client/web/src/components/TinySessionBody.vue +1 -0
  34. package/client/web/src/components/UmamiInfoPan.vue +1 -1
  35. package/client/web/src/components/UpdatePan.vue +2 -2
  36. package/client/web/src/components/WelPan.vue +1 -1
  37. package/client/web/src/components/tooltip/UserInfoTooltip.vue +1 -1
  38. package/client/web/src/function/connect.ts +279 -86
  39. package/client/web/src/function/elements/information.ts +1 -0
  40. package/client/web/src/function/msg.ts +42 -23
  41. package/client/web/src/function/satori-model.ts +35 -13
  42. package/client/web/src/function/satori.ts +33 -2
  43. package/client/web/src/function/utils/appUtil.ts +3 -11
  44. package/client/web/src/function/utils/avatarUtil.ts +18 -3
  45. package/client/web/src/function/utils/localHistoryUtil.ts +36 -12
  46. package/client/web/src/function/utils/msgUtil.ts +32 -21
  47. package/client/web/src/function/utils/sessionUtil.ts +20 -18
  48. package/client/web/src/main.ts +5 -0
  49. package/client/web/src/pages/Chat.vue +50 -47
  50. package/client/web/src/pages/Friends.vue +28 -24
  51. package/client/web/src/pages/Info.vue +5 -5
  52. package/client/web/src/pages/Messages.vue +57 -36
  53. package/client/web/src/pages/Qzone.vue +2 -2
  54. package/client/web/src/pages/chat-view/ChatTerminal.vue +4 -1
  55. package/client/web/src/pages/chat-view/SystemNotice.vue +3 -3
  56. package/client/web/src/pages/options/OptAccount.vue +6 -4
  57. package/client/web/src/pages/options/OptDev.vue +45 -2
  58. package/lib/database.d.ts +58 -0
  59. package/lib/index.js +234 -82
  60. package/lib/media.d.ts +24 -0
  61. package/lib/recorder.d.ts +21 -0
  62. package/lib/self-message.d.ts +27 -0
  63. package/lib/types.d.ts +121 -0
  64. package/lib/web.d.ts +7 -0
  65. package/package.json +1 -1
  66. package/src/bootstrap.ts +1 -0
  67. package/src/cache.ts +1 -5
  68. package/src/config.ts +1 -1
  69. package/src/database.ts +52 -8
  70. package/src/index.ts +4 -4
  71. package/src/media.ts +97 -0
  72. package/src/recorder.ts +35 -26
  73. package/src/self-message.ts +68 -19
  74. package/src/types.ts +10 -0
  75. package/src/web.ts +57 -48
  76. package/client/web/dist/assets/Chat-B448YOeN.js +0 -28
  77. package/client/web/dist/assets/index-fW_zcub3.css +0 -1
@@ -95,6 +95,7 @@ import {
95
95
  normalizeSessionId,
96
96
  resolveIncomingSession,
97
97
  setSessionContact,
98
+ upsertSessionContact,
98
99
  } from './utils/sessionUtil'
99
100
 
100
101
  const popInfo = new PopInfo()
@@ -120,11 +121,6 @@ function getString(value: unknown): string {
120
121
  return typeof value === 'string' ? value : String(value)
121
122
  }
122
123
 
123
- function normalizeChannelForMatch(value: string): string {
124
- if (/^(?:private|direct):/i.test(value)) return value.replace(/^(?:private|direct):/i, '')
125
- return normalizeGroupId(value)
126
- }
127
-
128
124
  // 其他 tag
129
125
  let listLoadTimes = 0
130
126
  const logger = new Logger()
@@ -995,10 +991,13 @@ const msgFunctions = {
995
991
  const chatStore = useChatStore()
996
992
  const rawData = msg.data
997
993
  const firstData = Array.isArray(rawData) ? rawData[0] : rawData
994
+ const firstRawId = getDataObject(firstData).id
995
+ ?? getDataObject(rawData).id
998
996
  const realMessageId = getString(msg.message_id)
999
997
  || getString(getDataObject(rawData).message_id)
1000
998
  || getString(getDataObject(firstData).id)
1001
999
  || getString(getDataObject(firstData).message_id)
1000
+ || (firstRawId == null ? '' : String(firstRawId))
1002
1001
  if (realMessageId) msg.message_id = realMessageId
1003
1002
  if (echoList[1] == 'forward') {
1004
1003
  // PS:这儿写是写了转发成功,事实上不确定消息有没有真的发送出去(x
@@ -1341,8 +1340,16 @@ const msgFunctions = {
1341
1340
  )
1342
1341
  getMessageList(trueMsg).then((trueMsg) => {
1343
1342
  if (trueMsg?.length == 1) {
1343
+ const nextMessage = Array.isArray(trueMsg[0].message)
1344
+ && trueMsg[0].message.some((item: any) => {
1345
+ if (item?.type !== 'text') return true
1346
+ const text = String(item?.text ?? item?.content ?? '')
1347
+ return text.trim() !== ''
1348
+ })
1349
+ ? trueMsg[0].message
1350
+ : fakeMsg.message
1344
1351
  // 使用消息对象引用直接更新,避免索引问题
1345
- fakeMsg.message = trueMsg[0].message
1352
+ if (Array.isArray(nextMessage)) fakeMsg.message = nextMessage
1346
1353
  fakeMsg.raw_message = trueMsg[0].raw_message
1347
1354
  fakeMsg.time = trueMsg[0].time
1348
1355
  fakeMsg.fake_msg = undefined
@@ -1699,8 +1706,9 @@ function saveUser(msg: { [key: string]: any }, type: string) {
1699
1706
  const channel = String(item.channel_id ?? item.channelId ?? '')
1700
1707
  const group = String(item.group_id ?? '')
1701
1708
  const user = String(item.user_id ?? '')
1702
- if (group) return `group:${normalizeGroupId(channel || group)}`
1703
- return `user:${String(user).replace(/^(?:private|direct):/i, '')}`
1709
+ if (channel) return normalizeSessionId(channel)
1710
+ if (group) return normalizeSessionId(group)
1711
+ return normalizeSessionId(user)
1704
1712
  }
1705
1713
  const existingIds = new Set(contactStore.userList.map(sessionDedupKey))
1706
1714
  const freshList = list.filter((item) => {
@@ -1891,19 +1899,28 @@ async function saveMsg(msg: any, append = undefined as undefined | string) {
1891
1899
  const lastMsg =
1892
1900
  chatStore.messageList[chatStore.messageList.length - 1]
1893
1901
  if (lastMsg) {
1902
+ const isGroup = chatStore.chatInfo.show.type === 'group'
1894
1903
  const user = findSessionContact(contactStore.userList, chatStore.chatInfo.show.id)
1895
1904
  const sessionId = normalizeSessionId(
1896
1905
  chatStore.chatInfo.show.channel_id
1897
1906
  ?? chatStore.chatInfo.show.id,
1898
1907
  )
1899
- const session = contactStore.baseOnMsgList.get(sessionId) ?? user
1908
+ const session = isGroup
1909
+ ? user
1910
+ : contactStore.baseOnMsgList.get(sessionId) ?? user
1900
1911
  if (session) {
1901
1912
  const preview = formatMessageData(
1902
1913
  lastMsg,
1903
- chatStore.chatInfo.show.type == 'group',
1914
+ isGroup,
1904
1915
  )
1905
1916
  if (user) Object.assign(user, preview)
1906
1917
  Object.assign(session, preview)
1918
+ if (isGroup) {
1919
+ // 群历史同步到联系人列表,同时进入群会话列表
1920
+ contactStore.userList = upsertSessionContact(contactStore.userList, session)
1921
+ } else if (session._groupMember) {
1922
+ session._groupMember = false
1923
+ }
1907
1924
  contactStore.baseOnMsgList.set(sessionId, session)
1908
1925
  updateBaseOnMsgList()
1909
1926
  }
@@ -2326,7 +2343,7 @@ function newMsg(_: string, data: any) {
2326
2343
  }
2327
2344
  // 消息基础信息:优先使用 Satori 转换层生成的 infoList,避免 OneBot pathMap 误解析。
2328
2345
  const rawInfo = getDataObject(data.infoList ?? data)
2329
- const id = getString(rawInfo.group_id) ||
2346
+ const rawId = getString(rawInfo.group_id) ||
2330
2347
  getString(rawInfo.private_id) ||
2331
2348
  getString(data.group_id) ||
2332
2349
  getString(data.user_id) ||
@@ -2354,12 +2371,16 @@ function newMsg(_: string, data: any) {
2354
2371
  : data.message_type === 'group' || Boolean(data.group_id) || Boolean(rawInfo.group_id)
2355
2372
  const sessionChannelId = getString(rawInfo.channel_id) || getString(data.channel_id)
2356
2373
  const sessionGuildId = getString(rawInfo.guild_id) || getString(data.guild_id)
2374
+ // 群消息必须用群/频道 ID 作为会话,不能退化成发送者用户 ID
2375
+ const id = isGroupMessage
2376
+ ? getString(rawInfo.group_id) || getString(data.group_id) || sessionChannelId || sessionGuildId || ''
2377
+ : rawId
2357
2378
  if (!id || id === '0') return
2358
2379
  const isCurrentSession = Boolean(
2359
2380
  (
2360
2381
  sessionChannelId &&
2361
2382
  showChannelId &&
2362
- normalizeChannelForMatch(sessionChannelId) === normalizeChannelForMatch(showChannelId)
2383
+ sessionChannelId === showChannelId
2363
2384
  ) || (
2364
2385
  (!sessionChannelId || !showChannelId) &&
2365
2386
  (id === showId || targetId === showId || String(sessionChannelId || sessionGuildId || '') === showId)
@@ -2572,6 +2593,13 @@ function newMsg(_: string, data: any) {
2572
2593
  if (data.atall) { session.highlight = $t('[@全体]') }
2573
2594
  if (isImportant) { session.highlight = $t('[特別关心]') }
2574
2595
  }
2596
+ if (!isGroupMessage && session._groupMember) {
2597
+ session._groupMember = false
2598
+ }
2599
+ if (isGroupMessage) {
2600
+ // 群消息同步到联系人列表,同时进入群会话列表
2601
+ contactStore.userList = upsertSessionContact(contactStore.userList, session)
2602
+ }
2575
2603
  setSessionContact(contactStore.baseOnMsgList, session)
2576
2604
  updateBaseOnMsgList()
2577
2605
  }
@@ -2589,17 +2617,8 @@ function newMsg(_: string, data: any) {
2589
2617
  hidden: document.hidden,
2590
2618
  isImportant: isImportant
2591
2619
  })
2592
- // (发送者没有被打开 || 窗口没有焦点 || 窗口被最小化 || 在特别关心列表里) 这些情况需要进行消息通知
2593
- const forceGroupSystemNotice = isGroupMessage && groupNoticeType === 'all'
2594
- const forceImportantNotice = isImportant ||
2595
- (isGroupMessage && (data.atme || data.atall || isGroupNotice))
2596
- if (
2597
- forceGroupSystemNotice ||
2598
- forceImportantNotice ||
2599
- !isCurrentSession ||
2600
- !document.hasFocus() ||
2601
- document.hidden
2602
- ) {
2620
+ // 当前会话已打开时不弹系统通知,新消息交给右下角置底箭头提示
2621
+ if (!isCurrentSession) {
2603
2622
  // 准备消息内容
2604
2623
  let raw = getMsgRawTxt(data)
2605
2624
  raw = raw === '' ? data.raw_message : raw
@@ -21,9 +21,7 @@ function getString(value: unknown): string {
21
21
  }
22
22
 
23
23
  function normalizeGroupId(value: string): string {
24
- const raw = value.replace(/^(?:group|room|chat|channel|guild):/i, '').trim()
25
- const wrapped = raw.match(/^\[_?([\s\S]+?)_?\]$/)
26
- return wrapped ? wrapped[1] : raw || value
24
+ return String(value)
27
25
  }
28
26
 
29
27
  function isDirectChannelType(channel: SatoriObject): boolean {
@@ -234,6 +232,13 @@ function containsElementMarkup(source: string): boolean {
234
232
  return /<(img|image|audio|video|file|mface|face|quote|at|forward|json|xml|markdown|keyboard|p|br|i18n|a|text|sharp)(?:\s|\/|>)/i.test(source)
235
233
  }
236
234
 
235
+ // QQ 官方机器人会把部分大表情/图片消息的私有标记塞进 content,渲染前需要去掉
236
+ function cleanQqFaceMarkup(source: string): string {
237
+ return source
238
+ .replace(/<faceType\b[^>]*>/gi, '')
239
+ .replace(/&lt;faceType\b[^>]*&gt;/gi, '')
240
+ }
241
+
237
242
  function splitMarkupTextSegments(
238
243
  segments: Array<Record<string, unknown>>,
239
244
  ): Array<Record<string, unknown>> {
@@ -250,6 +255,7 @@ function splitMarkupTextSegments(
250
255
  }
251
256
 
252
257
  export function parseSatoriMarkup(source: string): Array<Record<string, unknown>> {
258
+ source = cleanQqFaceMarkup(source)
253
259
  const result: Array<Record<string, unknown>> = []
254
260
  let index = 0
255
261
  while (index < source.length) {
@@ -441,11 +447,11 @@ export function satoriEventToOneBot(
441
447
  if (type === 'message' || type === 'message-created' || type === 'send') {
442
448
  const isGroup = isGroupChannel(channel, guild)
443
449
  const userId = getString(user.id)
444
- || (isGroup ? '' : getString(channel.id).replace(/^private:/, ''))
450
+ || (isGroup ? '' : getString(channel.id))
445
451
  const groupId = isGroup
446
452
  ? normalizeGroupId(getString(guild.id)) || normalizeGroupId(getString(channel.id))
447
453
  : ''
448
- const directChannelId = isGroup ? groupId : userId
454
+ const directChannelId = isGroup ? groupId : getString(channel.id) || userId
449
455
  const nickname = getString(user.name) || getString(user.nick) || userId
450
456
  const card = getString(member.nick) || getString(member.name) || ''
451
457
  return {
@@ -462,7 +468,7 @@ export function satoriEventToOneBot(
462
468
  group_avatar: isGroup
463
469
  ? (usableAvatar(guild.avatar) || usableAvatar(channel.avatar) || buildQqGroupAvatar(platform, groupId) || undefined)
464
470
  : undefined,
465
- channel_id: getString(channel.id) || (isGroup ? groupId : `private:${userId}`),
471
+ channel_id: getString(channel.id) || (isGroup ? groupId : userId),
466
472
  guild_id: getString(guild.id),
467
473
  target_id: selfId,
468
474
  message: messageSegments(message),
@@ -478,7 +484,7 @@ export function satoriEventToOneBot(
478
484
  message_id: getString(message.id),
479
485
  private_id: isGroup ? '' : directChannelId,
480
486
  group_id: groupId,
481
- channel_id: getString(channel.id) || (isGroup ? groupId : `private:${userId}`),
487
+ channel_id: getString(channel.id) || (isGroup ? groupId : userId),
482
488
  guild_id: getString(guild.id),
483
489
  target_id: selfId,
484
490
  sender: userId,
@@ -525,6 +531,24 @@ export function satoriEventToOneBot(
525
531
  }
526
532
  }
527
533
 
534
+ if (type === 'message-deleted') {
535
+ const isGroup = isGroupChannel(channel, guild)
536
+ const userId = getString(user.id) || (isGroup ? '' : getString(channel.id))
537
+ const groupId = isGroup
538
+ ? normalizeGroupId(getString(guild.id)) || normalizeGroupId(getString(channel.id))
539
+ : ''
540
+ return {
541
+ ...base,
542
+ post_type: 'notice',
543
+ notice_type: isGroup ? 'group_recall' : 'friend_recall',
544
+ group_id: groupId,
545
+ user_id: userId,
546
+ message_id: getString(message.id),
547
+ channel_id: getString(channel.id) || (isGroup ? groupId : userId),
548
+ guild_id: getString(guild.id),
549
+ }
550
+ }
551
+
528
552
  return null
529
553
  }
530
554
 
@@ -629,9 +653,7 @@ export function mapAction(action: string, params: Record<string, unknown>): {
629
653
  const guildId = getString(params.group_id) || getString(params.guild_id)
630
654
  const userId = getString(params.user_id)
631
655
  const messageId = getString(params.message_id)
632
- const directChannel = userId && !/^(?:group|room|chat|channel|guild|private):/i.test(userId)
633
- ? `private:${userId}`
634
- : userId
656
+ const directChannel = userId
635
657
  const channelId = getString(params.channel_id) || guildId || directChannel || userId
636
658
  const content = Array.isArray(params.message)
637
659
  ? serializeSatoriContent(params.message)
@@ -688,8 +710,8 @@ function messageListFromResponse(data: unknown): unknown[] {
688
710
  ? normalizeGroupId(getString(guild.id)) || normalizeGroupId(getString(channel.id))
689
711
  : ''
690
712
  const userId = getString(user.id)
691
- || (isGroup ? '' : getString(channel.id).replace(/^private:/, ''))
692
- const directChannelId = isGroup ? groupId : userId
713
+ || (isGroup ? '' : getString(channel.id))
714
+ const directChannelId = isGroup ? groupId : getString(channel.id) || userId
693
715
  return {
694
716
  message_id: getString(message.id),
695
717
  sn: getNumber(message.sn),
@@ -701,7 +723,7 @@ function messageListFromResponse(data: unknown): unknown[] {
701
723
  channel_type: channel.type,
702
724
  group_id: groupId,
703
725
  user_id: groupId ? userId : directChannelId,
704
- channel_id: getString(channel.id) || (isGroup ? groupId : `private:${userId}`),
726
+ channel_id: getString(channel.id) || (isGroup ? groupId : userId),
705
727
  guild_id: getString(guild.id),
706
728
  sender: {
707
729
  user_id: userId,
@@ -4,6 +4,10 @@ export interface SatoriBootstrap {
4
4
  endpoint: string
5
5
  token: string
6
6
  basePath: string
7
+ blockedPlatforms: Array<{
8
+ platformName: string
9
+ exactMatch: boolean
10
+ }>
7
11
  }
8
12
 
9
13
  export interface SatoriLogin {
@@ -24,6 +28,11 @@ export interface SatoriEvent {
24
28
  body: Record<string, unknown>
25
29
  }
26
30
 
31
+ // 同一机器人 ID 可能同时存在于不同平台(例如 qq / qqguild),用平台 + ID 做唯一键
32
+ export function botKey(platform: string, selfId: string): string {
33
+ return [platform, selfId].map((value) => encodeURIComponent(value)).join(':')
34
+ }
35
+
27
36
  interface PendingConsoleRequest {
28
37
  resolve: (value: unknown) => void
29
38
  reject: (reason: unknown) => void
@@ -47,6 +56,17 @@ function getObject(value: unknown): Record<string, unknown> {
47
56
  return typeof value === 'object' && value !== null ? value as Record<string, unknown> : {}
48
57
  }
49
58
 
59
+ function isBlockedPlatform(
60
+ platform: string,
61
+ blockedPlatforms: SatoriBootstrap['blockedPlatforms'],
62
+ ): boolean {
63
+ return (blockedPlatforms ?? []).some((item) => {
64
+ return item.exactMatch
65
+ ? platform === item.platformName
66
+ : platform.includes(item.platformName)
67
+ })
68
+ }
69
+
50
70
  async function parseJsonResponse(response: Response): Promise<unknown> {
51
71
  const text = await response.text()
52
72
  if (!response.ok) {
@@ -55,7 +75,8 @@ async function parseJsonResponse(response: Response): Promise<unknown> {
55
75
  try {
56
76
  return JSON.parse(text) as unknown
57
77
  } catch {
58
- throw new Error(`Satori API 返回了非 JSON 内容: ${text.slice(0, 120)}`)
78
+ // 部分平台对 delete 等操作只回显纯文本(例如 OK),HTTP 200 即视为成功
79
+ return text
59
80
  }
60
81
  }
61
82
 
@@ -94,6 +115,9 @@ function receiveBootstrap(data: Record<string, unknown>) {
94
115
  endpoint: getString(payload.endpoint) || '/satori',
95
116
  token: getString(payload.token),
96
117
  basePath: getString(payload.basePath) || '/chat-patch',
118
+ blockedPlatforms: Array.isArray(payload.blockedPlatforms)
119
+ ? payload.blockedPlatforms as SatoriBootstrap['blockedPlatforms']
120
+ : [],
97
121
  }
98
122
  window.clearTimeout(bootstrapTimeout)
99
123
  resolveBootstrapCallback?.(bootstrap)
@@ -118,6 +142,10 @@ export function getBootstrap(): Promise<SatoriBootstrap> {
118
142
  return resolveBootstrap()
119
143
  }
120
144
 
145
+ export function getBlockedPlatforms(): SatoriBootstrap['blockedPlatforms'] {
146
+ return bootstrap?.blockedPlatforms ?? []
147
+ }
148
+
121
149
  export function getLogins(): SatoriLogin[] {
122
150
  return logins
123
151
  }
@@ -219,6 +247,9 @@ export function connect(
219
247
  endpoint: getString(payload.endpoint) || '/satori',
220
248
  token: getString(payload.token),
221
249
  basePath: getString(payload.basePath) || '/chat-patch',
250
+ blockedPlatforms: Array.isArray(payload.blockedPlatforms)
251
+ ? payload.blockedPlatforms as SatoriBootstrap['blockedPlatforms']
252
+ : [],
222
253
  }
223
254
  }
224
255
  }
@@ -258,7 +289,7 @@ export function connect(
258
289
  const platform = getString(login.platform) || getString(user.platform)
259
290
  const selfId = getString(user.id) || getString(login.selfId)
260
291
  const key = [platform, selfId].map((value) => encodeURIComponent(value)).join(':')
261
- if (!platform || !selfId || seen.has(key)) continue
292
+ if (!platform || !selfId || seen.has(key) || isBlockedPlatform(platform, bootstrap?.blockedPlatforms ?? [])) continue
262
293
  seen.add(key)
263
294
  nextLogins.push({
264
295
  platform: getString(login.platform) || getString(user.platform),
@@ -53,11 +53,6 @@ function encodeKeyPart(value: unknown): string {
53
53
  return encodeURIComponent(String(value ?? ''))
54
54
  }
55
55
 
56
- function ensureChannelPrefix(channelId: string, prefix: string): string {
57
- if (/^(?:group|room|chat|channel|guild|private):/i.test(channelId)) return channelId
58
- return `${prefix}:${channelId}`
59
- }
60
-
61
56
  /**
62
57
  * 滚动到目标消息(不自动加载)
63
58
  * @param seqName DOM 名(chat-xx)
@@ -121,7 +116,8 @@ export async function loadHistory(info: BaseChatInfoElem) {
121
116
  uiStore.nowGetHistory = false
122
117
  uiStore.historyBeforeTime = undefined
123
118
  chatStore.messageList = []
124
- const cacheKey = [authStore.loginInfo.platform, authStore.loginInfo.uin, info.id].map(encodeKeyPart).join(':')
119
+ const channelId = String(info.channel_id ?? info.id ?? '')
120
+ const cacheKey = [authStore.loginInfo.platform, authStore.loginInfo.uin, channelId].map(encodeKeyPart).join(':')
125
121
  const cachedMessages = chatStore.sessionMessageCache.get(cacheKey)
126
122
  if (cachedMessages?.length) {
127
123
  chatStore.messageList = [...cachedMessages]
@@ -133,17 +129,13 @@ export async function loadHistory(info: BaseChatInfoElem) {
133
129
  ) {
134
130
  const localMsgs = await dbGetLatest(
135
131
  authStore.loginInfo.uin,
136
- info.id,
132
+ channelId,
137
133
  20,
138
134
  )
139
135
  if (localMsgs.length > 0) {
140
136
  chatStore.messageList = localMsgs
141
137
  }
142
138
  }
143
- const rawChannelId = String(info.channel_id ?? '')
144
- const channelId = info.type === 'group'
145
- ? ensureChannelPrefix(rawChannelId || String(info.id), 'group')
146
- : ensureChannelPrefix(rawChannelId || String(info.id), 'private')
147
139
  try {
148
140
  const cachedMessages = await loadChatHistoryFromCache({
149
141
  platform: String(authStore.loginInfo.platform ?? ''),
@@ -7,6 +7,8 @@ const QQ_GROUP_AVATAR_PLATFORMS = new Set([
7
7
  'llob',
8
8
  ])
9
9
 
10
+ export const DEFAULT_AVATAR = '/img/icons/icon.svg'
11
+
10
12
  // 仅用于白名单平台的 QQ 群头像,不能用于用户头像
11
13
  export function buildQqGroupAvatar(
12
14
  platform: string,
@@ -24,8 +26,21 @@ export function buildQqGroupAvatar(
24
26
  return `https://p.qlogo.cn/gh/${rawId}/${rawId}/${safeSize}`
25
27
  }
26
28
 
29
+ // 命中头像上下文后,任何加载失败都统一换默认 SVG
30
+ export function isAvatarImage(img: HTMLImageElement): boolean {
31
+ if (img.hasAttribute('data-avatar') || img.getAttribute('name') === 'avatar') return true
32
+ if (img.dataset.avatarFallback === 'true') return true
33
+ if (img.closest('[data-avatar], .avatar, [class*="avatar"], [name="avatar"]')) return true
34
+ const source = img.currentSrc || img.src || img.getAttribute('src') || ''
35
+ return !source ? false : /(?:q\.qlogo\.cn|p\.qlogo\.cn|\/qqapp\/)/i.test(source)
36
+ }
37
+
27
38
  export function avatarError(event: Event) {
28
- const img = event.target as HTMLImageElement
29
- const fallback = new URL('/img/icons/icon.svg', location.origin).href
30
- if (img.src !== fallback) img.src = fallback
39
+ const img = event.target
40
+ if (!(img instanceof HTMLImageElement)) return
41
+ const fallback = new URL(DEFAULT_AVATAR, location.origin).href
42
+ const source = img.currentSrc || img.src
43
+ if (!source || source === fallback || img.dataset.avatarFallback === 'true') return
44
+ img.dataset.avatarFallback = 'true'
45
+ img.src = fallback
31
46
  }
@@ -18,7 +18,7 @@ const logger = new Logger()
18
18
  export interface LocalMsgRecord {
19
19
  /** Bot 侧的消息 ID */
20
20
  message_id: string
21
- /** 会话 ID(group_id user_id) */
21
+ /** 会话 ID:保存完整 channel_id,不剥离前缀 */
22
22
  chat_id: number | string
23
23
  /** 会话类型:"group" | "private" */
24
24
  chat_type: string
@@ -115,18 +115,28 @@ function computeRawMessage(msg: any): string | null {
115
115
  }
116
116
  }
117
117
 
118
+ function firstDefinedId(value: any, keys: string[]): number | string | undefined {
119
+ for (const key of keys) {
120
+ const current = value?.[key]
121
+ if (current != null && String(current) !== '') return current
122
+ }
123
+ return undefined
124
+ }
125
+
118
126
  function deriveChatId(selfId: string | number, msgs: any[]): number | string | undefined {
119
127
  const firstMsg = msgs[0]
120
- let chatId: number | string | undefined = firstMsg?.infoList?.group_id ?? firstMsg?.infoList?.target_id
128
+ const firstInfo = firstMsg?.infoList ?? firstMsg
129
+ const chatId = firstDefinedId(firstInfo, ['channel_id', 'channelId', 'private_id', 'group_id', 'user_id'])
130
+ ?? firstDefinedId(firstMsg, ['channel_id', 'channelId', 'private_id', 'group_id', 'user_id'])
121
131
  if (chatId != null) return chatId
122
132
 
123
133
  for (const item of msgs) {
124
- if (item?.infoList?.sender != null && String(item.infoList.sender) !== String(selfId)) {
125
- chatId = item.infoList.sender
126
- break
134
+ const info = item?.infoList ?? item
135
+ if (info?.sender != null && String(info.sender) !== String(selfId)) {
136
+ return info.sender
127
137
  }
128
138
  }
129
- return chatId
139
+ return undefined
130
140
  }
131
141
 
132
142
  export function ensureChatIdOnMsgs(selfId: string | number, msgs: any[]): any[] {
@@ -135,14 +145,21 @@ export function ensureChatIdOnMsgs(selfId: string | number, msgs: any[]): any[]
135
145
 
136
146
  return msgs.map((item: any) => {
137
147
  if (!item?.infoList) return item
138
- if (item.infoList.group_id != null || item.infoList.target_id != null) {
148
+ if (
149
+ item.infoList.channel_id != null
150
+ || item.infoList.channelId != null
151
+ || item.infoList.private_id != null
152
+ || item.infoList.group_id != null
153
+ || item.infoList.user_id != null
154
+ ) {
139
155
  return item
140
156
  }
141
157
  return {
142
158
  ...item,
143
159
  infoList: {
144
160
  ...item.infoList,
145
- target_id: chatId,
161
+ channel_id: chatId,
162
+ channelId: chatId,
146
163
  },
147
164
  }
148
165
  })
@@ -158,12 +175,14 @@ export function msgToRecord(msg: any): LocalMsgRecord | null {
158
175
  const messageId = msg.message_id
159
176
  if (!messageId) return null
160
177
 
161
- const chatId: number | string = msg.infoList.group_id ?? msg.infoList.target_id
178
+ const info = msg.infoList ?? msg
179
+ const chatId = firstDefinedId(info, ['channel_id', 'channelId', 'private_id', 'group_id', 'user_id'])
180
+ ?? firstDefinedId(msg, ['channel_id', 'channelId', 'private_id', 'group_id', 'user_id'])
162
181
  if (chatId == null) return null
163
182
 
164
183
  const chatType: string =
165
184
  msg.message_type ?? (msg.group_id != null ? 'group' : 'private')
166
- const senderId: number | string = msg.infoList.sender
185
+ const senderId: number | string = info.sender ?? msg.sender?.user_id
167
186
  if (senderId == null) return null
168
187
 
169
188
  const senderName: string | null =
@@ -490,7 +509,8 @@ function deserializeRecord(record: LocalMsgRecord): any {
490
509
  message_id: record.message_id,
491
510
  private_id: isGroup ? undefined : record.chat_id,
492
511
  group_id: isGroup ? record.chat_id : undefined,
493
- target_id: isGroup ? undefined : record.chat_id,
512
+ channel_id: record.chat_id,
513
+ channelId: record.chat_id,
494
514
  sender: record.sender_id,
495
515
  }
496
516
 
@@ -499,7 +519,9 @@ function deserializeRecord(record: LocalMsgRecord): any {
499
519
  message_id: record.message_id,
500
520
  message_type: record.chat_type,
501
521
  // 根据 chat_type 恢复对应的 id 字段
502
- ...(isGroup ? { group_id: record.chat_id } : { user_id: record.chat_id }),
522
+ ...(isGroup ? { group_id: record.chat_id } : { user_id: record.sender_id }),
523
+ channel_id: record.chat_id,
524
+ channelId: record.chat_id,
503
525
  sender,
504
526
  time: record.time,
505
527
  timestamp_ms: timeMs || undefined,
@@ -509,6 +531,8 @@ function deserializeRecord(record: LocalMsgRecord): any {
509
531
  infoList,
510
532
  raw_message: record.raw_message ?? '',
511
533
  revoked: record.revoked,
534
+ revoke: record.revoked,
535
+ revokeTime: record.revoked ? Date.now() / 1000 : undefined,
512
536
  // 消息序列号(并非所有 Bot 都提供,可为 null)
513
537
  ...(record.seq != null ? { message_seq: record.seq, seq_id: record.seq } : {}),
514
538
  // 标记来源为本地缓存,业务层可按需用此字段区分
@@ -27,6 +27,7 @@ import {
27
27
  getSessionId,
28
28
  mergeSessionState,
29
29
  normalizeSessionId,
30
+ upsertSessionContact,
30
31
  } from './sessionUtil'
31
32
 
32
33
  const logger = new Logger()
@@ -681,21 +682,31 @@ export async function sendMsgRaw(
681
682
  chatStore.messageList = chatStore.messageList.concat([showMsg])
682
683
 
683
684
  // 发送方不一定会上报自身消息事件,先用预发送消息同步会话预览。
684
- const sessionId = normalizeSessionId(String(id).split('/')[0])
685
+ const sessionId = normalizeSessionId(
686
+ targetChannelId
687
+ || chatStore.chatInfo.show.channel_id
688
+ || String(id).split('/')[0],
689
+ )
685
690
  const session = contactStore.baseOnMsgList.get(sessionId) ??
686
691
  findSessionContact(contactStore.userList, sessionId)
687
692
  if (session) {
688
693
  const raw = getMsgRawTxt(showMsg)
689
694
  const senderName = authStore.loginInfo.nickname
690
- Object.assign(session, {
691
- message_id: showMsg.message_id,
692
- raw_msg: type === 'group' && senderName ? `${senderName}: ${raw}` : raw,
693
- raw_msg_base: raw,
694
- time: showMsg.time * 1000,
695
- })
696
- contactStore.baseOnMsgList.set(normalizeSessionId(sessionId), session)
697
- updateBaseOnMsgList()
698
- }
695
+ Object.assign(session, {
696
+ message_id: showMsg.message_id,
697
+ raw_msg: type === 'group' && senderName ? `${senderName}: ${raw}` : raw,
698
+ raw_msg_base: raw,
699
+ time: showMsg.time * 1000,
700
+ })
701
+ if (type === 'group') {
702
+ // 群发送预览同步到联系人列表,同时进入群会话列表
703
+ contactStore.userList = upsertSessionContact(contactStore.userList, session)
704
+ } else if (session._groupMember) {
705
+ session._groupMember = false
706
+ }
707
+ contactStore.baseOnMsgList.set(normalizeSessionId(sessionId), session)
708
+ updateBaseOnMsgList()
709
+ }
699
710
  }
700
711
  // 预发送保留 Base64 用于本地预览;真正发送前先写入后端临时目录
701
712
  try {
@@ -762,7 +773,7 @@ export async function sendMsgRaw(
762
773
  {
763
774
  user_id: id.split('/')[0],
764
775
  group_id: id.split('/')[1],
765
- channel_id: String(targetChannelId ?? chatStore.chatInfo.show.channel_id ?? `private:${String(id).split('/')[0]}`),
776
+ channel_id: String(targetChannelId ?? chatStore.chatInfo.show.channel_id ?? String(id).split('/')[0]),
766
777
  message: msg,
767
778
  },
768
779
  echo + '_uuid_' + msgUUID,
@@ -773,7 +784,7 @@ export async function sendMsgRaw(
773
784
  'send_msg',
774
785
  {
775
786
  user_id: id,
776
- channel_id: String(targetChannelId ?? chatStore.chatInfo.show.channel_id ?? (/^(?:group|room|chat|channel|guild|private):/i.test(String(id)) ? String(id) : `private:${String(id)}`)),
787
+ channel_id: String(targetChannelId ?? chatStore.chatInfo.show.channel_id ?? String(id)),
777
788
  message: msg,
778
789
  },
779
790
  echo + '_uuid_' + msgUUID,
@@ -827,7 +838,7 @@ function normalizeBaseSessionMap() {
827
838
  const seen = new Map<string, UserFriendElem & UserGroupElem>()
828
839
  for (const [rawKey, item] of contactStore.baseOnMsgList) {
829
840
  const id = getSessionDedupKey(item)
830
- if (id.endsWith(':0') || id.endsWith(':')) continue
841
+ if (id === '0' || id === '') continue
831
842
  const existing = seen.get(id)
832
843
  if (existing && existing !== item) {
833
844
  needNormalize = true
@@ -846,7 +857,7 @@ function normalizeBaseSessionMap() {
846
857
  const canonical = new Map<string, UserFriendElem & UserGroupElem>()
847
858
  contactStore.baseOnMsgList.forEach((item) => {
848
859
  const id = getSessionDedupKey(item)
849
- if (id === 'group:0' || id === 'group:' || id === 'user:0' || id === 'user:') return
860
+ if (id === '0' || id === '') return
850
861
  const existing = canonical.get(id)
851
862
  if (existing && existing !== item) {
852
863
  canonical.set(id, mergeSessionState(existing, item))
@@ -869,7 +880,7 @@ function getSessionList() {
869
880
 
870
881
  const addSession = (item: UserFriendElem & UserGroupElem) => {
871
882
  const id = getSessionDedupKey(item)
872
- if (id === 'group:0' || id === 'group:' || id === 'user:0' || id === 'user:') return
883
+ if (id === '0' || id === '') return
873
884
  const existing = sessionMap.get(id)
874
885
  if (existing && existing !== item) {
875
886
  sessionMap.set(id, mergeSessionState(existing, item))
@@ -880,7 +891,7 @@ function getSessionList() {
880
891
 
881
892
  if (settingsStore.sysConfig.session_display_mode === 'all') {
882
893
  contactStore.userList.forEach((item) => {
883
- addSession(item)
894
+ if (!item._groupMember) addSession(item)
884
895
  })
885
896
  }
886
897
 
@@ -894,11 +905,11 @@ function getSessionList() {
894
905
  /**
895
906
  * 刷新消息列表排序
896
907
  */
897
- export function updateBaseOnMsgList() {
898
- const contactStore = useContactStore()
899
- const settingsStore = useSettingsStore()
900
- normalizeBaseSessionMap()
901
- const allList = getSessionList()
908
+ export function updateBaseOnMsgList() {
909
+ const contactStore = useContactStore()
910
+ const settingsStore = useSettingsStore()
911
+ normalizeBaseSessionMap()
912
+ const allList = getSessionList()
902
913
  // 先更具 item.always_top 是不是 true 拆为两个数组
903
914
  const topList = allList.filter((item) => item.always_top)
904
915
  const normalList = allList.filter((item) => !item.always_top)