diff --git a/channels/shangwutong/internal/delivery/inbound.go b/channels/shangwutong/internal/delivery/inbound.go index d8054879..90445166 100644 --- a/channels/shangwutong/internal/delivery/inbound.go +++ b/channels/shangwutong/internal/delivery/inbound.go @@ -284,6 +284,7 @@ func (i *Inbound) applyMappedEvent(ctx context.Context, account *dbgen.Account, } func mediaFallback(content string, references []mediaReference) string { + content = inlineImageMarkerPattern.ReplaceAllString(content, "") parts := make([]string, 0, len(references)+1) if strings.TrimSpace(content) != "" { parts = append(parts, strings.TrimSpace(content)) diff --git a/channels/shangwutong/internal/delivery/mapping.go b/channels/shangwutong/internal/delivery/mapping.go index 120c8592..697fb7d6 100644 --- a/channels/shangwutong/internal/delivery/mapping.go +++ b/channels/shangwutong/internal/delivery/mapping.go @@ -39,6 +39,7 @@ type mediaReference struct { Name string `json:"name,omitempty"` FileType string `json:"file_type"` Voice bool `json:"voice,omitempty"` + Inline bool `json:"inline,omitempty"` } func mapInboundEvent(kind int64, seqID int64, text, operator, rawTimestamp, sourceID string, inboxID int64, fallbackTime time.Time) mappedEvent { @@ -191,6 +192,15 @@ func mapInboundEvent(kind int64, seqID int64, text, operator, rawTimestamp, sour if mapped.Message != nil { swtAttrs := mapped.Message.ContentAttributes["swt"].(map[string]any) swtAttrs["subtype"] = mapped.Subtype + inlineMedia := make([]int, 0) + for index, reference := range mapped.Media { + if reference.Inline { + inlineMedia = append(inlineMedia, index) + } + } + if len(inlineMedia) > 0 { + swtAttrs["inline_media"] = inlineMedia + } } return mapped } @@ -364,37 +374,72 @@ func normalizeMessageContent(value string) (content, subtype string, fallback bo } return truncate(cleanText(value), 1000), "unknown_json", true, nil default: - images := imageReferences(value) - return cleanText(value), "text", false, images + content, media := inlineMessageContent(value) + return content, "text", false, media } } -func imageReferences(value string) []mediaReference { +var inlineImageMarkerPattern = regexp.MustCompile(`!\[[^\]]*\]\(swt-attachment://[0-9]+\)`) + +func inlineMessageContent(value string) (string, []mediaReference) { + value = html.UnescapeString(value) tokenizer := htmlpkg.NewTokenizer(strings.NewReader(value)) - seen := map[string]struct{}{} - var references []mediaReference + var builder strings.Builder + references := make([]mediaReference, 0) + seen := make(map[string]int) + skipDepth := 0 for { switch tokenizer.Next() { case htmlpkg.ErrorToken: - return references + content := regexp.MustCompile(`\n{3,}`).ReplaceAllString(builder.String(), "\n\n") + return strings.TrimSpace(content), references case htmlpkg.SelfClosingTagToken, htmlpkg.StartTagToken: token := tokenizer.Token() - if !strings.EqualFold(token.Data, "img") { + tag := strings.ToLower(token.Data) + if tag == "script" || tag == "style" { + skipDepth++ continue } - for _, attribute := range token.Attr { - if !strings.EqualFold(attribute.Key, "src") { - continue + if skipDepth > 0 { + continue + } + if tag == "img" { + resource := "" + for _, attribute := range token.Attr { + if strings.EqualFold(attribute.Key, "src") { + resource = safeHTTPURL(attribute.Val) + break + } } - resource := safeHTTPURL(attribute.Val) - if resource == "" { - continue + if resource != "" { + index, exists := seen[resource] + if !exists { + index = len(references) + seen[resource] = index + references = append(references, mediaReference{ + URL: resource, Name: mediaName(resource, "image"), FileType: "image", Inline: true, + }) + } + builder.WriteString(" + builder.WriteString(strconv.Itoa(index)) + builder.WriteString(")") } - if _, duplicate := seen[resource]; duplicate { - continue - } - seen[resource] = struct{}{} - references = append(references, mediaReference{URL: resource, Name: mediaName(resource, "image"), FileType: "image"}) + continue + } + if tag == "br" || tag == "p" || tag == "div" { + builder.WriteByte('\n') + } + case htmlpkg.EndTagToken: + name, _ := tokenizer.TagName() + tag := strings.ToLower(string(name)) + if (tag == "script" || tag == "style") && skipDepth > 0 { + skipDepth-- + } else if skipDepth == 0 && (tag == "p" || tag == "div") { + builder.WriteByte('\n') + } + case htmlpkg.TextToken: + if skipDepth == 0 { + builder.Write(tokenizer.Text()) } } } diff --git a/channels/shangwutong/internal/delivery/mapping_test.go b/channels/shangwutong/internal/delivery/mapping_test.go index c3c3fdd9..dd167a8f 100644 --- a/channels/shangwutong/internal/delivery/mapping_test.go +++ b/channels/shangwutong/internal/delivery/mapping_test.go @@ -359,7 +359,8 @@ func TestKind31SubtypeMatrix(t *testing.T) { func TestNormalizeMessageContentExtractsMultipleImagesAndJSONMedia(t *testing.T) { content, subtype, fallback, media := normalizeMessageContent(`
说明


`)
- if content != "说明" || subtype != "text" || fallback || len(media) != 2 || media[0].FileType != "image" || media[1].URL != "https://media.example/b.png" {
+ wantContent := "说明\n"
+ if content != wantContent || subtype != "text" || fallback || len(media) != 2 || media[0].FileType != "image" || media[1].URL != "https://media.example/b.png" || !media[0].Inline || !media[1].Inline {
t.Fatalf("html content=%q subtype=%q fallback=%v media=%#v", content, subtype, fallback, media)
}
for _, test := range []struct {
@@ -379,6 +380,17 @@ func TestNormalizeMessageContentExtractsMultipleImagesAndJSONMedia(t *testing.T)
}
}
+func TestMapInboundEventMarksInlineMedia(t *testing.T) {
+ mapped := mapInboundEvent(2, 42, `你好
世界`, "", "", "source", 10, time.Now())
+ if mapped.Message == nil || mapped.Message.Content != "你好世界" {
+ t.Fatalf("inline message = %#v", mapped.Message)
+ }
+ attrs, ok := mapped.Message.ContentAttributes["swt"].(map[string]any)
+ if !ok || len(attrs["inline_media"].([]int)) != 1 || attrs["inline_media"].([]int)[0] != 0 {
+ t.Fatalf("inline media attributes = %#v", mapped.Message.ContentAttributes)
+ }
+}
+
func TestNormalizeMessageContentUsesSafeFallbacksForCardsAndUnknownJSON(t *testing.T) {
content, subtype, fallback, media := normalizeMessageContent("baidunmdata_msg|1|https://example.test/product")
if !fallback || subtype != "rich_card" || !strings.Contains(content, "商品卡片") || len(media) != 0 {
diff --git a/frontend/app/javascript/dashboard/components-next/message/bubbles/Base.vue b/frontend/app/javascript/dashboard/components-next/message/bubbles/Base.vue
index c40d6336..41e30549 100644
--- a/frontend/app/javascript/dashboard/components-next/message/bubbles/Base.vue
+++ b/frontend/app/javascript/dashboard/components-next/message/bubbles/Base.vue
@@ -10,6 +10,7 @@ import { useI18n } from 'vue-i18n';
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { MESSAGE_VARIANTS, ORIENTATION } from '../constants';
+import { formatShangwutongInlineContent } from '../inlineMedia.js';
const props = defineProps({
hideMeta: { type: Boolean, default: false },
@@ -77,11 +78,19 @@ const shouldShowMeta = computed(
);
const replyToPreview = computed(() => {
- if (!inReplyTo) return '';
+ if (!inReplyTo?.value) return '';
- const { content, attachments } = inReplyTo.value;
+ const { content, attachments, contentAttributes, content_attributes } =
+ inReplyTo.value;
- if (content) return new MessageFormatter(content).formattedMessage;
+ if (content) {
+ const formattedContent = formatShangwutongInlineContent(
+ content,
+ attachments,
+ contentAttributes ?? content_attributes
+ );
+ return new MessageFormatter(formattedContent).formattedMessage;
+ }
if (attachments?.length) {
const firstAttachment = attachments[0];
const fileType = firstAttachment.fileType ?? firstAttachment.file_type;
diff --git a/frontend/app/javascript/dashboard/components-next/message/bubbles/Text/FormattedContent.vue b/frontend/app/javascript/dashboard/components-next/message/bubbles/Text/FormattedContent.vue
index 34b9a007..bd569717 100644
--- a/frontend/app/javascript/dashboard/components-next/message/bubbles/Text/FormattedContent.vue
+++ b/frontend/app/javascript/dashboard/components-next/message/bubbles/Text/FormattedContent.vue
@@ -4,6 +4,7 @@ import { useMessageContext } from '../../provider.js';
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
import { MESSAGE_VARIANTS } from '../../constants';
+import { formatShangwutongInlineContent } from '../../inlineMedia.js';
const props = defineProps({
content: {
@@ -12,14 +13,20 @@ const props = defineProps({
},
});
-const { variant } = useMessageContext();
+const { variant, attachments, contentAttributes } = useMessageContext();
const formattedContent = computed(() => {
+ const content = formatShangwutongInlineContent(
+ props.content,
+ attachments.value,
+ contentAttributes.value
+ );
+
if (variant.value === MESSAGE_VARIANTS.ACTIVITY) {
- return props.content;
+ return content;
}
- return new MessageFormatter(props.content).formattedMessage;
+ return new MessageFormatter(content).formattedMessage;
});
diff --git a/frontend/app/javascript/dashboard/components-next/message/chips/AttachmentChips.vue b/frontend/app/javascript/dashboard/components-next/message/chips/AttachmentChips.vue
index 0a3299d4..92d8e9db 100644
--- a/frontend/app/javascript/dashboard/components-next/message/chips/AttachmentChips.vue
+++ b/frontend/app/javascript/dashboard/components-next/message/chips/AttachmentChips.vue
@@ -8,6 +8,7 @@ import FileChip from 'next/message/chips/File.vue';
import { useMessageContext } from '../provider.js';
import { ATTACHMENT_TYPES } from '../constants';
+import { shangwutongInlineMediaIndexes } from '../inlineMedia.js';
/**
* @typedef {Object} Attachment
@@ -34,7 +35,7 @@ defineOptions({
});
const attrs = useAttrs();
-const { orientation } = useMessageContext();
+const { orientation, contentAttributes } = useMessageContext();
const classToApply = computed(() => {
const baseClasses = [attrs.class, 'flex', 'flex-wrap'];
@@ -46,8 +47,15 @@ const classToApply = computed(() => {
return baseClasses;
});
+const inlineAttachmentIndexes = computed(() =>
+ shangwutongInlineMediaIndexes(contentAttributes.value)
+);
+
const allAttachments = computed(() => {
- return Array.isArray(props.attachments) ? props.attachments : [];
+ const attachments = Array.isArray(props.attachments) ? props.attachments : [];
+ return attachments.filter(
+ (_, index) => !inlineAttachmentIndexes.value.has(index)
+ );
});
const mediaAttachments = computed(() => {
diff --git a/frontend/app/javascript/dashboard/components-next/message/inlineMedia.js b/frontend/app/javascript/dashboard/components-next/message/inlineMedia.js
new file mode 100644
index 00000000..1773aef2
--- /dev/null
+++ b/frontend/app/javascript/dashboard/components-next/message/inlineMedia.js
@@ -0,0 +1,61 @@
+const INLINE_MEDIA_PATTERN = /!\[([^\]]*)\]\(swt-attachment:\/\/(\d+)\)/g;
+
+export const shangwutongInlineMediaIndexes = contentAttributes => {
+ const indexes = contentAttributes?.swt?.inline_media;
+ if (!Array.isArray(indexes)) return new Set();
+
+ return new Set(
+ indexes.map(Number).filter(index => Number.isInteger(index) && index >= 0)
+ );
+};
+
+const attachmentURL = attachment => attachment?.dataUrl ?? attachment?.data_url;
+
+const resolveAttachmentURL = dataUrl => {
+ if (!dataUrl) return '';
+
+ try {
+ return new URL(
+ dataUrl,
+ typeof window === 'undefined'
+ ? 'http://localhost'
+ : window.location.origin
+ ).href;
+ } catch {
+ return dataUrl;
+ }
+};
+
+export const formatShangwutongInlineContent = (
+ content,
+ attachments,
+ contentAttributes
+) => {
+ const indexes = shangwutongInlineMediaIndexes(contentAttributes);
+ if (!indexes.size) return content || '';
+
+ const renderedIndexes = new Set();
+ const formattedContent = (content || '').replace(
+ INLINE_MEDIA_PATTERN,
+ (match, alt, rawIndex) => {
+ const index = Number(rawIndex);
+ if (!indexes.has(index)) return match;
+ renderedIndexes.add(index);
+
+ const dataUrl = resolveAttachmentURL(attachmentURL(attachments?.[index]));
+ if (!dataUrl) return alt || '商务通表情';
+
+ return ``;
+ }
+ );
+
+ const missingImages = [...indexes]
+ .filter(index => !renderedIndexes.has(index))
+ .map(index => {
+ const dataUrl = resolveAttachmentURL(attachmentURL(attachments?.[index]));
+ return dataUrl ? `` : '商务通表情';
+ });
+
+ if (!missingImages.length) return formattedContent;
+ return [formattedContent, missingImages.join('')].filter(Boolean).join('\n');
+};
diff --git a/frontend/app/javascript/dashboard/components-next/message/inlineMedia.spec.js b/frontend/app/javascript/dashboard/components-next/message/inlineMedia.spec.js
new file mode 100644
index 00000000..71eed272
--- /dev/null
+++ b/frontend/app/javascript/dashboard/components-next/message/inlineMedia.spec.js
@@ -0,0 +1,54 @@
+// @vitest-environment node
+
+import { describe, expect, it } from 'vitest';
+
+import {
+ formatShangwutongInlineContent,
+ shangwutongInlineMediaIndexes,
+} from './inlineMedia.js';
+
+describe('Shangwutong inline media', () => {
+ it('replaces declared attachment markers while preserving order', () => {
+ const content =
+ '前中后';
+ const attachments = [
+ { dataUrl: '/uploads/emoji-a.gif' },
+ { dataUrl: '/uploads/emoji-b.gif' },
+ ];
+
+ expect(
+ formatShangwutongInlineContent(content, attachments, {
+ swt: { inline_media: [0, 1] },
+ })
+ ).toBe(
+ '前![商务通表情](