From 6e62f5094fa2381c5b283eeb9be3f94a95096f1c Mon Sep 17 00:00:00 2001 From: Rogee Date: Sat, 12 Sep 2026 15:26:03 +0800 Subject: [PATCH] feat(shangwutong): render inbound images inline --- .../shangwutong/internal/delivery/inbound.go | 1 + .../shangwutong/internal/delivery/mapping.go | 81 ++++++++++++++----- .../internal/delivery/mapping_test.go | 14 +++- .../components-next/message/bubbles/Base.vue | 15 +++- .../message/bubbles/Text/FormattedContent.vue | 13 ++- .../message/chips/AttachmentChips.vue | 12 ++- .../components-next/message/inlineMedia.js | 61 ++++++++++++++ .../message/inlineMedia.spec.js | 54 +++++++++++++ frontend/tailwind.config.js | 7 +- 9 files changed, 227 insertions(+), 31 deletions(-) create mode 100644 frontend/app/javascript/dashboard/components-next/message/inlineMedia.js create mode 100644 frontend/app/javascript/dashboard/components-next/message/inlineMedia.spec.js 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("![商务通表情](swt-attachment://") + 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![商务通表情](swt-attachment://0)![商务通表情](swt-attachment://1)![商务通表情](swt-attachment://0)" + 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 != "你好![商务通表情](swt-attachment://0)世界" { + 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 `![${alt || '商务通表情'}](<${dataUrl}>)`; + } + ); + + const missingImages = [...indexes] + .filter(index => !renderedIndexes.has(index)) + .map(index => { + const dataUrl = resolveAttachmentURL(attachmentURL(attachments?.[index])); + return dataUrl ? `![商务通表情](<${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 = + '前![商务通表情](swt-attachment://0)中![商务通表情](swt-attachment://1)后'; + const attachments = [ + { dataUrl: '/uploads/emoji-a.gif' }, + { dataUrl: '/uploads/emoji-b.gif' }, + ]; + + expect( + formatShangwutongInlineContent(content, attachments, { + swt: { inline_media: [0, 1] }, + }) + ).toBe( + '前![商务通表情]()中![商务通表情]()后' + ); + }); + + it('appends inline images when translated content omits markers', () => { + expect( + formatShangwutongInlineContent( + 'translated', + [{ dataUrl: '/emoji.gif' }], + { + swt: { inline_media: [0] }, + } + ) + ).toBe('translated\n![商务通表情]()'); + }); + + it('does not resolve undeclared markers', () => { + const content = '![商务通表情](swt-attachment://0)'; + expect( + formatShangwutongInlineContent(content, [{ dataUrl: '/private.png' }], { + swt: { inline_media: [] }, + }) + ).toBe(content); + }); + + it('accepts numeric strings from JSON attributes', () => { + expect( + shangwutongInlineMediaIndexes({ swt: { inline_media: ['2'] } }) + ).toEqual(new Set([2])); + }); +}); diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 79c137d6..4f7472f8 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -2,10 +2,7 @@ import { slateDark } from '@radix-ui/colors'; import { colors } from './theme/colors'; import { icons } from './theme/icons'; import defaultTheme from 'tailwindcss/defaultTheme'; -import { - iconsPlugin, - getIconCollections, -} from '@egoist/tailwindcss-icons'; +import { iconsPlugin, getIconCollections } from '@egoist/tailwindcss-icons'; import typography from '@tailwindcss/typography'; const defaultSansFonts = [ @@ -178,6 +175,8 @@ const tailwindConfig = { border: `none`, }, img: { + display: 'inline-block', + verticalAlign: 'middle', maxWidth: '100%', height: 'auto', marginTop: 'unset',