feat(shangwutong): render inbound images inline
This commit is contained in:
@@ -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("
|
||||
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"
|
||||
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 != "你好世界" {
|
||||
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;
|
||||
|
||||
+10
-3
@@ -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>
|
||||
|
||||
|
||||
+10
-2
@@ -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 ``;
|
||||
}
|
||||
);
|
||||
|
||||
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');
|
||||
};
|
||||
@@ -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(
|
||||
'前中后'
|
||||
);
|
||||
});
|
||||
|
||||
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 = '';
|
||||
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]));
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user