koishi-plugin-chat-patch 6.1.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.
@@ -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
+ }
@@ -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) {
@@ -376,15 +376,29 @@
376
376
  </div>
377
377
  <!-- 消息发送框 -->
378
378
  <div>
379
- <div v-menu.prevent="_=>moreFunClick()"
380
- @click="moreFunClick(settingsStore.sysConfig.quick_send)">
381
- <font-awesome-icon v-if="tags.showMoreDetail || details.find(item => item.open)" :icon="['fas', 'minus']" />
382
- <font-awesome-icon v-else-if="settingsStore.sysConfig.quick_send == 'default'" :icon="['fas', 'plus']" />
383
- <font-awesome-icon v-else-if="settingsStore.sysConfig.quick_send == 'img'" :icon="['fas', 'image']" />
384
- <font-awesome-icon v-else-if="settingsStore.sysConfig.quick_send == 'file'" :icon="['fas', 'folder']" />
379
+ <div class="send-tool-buttons">
380
+ <div class="send-tool-button"
381
+ v-menu.prevent="_=>moreFunClick()"
382
+ @click="moreFunClick(settingsStore.sysConfig.quick_send)">
383
+ <font-awesome-icon v-if="tags.showMoreDetail || details.find(item => item.open)" :icon="['fas', 'minus']" />
384
+ <font-awesome-icon v-else-if="settingsStore.sysConfig.quick_send == 'default'" :icon="['fas', 'plus']" />
385
+ <font-awesome-icon v-else-if="settingsStore.sysConfig.quick_send == 'img'" :icon="['fas', 'image']" />
386
+ <font-awesome-icon v-else-if="settingsStore.sysConfig.quick_send == 'file'" :icon="['fas', 'folder']" />
387
+ </div>
388
+ <div
389
+ class="send-tool-button custom-element-toggle"
390
+ :class="{ active: customElementMode }"
391
+ :title="$t('自定义元素')"
392
+ :aria-pressed="customElementMode"
393
+ role="button"
394
+ @click="toggleCustomElementMode">
395
+ <font-awesome-icon :icon="['fas', 'code']" />
396
+ </div>
385
397
  </div>
386
398
  <div>
387
- <form @submit="mainSubmit">
399
+ <form
400
+ :class="{ 'custom-element-form': customElementMode }"
401
+ @submit="mainSubmit">
388
402
  <template v-if="pendingVoice">
389
403
  <div id="voice-pending-input" class="voice-pending" role="button"
390
404
  @click="cancelPendingVoice">
@@ -392,6 +406,51 @@
392
406
  <font-awesome-icon :icon="['fas', 'xmark']" />
393
407
  </div>
394
408
  </template>
409
+ <template v-else-if="customElementMode">
410
+ <label for="custom-element-type" class="sr-only">{{ $t('元素类型') }}</label>
411
+ <input
412
+ id="custom-element-type"
413
+ ref="customElementTypeInput"
414
+ v-model="customElementType"
415
+ class="custom-element-type-input"
416
+ type="text"
417
+ autocomplete="off"
418
+ placeholder="text / img / file"
419
+ :disabled="uiStore.openSideBar || chat.info.me_info.shut_up_timestamp > 0">
420
+ <template v-if="settingsStore.sysConfig.send_key === 'none'">
421
+ <label for="main-input" class="sr-only">{{ $t('消息输入框') }}</label>
422
+ <input
423
+ id="main-input"
424
+ ref="mainInput"
425
+ v-model="msg"
426
+ class="custom-element-content-input"
427
+ type="text"
428
+ autocomplete="off"
429
+ placeholder="URL / content"
430
+ :disabled="uiStore.openSideBar || chat.info.me_info.shut_up_timestamp > 0"
431
+ @keydown="mainAtKey"
432
+ @keyup="mainKeyUp"
433
+ @click="selectSQIn"
434
+ @input="handleInput">
435
+ </template>
436
+ <template v-else>
437
+ <label for="main-input-ex" class="sr-only">{{ $t('消息输入框') }}</label>
438
+ <textarea
439
+ id="main-input-ex"
440
+ ref="mainInput"
441
+ v-model="msg"
442
+ class="custom-element-content-input"
443
+ type="text"
444
+ placeholder="URL / content"
445
+ :disabled="uiStore.openSideBar"
446
+ @keydown="mainKey"
447
+ @keyup="mainKeyUp"
448
+ @click="selectSQIn"
449
+ @input="handleInput"
450
+ @compositionstart="handleCompositionStart"
451
+ @compositionend="handleCompositionEnd" />
452
+ </template>
453
+ </template>
395
454
  <template v-else>
396
455
  <template v-if="settingsStore.sysConfig.send_key === 'none'">
397
456
  <label for="main-input" class="sr-only">{{ $t('消息输入框') }}</label>
@@ -557,6 +616,7 @@
557
616
  import app from '../main'
558
617
  import { i18n } from '../main'
559
618
  import SendUtil from '../function/sender'
619
+ import { buildCustomElement } from '../function/custom-element'
560
620
  import Option, { get } from '../function/option'
561
621
  import Info from '../pages/Info.vue'
562
622
  import MsgBody from '../components/MsgBody.vue'
@@ -650,6 +710,7 @@ const msgPan = useTemplateRef<HTMLDivElement>('msgPan')
650
710
  const chatPadding = useTemplateRef<HTMLSpanElement>('chatPadding')
651
711
  const sendMore = useTemplateRef<HTMLDivElement>('sendMore')
652
712
  const mainInput = useTemplateRef<HTMLInputElement | HTMLTextAreaElement>('mainInput')
713
+ const customElementTypeInput = useTemplateRef<HTMLInputElement>('customElementTypeInput')
653
714
 
654
715
  type ForwardAction = 'single-message' | 'individual-messages' | 'merged-messages'
655
716
 
@@ -706,6 +767,8 @@ const details = ref([
706
767
  const msgMenus = ref<any[]>([])
707
768
  const NewMsgNum = ref(0)
708
769
  const msg = ref('')
770
+ const customElementMode = ref(false)
771
+ const customElementType = ref('')
709
772
  const oldMsg = ref('')
710
773
  const imgCache = ref(new Map<number, string>())
711
774
  const voiceRecording = ref(false)
@@ -842,6 +905,8 @@ const chatMoveOptions: VMoveOptions<HTMLDivElement> = {
842
905
 
843
906
  function resetState() {
844
907
  NewMsgNum.value = 0
908
+ customElementMode.value = false
909
+ customElementType.value = ''
845
910
  tags.value = {
846
911
  sendTag: 'REFUSE',
847
912
  showBottomButton: false,
@@ -2871,6 +2936,44 @@ function sendMsg(echo = 'sendMsgBack') {
2871
2936
  return
2872
2937
  }
2873
2938
 
2939
+ if (customElementMode.value) {
2940
+ const customElement = buildCustomElement(
2941
+ customElementType.value,
2942
+ msg.value,
2943
+ )
2944
+ if (!customElement) {
2945
+ new PopInfo().add(PopType.ERR, $t('请输入元素类型和内容'))
2946
+ return
2947
+ }
2948
+ const parsedMsg = customElement.toString()
2949
+ if (chat.show.temp) {
2950
+ sendMsgRaw(
2951
+ chat.show.id + '/' + chat.show.temp,
2952
+ chat.show.type,
2953
+ parsedMsg,
2954
+ true,
2955
+ echo,
2956
+ )
2957
+ } else {
2958
+ sendMsgRaw(
2959
+ chat.show.id,
2960
+ chat.show.type,
2961
+ parsedMsg,
2962
+ true,
2963
+ echo,
2964
+ )
2965
+ }
2966
+ customElementType.value = ''
2967
+ msg.value = ''
2968
+ sendCache.value = []
2969
+ imgCache.value.clear()
2970
+ scrollBottom()
2971
+ cancelReply()
2972
+ scheduleResizeMainInput(undefined, true)
2973
+ nextTick(() => customElementTypeInput.value?.focus())
2974
+ return
2975
+ }
2976
+
2874
2977
  for (const [key, base64data] of imgCache.value) {
2875
2978
  sendCache.value[key] = {
2876
2979
  type: 'image',
@@ -3289,6 +3392,24 @@ function viewerEssImg(url: string) {
3289
3392
  viewerRef.value.open(new Img(url))
3290
3393
  }
3291
3394
 
3395
+ function toggleCustomElementMode() {
3396
+ if (pendingVoice.value) return
3397
+ customElementMode.value = !customElementMode.value
3398
+ details.value.forEach((item) => {
3399
+ item.open = false
3400
+ })
3401
+ tags.value.showMoreDetail = false
3402
+ voiceMenuShow.value = false
3403
+ tags.value.onAtFind = false
3404
+ nextTick(() => {
3405
+ const input = customElementMode.value
3406
+ ? customElementTypeInput.value
3407
+ : mainInput.value
3408
+ input?.focus()
3409
+ scheduleResizeMainInput()
3410
+ })
3411
+ }
3412
+
3292
3413
  function moreFunClick(type = 'default') {
3293
3414
  let hasOpen = false
3294
3415
  details.value.forEach((item) => {
@@ -3386,6 +3507,14 @@ function exitWin() {
3386
3507
  overflow: visible;
3387
3508
  }
3388
3509
 
3510
+ .send-tool-button.custom-element-toggle.active {
3511
+ background: var(--color-main);
3512
+ }
3513
+
3514
+ .send-tool-button.custom-element-toggle.active > svg {
3515
+ color: #fff;
3516
+ }
3517
+
3389
3518
  .voice-menu-wrap {
3390
3519
  position: relative;
3391
3520
  width: 50px;
package/lib/index.js CHANGED
@@ -7938,7 +7938,35 @@ var import_node_path3 = __toESM(require("node:path"));
7938
7938
  var import_node_url2 = require("node:url");
7939
7939
  var import_koa_send = __toESM(require_koa_send());
7940
7940
  var import_file_type2 = __toESM(require_file_type());
7941
+
7942
+ // src/web-error-filter.ts
7943
+ function isIgnoredWebError(error) {
7944
+ if (error.name === "RangeError" && error.message === "Invalid string length") return true;
7945
+ return error.code === "ECONNRESET";
7946
+ }
7947
+ __name(isIgnoredWebError, "isIgnoredWebError");
7948
+ function registerWebErrorFilter(ctx) {
7949
+ const app = ctx.server._koa;
7950
+ const defaultHandler = app.onerror;
7951
+ const hasDefaultHandler = app.listeners("error").includes(defaultHandler);
7952
+ if (hasDefaultHandler) app.off("error", defaultHandler);
7953
+ const errorHandler = /* @__PURE__ */ __name((error) => {
7954
+ if (isIgnoredWebError(error)) return;
7955
+ if (hasDefaultHandler) defaultHandler.call(app, error);
7956
+ }, "errorHandler");
7957
+ app.on("error", errorHandler);
7958
+ ctx.on("dispose", () => {
7959
+ app.off("error", errorHandler);
7960
+ if (hasDefaultHandler && !app.listeners("error").includes(defaultHandler)) {
7961
+ app.on("error", defaultHandler);
7962
+ }
7963
+ });
7964
+ }
7965
+ __name(registerWebErrorFilter, "registerWebErrorFilter");
7966
+
7967
+ // src/web.ts
7941
7968
  function registerWeb(ctx, config, database, contactCache, media, logger) {
7969
+ registerWebErrorFilter(ctx);
7942
7970
  const webRoot = import_node_path3.default.resolve(__dirname, "..", "client", "web", "dist");
7943
7971
  const webSource = import_node_path3.default.resolve(__dirname, "..", "client", "web", "src");
7944
7972
  const webPublic = import_node_path3.default.resolve(__dirname, "..", "client", "web", "public");
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-chat-patch",
3
3
  "description": "[<ruby>chat-patch<rp>(</rp><rt>点我预览效果</rt><rp>)</rp></ruby>](https://i0.hdslb.com/bfs/openplatform/71074dfc9e5256fc3333d8bd8478bec1af874046.png) 视奸小插件((bushi( (低性能警告)。手机端适配。灵感来自 chat 插件。",
4
- "version": "6.1.0",
4
+ "version": "6.1.1",
5
5
  "scripts": {
6
6
  "build:web": "npm --prefix client/web run build",
7
7
  "prepack": "npm run build:web",
@@ -0,0 +1,30 @@
1
+ import { Context } from 'koishi'
2
+ import {} from '@koishijs/plugin-server'
3
+
4
+ type KoaErrorListener = (error: Error) => void
5
+
6
+ // 仅过滤客户端断开和超大历史响应导致的已知错误。
7
+ function isIgnoredWebError(error: Error): boolean {
8
+ if (error.name === 'RangeError' && error.message === 'Invalid string length') return true
9
+ return (error as NodeJS.ErrnoException).code === 'ECONNRESET'
10
+ }
11
+
12
+ export function registerWebErrorFilter(ctx: Context) {
13
+ const app = ctx.server._koa
14
+ const defaultHandler = app.onerror
15
+ const hasDefaultHandler = app.listeners('error').includes(defaultHandler)
16
+ if (hasDefaultHandler) app.off('error', defaultHandler)
17
+
18
+ const errorHandler: KoaErrorListener = (error) => {
19
+ if (isIgnoredWebError(error)) return
20
+ if (hasDefaultHandler) defaultHandler.call(app, error)
21
+ }
22
+ app.on('error', errorHandler)
23
+
24
+ ctx.on('dispose', () => {
25
+ app.off('error', errorHandler)
26
+ if (hasDefaultHandler && !app.listeners('error').includes(defaultHandler)) {
27
+ app.on('error', defaultHandler)
28
+ }
29
+ })
30
+ }
package/src/web.ts CHANGED
@@ -15,6 +15,7 @@ import { ChatDatabase } from './database'
15
15
  import { MediaManager } from './media'
16
16
  import { PluginLogger } from './logger'
17
17
  import { ContactCacheItem, MessageRecord, SelfMessagePayload, SelfMessageRecord } from './types'
18
+ import { registerWebErrorFilter } from './web-error-filter'
18
19
 
19
20
  interface ViteConsoleServer {
20
21
  config?: {
@@ -37,7 +38,8 @@ export function registerWeb(
37
38
  media: MediaManager,
38
39
  logger: PluginLogger,
39
40
  ) {
40
- const webRoot = path.resolve(__dirname, '..', 'client', 'web', 'dist')
41
+ registerWebErrorFilter(ctx)
42
+ const webRoot = path.resolve(__dirname, '..', 'client', 'web', 'dist')
41
43
  const webSource = path.resolve(__dirname, '..', 'client', 'web', 'src')
42
44
  const webPublic = path.resolve(__dirname, '..', 'client', 'web', 'public')
43
45
  const webIndex = path.resolve(__dirname, '..', 'client', 'web', 'index.html')