feat(shangwutong): render inbound images inline

This commit is contained in:
2026-09-12 15:26:03 +08:00
parent c415d6d13f
commit 6e62f5094f
9 changed files with 227 additions and 31 deletions
@@ -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))
@@ -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())
}
}
}
@@ -359,7 +359,8 @@ func TestKind31SubtypeMatrix(t *testing.T) {
func TestNormalizeMessageContentExtractsMultipleImagesAndJSONMedia(t *testing.T) {
content, subtype, fallback, media := normalizeMessageContent(`<p>说明</p><img src="https://media.example/a.png"><img src="https://media.example/b.png"><img src="https://media.example/a.png">`)
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, `你好<img src="https://media.example/emoji.png">世界`, "", "", "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 {
@@ -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;
@@ -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;
});
</script>
@@ -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(() => {
@@ -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');
};
@@ -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(
'前![商务通表情](<http://localhost/uploads/emoji-a.gif>)中![商务通表情](<http://localhost/uploads/emoji-b.gif>)后'
);
});
it('appends inline images when translated content omits markers', () => {
expect(
formatShangwutongInlineContent(
'translated',
[{ dataUrl: '/emoji.gif' }],
{
swt: { inline_media: [0] },
}
)
).toBe('translated\n![商务通表情](<http://localhost/emoji.gif>)');
});
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]));
});
});
+3 -4
View File
@@ -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',