feat(dingtalk): stabilize multimodal answer workflows

This commit is contained in:
姚章浩
2026-09-09 13:57:43 +08:00
parent 18c81232ce
commit 0798a2dac7
46 changed files with 2309 additions and 195 deletions
+25 -15
View File
@@ -185,6 +185,24 @@ func main() {
}()
log.Printf("DingTalk support notification dispatcher started")
}
// Outbound image runtime (stable-image publishing) is built from environment
// variables independently of the Stream ingress, so both the question-table
// answer rewriter and the inbound-image publisher can share it.
outboundImages, err := buildOutboundImageRuntime(outboundImageConfig{
WeKnoraBaseURL: os.Getenv("WEKNORA_BASE_URL"),
WeKnoraAPIKey: os.Getenv("WEKNORA_API_KEY"),
Endpoint: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT"),
Region: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_REGION"),
AccessKeyID: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID"),
AccessKeySecret: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET"),
Bucket: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET"),
PublicBaseURL: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL"),
})
if err != nil {
log.Fatalf("DingTalk outbound image init failed: %v", err)
}
// Question table dispatcher (optional, fail-closed).
qtCfg, err := readQuestionTableConfig()
if err != nil {
@@ -200,7 +218,7 @@ func main() {
if err != nil {
log.Fatal(err)
}
qtDispatcher, err := buildQuestionTableDispatcher(qtRepo, dingCfg.ClientID, dingCfg.ClientSecret, qtCfg)
qtDispatcher, err := buildQuestionTableDispatcher(qtRepo, dingCfg.ClientID, dingCfg.ClientSecret, qtCfg, answerRewriterAdapter{rewriter: outboundImages.rewriter})
if err != nil {
log.Fatalf("DingTalk question table dispatcher init failed: %v", err)
}
@@ -232,18 +250,6 @@ func main() {
if err != nil {
log.Fatalf("DingTalk directory resolver init failed: %v", err)
}
outboundRewriter, err := buildOutboundImageRewriter(outboundImageConfig{
WeKnoraBaseURL: os.Getenv("WEKNORA_BASE_URL"),
WeKnoraAPIKey: os.Getenv("WEKNORA_API_KEY"),
Endpoint: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT"),
Region: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_REGION"),
AccessKeyID: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID"),
AccessKeySecret: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET"),
Bucket: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET"),
})
if err != nil {
log.Fatalf("DingTalk outbound image init failed: %v", err)
}
feedbackService, err := feedback.NewService(dingCfg.TenantKey, "dingtalk", store)
if err != nil {
log.Fatal(err)
@@ -284,9 +290,10 @@ func main() {
sender,
dingtalk.HandlerOptions{
ImageDownloader: downloader,
ImagePublisher: outboundImages.publisher,
CardSender: cardSender,
Directory: directoryResolver,
OutboundRewriter: outboundRewriter,
OutboundRewriter: outboundImages.rewriter,
SettingsRouter: &dingTalkSettingsRouter{controller: settingsRuntime.controller},
LanguageResolver: settingsRuntime.repository,
},
@@ -334,6 +341,9 @@ func main() {
})
rootHandler := http.NewServeMux()
rootHandler.Handle("/", api.Handler())
if outboundImages.handler != nil {
rootHandler.Handle(outboundImageRoute, outboundImages.handler)
}
if strings.TrimSpace(os.Getenv("DOBOTAI_ADMIN_TOKEN")) != "" {
rootHandler.Handle("/admin/", adminweb.New(settingsRuntime.admin, os.Getenv("DOBOTAI_ADMIN_TOKEN")))
}
@@ -377,7 +387,7 @@ func outboundOSSConfigured() bool {
for _, key := range []string{
"DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT", "DOBOTAI_OUTBOUND_IMAGE_OSS_REGION",
"DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID", "DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET",
"DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET",
"DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET", "DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL",
} {
if strings.TrimSpace(os.Getenv(key)) == "" {
return false
+47 -8
View File
@@ -116,21 +116,21 @@ func TestMainWiresOptionalDingTalkCardDebugUser(t *testing.T) {
}
}
func TestBuildOutboundImageRewriterConfig(t *testing.T) {
func TestBuildOutboundImageRuntimeConfig(t *testing.T) {
t.Run("disabled", func(t *testing.T) {
rewriter, err := buildOutboundImageRewriter(outboundImageConfig{})
if err != nil || rewriter != nil {
t.Fatalf("rewriter=%v err=%v", rewriter, err)
runtime, err := buildOutboundImageRuntime(outboundImageConfig{})
if err != nil || runtime.rewriter != nil || runtime.handler != nil {
t.Fatalf("runtime=%+v err=%v", runtime, err)
}
})
t.Run("partial configuration rejected", func(t *testing.T) {
_, err := buildOutboundImageRewriter(outboundImageConfig{Bucket: "private-bucket"})
_, err := buildOutboundImageRuntime(outboundImageConfig{Bucket: "private-bucket"})
if err == nil {
t.Fatal("expected configuration error")
}
})
t.Run("complete configuration", func(t *testing.T) {
rewriter, err := buildOutboundImageRewriter(outboundImageConfig{
runtime, err := buildOutboundImageRuntime(outboundImageConfig{
WeKnoraBaseURL: "http://127.0.0.1:8080",
WeKnoraAPIKey: "api-key",
Endpoint: "https://oss-cn-shenzhen.aliyuncs.com",
@@ -138,9 +138,48 @@ func TestBuildOutboundImageRewriterConfig(t *testing.T) {
AccessKeyID: "access-key-id",
AccessKeySecret: "access-key-secret",
Bucket: "private-bucket",
PublicBaseURL: "https://weknora-api-test.dobot.cn/dingtalk/i",
})
if err != nil || rewriter == nil {
t.Fatalf("rewriter=%v err=%v", rewriter, err)
if err != nil || runtime.rewriter == nil || runtime.handler == nil {
t.Fatalf("runtime=%+v err=%v", runtime, err)
}
})
}
func TestMainWiresStableOutboundImageRuntime(t *testing.T) {
data, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
source := string(data)
for _, fragment := range []string{
`buildOutboundImageRuntime(outboundImageConfig{`,
`PublicBaseURL: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL")`,
`OutboundRewriter: outboundImages.rewriter`,
`rootHandler.Handle(outboundImageRoute, outboundImages.handler)`,
} {
if !strings.Contains(source, fragment) {
t.Errorf("main stable image wiring missing %q", fragment)
}
}
}
func TestOutboundOSSConfiguredRequiresStablePublicBaseURL(t *testing.T) {
for _, key := range []string{
"DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT",
"DOBOTAI_OUTBOUND_IMAGE_OSS_REGION",
"DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID",
"DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET",
"DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET",
} {
t.Setenv(key, "configured")
}
t.Setenv("DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL", "")
if outboundOSSConfigured() {
t.Fatal("stable public base URL must be required")
}
t.Setenv("DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL", "https://img.example/dingtalk/i")
if !outboundOSSConfigured() {
t.Fatal("complete stable image configuration should be active")
}
}
+32 -9
View File
@@ -13,8 +13,8 @@ import (
const (
outboundImageMaxCount = 5
outboundImageMaxBytes = 10 << 20
outboundImageURLTTL = 2 * time.Hour
outboundImagePrefix = "dobotai/im-images"
outboundImageRoute = "/dingtalk/i/"
)
type outboundImageConfig struct {
@@ -25,10 +25,17 @@ type outboundImageConfig struct {
AccessKeyID string
AccessKeySecret string
Bucket string
PublicBaseURL string
}
func buildOutboundImageRewriter(cfg outboundImageConfig) (dingtalk.OutboundContentRewriter, error) {
ossValues := []string{cfg.Endpoint, cfg.Region, cfg.AccessKeyID, cfg.AccessKeySecret, cfg.Bucket}
type outboundImageRuntime struct {
rewriter dingtalk.OutboundContentRewriter
publisher *outboundimage.StableHashPublisher
handler http.Handler
}
func buildOutboundImageRuntime(cfg outboundImageConfig) (outboundImageRuntime, error) {
ossValues := []string{cfg.Endpoint, cfg.Region, cfg.AccessKeyID, cfg.AccessKeySecret, cfg.Bucket, cfg.PublicBaseURL}
enabled := false
for _, value := range ossValues {
if strings.TrimSpace(value) != "" {
@@ -37,11 +44,11 @@ func buildOutboundImageRewriter(cfg outboundImageConfig) (dingtalk.OutboundConte
}
}
if !enabled {
return nil, nil
return outboundImageRuntime{}, nil
}
for _, value := range append([]string{cfg.WeKnoraBaseURL, cfg.WeKnoraAPIKey}, ossValues...) {
if strings.TrimSpace(value) == "" {
return nil, errors.New("outbound image: complete WeKnora and OSS configuration is required")
return outboundImageRuntime{}, errors.New("outbound image: complete WeKnora, OSS, and public URL configuration is required")
}
}
transport := http.DefaultTransport.(*http.Transport).Clone()
@@ -53,7 +60,7 @@ func buildOutboundImageRewriter(cfg outboundImageConfig) (dingtalk.OutboundConte
outboundImageMaxBytes,
)
if err != nil {
return nil, err
return outboundImageRuntime{}, err
}
store, err := outboundimage.NewAliyunOSSStore(
cfg.Endpoint,
@@ -63,8 +70,24 @@ func buildOutboundImageRewriter(cfg outboundImageConfig) (dingtalk.OutboundConte
cfg.Bucket,
)
if err != nil {
return nil, err
return outboundImageRuntime{}, err
}
publisher := outboundimage.NewHashPublisher(store, outboundImagePrefix, outboundImageURLTTL)
return outboundimage.NewRewriter(fetcher, publisher, outboundImageMaxCount), nil
publisher, err := outboundimage.NewStableHashPublisher(store, outboundImagePrefix, cfg.PublicBaseURL)
if err != nil {
return outboundImageRuntime{}, err
}
handler, err := outboundimage.NewStableImageHandler(store, outboundImagePrefix, outboundImageRoute, outboundImageMaxBytes)
if err != nil {
return outboundImageRuntime{}, err
}
return outboundImageRuntime{
rewriter: outboundimage.NewRewriter(fetcher, publisher, outboundImageMaxCount),
publisher: publisher,
handler: handler,
}, nil
}
func buildOutboundImageRewriter(cfg outboundImageConfig) (dingtalk.OutboundContentRewriter, error) {
runtime, err := buildOutboundImageRuntime(cfg)
return runtime.rewriter, err
}
+24 -4
View File
@@ -9,6 +9,7 @@ import (
"strings"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/questiontable"
)
@@ -49,9 +50,11 @@ func readQuestionTableConfig() (questionTableConfig, error) {
// buildQuestionTableDispatcher creates a validated question-table dispatcher
// when the repo is non-nil and all required credentials are present.
// No HTTP request occurs at build time. It returns (nil, nil) when the
// question-table feature is disabled (empty config).
func buildQuestionTableDispatcher(repo questiontable.ExportRepository, clientID, clientSecret string, cfg questionTableConfig) (*questiontable.Dispatcher, error) {
// The optional answer rewriter converts the stored answer into the same
// outbound form used for DingTalk delivery (resource images to stable HTTPS,
// document links, anchor cleanup). No HTTP request occurs at build time.
// It returns (nil, nil) when the question-table feature is disabled.
func buildQuestionTableDispatcher(repo questiontable.ExportRepository, clientID, clientSecret string, cfg questionTableConfig, rewriter questiontable.AnswerRewriter) (*questiontable.Dispatcher, error) {
// Disabled: all blank config.
if cfg.BaseID == "" && cfg.SheetID == "" && cfg.OperatorID == "" {
return nil, nil
@@ -73,7 +76,24 @@ func buildQuestionTableDispatcher(repo questiontable.ExportRepository, clientID,
return nil, err
}
return questiontable.NewDispatcher(repo, client, nil)
return questiontable.NewDispatcher(repo, client, &questiontable.DispatcherOptions{
Rewriter: rewriter,
SendTimeout: 60 * time.Second,
})
}
// answerRewriterAdapter adapts a blocking outbound content rewriter to the
// AnswerRewriter contract (caller never gets an error, so the original answer
// is always written when conversion cannot run).
type answerRewriterAdapter struct {
rewriter dingtalk.OutboundContentRewriter
}
func (a answerRewriterAdapter) Rewrite(ctx context.Context, content string) (string, error) {
if a.rewriter == nil {
return content, nil
}
return a.rewriter.Rewrite(ctx, content), nil
}
// runQuestionTablePoller runs the dispatcher immediately, then every interval.
+5 -5
View File
@@ -32,7 +32,7 @@ func TestReadQuestionTableConfigDisabledAllBlank(t *testing.T) {
}
func TestBuildQuestionTableDispatcherDisabled(t *testing.T) {
d, err := buildQuestionTableDispatcher(nil, "", "", questionTableConfig{})
d, err := buildQuestionTableDispatcher(nil, "", "", questionTableConfig{}, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -87,7 +87,7 @@ func TestReadQuestionTableConfigPartialErrors(t *testing.T) {
func TestBuildQuestionTableDispatcherEnabledMissingRepo(t *testing.T) {
cfg := questionTableConfig{BaseID: "b1", SheetID: "sh1", OperatorID: "op1"}
_, err := buildQuestionTableDispatcher(nil, "cid", "secret", cfg)
_, err := buildQuestionTableDispatcher(nil, "cid", "secret", cfg, nil)
if err == nil {
t.Fatal("expected error when repo is nil")
}
@@ -99,7 +99,7 @@ func TestBuildQuestionTableDispatcherEnabledMissingRepo(t *testing.T) {
func TestBuildQuestionTableDispatcherEnabledMissingClientID(t *testing.T) {
repo := &questionTableExportRepoStub{}
cfg := questionTableConfig{BaseID: "b1", SheetID: "sh1", OperatorID: "op1"}
_, err := buildQuestionTableDispatcher(repo, "", "secret", cfg)
_, err := buildQuestionTableDispatcher(repo, "", "secret", cfg, nil)
if err == nil {
t.Fatal("expected error when client id is empty")
}
@@ -108,7 +108,7 @@ func TestBuildQuestionTableDispatcherEnabledMissingClientID(t *testing.T) {
func TestBuildQuestionTableDispatcherEnabledMissingSecret(t *testing.T) {
repo := &questionTableExportRepoStub{}
cfg := questionTableConfig{BaseID: "b1", SheetID: "sh1", OperatorID: "op1"}
_, err := buildQuestionTableDispatcher(repo, "cid", "", cfg)
_, err := buildQuestionTableDispatcher(repo, "cid", "", cfg, nil)
if err == nil {
t.Fatal("expected error when secret is empty")
}
@@ -117,7 +117,7 @@ func TestBuildQuestionTableDispatcherEnabledMissingSecret(t *testing.T) {
func TestBuildQuestionTableDispatcherEnabledCompleteConfig(t *testing.T) {
repo := &questionTableExportRepoStub{}
cfg := questionTableConfig{BaseID: "b1", SheetID: "sh1", OperatorID: "op1"}
d, err := buildQuestionTableDispatcher(repo, "cid", "secret", cfg)
d, err := buildQuestionTableDispatcher(repo, "cid", "secret", cfg, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -0,0 +1,3 @@
-- 000016_question_image_urls down migration.
ALTER TABLE turns
DROP COLUMN IF EXISTS question_image_urls_json;
@@ -0,0 +1,6 @@
-- 000016_question_image_urls: stable published URLs for inbound question images.
-- Stores the object-storage HTTPS URLs of images a user sent in a question,
-- so the question-table export can render a readable, traceable record without
-- persisting download codes or data URIs.
ALTER TABLE turns
ADD COLUMN IF NOT EXISTS question_image_urls_json JSONB NOT NULL DEFAULT '[]'::JSONB;
@@ -0,0 +1,3 @@
-- 000017_knowledge_searched down migration.
ALTER TABLE turns
DROP COLUMN IF EXISTS knowledge_searched;
@@ -0,0 +1,6 @@
-- 000017_knowledge_searched: records whether a turn actually triggered a
-- knowledge-base search (knowledge_search tool call). Coverage classification
-- uses this to distinguish pure visual recognition from a searched-but-missed
-- knowledge question.
ALTER TABLE turns
ADD COLUMN IF NOT EXISTS knowledge_searched BOOLEAN NOT NULL DEFAULT FALSE;
@@ -0,0 +1,11 @@
-- 000018_coverage_visual_recognition_reason down migration.
ALTER TABLE coverage_decisions
DROP CONSTRAINT IF EXISTS coverage_decisions_reason_code_check;
ALTER TABLE coverage_decisions
ADD CONSTRAINT coverage_decisions_reason_code_check
CHECK (reason_code IN (
'covered_has_references',
'uncovered_no_references',
'uncovered_explicit_no_answer'
));
@@ -0,0 +1,14 @@
-- 000018_coverage_visual_recognition_reason: extend the coverage_decisions
-- reason_code allowlist with the new image-visual-recognition reason emitted by
-- coverage Evaluator v3 (visual recognition is not a knowledge-coverage miss).
ALTER TABLE coverage_decisions
DROP CONSTRAINT IF EXISTS coverage_decisions_reason_code_check;
ALTER TABLE coverage_decisions
ADD CONSTRAINT coverage_decisions_reason_code_check
CHECK (reason_code IN (
'covered_has_references',
'uncovered_no_references',
'uncovered_explicit_no_answer',
'covered_visual_recognition'
));
+2
View File
@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"io"
"log"
"net/http"
"strings"
"sync"
@@ -206,6 +207,7 @@ func (s *CardSender) UpdateCard(ctx context.Context, outTrackID, content string,
if len(content) > 64*1024 {
return errors.New("dingtalk card: content exceeds 64 KiB")
}
log.Print(formatCardUpdateDiagnostic(content, isFinalize, isError))
token, err := s.accessToken(ctx)
if err != nil {
@@ -0,0 +1,64 @@
package dingtalk
import (
"fmt"
"strings"
)
type cardContentShape struct {
bytes int
lines int
pipeLines int
leadingPipeLines int
delimiterLines int
confirmedTable bool
confirmedTablePos int
}
func inspectCardContent(content string) cardContentShape {
shape := cardContentShape{
bytes: len(content),
confirmedTablePos: -1,
}
if content == "" {
return shape
}
shape.lines = strings.Count(content, "\n") + 1
lines := splitMarkdownStreamLines(content)
for i, line := range lines {
if strings.Contains(line.text, "|") {
shape.pipeLines++
}
if isTableHeaderCandidate(line.text) {
shape.leadingPipeLines++
}
if !line.inFence && isCompleteTableDelimiter(line.text) {
shape.delimiterLines++
}
if shape.confirmedTable || i == 0 || line.inFence || lines[i-1].inFence {
continue
}
if isTableHeaderCandidate(lines[i-1].text) && isCompleteTableDelimiter(line.text) {
shape.confirmedTable = true
shape.confirmedTablePos = lines[i-1].start
}
}
return shape
}
func formatCardUpdateDiagnostic(content string, isFinalize, isError bool) string {
shape := inspectCardContent(content)
return fmt.Sprintf(
"DingTalk card update shape: finalize=%t error=%t bytes=%d lines=%d pipe_lines=%d leading_pipe_lines=%d delimiter_lines=%d confirmed_table=%t table_start=%d",
isFinalize,
isError,
shape.bytes,
shape.lines,
shape.pipeLines,
shape.leadingPipeLines,
shape.delimiterLines,
shape.confirmedTable,
shape.confirmedTablePos,
)
}
@@ -0,0 +1,93 @@
package dingtalk
import (
"bytes"
"context"
"fmt"
"log"
"strings"
"testing"
)
func TestCardSenderUpdateCardLogsOnlySafeContentShape(t *testing.T) {
ts := newCardTestServer()
defer ts.Close()
sender, err := newCardSenderForTest("client-id", "client-secret", "template-id", ts.Client(), ts.URL()+"/token", ts.URL()+"/create")
if err != nil {
t.Fatal(err)
}
var output bytes.Buffer
previousWriter := log.Writer()
previousFlags := log.Flags()
previousPrefix := log.Prefix()
log.SetOutput(&output)
log.SetFlags(0)
log.SetPrefix("")
t.Cleanup(func() {
log.SetOutput(previousWriter)
log.SetFlags(previousFlags)
log.SetPrefix(previousPrefix)
})
content := "DO_NOT_LOG_BOUNDARY_TEXT\n\n| 型号 | 负载 |\n| --- | --- |\n| CR5 | 5kg |"
if err := sender.UpdateCard(context.Background(), "track-diagnostic", content, false, false); err != nil {
t.Fatal(err)
}
got := output.String()
if !strings.Contains(got, "DingTalk card update shape:") || !strings.Contains(got, "confirmed_table=true") || !strings.Contains(got, "finalize=false") {
t.Fatalf("missing safe boundary diagnostic: %q", got)
}
if strings.Contains(got, "DO_NOT_LOG_BOUNDARY_TEXT") || strings.Contains(got, "CR5") || strings.Contains(got, "track-diagnostic") {
t.Fatalf("boundary diagnostic leaked content or card identifier: %q", got)
}
}
func TestFormatCardUpdateDiagnosticReportsStructureWithoutContent(t *testing.T) {
content := "DO_NOT_LOG_ANSWER_TEXT\n\n| 型号 | 负载 |\n| --- | --- |\n| CR5 | 5kg |"
got := formatCardUpdateDiagnostic(content, false, false)
wantFields := []string{
"DingTalk card update shape:",
"finalize=false",
"error=false",
fmt.Sprintf("bytes=%d", len(content)),
"lines=5",
"pipe_lines=3",
"leading_pipe_lines=3",
"delimiter_lines=1",
"confirmed_table=true",
fmt.Sprintf("table_start=%d", strings.Index(content, "| 型号")),
}
for _, field := range wantFields {
if !strings.Contains(got, field) {
t.Fatalf("diagnostic %q missing field %q", got, field)
}
}
if strings.Contains(got, "DO_NOT_LOG_ANSWER_TEXT") || strings.Contains(got, "CR5") {
t.Fatalf("diagnostic leaked answer content: %q", got)
}
}
func TestFormatCardUpdateDiagnosticDistinguishesFinalPlainText(t *testing.T) {
got := formatCardUpdateDiagnostic("普通回答", true, false)
wantFields := []string{
"finalize=true",
"error=false",
"lines=1",
"pipe_lines=0",
"leading_pipe_lines=0",
"delimiter_lines=0",
"confirmed_table=false",
"table_start=-1",
}
for _, field := range wantFields {
if !strings.Contains(got, field) {
t.Fatalf("diagnostic %q missing field %q", got, field)
}
}
if strings.Contains(got, "普通回答") {
t.Fatalf("diagnostic leaked answer content: %q", got)
}
}
+6
View File
@@ -39,6 +39,7 @@ func (p *cardUpdatePump) run(ctx context.Context, ticks <-chan time.Time, stopTi
if stopTicks != nil {
defer stopTicks()
}
lastSuccessful := ""
for {
select {
case <-ctx.Done():
@@ -52,9 +53,14 @@ func (p *cardUpdatePump) run(ctx context.Context, ticks <-chan time.Time, stopTi
default:
continue
}
if latest == lastSuccessful {
continue
}
if err := update(ctx, latest); err != nil {
log.Printf("DingTalk async card update failed, continuing")
continue
}
lastSuccessful = latest
}
}
}
@@ -0,0 +1,31 @@
package dingtalk
import (
"regexp"
"strings"
)
const displayReferenceMarkerPattern = `\[\[ref[\t ]+id[\t ]*=[\t ]*["']c[0-9]+["'][\t ]*\]\]`
var (
displayReferenceMarkerRE = regexp.MustCompile(displayReferenceMarkerPattern)
leadingReferenceGroupColonRE = regexp.MustCompile(`(?m)^[ ]*(?:` + displayReferenceMarkerPattern + `[ ]*)+[::][ ]*`)
spacedReferenceGroupRE = regexp.MustCompile(`[ ]+(?:` + displayReferenceMarkerPattern + `[ ]*)+`)
spaceBeforeDisplayPunctuation = regexp.MustCompile(`[ ]+([,。;:!?、,.;:!?])`)
spaceAfterDisplayColon = regexp.MustCompile(`([::])[\t ]+`)
)
func sanitizeDisplayAnswer(content string) string {
if index := strings.LastIndex(content, "[[ref"); index >= 0 && !strings.Contains(content[index:], "]]") {
content = content[:index]
}
if !displayReferenceMarkerRE.MatchString(content) {
return content
}
content = leadingReferenceGroupColonRE.ReplaceAllString(content, "")
content = spacedReferenceGroupRE.ReplaceAllString(content, " ")
content = displayReferenceMarkerRE.ReplaceAllString(content, "")
content = spaceBeforeDisplayPunctuation.ReplaceAllString(content, "$1")
content = spaceAfterDisplayColon.ReplaceAllString(content, "$1")
return content
}
@@ -0,0 +1,66 @@
package dingtalk
import "testing"
func TestSanitizeDisplayAnswerRemovesInternalReferenceMarkers(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "adjacent inline markers",
input: "结论支持该功能[[ref id=\"c2\"]][[ref id=\"c4\"]]。",
want: "结论支持该功能。",
},
{
name: "leading marker group and chinese colon",
input: "[[ref id=\"c2\"]][[ref id=\"c4\"]]:建议先检查配置。",
want: "建议先检查配置。",
},
{
name: "leading marker group and ascii colon on second line",
input: "第一段。\n[[ref id='c12']] : Second paragraph.",
want: "第一段。\nSecond paragraph.",
},
{
name: "normal punctuation remains natural",
input: "标题:[[ref id=\"c1\"]] 详细说明",
want: "标题:详细说明",
},
{
name: "spaces around marker collapse to one",
input: "Feature A [[ref id=\"c3\"]] remains supported.",
want: "Feature A remains supported.",
},
{
name: "unrelated double bracket syntax is preserved",
input: "请保留 [[wiki|页面名称]] 和 [普通链接](https://example.com)。",
want: "请保留 [[wiki|页面名称]] 和 [普通链接](https://example.com)。",
},
{
name: "content without references remains byte for byte unchanged",
input: "Protocol: value ; keep spacing\n indented",
want: "Protocol: value ; keep spacing\n indented",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := sanitizeDisplayAnswer(tt.input); got != tt.want {
t.Fatalf("sanitizeDisplayAnswer()=%q, want %q", got, tt.want)
}
})
}
}
func TestSanitizeDisplayAnswerHoldsBackDanglingStreamingReference(t *testing.T) {
for _, input := range []string{
"答案正文[[ref",
"答案正文[[ref id=\"c",
"答案正文[[ref id=\"c2\"",
} {
if got := sanitizeDisplayAnswer(input); got != "答案正文" {
t.Fatalf("sanitizeDisplayAnswer(%q)=%q", input, got)
}
}
}
@@ -0,0 +1,33 @@
package dingtalk
import (
"testing"
)
func TestEffectiveDownloadCodesDeduplicatesAndPreservesOrder(t *testing.T) {
cb := BotCallback{
DownloadCode: "code-first",
DownloadCodes: []string{"code-first", "code-second", " code-third "},
}
codes := effectiveDownloadCodes(cb)
if len(codes) != 3 {
t.Fatalf("codes=%v, want 3", codes)
}
if codes[0] != "code-first" || codes[1] != "code-second" || codes[2] != "code-third" {
t.Fatalf("codes=%v, want ordered de-dup", codes)
}
}
func TestEffectiveDownloadCodesSingleOnly(t *testing.T) {
cb := BotCallback{DownloadCode: " solo "}
codes := effectiveDownloadCodes(cb)
if len(codes) != 1 || codes[0] != "solo" {
t.Fatalf("codes=%v, want [solo]", codes)
}
}
func TestEffectiveDownloadCodesEmpty(t *testing.T) {
if codes := effectiveDownloadCodes(BotCallback{}); len(codes) != 0 {
t.Fatalf("codes=%v, want empty", codes)
}
}
+106 -10
View File
@@ -2,6 +2,7 @@ package dingtalk
import (
"context"
"encoding/base64"
"errors"
"fmt"
"log"
@@ -45,6 +46,7 @@ type BotCallback struct {
SessionWebhook string
Text string
DownloadCode string
DownloadCodes []string
}
type QAAsker interface {
@@ -75,6 +77,12 @@ type ImageDownloader interface {
Download(ctx context.Context, downloadCode string) (core.ImageInput, error)
}
// ImagePublisher publishes a downloaded image to object storage and returns a
// stable HTTPS URL. Implementation satisfies outboundimage.Publisher.
type ImagePublisher interface {
Publish(ctx context.Context, data []byte, contentType string) (string, error)
}
type UserDirectory interface {
ResolveUser(ctx context.Context, tenantKey, staffID, fallbackName string) (directory.Profile, error)
}
@@ -83,6 +91,10 @@ type OutboundContentRewriter interface {
Rewrite(ctx context.Context, content string) string
}
type OutboundPartialSanitizer interface {
SanitizePartial(content string) string
}
type SettingsMessage struct {
TenantKey string
Channel string
@@ -119,6 +131,7 @@ type StreamingCardSender interface {
type HandlerOptions struct {
ImageDownloader ImageDownloader
ImagePublisher ImagePublisher
CardSender FinalCardSender
Directory UserDirectory
OutboundRewriter OutboundContentRewriter
@@ -131,6 +144,7 @@ type Handler struct {
qa QAAsker
reply MarkdownSender
downloader ImageDownloader
imagePublisher ImagePublisher
card FinalCardSender
directory UserDirectory
outboundRewriter OutboundContentRewriter
@@ -175,6 +189,7 @@ func NewHandlerWithOptions(cfg TenantConfig, qa QAAsker, reply MarkdownSender, o
qa: qa,
reply: reply,
downloader: opts.ImageDownloader,
imagePublisher: opts.ImagePublisher,
card: opts.CardSender,
directory: opts.Directory,
outboundRewriter: opts.OutboundRewriter,
@@ -287,7 +302,8 @@ func (h *Handler) Handle(ctx context.Context, cb BotCallback) error {
return errors.New("dingtalk handler: text message content is empty")
}
case msgTypePicture:
if strings.TrimSpace(cb.DownloadCode) == "" {
codes := effectiveDownloadCodes(cb)
if len(codes) == 0 {
log.Printf("DingTalk image preparation failed: stage=missing_download_code")
return h.sendReply(ctx, webhookURL, imageErrorReply)
}
@@ -295,16 +311,25 @@ func (h *Handler) Handle(ctx context.Context, cb BotCallback) error {
log.Printf("DingTalk image preparation failed: stage=downloader_unavailable")
return h.sendReply(ctx, webhookURL, imageErrorReply)
}
image, err := h.downloader.Download(ctx, strings.TrimSpace(cb.DownloadCode))
if err != nil {
log.Printf("DingTalk image preparation failed: stage=%s", imageFailureStage(err))
return h.sendReply(ctx, webhookURL, imageErrorReply)
images := make([]core.ImageInput, 0, len(codes))
for _, code := range codes {
image, err := h.downloader.Download(ctx, code)
if err != nil {
log.Printf("DingTalk image preparation failed: stage=%s", imageFailureStage(err))
return h.sendReply(ctx, webhookURL, imageErrorReply)
}
if h.imagePublisher != nil {
if url := h.publishQuestionImage(ctx, image); url != "" {
image.PublishedURL = url
}
}
images = append(images, image)
}
req.Query = strings.TrimSpace(cb.Text)
if req.Query == "" {
req.Query = imageQuery
}
req.Images = []core.ImageInput{image}
req.Images = images
default:
return h.sendReply(ctx, webhookURL, unsupportedMessageReply)
}
@@ -419,19 +444,26 @@ func (h *Handler) handleStreaming(ctx context.Context, webhookURL string, req co
// Streaming observer with the configured update throttle (500ms by default).
var lastUpdate time.Time
var lastPartialContent string
observer := func(fullContent string) {
if strings.TrimSpace(fullContent) == "" {
return
}
now := h.now()
if lastUpdate.IsZero() || now.Sub(lastUpdate) >= h.cardUpdateInterval {
partialContent := h.sanitizeOutboundPartial(fullContent)
if strings.TrimSpace(partialContent) == "" || partialContent == lastPartialContent {
return
}
// Advance the throttle window before I/O so a failing DingTalk API
// cannot be hammered once per model chunk. The final update below is
// always attempted regardless of this timestamp.
lastUpdate = now
if updateErr := streamingCard.UpdateCard(ctx, outTrackID, fullContent, false, false); updateErr != nil {
if updateErr := streamingCard.UpdateCard(ctx, outTrackID, partialContent, false, false); updateErr != nil {
log.Printf("DingTalk streaming card update failed, continuing")
return
}
lastPartialContent = partialContent
}
}
@@ -477,7 +509,9 @@ func (h *Handler) handleEventStreaming(ctx context.Context, webhookURL string, r
result, err := eventQA.AskEvents(ctx, req, func(event weknora.StreamEvent) {
if event.ResponseType == "answer" && strings.TrimSpace(event.FullAnswer) != "" {
answerStarted = true
pump.Offer(event.FullAnswer)
if partialContent := h.sanitizeOutboundPartial(event.FullAnswer); strings.TrimSpace(partialContent) != "" {
pump.Offer(partialContent)
}
return
}
if answerStarted {
@@ -538,7 +572,7 @@ func (h *Handler) finalizeCardOrMarkdown(ctx context.Context, webhookURL string,
func (h *Handler) rewriteOutboundContent(ctx context.Context, content string) string {
if h.outboundRewriter == nil {
return content
return sanitizeDisplayAnswer(content)
}
timeout := h.finalDeliveryTimeout
if timeout <= 0 {
@@ -546,7 +580,15 @@ func (h *Handler) rewriteOutboundContent(ctx context.Context, content string) st
}
rewriteCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout)
defer cancel()
return h.outboundRewriter.Rewrite(rewriteCtx, content)
return sanitizeDisplayAnswer(h.outboundRewriter.Rewrite(rewriteCtx, content))
}
func (h *Handler) sanitizeOutboundPartial(content string) string {
sanitizer, ok := h.outboundRewriter.(OutboundPartialSanitizer)
if ok {
content = sanitizer.SanitizePartial(content)
}
return stableMarkdownPrefix(sanitizeDisplayAnswer(content))
}
func (h *Handler) recordDelivery(ctx context.Context, result core.QAResult, status, method, providerMessageID, failureCode string) {
@@ -671,3 +713,57 @@ func (h *Handler) sendReply(ctx context.Context, webhookURL, text string) error
}
return nil
}
// publishQuestionImage best-effort publishes a downloaded image's bytes to
// object storage for traceability. It returns the stable HTTPS URL, or an
// empty string when publishing is unavailable or fails, so that an image
// question is never blocked by storage.
func (h *Handler) publishQuestionImage(ctx context.Context, image core.ImageInput) string {
if h.imagePublisher == nil {
return ""
}
data, err := decodeImageDataURI(image.DataURI)
if err != nil || len(data) == 0 {
log.Printf("DingTalk question image publish failed: stage=decode")
return ""
}
url, err := h.imagePublisher.Publish(ctx, data, image.MIMEType)
if err != nil || strings.TrimSpace(url) == "" {
log.Printf("DingTalk question image publish failed: stage=publish")
return ""
}
return strings.TrimSpace(url)
}
// decodeImageDataURI strips the "data:<mime>;base64," prefix and decodes the
// payload into raw bytes. It is used only to publish inbound images and never
// writes the data URI to storage.
func decodeImageDataURI(dataURI string) ([]byte, error) {
const prefix = "base64,"
index := strings.Index(dataURI, prefix)
if index < 0 {
return nil, errors.New("dingtalk image: data URI missing base64 payload")
}
return base64.StdEncoding.DecodeString(dataURI[index+len(prefix):])
}
// effectiveDownloadCodes returns the ordered, de-duplicated list of image
// download codes for a picture callback. DownloadCode equals the first code
// and DownloadCodes holds every code (richText with several pictures), so the
// single value is used to canonicalize the order without duplication.
func effectiveDownloadCodes(cb BotCallback) []string {
var codes []string
seen := make(map[string]struct{})
for _, code := range append([]string{cb.DownloadCode}, cb.DownloadCodes...) {
code = strings.TrimSpace(code)
if code == "" {
continue
}
if _, ok := seen[code]; ok {
continue
}
seen[code] = struct{}{}
codes = append(codes, code)
}
return codes
}
+141 -8
View File
@@ -3,6 +3,7 @@ package dingtalk
import (
"context"
"errors"
"reflect"
"strings"
"testing"
"time"
@@ -981,9 +982,10 @@ type cardUpdate struct {
}
type fakeOutboundRewriter struct {
calls int
from string
to string
calls int
partialCalls int
from string
to string
}
func (f *fakeOutboundRewriter) Rewrite(_ context.Context, content string) string {
@@ -991,6 +993,11 @@ func (f *fakeOutboundRewriter) Rewrite(_ context.Context, content string) string
return strings.ReplaceAll(content, f.from, f.to)
}
func (f *fakeOutboundRewriter) SanitizePartial(content string) string {
f.partialCalls++
return strings.ReplaceAll(content, f.from, "")
}
func (f *fakeStreamingCard) SendFinalCard(_ context.Context, req CardRequest) (string, error) {
f.finalCalled = true
f.finalReq = req
@@ -1668,15 +1675,16 @@ func TestRenderProgressEventUsesOnlySafeFixedText(t *testing.T) {
}
func TestHandleEventStreamingFinalizesLatestAnswerWithoutMarkdown(t *testing.T) {
rawAnswer := "完整答案[[ref id=\"c8\"]]"
card := &fakeStreamingCard{startID: "track-events"}
qa := &fakeEventQA{
fakeStreamingQA: fakeStreamingQA{result: core.QAResult{Answer: "完整答案"}},
fakeStreamingQA: fakeStreamingQA{result: core.QAResult{Answer: rawAnswer}},
events: []weknora.StreamEvent{
{ResponseType: "tool_call", ToolName: "query_understand"},
{ResponseType: "tool_call", ToolName: "knowledge_search"},
{ResponseType: "references", ReferenceCount: 3},
{ResponseType: "answer", FullAnswer: "部分"},
{ResponseType: "answer", FullAnswer: "完整答案", Done: true},
{ResponseType: "answer", FullAnswer: "部分[[ref"},
{ResponseType: "answer", FullAnswer: rawAnswer, Done: true},
},
}
reply := &fakeReply{}
@@ -1698,6 +1706,9 @@ func TestHandleEventStreamingFinalizesLatestAnswerWithoutMarkdown(t *testing.T)
if last.content != "完整答案" || !last.isFinalize || last.isError {
t.Fatalf("final update=%+v", last)
}
if qa.result.Answer != rawAnswer {
t.Fatalf("raw QA answer mutated: %q", qa.result.Answer)
}
if reply.called {
t.Fatal("SendMarkdown must not be called")
}
@@ -2080,8 +2091,8 @@ func TestHandleStreamingRewritesOnlyFinalOutboundImageContent(t *testing.T) {
if len(card.updates) < 2 {
t.Fatalf("updates=%d", len(card.updates))
}
if !strings.Contains(card.updates[0].content, resource) {
t.Fatalf("streaming intermediate content should remain untouched: %q", card.updates[0].content)
if strings.Contains(card.updates[0].content, resource) {
t.Fatalf("streaming intermediate content leaked internal resource: %q", card.updates[0].content)
}
final := card.updates[len(card.updates)-1]
if !final.isFinalize || strings.Contains(final.content, resource) || !strings.Contains(final.content, "https://img.example/signed.jpg") {
@@ -2090,4 +2101,126 @@ func TestHandleStreamingRewritesOnlyFinalOutboundImageContent(t *testing.T) {
if rewriter.calls != 1 {
t.Fatalf("rewrite calls=%d, want 1", rewriter.calls)
}
if rewriter.partialCalls == 0 {
t.Fatal("streaming intermediate content was not sanitized")
}
}
func TestHandleFiltersReferenceMarkersAtDingTalkDisplayBoundary(t *testing.T) {
rawAnswer := "结论[[ref id=\"c2\"]][[ref id=\"c4\"]]。"
wantDisplay := "结论。"
t.Run("final card", func(t *testing.T) {
qa := &fakeQA{result: core.QAResult{Answer: rawAnswer}}
reply := &fakeReply{}
card := &fakeCard{returnID: "track-reference-filter"}
h := newTestHandlerWithCard(t, qa, reply, card)
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
t.Fatal(err)
}
if card.lastReq.Content != wantDisplay {
t.Fatalf("card content=%q, want %q", card.lastReq.Content, wantDisplay)
}
if qa.result.Answer != rawAnswer {
t.Fatalf("raw QA answer mutated: %q", qa.result.Answer)
}
})
t.Run("markdown fallback", func(t *testing.T) {
qa := &fakeQA{result: core.QAResult{Answer: rawAnswer}}
reply := &fakeReply{}
card := &fakeCard{err: errors.New("card unavailable")}
h := newTestHandlerWithCard(t, qa, reply, card)
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
t.Fatal(err)
}
if reply.lastText != wantDisplay {
t.Fatalf("markdown content=%q, want %q", reply.lastText, wantDisplay)
}
if qa.result.Answer != rawAnswer {
t.Fatalf("raw QA answer mutated: %q", qa.result.Answer)
}
})
}
func TestHandleStreamingHoldsBackPartialReferenceMarkers(t *testing.T) {
rawAnswer := "答案正文[[ref id=\"c2\"]]继续。"
qa := &fakeStreamingQA{
chunks: []string{"答案正文[[ref", " id=\"c2\"]]继续。"},
result: core.QAResult{Answer: rawAnswer},
}
reply := &fakeReply{}
card := &fakeStreamingCard{startID: "track-stream-reference-filter"}
h := newTestHandlerWithStreaming(t, qa, reply, card)
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
t.Fatal(err)
}
if len(card.updates) < 2 {
t.Fatalf("updates=%d, want at least one partial plus final", len(card.updates))
}
for i, update := range card.updates {
if strings.Contains(update.content, "[[ref") {
t.Fatalf("update[%d] leaked reference marker: %q", i, update.content)
}
}
if card.updates[0].content != "答案正文" {
t.Fatalf("first partial=%q, want dangling marker held back", card.updates[0].content)
}
final := card.updates[len(card.updates)-1]
if !final.isFinalize || final.content != "答案正文继续。" {
t.Fatalf("final content=%q finalize=%t", final.content, final.isFinalize)
}
if qa.result.Answer != rawAnswer {
t.Fatalf("raw QA answer mutated: %q", qa.result.Answer)
}
}
func TestHandleStreamingPublishesTableOnlyOnFinalUpdate(t *testing.T) {
answer := "说明\n\n| 检查项 | 操作要求 |\n| --- | --- |\n| 接地检查 | 上电前确认 |\n\n结论"
qa := &fakeStreamingQA{
chunks: []string{
"说明\n\n",
"| 检查项",
" | 操作要求 |\n",
"| --- | --",
"- |\n",
"| 接地检查 | 上电前确",
"认 |\n\n结论",
},
result: core.QAResult{Answer: answer},
}
reply := &fakeReply{}
card := &fakeStreamingCard{startID: "track-stream-table"}
h := newTestHandlerWithStreaming(t, qa, reply, card)
callCount := int64(0)
h.now = func() time.Time {
callCount++
return time.Unix(0, callCount*int64(time.Second))
}
h.cardUpdateInterval = time.Millisecond
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
t.Fatal(err)
}
var partials []string
for _, update := range card.updates {
if !update.isFinalize {
partials = append(partials, update.content)
}
}
wantPartials := []string{"说明\n\n"}
if !reflect.DeepEqual(partials, wantPartials) {
t.Fatalf("partial updates=%q, want only pre-table stream %q", partials, wantPartials)
}
final := card.updates[len(card.updates)-1]
if !final.isFinalize || final.content != answer {
t.Fatalf("final=%+v, want unchanged complete answer", final)
}
if qa.result.Answer != answer {
t.Fatalf("raw QA answer mutated: %q", qa.result.Answer)
}
}
@@ -0,0 +1,80 @@
package dingtalk
import (
"context"
"encoding/base64"
"errors"
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
)
type fakeImagePublisher struct {
published []byte
mime string
url string
err error
calls int
}
func (f *fakeImagePublisher) Publish(_ context.Context, data []byte, contentType string) (string, error) {
f.calls++
f.published = data
f.mime = contentType
return f.url, f.err
}
func TestDecodeImageDataURI(t *testing.T) {
raw := []byte("hello-image-bytes")
uri := "data:image/png;base64," + base64.StdEncoding.EncodeToString(raw)
decoded, err := decodeImageDataURI(uri)
if err != nil {
t.Fatalf("decodeImageDataURI: %v", err)
}
if string(decoded) != string(raw) {
t.Fatalf("decoded=%q, want %q", decoded, raw)
}
if _, err := decodeImageDataURI("data:image/png;base64"); err == nil {
t.Fatal("expected error for missing base64 payload")
}
if _, err := decodeImageDataURI("no comma at all"); err == nil {
t.Fatal("expected error for non-data URI")
}
}
func TestPublishQuestionImagePublishesDecodedBytes(t *testing.T) {
pub := &fakeImagePublisher{url: "https://weknora-api.example/i/001"}
raw := []byte{0x89, 0x50, 0x4e, 0x47}
image := core.ImageInput{
DataURI: "data:image/png;base64," + base64.StdEncoding.EncodeToString(raw),
MIMEType: "image/png",
}
h := &Handler{imagePublisher: pub}
got := h.publishQuestionImage(context.Background(), image)
if got != "https://weknora-api.example/i/001" {
t.Fatalf("url=%q, want published", got)
}
if pub.calls != 1 || pub.mime != "image/png" || string(pub.published) != string(raw) {
t.Fatalf("publisher calls=%d mime=%q bytes=%q", pub.calls, pub.mime, pub.published)
}
}
func TestPublishQuestionImageFailsClosedWithoutURL(t *testing.T) {
h := &Handler{imagePublisher: nil}
if got := h.publishQuestionImage(context.Background(), core.ImageInput{}); got != "" {
t.Fatalf("expected empty URL without publisher, got %q", got)
}
pub := &fakeImagePublisher{err: errors.New("oss down")}
h2 := &Handler{imagePublisher: pub}
image := core.ImageInput{
DataURI: "data:image/png;base64," + base64.StdEncoding.EncodeToString([]byte("x")),
MIMEType: "image/png",
}
if got := h2.publishQuestionImage(context.Background(), image); got != "" {
t.Fatalf("expected empty URL on publish error, got %q", got)
}
}
@@ -0,0 +1,114 @@
package dingtalk
import "strings"
type markdownStreamLine struct {
start int
text string
inFence bool
}
// stableMarkdownPrefix returns the cumulative content that is safe to render
// while Markdown is still streaming. Once a table is confirmed, the table and
// every following token stay hidden until the terminal card update.
func stableMarkdownPrefix(content string) string {
lines := splitMarkdownStreamLines(content)
for i := 0; i < len(lines); i++ {
if lines[i].inFence || !isTableHeaderCandidate(lines[i].text) {
continue
}
if i == len(lines)-1 {
return content[:lines[i].start]
}
if lines[i+1].inFence || !isTableDelimiterCandidate(lines[i+1].text) {
continue
}
if !isCompleteTableDelimiter(lines[i+1].text) {
return content[:lines[i].start]
}
// DingTalk only lays out Markdown tables reliably on the terminal
// isFinalize=true update. Keep the table and every following partial
// token hidden; the authoritative final update bypasses this helper.
return content[:lines[i].start]
}
return content
}
func splitMarkdownStreamLines(content string) []markdownStreamLine {
lines := make([]markdownStreamLine, 0, strings.Count(content, "\n")+1)
fence := ""
for start := 0; start < len(content); {
end := strings.IndexByte(content[start:], '\n')
if end < 0 {
end = len(content)
} else {
end += start + 1
}
text := strings.TrimSuffix(content[start:end], "\n")
text = strings.TrimSuffix(text, "\r")
trimmed := strings.TrimSpace(text)
line := markdownStreamLine{start: start, text: text, inFence: fence != ""}
if marker := markdownFenceMarker(trimmed); marker != "" {
line.inFence = true
if fence == "" {
fence = marker
} else if marker == fence {
fence = ""
}
}
lines = append(lines, line)
start = end
}
return lines
}
func markdownFenceMarker(trimmed string) string {
switch {
case strings.HasPrefix(trimmed, "```"):
return "```"
case strings.HasPrefix(trimmed, "~~~"):
return "~~~"
default:
return ""
}
}
func isTableHeaderCandidate(line string) bool {
return strings.HasPrefix(strings.TrimSpace(line), "|")
}
func isTableDelimiterCandidate(line string) bool {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "|") {
return false
}
for _, r := range trimmed {
switch r {
case '|', ':', '-', ' ', '\t':
default:
return false
}
}
return true
}
func isCompleteTableDelimiter(line string) bool {
trimmed := strings.TrimSpace(line)
trimmed = strings.TrimPrefix(trimmed, "|")
trimmed = strings.TrimSuffix(trimmed, "|")
cells := strings.Split(trimmed, "|")
if len(cells) < 2 {
return false
}
for _, cell := range cells {
cell = strings.TrimSpace(cell)
cell = strings.TrimPrefix(cell, ":")
cell = strings.TrimSuffix(cell, ":")
if len(cell) < 3 || strings.Trim(cell, "-") != "" {
return false
}
}
return true
}
@@ -0,0 +1,65 @@
package dingtalk
import (
"context"
"reflect"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/weknora"
)
type pacedTableEventQA struct {
fakeStreamingQA
events []weknora.StreamEvent
pause time.Duration
}
func (f *pacedTableEventQA) AskEvents(_ context.Context, req core.QARequest, observer weknora.StreamObserver) (core.QAResult, error) {
f.lastReq = req
for _, event := range f.events {
if observer != nil {
observer(event)
}
time.Sleep(f.pause)
}
return f.result, f.err
}
func TestHandleEventStreamingPublishesTableOnlyOnFinalUpdate(t *testing.T) {
answer := "说明\n\n| 检查项 | 操作要求 |\n| --- | --- |\n| 接地检查 | 上电前确认 |\n\n结论"
qa := &pacedTableEventQA{
fakeStreamingQA: fakeStreamingQA{result: core.QAResult{Answer: answer}},
events: []weknora.StreamEvent{
{ResponseType: "answer", FullAnswer: "说明\n\n"},
{ResponseType: "answer", FullAnswer: "说明\n\n| 检查项"},
{ResponseType: "answer", FullAnswer: "说明\n\n| 检查项 | 操作要求 |\n| --- | --"},
{ResponseType: "answer", FullAnswer: answer, Done: true},
},
pause: 20 * time.Millisecond,
}
reply := &fakeReply{}
card := &fakeStreamingCard{startID: "track-event-table"}
h := newTestHandlerWithStreaming(t, qa, reply, card)
h.cardUpdateInterval = time.Millisecond
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
t.Fatal(err)
}
var partials []string
for _, update := range card.updates {
if !update.isFinalize {
partials = append(partials, update.content)
}
}
wantPartials := []string{"说明\n\n"}
if !reflect.DeepEqual(partials, wantPartials) {
t.Fatalf("event partial updates=%q, want only pre-table stream %q", partials, wantPartials)
}
final := card.updates[len(card.updates)-1]
if !final.isFinalize || final.content != answer {
t.Fatalf("final=%+v, want unchanged complete answer", final)
}
}
@@ -0,0 +1,101 @@
package dingtalk
import "testing"
func TestStableMarkdownPrefixHoldsCompletedTableUntilFinalization(t *testing.T) {
content := "说明\n\n| 检查项 | 操作要求 |\n| --- | --- |\n| 接地检查 | 上电前确认 |\n\n表后正文仍在生成"
want := "说明\n\n"
if got := stableMarkdownPrefix(content); got != want {
t.Fatalf("stableMarkdownPrefix()=%q, want table and trailing stream held as %q", got, want)
}
}
func TestStableMarkdownPrefixHoldsBackTableUntilFinalization(t *testing.T) {
tests := []struct {
name string
content string
want string
}{
{
name: "split header",
content: "说明\n\n| 检查项",
want: "说明\n\n",
},
{
name: "header waiting for delimiter",
content: "说明\n\n| 检查项 | 操作要求 |\n",
want: "说明\n\n",
},
{
name: "partial delimiter",
content: "说明\n\n| 检查项 | 操作要求 |\n| --- | --",
want: "说明\n\n",
},
{
name: "partial data row",
content: "说明\n\n| 检查项 | 操作要求 |\n| --- | --- |\n| 接地检查 | 上电前确",
want: "说明\n\n",
},
{
name: "complete table without terminator",
content: "说明\n\n| 检查项 | 操作要求 |\n| --- | --- |\n| 接地检查 | 上电前确认 |\n",
want: "说明\n\n",
},
{
name: "complete table followed by blank line",
content: "说明\n\n| 检查项 | 操作要求 |\n| --- | --- |\n| 接地检查 | 上电前确认 |\n\n后文",
want: "说明\n\n",
},
{
name: "complete table followed by heading",
content: "| 检查项 | 操作要求 |\n| --- | --- |\n| 接地检查 | 上电前确认 |\n## 后续",
want: "",
},
{
name: "second consecutive table remains hidden",
content: "| A | B |\n| --- | --- |\n| 1 | 2 |\n\n| C | D",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := stableMarkdownPrefix(tt.content); got != tt.want {
t.Fatalf("stableMarkdownPrefix()=%q, want %q", got, tt.want)
}
})
}
}
func TestStableMarkdownPrefixLeavesNonTablesUnchanged(t *testing.T) {
tests := []struct {
name string
content string
}{
{
name: "ordinary text",
content: "普通段落继续流式显示。",
},
{
name: "ordinary inline pipe",
content: "状态使用 ready | running | done 表示。",
},
{
name: "pipe inside fenced code",
content: "```text\n| 这不是 | 表格 |\n| --- | --- |\n```",
},
{
name: "completed prose line starting with pipe",
content: "| 这是引用样式文本\n下一段普通文字",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := stableMarkdownPrefix(tt.content); got != tt.content {
t.Fatalf("stableMarkdownPrefix()=%q, want unchanged %q", got, tt.content)
}
})
}
}
+17 -16
View File
@@ -86,10 +86,13 @@ func BotCallbackFromSDK(data *chatbot.BotCallbackDataModel) (BotCallback, error)
Text: data.Text.Content,
}
if msgtype == "richtext" {
rtMsgtype, rtText, rtCode := parseRichText(data.Content)
rtMsgtype, rtText, rtCodes := parseRichText(data.Content)
cb.Msgtype = rtMsgtype
cb.Text = rtText
cb.DownloadCode = rtCode
cb.DownloadCodes = rtCodes
if len(rtCodes) > 0 {
cb.DownloadCode = rtCodes[0]
}
return cb, nil
}
if msgtype == "link" {
@@ -178,19 +181,19 @@ func safeHTTPURL(raw string) string {
// 3. Nested envelope: {"content":{"richText":[...]}}
//
// Picture items are identified by a "type" field (case-insensitive trimmed)
// equal to "picture". The first non-empty downloadCode is used; subsequent
// picture items are ignored. Text items are joined with newlines.
// equal to "picture". All non-empty download codes are collected in order;
// text items are joined with newlines.
//
// Returns: (msgtype, text, downloadCode). Msgtype is "picture" if any picture
// Returns: (msgtype, text, downloadCodes). Msgtype is "picture" if any picture
// item exists, "text" if only text items, "richtext" if malformed/empty.
func parseRichText(raw interface{}) (string, string, string) {
func parseRichText(raw interface{}) (string, string, []string) {
items := extractRichTextItems(raw)
if len(items) == 0 {
return "richtext", "", ""
return "richtext", "", nil
}
var textParts []string
var downloadCode string
var downloadCodes []string
hasPicture := false
for _, item := range items {
@@ -198,11 +201,9 @@ func parseRichText(raw interface{}) (string, string, string) {
if itemType == "picture" {
hasPicture = true
if downloadCode == "" {
code := strings.TrimSpace(item.DownloadCode)
if code != "" {
downloadCode = code
}
code := strings.TrimSpace(item.DownloadCode)
if code != "" {
downloadCodes = append(downloadCodes, code)
}
continue
}
@@ -216,12 +217,12 @@ func parseRichText(raw interface{}) (string, string, string) {
text := strings.Join(textParts, "\n")
if hasPicture {
return "picture", text, downloadCode
return "picture", text, downloadCodes
}
if text != "" {
return "text", text, ""
return "text", text, nil
}
return "richtext", "", ""
return "richtext", "", nil
}
// richTextItem is a single item in a richText array.
+6 -2
View File
@@ -2,6 +2,7 @@ package dingtalk
import (
"context"
"reflect"
"strings"
"sync"
"testing"
@@ -186,7 +187,7 @@ func TestBotCallbackFromSDK(t *testing.T) {
SessionWebhook: "https://oapi.dingtalk.com/robot/sendBySession?session=abc",
Text: " 你好 ",
}
if cb != want {
if !reflect.DeepEqual(cb, want) {
t.Fatalf("BotCallbackFromSDK() = %+v, want %+v", cb, want)
}
})
@@ -450,7 +451,7 @@ func TestBotCallbackFromSDKRichText(t *testing.T) {
}
})
t.Run("first non-empty downloadCode used, subsequent ignored", func(t *testing.T) {
t.Run("all picture downloadCodes collected in order", func(t *testing.T) {
t.Parallel()
data := &chatbot.BotCallbackDataModel{
ConversationType: "1",
@@ -468,6 +469,9 @@ func TestBotCallbackFromSDKRichText(t *testing.T) {
if cb.DownloadCode != "code-first" {
t.Fatalf("DownloadCode = %q, want first code-first", cb.DownloadCode)
}
if len(cb.DownloadCodes) != 2 || cb.DownloadCodes[0] != "code-first" || cb.DownloadCodes[1] != "code-second" {
t.Fatalf("DownloadCodes = %+v, want [code-first code-second]", cb.DownloadCodes)
}
})
t.Run("picture item missing downloadCode still picture", func(t *testing.T) {
+10 -6
View File
@@ -17,13 +17,15 @@ var ErrTurnInProgress = errors.New("turn is already processing")
// ImageInput contains the image data for a QA request. The DataURI is a
// full data: URI (e.g. "data:image/png;base64,...") and must never be
// persisted to the audit log.
// persisted to the audit log. PublishedURL is an optional stable HTTPS URL
// produced after the image is published to object storage for traceability.
type ImageInput struct {
DataURI string
MIMEType string
SizeBytes int64
Width int
Height int
DataURI string
MIMEType string
SizeBytes int64
Width int
Height int
PublishedURL string
}
// MediaAudit records metadata about media in a turn for audit purposes.
@@ -117,6 +119,8 @@ type Turn struct {
Answer string
References []Reference
Media []MediaAudit
QuestionImageURLs []string
KnowledgeSearched bool
Status string
ErrorMessage string
}
+56 -2
View File
@@ -54,8 +54,14 @@ func (e *Evaluator) Version() string {
// rules (in priority order):
// 1. Failed/processing turns → ErrTurnNotSucceeded
// 2. Answer contains an explicit "no answer" phrase → uncovered_explicit_no_answer
// 3. Zero references (nil or empty) → uncovered_no_references
// 4. Has references → covered_has_references
// 3. Image turns (image media present) are classified by whether the question
// actually triggered a knowledge-base search:
// - has references → covered_has_references
// - no references + knowledge searched → uncovered_no_references
// - no references + no knowledge search → covered_visual_recognition
// (pure visual recognition is not a coverage miss)
// 4. Text turns: zero references → uncovered_no_references
// 5. Has references → covered_has_references
//
// v1 never uses a score threshold. TopScore captures the highest reference score
// (0 if no references).
@@ -82,6 +88,44 @@ func (e *Evaluator) Evaluate(turn core.Turn) (Decision, error) {
}, nil
}
// Image turns are classified by whether knowledge search occurred.
if hasImageMedia(turn.Media) {
if len(turn.References) > 0 {
return Decision{
TurnID: turn.ID,
TenantKey: turn.TenantKey,
Channel: turn.Channel,
EvaluatorVersion: EvaluatorVersion,
Verdict: VerdictCovered,
ReasonCode: ReasonCoveredHasReferences,
ReferenceCount: len(turn.References),
TopScore: maxScore(turn.References),
}, nil
}
if turn.KnowledgeSearched {
return Decision{
TurnID: turn.ID,
TenantKey: turn.TenantKey,
Channel: turn.Channel,
EvaluatorVersion: EvaluatorVersion,
Verdict: VerdictUncovered,
ReasonCode: ReasonUncoveredNoReferences,
ReferenceCount: 0,
TopScore: 0,
}, nil
}
return Decision{
TurnID: turn.ID,
TenantKey: turn.TenantKey,
Channel: turn.Channel,
EvaluatorVersion: EvaluatorVersion,
Verdict: VerdictCovered,
ReasonCode: ReasonCoveredVisualRecognition,
ReferenceCount: 0,
TopScore: 0,
}, nil
}
// No references → uncovered.
if len(turn.References) == 0 {
return Decision{
@@ -109,6 +153,16 @@ func (e *Evaluator) Evaluate(turn core.Turn) (Decision, error) {
}, nil
}
// hasImageMedia reports whether the turn carried at least one image input.
func hasImageMedia(media []core.MediaAudit) bool {
for _, m := range media {
if strings.EqualFold(strings.TrimSpace(m.Type), "image") {
return true
}
}
return false
}
func isNoAnswer(answer string) bool {
lower := strings.ToLower(answer)
for _, phrase := range noAnswerPhrases {
+85
View File
@@ -0,0 +1,85 @@
package coverage
import (
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
)
func imageTurn(answer string, refs []core.Reference, searched bool) core.Turn {
return core.Turn{
ID: "img-turn",
Status: "succeeded",
MessageType: "image",
Answer: answer,
References: refs,
Media: []core.MediaAudit{{Type: "image", MIMEType: "image/jpeg"}},
KnowledgeSearched: searched,
}
}
func TestEvaluator_ImageVisualRecognitionNoSearch(t *testing.T) {
e := NewEvaluator()
turn := imageTurn("这张图片展示了一个手写的知识库数据处理与发布流程图。", nil, false)
d, err := e.Evaluate(turn)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if d.Verdict != VerdictCovered {
t.Fatalf("verdict=%s, want covered (visual recognition is not a coverage miss)", d.Verdict)
}
if d.ReasonCode != ReasonCoveredVisualRecognition {
t.Fatalf("reason_code=%s, want %s", d.ReasonCode, ReasonCoveredVisualRecognition)
}
}
func TestEvaluator_ImageSearchButNoReferencesIsUncovered(t *testing.T) {
e := NewEvaluator()
turn := imageTurn("根据图片中的错误信息,未能在知识库中找到对应的解决方案。", nil, true)
d, err := e.Evaluate(turn)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if d.Verdict != VerdictUncovered {
t.Fatalf("verdict=%s, want uncovered (image turn searched KB but got no refs)", d.Verdict)
}
if d.ReasonCode != ReasonUncoveredNoReferences {
t.Fatalf("reason_code=%s, want %s", d.ReasonCode, ReasonUncoveredNoReferences)
}
}
func TestEvaluator_ImageWithReferencesIsCovered(t *testing.T) {
e := NewEvaluator()
turn := imageTurn("根据知识库资料,这个是故障代码E5,请检查...", []core.Reference{{ID: "r1", Score: 0.9}}, true)
d, err := e.Evaluate(turn)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if d.Verdict != VerdictCovered {
t.Fatalf("verdict=%s, want covered", d.Verdict)
}
if d.ReasonCode != ReasonCoveredHasReferences {
t.Fatalf("reason_code=%s, want %s", d.ReasonCode, ReasonCoveredHasReferences)
}
}
func TestEvaluator_TextTurnNoReferencesStillUncovered(t *testing.T) {
e := NewEvaluator()
turn := core.Turn{
ID: "text-turn",
Status: "succeeded",
Answer: "这是一个正常的回答,但没有任何引用",
MessageType: "text",
References: []core.Reference{},
}
d, err := e.Evaluate(turn)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if d.Verdict != VerdictUncovered || d.ReasonCode != ReasonUncoveredNoReferences {
t.Fatalf("verdict=%s reason=%s, want uncovered_no_references (text turn unchanged)", d.Verdict, d.ReasonCode)
}
}
+1
View File
@@ -28,6 +28,7 @@ const (
ReasonCoveredHasReferences = "covered_has_references"
ReasonUncoveredNoReferences = "uncovered_no_references"
ReasonUncoveredExplicitNoAnswer = "uncovered_explicit_no_answer"
ReasonCoveredVisualRecognition = "covered_visual_recognition"
)
// EvaluatorVersion is the current version of the evaluator.
+23 -8
View File
@@ -4,8 +4,8 @@ import (
"bytes"
"context"
"errors"
"io"
"strings"
"time"
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss"
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss/credentials"
@@ -49,16 +49,31 @@ func (s *AliyunOSSStore) Put(ctx context.Context, key string, data []byte, conte
return nil
}
func (s *AliyunOSSStore) PresignGet(ctx context.Context, key string, ttl time.Duration) (string, error) {
if s == nil || s.client == nil || strings.TrimSpace(key) == "" || ttl <= 0 {
return "", errors.New("outbound image: invalid OSS presign request")
func (s *AliyunOSSStore) Get(ctx context.Context, key string, maxBytes int64) ([]byte, string, error) {
if s == nil || s.client == nil || strings.TrimSpace(key) == "" || maxBytes <= 0 {
return nil, "", errors.New("outbound image: invalid OSS get request")
}
result, err := s.client.Presign(ctx, &oss.GetObjectRequest{
result, err := s.client.GetObject(ctx, &oss.GetObjectRequest{
Bucket: oss.Ptr(s.bucket),
Key: oss.Ptr(key),
}, oss.PresignExpires(ttl))
})
if err != nil {
return "", errors.New("outbound image: OSS presign failed")
return nil, "", errors.New("outbound image: OSS get failed")
}
return result.URL, nil
defer result.Body.Close()
if result.ContentLength > maxBytes {
return nil, "", errors.New("outbound image: OSS object exceeds size limit")
}
data, err := io.ReadAll(io.LimitReader(result.Body, maxBytes+1))
if err != nil {
return nil, "", errors.New("outbound image: OSS read failed")
}
if int64(len(data)) > maxBytes {
return nil, "", errors.New("outbound image: OSS object exceeds size limit")
}
contentType := ""
if result.ContentType != nil {
contentType = strings.TrimSpace(*result.ContentType)
}
return data, contentType, nil
}
+58
View File
@@ -0,0 +1,58 @@
package outboundimage
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss"
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss/credentials"
)
func TestAliyunOSSStoreGetReturnsBoundedImageBytesAndMIME(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/private-bucket/dobotai/im-images/id" {
t.Fatalf("method=%s path=%s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Content-Length", "4")
_, _ = w.Write([]byte("PNG!"))
}))
defer server.Close()
config := oss.LoadDefaultConfig().
WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test-id", "test-secret", "")).
WithRegion("cn-test").
WithEndpoint(server.URL).
WithUsePathStyle(true)
store := &AliyunOSSStore{client: oss.NewClient(config), bucket: "private-bucket"}
data, contentType, err := store.Get(context.Background(), "dobotai/im-images/id", 4)
if err != nil {
t.Fatal(err)
}
if string(data) != "PNG!" || contentType != "image/png" {
t.Fatalf("data=%q contentType=%q", data, contentType)
}
}
func TestAliyunOSSStoreGetRejectsOversizedObjects(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Content-Length", "5")
_, _ = w.Write([]byte("PNG!!"))
}))
defer server.Close()
config := oss.LoadDefaultConfig().
WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test-id", "test-secret", "")).
WithRegion("cn-test").
WithEndpoint(server.URL).
WithUsePathStyle(true)
store := &AliyunOSSStore{client: oss.NewClient(config), bucket: "private-bucket"}
if _, _, err := store.Get(context.Background(), "dobotai/im-images/id", 4); err == nil {
t.Fatal("expected oversized object error")
}
}
+35 -26
View File
@@ -5,48 +5,57 @@ import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/url"
"path"
"regexp"
"strings"
"time"
)
type ObjectStore interface {
var stableImageIDRE = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`)
type ObjectWriter interface {
Put(ctx context.Context, key string, data []byte, contentType string) error
PresignGet(ctx context.Context, key string, ttl time.Duration) (string, error)
}
type HashPublisher struct {
store ObjectStore
prefix string
ttl time.Duration
type StableHashPublisher struct {
store ObjectWriter
prefix string
publicBaseURL string
}
func NewHashPublisher(store ObjectStore, prefix string, ttl time.Duration) *HashPublisher {
return &HashPublisher{store: store, prefix: strings.Trim(strings.TrimSpace(prefix), "/"), ttl: ttl}
}
func (p *HashPublisher) Publish(ctx context.Context, data []byte, contentType string) (string, error) {
if p == nil || p.store == nil || len(data) == 0 || p.prefix == "" || p.ttl <= 0 {
return "", errors.New("outbound image: invalid publisher configuration")
func NewStableHashPublisher(store ObjectWriter, prefix, publicBaseURL string) (*StableHashPublisher, error) {
prefix = strings.Trim(strings.TrimSpace(prefix), "/")
publicBaseURL = strings.TrimRight(strings.TrimSpace(publicBaseURL), "/")
parsed, err := url.Parse(publicBaseURL)
if store == nil || prefix == "" || err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
return nil, errors.New("outbound image: invalid stable publisher configuration")
}
extension := imageExtension(contentType)
if extension == "" {
return &StableHashPublisher{
store: store,
prefix: prefix,
publicBaseURL: publicBaseURL,
}, nil
}
func (p *StableHashPublisher) Publish(ctx context.Context, data []byte, contentType string) (string, error) {
if p == nil || p.store == nil || len(data) == 0 || p.prefix == "" || p.publicBaseURL == "" {
return "", errors.New("outbound image: invalid stable publisher configuration")
}
if imageExtension(contentType) == "" {
return "", errors.New("outbound image: unsupported image content type")
}
sum := sha256.Sum256(data)
digest := hex.EncodeToString(sum[:])
key := path.Join(p.prefix, digest[:2], digest+extension)
idBytes := sum[:16]
idBytes[6] = (idBytes[6] & 0x0f) | 0x50
idBytes[8] = (idBytes[8] & 0x3f) | 0x80
idHex := hex.EncodeToString(idBytes)
id := fmt.Sprintf("%s-%s-%s-%s-%s", idHex[:8], idHex[8:12], idHex[12:16], idHex[16:20], idHex[20:])
key := path.Join(p.prefix, id)
if err := p.store.Put(ctx, key, data, contentType); err != nil {
return "", errors.New("outbound image: publish object failed")
}
signedURL, err := p.store.PresignGet(ctx, key, p.ttl)
if err != nil {
return "", errors.New("outbound image: sign object URL failed")
}
if !strings.HasPrefix(strings.TrimSpace(signedURL), "https://") {
return "", errors.New("outbound image: signed object URL must use HTTPS")
}
return signedURL, nil
return p.publicBaseURL + "/" + id, nil
}
func imageExtension(contentType string) string {
+50 -15
View File
@@ -4,14 +4,12 @@ import (
"context"
"strings"
"testing"
"time"
)
type fakeObjectStore struct {
key string
data []byte
contentType string
ttl time.Duration
}
func (s *fakeObjectStore) Put(_ context.Context, key string, data []byte, contentType string) error {
@@ -21,14 +19,16 @@ func (s *fakeObjectStore) Put(_ context.Context, key string, data []byte, conten
return nil
}
func (s *fakeObjectStore) PresignGet(_ context.Context, key string, ttl time.Duration) (string, error) {
s.ttl = ttl
return "https://private-oss.example/" + key + "?signature=short", nil
}
func TestHashPublisherUsesDeterministicTemporaryKeyAndTwoHourHTTPSURL(t *testing.T) {
func TestStableHashPublisherUsesDeterministicUUIDURLWithoutPresigning(t *testing.T) {
store := &fakeObjectStore{}
publisher := NewHashPublisher(store, "dobotai/im-images", 2*time.Hour)
publisher, err := NewStableHashPublisher(
store,
"dobotai/im-images",
"https://weknora-api-test.dobot.cn/dingtalk/i",
)
if err != nil {
t.Fatal(err)
}
first, err := publisher.Publish(context.Background(), []byte("same-image"), "image/jpeg")
if err != nil {
@@ -43,13 +43,48 @@ func TestHashPublisherUsesDeterministicTemporaryKeyAndTwoHourHTTPSURL(t *testing
if first != second || firstKey != store.key {
t.Fatalf("publish is not deterministic: url1=%q url2=%q key1=%q key2=%q", first, second, firstKey, store.key)
}
if !strings.HasPrefix(store.key, "dobotai/im-images/") || !strings.HasSuffix(store.key, ".jpg") {
t.Fatalf("key=%q", store.key)
wantURLPrefix := "https://weknora-api-test.dobot.cn/dingtalk/i/"
if !strings.HasPrefix(first, wantURLPrefix) {
t.Fatalf("url=%q", first)
}
if strings.Contains(store.key, "same-image") {
t.Fatalf("key leaks content: %q", store.key)
id := strings.TrimPrefix(first, wantURLPrefix)
if !stableImageIDRE.MatchString(id) {
t.Fatalf("stable image id=%q", id)
}
if store.ttl != 2*time.Hour || !strings.HasPrefix(first, "https://") {
t.Fatalf("ttl=%s url=%q", store.ttl, first)
if store.key != "dobotai/im-images/"+id {
t.Fatalf("key=%q id=%q", store.key, id)
}
}
func TestStableHashPublisherRejectsUnsafeConfigurationAndNonImages(t *testing.T) {
store := &fakeObjectStore{}
for _, test := range []struct {
name string
store ObjectWriter
prefix string
baseURL string
}{
{name: "nil store", prefix: "dobotai/im-images", baseURL: "https://img.example/i"},
{name: "empty prefix", store: store, baseURL: "https://img.example/i"},
{name: "http base", store: store, prefix: "dobotai/im-images", baseURL: "http://img.example/i"},
{name: "query base", store: store, prefix: "dobotai/im-images", baseURL: "https://img.example/i?token=secret"},
} {
t.Run(test.name, func(t *testing.T) {
if _, err := NewStableHashPublisher(test.store, test.prefix, test.baseURL); err == nil {
t.Fatal("expected configuration error")
}
})
}
publisher, err := NewStableHashPublisher(store, "dobotai/im-images", "https://img.example/i")
if err != nil {
t.Fatal(err)
}
if _, err := publisher.Publish(context.Background(), nil, "image/png"); err == nil {
t.Fatal("expected empty image error")
}
if _, err := publisher.Publish(context.Background(), []byte("text"), "text/plain"); err == nil {
t.Fatal("expected non-image error")
}
}
+164 -17
View File
@@ -2,11 +2,29 @@ package outboundimage
import (
"context"
"net"
"net/url"
"path"
"regexp"
"strings"
)
var resourceMarkdownImageRE = regexp.MustCompile(`!\[[^\]]*\]\((resource://[0-9A-Za-z_-]+)\)`)
var (
resourceMarkdownImageRE = regexp.MustCompile(`!\[[^\]]*\]\((resource://[0-9A-Za-z_-]+)(?:\s+(?:"[^"\r\n]*"|'[^'\r\n]*'|\([^\)\r\n]*\)))?\)`)
resourceHTMLDoubleImageRE = regexp.MustCompile(`(?i)<img\b[^>]*\bsrc\s*=\s*"(resource://[0-9A-Za-z_-]+)"[^>]*>`)
resourceHTMLSingleImageRE = regexp.MustCompile(`(?i)<img\b[^>]*\bsrc\s*=\s*'(resource://[0-9A-Za-z_-]+)'[^>]*>`)
resourceHTMLUnsafeImageRE = regexp.MustCompile(`(?i)<img\b[^>]*resource://[0-9A-Za-z_-]+[^>]*>`)
markdownImageRE = regexp.MustCompile(`!\[[^\]]*\]\(([^\s)]+)(?:\s+(?:"[^"\r\n]*"|'[^'\r\n]*'|\([^\)\r\n]*\)))?\)`)
incompleteMarkdownImageRE = regexp.MustCompile(`!\[[^\]\r\n]*\]\([^\)\r\n]*$`)
htmlImageRE = regexp.MustCompile(`(?i)<img\b[^>]*>`)
incompleteHTMLImageRE = regexp.MustCompile(`(?i)<img\b[^>]*$`)
htmlDoubleSrcRE = regexp.MustCompile(`(?i)\bsrc\s*=\s*"([^"]+)"`)
htmlSingleSrcRE = regexp.MustCompile(`(?i)\bsrc\s*=\s*'([^']+)'`)
htmlDoubleAltRE = regexp.MustCompile(`(?i)\balt\s*=\s*"([^"]*)"`)
htmlSingleAltRE = regexp.MustCompile(`(?i)\balt\s*=\s*'([^']*)'`)
resourceReferenceRE = regexp.MustCompile(`resource://[0-9A-Za-z_-]+`)
dataImageReferenceRE = regexp.MustCompile(`(?i)data:image[^\s]*`)
)
type Fetcher interface {
Fetch(ctx context.Context, reference string) ([]byte, string, error)
@@ -28,21 +46,15 @@ func NewRewriter(fetcher Fetcher, publisher Publisher, maxImages int) *Rewriter
func (r *Rewriter) Rewrite(ctx context.Context, markdown string) string {
if r == nil || r.fetcher == nil || r.publisher == nil || r.maxImages <= 0 {
return resourceMarkdownImageRE.ReplaceAllString(markdown, "")
markdown = normalizePublicHTMLImages(stripResourceImages(markdown))
markdown = resourceReferenceRE.ReplaceAllString(resourceHTMLUnsafeImageRE.ReplaceAllString(markdown, ""), "")
return sanitizePublicMarkdownImages(markdown)
}
urls := map[string]string{}
attempted := 0
return resourceMarkdownImageRE.ReplaceAllStringFunc(markdown, func(image string) string {
match := resourceMarkdownImageRE.FindStringSubmatch(image)
if len(match) != 2 {
return ""
}
reference := match[1]
if signedURL, ok := urls[reference]; ok {
if signedURL == "" {
return ""
}
return strings.Replace(image, reference, signedURL, 1)
resolve := func(reference string) string {
if publishedURL, ok := urls[reference]; ok {
return publishedURL
}
if attempted >= r.maxImages {
urls[reference] = ""
@@ -54,12 +66,147 @@ func (r *Rewriter) Rewrite(ctx context.Context, markdown string) string {
urls[reference] = ""
return ""
}
signedURL, err := r.publisher.Publish(ctx, data, contentType)
if err != nil || !strings.HasPrefix(signedURL, "https://") {
publishedURL, err := r.publisher.Publish(ctx, data, contentType)
if err != nil || !strings.HasPrefix(publishedURL, "https://") {
urls[reference] = ""
return ""
}
urls[reference] = signedURL
return strings.Replace(image, reference, signedURL, 1)
urls[reference] = publishedURL
return publishedURL
}
markdown = resourceMarkdownImageRE.ReplaceAllStringFunc(markdown, func(image string) string {
match := resourceMarkdownImageRE.FindStringSubmatch(image)
if len(match) != 2 {
return ""
}
reference := match[1]
publishedURL := resolve(reference)
if publishedURL == "" {
return ""
}
return strings.Replace(image, reference, publishedURL, 1)
})
markdown = rewriteHTMLImages(markdown, resourceHTMLDoubleImageRE, resolve)
markdown = rewriteHTMLImages(markdown, resourceHTMLSingleImageRE, resolve)
markdown = normalizePublicHTMLImages(markdown)
markdown = resourceReferenceRE.ReplaceAllString(resourceHTMLUnsafeImageRE.ReplaceAllString(markdown, ""), "")
return sanitizePublicMarkdownImages(markdown)
}
func sanitizePublicMarkdownImages(markdown string) string {
markdown = markdownImageRE.ReplaceAllStringFunc(markdown, func(image string) string {
match := markdownImageRE.FindStringSubmatch(image)
if len(match) != 2 || !isSafePublicImageURL(match[1]) {
return ""
}
if isKnownDocumentURL(match[1]) {
return strings.TrimPrefix(image, "!")
}
return image
})
markdown = incompleteMarkdownImageRE.ReplaceAllString(markdown, "")
markdown = incompleteHTMLImageRE.ReplaceAllString(markdown, "")
return dataImageReferenceRE.ReplaceAllString(markdown, "")
}
func isKnownDocumentURL(raw string) bool {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return false
}
switch strings.ToLower(path.Ext(u.Path)) {
case ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
".csv", ".txt", ".zip", ".rar", ".7z":
return true
default:
return false
}
}
func rewriteHTMLImages(content string, pattern *regexp.Regexp, resolve func(string) string) string {
return pattern.ReplaceAllStringFunc(content, func(image string) string {
match := pattern.FindStringSubmatch(image)
if len(match) != 2 {
return ""
}
publishedURL := resolve(match[1])
if publishedURL == "" {
return ""
}
alt := "图片"
for _, pattern := range []*regexp.Regexp{htmlDoubleAltRE, htmlSingleAltRE} {
if altMatch := pattern.FindStringSubmatch(image); len(altMatch) == 2 && strings.TrimSpace(altMatch[1]) != "" {
alt = strings.ReplaceAll(strings.TrimSpace(altMatch[1]), "]", `\]`)
break
}
}
return "![" + alt + "](" + publishedURL + ")"
})
}
func normalizePublicHTMLImages(content string) string {
return htmlImageRE.ReplaceAllStringFunc(content, func(image string) string {
source := ""
for _, pattern := range []*regexp.Regexp{htmlDoubleSrcRE, htmlSingleSrcRE} {
if match := pattern.FindStringSubmatch(image); len(match) == 2 {
source = strings.TrimSpace(match[1])
break
}
}
if !isSafePublicImageURL(source) {
return ""
}
return "![" + imageAlt(image) + "](" + source + ")"
})
}
func imageAlt(image string) string {
for _, pattern := range []*regexp.Regexp{htmlDoubleAltRE, htmlSingleAltRE} {
if match := pattern.FindStringSubmatch(image); len(match) == 2 && strings.TrimSpace(match[1]) != "" {
return strings.ReplaceAll(strings.TrimSpace(match[1]), "]", `\]`)
}
}
return "图片"
}
func isSafePublicImageURL(raw string) bool {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil {
return false
}
hostname := strings.TrimSuffix(strings.ToLower(u.Hostname()), ".")
if hostname == "" || hostname == "localhost" || strings.HasSuffix(hostname, ".localhost") || strings.HasSuffix(hostname, ".local") || strings.HasSuffix(hostname, ".internal") {
return false
}
if port := u.Port(); port != "" && port != "443" {
return false
}
if ip := net.ParseIP(hostname); ip != nil {
if !ip.IsGlobalUnicast() || ip.IsPrivate() {
return false
}
} else if !strings.Contains(hostname, ".") {
return false
}
for key := range u.Query() {
normalized := strings.ToLower(key)
if strings.Contains(normalized, "signature") || normalized == "ossaccesskeyid" || strings.Contains(normalized, "credential") {
return false
}
}
return true
}
func stripResourceImages(content string) string {
content = resourceMarkdownImageRE.ReplaceAllString(content, "")
content = resourceHTMLDoubleImageRE.ReplaceAllString(content, "")
content = resourceHTMLSingleImageRE.ReplaceAllString(content, "")
return resourceHTMLUnsafeImageRE.ReplaceAllString(content, "")
}
func (r *Rewriter) SanitizePartial(content string) string {
content = resourceReferenceRE.ReplaceAllString(stripResourceImages(content), "")
content = normalizePublicHTMLImages(content)
return sanitizePublicMarkdownImages(content)
}
+299
View File
@@ -76,3 +76,302 @@ func TestRewriteMarkdownResourceImagesDropsFailuresAndImagesBeyondLimit(t *testi
t.Fatalf("publisher calls=%d, want 2", publisher.calls)
}
}
func TestRewriteHTMLResourceImagesAsDingTalkMarkdown(t *testing.T) {
resource := "resource://AbCdEfGhIjKlMnOpQrStUv"
fetcher := &fakeFetcher{}
publisher := &fakePublisher{}
r := NewRewriter(fetcher, publisher, 5)
input := "前文\n<img alt=\"步骤图\" width=\"100\" src=\"" + resource + "\"/>\n" +
"重复\n<img src='" + resource + "' class='diagram'>\n后文"
got := r.Rewrite(context.Background(), input)
if strings.Contains(got, "resource://") || strings.Contains(strings.ToLower(got), "<img") {
t.Fatalf("internal HTML image remained: %s", got)
}
if !strings.Contains(got, "![步骤图](https://img.example/1.png?sig=short)") {
t.Fatalf("alt image not normalized: %s", got)
}
if !strings.Contains(got, "![图片](https://img.example/1.png?sig=short)") {
t.Fatalf("default-alt image not normalized: %s", got)
}
if fetcher.calls[resource] != 1 || publisher.calls != 1 {
t.Fatalf("calls fetch=%v publish=%d", fetcher.calls, publisher.calls)
}
}
func TestSanitizePartialRemovesInternalImageNodesAndBareReferences(t *testing.T) {
r := NewRewriter(&fakeFetcher{}, &fakePublisher{}, 5)
input := "正文\n![图](resource://AbCdEfGhIjKlMnOpQrStUv)\n" +
"<img alt='图' src='resource://SecondRefAbCdEfGhIjKlMn'>\n" +
"内部引用 resource://BareRefAbCdEfGhIjKlMn"
got := r.SanitizePartial(input)
if strings.Contains(got, "resource://") || strings.Contains(strings.ToLower(got), "<img") {
t.Fatalf("partial content leaked an internal image: %s", got)
}
if !strings.Contains(got, "正文") || !strings.Contains(got, "内部引用") {
t.Fatalf("partial text was lost: %s", got)
}
}
func TestRewriteFailsClosedOnUnquotedHTMLAndBareResourceReferences(t *testing.T) {
fetcher := &fakeFetcher{}
r := NewRewriter(fetcher, &fakePublisher{}, 5)
input := "正文\n<img src=resource://UnsafeRefAbCdEfGhIjKlMn>\n内部引用 resource://BareRefAbCdEfGhIjKlMn"
got := r.Rewrite(context.Background(), input)
if strings.Contains(got, "resource://") || strings.Contains(strings.ToLower(got), "<img") {
t.Fatalf("final content leaked an internal reference: %s", got)
}
if !strings.Contains(got, "正文") || !strings.Contains(got, "内部引用") {
t.Fatalf("final text was lost: %s", got)
}
if len(fetcher.calls) != 0 {
t.Fatalf("unsafe references must not be fetched: %v", fetcher.calls)
}
}
func TestRewriteRemovesUnsafeImageLocatorsAndSignedURLs(t *testing.T) {
r := NewRewriter(&fakeFetcher{}, &fakePublisher{}, 5)
input := strings.Join([]string{
"正文",
"![local](local://bucket/a.png)",
"![oss](oss://private/a.png)",
"![private](https://127.0.0.1/a.png)",
"![signed](https://bucket.oss-cn-shenzhen.aliyuncs.com/a.png?x-oss-signature=secret)",
"![public](https://example.com/a.png)",
}, "\n")
got := r.Rewrite(context.Background(), input)
for _, forbidden := range []string{"local://", "oss://", "127.0.0.1", "x-oss-signature"} {
if strings.Contains(strings.ToLower(got), forbidden) {
t.Fatalf("final content retained %q: %s", forbidden, got)
}
}
if !strings.Contains(got, "![public](https://example.com/a.png)") {
t.Fatalf("public HTTPS image was removed: %s", got)
}
}
func TestRewriteNormalizesPublicHTMLImageAsDingTalkMarkdown(t *testing.T) {
r := NewRewriter(&fakeFetcher{}, &fakePublisher{}, 5)
input := `<img class="diagram" src="https://example.com/a.png" alt="公网图" width="100">`
got := r.Rewrite(context.Background(), input)
if got != "![公网图](https://example.com/a.png)" {
t.Fatalf("normalized public HTML image=%q", got)
}
}
func TestRewriteRemovesDataImageWithOptionalTitle(t *testing.T) {
r := NewRewriter(&fakeFetcher{}, &fakePublisher{}, 5)
input := "正文\n![占位图](data:image/png;base64,iVBORw0KGgo= \"\")\n后文"
got := r.Rewrite(context.Background(), input)
if strings.Contains(got, "data:image") || strings.Contains(got, "占位图") {
t.Fatalf("final content retained a data image: %s", got)
}
if !strings.Contains(got, "正文") || !strings.Contains(got, "后文") {
t.Fatalf("surrounding text was lost: %s", got)
}
}
func TestSanitizePartialRemovesDataImageWithOptionalTitle(t *testing.T) {
r := NewRewriter(&fakeFetcher{}, &fakePublisher{}, 5)
input := "正文\n![占位图](data:image/png;base64,iVBORw0KGgo= \"\")\n后文"
got := r.SanitizePartial(input)
if strings.Contains(got, "data:image") || strings.Contains(got, "占位图") {
t.Fatalf("partial content retained a data image: %s", got)
}
if !strings.Contains(got, "正文") || !strings.Contains(got, "后文") {
t.Fatalf("surrounding text was lost: %s", got)
}
}
func TestRewritePublishesResourceImageWithOptionalTitle(t *testing.T) {
resource := "resource://AbCdEfGhIjKlMnOpQrStUv"
fetcher := &fakeFetcher{}
publisher := &fakePublisher{}
r := NewRewriter(fetcher, publisher, 5)
input := "![步骤图](" + resource + " \"安装说明\")"
got := r.Rewrite(context.Background(), input)
if got != "![步骤图](https://img.example/1.png?sig=short \"安装说明\")" {
t.Fatalf("resource image with title=%q", got)
}
if fetcher.calls[resource] != 1 || publisher.calls != 1 {
t.Fatalf("calls fetch=%v publish=%d", fetcher.calls, publisher.calls)
}
}
func TestSanitizePartialRemovesDataHTMLImage(t *testing.T) {
r := NewRewriter(&fakeFetcher{}, &fakePublisher{}, 5)
input := `正文<img alt="占位图" src="data:image/png;base64,iVBORw0KGgo=">后文`
got := r.SanitizePartial(input)
if strings.Contains(got, "data:image") || strings.Contains(strings.ToLower(got), "<img") {
t.Fatalf("partial content retained a data HTML image: %s", got)
}
if !strings.Contains(got, "正文") || !strings.Contains(got, "后文") {
t.Fatalf("surrounding text was lost: %s", got)
}
}
func TestRewritePreservesPublicHTTPSImageWithOptionalTitle(t *testing.T) {
r := NewRewriter(&fakeFetcher{}, &fakePublisher{}, 5)
input := `![官网](https://example.com/a.png "产品图")`
if got := r.Rewrite(context.Background(), input); got != input {
t.Fatalf("final public HTTPS image=%q", got)
}
}
func TestSanitizePartialPreservesPublicHTTPSImageWithOptionalTitle(t *testing.T) {
r := NewRewriter(&fakeFetcher{}, &fakePublisher{}, 5)
input := `![官网](https://example.com/a.png "产品图")`
if got := r.SanitizePartial(input); got != input {
t.Fatalf("partial public HTTPS image=%q", got)
}
}
func TestSanitizePartialRemovesIncompleteDataMarkdownImage(t *testing.T) {
r := NewRewriter(&fakeFetcher{}, &fakePublisher{}, 5)
input := "正文\n![占位图](data:image/png;base64,iVBORw0K"
got := r.SanitizePartial(input)
if strings.Contains(got, "data:image") || strings.Contains(got, "占位图") {
t.Fatalf("partial content retained an incomplete data image: %s", got)
}
if !strings.Contains(got, "正文") {
t.Fatalf("surrounding text was lost: %s", got)
}
}
func TestSanitizePartialRemovesIncompleteDataHTMLImage(t *testing.T) {
r := NewRewriter(&fakeFetcher{}, &fakePublisher{}, 5)
input := `正文<img alt="占位图" src="data:image/png;base64,iVBORw0K`
got := r.SanitizePartial(input)
if strings.Contains(got, "data:image") || strings.Contains(strings.ToLower(got), "<img") {
t.Fatalf("partial content retained an incomplete data HTML image: %s", got)
}
if !strings.Contains(got, "正文") {
t.Fatalf("surrounding text was lost: %s", got)
}
}
func TestRewriteRemovesBareDataImageReference(t *testing.T) {
r := NewRewriter(&fakeFetcher{}, &fakePublisher{}, 5)
input := "正文 data:image/png;base64,iVBORw0KGgo= 后文"
got := r.Rewrite(context.Background(), input)
if strings.Contains(got, "data:image") {
t.Fatalf("final content retained a bare data image: %s", got)
}
if !strings.Contains(got, "正文") || !strings.Contains(got, "后文") {
t.Fatalf("surrounding text was lost: %s", got)
}
}
func TestRewriteRemovesIncompleteDataMarkdownImage(t *testing.T) {
r := NewRewriter(&fakeFetcher{}, &fakePublisher{}, 5)
input := "正文\n![占位图](data:image/png;base64,iVBORw0K"
got := r.Rewrite(context.Background(), input)
if strings.Contains(got, "data:image") || strings.Contains(got, "占位图") || strings.Contains(got, "![") {
t.Fatalf("final content retained a broken data image marker: %s", got)
}
if !strings.Contains(got, "正文") {
t.Fatalf("surrounding text was lost: %s", got)
}
}
func TestRewriteWithoutPublisherRemovesDataImage(t *testing.T) {
r := NewRewriter(nil, nil, 0)
input := `正文 ![占位图](data:image/png;base64,iVBORw0KGgo= "") 后文`
got := r.Rewrite(context.Background(), input)
if strings.Contains(got, "data:image") || strings.Contains(got, "占位图") {
t.Fatalf("fail-closed rewrite retained a data image: %s", got)
}
if !strings.Contains(got, "正文") || !strings.Contains(got, "后文") {
t.Fatalf("surrounding text was lost: %s", got)
}
}
func TestRewriteDowngradesPDFImageToDocumentLink(t *testing.T) {
r := NewRewriter(&fakeFetcher{}, &fakePublisher{}, 5)
input := `![机械臂示教与应用课件](https://sales-edu.example/06%20robot-teaching.PDF?tenant=demo "课件")`
want := `[机械臂示教与应用课件](https://sales-edu.example/06%20robot-teaching.PDF?tenant=demo "课件")`
if got := r.Rewrite(context.Background(), input); got != want {
t.Fatalf("PDF image rewrite=%q, want %q", got, want)
}
}
func TestFinalAndPartialDowngradeKnownDocumentImages(t *testing.T) {
r := NewRewriter(&fakeFetcher{}, &fakePublisher{}, 5)
for _, extension := range []string{
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
".csv", ".txt", ".zip", ".rar", ".7z",
} {
t.Run(extension, func(t *testing.T) {
url := "https://docs.example/manual" + extension + "?tenant=demo"
input := "![资料](" + url + ")"
want := "[资料](" + url + ")"
if got := r.Rewrite(context.Background(), input); got != want {
t.Fatalf("final rewrite=%q, want %q", got, want)
}
if got := r.SanitizePartial(input); got != want {
t.Fatalf("partial rewrite=%q, want %q", got, want)
}
})
}
html := `<img src="https://docs.example/manual.xlsx" alt="参数表">`
if got, want := r.Rewrite(context.Background(), html), `[参数表](https://docs.example/manual.xlsx)`; got != want {
t.Fatalf("HTML document image rewrite=%q, want %q", got, want)
}
if got, want := r.SanitizePartial(html), `[参数表](https://docs.example/manual.xlsx)`; got != want {
t.Fatalf("partial HTML document image rewrite=%q, want %q", got, want)
}
}
func TestDocumentImageDowngradePreservesImageAndLinkContracts(t *testing.T) {
r := NewRewriter(&fakeFetcher{}, &fakePublisher{}, 5)
input := strings.Join([]string{
`![稳定图片](https://weknora-api.example/i/006d001d-7d0b-542e-b5ab-6ee7d7cdce54)`,
`[已有PDF链接](https://docs.example/manual.pdf)`,
`![危险PDF](https://127.0.0.1/manual.pdf)`,
}, "\n")
got := r.Rewrite(context.Background(), input)
if !strings.Contains(got, `![稳定图片](https://weknora-api.example/i/006d001d-7d0b-542e-b5ab-6ee7d7cdce54)`) {
t.Fatalf("extensionless stable image changed: %s", got)
}
if !strings.Contains(got, `[已有PDF链接](https://docs.example/manual.pdf)`) {
t.Fatalf("existing document link changed: %s", got)
}
if strings.Contains(got, "127.0.0.1") || strings.Contains(got, "危险PDF") {
t.Fatalf("unsafe document image became a link: %s", got)
}
}
+48
View File
@@ -0,0 +1,48 @@
package outboundimage
import (
"context"
"errors"
"net/http"
"path"
"strconv"
"strings"
)
type ObjectReader interface {
Get(ctx context.Context, key string, maxBytes int64) ([]byte, string, error)
}
func NewStableImageHandler(reader ObjectReader, objectPrefix, routePrefix string, maxBytes int64) (http.Handler, error) {
objectPrefix = strings.Trim(strings.TrimSpace(objectPrefix), "/")
routePrefix = "/" + strings.Trim(strings.TrimSpace(routePrefix), "/") + "/"
if reader == nil || objectPrefix == "" || maxBytes <= 0 {
return nil, errors.New("outbound image: invalid stable image handler configuration")
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return
}
id := strings.TrimPrefix(r.URL.Path, routePrefix)
if r.URL.Path == id || strings.Contains(id, "/") || !stableImageIDRE.MatchString(id) {
http.NotFound(w, r)
return
}
data, contentType, err := reader.Get(r.Context(), path.Join(objectPrefix, id), maxBytes)
if err != nil || len(data) == 0 || imageExtension(contentType) == "" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", strings.TrimSpace(strings.Split(contentType, ";")[0]))
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
w.Header().Set("Cache-Control", "public, max-age=86400, immutable")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(http.StatusOK)
if r.Method == http.MethodHead {
return
}
_, _ = w.Write(data)
}), nil
}
@@ -0,0 +1,91 @@
package outboundimage
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
type fakeImageReader struct {
key string
maxBytes int64
data []byte
contentType string
err error
}
func (r *fakeImageReader) Get(_ context.Context, key string, maxBytes int64) ([]byte, string, error) {
r.key = key
r.maxBytes = maxBytes
return append([]byte(nil), r.data...), r.contentType, r.err
}
func TestStableImageHandlerReturnsImageBytesDirectly(t *testing.T) {
const id = "ca13f02b-58b6-5f8f-a80d-b41873a97425"
reader := &fakeImageReader{data: []byte("JPEG"), contentType: "image/jpeg"}
handler, err := NewStableImageHandler(reader, "dobotai/im-images", "/dingtalk/i/", 10<<20)
if err != nil {
t.Fatal(err)
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/dingtalk/i/"+id, nil))
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d", recorder.Code)
}
if recorder.Header().Get("Location") != "" {
t.Fatalf("stable image endpoint must not redirect: location=%q", recorder.Header().Get("Location"))
}
if recorder.Header().Get("Content-Type") != "image/jpeg" {
t.Fatalf("content-type=%q", recorder.Header().Get("Content-Type"))
}
if recorder.Body.String() != "JPEG" {
t.Fatalf("body=%q", recorder.Body.String())
}
if reader.key != "dobotai/im-images/"+id || reader.maxBytes != 10<<20 {
t.Fatalf("key=%q maxBytes=%d", reader.key, reader.maxBytes)
}
}
func TestStableImageHandlerAllowsHeadWithoutBody(t *testing.T) {
const id = "ca13f02b-58b6-5f8f-a80d-b41873a97425"
reader := &fakeImageReader{data: []byte("JPEG"), contentType: "image/jpeg"}
handler, err := NewStableImageHandler(reader, "dobotai/im-images", "/dingtalk/i/", 10<<20)
if err != nil {
t.Fatal(err)
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodHead, "/dingtalk/i/"+id, nil))
if recorder.Code != http.StatusOK || recorder.Body.Len() != 0 {
t.Fatalf("status=%d body=%q", recorder.Code, recorder.Body.String())
}
if recorder.Header().Get("Content-Length") != "4" {
t.Fatalf("content-length=%q", recorder.Header().Get("Content-Length"))
}
}
func TestStableImageHandlerRejectsPostAndInvalidIDsWithoutReadingOSS(t *testing.T) {
reader := &fakeImageReader{data: []byte("JPEG"), contentType: "image/jpeg"}
handler, err := NewStableImageHandler(reader, "dobotai/im-images", "/dingtalk/i/", 10<<20)
if err != nil {
t.Fatal(err)
}
post := httptest.NewRecorder()
handler.ServeHTTP(post, httptest.NewRequest(http.MethodPost, "/dingtalk/i/ca13f02b-58b6-5f8f-a80d-b41873a97425", nil))
if post.Code != http.StatusMethodNotAllowed {
t.Fatalf("post status=%d", post.Code)
}
invalid := httptest.NewRecorder()
handler.ServeHTTP(invalid, httptest.NewRequest(http.MethodGet, "/dingtalk/i/../../secret", nil))
if invalid.Code != http.StatusNotFound {
t.Fatalf("invalid status=%d", invalid.Code)
}
if reader.key != "" {
t.Fatalf("invalid requests reached OSS key=%q", reader.key)
}
}
+21 -3
View File
@@ -129,11 +129,15 @@ func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserve
}
media := make([]core.MediaAudit, 0, len(request.Images))
questionImageURLs := make([]string, 0, len(request.Images))
for _, image := range request.Images {
media = append(media, core.MediaAudit{
Type: "image", MIMEType: image.MIMEType, SizeBytes: image.SizeBytes,
Width: image.Width, Height: image.Height,
})
if url := strings.TrimSpace(image.PublishedURL); url != "" {
questionImageURLs = append(questionImageURLs, url)
}
}
messageType := normalizeMessageType(request)
if messageType == "" {
@@ -161,6 +165,7 @@ func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserve
RequestMessageID: newID(),
Question: request.Query,
Media: media,
QuestionImageURLs: questionImageURLs,
Status: "processing",
}
stored, created, err := s.repository.BeginTurn(ctx, turn)
@@ -214,20 +219,32 @@ func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserve
Query: query, AgentID: agentID, AgentEnabled: true, Channel: "im", EnableMemory: true,
Images: images,
}
// Track whether this turn actually triggered a knowledge-base search by
// observing knowledge_search tool-call events. The flag feeds coverage
// classification (image turns) and is persisted with the turn.
var knowledgeSearched bool
effectiveEventObserver := weknora.StreamObserver(func(e weknora.StreamEvent) {
if strings.EqualFold(strings.TrimSpace(e.ToolName), "knowledge_search") {
knowledgeSearched = true
}
if eventObserver != nil {
eventObserver(e)
}
})
runChat := func(targetSessionID string) (weknora.ChatResult, error) {
var result weknora.ChatResult
var runErr error
if eventObserver != nil {
// Prefer AgentChatEvents; fallback to AgentChatStream adaptation; then AgentChat.
if eventStream, ok := s.weknora.(eventStreamingWeKnora); ok {
result, runErr = eventStream.AgentChatEvents(ctx, targetSessionID, chatRequest, eventObserver)
result, runErr = eventStream.AgentChatEvents(ctx, targetSessionID, chatRequest, effectiveEventObserver)
} else if streaming, ok := s.weknora.(streamingWeKnora); ok {
// Adapt AgentChatStream answer chunks to StreamEvent
result, runErr = streaming.AgentChatStream(ctx, targetSessionID, chatRequest, func(full string) {
if answerObserver != nil {
answerObserver(full)
}
eventObserver(weknora.StreamEvent{
effectiveEventObserver(weknora.StreamEvent{
ResponseType: "answer",
FullAnswer: full,
})
@@ -238,7 +255,7 @@ func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserve
if answerObserver != nil {
answerObserver(result.Answer)
}
eventObserver(weknora.StreamEvent{
effectiveEventObserver(weknora.StreamEvent{
ResponseType: "answer",
FullAnswer: result.Answer,
Done: true,
@@ -284,6 +301,7 @@ func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserve
}
turn.Status, turn.Answer, turn.References = "succeeded", chat.Answer, chat.References
turn.WeKnoraRequestID = chat.RequestID
turn.KnowledgeSearched = knowledgeSearched
turn.AnswerMessageID = newID()
if err := s.repository.FinishTurn(ctx, turn); err != nil {
return core.QAResult{}, fmt.Errorf("finish turn: %w", err)
+43 -1
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"reflect"
"strings"
"time"
)
@@ -38,6 +39,15 @@ type RecordInserter interface {
InsertRecord(ctx context.Context, row Row) (string, error)
}
// AnswerRewriter optionally transforms the answer before it is written to the
// question table, applying the same outbound conversion used for DingTalk
// delivery (resource images to stable HTTPS, document links, anchor cleanup).
// When nil or when Rewrite returns an error, the original answer is written so
// the record is never lost.
type AnswerRewriter interface {
Rewrite(ctx context.Context, content string) (string, error)
}
// DispatcherStats holds the outcome counters for a single RunOnce invocation.
type DispatcherStats struct {
Claimed int
@@ -51,6 +61,7 @@ type DispatcherOptions struct {
MaxAttempts int
ClaimLease time.Duration
SendTimeout time.Duration
Rewriter AnswerRewriter
}
// Dispatcher runs the background dispatch loop that claims pending exports
@@ -58,6 +69,7 @@ type DispatcherOptions struct {
type Dispatcher struct {
repo ExportRepository
inserter RecordInserter
rewriter AnswerRewriter
batch int
maxAttempts int
@@ -96,8 +108,10 @@ func NewDispatcher(repo ExportRepository, inserter RecordInserter, opts *Dispatc
maxAttempts := 5
claimLease := 5 * time.Minute
sendTimeout := 10 * time.Second
var rewriter AnswerRewriter
if opts != nil {
rewriter = opts.Rewriter
if opts.Batch < 0 {
return nil, fmt.Errorf("questiontable dispatcher: batch must be >= 0, got %d", opts.Batch)
}
@@ -130,6 +144,7 @@ func NewDispatcher(repo ExportRepository, inserter RecordInserter, opts *Dispatc
return &Dispatcher{
repo: repo,
inserter: inserter,
rewriter: rewriter,
batch: batch,
maxAttempts: maxAttempts,
claimLease: claimLease,
@@ -165,8 +180,18 @@ func (d *Dispatcher) RunOnce(ctx context.Context) (DispatcherStats, error) {
return stats, fmt.Errorf("questiontable dispatcher: context cancelled during dispatch: %w", err)
}
row := item.Row
if err := d.rewriteAnswer(ctx, &row); err != nil {
// Rewriting is best-effort: the original answer is written so the
// question-table record is never lost. Only the row is skipped when
// the parent context is cancelled.
if ctx.Err() != nil {
return stats, fmt.Errorf("questiontable dispatcher: context cancelled during rewrite: %w", ctx.Err())
}
}
sendCtx, cancel := context.WithTimeout(ctx, d.sendTimeout)
recordID, insertErr := d.inserter.InsertRecord(sendCtx, item.Row)
recordID, insertErr := d.inserter.InsertRecord(sendCtx, row)
cancel()
if insertErr != nil {
@@ -189,3 +214,20 @@ func (d *Dispatcher) RunOnce(ctx context.Context) (DispatcherStats, error) {
return stats, nil
}
// rewriteAnswer transforms the outbound answer when an AnswerRewriter is
// configured. A nil rewriter or a rewriter error leaves the original answer
// intact; the row is never skipped for a rewriter error.
func (d *Dispatcher) rewriteAnswer(ctx context.Context, row *Row) error {
if d.rewriter == nil {
return nil
}
rewritten, err := d.rewriter.Rewrite(ctx, row.Answer)
if err != nil {
return err
}
if strings.TrimSpace(rewritten) != "" {
row.Answer = rewritten
}
return nil
}
@@ -0,0 +1,137 @@
package questiontable
import (
"context"
"strings"
"testing"
"time"
)
type answerRewriterStub struct {
rewritten string
err error
}
func (s answerRewriterStub) Rewrite(ctx context.Context, content string) (string, error) {
return s.rewritten, s.err
}
func TestRunOnceRewritesAnswerBeforeInsert(t *testing.T) {
repo := newStubRepo()
inserter := newStubInserter()
inserter.recordID = "rec-rewrite-1"
repo.claimFn = func(ctx context.Context, limit int, maxAttempts int, staleBefore time.Time) ([]PendingExport, error) {
return []PendingExport{
{
ExportID: "exp-rewrite",
Row: Row{
Question: "机械臂如何学习使用呢",
Answer: "原始答案含[[3](#_Toc1)]和![图](resource://abc)",
AskedTime: time.Now(),
AskerName: "张三",
AskerUnionID: "union-z",
TriggerPerson: "张三",
Department: "研发部",
Region: "华东",
QuestionImageURLs: []string{"https://weknora-api.example/i/001"},
},
Attempts: 0,
},
}, nil
}
rw := answerRewriterStub{rewritten: "转换后答案[3]", err: nil}
d, err := NewDispatcher(repo, inserter, &DispatcherOptions{Rewriter: rw})
if err != nil {
t.Fatalf("NewDispatcher: %v", err)
}
stats, err := d.RunOnce(context.Background())
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
if stats.Claimed != 1 || stats.Exported != 1 || stats.Failed != 0 {
t.Fatalf("stats=%+v", stats)
}
if len(inserter.rows) != 1 {
t.Fatalf("rows=%d, want 1", len(inserter.rows))
}
row := inserter.rows[0]
if row.Answer != "转换后答案[3]" {
t.Fatalf("Answer=%q, want rewritten", row.Answer)
}
}
func TestRunOnceRewriterErrorFallsBackToOriginalAnswer(t *testing.T) {
repo := newStubRepo()
inserter := newStubInserter()
inserter.recordID = "rec-fallback-1"
repo.claimFn = func(ctx context.Context, limit int, maxAttempts int, staleBefore time.Time) ([]PendingExport, error) {
return []PendingExport{
{
ExportID: "exp-fallback",
Row: Row{
Question: "问题",
Answer: "原始答案",
AskedTime: time.Now(),
},
Attempts: 0,
},
}, nil
}
rw := answerRewriterStub{rewritten: "", err: context.DeadlineExceeded}
d, err := NewDispatcher(repo, inserter, &DispatcherOptions{Rewriter: rw})
if err != nil {
t.Fatalf("NewDispatcher: %v", err)
}
stats, err := d.RunOnce(context.Background())
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
if stats.Failed != 0 || stats.Exported != 1 {
t.Fatalf("stats=%+v", stats)
}
if len(inserter.rows) != 1 || inserter.rows[0].Answer != "原始答案" {
t.Fatalf("rows=%+v, want fallback original answer", inserter.rows)
}
}
func TestRowToFieldsIncludesQuestionImageURLsAndImageMarker(t *testing.T) {
r := Row{
Question: "请描述并分析这张图片。",
Answer: "回答了。",
AskedTime: time.Now(),
AskerName: "张三",
AskerUnionID: "union-z",
TriggerPerson: "张三",
Department: "研发部",
Region: "华东",
QuestionImageURLs: []string{
"https://weknora-api.example/i/001",
"https://weknora-api.example/i/002",
},
}
fields := r.ToFields()
original, ok := fields[FieldOriginalQuestion].(string)
if !ok {
t.Fatalf("OriginalQuestion type=%T", fields[FieldOriginalQuestion])
}
if !strings.HasPrefix(original, "[图片]") {
t.Fatalf("OriginalQuestion=%q, want [图片] prefix", original)
}
// Image URLs must be rendered as markdown image syntax so the Notable cell
// previews them (same as the answer column), not as a plain-text link.
if !strings.Contains(original, "![图片1](https://weknora-api.example/i/001)") ||
!strings.Contains(original, "![图片2](https://weknora-api.example/i/002)") {
t.Fatalf("OriginalQuestion=%q, missing markdown image syntax", original)
}
// Without image URLs, no [图片] prefix.
plain := Row{Question: "普通问题", AskedTime: time.Now()}
if got := plain.ToFields()[FieldOriginalQuestion]; got != "普通问题" {
t.Fatalf("plain OriginalQuestion=%q, want unchanged", got)
}
}
+37 -10
View File
@@ -1,6 +1,7 @@
package questiontable
import (
"strconv"
"strings"
"time"
"unicode"
@@ -23,14 +24,15 @@ const (
// Row holds the data needed to produce a Notable v1 typed record.
type Row struct {
Question string
Answer string
AskedTime time.Time
AskerName string
AskerUnionID string
TriggerPerson string
Department string
Region string
Question string
Answer string
AskedTime time.Time
AskerName string
AskerUnionID string
TriggerPerson string
Department string
Region string
QuestionImageURLs []string
}
// ToFields returns a typed field-ID → value map suitable for the Notable v1
@@ -67,8 +69,8 @@ func (r Row) ToFields() map[string]interface{} {
}
}
// OriginalQuestion.
fields[FieldOriginalQuestion] = sanitise(r.Question)
// OriginalQuestion: include image marker and stable URLs for image turns.
fields[FieldOriginalQuestion] = sanitise(questionWithImageURLs(r.Question, r.QuestionImageURLs))
// Language.
fields[FieldLanguage] = sanitise(lang)
@@ -134,3 +136,28 @@ func sanitise(s string) string {
}
return s
}
// questionWithImageURLs prefixes an image marker and renders the stable image
// URLs as markdown image syntax when the turn carried image input. Markdown
// image syntax lets the Notable cell preview the image (same as the answer
// column); a plain-text URL would render as an unpreviewable link.
func questionWithImageURLs(question string, imageURLs []string) string {
if len(imageURLs) == 0 {
return question
}
var builder strings.Builder
builder.WriteString("[图片] ")
builder.WriteString(strings.TrimSpace(question))
for index, imageURL := range imageURLs {
imageURL = strings.TrimSpace(imageURL)
if imageURL == "" {
continue
}
builder.WriteString("\n![图片")
builder.WriteString(strconv.Itoa(index + 1))
builder.WriteString("](")
builder.WriteString(imageURL)
builder.WriteString(")")
}
return builder.String()
}
+3 -10
View File
@@ -85,7 +85,7 @@ func (c *Controller) Handle(ctx context.Context, in SettingInput) (string, error
if err != nil {
return "", err
}
return "身份已验证,请回到后台确认首位管理员。", nil
return "身份已验证,请回到服务器命令行等待确认。", nil
}
if explicit && len(args) == 0 {
admin, err := c.repo.IsAdministrator(ctx, in.TenantKey, in.StaffID)
@@ -158,7 +158,7 @@ func (c *Controller) Handle(ctx context.Context, in SettingInput) (string, error
return "", err
}
func settingMenu(admin, direct bool) string {
func settingMenu(admin, _ bool) string {
menu := "⚙️ 个人设置\n入口:/setting 或 /设置\n" +
"回答语言:/setting language <auto|zh-CN|en|ja|ko|es>\n" +
"- auto:跟随当前提问语言(推荐)\n" +
@@ -172,14 +172,7 @@ func settingMenu(admin, direct bool) string {
"- support:在目标技术支持群@机器人发送 /setting support 保存;再发送 /setting support test,测试成功后激活。\n" +
"- question:私聊发送 /setting question,再在10分钟内发送问题AI表链接;Schema验证通过后激活并短暂重启。\n" +
"- faq:私聊发送 /setting faq,再在10分钟内发送FAQ表链接;Schema验证通过后激活并短暂重启。"
if !direct {
return menu + "\n\n🌐 管理后台与管理员更换:请私聊机器人发送 /setting 查看。"
}
return menu + "\n\n🌐 管理后台(仅PC)\n" +
"1. 通过SSH隧道将本地8090转发到服务器127.0.0.1:8090。\n" +
"2. 浏览器打开:http://127.0.0.1:8090/admin/settings\n" +
"3. Basic Auth用户名任意;密码为服务器DOBOTAI_ADMIN_TOKEN(仅从服务器安全获取,禁止发送到钉钉)。\n" +
"4. 更换管理员:后台生成6位码 → 新管理员私聊 /setting <6位码> → 回后台确认 → 新管理员生效后再停用旧管理员。"
return menu
}
func tableBindingFailureReply(action string, err error) string {
+15 -6
View File
@@ -88,7 +88,7 @@ func TestControllerSettingMenuExplainsLanguageWithoutAdminActionsToOrdinaryUser(
}
}
func TestControllerSettingMenuExplainsAdminActionsAndBackendAccess(t *testing.T) {
func TestControllerSettingMenuExplainsAdminActionsWithoutBackendAccess(t *testing.T) {
r := newMemoryRepo()
r.admins["t/a"] = true
c := NewController(r)
@@ -103,15 +103,21 @@ func TestControllerSettingMenuExplainsAdminActionsAndBackendAccess(t *testing.T)
"/setting question",
"/setting faq",
"Schema验证通过后激活并短暂重启",
} {
if !strings.Contains(out, want) {
t.Fatalf("reply=%q must contain %q", out, want)
}
}
for _, forbidden := range []string{
"管理后台",
"SSH隧道",
"127.0.0.1:8090/admin/settings",
"Basic Auth",
"DOBOTAI_ADMIN_TOKEN",
"新管理员私聊 /setting <6位码>",
"新管理员生效后再停用旧管理员",
"回后台确认",
} {
if !strings.Contains(out, want) {
t.Fatalf("reply=%q must contain %q", out, want)
if strings.Contains(out, forbidden) {
t.Fatalf("reply=%q must not contain %q", out, forbidden)
}
}
}
@@ -312,11 +318,14 @@ func TestControllerDetectsBootstrapCandidate(t *testing.T) {
r := newMemoryRepo()
c := NewController(r)
c.now = func() time.Time { return time.Unix(100, 0) }
_, err := c.Handle(context.Background(), SettingInput{TenantKey: "t", StaffID: "a", UnionID: "union", DisplayName: "Alice", Text: "/setting 123456", ConversationType: "direct"})
out, err := c.Handle(context.Background(), SettingInput{TenantKey: "t", StaffID: "a", UnionID: "union", DisplayName: "Alice", Text: "/setting 123456", ConversationType: "direct"})
if err != nil {
t.Fatal(err)
}
if r.candidate.Code != "123456" || r.candidate.StaffID != "a" {
t.Fatalf("candidate=%+v", r.candidate)
}
if !strings.Contains(out, "服务器命令行") || strings.Contains(out, "后台") {
t.Fatalf("reply=%q must direct confirmation back to the server CLI only", out)
}
}
@@ -2,6 +2,7 @@ package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
@@ -82,6 +83,7 @@ func (r *QuestionTableExportRepository) Claim(ctx context.Context, limit int, ma
cl.attempts,
t.question,
t.answer,
t.question_image_urls_json,
m.created_at,
COALESCE(m.actor_name_snapshot, '') AS actor_name_snapshot,
COALESCE(m.actor_union_id_snapshot, '') AS actor_union_id_snapshot,
@@ -109,6 +111,7 @@ func (r *QuestionTableExportRepository) Claim(ctx context.Context, limit int, ma
attempts int
question string
answer string
imageURLsJSON []byte
askedTime time.Time
actorNameSnapshot string
actorUnionIDSnapshot string
@@ -117,25 +120,32 @@ func (r *QuestionTableExportRepository) Claim(ctx context.Context, limit int, ma
regionSnap string
)
if err := rows.Scan(&exportID, &attempts,
&question, &answer,
&question, &answer, &imageURLsJSON,
&askedTime,
&actorNameSnapshot, &actorUnionIDSnapshot,
&triggerPersonSnap, &departmentSnap, &regionSnap); err != nil {
rows.Close()
return nil, fmt.Errorf("questiontable export claim scan: %w", err)
}
var imageURLs []string
if len(imageURLsJSON) > 0 {
if err := json.Unmarshal(imageURLsJSON, &imageURLs); err != nil {
imageURLs = nil
}
}
exports = append(exports, questiontable.PendingExport{
ExportID: exportID,
Attempts: attempts,
Row: questiontable.Row{
Question: question,
Answer: answer,
AskedTime: askedTime,
AskerName: actorNameSnapshot,
AskerUnionID: actorUnionIDSnapshot,
TriggerPerson: triggerPersonSnap,
Department: departmentSnap,
Region: regionSnap,
Question: question,
Answer: answer,
AskedTime: askedTime,
AskerName: actorNameSnapshot,
AskerUnionID: actorUnionIDSnapshot,
TriggerPerson: triggerPersonSnap,
Department: departmentSnap,
Region: regionSnap,
QuestionImageURLs: imageURLs,
},
})
}
+13 -5
View File
@@ -56,6 +56,14 @@ func (s *Store) BeginTurn(ctx context.Context, turn core.Turn) (core.Turn, bool,
if err != nil {
return core.Turn{}, false, err
}
imageURLs := turn.QuestionImageURLs
if imageURLs == nil {
imageURLs = []string{}
}
imageURLsJSON, err := json.Marshal(imageURLs)
if err != nil {
return core.Turn{}, false, err
}
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return core.Turn{}, false, err
@@ -106,10 +114,10 @@ func (s *Store) BeginTurn(ctx context.Context, turn core.Turn) (core.Turn, bool,
}
command, err := tx.Exec(ctx, `INSERT INTO turns
(id,tenant_key,channel,external_message_id,session_key,question,status,media_json,request_message_id,agent_id)
VALUES($1,$2,$3,$4,$5,$6,'processing',$7,$8,$9) ON CONFLICT DO NOTHING`,
(id,tenant_key,channel,external_message_id,session_key,question,status,media_json,request_message_id,agent_id,question_image_urls_json)
VALUES($1,$2,$3,$4,$5,$6,'processing',$7,$8,$9,$10) ON CONFLICT DO NOTHING`,
turn.ID, turn.TenantKey, turn.Channel, turn.ExternalMessageID, turn.SessionKey, turn.Question,
mediaJSON, turn.RequestMessageID, turn.AgentID)
mediaJSON, turn.RequestMessageID, turn.AgentID, imageURLsJSON)
if err != nil {
return core.Turn{}, false, err
}
@@ -240,9 +248,9 @@ func (s *Store) FinishTurn(ctx context.Context, turn core.Turn) error {
}
}
}
_, err = tx.Exec(ctx, `UPDATE turns SET weknora_session_id=$2,weknora_request_id=$3,answer=$4,references_json=$5,media_json=$6,status=$7,error_message=$8,answer_message_id=COALESCE($9::TEXT,answer_message_id),updated_at=NOW() WHERE id=$1`,
_, err = tx.Exec(ctx, `UPDATE turns SET weknora_session_id=$2,weknora_request_id=$3,answer=$4,references_json=$5,media_json=$6,status=$7,error_message=$8,answer_message_id=COALESCE($9::TEXT,answer_message_id),knowledge_searched=$10,updated_at=NOW() WHERE id=$1`,
turn.ID, turn.WeKnoraSessionID, turn.WeKnoraRequestID, turn.Answer, references, mediaJSON,
turn.Status, turn.ErrorMessage, answerMessageID)
turn.Status, turn.ErrorMessage, answerMessageID, turn.KnowledgeSearched)
if err != nil {
return err
}