chore: import clean Agent Desk snapshot (2/7)
This commit is contained in:
125
internal/pkg/utils/content_chunk.go
Normal file
125
internal/pkg/utils/content_chunk.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
type ContentChunkType string
|
||||
|
||||
const (
|
||||
ContentChunkTypeText ContentChunkType = "text"
|
||||
ContentChunkTypeImage ContentChunkType = "image"
|
||||
)
|
||||
|
||||
type ContentChunk struct {
|
||||
Type ContentChunkType //
|
||||
Content string // text content or image url
|
||||
AssetID string // image asset id
|
||||
Provider enums.AssetProvider // image provider
|
||||
StorageKey string // image storage key
|
||||
}
|
||||
|
||||
func SplitHTMLContentChunks(content string) ([]ContentChunk, error) {
|
||||
root, err := html.Parse(strings.NewReader("<div>" + strings.TrimSpace(content) + "</div>"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var (
|
||||
chunks []ContentChunk
|
||||
buffer strings.Builder
|
||||
)
|
||||
flushText := func() {
|
||||
text := normalizeContentChunkText(buffer.String())
|
||||
buffer.Reset()
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
chunks = append(chunks, ContentChunk{
|
||||
Type: ContentChunkTypeText,
|
||||
Content: text,
|
||||
})
|
||||
}
|
||||
|
||||
var walk func(*html.Node)
|
||||
walk = func(node *html.Node) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
switch node.Type {
|
||||
case html.TextNode:
|
||||
buffer.WriteString(node.Data)
|
||||
case html.ElementNode:
|
||||
switch node.Data {
|
||||
case "br":
|
||||
buffer.WriteString("\n")
|
||||
case "p", "div", "li", "blockquote":
|
||||
buffer.WriteString("\n")
|
||||
case "img":
|
||||
flushText()
|
||||
src := strings.TrimSpace(findContentChunkHTMLAttr(node, "src"))
|
||||
assetID := strings.TrimSpace(findContentChunkHTMLAttr(node, "data-asset-id"))
|
||||
provider := enums.AssetProvider(strings.TrimSpace(findContentChunkHTMLAttr(node, "data-provider")))
|
||||
storageKey := strings.TrimSpace(findContentChunkHTMLAttr(node, "data-storage-key"))
|
||||
if src == "" && assetID == "" {
|
||||
return
|
||||
}
|
||||
chunks = append(chunks, ContentChunk{
|
||||
Type: ContentChunkTypeImage,
|
||||
Content: src,
|
||||
AssetID: assetID,
|
||||
Provider: provider,
|
||||
StorageKey: storageKey,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
walk(child)
|
||||
}
|
||||
if node.Type == html.ElementNode {
|
||||
switch node.Data {
|
||||
case "p", "div", "li", "blockquote":
|
||||
buffer.WriteString("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
flushText()
|
||||
return chunks, nil
|
||||
}
|
||||
|
||||
func normalizeContentChunkText(value string) string {
|
||||
value = strings.ReplaceAll(value, "\u00a0", " ")
|
||||
lines := strings.Split(value, "\n")
|
||||
normalizedLines := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
line = strings.Join(strings.Fields(strings.TrimSpace(line)), " ")
|
||||
if line == "" {
|
||||
if len(normalizedLines) > 0 && normalizedLines[len(normalizedLines)-1] != "" {
|
||||
normalizedLines = append(normalizedLines, "")
|
||||
}
|
||||
continue
|
||||
}
|
||||
normalizedLines = append(normalizedLines, line)
|
||||
}
|
||||
for len(normalizedLines) > 0 && normalizedLines[0] == "" {
|
||||
normalizedLines = normalizedLines[1:]
|
||||
}
|
||||
for len(normalizedLines) > 0 && normalizedLines[len(normalizedLines)-1] == "" {
|
||||
normalizedLines = normalizedLines[:len(normalizedLines)-1]
|
||||
}
|
||||
return strings.Join(normalizedLines, "\n")
|
||||
}
|
||||
|
||||
func findContentChunkHTMLAttr(node *html.Node, key string) string {
|
||||
for _, attr := range node.Attr {
|
||||
if strings.EqualFold(attr.Key, key) {
|
||||
return attr.Val
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
85
internal/pkg/utils/content_chunk_test.go
Normal file
85
internal/pkg/utils/content_chunk_test.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSplitHTMLContentChunks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
want []ContentChunk
|
||||
}{
|
||||
{
|
||||
name: "plain text paragraph",
|
||||
content: "<p>你好,微信用户</p>",
|
||||
want: []ContentChunk{
|
||||
{Type: ContentChunkTypeText, Content: "你好,微信用户"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "single image",
|
||||
content: `<p><img src="https://example.com/a.png" alt="a"></p>`,
|
||||
want: []ContentChunk{
|
||||
{Type: ContentChunkTypeImage, Content: "https://example.com/a.png"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mixed text and images keep order",
|
||||
content: `<p>第一段</p><p><img src="https://example.com/1.png"></p><p>第二段<img src="https://example.com/2.png">第三段</p>`,
|
||||
want: []ContentChunk{
|
||||
{Type: ContentChunkTypeText, Content: "第一段"},
|
||||
{Type: ContentChunkTypeImage, Content: "https://example.com/1.png"},
|
||||
{Type: ContentChunkTypeText, Content: "第二段"},
|
||||
{Type: ContentChunkTypeImage, Content: "https://example.com/2.png"},
|
||||
{Type: ContentChunkTypeText, Content: "第三段"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "normalize blank lines and spaces",
|
||||
content: "<div> 第一行 <br><br> 第二行 </div>",
|
||||
want: []ContentChunk{
|
||||
{Type: ContentChunkTypeText, Content: "第一行\n\n第二行"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ignore image without src",
|
||||
content: `<p>前文</p><img alt="missing-src"><p>后文</p>`,
|
||||
want: []ContentChunk{
|
||||
{Type: ContentChunkTypeText, Content: "前文"},
|
||||
{Type: ContentChunkTypeText, Content: "后文"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "image with asset metadata only",
|
||||
content: `<p><img data-asset-id="asset_1" data-provider="local" data-storage-key="images/a.png" alt="a"></p>`,
|
||||
want: []ContentChunk{
|
||||
{
|
||||
Type: ContentChunkTypeImage,
|
||||
AssetID: "asset_1",
|
||||
Provider: enums.AssetProviderLocal,
|
||||
StorageKey: "images/a.png",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty html returns empty chunks",
|
||||
content: "<p><br></p>",
|
||||
want: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := SplitHTMLContentChunks(tt.content)
|
||||
if err != nil {
|
||||
t.Fatalf("SplitHTMLContentChunks() error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("SplitHTMLContentChunks() = %#v, want %#v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
374
internal/pkg/utils/message.go
Normal file
374
internal/pkg/utils/message.go
Normal file
@@ -0,0 +1,374 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/repositories"
|
||||
"agent-desk/internal/services/storage"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
type imMessageAssetPayload struct {
|
||||
AssetID string `json:"assetId"`
|
||||
Provider enums.AssetProvider `json:"provider,omitempty"`
|
||||
StorageKey string `json:"storageKey,omitempty"`
|
||||
Filename string `json:"filename,omitempty"`
|
||||
FileSize int64 `json:"fileSize,omitempty"`
|
||||
MimeType string `json:"mimeType,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
func SanitizeMessageHTML(content string) string {
|
||||
policy := bluemonday.UGCPolicy()
|
||||
policy.AllowElements("img")
|
||||
policy.AllowAttrs("src", "alt", "title", "data-asset-id", "data-provider", "data-storage-key").OnElements("img")
|
||||
policy.AllowURLSchemes("http", "https")
|
||||
policy.AllowStandardURLs()
|
||||
policy.AllowElements("p", "br")
|
||||
return stripHTMLImageSrcIfBound(strings.TrimSpace(policy.Sanitize(content)))
|
||||
}
|
||||
|
||||
func NormalizeMessageHTMLAssets(content string) (string, error) {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return "", nil
|
||||
}
|
||||
doc, err := html.Parse(strings.NewReader("<div>" + content + "</div>"))
|
||||
if err != nil {
|
||||
return content, nil
|
||||
}
|
||||
var walkErr error
|
||||
var walk func(*html.Node)
|
||||
walk = func(node *html.Node) {
|
||||
if node == nil || walkErr != nil {
|
||||
return
|
||||
}
|
||||
if node.Type == html.ElementNode && node.Data == "img" {
|
||||
asset, err := normalizeHTMLImageAsset(node)
|
||||
if err != nil {
|
||||
walkErr = err
|
||||
return
|
||||
}
|
||||
if asset != nil {
|
||||
setHTMLAttr(node, "data-asset-id", strings.TrimSpace(asset.AssetID))
|
||||
setHTMLAttr(node, "data-provider", strings.TrimSpace(string(asset.Provider)))
|
||||
setHTMLAttr(node, "data-storage-key", strings.TrimSpace(asset.StorageKey))
|
||||
removeHTMLAttr(node, "src")
|
||||
}
|
||||
}
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(doc)
|
||||
if walkErr != nil {
|
||||
return "", walkErr
|
||||
}
|
||||
return renderHTMLFragment(doc), nil
|
||||
}
|
||||
|
||||
func BuildHTMLSummary(content string) string {
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return ""
|
||||
}
|
||||
doc, err := html.Parse(strings.NewReader("<div>" + content + "</div>"))
|
||||
if err != nil {
|
||||
return strings.TrimSpace(content)
|
||||
}
|
||||
parts := make([]string, 0, 8)
|
||||
var walk func(*html.Node)
|
||||
walk = func(node *html.Node) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
if node.Type == html.TextNode {
|
||||
text := strings.TrimSpace(node.Data)
|
||||
if text != "" {
|
||||
parts = append(parts, text)
|
||||
}
|
||||
}
|
||||
if node.Type == html.ElementNode && node.Data == "img" {
|
||||
parts = append(parts, "[图片]")
|
||||
}
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(doc)
|
||||
return strings.TrimSpace(strings.Join(parts, " "))
|
||||
}
|
||||
|
||||
func BuildRuntimeMessageText(messageType enums.IMMessageType, content string) string {
|
||||
content = strings.TrimSpace(content)
|
||||
switch messageType {
|
||||
case enums.IMMessageTypeHTML:
|
||||
return BuildHTMLSummary(content)
|
||||
case enums.IMMessageTypeImage:
|
||||
if content != "" {
|
||||
return "[图片] " + content
|
||||
}
|
||||
return "[图片]"
|
||||
case enums.IMMessageTypeAttachment:
|
||||
if content != "" {
|
||||
return "[附件] " + content
|
||||
}
|
||||
return "[附件]"
|
||||
default:
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
||||
func BuildRenderableMessage(item *models.Message) (content, payload string) {
|
||||
if item == nil {
|
||||
return "", ""
|
||||
}
|
||||
if item.RecalledAt != nil {
|
||||
return "该消息已撤回", ""
|
||||
}
|
||||
if item.SendStatus == enums.IMMessageStatusRecalled {
|
||||
return "该消息已撤回", ""
|
||||
}
|
||||
|
||||
content = item.Content
|
||||
payload = item.Payload
|
||||
switch item.MessageType {
|
||||
case enums.IMMessageTypeImage, enums.IMMessageTypeAttachment:
|
||||
payload = buildIMMessageAssetPayloadForResponse(item.Payload)
|
||||
case enums.IMMessageTypeHTML:
|
||||
content = BuildMessageHTMLForResponse(item.Content)
|
||||
}
|
||||
return content, payload
|
||||
}
|
||||
|
||||
func BuildMessageHTMLForResponse(content string) string {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
doc, err := html.Parse(strings.NewReader("<div>" + content + "</div>"))
|
||||
if err != nil {
|
||||
return content
|
||||
}
|
||||
var walk func(*html.Node)
|
||||
walk = func(node *html.Node) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
if node.Type == html.ElementNode && node.Data == "img" {
|
||||
provider := enums.AssetProvider(strings.TrimSpace(findHTMLAttr(node, "data-provider")))
|
||||
storageKey := strings.TrimSpace(findHTMLAttr(node, "data-storage-key"))
|
||||
if provider != "" && storageKey != "" {
|
||||
if storageProvider, err := storage.NewProvider(provider); err == nil {
|
||||
setHTMLAttr(node, "src", storageProvider.GetSignedURL(storageKey))
|
||||
}
|
||||
}
|
||||
}
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(doc)
|
||||
return renderHTMLFragment(doc)
|
||||
}
|
||||
|
||||
func stripHTMLImageSrcIfBound(content string) string {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
doc, err := html.Parse(strings.NewReader("<div>" + content + "</div>"))
|
||||
if err != nil {
|
||||
return content
|
||||
}
|
||||
var walk func(*html.Node)
|
||||
walk = func(node *html.Node) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
if node.Type == html.ElementNode && node.Data == "img" {
|
||||
provider := strings.TrimSpace(findHTMLAttr(node, "data-provider"))
|
||||
storageKey := strings.TrimSpace(findHTMLAttr(node, "data-storage-key"))
|
||||
if provider != "" && storageKey != "" {
|
||||
removeHTMLAttr(node, "src")
|
||||
}
|
||||
}
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(doc)
|
||||
return renderHTMLFragment(doc)
|
||||
}
|
||||
|
||||
func renderHTMLFragment(doc *html.Node) string {
|
||||
if doc == nil {
|
||||
return ""
|
||||
}
|
||||
root := findHTMLRoot(doc)
|
||||
if root == nil {
|
||||
return ""
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
for child := root.FirstChild; child != nil; child = child.NextSibling {
|
||||
if err := html.Render(&buf, child); err != nil {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(buf.String())
|
||||
}
|
||||
|
||||
func findHTMLRoot(doc *html.Node) *html.Node {
|
||||
var walk func(*html.Node) *html.Node
|
||||
walk = func(node *html.Node) *html.Node {
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
if node.Type == html.ElementNode && node.Data == "div" {
|
||||
return node
|
||||
}
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
if found := walk(child); found != nil {
|
||||
return found
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return walk(doc)
|
||||
}
|
||||
|
||||
func findHTMLAttr(node *html.Node, key string) string {
|
||||
if node == nil {
|
||||
return ""
|
||||
}
|
||||
for _, attr := range node.Attr {
|
||||
if attr.Key == key {
|
||||
return attr.Val
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func setHTMLAttr(node *html.Node, key, value string) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
for i := range node.Attr {
|
||||
if node.Attr[i].Key == key {
|
||||
node.Attr[i].Val = value
|
||||
return
|
||||
}
|
||||
}
|
||||
node.Attr = append(node.Attr, html.Attribute{Key: key, Val: value})
|
||||
}
|
||||
|
||||
func removeHTMLAttr(node *html.Node, key string) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
dst := node.Attr[:0]
|
||||
for _, attr := range node.Attr {
|
||||
if attr.Key != key {
|
||||
dst = append(dst, attr)
|
||||
}
|
||||
}
|
||||
node.Attr = dst
|
||||
}
|
||||
|
||||
func buildIMMessageAssetPayloadForResponse(payload string) string {
|
||||
assetPayload, err := parseIMMessageAssetPayload(payload)
|
||||
if err != nil {
|
||||
return strings.TrimSpace(payload)
|
||||
}
|
||||
assetPayload = hydrateIMMessageAssetPayload(assetPayload)
|
||||
if assetPayload.Provider != "" && assetPayload.StorageKey != "" {
|
||||
if provider, err := storage.NewProvider(assetPayload.Provider); err == nil {
|
||||
assetPayload.URL = provider.GetSignedURL(assetPayload.StorageKey)
|
||||
}
|
||||
}
|
||||
data, err := json.Marshal(assetPayload)
|
||||
if err != nil {
|
||||
return strings.TrimSpace(payload)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func parseIMMessageAssetPayload(payload string) (*imMessageAssetPayload, error) {
|
||||
payload = strings.TrimSpace(payload)
|
||||
if payload == "" {
|
||||
return nil, nil
|
||||
}
|
||||
ret := &imMessageAssetPayload{}
|
||||
if err := json.Unmarshal([]byte(payload), ret); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ret.AssetID = strings.TrimSpace(ret.AssetID)
|
||||
ret.Provider = enums.AssetProvider(strings.TrimSpace(string(ret.Provider)))
|
||||
ret.StorageKey = strings.TrimSpace(ret.StorageKey)
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func hydrateIMMessageAssetPayload(payload *imMessageAssetPayload) *imMessageAssetPayload {
|
||||
if payload == nil {
|
||||
return nil
|
||||
}
|
||||
if payload.Provider != "" && payload.StorageKey != "" {
|
||||
return payload
|
||||
}
|
||||
if payload.AssetID == "" {
|
||||
return payload
|
||||
}
|
||||
asset := repositories.AssetRepository.GetByAssetID(sqls.DB(), payload.AssetID)
|
||||
if asset == nil {
|
||||
return payload
|
||||
}
|
||||
if payload.Provider == "" {
|
||||
payload.Provider = asset.Provider
|
||||
}
|
||||
if payload.StorageKey == "" {
|
||||
payload.StorageKey = strings.TrimSpace(asset.StorageKey)
|
||||
}
|
||||
if payload.Filename == "" {
|
||||
payload.Filename = strings.TrimSpace(asset.Filename)
|
||||
}
|
||||
if payload.FileSize <= 0 {
|
||||
payload.FileSize = asset.FileSize
|
||||
}
|
||||
if payload.MimeType == "" {
|
||||
payload.MimeType = strings.TrimSpace(asset.MimeType)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func normalizeHTMLImageAsset(node *html.Node) (*models.Asset, error) {
|
||||
if node == nil {
|
||||
return nil, nil
|
||||
}
|
||||
assetID := strings.TrimSpace(findHTMLAttr(node, "data-asset-id"))
|
||||
provider := enums.AssetProvider(strings.TrimSpace(findHTMLAttr(node, "data-provider")))
|
||||
storageKey := strings.TrimSpace(findHTMLAttr(node, "data-storage-key"))
|
||||
|
||||
hasAssetID := assetID != ""
|
||||
hasProvider := provider != ""
|
||||
hasStorageKey := storageKey != ""
|
||||
if hasAssetID || hasProvider || hasStorageKey {
|
||||
if !(hasAssetID && hasProvider && hasStorageKey) {
|
||||
return nil, fmt.Errorf("html message image asset attributes are incomplete")
|
||||
}
|
||||
asset := repositories.AssetRepository.GetByAssetID(sqls.DB(), assetID)
|
||||
if asset == nil {
|
||||
return nil, fmt.Errorf("html message image asset not found")
|
||||
}
|
||||
if asset.Provider != provider || strings.TrimSpace(asset.StorageKey) != storageKey {
|
||||
return nil, fmt.Errorf("html message image asset attributes mismatch")
|
||||
}
|
||||
return asset, nil
|
||||
}
|
||||
return nil, fmt.Errorf("html message image must include asset metadata")
|
||||
}
|
||||
242
internal/pkg/utils/message_test.go
Normal file
242
internal/pkg/utils/message_test.go
Normal file
@@ -0,0 +1,242 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/config"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestBuildIMMessageAssetPayloadForResponseAddsSignedURL(t *testing.T) {
|
||||
config.SetCurrent(&config.Config{
|
||||
Storage: config.StorageConfig{
|
||||
Default: enums.AssetProviderLocal,
|
||||
Local: config.LocalStorageConfig{
|
||||
BaseURL: "https://files.example.com",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
payload := `{"assetId":"asset_1","provider":"local","storageKey":"attachments/demo.png","filename":"demo.png"}`
|
||||
got := buildIMMessageAssetPayloadForResponse(payload)
|
||||
|
||||
if !strings.Contains(got, `"provider":"local"`) {
|
||||
t.Fatalf("expected provider in payload, got: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"storageKey":"attachments/demo.png"`) {
|
||||
t.Fatalf("expected storageKey in payload, got: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"url":"https://files.example.com/attachments/demo.png"`) {
|
||||
t.Fatalf("expected signed url in payload, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeMessageHTMLStripsStoredSrcForManagedImages(t *testing.T) {
|
||||
html := `<p><img src="https://files.example.com/demo.png" data-provider="local" data-storage-key="attachments/demo.png" alt="demo"></p>`
|
||||
|
||||
got := SanitizeMessageHTML(html)
|
||||
|
||||
if strings.Contains(got, `src=`) {
|
||||
t.Fatalf("expected src removed from stored html, got: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `data-provider="local"`) {
|
||||
t.Fatalf("expected data-provider kept, got: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `data-storage-key="attachments/demo.png"`) {
|
||||
t.Fatalf("expected data-storage-key kept, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMessageHTMLForResponseAddsSignedURL(t *testing.T) {
|
||||
config.SetCurrent(&config.Config{
|
||||
Storage: config.StorageConfig{
|
||||
Default: enums.AssetProviderLocal,
|
||||
Local: config.LocalStorageConfig{
|
||||
BaseURL: "https://files.example.com",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
html := `<p><img data-provider="local" data-storage-key="attachments/demo.png" alt="demo"></p>`
|
||||
got := BuildMessageHTMLForResponse(html)
|
||||
|
||||
if !strings.Contains(got, `src="https://files.example.com/attachments/demo.png"`) {
|
||||
t.Fatalf("expected signed src in response html, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRuntimeMessageTextForHTML(t *testing.T) {
|
||||
got := BuildRuntimeMessageText(enums.IMMessageTypeHTML, `<p>你好</p><p><img data-provider="local" data-storage-key="images/demo.png" alt="demo"></p>`)
|
||||
if got != "你好 [图片]" {
|
||||
t.Fatalf("expected html converted to plain text summary, got: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRuntimeMessageTextForAssetMessages(t *testing.T) {
|
||||
if got := BuildRuntimeMessageText(enums.IMMessageTypeImage, "demo.png"); got != "[图片] demo.png" {
|
||||
t.Fatalf("unexpected image runtime text: %q", got)
|
||||
}
|
||||
if got := BuildRuntimeMessageText(enums.IMMessageTypeAttachment, "spec.pdf"); got != "[附件] spec.pdf" {
|
||||
t.Fatalf("unexpected attachment runtime text: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRenderableMessageTransformsPayloadAndHTML(t *testing.T) {
|
||||
config.SetCurrent(&config.Config{
|
||||
Storage: config.StorageConfig{
|
||||
Default: enums.AssetProviderLocal,
|
||||
Local: config.LocalStorageConfig{
|
||||
BaseURL: "https://files.example.com",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
image := &models.Message{
|
||||
MessageType: enums.IMMessageTypeImage,
|
||||
Payload: `{"assetId":"asset_1","provider":"local","storageKey":"attachments/demo.png","filename":"demo.png"}`,
|
||||
}
|
||||
_, imagePayload := BuildRenderableMessage(image)
|
||||
if !strings.Contains(imagePayload, `"url":"https://files.example.com/attachments/demo.png"`) {
|
||||
t.Fatalf("expected image payload signed url, got: %s", imagePayload)
|
||||
}
|
||||
|
||||
htmlMsg := &models.Message{
|
||||
MessageType: enums.IMMessageTypeHTML,
|
||||
Content: `<p><img data-provider="local" data-storage-key="attachments/demo.png"></p>`,
|
||||
}
|
||||
htmlContent, _ := BuildRenderableMessage(htmlMsg)
|
||||
if !strings.Contains(htmlContent, `src="https://files.example.com/attachments/demo.png"`) {
|
||||
t.Fatalf("expected html content signed src, got: %s", htmlContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMessageHTMLAssetsKeepsValidAttrsAndRemovesSrc(t *testing.T) {
|
||||
setupMessageTestDB(t)
|
||||
config.SetCurrent(&config.Config{
|
||||
Storage: config.StorageConfig{
|
||||
Default: enums.AssetProviderLocal,
|
||||
Local: config.LocalStorageConfig{
|
||||
BaseURL: "https://files.example.com",
|
||||
},
|
||||
},
|
||||
})
|
||||
createTestAsset(t, &models.Asset{
|
||||
AssetID: "asset_local_1",
|
||||
Provider: enums.AssetProviderLocal,
|
||||
StorageKey: "images/demo.png",
|
||||
Filename: "demo.png",
|
||||
FileSize: 123,
|
||||
MimeType: "image/png",
|
||||
Status: enums.AssetStatusSuccess,
|
||||
})
|
||||
|
||||
got, err := NormalizeMessageHTMLAssets(`<p><img src="https://files.example.com/images/demo.png" data-asset-id="asset_local_1" data-provider="local" data-storage-key="images/demo.png" alt="demo"></p>`)
|
||||
if err != nil {
|
||||
t.Fatalf("expected normalization success, got error: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(got, `data-asset-id="asset_local_1"`) {
|
||||
t.Fatalf("expected data-asset-id added, got: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `data-provider="local"`) {
|
||||
t.Fatalf("expected data-provider added, got: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `data-storage-key="images/demo.png"`) {
|
||||
t.Fatalf("expected data-storage-key added, got: %s", got)
|
||||
}
|
||||
if strings.Contains(got, `src=`) {
|
||||
t.Fatalf("expected src removed after asset binding, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMessageHTMLAssetsRejectsMissingAssetMetadata(t *testing.T) {
|
||||
setupMessageTestDB(t)
|
||||
config.SetCurrent(&config.Config{
|
||||
Storage: config.StorageConfig{
|
||||
Default: enums.AssetProviderLocal,
|
||||
Local: config.LocalStorageConfig{
|
||||
BaseURL: "https://files.example.com",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
_, err := NormalizeMessageHTMLAssets(`<p><img src="https://unknown.example.com/demo.png" alt="demo"></p>`)
|
||||
if err == nil {
|
||||
t.Fatalf("expected missing image asset metadata rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMessageHTMLAssetsRejectsIncompleteAttrs(t *testing.T) {
|
||||
setupMessageTestDB(t)
|
||||
config.SetCurrent(&config.Config{
|
||||
Storage: config.StorageConfig{
|
||||
Default: enums.AssetProviderLocal,
|
||||
Local: config.LocalStorageConfig{
|
||||
BaseURL: "https://files.example.com",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
_, err := NormalizeMessageHTMLAssets(`<p><img data-asset-id="asset1" data-provider="local" alt="demo"></p>`)
|
||||
if err == nil {
|
||||
t.Fatalf("expected incomplete asset attrs rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMessageHTMLAssetsRejectsMismatchedAttrs(t *testing.T) {
|
||||
setupMessageTestDB(t)
|
||||
config.SetCurrent(&config.Config{
|
||||
Storage: config.StorageConfig{
|
||||
Default: enums.AssetProviderLocal,
|
||||
Local: config.LocalStorageConfig{
|
||||
BaseURL: "https://files.example.com",
|
||||
},
|
||||
},
|
||||
})
|
||||
createTestAsset(t, &models.Asset{
|
||||
AssetID: "asset_local_2",
|
||||
Provider: enums.AssetProviderLocal,
|
||||
StorageKey: "images/real.png",
|
||||
Filename: "real.png",
|
||||
FileSize: 456,
|
||||
MimeType: "image/png",
|
||||
Status: enums.AssetStatusSuccess,
|
||||
})
|
||||
|
||||
_, err := NormalizeMessageHTMLAssets(`<p><img data-asset-id="asset_local_2" data-provider="local" data-storage-key="images/wrong.png" alt="demo"></p>`)
|
||||
if err == nil {
|
||||
t.Fatalf("expected mismatched asset attrs rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func setupMessageTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite failed: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.Asset{}); err != nil {
|
||||
t.Fatalf("auto migrate asset failed: %v", err)
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
}
|
||||
|
||||
func createTestAsset(t *testing.T, item *models.Asset) {
|
||||
t.Helper()
|
||||
now := time.Now()
|
||||
if item.CreatedAt.IsZero() {
|
||||
item.CreatedAt = now
|
||||
}
|
||||
if item.UpdatedAt.IsZero() {
|
||||
item.UpdatedAt = now
|
||||
}
|
||||
if err := sqls.DB().Create(item).Error; err != nil {
|
||||
t.Fatalf("create asset failed: %v", err)
|
||||
}
|
||||
}
|
||||
103
internal/pkg/utils/utils.go
Normal file
103
internal/pkg/utils/utils.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/dto"
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
"crypto/rand"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
func NormalizeNullableString(value *string) *string {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
v := strings.TrimSpace(*value)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
func BuildAuditFields(operator *dto.AuthPrincipal) models.AuditFields {
|
||||
now := time.Now()
|
||||
fields := models.AuditFields{
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if operator != nil {
|
||||
fields.CreateUserID = operator.UserID
|
||||
fields.CreateUserName = operator.Username
|
||||
fields.UpdateUserID = operator.UserID
|
||||
fields.UpdateUserName = operator.Username
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func FormatTimePtr(t *time.Time) string {
|
||||
if t == nil || t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.Format(time.DateTime)
|
||||
}
|
||||
|
||||
func FormatTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.Format(time.DateTime)
|
||||
}
|
||||
|
||||
func GenerateRandomPassword(length int) (string, error) {
|
||||
if length <= 0 {
|
||||
return "", errorsx.InvalidParamI18n("error.e0175")
|
||||
}
|
||||
|
||||
const charset = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"
|
||||
buf := make([]byte, length)
|
||||
random := make([]byte, length)
|
||||
if _, err := rand.Read(random); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for i := range buf {
|
||||
buf[i] = charset[int(random[i])%len(charset)]
|
||||
}
|
||||
return string(buf), nil
|
||||
}
|
||||
|
||||
func JoinInt64s(values []int64) string {
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
parts = append(parts, cast.ToString(value))
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
func SplitInt64s(raw string) []int64 {
|
||||
parts := strings.Split(strings.TrimSpace(raw), ",")
|
||||
ret := make([]int64, 0, len(parts))
|
||||
seen := make(map[int64]struct{})
|
||||
for _, part := range parts {
|
||||
value := strings.TrimSpace(part)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
id, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ret = append(ret, id)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
Reference in New Issue
Block a user