koishi-plugin-chat-patch 6.0.0 → 6.1.1

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.
@@ -11,8 +11,8 @@
11
11
  <link rel="stylesheet" href="./bcui/css/style.css">
12
12
  <link rel="stylesheet" href="./bcui/css/color-light.css">
13
13
  <link rel="stylesheet" href="./css/append-light.css">
14
- <script type="module" crossorigin src="./assets/index--7-eCx0r.js"></script>
15
- <link rel="stylesheet" crossorigin href="./assets/index-X9hFOAza.css">
14
+ <script type="module" crossorigin src="./assets/index-N2_GDY3v.js"></script>
15
+ <link rel="stylesheet" crossorigin href="./assets/index-BW47RWcA.css">
16
16
  </head>
17
17
 
18
18
  <body>
@@ -180,12 +180,21 @@ input {
180
180
  border-radius: 7px;
181
181
  }
182
182
  .chat-pan > div.more > div:nth-child(2) > div:first-child {
183
- margin-right: 20px;
183
+ display: flex;
184
+ flex: 0 0 auto;
185
+ gap: 8px;
186
+ margin-right: 12px;
187
+ background: transparent;
188
+ }
189
+ .chat-pan > div.more > div:nth-child(2) > div:first-child > .send-tool-button {
190
+ background: var(--color-card-1);
191
+ border-radius: 7px;
184
192
  cursor: pointer;
185
193
  height: 50px;
194
+ margin: 0;
186
195
  width: 50px;
187
196
  }
