feat: add DingTalk directory-backed session titles

This commit is contained in:
姚章浩
2026-07-23 10:55:25 +08:00
parent 39c5fbbaef
commit 5e0694835a
33 changed files with 1648 additions and 62 deletions
+85
View File
@@ -0,0 +1,85 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"strings"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/directory"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/store/postgres"
)
func main() {
if err := run(os.Args[1:], os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run(args []string, out io.Writer) error {
flags := flag.NewFlagSet("directory-import", flag.ContinueOnError)
flags.SetOutput(io.Discard)
csvPath := flags.String("csv", "", "path to tested DingTalk directory CSV output")
tenant := flags.String("tenant", "", "tenant key")
databaseURLFile := flags.String("database-url-file", "", "file containing DATABASE_URL")
validateOnly := flags.Bool("validate-only", false, "validate CSV without connecting to PostgreSQL")
if err := flags.Parse(args); err != nil {
return errors.New("invalid directory import arguments")
}
if strings.TrimSpace(*csvPath) == "" || strings.TrimSpace(*tenant) == "" {
return errors.New("-csv and -tenant are required")
}
input, err := os.Open(*csvPath)
if err != nil {
return errors.New("open directory CSV failed")
}
defer input.Close()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if *validateOnly {
count, importErr := directory.ImportCSV(ctx, *tenant, input, validationWriter{})
if importErr != nil {
return errors.New("directory CSV validation failed")
}
_, _ = fmt.Fprintf(out, "validated_profiles=%d\n", count)
return nil
}
if strings.TrimSpace(*databaseURLFile) == "" {
return errors.New("-database-url-file is required for import")
}
databaseURL, err := readSecretFile(*databaseURLFile)
if err != nil {
return err
}
store, err := postgres.Open(ctx, databaseURL)
if err != nil {
return errors.New("open directory database failed")
}
defer store.Close()
count, err := directory.ImportCSV(ctx, *tenant, input, store)
if err != nil {
return errors.New("directory CSV import failed")
}
_, _ = fmt.Fprintf(out, "imported_profiles=%d\n", count)
return nil
}
type validationWriter struct{}
func (validationWriter) UpsertDirectoryProfiles(context.Context, string, []directory.Profile) error {
return nil
}
func readSecretFile(path string) (string, error) {
raw, err := os.ReadFile(strings.TrimSpace(path))
if err != nil { return "", errors.New("read database URL file failed") }
if len(raw) > 16<<10 { return "", errors.New("database URL file is too large") }
value := strings.TrimSpace(string(raw))
if value == "" { return "", errors.New("database URL file is empty") }
return value, nil
}
+48
View File
@@ -0,0 +1,48 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
func TestReadSecretFileTrimsValue(t *testing.T) {
path := filepath.Join(t.TempDir(), "database_url")
if err := os.WriteFile(path, []byte(" postgres://secret-value \n"), 0o600); err != nil { t.Fatal(err) }
value, err := readSecretFile(path)
if err != nil { t.Fatalf("readSecretFile: %v", err) }
if value != "postgres://secret-value" { t.Fatalf("value=%q", value) }
}
func TestReadSecretFileErrorDoesNotLeakContents(t *testing.T) {
path := filepath.Join(t.TempDir(), "empty")
if err := os.WriteFile(path, []byte(""), 0o600); err != nil { t.Fatal(err) }
_, err := readSecretFile(path)
if err == nil { t.Fatal("expected error") }
if strings.Contains(err.Error(), "secret-value") { t.Fatalf("error leaked secret: %v", err) }
}
func TestRunValidateOnlyDoesNotRequireDatabase(t *testing.T) {
csvPath := filepath.Join(t.TempDir(), "directory.csv")
if err := os.WriteFile(csvPath, []byte("user_id,union_id,name,nickname,title,department_ids,dept_name\nstaff-1,,张三,,工程师,100,技术支持部\n"), 0o600); err != nil {
t.Fatal(err)
}
var out bytes.Buffer
err := run([]string{"-csv", csvPath, "-tenant", "tenant-a", "-validate-only"}, &out)
if err != nil {
t.Fatalf("run validate-only: %v", err)
}
if !strings.Contains(out.String(), "validated_profiles=1") {
t.Fatalf("output=%q", out.String())
}
}
func TestRunRequiresCSVAndDatabaseURLFileBeforeConnecting(t *testing.T) {
var out bytes.Buffer
for _, args := range [][]string{{}, {"-csv", "snapshot.csv"}} {
err := run(args, &out)
if err == nil { t.Fatalf("run(%v) expected error", args) }
}
}
+24
View File
@@ -0,0 +1,24 @@
package main
import (
"os"
"strings"
"testing"
)
func TestMainWiresDirectoryResolverIntoDingTalkHandler(t *testing.T) {
raw, err := os.ReadFile("main.go")
if err != nil {
t.Fatalf("read main.go: %v", err)
}
src := string(raw)
for _, required := range []string{
"directory.NewDingTalkLookup",
"directory.NewResolver",
"Directory: directoryResolver",
} {
if !strings.Contains(src, required) {
t.Errorf("main.go missing %q", required)
}
}
}
+16 -1
View File
@@ -15,6 +15,7 @@ import (
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/audit"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/coverage"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/directory"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/feedback"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/httpapi"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/qa"
@@ -100,6 +101,20 @@ func main() {
}
var streamClient *dingtalk.StreamClient
if dingCfg.Enabled() {
directoryLookup, err := directory.NewDingTalkLookup(directory.DingTalkLookupConfig{
TokenURL: "https://oapi.dingtalk.com/gettoken",
APIBaseURL: "https://oapi.dingtalk.com",
ClientID: dingCfg.ClientID,
ClientSecret: dingCfg.ClientSecret,
Timeout: 10 * time.Second,
})
if err != nil {
log.Fatalf("DingTalk directory lookup init failed: %v", err)
}
directoryResolver, err := directory.NewResolver(store, directoryLookup)
if err != nil {
log.Fatalf("DingTalk directory resolver init failed: %v", err)
}
feedbackService, err := feedback.NewService(dingCfg.TenantKey, "dingtalk", store)
if err != nil {
log.Fatal(err)
@@ -138,7 +153,7 @@ func main() {
dingtalk.TenantConfig{TenantKey: dingCfg.TenantKey, Channel: "dingtalk"},
qaService,
sender,
dingtalk.HandlerOptions{ImageDownloader: downloader, CardSender: cardSender},
dingtalk.HandlerOptions{ImageDownloader: downloader, CardSender: cardSender, Directory: directoryResolver},
)
if err != nil {
log.Fatal(err)
@@ -0,0 +1,2 @@
ALTER TABLE session_mappings
DROP COLUMN IF EXISTS display_title;
@@ -0,0 +1,2 @@
ALTER TABLE session_mappings
ADD COLUMN IF NOT EXISTS display_title TEXT NOT NULL DEFAULT '';
@@ -0,0 +1,28 @@
package migrations
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestSessionDisplayTitleMigration(t *testing.T) {
up, err := os.ReadFile(filepath.Join("000008_session_display_title.up.sql"))
if err != nil {
t.Fatalf("read up migration: %v", err)
}
down, err := os.ReadFile(filepath.Join("000008_session_display_title.down.sql"))
if err != nil {
t.Fatalf("read down migration: %v", err)
}
upper := strings.ToUpper(string(up))
for _, required := range []string{"ALTER TABLE SESSION_MAPPINGS", "ADD COLUMN IF NOT EXISTS DISPLAY_TITLE", "TEXT NOT NULL", "DEFAULT ''"} {
if !strings.Contains(upper, required) {
t.Errorf("up migration missing %q", required)
}
}
if !strings.Contains(strings.ToUpper(string(down)), "DROP COLUMN IF EXISTS DISPLAY_TITLE") {
t.Error("down migration must drop display_title")
}
}
@@ -0,0 +1,7 @@
ALTER TABLE messages
DROP COLUMN IF EXISTS region_snapshot;
ALTER TABLE messages
DROP COLUMN IF EXISTS department_snapshot;
DROP TABLE IF EXISTS directory_profiles;
@@ -0,0 +1,26 @@
CREATE TABLE IF NOT EXISTS directory_profiles (
tenant_key TEXT NOT NULL,
staff_id TEXT NOT NULL,
union_id TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
nickname TEXT NOT NULL DEFAULT '',
job_title TEXT NOT NULL DEFAULT '',
department_ids JSONB NOT NULL DEFAULT '[]'::JSONB,
department_name TEXT NOT NULL DEFAULT '',
main_department_name TEXT NOT NULL DEFAULT '',
region TEXT NOT NULL DEFAULT '',
employment_status TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL CHECK (source IN ('full_sync','on_demand')),
synced_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (tenant_key, staff_id)
);
CREATE INDEX IF NOT EXISTS idx_directory_profiles_tenant_name
ON directory_profiles (tenant_key, name);
ALTER TABLE messages
ADD COLUMN IF NOT EXISTS department_snapshot TEXT NOT NULL DEFAULT '';
ALTER TABLE messages
ADD COLUMN IF NOT EXISTS region_snapshot TEXT NOT NULL DEFAULT '';
@@ -0,0 +1,42 @@
package migrations
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestDirectoryProfilesMigration(t *testing.T) {
up, err := os.ReadFile(filepath.Join("000009_directory_profiles.up.sql"))
if err != nil {
t.Fatalf("read up migration: %v", err)
}
down, err := os.ReadFile(filepath.Join("000009_directory_profiles.down.sql"))
if err != nil {
t.Fatalf("read down migration: %v", err)
}
upper := strings.ToUpper(string(up))
for _, required := range []string{
"CREATE TABLE IF NOT EXISTS DIRECTORY_PROFILES",
"PRIMARY KEY (TENANT_KEY, STAFF_ID)",
"DEPARTMENT_IDS JSONB",
"REGION TEXT",
"SOURCE TEXT",
"ADD COLUMN IF NOT EXISTS DEPARTMENT_SNAPSHOT",
"ADD COLUMN IF NOT EXISTS REGION_SNAPSHOT",
} {
if !strings.Contains(upper, required) {
t.Errorf("up migration missing %q", required)
}
}
for _, forbidden := range []string{"MOBILE", "EMAIL", "CLIENT_SECRET", "ACCESS_TOKEN", "RAW_PAYLOAD"} {
if strings.Contains(upper, forbidden) {
t.Errorf("up migration contains forbidden sensitive field %q", forbidden)
}
}
downUpper := strings.ToUpper(string(down))
if !strings.Contains(downUpper, "DROP TABLE IF EXISTS DIRECTORY_PROFILES") || !strings.Contains(downUpper, "DROP COLUMN IF EXISTS REGION_SNAPSHOT") {
t.Error("down migration must remove directory table and snapshot columns")
}
}
+37 -12
View File
@@ -9,6 +9,7 @@ import (
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/directory"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/weknora"
)
@@ -32,17 +33,18 @@ type TenantConfig struct {
}
type BotCallback struct {
ConversationType string
ConversationID string
Msgtype string
MsgID string
SenderNick string
SenderStaffID string
SenderID string
IsInAtList bool
SessionWebhook string
Text string
DownloadCode string
ConversationType string
ConversationID string
ConversationTitle string
Msgtype string
MsgID string
SenderNick string
SenderStaffID string
SenderID string
IsInAtList bool
SessionWebhook string
Text string
DownloadCode string
}
type QAAsker interface {
@@ -73,6 +75,10 @@ type ImageDownloader interface {
Download(ctx context.Context, downloadCode string) (core.ImageInput, error)
}
type UserDirectory interface {
ResolveUser(ctx context.Context, tenantKey, staffID, fallbackName string) (directory.Profile, error)
}
// FinalCardSender sends an AI card as the final reply. Implementations must
// not log err, outTrackID, or content at any level.
type FinalCardSender interface {
@@ -89,6 +95,7 @@ type StreamingCardSender interface {
type HandlerOptions struct {
ImageDownloader ImageDownloader
CardSender FinalCardSender
Directory UserDirectory
}
type Handler struct {
@@ -97,6 +104,7 @@ type Handler struct {
reply MarkdownSender
downloader ImageDownloader
card FinalCardSender
directory UserDirectory
now func() time.Time
cardUpdateInterval time.Duration
finalDeliveryTimeout time.Duration
@@ -137,6 +145,7 @@ func NewHandlerWithOptions(cfg TenantConfig, qa QAAsker, reply MarkdownSender, o
reply: reply,
downloader: opts.ImageDownloader,
card: opts.CardSender,
directory: opts.Directory,
now: time.Now,
cardUpdateInterval: 500 * time.Millisecond,
finalDeliveryTimeout: defaultHTTPTimeout,
@@ -170,13 +179,28 @@ func (h *Handler) Handle(ctx context.Context, cb BotCallback) error {
return errors.New("dingtalk handler: sender staffId/senderId is required")
}
senderName := strings.TrimSpace(cb.SenderNick)
department := ""
region := ""
if h.directory != nil {
profile, _ := h.directory.ResolveUser(ctx, h.cfg.TenantKey, externalUserID, senderName)
if name := strings.TrimSpace(profile.Name); name != "" {
senderName = name
department = strings.TrimSpace(profile.DepartmentName)
region = strings.TrimSpace(profile.Region)
}
}
req := core.QARequest{
TenantKey: h.cfg.TenantKey,
Channel: h.cfg.Channel,
ExternalUserID: externalUserID,
ExternalMessageID: strings.TrimSpace(cb.MsgID),
SenderName: cb.SenderNick,
SenderName: senderName,
Department: department,
Region: region,
MessageType: strings.ToLower(strings.TrimSpace(cb.Msgtype)),
ConversationTitle: strings.TrimSpace(cb.ConversationTitle),
}
switch cb.ConversationType {
case conversationTypeGroup:
@@ -187,6 +211,7 @@ func (h *Handler) Handle(ctx context.Context, cb BotCallback) error {
req.ExternalChatID = strings.TrimSpace(cb.ConversationID)
default:
req.ConversationType = core.ConversationDirect
req.ConversationTitle = ""
}
switch strings.ToLower(strings.TrimSpace(cb.Msgtype)) {
@@ -8,6 +8,7 @@ import (
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/directory"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/weknora"
)
@@ -31,6 +32,17 @@ func (f *fakeQA) RecordDelivery(_ context.Context, update core.DeliveryUpdate) e
return f.deliveryErr
}
type fakeDirectory struct {
profile directory.Profile
err error
calls int
}
func (f *fakeDirectory) ResolveUser(context.Context, string, string, string) (directory.Profile, error) {
f.calls++
return f.profile, f.err
}
type fakeReply struct {
called bool
lastURL string
@@ -192,6 +204,42 @@ func TestHandleDirectTextCallsQAAndRepliesAnswer(t *testing.T) {
}
}
func TestHandleDirectUsesDirectoryProfile(t *testing.T) {
qa := &fakeQA{result: core.QAResult{Answer: "ok"}}
reply := &fakeReply{}
profiles := &fakeDirectory{profile: directory.Profile{Name: "张三", DepartmentName: "技术支持部", Region: "APAC"}}
h, err := NewHandlerWithOptions(TenantConfig{TenantKey: "tenant-a", Channel: "dingtalk"}, qa, reply, HandlerOptions{Directory: profiles})
if err != nil {
t.Fatalf("NewHandlerWithOptions: %v", err)
}
data := directTextCallback()
data.SenderNick = "钉钉昵称"
if err := h.Handle(context.Background(), data); err != nil {
t.Fatalf("Handle: %v", err)
}
if profiles.calls != 1 || qa.lastReq.SenderName != "张三" || qa.lastReq.Department != "技术支持部" || qa.lastReq.Region != "APAC" {
t.Fatalf("calls=%d request=%+v", profiles.calls, qa.lastReq)
}
}
func TestHandleDirectoryFailureFallsBackToSenderNick(t *testing.T) {
qa := &fakeQA{result: core.QAResult{Answer: "ok"}}
reply := &fakeReply{}
profiles := &fakeDirectory{err: errors.New("directory unavailable")}
h, err := NewHandlerWithOptions(TenantConfig{TenantKey: "tenant-a", Channel: "dingtalk"}, qa, reply, HandlerOptions{Directory: profiles})
if err != nil {
t.Fatalf("NewHandlerWithOptions: %v", err)
}
data := directTextCallback()
data.SenderNick = " 回调昵称 "
if err := h.Handle(context.Background(), data); err != nil {
t.Fatalf("Handle: %v", err)
}
if qa.lastReq.SenderName != "回调昵称" || qa.lastReq.Department != "" || qa.lastReq.Region != "" {
t.Fatalf("request=%+v", qa.lastReq)
}
}
func TestHandleDirectTextFallsBackToSenderID(t *testing.T) {
qa := &fakeQA{result: core.QAResult{Answer: "ok"}}
reply := &fakeReply{}
@@ -1796,3 +1844,33 @@ func TestDeliveryAuditEventStreamingProductionPath(t *testing.T) {
}
requireDelivery(t, qa.deliveries, core.DeliverySent, core.DeliveryMethodCard, "event-track-1", "")
}
func TestHandlePassesConversationTitleToQARequest(t *testing.T) {
qa := &fakeQA{result: core.QAResult{Answer: "ok"}}
reply := &fakeReply{}
h := newTestHandler(t, qa, reply)
t.Run("group conversation title propagated", func(t *testing.T) {
cb := groupTextCallback()
cb.ConversationTitle = "项目讨论群"
if err := h.Handle(context.Background(), cb); err != nil {
t.Fatalf("Handle: %v", err)
}
if qa.lastReq.ConversationTitle != "项目讨论群" {
t.Fatalf("ConversationTitle = %q, want 项目讨论群", qa.lastReq.ConversationTitle)
}
})
t.Run("direct conversation title is empty", func(t *testing.T) {
qa2 := &fakeQA{result: core.QAResult{Answer: "ok"}}
h2 := newTestHandler(t, qa2, &fakeReply{})
cb := directTextCallback()
cb.ConversationTitle = "should not appear"
if err := h2.Handle(context.Background(), cb); err != nil {
t.Fatalf("Handle: %v", err)
}
if qa2.lastReq.ConversationTitle != "" {
t.Fatalf("ConversationTitle = %q, want empty for direct", qa2.lastReq.ConversationTitle)
}
})
}
+11 -10
View File
@@ -69,16 +69,17 @@ func BotCallbackFromSDK(data *chatbot.BotCallbackDataModel) (BotCallback, error)
}
msgtype := strings.ToLower(strings.TrimSpace(data.Msgtype))
cb := BotCallback{
ConversationType: data.ConversationType,
ConversationID: data.ConversationId,
Msgtype: msgtype,
MsgID: data.MsgId,
SenderNick: data.SenderNick,
SenderStaffID: data.SenderStaffId,
SenderID: data.SenderId,
IsInAtList: data.IsInAtList,
SessionWebhook: data.SessionWebhook,
Text: data.Text.Content,
ConversationType: data.ConversationType,
ConversationID: data.ConversationId,
ConversationTitle: data.ConversationTitle,
Msgtype: msgtype,
MsgID: data.MsgId,
SenderNick: data.SenderNick,
SenderStaffID: data.SenderStaffId,
SenderID: data.SenderId,
IsInAtList: data.IsInAtList,
SessionWebhook: data.SessionWebhook,
Text: data.Text.Content,
}
if msgtype == "richtext" {
rtMsgtype, rtText, rtCode := parseRichText(data.Content)
+40
View File
@@ -542,6 +542,46 @@ func TestBotCallbackFromSDKRichText(t *testing.T) {
})
}
func TestBotCallbackFromSDKConversationTitle(t *testing.T) {
t.Parallel()
t.Run("conversation title survives conversion", func(t *testing.T) {
t.Parallel()
data := &chatbot.BotCallbackDataModel{
ConversationType: "2",
ConversationId: "cid123",
Msgtype: "text",
MsgId: "msg-1",
ConversationTitle: "项目讨论群",
Text: chatbot.BotCallbackDataTextModel{Content: "hello"},
}
cb, err := BotCallbackFromSDK(data)
if err != nil {
t.Fatalf("BotCallbackFromSDK() error = %v", err)
}
if cb.ConversationTitle != "项目讨论群" {
t.Fatalf("ConversationTitle = %q, want 项目讨论群", cb.ConversationTitle)
}
})
t.Run("conversation title empty for direct", func(t *testing.T) {
t.Parallel()
data := &chatbot.BotCallbackDataModel{
ConversationType: "1",
Msgtype: "text",
MsgId: "msg-2",
Text: chatbot.BotCallbackDataTextModel{Content: "hi"},
}
cb, err := BotCallbackFromSDK(data)
if err != nil {
t.Fatalf("BotCallbackFromSDK() error = %v", err)
}
if cb.ConversationTitle != "" {
t.Fatalf("ConversationTitle = %q, want empty for direct", cb.ConversationTitle)
}
})
}
func TestNewStreamClientValidation(t *testing.T) {
t.Parallel()
h := mustTestHandler(t)
+10
View File
@@ -40,10 +40,13 @@ type QARequest struct {
TenantKey string
Channel string
ConversationType ConversationType
ConversationTitle string
ExternalChatID string
ExternalUserID string
ExternalMessageID string
SenderName string
Department string
Region string
Query string
Images []ImageInput
MessageType string
@@ -77,8 +80,11 @@ type SessionMapping struct {
ConversationType ConversationType
ExternalSubjectID string
WeKnoraSessionID string
DisplayTitle string
}
type SessionTitleUpdate func(ctx context.Context, sessionID, title string) error
type Turn struct {
ID string
TenantKey string
@@ -89,9 +95,12 @@ type Turn struct {
WeKnoraRequestID string
AgentID string
ConversationType ConversationType
ConversationTitle string
ExternalChatID string
ExternalUserID string
SenderName string
DepartmentSnapshot string
RegionSnapshot string
MessageType string
ActorID string
ConversationID string
@@ -143,6 +152,7 @@ var ErrDeliveryUpdateFailed = errors.New("delivery update failed: RowsAffected !
type Repository interface {
BeginTurn(ctx context.Context, turn Turn) (Turn, bool, error)
ResolveSession(ctx context.Context, mapping SessionMapping, create func(context.Context) (string, error)) (string, error)
SyncSessionTitle(ctx context.Context, mapping SessionMapping, title string, update SessionTitleUpdate) error
FinishTurn(ctx context.Context, turn Turn) error
RecordDelivery(ctx context.Context, du DeliveryUpdate) error
}
+121
View File
@@ -0,0 +1,121 @@
package directory
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
type DingTalkLookupConfig struct {
TokenURL string
APIBaseURL string
ClientID string
ClientSecret string
Timeout time.Duration
}
type DingTalkLookup struct {
tokenURL string
apiBaseURL string
clientID string
clientSecret string
httpClient *http.Client
mu sync.Mutex
token string
tokenExpiry time.Time
now func() time.Time
}
func NewDingTalkLookup(cfg DingTalkLookupConfig) (*DingTalkLookup, error) {
cfg.TokenURL = strings.TrimSpace(cfg.TokenURL)
cfg.APIBaseURL = strings.TrimRight(strings.TrimSpace(cfg.APIBaseURL), "/")
if cfg.TokenURL == "" || cfg.APIBaseURL == "" || strings.TrimSpace(cfg.ClientID) == "" || strings.TrimSpace(cfg.ClientSecret) == "" {
return nil, errors.New("dingtalk lookup endpoints and credentials are required")
}
if _, err := url.ParseRequestURI(cfg.TokenURL); err != nil { return nil, errors.New("invalid dingtalk token endpoint") }
if _, err := url.ParseRequestURI(cfg.APIBaseURL); err != nil { return nil, errors.New("invalid dingtalk API endpoint") }
if cfg.Timeout <= 0 { cfg.Timeout = 10 * time.Second }
return &DingTalkLookup{
tokenURL: cfg.TokenURL, apiBaseURL: cfg.APIBaseURL,
clientID: cfg.ClientID, clientSecret: cfg.ClientSecret,
httpClient: &http.Client{Timeout: cfg.Timeout}, now: time.Now,
}, nil
}
func (c *DingTalkLookup) LookupUser(ctx context.Context, staffID string) (Profile, error) {
staffID = strings.TrimSpace(staffID)
if staffID == "" { return Profile{}, errors.New("dingtalk user id is required") }
token, err := c.accessToken(ctx)
if err != nil { return Profile{}, err }
payload, _ := json.Marshal(map[string]string{"userid": staffID})
endpoint := c.apiBaseURL + "/topapi/v2/user/get?access_token=" + url.QueryEscape(token)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
if err != nil { return Profile{}, errors.New("dingtalk user request failed") }
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil { return Profile{}, errors.New("dingtalk user request failed") }
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 { return Profile{}, errors.New("dingtalk user HTTP failure") }
var decoded struct {
ErrCode int `json:"errcode"`
Result struct {
UserID string `json:"userid"`
UnionID string `json:"unionid"`
Name string `json:"name"`
Nickname string `json:"nickname"`
Title string `json:"title"`
DepartmentIDs []int64 `json:"dept_id_list"`
Status int `json:"status"`
} `json:"result"`
}
if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil || decoded.ErrCode != 0 {
return Profile{}, errors.New("dingtalk user response invalid")
}
ids := make([]string, 0, len(decoded.Result.DepartmentIDs))
for _, id := range decoded.Result.DepartmentIDs { ids = append(ids, strconv.FormatInt(id, 10)) }
return Profile{
StaffID: staffID, UnionID: decoded.Result.UnionID, Name: strings.TrimSpace(decoded.Result.Name),
Nickname: decoded.Result.Nickname, JobTitle: decoded.Result.Title, DepartmentIDs: ids,
EmploymentStatus: strconv.Itoa(decoded.Result.Status), Source: SourceOnDemand, SyncedAt: c.now().UTC(),
}, nil
}
func (c *DingTalkLookup) accessToken(ctx context.Context) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.token != "" && c.now().Before(c.tokenExpiry) { return c.token, nil }
parsed, err := url.Parse(c.tokenURL)
if err != nil { return "", errors.New("dingtalk token request failed") }
query := parsed.Query()
query.Set("appkey", c.clientID)
query.Set("appsecret", c.clientSecret)
parsed.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
if err != nil { return "", errors.New("dingtalk token request failed") }
resp, err := c.httpClient.Do(req)
if err != nil { return "", errors.New("dingtalk token request failed") }
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 { return "", errors.New("dingtalk token HTTP failure") }
var decoded struct {
ErrCode int `json:"errcode"`
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil || decoded.ErrCode != 0 || decoded.AccessToken == "" {
return "", errors.New("dingtalk token response invalid")
}
ttl := time.Duration(decoded.ExpiresIn) * time.Second
if ttl <= 0 { ttl = time.Hour }
refreshMargin := 5 * time.Minute
if ttl <= refreshMargin { refreshMargin = ttl / 2 }
c.token = decoded.AccessToken
c.tokenExpiry = c.now().Add(ttl - refreshMargin)
return c.token, nil
}
@@ -0,0 +1,81 @@
package directory
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
func TestDingTalkLookupFetchesUserAndCachesToken(t *testing.T) {
var tokenCalls atomic.Int32
var userCalls atomic.Int32
mux := http.NewServeMux()
mux.HandleFunc("/gettoken", func(w http.ResponseWriter, r *http.Request) {
tokenCalls.Add(1)
if r.Method != http.MethodGet || r.URL.Query().Get("appkey") != "app-key" || r.URL.Query().Get("appsecret") != "app-secret" {
t.Fatalf("unexpected token request: %s %s", r.Method, r.URL.String())
}
_, _ = w.Write([]byte(`{"errcode":0,"access_token":"token-secret","expires_in":7200}`))
})
mux.HandleFunc("/topapi/v2/user/get", func(w http.ResponseWriter, r *http.Request) {
userCalls.Add(1)
if r.Method != http.MethodPost || r.URL.Query().Get("access_token") != "token-secret" {
t.Fatalf("unexpected user request: %s %s", r.Method, r.URL.String())
}
var body [256]byte
n, _ := r.Body.Read(body[:])
if !strings.Contains(string(body[:n]), `"userid":"staff-1"`) {
t.Fatalf("unexpected body: %s", body[:n])
}
_, _ = w.Write([]byte(`{"errcode":0,"result":{"userid":"staff-1","unionid":"union-1","name":"张三","nickname":"小张","title":"工程师","dept_id_list":[100,200],"status":5}}`))
})
server := httptest.NewServer(mux)
defer server.Close()
lookup, err := NewDingTalkLookup(DingTalkLookupConfig{
TokenURL: server.URL + "/gettoken", APIBaseURL: server.URL,
ClientID: "app-key", ClientSecret: "app-secret", Timeout: 2 * time.Second,
})
if err != nil { t.Fatal(err) }
for i := 0; i < 2; i++ {
profile, err := lookup.LookupUser(context.Background(), "staff-1")
if err != nil { t.Fatalf("LookupUser(%d): %v", i, err) }
if profile.Name != "张三" || profile.UnionID != "union-1" || profile.JobTitle != "工程师" || len(profile.DepartmentIDs) != 2 || profile.DepartmentIDs[1] != "200" {
t.Fatalf("profile=%+v", profile)
}
}
if tokenCalls.Load() != 1 || userCalls.Load() != 2 {
t.Fatalf("token calls=%d user calls=%d", tokenCalls.Load(), userCalls.Load())
}
}
func TestDingTalkLookupErrorsAreSanitized(t *testing.T) {
for _, failPath := range []string{"token", "user"} {
t.Run(failPath, func(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/gettoken", func(w http.ResponseWriter, _ *http.Request) {
if failPath == "token" { http.Error(w, "raw-secret-body", 500); return }
_, _ = w.Write([]byte(`{"errcode":0,"access_token":"token-secret","expires_in":7200}`))
})
mux.HandleFunc("/topapi/v2/user/get", func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "raw-secret-body", 500)
})
server := httptest.NewServer(mux)
defer server.Close()
lookup, err := NewDingTalkLookup(DingTalkLookupConfig{TokenURL: server.URL + "/gettoken", APIBaseURL: server.URL, ClientID: "app-key", ClientSecret: "app-secret", Timeout: time.Second})
if err != nil { t.Fatal(err) }
_, err = lookup.LookupUser(context.Background(), "staff-secret")
if err == nil { t.Fatal("expected error") }
for _, forbidden := range []string{"raw-secret-body", "app-secret", "token-secret", "staff-secret"} {
if strings.Contains(err.Error(), forbidden) { t.Fatalf("error leaked %q: %v", forbidden, err) }
}
if !strings.Contains(err.Error(), fmt.Sprintf("dingtalk %s", failPath)) {
t.Fatalf("error=%q", err)
}
})
}
}
+105
View File
@@ -0,0 +1,105 @@
package directory
import (
"context"
"encoding/csv"
"errors"
"fmt"
"io"
"strings"
"time"
)
var requiredCSVHeaders = []string{"user_id", "name", "dept_name"}
// ImportCSV validates the complete snapshot before making one repository call.
// It consumes the tested Python sync output and accepts optional production
// enrichment columns without reimplementing the bulk DingTalk crawler in Go.
func ImportCSV(ctx context.Context, tenantKey string, input io.Reader, writer SnapshotWriter) (int, error) {
tenantKey = strings.TrimSpace(tenantKey)
if tenantKey == "" || input == nil || writer == nil {
return 0, errors.New("tenant, CSV input and writer are required")
}
r := csv.NewReader(input)
r.FieldsPerRecord = -1
header, err := r.Read()
if err != nil {
return 0, fmt.Errorf("read directory CSV header: %w", err)
}
columns := make(map[string]int, len(header))
for i, name := range header {
name = strings.TrimSpace(strings.TrimPrefix(name, "\ufeff"))
columns[name] = i
}
for _, name := range requiredCSVHeaders {
if _, ok := columns[name]; !ok {
return 0, fmt.Errorf("directory CSV missing required header %q", name)
}
}
profiles := make([]Profile, 0, 256)
line := 1
for {
record, readErr := r.Read()
if errors.Is(readErr, io.EOF) {
break
}
line++
if readErr != nil {
return 0, fmt.Errorf("read directory CSV line %d: %w", line, readErr)
}
value := func(name string) string {
idx, ok := columns[name]
if !ok || idx >= len(record) {
return ""
}
return strings.TrimSpace(record[idx])
}
staffID := value("user_id")
name := value("name")
if staffID == "" || name == "" {
return 0, fmt.Errorf("directory CSV line %d has empty user_id or name", line)
}
profiles = append(profiles, Profile{
StaffID: staffID,
UnionID: value("union_id"),
Name: name,
Nickname: value("nickname"),
JobTitle: value("title"),
DepartmentIDs: splitDepartmentIDs(value("department_ids")),
DepartmentName: value("dept_name"),
MainDepartmentName: value("main_department_name"),
Region: value("region"),
EmploymentStatus: value("employment_status"),
Source: SourceFullSync,
SyncedAt: parseSyncTime(value("sync_time")),
})
}
if len(profiles) == 0 {
return 0, errors.New("directory CSV snapshot is empty")
}
if err := writer.UpsertDirectoryProfiles(ctx, tenantKey, profiles); err != nil {
return 0, fmt.Errorf("write directory snapshot: %w", err)
}
return len(profiles), nil
}
func splitDepartmentIDs(raw string) []string {
parts := strings.FieldsFunc(raw, func(r rune) bool { return r == '/' || r == ',' || r == ';' })
out := make([]string, 0, len(parts))
for _, part := range parts {
if part = strings.TrimSpace(part); part != "" {
out = append(out, part)
}
}
return out
}
func parseSyncTime(raw string) time.Time {
for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05"} {
if parsed, err := time.Parse(layout, raw); err == nil {
return parsed
}
}
return time.Time{}
}
+87
View File
@@ -0,0 +1,87 @@
package directory
import (
"context"
"errors"
"strings"
"testing"
)
type recordingWriter struct {
calls int
tenant string
profiles []Profile
err error
}
func (w *recordingWriter) UpsertDirectoryProfiles(_ context.Context, tenant string, profiles []Profile) error {
w.calls++
w.tenant = tenant
w.profiles = append([]Profile(nil), profiles...)
return w.err
}
func TestImportCSVAcceptsTestedSyncOutputAndExtendedFields(t *testing.T) {
csv := "\ufeffuser_id,union_id,name,nickname,title,department_ids,dept_name,main_department_name,region,employment_status,sync_time,mobile\n" +
"staff-1,union-1, 张三 ,小张,工程师,100/200,公司/技术支持部,技术支持部,APAC,on_job,2026-07-22 02:30:00,13800000000\n"
writer := &recordingWriter{}
count, err := ImportCSV(context.Background(), "tenant-a", strings.NewReader(csv), writer)
if err != nil {
t.Fatalf("ImportCSV: %v", err)
}
if count != 1 || writer.calls != 1 || writer.tenant != "tenant-a" || len(writer.profiles) != 1 {
t.Fatalf("count=%d calls=%d tenant=%q profiles=%d", count, writer.calls, writer.tenant, len(writer.profiles))
}
p := writer.profiles[0]
if p.StaffID != "staff-1" || p.Name != "张三" || p.DepartmentName != "公司/技术支持部" || p.MainDepartmentName != "技术支持部" || p.Region != "APAC" || p.EmploymentStatus != "on_job" || p.Source != SourceFullSync {
t.Fatalf("profile mismatch: %+v", p)
}
if len(p.DepartmentIDs) != 2 || p.DepartmentIDs[0] != "100" || p.DepartmentIDs[1] != "200" {
t.Fatalf("department ids: %v", p.DepartmentIDs)
}
// The domain type intentionally has no mobile/email/raw payload fields.
}
func TestImportCSVAcceptsCurrentSevenColumnOutput(t *testing.T) {
csv := "user_id,union_id,name,nickname,title,department_ids,dept_name\n" +
"staff-2,union-2,李四,,经理,300,公司/销售部\n"
writer := &recordingWriter{}
count, err := ImportCSV(context.Background(), "tenant-a", strings.NewReader(csv), writer)
if err != nil {
t.Fatalf("ImportCSV: %v", err)
}
if count != 1 || writer.profiles[0].Name != "李四" || writer.profiles[0].Region != "" {
t.Fatalf("count=%d profile=%+v", count, writer.profiles[0])
}
}
func TestImportCSVRejectsInvalidSnapshotBeforeWrite(t *testing.T) {
tests := []struct {
name string
csv string
}{
{name: "missing required header", csv: "user_id,dept_name\nstaff-1,技术支持部\n"},
{name: "empty snapshot", csv: "user_id,name,dept_name\n"},
{name: "empty staff id", csv: "user_id,name,dept_name\n,张三,技术支持部\n"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
writer := &recordingWriter{}
_, err := ImportCSV(context.Background(), "tenant-a", strings.NewReader(tt.csv), writer)
if err == nil {
t.Fatal("expected error")
}
if writer.calls != 0 {
t.Fatalf("writer called %d times before validation", writer.calls)
}
})
}
}
func TestImportCSVPropagatesWriterError(t *testing.T) {
writer := &recordingWriter{err: errors.New("db unavailable")}
_, err := ImportCSV(context.Background(), "tenant-a", strings.NewReader("user_id,name,dept_name\nstaff-1,张三,技术支持部\n"), writer)
if err == nil || writer.calls != 1 {
t.Fatalf("error=%v calls=%d", err, writer.calls)
}
}
+32
View File
@@ -0,0 +1,32 @@
package directory
import (
"context"
"time"
)
const (
SourceFullSync = "full_sync"
SourceOnDemand = "on_demand"
)
// Profile is the minimum non-sensitive DingTalk directory record used by
// DobotAI. It deliberately excludes mobile, email, credentials and raw payloads.
type Profile struct {
StaffID string
UnionID string
Name string
Nickname string
JobTitle string
DepartmentIDs []string
DepartmentName string
MainDepartmentName string
Region string
EmploymentStatus string
Source string
SyncedAt time.Time
}
type SnapshotWriter interface {
UpsertDirectoryProfiles(ctx context.Context, tenantKey string, profiles []Profile) error
}
+65
View File
@@ -0,0 +1,65 @@
package directory
import (
"context"
"errors"
"strings"
"time"
)
type ProfileStore interface {
LookupDirectoryProfile(ctx context.Context, tenantKey, staffID string) (Profile, bool, error)
UpsertDirectoryProfiles(ctx context.Context, tenantKey string, profiles []Profile) error
}
type UserLookup interface {
LookupUser(ctx context.Context, staffID string) (Profile, error)
}
type Resolver struct {
store ProfileStore
lookup UserLookup
now func() time.Time
}
func NewResolver(store ProfileStore, lookup UserLookup) (*Resolver, error) {
if store == nil {
return nil, errors.New("directory profile store is required")
}
return &Resolver{store: store, lookup: lookup, now: time.Now}, nil
}
// ResolveUser always returns a safe callback-based fallback profile when the
// cache or on-demand lookup is unavailable. The diagnostic error is intentionally
// generic so identifiers and upstream payloads cannot leak into logs.
func (r *Resolver) ResolveUser(ctx context.Context, tenantKey, staffID, fallbackName string) (Profile, error) {
fallback := Profile{StaffID: strings.TrimSpace(staffID), Name: strings.TrimSpace(fallbackName)}
profile, found, err := r.store.LookupDirectoryProfile(ctx, tenantKey, fallback.StaffID)
if err != nil {
return fallback, errors.New("directory local lookup failed")
}
if found && strings.TrimSpace(profile.Name) != "" {
profile.Name = strings.TrimSpace(profile.Name)
return profile, nil
}
if r.lookup == nil {
return fallback, nil
}
profile, err = r.lookup.LookupUser(ctx, fallback.StaffID)
if err != nil {
return fallback, errors.New("directory on-demand lookup failed")
}
profile.StaffID = fallback.StaffID
profile.Name = strings.TrimSpace(profile.Name)
if profile.Name == "" {
return fallback, errors.New("directory on-demand lookup returned no name")
}
profile.Source = SourceOnDemand
if profile.SyncedAt.IsZero() {
profile.SyncedAt = r.now().UTC()
}
if err := r.store.UpsertDirectoryProfiles(ctx, tenantKey, []Profile{profile}); err != nil {
return profile, errors.New("directory on-demand cache write failed")
}
return profile, nil
}
+93
View File
@@ -0,0 +1,93 @@
package directory
import (
"context"
"errors"
"testing"
)
type fakeStore struct {
profile Profile
found bool
readErr error
writeErr error
writes int
}
func (s *fakeStore) LookupDirectoryProfile(context.Context, string, string) (Profile, bool, error) {
return s.profile, s.found, s.readErr
}
func (s *fakeStore) UpsertDirectoryProfiles(_ context.Context, _ string, profiles []Profile) error {
s.writes++
if len(profiles) > 0 {
s.profile = profiles[0]
}
return s.writeErr
}
type fakeLookup struct {
profile Profile
err error
calls int
}
func (l *fakeLookup) LookupUser(context.Context, string) (Profile, error) {
l.calls++
return l.profile, l.err
}
func TestResolverUsesLocalProfileWithoutRemoteLookup(t *testing.T) {
store := &fakeStore{profile: Profile{StaffID: "staff-1", Name: "张三", DepartmentName: "技术支持部", Region: "APAC"}, found: true}
lookup := &fakeLookup{}
resolver, err := NewResolver(store, lookup)
if err != nil { t.Fatal(err) }
profile, err := resolver.ResolveUser(context.Background(), "tenant-a", "staff-1", "昵称")
if err != nil { t.Fatalf("ResolveUser: %v", err) }
if profile.Name != "张三" || lookup.calls != 0 || store.writes != 0 {
t.Fatalf("profile=%+v lookup=%d writes=%d", profile, lookup.calls, store.writes)
}
}
func TestResolverLooksUpAndCachesMissingUser(t *testing.T) {
store := &fakeStore{}
lookup := &fakeLookup{profile: Profile{Name: "李四", DepartmentName: "销售部", Region: "EMEA"}}
resolver, err := NewResolver(store, lookup)
if err != nil { t.Fatal(err) }
profile, err := resolver.ResolveUser(context.Background(), "tenant-a", "staff-2", "昵称")
if err != nil { t.Fatalf("ResolveUser: %v", err) }
if profile.StaffID != "staff-2" || profile.Name != "李四" || profile.Source != SourceOnDemand || lookup.calls != 1 || store.writes != 1 {
t.Fatalf("profile=%+v lookup=%d writes=%d", profile, lookup.calls, store.writes)
}
}
func TestResolverFallsBackWithoutBlockingOnFailures(t *testing.T) {
tests := []struct{
name string
store *fakeStore
lookup *fakeLookup
}{
{name: "local read fails", store: &fakeStore{readErr: errors.New("db unavailable")}, lookup: &fakeLookup{}},
{name: "remote lookup fails", store: &fakeStore{}, lookup: &fakeLookup{err: errors.New("remote unavailable")}},
{name: "cache write fails but resolved profile survives", store: &fakeStore{writeErr: errors.New("db unavailable")}, lookup: &fakeLookup{profile: Profile{Name: "王五"}}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resolver, err := NewResolver(tt.store, tt.lookup)
if err != nil { t.Fatal(err) }
profile, resolveErr := resolver.ResolveUser(context.Background(), "tenant-a", "staff-3", " 回调昵称 ")
if profile.Name == "" { t.Fatalf("empty fallback profile: %+v", profile) }
if tt.name != "cache write fails but resolved profile survives" && profile.Name != "回调昵称" {
t.Fatalf("profile name=%q, want fallback", profile.Name)
}
if resolveErr == nil { t.Fatal("expected diagnostic error") }
})
}
}
func TestResolverWithoutRemoteUsesFallback(t *testing.T) {
resolver, err := NewResolver(&fakeStore{}, nil)
if err != nil { t.Fatal(err) }
profile, err := resolver.ResolveUser(context.Background(), "tenant-a", "staff-4", "赵六")
if err != nil || profile.Name != "赵六" || profile.StaffID != "staff-4" {
t.Fatalf("profile=%+v err=%v", profile, err)
}
}
+33 -20
View File
@@ -20,6 +20,7 @@ type AgentResolver interface {
type WeKnora interface {
CreateSession(ctx context.Context, title, description string) (string, error)
UpdateSessionTitle(ctx context.Context, sessionID, title, description string) error
AgentChat(ctx context.Context, sessionID string, request weknora.AgentChatRequest) (weknora.ChatResult, error)
}
@@ -135,23 +136,26 @@ func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserve
return core.QAResult{}, errors.New("unsupported message_type")
}
turn := core.Turn{
ID: newID(),
TenantKey: request.TenantKey,
Channel: request.Channel,
ExternalMessageID: request.ExternalMessageID,
SessionKey: sessionKey,
AgentID: agentID,
ConversationType: request.ConversationType,
ExternalChatID: request.ExternalChatID,
ExternalUserID: request.ExternalUserID,
SenderName: request.SenderName,
MessageType: messageType,
ActorID: newID(),
ConversationID: newID(),
RequestMessageID: newID(),
Question: request.Query,
Media: media,
Status: "processing",
ID: newID(),
TenantKey: request.TenantKey,
Channel: request.Channel,
ExternalMessageID: request.ExternalMessageID,
SessionKey: sessionKey,
AgentID: agentID,
ConversationType: request.ConversationType,
ConversationTitle: request.ConversationTitle,
ExternalChatID: request.ExternalChatID,
ExternalUserID: request.ExternalUserID,
SenderName: request.SenderName,
DepartmentSnapshot: request.Department,
RegionSnapshot: request.Region,
MessageType: messageType,
ActorID: newID(),
ConversationID: newID(),
RequestMessageID: newID(),
Question: request.Query,
Media: media,
Status: "processing",
}
stored, created, err := s.repository.BeginTurn(ctx, turn)
if err != nil {
@@ -168,12 +172,18 @@ func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserve
}
turn = stored
displayTitle := session.BuildDisplayTitle(session.TitleIdentity{
ConversationType: request.ConversationType,
ExternalUserID: request.ExternalUserID,
ExternalChatID: request.ExternalChatID,
SenderName: request.SenderName,
ConversationTitle: request.ConversationTitle,
})
mapping := core.SessionMapping{SessionKey: sessionKey, TenantKey: request.TenantKey,
Channel: request.Channel, AgentID: agentID, ConversationType: request.ConversationType,
ExternalSubjectID: subjectID}
ExternalSubjectID: subjectID, DisplayTitle: displayTitle}
sessionID, err := s.repository.ResolveSession(ctx, mapping, func(createCtx context.Context) (string, error) {
title := fmt.Sprintf("%s %s", request.Channel, subjectID)
return s.weknora.CreateSession(createCtx, title, "Managed by DobotAI; business state remains outside WeKnora")
return s.weknora.CreateSession(createCtx, displayTitle, "Managed by DobotAI; business state remains outside WeKnora")
})
if err != nil {
turn.Status, turn.ErrorMessage = "failed", err.Error()
@@ -181,6 +191,9 @@ func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserve
return core.QAResult{}, fmt.Errorf("resolve weknora session: %w", err)
}
turn.WeKnoraSessionID = sessionID
_ = s.repository.SyncSessionTitle(ctx, mapping, displayTitle, func(updateCtx context.Context, existingSessionID, title string) error {
return s.weknora.UpdateSessionTitle(updateCtx, existingSessionID, title, "Managed by DobotAI; business state remains outside WeKnora")
})
query := request.Query
if request.ConversationType == core.ConversationGroup && strings.TrimSpace(request.SenderName) != "" {
+173 -12
View File
@@ -13,16 +13,21 @@ import (
)
type memoryRepository struct {
mu sync.Mutex
turns map[string]core.Turn
sessions map[string]string
beginTurns []core.Turn
finishTurns []core.Turn
deliveries []core.DeliveryUpdate
mu sync.Mutex
turns map[string]core.Turn
sessions map[string]string
sessionTitles map[string]string
beginTurns []core.Turn
finishTurns []core.Turn
deliveries []core.DeliveryUpdate
}
func newMemoryRepository() *memoryRepository {
return &memoryRepository{turns: map[string]core.Turn{}, sessions: map[string]string{}}
return &memoryRepository{
turns: map[string]core.Turn{},
sessions: map[string]string{},
sessionTitles: map[string]string{},
}
}
func (r *memoryRepository) BeginTurn(_ context.Context, turn core.Turn) (core.Turn, bool, error) {
r.mu.Lock()
@@ -52,8 +57,25 @@ func (r *memoryRepository) ResolveSession(ctx context.Context, m core.SessionMap
return old, nil
}
r.sessions[m.SessionKey] = id
r.sessionTitles[m.SessionKey] = m.DisplayTitle
return id, nil
}
func (r *memoryRepository) SyncSessionTitle(ctx context.Context, m core.SessionMapping, title string, update core.SessionTitleUpdate) error {
r.mu.Lock()
id := r.sessions[m.SessionKey]
current := r.sessionTitles[m.SessionKey]
r.mu.Unlock()
if current == title {
return nil
}
if err := update(ctx, id, title); err != nil {
return err
}
r.mu.Lock()
r.sessionTitles[m.SessionKey] = title
r.mu.Unlock()
return nil
}
func (r *memoryRepository) FinishTurn(_ context.Context, turn core.Turn) error {
r.mu.Lock()
defer r.mu.Unlock()
@@ -84,16 +106,32 @@ func (r *recordingResolver) Resolve(channel, externalChatID string) (string, err
}
type fakeWeKnora struct {
mu sync.Mutex
sessions int
queries []string
requests []weknora.AgentChatRequest
mu sync.Mutex
sessions int
titles []string
updatedTitles []string
updateErrors []error
queries []string
requests []weknora.AgentChatRequest
}
func (f *fakeWeKnora) CreateSession(context.Context, string, string) (string, error) {
func (f *fakeWeKnora) UpdateSessionTitle(_ context.Context, _, title, _ string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.updatedTitles = append(f.updatedTitles, title)
if len(f.updateErrors) == 0 {
return nil
}
err := f.updateErrors[0]
f.updateErrors = f.updateErrors[1:]
return err
}
func (f *fakeWeKnora) CreateSession(_ context.Context, title, _ string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.sessions++
f.titles = append(f.titles, title)
return "session-1", nil
}
func (f *fakeWeKnora) AgentChat(_ context.Context, sessionID string, request weknora.AgentChatRequest) (weknora.ChatResult, error) {
@@ -166,6 +204,129 @@ func TestAskUsesSharedGroupSessionAndReplaysDuplicate(t *testing.T) {
}
}
func TestAskCreatesReadableDirectSessionTitle(t *testing.T) {
repo := newMemoryRepository()
client := &fakeWeKnora{}
service, err := New(repo, fixedResolver{}, client, session.GroupShared)
if err != nil {
t.Fatal(err)
}
_, err = service.Ask(context.Background(), core.QARequest{
TenantKey: "tenant", Channel: "dingtalk", ConversationType: core.ConversationDirect,
ExternalUserID: "staff-123456", ExternalMessageID: "message-direct-title",
SenderName: " 张三 ", Query: "question",
})
if err != nil {
t.Fatalf("Ask: %v", err)
}
client.mu.Lock()
defer client.mu.Unlock()
if len(client.titles) != 1 || client.titles[0] != "张三" {
t.Fatalf("created titles = %v, want [张三]", client.titles)
}
}
func TestAskCreatesReadableGroupSessionTitle(t *testing.T) {
repo := newMemoryRepository()
client := &fakeWeKnora{}
service, err := New(repo, fixedResolver{}, client, session.GroupShared)
if err != nil {
t.Fatal(err)
}
_, err = service.Ask(context.Background(), core.QARequest{
TenantKey: "tenant", Channel: "dingtalk", ConversationType: core.ConversationGroup,
ExternalChatID: "cid-987654", ExternalUserID: "staff-1", ExternalMessageID: "message-group-title",
SenderName: "张三", ConversationTitle: " NOVA项目群 ", Query: "question",
})
if err != nil {
t.Fatalf("Ask: %v", err)
}
client.mu.Lock()
defer client.mu.Unlock()
if len(client.titles) != 1 || client.titles[0] != "NOVA项目群" {
t.Fatalf("created titles = %v, want [NOVA项目群]", client.titles)
}
}
func TestAskSynchronizesExistingSessionTitleOnlyWhenChanged(t *testing.T) {
repo := newMemoryRepository()
key := "v1:tenant:dingtalk:agent-1:user:staff-1"
repo.sessions[key] = "session-existing"
repo.sessionTitles[key] = ""
client := &fakeWeKnora{}
service, err := New(repo, fixedResolver{}, client, session.GroupShared)
if err != nil {
t.Fatal(err)
}
_, err = service.Ask(context.Background(), core.QARequest{
TenantKey: "tenant", Channel: "dingtalk", ConversationType: core.ConversationDirect,
ExternalUserID: "staff-1", ExternalMessageID: "message-title-sync", SenderName: "张三", Query: "question",
})
if err != nil {
t.Fatalf("Ask: %v", err)
}
client.mu.Lock()
defer client.mu.Unlock()
if client.sessions != 0 || len(client.updatedTitles) != 1 || client.updatedTitles[0] != "张三" {
t.Fatalf("sessions=%d updatedTitles=%v", client.sessions, client.updatedTitles)
}
}
func TestAskSkipsExistingSessionTitleWhenUnchanged(t *testing.T) {
repo := newMemoryRepository()
key := "v1:tenant:dingtalk:agent-1:user:staff-1"
repo.sessions[key] = "session-existing"
repo.sessionTitles[key] = "张三"
client := &fakeWeKnora{}
service, err := New(repo, fixedResolver{}, client, session.GroupShared)
if err != nil {
t.Fatal(err)
}
_, err = service.Ask(context.Background(), core.QARequest{
TenantKey: "tenant", Channel: "dingtalk", ConversationType: core.ConversationDirect,
ExternalUserID: "staff-1", ExternalMessageID: "message-title-unchanged", SenderName: "张三", Query: "question",
})
if err != nil {
t.Fatalf("Ask: %v", err)
}
client.mu.Lock()
defer client.mu.Unlock()
if len(client.updatedTitles) != 0 {
t.Fatalf("unexpected title updates: %v", client.updatedTitles)
}
}
func TestAskRetriesFailedSessionTitleUpdateWithoutBlockingQA(t *testing.T) {
repo := newMemoryRepository()
key := "v1:tenant:dingtalk:agent-1:user:staff-1"
repo.sessions[key] = "session-existing"
repo.sessionTitles[key] = ""
client := &fakeWeKnora{updateErrors: []error{errors.New("temporary update failure"), nil}}
service, err := New(repo, fixedResolver{}, client, session.GroupShared)
if err != nil {
t.Fatal(err)
}
for _, messageID := range []string{"message-title-retry-1", "message-title-retry-2"} {
result, askErr := service.Ask(context.Background(), core.QARequest{
TenantKey: "tenant", Channel: "dingtalk", ConversationType: core.ConversationDirect,
ExternalUserID: "staff-1", ExternalMessageID: messageID, SenderName: "张三", Query: "question",
})
if askErr != nil || result.Answer != "answer" {
t.Fatalf("Ask(%s) result=%+v error=%v", messageID, result, askErr)
}
}
client.mu.Lock()
defer client.mu.Unlock()
if client.sessions != 0 || len(client.updatedTitles) != 2 {
t.Fatalf("sessions=%d updatedTitles=%v", client.sessions, client.updatedTitles)
}
repo.mu.Lock()
defer repo.mu.Unlock()
if repo.sessionTitles[key] != "张三" {
t.Fatalf("tracked title=%q, want 张三", repo.sessionTitles[key])
}
}
func TestAskPropagatesImagesToWeKnora(t *testing.T) {
repo := newMemoryRepository()
client := &fakeWeKnora{}
+48
View File
@@ -0,0 +1,48 @@
package session
import (
"strings"
"unicode/utf8"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
)
// TitleIdentity contains only the display inputs required to name a WeKnora
// session. Stable IDs remain the session identity; titles are display-only.
type TitleIdentity struct {
ConversationType core.ConversationType
ExternalUserID string
ExternalChatID string
SenderName string
ConversationTitle string
}
// BuildDisplayTitle returns a readable DingTalk session title without exposing
// the full stable ID. Direct titles contain only the employee name; department
// and region are deliberately not part of this contract.
func BuildDisplayTitle(identity TitleIdentity) string {
switch identity.ConversationType {
case core.ConversationGroup:
if title := strings.TrimSpace(identity.ConversationTitle); title != "" {
return title
}
return fallbackTitle("钉钉群聊", identity.ExternalChatID)
default:
if name := strings.TrimSpace(identity.SenderName); name != "" {
return name
}
return fallbackTitle("钉钉用户", identity.ExternalUserID)
}
}
func fallbackTitle(prefix, stableID string) string {
stableID = strings.TrimSpace(stableID)
if stableID == "" {
return prefix
}
runes := []rune(stableID)
if utf8.RuneCountInString(stableID) > 4 {
runes = runes[len(runes)-4:]
}
return prefix + "-" + string(runes)
}
+68
View File
@@ -0,0 +1,68 @@
package session
import (
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
)
func TestBuildDisplayTitle(t *testing.T) {
t.Parallel()
tests := []struct {
name string
in TitleIdentity
want string
}{
{
name: "direct uses employee name only",
in: TitleIdentity{
ConversationType: core.ConversationDirect,
ExternalUserID: "staff-123456",
SenderName: " 张三 ",
},
want: "张三",
},
{
name: "direct masks stable id when name unavailable",
in: TitleIdentity{
ConversationType: core.ConversationDirect,
ExternalUserID: "staff-123456",
},
want: "钉钉用户-3456",
},
{
name: "group uses conversation title",
in: TitleIdentity{
ConversationType: core.ConversationGroup,
ExternalChatID: "cid-987654",
ConversationTitle: " NOVA项目群 ",
},
want: "NOVA项目群",
},
{
name: "group masks stable id when title unavailable",
in: TitleIdentity{
ConversationType: core.ConversationGroup,
ExternalChatID: "cid-987654",
},
want: "钉钉群聊-7654",
},
{
name: "short id remains usable",
in: TitleIdentity{
ConversationType: core.ConversationDirect,
ExternalUserID: "u7",
},
want: "钉钉用户-u7",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := BuildDisplayTitle(tt.in); got != tt.want {
t.Fatalf("BuildDisplayTitle() = %q, want %q", got, tt.want)
}
})
}
}
+82
View File
@@ -0,0 +1,82 @@
package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/directory"
)
func (s *Store) LookupDirectoryProfile(ctx context.Context, tenantKey, staffID string) (directory.Profile, bool, error) {
tenantKey = strings.TrimSpace(tenantKey)
staffID = strings.TrimSpace(staffID)
if tenantKey == "" || staffID == "" {
return directory.Profile{}, false, errors.New("tenant and staff id are required")
}
var profile directory.Profile
var departmentIDs []byte
err := s.pool.QueryRow(ctx, `SELECT staff_id,union_id,name,nickname,job_title,department_ids,department_name,main_department_name,region,employment_status,source,synced_at
FROM directory_profiles WHERE tenant_key=$1 AND staff_id=$2`, tenantKey, staffID).Scan(
&profile.StaffID, &profile.UnionID, &profile.Name, &profile.Nickname, &profile.JobTitle,
&departmentIDs, &profile.DepartmentName, &profile.MainDepartmentName, &profile.Region,
&profile.EmploymentStatus, &profile.Source, &profile.SyncedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return directory.Profile{}, false, nil
}
if err != nil {
return directory.Profile{}, false, fmt.Errorf("lookup directory profile: %w", err)
}
if err := json.Unmarshal(departmentIDs, &profile.DepartmentIDs); err != nil {
return directory.Profile{}, false, errors.New("decode directory department ids")
}
return profile, true, nil
}
func (s *Store) UpsertDirectoryProfiles(ctx context.Context, tenantKey string, profiles []directory.Profile) error {
tenantKey = strings.TrimSpace(tenantKey)
if tenantKey == "" || len(profiles) == 0 {
return errors.New("tenant and non-empty directory profiles are required")
}
for _, profile := range profiles {
if strings.TrimSpace(profile.StaffID) == "" || strings.TrimSpace(profile.Name) == "" {
return errors.New("directory profile requires staff id and name")
}
}
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
for _, profile := range profiles {
departmentIDs, marshalErr := json.Marshal(profile.DepartmentIDs)
if marshalErr != nil {
return errors.New("encode directory department ids")
}
syncedAt := profile.SyncedAt
if syncedAt.IsZero() {
syncedAt = time.Now().UTC()
}
_, err = tx.Exec(ctx, `INSERT INTO directory_profiles
(tenant_key,staff_id,union_id,name,nickname,job_title,department_ids,department_name,main_department_name,region,employment_status,source,synced_at)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
ON CONFLICT (tenant_key,staff_id) DO UPDATE SET
union_id=EXCLUDED.union_id,name=EXCLUDED.name,nickname=EXCLUDED.nickname,job_title=EXCLUDED.job_title,
department_ids=EXCLUDED.department_ids,department_name=EXCLUDED.department_name,
main_department_name=EXCLUDED.main_department_name,region=EXCLUDED.region,
employment_status=EXCLUDED.employment_status,source=EXCLUDED.source,synced_at=EXCLUDED.synced_at,updated_at=NOW()`,
tenantKey, profile.StaffID, profile.UnionID, profile.Name, profile.Nickname, profile.JobTitle,
departmentIDs, profile.DepartmentName, profile.MainDepartmentName, profile.Region,
profile.EmploymentStatus, profile.Source, syncedAt)
if err != nil {
return fmt.Errorf("upsert directory profile: %w", err)
}
}
return tx.Commit(ctx)
}
@@ -0,0 +1,30 @@
package postgres
import (
"os"
"strings"
"testing"
)
func TestDirectoryStoreSQLContract(t *testing.T) {
raw, err := os.ReadFile("directory.go")
if err != nil {
t.Fatalf("read directory.go: %v", err)
}
src := strings.ToUpper(string(raw))
for _, required := range []string{
"SELECT STAFF_ID,UNION_ID,NAME,NICKNAME,JOB_TITLE,DEPARTMENT_IDS,DEPARTMENT_NAME,MAIN_DEPARTMENT_NAME,REGION,EMPLOYMENT_STATUS,SOURCE,SYNCED_AT",
"FROM DIRECTORY_PROFILES",
"INSERT INTO DIRECTORY_PROFILES",
"ON CONFLICT (TENANT_KEY,STAFF_ID) DO UPDATE",
} {
if !strings.Contains(src, required) {
t.Errorf("directory.go missing SQL contract %q", required)
}
}
for _, forbidden := range []string{"MOBILE", "EMAIL", "CLIENT_SECRET", "ACCESS_TOKEN", "RAW_PAYLOAD"} {
if strings.Contains(src, forbidden) {
t.Errorf("directory store contains forbidden sensitive field %q", forbidden)
}
}
}
@@ -0,0 +1,25 @@
package postgres
import (
"os"
"strings"
"testing"
)
func TestBeginTurnStoresDirectorySnapshotsAndGroupTitle(t *testing.T) {
raw, err := os.ReadFile("store.go")
if err != nil {
t.Fatalf("read store.go: %v", err)
}
src := strings.ToUpper(string(raw))
for _, required := range []string{
"DEPARTMENT_SNAPSHOT,REGION_SNAPSHOT",
"TURN.DEPARTMENTSNAPSHOT",
"TURN.REGIONSNAPSHOT",
"TURN.CONVERSATIONTITLE",
} {
if !strings.Contains(src, required) {
t.Errorf("store.go missing %q", required)
}
}
}
@@ -0,0 +1,28 @@
package postgres
import (
"os"
"strings"
"testing"
)
func TestStoreSessionTitleSQLContract(t *testing.T) {
raw, err := os.ReadFile("store.go")
if err != nil {
t.Fatalf("read store.go: %v", err)
}
src := strings.ToUpper(string(raw))
for _, required := range []string{
"INSERT INTO SESSION_MAPPINGS",
"DISPLAY_TITLE",
"SELECT WEKNORA_SESSION_ID,DISPLAY_TITLE FROM SESSION_MAPPINGS",
"UPDATE SESSION_MAPPINGS SET DISPLAY_TITLE=",
} {
if !strings.Contains(src, required) {
t.Errorf("store.go missing session-title SQL contract %q", required)
}
}
if !strings.Contains(string(raw), "func (s *Store) SyncSessionTitle") {
t.Error("Store must implement SyncSessionTitle")
}
}
+36 -5
View File
@@ -67,6 +67,8 @@ func (s *Store) BeginTurn(ctx context.Context, turn core.Turn) (core.Turn, bool,
conversationTitle := ""
if turn.ConversationType == core.ConversationDirect {
conversationTitle = strings.TrimSpace(turn.SenderName)
} else if turn.ConversationType == core.ConversationGroup {
conversationTitle = strings.TrimSpace(turn.ConversationTitle)
}
if err = tx.QueryRow(ctx, `INSERT INTO channel_conversations
(id,tenant_key,channel,external_conversation_id,conversation_type,title,status)
@@ -81,13 +83,13 @@ func (s *Store) BeginTurn(ctx context.Context, turn core.Turn) (core.Turn, bool,
if err = tx.QueryRow(ctx, `INSERT INTO messages
(id,tenant_key,channel,conversation_id,actor_id,external_message_id,correlation_id,
direction,role,message_type,text_content,media_json,status,actor_name_snapshot,safe_metadata_json)
VALUES($1,$2,$3,$4,$5,$6,$7,'inbound','user',$8,$9,$10,'received',$11,'{}'::JSONB)
direction,role,message_type,text_content,media_json,status,actor_name_snapshot,department_snapshot,region_snapshot,safe_metadata_json)
VALUES($1,$2,$3,$4,$5,$6,$7,'inbound','user',$8,$9,$10,'received',$11,$12,$13,'{}'::JSONB)
ON CONFLICT (tenant_key,channel,external_message_id) WHERE external_message_id!=''
DO UPDATE SET updated_at=messages.updated_at
RETURNING id`, turn.RequestMessageID, turn.TenantKey, turn.Channel, turn.ConversationID,
turn.ActorID, turn.ExternalMessageID, turn.ID, turn.MessageType, turn.Question, mediaJSON,
turn.SenderName).Scan(&turn.RequestMessageID); err != nil {
turn.SenderName, turn.DepartmentSnapshot, turn.RegionSnapshot).Scan(&turn.RequestMessageID); err != nil {
return core.Turn{}, false, err
}
@@ -137,14 +139,43 @@ func (s *Store) ResolveSession(ctx context.Context, mapping core.SessionMapping,
return "", err
}
_, err = tx.Exec(ctx, `INSERT INTO session_mappings
(session_key,tenant_key,channel,agent_id,conversation_type,external_subject_id,weknora_session_id)
VALUES($1,$2,$3,$4,$5,$6,$7)`, mapping.SessionKey, mapping.TenantKey, mapping.Channel, mapping.AgentID, string(mapping.ConversationType), mapping.ExternalSubjectID, sessionID)
(session_key,tenant_key,channel,agent_id,conversation_type,external_subject_id,weknora_session_id,display_title)
VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, mapping.SessionKey, mapping.TenantKey, mapping.Channel, mapping.AgentID, string(mapping.ConversationType), mapping.ExternalSubjectID, sessionID, mapping.DisplayTitle)
if err != nil {
return "", err
}
return sessionID, tx.Commit(ctx)
}
func (s *Store) SyncSessionTitle(ctx context.Context, mapping core.SessionMapping, title string, update core.SessionTitleUpdate) error {
title = strings.TrimSpace(title)
if strings.TrimSpace(mapping.SessionKey) == "" || title == "" || update == nil {
return errors.New("session key, title and update callback are required")
}
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, mapping.SessionKey); err != nil {
return err
}
var sessionID, currentTitle string
if err = tx.QueryRow(ctx, `SELECT weknora_session_id,display_title FROM session_mappings WHERE session_key=$1`, mapping.SessionKey).Scan(&sessionID, &currentTitle); err != nil {
return err
}
if currentTitle == title {
return tx.Commit(ctx)
}
if err = update(ctx, sessionID, title); err != nil {
return fmt.Errorf("update session title: %w", err)
}
if _, err = tx.Exec(ctx, `UPDATE session_mappings SET display_title=$2,updated_at=NOW() WHERE session_key=$1`, mapping.SessionKey, title); err != nil {
return err
}
return tx.Commit(ctx)
}
func (s *Store) FinishTurn(ctx context.Context, turn core.Turn) error {
references, err := json.Marshal(turn.References)
if err != nil {
+23 -2
View File
@@ -96,6 +96,28 @@ func (c *Client) CreateSession(ctx context.Context, title, description string) (
return response.Data.ID, nil
}
// UpdateSessionTitle updates only the readable title/description of an
// existing WeKnora session. The stable session ID is never changed.
func (c *Client) UpdateSessionTitle(ctx context.Context, sessionID, title, description string) error {
sessionID = strings.TrimSpace(sessionID)
title = strings.TrimSpace(title)
if sessionID == "" || title == "" {
return errors.New("session id and title are required")
}
var response struct {
Success bool `json:"success"`
}
if err := c.doJSON(ctx, http.MethodPut, "/api/v1/sessions/"+url.PathEscape(sessionID), map[string]string{
"title": title, "description": description,
}, &response); err != nil {
return err
}
if !response.Success {
return errors.New("weknora session update failed")
}
return nil
}
func (c *Client) AgentChat(ctx context.Context, sessionID string, request AgentChatRequest) (ChatResult, error) {
return c.AgentChatStream(ctx, sessionID, request, nil)
}
@@ -153,8 +175,7 @@ func (c *Client) doJSON(ctx context.Context, method, path string, input, output
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
message, _ := io.ReadAll(io.LimitReader(resp.Body, 32<<10))
return fmt.Errorf("weknora returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(message)))
return fmt.Errorf("weknora returned HTTP %d", resp.StatusCode)
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 2<<20)).Decode(output); err != nil {
return fmt.Errorf("decode weknora response: %w", err)
+62
View File
@@ -49,6 +49,68 @@ func TestClientCreateSessionAndAgentChat(t *testing.T) {
}
}
func TestClientUpdateSessionTitle(t *testing.T) {
var gotMethod, gotPath, gotTitle, gotDescription string
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/sessions/session-1", func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
if r.Header.Get("X-API-Key") != "test-key" {
t.Errorf("missing api key")
}
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
gotTitle = body["title"]
gotDescription = body["description"]
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"data":{"id":"session-1"}}`))
})
server := httptest.NewServer(mux)
defer server.Close()
client, err := NewClient(server.URL, "test-key", 5*time.Second)
if err != nil {
t.Fatalf("NewClient: %v", err)
}
if err := client.UpdateSessionTitle(context.Background(), "session-1", "张三", "Managed by DobotAI"); err != nil {
t.Fatalf("UpdateSessionTitle: %v", err)
}
if gotMethod != http.MethodPut || gotPath != "/api/v1/sessions/session-1" {
t.Fatalf("request = %s %s", gotMethod, gotPath)
}
if gotTitle != "张三" || gotDescription != "Managed by DobotAI" {
t.Fatalf("payload title=%q description=%q", gotTitle, gotDescription)
}
}
func TestClientUpdateSessionTitleSanitizesHTTPError(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/sessions/session-1", func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "raw-secret-body", http.StatusInternalServerError)
})
server := httptest.NewServer(mux)
defer server.Close()
client, err := NewClient(server.URL, "api-secret", 5*time.Second)
if err != nil {
t.Fatalf("NewClient: %v", err)
}
err = client.UpdateSessionTitle(context.Background(), "session-1", "customer-secret-title", "description")
if err == nil {
t.Fatal("expected error")
}
for _, forbidden := range []string{"raw-secret-body", "api-secret", "customer-secret-title"} {
if strings.Contains(err.Error(), forbidden) {
t.Fatalf("error leaked %q: %v", forbidden, err)
}
}
if !strings.Contains(err.Error(), "HTTP 500") {
t.Fatalf("error = %q, want HTTP status", err)
}
}
func TestParseSSEFailsClosedOnPrematureEOF(t *testing.T) {
_, err := parseSSE(strings.NewReader("data: {\"response_type\":\"answer\",\"content\":\"partial\",\"done\":false}\n\n"))
if !errors.Is(err, ErrIncompleteStream) {