188
- .chat-pan > div.more > div:nth-child(2) > div:first-child > svg {
197
+ .chat-pan > div.more > div:nth-child(2) > div:first-child > .send-tool-button > svg {
189
198
  color: var(--color-font-1);
190
199
  margin: 15px;
191
200
  height: 20px;
@@ -225,6 +234,19 @@ input {
225
234
  resize: none;
226
235
  border: 0;
227
236
  }
237
+ .chat-pan > div.more > div:nth-child(2) > div > form.custom-element-form > input.custom-element-type-input {
238
+ flex: 0 1 18%;
239
+ width: 18%;
240
+ min-width: 72px;
241
+ max-width: 130px;
242
+ margin-right: 8px;
243
+ }
244
+ .chat-pan > div.more > div:nth-child(2) > div > form.custom-element-form > input.custom-element-content-input,
245
+ .chat-pan > div.more > div:nth-child(2) > div > form.custom-element-form > textarea.custom-element-content-input {
246
+ flex: 1 1 auto;
247
+ width: auto;
248
+ min-width: 0;
249
+ }
228
250
  .chat-pan > div.more > div:nth-child(2) > div > form > input:disabled,
229
251
  .chat-pan > div.more > div:nth-child(2) > div > form > textarea:disabled {
230
252
  background: var(--color-card);
@@ -1047,9 +1047,6 @@ msgstr ""
1047
1047
  msgid "应用内通知和系统通知"
1048
1048
  msgstr ""
1049
1049
 
1050
- msgid "成员设置"
1051
- msgstr ""
1052
-
1053
1050
  msgid "成员信息"
1054
1051
  msgstr ""
1055
1052
 
@@ -103,17 +103,17 @@
103
103
  class="msg-md" />
104
104
  <img v-else-if="item.type == 'image' && item.file == 'marketface'"
105
105
  :class=" imgStyle(data.message.length, Number(index), true) + ' msg-mface'"
106
- :src="item.url"
106
+ :src="getImageUrl(item)"
107
107
  :alt="item.summary"
108
108
  @load="imageLoaded"
109
- @click="preImgClick(item.url || item.file)"
109
+ @click="preImgClick(getImageUrl(item))"
110
110
  @error="imgLoadFail">
111
111
  <img v-else-if="item.type == 'mface'"
112
112
  :class=" imgStyle(data.message.length, Number(index), true) + ' msg-mface'"
113
- :src="item.url"
113
+ :src="getImageUrl(item)"
114
114
  :alt="item.summary"
115
115
  @load="imageLoaded"
116
- @click="preImgClick(item.url || item.file)"
116
+ @click="preImgClick(getImageUrl(item))"
117
117
  @error="imgLoadFail">
118
118
  <template v-else-if="item.type == 'image'">
119
119
  <div v-show="shouldShowImagePlaceholder(item, Number(index))"
@@ -121,21 +121,21 @@
121
121
  :class="imgStyle(data.message.length, Number(index), isFace(item)) + ' msg-img-placeholder'"
122
122
  @click="loadImage(item, Number(index), $event)">
123
123
  <font-awesome-icon
124
- :icon="['fas', imageLoading(getImageKey(Number(index), item.url)) ? 'spinner' : 'image']"
125
- :spin="imageLoading(getImageKey(Number(index), item.url))" />
124
+ :icon="['fas', imageLoading(getImageKey(Number(index), getImageUrl(item))) ? 'spinner' : 'image']"
125
+ :spin="imageLoading(getImageKey(Number(index), getImageUrl(item)))" />
126
126
  <span>
127
- {{ imageLoading(getImageKey(Number(index), item.url)) ? $t('加载中') : $t('点击加载图片') }}
127
+ {{ imageLoading(getImageKey(Number(index), getImageUrl(item))) ? $t('加载中') : $t('点击加载图片') }}
128
128
  </span>
129
129
  </div>
130
130
  <img v-show="!shouldShowImagePlaceholder(item, Number(index))"
131
131
  :title="(!item.summary || item.summary == '') ? $t('预览图片') : item.summary"
132
132
  :alt="$t('图片')"
133
133
  :class=" imgStyle(data.message.length, Number(index), isFace(item))"
134
- :src="getImgSrc(item.url)"
134
+ :src="getImgSrc(getImageUrl(item))"
135
135
  data-type="image"
136
136
  @load="imageLoaded"
137
137
  @error="imgLoadFail"
138
- @click="imgClick(item.url || item.file, $event)">
138
+ @click="imgClick(getImageUrl(item), $event)">
139
139
  </template>
140
140
  <template v-else-if="item.type == 'face'">
141
141
  <EmojiFace :emoji="Emoji.get(Number(item.id))" class="msg-face" />
@@ -173,7 +173,8 @@
173
173
  <div v-if="data.fileView && Object.keys(data.fileView).length > 0"
174
174
  class="file-view">
175
175
  <img v-if="['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(data.fileView.ext)"
176
- :src="getMediaSrc(data.fileView.url)">
176
+ :src="getMediaSrc(data.fileView.url)"
177
+ @click="preImgClick(getMediaSrc(data.fileView.url))">
177
178
  <video v-else-if="['mp4', 'avi', 'mkv', 'flv'].includes(data.fileView.ext)"
178
179
  playsinline controls muted
179
180
  autoplay>
@@ -570,11 +571,16 @@ async function loadCachedImages() {
570
571
  const selfId = authStore.loginInfo?.uin
571
572
  if (!selfId) return
572
573
  for (const seg of data.message) {
573
- if (seg.type !== 'image' || !seg.url) continue
574
- await loadCachedImage(seg.url)
574
+ const url = getImageUrl(seg)
575
+ if (seg.type !== 'image' || !url) continue
576
+ await loadCachedImage(url)
575
577
  }
576
578
  }
577
579
 
580
+ function getImageUrl(item: { url?: string; file?: string }): string {
581
+ return String(item?.url || item?.file || '')
582
+ }
583
+
578
584
  async function loadCachedImage(url: string) {
579
585
  if (resolvedImages.value[url]) return resolvedImages.value[url]
580
586
 
@@ -593,6 +599,7 @@ async function loadCachedImage(url: string) {
593
599
  }
594
600
 
595
601
  function getImgSrc(url: string): string {
602
+ if (!url) return ''
596
603
  if (url.startsWith('base64://')) {
597
604
  return `data:image/png;base64,${url.slice(9)}`
598
605
  }
@@ -617,11 +624,11 @@ function imageLoading(key: string) {
617
624
  return pendingImageLoads.value[key] === true
618
625
  }
619
626
 
620
- function shouldShowImagePlaceholder(item: { type: string, url: string }, index: number) {
627
+ function shouldShowImagePlaceholder(item: { type: string, url?: string, file?: string }, index: number) {
621
628
  if (item.type !== 'image') return false
622
629
  if (settingsStore.sysConfig.opt_no_auto_load_image !== true) return false
623
630
 
624
- return manualImageLoads.value[getImageKey(index, item.url)] !== true
631
+ return manualImageLoads.value[getImageKey(index, getImageUrl(item))] !== true
625
632
  }
626
633
 
627
634
  function getAtClass(who: number | string) {
@@ -717,8 +724,9 @@ function buildMessageImageList(): Img | undefined {
717
724
  const collect = (msg: any) => {
718
725
  if (!Array.isArray(msg?.message)) return
719
726
  for (const item of msg.message) {
720
- if (item?.type === 'image' && item?.file !== 'marketface' && item?.url) {
721
- urls.push(String(item.url))
727
+ if (item?.type === 'image' && item?.file !== 'marketface') {
728
+ const url = getImageUrl(item)
729
+ if (url) urls.push(url)
722
730
  }
723
731
  }
724
732
  }
@@ -729,15 +737,16 @@ function buildMessageImageList(): Img | undefined {
729
737
  return Img.fromList([...new Set(urls)])
730
738
  }
731
739
 
732
- async function loadImage(item: { url: string }, index: number, _event?: MouseEvent) {
740
+ async function loadImage(item: { url?: string; file?: string }, index: number, _event?: MouseEvent) {
733
741
  if (props.selecting) return
734
- const key = getImageKey(index, item.url)
742
+ const url = getImageUrl(item)
743
+ const key = getImageKey(index, url)
735
744
  if (manualImageLoads.value[key] || pendingImageLoads.value[key]) return
736
745
 
737
746
  pendingImageLoads.value[key] = true
738
747
  try {
739
748
  if (data._from_local_db) {
740
- await loadCachedImage(item.url)
749
+ await loadCachedImage(url)
741
750
  }
742
751
  manualImageLoads.value[key] = true
743
752
  } finally {
@@ -1252,7 +1261,7 @@ async function openMerge(event?: MouseEvent) {
1252
1261
  imgList.push({
1253
1262
  index: index,
1254
1263
  message_id: item.message_id,
1255
- img_url: msg.url,
1264
+ img_url: msg.url || msg.file,
1256
1265
  })
1257
1266
  index++
1258
1267
  }
@@ -1303,6 +1303,8 @@ export interface SaveSelfMessagePayload {
1303
1303
  forwardId?: string
1304
1304
  forwardContent?: unknown[]
1305
1305
  sentAt?: number
1306
+ timestamp?: number
1307
+ timestampMs?: number
1306
1308
  source?: 'webui' | 'bot' | 'plugin'
1307
1309
  kind?: string
1308
1310
  }
@@ -1330,6 +1332,8 @@ function selfMessageToOneBot(record: unknown): Record<string, unknown> | null {
1330
1332
  const selfId = getString(obj.selfId)
1331
1333
  const channelId = getString(obj.channelId)
1332
1334
  const sentAt = Number(obj.sentAt ?? Date.now())
1335
+ const timestamp = Number(obj.timestamp) || Math.floor(sentAt / 1000)
1336
+ const timestampMs = Number(obj.timestampMs ?? sentAt)
1333
1337
  const messageId = getString(obj.messageId)
1334
1338
  const localId = getString(obj.id) || `self-${sentAt}`
1335
1339
  // 只按记录里的显式 channelType 判断,不根据频道 ID 前缀推断
@@ -1399,10 +1403,11 @@ function selfMessageToOneBot(record: unknown): Record<string, unknown> | null {
1399
1403
  channel_id: channelId,
1400
1404
  guild_id: guildId || undefined,
1401
1405
  target_id: selfId,
1402
- time: Math.floor(sentAt / 1000) || Math.floor(Date.now() / 1000),
1403
- local_time: sentAt,
1404
- timestamp_ms: sentAt,
1405
- time_ms: sentAt,
1406
+ timestamp,
1407
+ time: timestamp,
1408
+ local_time: timestampMs,
1409
+ timestamp_ms: timestampMs,
1410
+ time_ms: timestampMs,
1406
1411
  sender: {
1407
1412
  user_id: selfId,
1408
1413
  nickname: getString(login?.name) || selfId,
@@ -1469,8 +1474,9 @@ export async function loadChatHistoryFromCache(params: {
1469
1474
  const raw = getObject(recordObj.raw)
1470
1475
  const msg = satoriEventToOneBot(raw, params.platform)
1471
1476
  if (!msg || !msg.message_id) continue
1472
- const localTime = Number(recordObj.receivedAt ?? recordObj.timestampMs ?? recordObj.timestamp ?? 0)
1473
- if (localTime) {
1477
+ const rawTime = Number(recordObj.timestampMs ?? recordObj.timestamp ?? recordObj.receivedAt ?? 0)
1478
+ const localTime = rawTime > 1e12 ? rawTime : rawTime * 1000
1479
+ if (localTime > 0) {
1474
1480
  msg.local_time = localTime
1475
1481
  msg.timestamp_ms = localTime
1476
1482
  msg.time_ms = localTime
@@ -0,0 +1,31 @@
1
+ import h from '@satorijs/element'
2
+
3
+ // 自定义消息元素:使用 Satori 元素工厂,保留命名空间扩展类型。
4
+ const ELEMENT_TYPE_PATTERN = /^[a-z][a-z0-9_:-]*$/i
5
+
6
+ type CustomElement = ReturnType<typeof h>
7
+
8
+ /**
9
+ * 将输入的类型和内容转换为 Satori 元素。
10
+ * 空内容不生成元素,避免发送无意义的空消息。
11
+ */
12
+ export function buildCustomElement(type: string, content: string): CustomElement | null {
13
+ const elementType = type.trim()
14
+ const elementContent = content.trim()
15
+ if (!ELEMENT_TYPE_PATTERN.test(elementType) || elementContent.length === 0) return null
16
+
17
+ const normalizedType = elementType.toLowerCase()
18
+ if (normalizedType === 'text') return h.text(content)
19
+ if (normalizedType === 'img' || normalizedType === 'image') return h.img(elementContent)
20
+ if (normalizedType === 'file') return h.file(elementContent)
21
+ if (normalizedType === 'video') return h.video(elementContent)
22
+ if (normalizedType === 'audio' || normalizedType === 'record') return h.audio(elementContent)
23
+ if (normalizedType === 'json' || normalizedType === 'xml') {
24
+ return h(normalizedType, { data: elementContent })
25
+ }
26
+ if (normalizedType === 'markdown') {
27
+ return h('markdown', elementContent)
28
+ }
29
+
30
+ return h(elementType, elementContent)
31
+ }
@@ -1039,6 +1039,8 @@ const msgFunctions = {
1039
1039
  message: Array.isArray(sentItem?.message) ? sentItem.message : [],
1040
1040
  source: 'webui',
1041
1041
  sentAt: Number(sentItem?.local_time ?? sentItem?.timestamp_ms ?? Date.now()),
1042
+ timestamp: Number(sentItem?.time) || Math.floor(Date.now() / 1000),
1043
+ timestampMs: Number(sentItem?.local_time ?? sentItem?.timestamp_ms ?? Date.now()),
1042
1044
  kind: String(sentItem?.message?.[0]?.type ?? 'text'),
1043
1045
  })
1044
1046
  }
@@ -456,6 +456,7 @@ export function satoriEventToOneBot(
456
456
  self_id: selfId,
457
457
  platform,
458
458
  sn: getNumber(event.sn),
459
+ timestamp: Math.floor(normalizeTimestampMs(event.timestamp) / 1000),
459
460
  timestamp_ms: getNumber(event.timestamp) ? normalizeTimestampMs(event.timestamp) : 0,
460
461
  time: Math.floor(normalizeTimestampMs(event.timestamp) / 1000),
461
462
  message_seq: getNumber(event.sn),
@@ -733,6 +734,7 @@ function messageListFromResponse(data: unknown): unknown[] {
733
734
  return {
734
735
  message_id: getString(message.id),
735
736
  sn: getNumber(message.sn),
737
+ timestamp: Math.floor(normalizeTimestampMs(message.timestamp) / 1000),
736
738
  timestamp_ms: getNumber(message.timestamp) ? normalizeTimestampMs(message.timestamp) : 0,
737
739
  time: Math.floor(normalizeTimestampMs(message.timestamp) / 1000),
738
740
  message_seq: getNumber(message.sn) || getNumber(message.seq),
@@ -459,12 +459,12 @@ export function parseCQ(data: any) {
459
459
  * @param preShow 是否消息预显
460
460
  * @param echo 回显的事件名
461
461
  */
462
- function parsePreviewMarkup(source: string): MsgItemElem[] {
463
- const result: MsgItemElem[] = []
464
- const tagPattern = /<(img|image|face|mface|file|audio|video|at|quote|sharp)\s+([^>]*?)\/?>/gi
465
- let last = 0
466
- let match: RegExpExecArray | null
467
- while ((match = tagPattern.exec(source)) !== null) {
462
+ function parsePreviewMarkup(source: string): MsgItemElem[] {
463
+ const result: MsgItemElem[] = []
464
+ const tagPattern = /<([a-z][\w:-]*)\s+([^>]*?)\/?>/gi
465
+ let last = 0
466
+ let match: RegExpExecArray | null
467
+ while ((match = tagPattern.exec(source)) !== null) {
468
468
  if (match.index > last) {
469
469
  result.push({ type: 'text', text: source.slice(last, match.index) })
470
470
  }
@@ -475,20 +475,28 @@ function parsePreviewMarkup(source: string): MsgItemElem[] {
475
475
  const name = attrs.match(/name\s*=\s*(?:"([^"]*)"|'([^']*)')/i)?.[1]
476
476
  ?? attrs.match(/name\s*=\s*(?:"([^"]*)"|'([^']*)')/i)?.[2]
477
477
  ?? ''
478
- const id = attrs.match(/id\s*=\s*(?:"([^"]*)"|'([^']*)')/i)?.[1]
479
- ?? attrs.match(/id\s*=\s*(?:"([^"]*)"|'([^']*)')/i)?.[2]
480
- ?? ''
481
- const tag = String(match[1] ?? '').toLowerCase()
482
- if (tag === 'at') {
483
- result.push({ type: 'at', qq: id, text: name || id })
484
- } else if (tag === 'quote') {
485
- result.push({ type: 'reply', id })
486
- } else if (tag === 'sharp') {
487
- result.push({ type: 'text', text: name ? `#${name}` : `#${id}` })
488
- } else {
489
- const type = tag === 'file'
490
- ? 'file'
491
- : tag === 'audio'
478
+ const id = attrs.match(/id\s*=\s*(?:"([^"]*)"|'([^']*)')/i)?.[1]
479
+ ?? attrs.match(/id\s*=\s*(?:"([^"]*)"|'([^']*)')/i)?.[2]
480
+ ?? ''
481
+ const content = attrs.match(/content\s*=\s*(?:"([^"]*)"|'([^']*)')/i)?.[1]
482
+ ?? attrs.match(/content\s*=\s*(?:"([^"]*)"|'([^']*)')/i)?.[2]
483
+ ?? ''
484
+ const data = attrs.match(/data\s*=\s*(?:"([^"]*)"|'([^']*)')/i)?.[1]
485
+ ?? attrs.match(/data\s*=\s*(?:"([^"]*)"|'([^']*)')/i)?.[2]
486
+ ?? ''
487
+ const tag = String(match[1] ?? '').toLowerCase()
488
+ if (tag === 'at') {
489
+ result.push({ type: 'at', qq: id, text: name || id })
490
+ } else if (tag === 'quote') {
491
+ result.push({ type: 'reply', id })
492
+ } else if (tag === 'sharp') {
493
+ result.push({ type: 'text', text: name ? `#${name}` : `#${id}` })
494
+ } else if (tag === 'text') {
495
+ result.push({ type: 'text', text: content })
496
+ } else if (['img', 'image', 'face', 'mface', 'file', 'audio', 'video'].includes(tag)) {
497
+ const type = tag === 'file'
498
+ ? 'file'
499
+ : tag === 'audio'
492
500
  ? 'record'
493
501
  : tag === 'video'
494
502
  ? 'video'
@@ -497,9 +505,17 @@ function parsePreviewMarkup(source: string): MsgItemElem[] {
497
505
  type,
498
506
  file: src,
499
507
  url: src,
500
- ...(name ? { name } : {}),
501
- })
502
- }
508
+ ...(name ? { name } : {}),
509
+ })
510
+ } else {
511
+ result.push({
512
+ type: tag,
513
+ ...(src ? { src, file: src, url: src } : {}),
514
+ ...(name ? { name } : {}),
515
+ ...(content ? { content } : {}),
516
+ ...(data ? { data } : {}),
517
+ })
518
+ }
503
519
  last = match.index + match[0].length
504
520
  }
505
521
  if (last < source.length) {
@@ -518,13 +534,24 @@ function getBase64Source(item: any): string {
518
534
  return ''
519
535
  }
520
536
 
521
- function isBase64Source(source: string): boolean {
522
- return source.startsWith('base64://') || source.startsWith('data:')
523
- }
524
-
525
- export function getLocalMediaUrl(value: string): string {
526
- if (!value) return value
527
- const isLocalPath = value.startsWith('file:') ||
537
+ function isBase64Source(source: string): boolean {
538
+ return source.startsWith('base64://') || source.startsWith('data:')
539
+ }
540
+
541
+ function toPreviewImageSrc(item: any): string {
542
+ const source = String(item?.url ?? item?.file ?? '')
543
+ if (source.startsWith('base64://')) {
544
+ return `data:image/png;base64,${source.slice(9)}`
545
+ }
546
+ if (source.startsWith('data:') || source.startsWith('http:') || source.startsWith('https:')) {
547
+ return source
548
+ }
549
+ return getLocalMediaUrl(source)
550
+ }
551
+
552
+ export function getLocalMediaUrl(value: string): string {
553
+ if (!value) return value
554
+ const isLocalPath = value.startsWith('file:') ||
528
555
  /^[a-z]:[\\/]/i.test(value) ||
529
556
  value.startsWith('\\\\')
530
557
  if (!isLocalPath) return value
@@ -637,20 +664,15 @@ export async function sendMsgRaw(
637
664
  const preShowMsg = typeof msg === 'string'
638
665
  ? parsePreviewMarkup(msg)
639
666
  : JSON.parse(JSON.stringify(msg));
640
- preShowMsg.forEach((item: any) => {
641
- // 对 base64 图片做特殊处理
642
- if (item.type == 'image') {
643
- if (item.file.startsWith('base64://')) {
644
- const b64Str = (item.file as string).substring(9)
645
- item.url = 'data:image/png;base64,' + b64Str
646
- } else {
647
- item.url = item.file
648
- }
649
- }
650
- if (item.type == 'record' && item.file.startsWith('base64://')) {
651
- const b64Str = (item.file as string).substring(9)
652
- item.url = 'data:audio/webm;base64,' + b64Str
653
- }
667
+ preShowMsg.forEach((item: any) => {
668
+ // 对 base64 图片做特殊处理
669
+ if (item.type == 'image') {
670
+ item.url = toPreviewImageSrc(item)
671
+ }
672
+ if (item.type == 'record' && String(item?.file ?? '').startsWith('base64://')) {
673
+ const b64Str = (item.file as string).substring(9)
674
+ item.url = 'data:audio/webm;base64,' + b64Str
675
+ }
654
676
  })
655
677
  const showMsg = {
656
678
  revoke: true,