WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
+182
View File
@@ -0,0 +1,182 @@
package im
import (
"context"
"io"
"github.com/gin-gonic/gin"
)
// Platform identifies an IM platform.
type Platform string
const (
PlatformWeCom Platform = "wecom"
PlatformFeishu Platform = "feishu"
// PlatformLark is Feishu's international edition (open.larksuite.com).
// It shares the Feishu adapter; only the API host and tenant differ.
PlatformLark Platform = "lark"
PlatformSlack Platform = "slack"
PlatformTelegram Platform = "telegram"
PlatformDingtalk Platform = "dingtalk"
PlatformMattermost Platform = "mattermost"
PlatformWeChat Platform = "wechat"
PlatformQQBot Platform = "qqbot"
PlatformYunzhijia Platform = "yunzhijia"
)
// SessionMode determines how IM sessions are resolved.
type SessionMode string
const (
// SessionModeUser resolves sessions by (platform, user_id, chat_id, tenant_id).
SessionModeUser SessionMode = "user"
// SessionModeThread resolves sessions by (platform, thread_id, chat_id, tenant_id).
SessionModeThread SessionMode = "thread"
)
// MessageType identifies the kind of IM message.
type MessageType string
const (
MessageTypeText MessageType = "text"
MessageTypeFile MessageType = "file"
MessageTypeImage MessageType = "image"
)
// IncomingMessage is the unified message parsed from an IM callback.
type IncomingMessage struct {
// Platform identifies which IM platform the message comes from.
Platform Platform
// MessageType is "text" (default) or "file".
MessageType MessageType
// UserID is the IM-platform user identifier.
UserID string
// UserName is the display name of the user (optional).
UserName string
// ChatID is the group/channel ID (empty for direct messages).
ChatID string
// ChatType distinguishes direct message from group chat.
ChatType ChatType
// Content is the text content of the message (empty for file messages).
Content string
// MessageID is the IM-platform message identifier (for dedup).
MessageID string
// FileKey is the platform file identifier (for file messages).
FileKey string
// FileName is the original file name (for file messages).
FileName string
// FileSize is the file size in bytes (for file messages, optional).
FileSize int64
// ThreadID is the platform-specific thread identifier.
// - Slack: thread_ts (top-level message uses its own timestamp)
// - Mattermost: root_id, or post_id if top-level
// - Feishu/Lark: root_id, or message_id if top-level
// - Telegram: message_thread_id (Forum Topics only)
// Empty for platforms without thread support (WeCom, DingTalk).
// In thread mode, top-level messages use their own ID as ThreadID,
// effectively creating a new session per top-level message.
ThreadID string
// Quote is the quoted/replied message, if any.
// Populated by adapters on platforms that support quote-reply.
Quote *QuotedMessage
// Extra holds platform-specific fields (e.g., WeCom stream ID).
Extra map[string]string
}
// QuotedMessage holds the content and metadata of a quoted/replied message.
// Populated by platform adapters that support quote-reply (e.g. WeCom long-connection).
type QuotedMessage struct {
// MessageID is the platform message ID of the quoted message.
MessageID string
// Content is the text content. Empty for non-text message types.
Content string
// SenderID is the platform user ID of the quoted message's author.
SenderID string
// IsBotMessage indicates whether the quoted message was from the bot.
IsBotMessage bool
// NonTextType records the original message type when the quoted message
// has no extractable text (e.g. "image", "file", "video").
// Empty when Content is populated. Used to generate LLM instructions
// instead of content placeholders that cause hallucination.
NonTextType string
}
// ChatType represents the IM chat type.
type ChatType string
const (
ChatTypeDirect ChatType = "direct"
ChatTypeGroup ChatType = "group"
)
// ReplyMessage is what WeKnora sends back to the IM platform.
type ReplyMessage struct {
// Content is the text content (Markdown).
Content string
// IsStreaming indicates whether this is a streaming chunk.
IsStreaming bool
// IsFinal marks the last chunk of a streaming reply.
IsFinal bool
// Extra holds platform-specific fields.
Extra map[string]string
}
// Adapter is the interface every IM platform must implement.
type Adapter interface {
// Platform returns the platform identifier.
Platform() Platform
// VerifyCallback verifies the signature/token of an incoming callback request.
// Returns nil if verification passes.
VerifyCallback(c *gin.Context) error
// ParseCallback parses the raw IM callback request into a unified IncomingMessage.
// Returns nil message for non-message events (e.g., URL verification).
ParseCallback(c *gin.Context) (*IncomingMessage, error)
// SendReply sends a reply back to the IM platform.
SendReply(ctx context.Context, incoming *IncomingMessage, reply *ReplyMessage) error
// HandleURLVerification handles the initial URL verification challenge from the IM platform.
// Returns true if this request is a verification request and has been handled.
HandleURLVerification(c *gin.Context) bool
}
// StreamSender is an optional interface that adapters can implement to support streaming replies.
// In stream output mode the IM service pushes answer chunks in real time. In
// full output mode it may use the same replaceable message only as a progress
// placeholder, then replace it once with the completed answer.
type StreamSender interface {
// StartStream initializes a streaming reply session (e.g., creates a streaming card).
// Returns a platform-specific stream ID for subsequent chunk/end calls.
StartStream(ctx context.Context, incoming *IncomingMessage) (string, error)
// UpdateStreamContent replaces the user-visible stream text with fullContent so far.
// Platforms with replace semantics (WeCom, Telegram edit, etc.) show this as the entire message.
UpdateStreamContent(ctx context.Context, incoming *IncomingMessage, streamID string, fullContent string) error
// FinalizeStream performs the final replace with answer-only content (thinking/tools stripped).
FinalizeStream(ctx context.Context, incoming *IncomingMessage, streamID string, finalContent string) error
// EndStream finalizes a streaming reply.
EndStream(ctx context.Context, incoming *IncomingMessage, streamID string) error
}
// FullOutputProgressSender is an optional capability for adapters whose stream
// starts with a visible placeholder that can safely be replaced once with the
// completed answer. Full output never calls UpdateStreamContent.
type FullOutputProgressSender interface {
StreamSender
SupportsFullOutputProgress() bool
}
// FileDownloader is an optional interface that adapters can implement to support
// downloading file attachments from the IM platform. It allows file/image
// messages to be supplied to QA as attachments; when a knowledge_base_id is
// configured, the same capability also enables asynchronous knowledge-base save.
type FileDownloader interface {
// DownloadFile downloads a file resource from the IM platform.
// Returns the file content reader, the resolved file name, and any error.
DownloadFile(ctx context.Context, msg *IncomingMessage) (io.ReadCloser, string, error)
}
+103
View File
@@ -0,0 +1,103 @@
package im
import (
"context"
"errors"
"fmt"
"strings"
"github.com/Tencent/WeKnora/internal/types"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type verifiedAgentExecutionKey struct{}
type verifiedAgentExecution struct {
TenantID uint64
AgentID, OwnerID, ChannelID string
PrincipalID string
}
// IsVerifiedAgentExecution accepts only a proof minted inside the IM runtime
// after reading an enabled channel and its exact Agent from durable storage.
// A synthetic user, IM principal or request parameter alone never grants it.
func IsVerifiedAgentExecution(ctx context.Context, agent *types.CustomAgent) bool {
proof, ok := ctx.Value(verifiedAgentExecutionKey{}).(verifiedAgentExecution)
if !ok || agent == nil || proof.ChannelID == "" || proof.AgentID != agent.ID || proof.TenantID != agent.TenantID || proof.OwnerID != agent.CreatedBy {
return false
}
tenant, hasTenant := types.TenantIDFromContext(ctx)
user, hasUser := types.UserIDFromContext(ctx)
principal, hasPrincipal := types.PrincipalFromContext(ctx)
return hasTenant && hasUser && tenant == proof.TenantID && user == fmt.Sprintf("system-%d", tenant) &&
hasPrincipal && principal.Type == types.PrincipalIMUser && principal.ID == proof.PrincipalID
}
func (s *Service) resolveConfiguredChannelExecution(ctx context.Context, channel *IMChannel) (context.Context, *types.CustomAgent, error) {
agent, err := s.resolveConfiguredChannelAgent(ctx, channel)
if err != nil {
return ctx, nil, err
}
principal, hasPrincipal := types.PrincipalFromContext(ctx)
tenant, hasTenant := types.TenantIDFromContext(ctx)
user, hasUser := types.UserIDFromContext(ctx)
if !hasPrincipal || principal.Type != types.PrincipalIMUser || !hasTenant || tenant != channel.TenantID || !hasUser || user != fmt.Sprintf("system-%d", tenant) {
return ctx, nil, fmt.Errorf("verified IM identity is required for channel execution")
}
proof := verifiedAgentExecution{TenantID: channel.TenantID, AgentID: agent.ID, OwnerID: agent.CreatedBy, ChannelID: channel.ID, PrincipalID: principal.ID}
return context.WithValue(ctx, verifiedAgentExecutionKey{}, proof), agent, nil
}
// InvalidateDeletedChannels is called only after durable deletion commits.
func (s *Service) InvalidateDeletedChannels(ids []string) {
for _, id := range ids {
s.StopChannel(id)
s.publishChannelConfigChange(id)
}
}
func (s *Service) cancelChannelRequests(id string) {
s.inflight.Range(func(key, value any) bool {
userKey, ok := key.(string)
if ok && strings.HasPrefix(userKey, id+":") {
if entry, ok := value.(*inflightEntry); ok && entry.cancel != nil {
entry.cancel()
}
}
return true
})
}
// Serialize authoring with agent deletion so a concurrent channel create or
// reassignment cannot leave a new channel pointing at a deleted custom agent.
func lockChannelAgent(tx *gorm.DB, channel *IMChannel) error {
if types.IsBuiltinAgentID(channel.AgentID) {
return nil // Built-in agents cannot be deleted.
}
var agent types.CustomAgent
return tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").
Where("id = ? AND tenant_id = ?", channel.AgentID, channel.TenantID).First(&agent).Error
}
// resolveConfiguredChannelAgent is a trusted runtime path, not a user Agent
// lookup. A persisted, enabled channel is the explicit authorization to execute
// exactly its configured Agent, including an owner's private digital human.
func (s *Service) resolveConfiguredChannelAgent(ctx context.Context, channel *IMChannel) (*types.CustomAgent, error) {
if channel == nil || channel.ID == "" || channel.AgentID == "" {
return nil, fmt.Errorf("channel has no configured agent")
}
var fresh IMChannel
if err := s.db.WithContext(ctx).Where("id = ? AND tenant_id = ? AND agent_id = ? AND enabled = ?", channel.ID, channel.TenantID, channel.AgentID, true).First(&fresh).Error; err != nil {
return nil, err
}
var agent types.CustomAgent
err := s.db.WithContext(ctx).Where("id = ? AND tenant_id = ?", fresh.AgentID, fresh.TenantID).First(&agent).Error
if errors.Is(err, gorm.ErrRecordNotFound) && types.IsBuiltinAgentID(fresh.AgentID) {
return s.agentService.GetAgentByID(ctx, fresh.AgentID)
}
if err != nil {
return nil, err
}
agent.EnsureDefaults()
return &agent, nil
}
+131
View File
@@ -0,0 +1,131 @@
package im
import (
"context"
"testing"
"time"
"github.com/Tencent/WeKnora/internal/types"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestDigitalHumanIMConfiguredAgentIsScopedAndNeverFallsBack(t *testing.T) {
db := newLifecycleTestDB(t)
channel := createLifecycleChannel(t, db, "private-channel", "private-agent")
var agent types.CustomAgent
require.NoError(t, db.First(&agent, "id = ?", channel.AgentID).Error)
agent.CreatedBy = "owner"
agent.Config.SystemPrompt = "<!-- weknora:digital-human:private:start -->Private persona"
require.NoError(t, db.Save(&agent).Error)
svc := newLifecycleTestService(db, nil, "one")
t.Cleanup(svc.Stop)
ctx := withIMIdentity(context.Background(), 1, channel.ID, &IncomingMessage{UserID: "external-user"})
resolved, err := svc.resolveConfiguredChannelAgent(ctx, channel)
require.NoError(t, err)
require.Contains(t, resolved.Config.SystemPrompt, "Private persona")
foreign := *channel
foreign.TenantID = 2
_, err = svc.resolveConfiguredChannelAgent(ctx, &foreign)
require.ErrorIs(t, err, gorm.ErrRecordNotFound)
foreign = *channel
foreign.AgentID = "unconfigured-agent"
_, err = svc.resolveConfiguredChannelAgent(ctx, &foreign)
require.ErrorIs(t, err, gorm.ErrRecordNotFound)
require.NoError(t, db.Delete(&agent).Error)
_, err = svc.resolveConfiguredChannelAgent(ctx, channel)
require.ErrorIs(t, err, gorm.ErrRecordNotFound)
}
func TestDigitalHumanIMLateStartupCannotOutliveDeletion(t *testing.T) {
db := newLifecycleTestDB(t)
channel := createLifecycleChannel(t, db, "late-channel", "agent")
svc := newLifecycleTestService(db, nil, "one")
t.Cleanup(svc.Stop)
entered, proceed, stopped := make(chan struct{}), make(chan struct{}), make(chan struct{})
svc.RegisterAdapterFactory("test", func(context.Context, *IMChannel, func(context.Context, *IncomingMessage) error) (Adapter, context.CancelFunc, error) {
close(entered)
<-proceed
return &lifecycleTestAdapter{}, func() { close(stopped) }, nil
})
started := make(chan error, 1)
go func() { started <- svc.StartChannel(channel) }()
select {
case <-entered:
case <-time.After(time.Second):
t.Fatal("factory did not start")
}
require.NoError(t, db.Delete(channel).Error)
svc.InvalidateDeletedChannels([]string{channel.ID})
close(proceed)
require.ErrorIs(t, <-started, gorm.ErrRecordNotFound)
select {
case <-stopped:
default:
t.Fatal("late adapter was not stopped")
}
_, _, active := svc.GetChannelAdapter(channel.ID)
require.False(t, active)
}
func TestDigitalHumanIMDeletedChannelStopsRuntimeAndRejectsStaleMessages(t *testing.T) {
db := newLifecycleTestDB(t)
channel := createLifecycleChannel(t, db, "deleted-channel", "agent")
svc := newLifecycleTestService(db, nil, "one")
counters := &lifecycleFactoryCounters{}
svc.RegisterAdapterFactory("test", counters.factory())
t.Cleanup(svc.Stop)
require.NoError(t, svc.StartChannel(channel))
requestCtx, cancel := context.WithCancel(context.Background())
defer cancel()
svc.inflight.Store(channel.ID+":user:chat", &inflightEntry{cancel: cancel})
otherCtx, otherCancel := context.WithCancel(context.Background())
defer otherCancel()
svc.inflight.Store("other:user:chat", &inflightEntry{cancel: otherCancel})
require.NoError(t, db.Delete(channel).Error)
// A missed invalidation event must still reject the next long-poll message.
require.Error(t, svc.HandleMessage(context.Background(), &IncomingMessage{Content: "hello"}, channel.ID))
require.Equal(t, int32(1), counters.stops.Load())
require.ErrorIs(t, requestCtx.Err(), context.Canceled)
require.NoError(t, otherCtx.Err())
_, _, active := svc.GetChannelAdapter(channel.ID)
require.False(t, active)
svc.InvalidateDeletedChannels([]string{channel.ID})
require.Equal(t, int32(1), counters.stops.Load())
}
func TestDigitalHumanIMChannelWritesCannotRestoreDeletedBindings(t *testing.T) {
db := newLifecycleTestDB(t)
channel := createLifecycleChannel(t, db, "channel", "agent")
svc := newLifecycleTestService(db, nil, "one")
t.Cleanup(svc.Stop)
stale := *channel
require.NoError(t, db.Delete(channel).Error)
require.ErrorIs(t, svc.UpdateChannel(&stale), gorm.ErrRecordNotFound)
var active int64
require.NoError(t, db.Model(&IMChannel{}).Where("id = ?", channel.ID).Count(&active).Error)
require.Zero(t, active)
require.NoError(t, db.Where("id = ?", "agent").Delete(&types.CustomAgent{}).Error)
stale.ID = "new-channel"
require.ErrorIs(t, svc.CreateChannel(&stale), gorm.ErrRecordNotFound)
require.NoError(t, db.Model(&IMChannel{}).Count(&active).Error)
require.Zero(t, active)
}
func TestDigitalHumanIMDisabledChannelRemainsDisabledAtCreation(t *testing.T) {
db := newLifecycleTestDB(t)
existing := createLifecycleChannel(t, db, "existing-channel", "agent")
channel := &IMChannel{ID: "disabled-channel", TenantID: existing.TenantID, AgentID: existing.AgentID,
Platform: "test", Name: "Disabled connection", Enabled: false, Credentials: types.JSON("{}")}
svc := newLifecycleTestService(db, nil, "one")
t.Cleanup(svc.Stop)
counters := &lifecycleFactoryCounters{}
svc.RegisterAdapterFactory("test", counters.factory())
require.NoError(t, svc.CreateChannel(channel))
require.False(t, channel.Enabled)
var saved IMChannel
require.NoError(t, db.First(&saved, "id = ?", channel.ID).Error)
require.False(t, saved.Enabled)
_, _, active := svc.GetChannelAdapter(channel.ID)
require.False(t, active)
}
+36
View File
@@ -0,0 +1,36 @@
package im
import (
"context"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/stretchr/testify/require"
)
func TestRelocalizeBuiltinChannelAgentNames(t *testing.T) {
restore := types.OverrideBuiltinAgentEntriesForTest(map[string]*types.BuiltinAgentEntry{
types.BuiltinQuickAnswerID: {
ID: types.BuiltinQuickAnswerID,
Avatar: "quick.png",
I18n: map[string]types.BuiltinAgentI18n{
"default": {Name: "快速问答"},
"en-US": {Name: "Quick Answer"},
"zh-CN": {Name: "快速问答"},
},
},
})
t.Cleanup(restore)
rows := []ChannelWithAgent{
{AgentID: "custom-1", TenantID: 1, AgentName: "Mine"},
{AgentID: types.BuiltinQuickAnswerID, TenantID: 1, AgentName: ""},
{AgentID: types.BuiltinQuickAnswerID, TenantID: 1, AgentName: "frozen-zh"},
}
ctx := context.WithValue(context.Background(), types.LanguageContextKey, "en-US")
relocalizeBuiltinChannelAgentNames(ctx, rows)
require.Equal(t, "Mine", rows[0].AgentName)
require.Equal(t, "Quick Answer", rows[1].AgentName)
require.Equal(t, "Quick Answer", rows[2].AgentName)
}
+34
View File
@@ -0,0 +1,34 @@
package im
import (
"testing"
"github.com/Tencent/WeKnora/internal/types"
)
// Files uploaded through an IM channel are tagged with a knowledge Channel.
// Lark deliberately shares Feishu's channel (types.ChannelFeishu documents
// itself as "Feishu / Lark"), so Lark uploads group with Feishu ones in the KB
// UI rather than falling through to the generic "im" channel.
func TestIMPlatformToChannel(t *testing.T) {
cases := map[string]string{
"feishu": types.ChannelFeishu,
"lark": types.ChannelFeishu,
"Lark": types.ChannelFeishu, // matching is case-insensitive
"wechat": types.ChannelWechat,
"wecom": types.ChannelWecom,
"wxwork": types.ChannelWecom,
"dingtalk": types.ChannelDingtalk,
"slack": types.ChannelSlack,
// Platforms without a dedicated channel fall back to the generic one.
"telegram": types.ChannelIM,
"": types.ChannelIM,
}
for platform, want := range cases {
if got := imPlatformToChannel(platform); got != want {
t.Errorf("imPlatformToChannel(%q) = %q, want %q", platform, got, want)
}
}
}
+22
View File
@@ -0,0 +1,22 @@
package im
import "context"
// ClearCommand implements /clear.
// It soft-deletes the current ChannelSession and clears the LLM context so
// the next message starts a completely fresh conversation.
type ClearCommand struct{}
func newClearCommand() *ClearCommand { return &ClearCommand{} }
func (c *ClearCommand) Name() string { return "clear" }
func (c *ClearCommand) Description() string {
return "清空对话记忆,下次消息将开始全新会话"
}
func (c *ClearCommand) Execute(_ context.Context, _ *CommandContext, _ []string) (*CommandResult, error) {
return &CommandResult{
Content: "✅ 对话已清空,下次消息将开始全新会话。",
Action: ActionClear,
}, nil
}
+50
View File
@@ -0,0 +1,50 @@
package im
import (
"context"
"fmt"
"sort"
"strings"
)
// HelpCommand implements /help [command].
type HelpCommand struct {
registry *CommandRegistry
}
func newHelpCommand(registry *CommandRegistry) *HelpCommand {
return &HelpCommand{registry: registry}
}
func (c *HelpCommand) Name() string { return "help" }
func (c *HelpCommand) Description() string {
return "显示可用指令列表,或查看某个指令的详细用法"
}
func (c *HelpCommand) Execute(_ context.Context, _ *CommandContext, args []string) (*CommandResult, error) {
// /help <command> — show detailed usage for a specific command
if len(args) > 0 {
name := strings.ToLower(args[0])
cmd, _, ok := c.registry.Parse("/" + name)
if !ok {
return &CommandResult{
Content: fmt.Sprintf("未知指令 `%s`,发送 `/help` 查看所有可用指令。", args[0]),
}, nil
}
return &CommandResult{
Content: fmt.Sprintf("**/%s** — %s", cmd.Name(), cmd.Description()),
}, nil
}
// /help — list all commands sorted by name
cmds := c.registry.All()
sort.Slice(cmds, func(i, j int) bool { return cmds[i].Name() < cmds[j].Name() })
var sb strings.Builder
sb.WriteString("**可用指令**\n\n")
for _, cmd := range cmds {
sb.WriteString(fmt.Sprintf("· `/%s` — %s\n", cmd.Name(), cmd.Description()))
}
sb.WriteString("\n发送 `/help <指令名>` 查看详细用法")
return &CommandResult{Content: sb.String()}, nil
}
+132
View File
@@ -0,0 +1,132 @@
package im
import (
"context"
"fmt"
"strings"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
// InfoCommand implements /info.
// It shows the bound agent's profile and capabilities so IM users can
// understand what the bot can do without leaving the chat.
type InfoCommand struct {
kbService interfaces.KnowledgeBaseService
}
func newInfoCommand(kbService interfaces.KnowledgeBaseService) *InfoCommand {
return &InfoCommand{kbService: kbService}
}
func (c *InfoCommand) Name() string { return "info" }
func (c *InfoCommand) Description() string { return "查看当前智能体的信息与能力" }
func (c *InfoCommand) Execute(ctx context.Context, cmdCtx *CommandContext, _ []string) (*CommandResult, error) {
var sb strings.Builder
// Note: Feishu card markdown only renders **bold** when it occupies the
// entire inline segment. "**label:**value" on the same line will show
// raw asterisks. Always keep bold text self-contained on its own line.
// ── Header ──
name := cmdCtx.AgentName
if name == "" {
name = "未命名智能体"
}
sb.WriteString(fmt.Sprintf("🤖 **%s**\n", name))
if cmdCtx.CustomAgent != nil && cmdCtx.CustomAgent.Description != "" {
sb.WriteString(fmt.Sprintf("> %s\n", cmdCtx.CustomAgent.Description))
}
if cmdCtx.CustomAgent == nil {
sb.WriteString("\n未绑定智能体,发送 `/help` 查看可用指令。")
return &CommandResult{Content: sb.String()}, nil
}
cfg := cmdCtx.CustomAgent.Config
// ── Mode ──
if cmdCtx.CustomAgent.IsAgentMode() {
sb.WriteString("\n🧠 **Agent模式**\n")
sb.WriteString("支持多步思考、工具调用(ReAct)\n")
} else {
sb.WriteString("\n🧠 **Agent模式**\n")
sb.WriteString("基于知识库检索直接回答(RAG)\n")
}
// ── Knowledge bases ──
// KBSelectionMode: "all" uses every KB under the tenant (IDs list is empty),
// "selected" uses the explicit KnowledgeBases list, "none"/empty means disabled.
sb.WriteString("\n📚 **知识库**\n")
if cfg.KBSelectionMode == "all" {
kbs, err := c.kbService.ListKnowledgeBasesByTenantID(ctx, cmdCtx.TenantID)
if err == nil && len(kbs) > 0 {
for _, kb := range kbs {
sb.WriteString(fmt.Sprintf(" · %s\n", kb.Name))
}
sb.WriteString(fmt.Sprintf(" 共 %d 个(全部启用)\n", len(kbs)))
} else {
sb.WriteString(" 全部启用\n")
}
} else if len(cfg.KnowledgeBases) > 0 {
kbs, err := c.kbService.ListKnowledgeBasesByTenantID(ctx, cmdCtx.TenantID)
if err == nil {
nameMap := make(map[string]string, len(kbs))
for _, kb := range kbs {
nameMap[kb.ID] = kb.Name
}
for _, id := range cfg.KnowledgeBases {
label := id
if n, ok := nameMap[id]; ok {
label = n
}
sb.WriteString(fmt.Sprintf(" · %s\n", label))
}
} else {
sb.WriteString(fmt.Sprintf(" 已选择 %d 个\n", len(cfg.KnowledgeBases)))
}
} else {
sb.WriteString(" 未配置\n")
}
// ── Skills ──
sb.WriteString("\n⚡ **Skills**\n")
if cfg.SkillsSelectionMode == "all" {
sb.WriteString(" 全部启用\n")
} else if cfg.SkillsSelectionMode == "selected" && len(cfg.SelectedSkills) > 0 {
for _, s := range cfg.SelectedSkills {
sb.WriteString(fmt.Sprintf(" · %s\n", s))
}
} else {
sb.WriteString(" 未配置\n")
}
// ── MCP ──
sb.WriteString("\n🔌 **MCP 服务**\n")
if cfg.MCPSelectionMode == "all" {
sb.WriteString(" 全部接入\n")
} else if cfg.MCPSelectionMode == "selected" && len(cfg.MCPServices) > 0 {
sb.WriteString(fmt.Sprintf(" 已接入 %d 个服务\n", len(cfg.MCPServices)))
} else {
sb.WriteString(" 未配置\n")
}
// ── Web search ──
sb.WriteString("\n🌐 **网络搜索**\n")
if cfg.WebSearchEnabled {
sb.WriteString(" 已启用\n")
} else {
sb.WriteString(" 未启用\n")
}
// ── Footer ──
outputLabel := "流式输出"
if cmdCtx.ChannelOutputMode == "full" {
outputLabel = "完整输出"
}
sb.WriteString(fmt.Sprintf("\n⚙️ **输出模式**\n %s\n", outputLabel))
sb.WriteString("\n---\n发送 `/help` 查看所有可用指令")
return &CommandResult{Content: sb.String()}, nil
}
+135
View File
@@ -0,0 +1,135 @@
package im
import (
"context"
"fmt"
"strings"
"github.com/Tencent/WeKnora/internal/agent/tools"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
const (
searchMaxResults = 5
searchContentMaxLen = 200 // runes shown per result
)
// SearchCommand implements /search <query>.
//
// It runs a hybrid search (vector + keywords) against the user's selected
// knowledge bases—or the bot-level defaults when no override is active—and
// returns the raw matching passages without AI summarisation. This is useful
// when the user needs to inspect source text directly.
type SearchCommand struct {
sessionService interfaces.SessionService
kbService interfaces.KnowledgeBaseService
}
func newSearchCommand(sessionService interfaces.SessionService, kbService interfaces.KnowledgeBaseService) *SearchCommand {
return &SearchCommand{sessionService: sessionService, kbService: kbService}
}
func (c *SearchCommand) Name() string { return "search" }
func (c *SearchCommand) Description() string {
return "直接检索知识库原文(不经 AI 总结),例如:/search 退款政策"
}
func (c *SearchCommand) Execute(ctx context.Context, cmdCtx *CommandContext, args []string) (*CommandResult, error) {
if len(args) == 0 {
return &CommandResult{
Content: "请输入搜索内容,例如:`/search 退款政策`",
}, nil
}
query := strings.Join(args, " ")
// Resolve which KBs to search, mirroring the logic in the QA pipeline's
// resolveKnowledgeBasesFromAgent so that /search covers the same scope.
var kbIDs []string
if cmdCtx.CustomAgent != nil {
switch cmdCtx.CustomAgent.Config.KBSelectionMode {
case "all":
allKBs, err := c.kbService.ListKnowledgeBases(ctx)
if err == nil {
// Same capability filter as the QA pipeline's
// resolveKnowledgeBasesFromAgent (`all` branch) so `/search`
// agrees with what the agent's tools can actually reach.
// Agent-mode aware: quick-answer enforces RAG-only KBs.
agentMode := cmdCtx.CustomAgent.Config.AgentMode
allowed := cmdCtx.CustomAgent.Config.AllowedTools
filter := tools.DeriveKBFilterForAgent(agentMode, allowed)
skipped := 0
for _, kb := range allKBs {
if !filter.IsEmpty() &&
!tools.KBSatisfiesAgentRequirements(kb.Capabilities(), agentMode, allowed) {
skipped++
continue
}
kbIDs = append(kbIDs, kb.ID)
}
if skipped > 0 {
logger.Infof(ctx,
"/search(agent=%s, mode=all): capability filter removed %d of %d KBs",
cmdCtx.CustomAgent.ID, skipped, len(allKBs))
}
}
case "none":
// No knowledge bases configured — will return empty results.
case "selected":
kbIDs = cmdCtx.CustomAgent.Config.KnowledgeBases
default:
// Backward compatibility: fall back to configured list.
kbIDs = cmdCtx.CustomAgent.Config.KnowledgeBases
}
}
results, err := c.sessionService.SearchKnowledge(ctx, kbIDs, nil, nil, query)
if err != nil {
return nil, fmt.Errorf("search knowledge: %w", err)
}
if len(results) == 0 {
return &CommandResult{
Content: fmt.Sprintf("未在知识库中找到与「%s」相关的内容。", query),
}, nil
}
// Cap the number of results shown in IM (wall of text is unhelpful).
shown := results
if len(shown) > searchMaxResults {
shown = shown[:searchMaxResults]
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("🔍 **搜索「%s」** — 找到 %d 条结果\n\n", query, len(results)))
for i, r := range shown {
// Trim content to a readable length.
content := []rune(r.Content)
suffix := ""
if len(content) > searchContentMaxLen {
content = content[:searchContentMaxLen]
suffix = "…"
}
// Source label: prefer title, fall back to filename.
source := r.KnowledgeTitle
if source == "" {
source = r.KnowledgeFilename
}
sb.WriteString(fmt.Sprintf("**[%d]** %s\n> %s%s\n", i+1, source, string(content), suffix))
if r.Score > 0 {
sb.WriteString(fmt.Sprintf("匹配度:%.0f%%\n", r.Score*100))
}
sb.WriteString("\n")
}
if len(results) > searchMaxResults {
sb.WriteString(fmt.Sprintf("_(仅显示前 %d 条,共 %d 条)_", searchMaxResults, len(results)))
}
return &CommandResult{Content: sb.String()}, nil
}
+21
View File
@@ -0,0 +1,21 @@
package im
import "context"
// StopCommand implements /stop.
// It cancels the in-flight QA request for the current user+chat, allowing the
// user to abort a long-running ReAct reasoning chain without waiting for it to
// complete. If no request is in progress the command simply acknowledges.
type StopCommand struct{}
func newStopCommand() *StopCommand { return &StopCommand{} }
func (c *StopCommand) Name() string { return "stop" }
func (c *StopCommand) Description() string { return "中止当前正在进行的回答" }
func (c *StopCommand) Execute(_ context.Context, _ *CommandContext, _ []string) (*CommandResult, error) {
return &CommandResult{
Content: "✅ 已请求中止当前回答。",
Action: ActionStop,
}, nil
}
+66
View File
@@ -0,0 +1,66 @@
package im
import (
"context"
"github.com/Tencent/WeKnora/internal/types"
)
// CommandAction represents a service-level side effect that a command requests.
// Using an enum keeps commands free of service/DB dependencies—they declare intent,
// the Service executes it.
type CommandAction int
const (
// ActionNone means no side effect beyond sending the reply.
ActionNone CommandAction = iota
// ActionClear soft-deletes the current ChannelSession and clears the LLM
// context so the next message creates a completely fresh conversation.
ActionClear
// ActionStop cancels the in-flight QA request for this user+chat.
ActionStop
)
// CommandResult is the output produced by a Command.Execute call.
type CommandResult struct {
// Content is the Markdown reply sent back to the user.
Content string
// Action requests a service-level side effect (reset, clear, …).
Action CommandAction
}
// CommandContext carries all runtime data a command needs during execution.
// Services are NOT here; inject them into command structs at construction time.
type CommandContext struct {
// Incoming is the raw IM message that triggered the command.
Incoming *IncomingMessage
// Session is the IM channel session for this user×chat combination.
Session *ChannelSession
// TenantID is the tenant that owns this bot deployment.
TenantID uint64
// AgentName is the display name of the bound agent (empty if none).
AgentName string
// CustomAgent is the bound agent configuration. Commands that need to
// inspect agent-level settings (e.g. /search reading KBSelectionMode)
// can access it directly. May be nil when no agent is bound.
CustomAgent *types.CustomAgent
// ChannelOutputMode is the channel-level output mode configured by the admin
// ("stream" or "full").
ChannelOutputMode string
}
// Command is the interface every IM slash-command must implement.
//
// Design rules:
// - Dependencies (DB, services) are injected at construction time.
// - Validation errors (bad args, entity not found) are returned as a
// CommandResult with a helpful message, NOT as an error.
// - error is reserved for infrastructure failures (DB errors, network, …).
type Command interface {
// Name is the primary token used after "/" (e.g. "kb", "mode").
Name() string
// Description is the one-line summary shown in /help output.
Description() string
// Execute runs the command and returns a reply to send to the user.
Execute(ctx context.Context, cmdCtx *CommandContext, args []string) (*CommandResult, error)
}
+92
View File
@@ -0,0 +1,92 @@
package im
import "strings"
// CommandRegistry maps slash-command names to their handlers.
type CommandRegistry struct {
commands map[string]Command
}
// NewCommandRegistry returns an empty registry.
func NewCommandRegistry() *CommandRegistry {
return &CommandRegistry{commands: make(map[string]Command)}
}
// Register adds cmd to the registry under its Name(). Panics on duplicate names
// to surface misconfiguration at startup rather than silently ignoring it.
func (r *CommandRegistry) Register(cmd Command) {
key := strings.ToLower(cmd.Name())
if _, exists := r.commands[key]; exists {
panic("im: duplicate command registration: " + key)
}
r.commands[key] = cmd
}
// Parse checks whether content is a slash-command and, if so, returns the
// matching Command and the remaining tokens as args.
//
// It returns (nil, nil, false) when:
// - content does not start with "/"
// - the first token after "/" has no registered handler
//
// Note: unrecognised slash-words are deliberately NOT matched here so that
// the caller can decide whether to treat them as unknown commands (show help)
// or pass them through to the QA pipeline (e.g. "/api/v2/users" paths).
// Use LooksLikeCommand to distinguish the two cases.
func (r *CommandRegistry) Parse(content string) (Command, []string, bool) {
content = strings.TrimSpace(content)
if !strings.HasPrefix(content, "/") {
return nil, nil, false
}
parts := strings.Fields(content[1:])
if len(parts) == 0 {
return nil, nil, false
}
name := strings.ToLower(parts[0])
cmd, ok := r.commands[name]
if !ok {
return nil, nil, false
}
return cmd, parts[1:], true
}
// IsRegistered returns true when content starts with a registered command name.
// It is cheaper than Parse because it does not allocate a result.
func (r *CommandRegistry) IsRegistered(content string) bool {
content = strings.TrimSpace(content)
if !strings.HasPrefix(content, "/") {
return false
}
parts := strings.Fields(content[1:])
if len(parts) == 0 {
return false
}
_, ok := r.commands[strings.ToLower(parts[0])]
return ok
}
// All returns every registered command.
func (r *CommandRegistry) All() []Command {
cmds := make([]Command, 0, len(r.commands))
for _, cmd := range r.commands {
cmds = append(cmds, cmd)
}
return cmds
}
// LooksLikeCommand returns true when content appears to be a command attempt—
// it starts with "/" and the first token contains no further "/" separators.
//
// This distinguishes "/help" (command attempt) from "/api/v2/users" (URL path
// that should fall through to the QA pipeline).
func LooksLikeCommand(content string) bool {
content = strings.TrimSpace(content)
if !strings.HasPrefix(content, "/") {
return false
}
parts := strings.Fields(content[1:])
if len(parts) == 0 {
return false
}
return !strings.Contains(parts[0], "/")
}
+414
View File
@@ -0,0 +1,414 @@
package im
import (
"context"
"regexp"
"strings"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestStripIMCitationTags(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{"no tags", "hello world", "hello world"},
{"kb tag", `text <kb id="1" title="doc"/> more`, "text more"},
{"web tag", `see <web url="http://x.com"/> here`, "see here"},
{"multiple", `<kb id="1"/><web url="x"/>text`, "text"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, stripIMCitationTags(tt.in))
})
}
}
func TestStripImageXMLTags(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{
"with image_original",
`<image url="local://1/img.png">
<image_original>![alt](local://1/img.png)</image_original>
<image_caption>a cat</image_caption>
</image>`,
"![alt](local://1/img.png)",
},
{
"without image_original",
`<image url="local://1/img.png">
<image_caption>a cat</image_caption>
</image>`,
"",
},
{
"no image tags",
"just plain text ![img](http://example.com/img.png)",
"just plain text ![img](http://example.com/img.png)",
},
{
"mixed content",
`before <image url="x"><image_original>![a](b)</image_original></image> after`,
"before ![a](b) after",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, stripImageXMLTags(tt.in))
})
}
}
func TestFindIncompleteXMLTag(t *testing.T) {
tests := []struct {
name string
in string
want int
}{
{
"complete tag",
`text <kb id="1"/> more`,
-1,
},
{
"incomplete kb tag",
`text <kb id="1`,
5,
},
{
"incomplete image tag",
`text <image url="local://1/img`,
5,
},
{
"incomplete image_original",
`<image_original>![alt](local://1/`,
-1, // the tag itself is complete (has >), the content is truncated
},
{
"incomplete opening image_original",
`text <image_original`,
5,
},
{
"no XML",
"just plain text",
-1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := findIncompleteXMLTag(tt.in)
assert.Equal(t, tt.want, got, "findIncompleteXMLTag(%q)", tt.in)
})
}
}
func TestResolveIMFileServiceForPath_LocalSchemeDespiteCOSDefault(t *testing.T) {
t.Setenv("SYSTEM_AES_KEY", "weknora-test-aes-key-32bytes!!!")
t.Setenv("APP_EXTERNAL_URL", "https://weknora.example.com")
tenant := &types.Tenant{
StorageEngineConfig: &types.StorageEngineConfig{
DefaultProvider: "cos",
COS: &types.COSEngineConfig{
SecretID: "id",
SecretKey: "key",
BucketName: "bucket",
Region: "ap-shanghai",
},
},
}
svc := resolveIMFileServiceForPath(tenant, "local://10000/exports/img.png", nil)
require.NotNil(t, svc)
got, err := svc.GetFileURL(context.Background(), "local://10000/exports/img.png")
require.NoError(t, err)
assert.Contains(t, got, "/api/v1/files/presigned")
}
func TestRewriteStorageURLs_LocalUsesPresignedAPI(t *testing.T) {
t.Setenv("SYSTEM_AES_KEY", "weknora-test-aes-key-32bytes!!!")
t.Setenv("APP_EXTERNAL_URL", "https://weknora.example.com")
tenant := &types.Tenant{
StorageEngineConfig: &types.StorageEngineConfig{
DefaultProvider: "cos",
COS: &types.COSEngineConfig{
SecretID: "id",
SecretKey: "key",
BucketName: "bucket",
Region: "ap-shanghai",
},
},
}
in := "![img](local://10000/exports/abc.png)"
out := rewriteStorageURLs(context.Background(), in, newIMFileServiceResolver(tenant, nil))
assert.Contains(t, out, "/api/v1/files/presigned")
assert.NotContains(t, out, "myqcloud.com")
}
func TestRewriteStorageURLs_COSPathNotSignedAsLocalKey(t *testing.T) {
// Without real COS credentials, GetFileURL may fail; ensure we never embed
// local:// as a COS object key when rewriting fails.
tenant := &types.Tenant{
StorageEngineConfig: &types.StorageEngineConfig{
DefaultProvider: "cos",
COS: &types.COSEngineConfig{
SecretID: "id",
SecretKey: "key",
BucketName: "test-bucket",
Region: "ap-shanghai",
PathPrefix: "weknora",
},
},
}
path := "cos://test-bucket/ap-shanghai/weknora/10000/exports/abc.png"
svc := resolveIMFileServiceForPath(tenant, path, nil)
require.NotNil(t, svc)
in := "![img](" + path + ")"
out := rewriteStorageURLs(context.Background(), in, newIMFileServiceResolver(tenant, nil))
if out != in {
assert.False(t, strings.Contains(out, "local%3A"), "COS URL must not treat local:// as object key")
}
}
func TestHoldbackCutoff(t *testing.T) {
tests := []struct {
name string
in string
want int // expected cutoff (len(in) means no holdback)
}{
{
"no holdback needed",
"plain text with complete ![img](local://1/img.png) content",
-1,
},
{
"truncated URL inside markdown image",
"text ![img](local://1/abc/im",
5, // hold back from ![ — not from local://
},
{
"truncated markdown image with quotes in alt",
"### 2\n![知识助理\"知识库\"管理视图界面](minio://wizard-test/10000/exports/c91cf852",
-2, // computed via strings.Index below
},
{
"truncated XML",
"text <kb id=",
5,
},
{
"both truncated, XML earlier",
"<image url=\"local://1/im",
0,
},
{
"bare truncated provider URL without markdown wrapper",
"prefix minio://wizard-test/10000/exp",
7,
},
{
"bare truncated scoped URL without markdown wrapper",
"prefix storage://backend-a/cos://bucket/10000/exp",
7,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
want := tt.want
if want == -2 {
want = strings.Index(tt.in, "![知识助理")
require.GreaterOrEqual(t, want, 0)
}
got := holdbackCutoff(tt.in)
if want == -1 {
assert.Equal(t, len(tt.in), got, "holdbackCutoff(%q) should be len(in)", tt.in)
} else {
assert.Equal(t, want, got, "holdbackCutoff(%q)", tt.in)
}
})
}
}
// simulateIMStreamFlushStep mirrors one handleMessageStream flush (see flush() in service.go).
func simulateIMStreamFlushStep(holdback, incoming string, final bool) (sent, newHoldback string) {
chunk := holdback + incoming
holdback = ""
if chunk == "" {
return "", ""
}
if !final {
if cut := holdbackCutoff(chunk); cut < len(chunk) {
holdback = chunk[cut:]
chunk = chunk[:cut]
}
}
return chunk, holdback
}
// simulateIMStreamFlush mirrors handleMessageStream's flush/holdback logic (non-final
// flushes apply holdbackCutoff; final flush sends everything).
func simulateIMStreamFlush(parts []string) (sent []string, remainder string) {
var pending string
for i, part := range parts {
final := i == len(parts)-1
var chunk string
chunk, pending = simulateIMStreamFlushStep(pending, part, final)
if chunk != "" {
sent = append(sent, chunk)
}
}
return sent, pending
}
// orphanMarkdownImageOpenRe detects a flush chunk that ends with an opened Markdown
// image (](...) without the URL — the bug we fixed.
var orphanMarkdownImageOpenRe = regexp.MustCompile(`!\[[^\]]*\]\(\s*$`)
func TestSimulateIMStreamFlush_MiddleImageNotSplit(t *testing.T) {
// Reproduces the user report: three images; the middle one was split at minio://.
part1 := "### 1️⃣ 智能交互入口\n\n" +
"![知识助理AI对话界面](minio://wizard-test/10000/exports/bb524693-a3f7-48bd-88ca-0c3957cb56e7.png)\n\n" +
"### 2️⃣ 知识管理\n- 支持创建\n\n"
part2 := `![知识助理"知识库"管理视图界面](minio://wizard-test/10000/exports/c91cf852`
part3 := `-9c72-f549da25619a.png)\n\n### 3️⃣ API\n\n` +
"![API](minio://wizard-test/10000/exports/a0423e91-95c9-46bf-ab4c-9586b43d0a61.png)\n"
sent, remainder := simulateIMStreamFlush([]string{part1, part2, part3})
require.Empty(t, remainder, "final flush should drain holdback")
for i, chunk := range sent {
assert.False(t, orphanMarkdownImageOpenRe.MatchString(chunk),
"chunk %d must not end with orphan ![alt]( : %q", i, chunk)
}
joined := strings.Join(sent, "")
assert.Contains(t, joined, "bb524693-a3f7-48bd-88ca-0c3957cb56e7.png)")
assert.Contains(t, joined, "c91cf852-9c72-f549da25619a.png)")
assert.Contains(t, joined, "a0423e91-95c9-46bf-ab4c-9586b43d0a61.png)")
// Middle image must appear intact in some single sent chunk (after merge for assertion).
middleImg := `![知识助理"知识库"管理视图界面](minio://wizard-test/10000/exports/c91cf852-9c72-f549da25619a.png)`
assert.Contains(t, joined, middleImg)
foundMiddleIntact := false
for _, chunk := range sent {
if strings.Contains(chunk, middleImg) {
foundMiddleIntact = true
break
}
}
assert.True(t, foundMiddleIntact, "middle markdown image should ship in one chunk, got %d chunks", len(sent))
}
func TestSimulateIMStreamFlush_FirstChunkHoldsMiddleImagePrefix(t *testing.T) {
part1 := "### 1\n![ok](minio://wizard-test/10000/exports/ok.png)\n\n### 2\n"
part2 := `![知识助理"知识库"管理视图界面](minio://wizard-test/10000/exports/c91cf852`
s1, hb1 := simulateIMStreamFlushStep("", part1, false)
assert.Equal(t, part1, s1)
assert.Empty(t, hb1)
s2, hb2 := simulateIMStreamFlushStep(hb1, part2, false)
assert.Empty(t, s2, "incomplete middle image must not be sent on non-final flush")
require.NotEmpty(t, hb2)
assert.True(t, strings.HasPrefix(hb2, "![知识助理"),
"holdback should include full markdown image prefix, got %q", hb2)
assert.False(t, orphanMarkdownImageOpenRe.MatchString(s1))
}
func TestSimulateIMStreamFlush_CompleteImageInOnePart(t *testing.T) {
one := `![x](minio://wizard-test/10000/exports/uuid.png) tail`
sent, rem := simulateIMStreamFlush([]string{one})
require.Empty(t, rem)
require.Len(t, sent, 1)
assert.Equal(t, one, sent[0])
}
func TestSimulateIMStreamFlush_BareProviderURLStillHeld(t *testing.T) {
s1, hb1 := simulateIMStreamFlushStep("", "intro ", false)
assert.Equal(t, "intro ", s1)
s2, hb2 := simulateIMStreamFlushStep(hb1, "minio://wizard-test/10000/exp", false)
assert.Empty(t, s2)
require.NotEmpty(t, hb2)
assert.True(t, strings.HasPrefix(hb2, "minio://wizard-test/10000/exp"))
}
func TestCleanIMContent_AfterStreamReassembly(t *testing.T) {
t.Setenv("SYSTEM_AES_KEY", "weknora-test-aes-key-32bytes!!!")
t.Setenv("APP_EXTERNAL_URL", "https://weknora.example.com")
tenant := &types.Tenant{
StorageEngineConfig: &types.StorageEngineConfig{
DefaultProvider: "cos",
COS: &types.COSEngineConfig{
SecretID: "id",
SecretKey: "key",
BucketName: "bucket",
Region: "ap-shanghai",
},
},
}
parts := []string{
`![知识助理"知识库"管理视图界面](local://10000/exports/c91cf852`,
`-9c72-f549da25619a.png)`,
}
sent, rem := simulateIMStreamFlush(parts)
require.Empty(t, rem)
joined := strings.Join(sent, "")
out := cleanIMContent(context.Background(), joined, tenant, nil)
assert.Contains(t, out, "/api/v1/files/presigned")
assert.NotContains(t, out, "local://10000")
}
func TestHoldbackCutoff_BracketInAlt(t *testing.T) {
in := "text ![a[b]](minio://wizard-test/10000/exp"
want := strings.Index(in, "![a[b]]")
require.GreaterOrEqual(t, want, 0)
assert.Equal(t, want, holdbackCutoff(in))
}
func TestSimulateIMStreamFlush_BracketInAltMiddleImage(t *testing.T) {
part1 := "### 1\n![ok](minio://wizard-test/10000/exports/ok.png)\n\n### 2\n"
part2 := "![a[b]](minio://wizard-test/10000/exports/c91cf852"
part3 := "-suffix.png)\n"
sent, rem := simulateIMStreamFlush([]string{part1, part2, part3})
require.Empty(t, rem)
joined := strings.Join(sent, "")
assert.Contains(t, joined, "![a[b]](minio://wizard-test/10000/exports/c91cf852-suffix.png)")
for _, chunk := range sent {
assert.False(t, orphanMarkdownImageOpenRe.MatchString(chunk))
}
}
func TestRewriteStorageURLs_MultipleImagesInOneChunk(t *testing.T) {
t.Setenv("SYSTEM_AES_KEY", "weknora-test-aes-key-32bytes!!!")
t.Setenv("APP_EXTERNAL_URL", "https://weknora.example.com")
tenant := &types.Tenant{
StorageEngineConfig: &types.StorageEngineConfig{DefaultProvider: "cos"},
}
doc := "### 1\n\n![a](local://10000/exports/bb524693.png)\n\n### 2\n\n" +
`![知识助理"知识库"管理视图界面](local://10000/exports/c91cf852.png)` + "\n\n### 3\n\n" +
"![c](local://10000/exports/a0423e91.png)\n"
out := rewriteStorageURLs(context.Background(), doc, newIMFileServiceResolver(tenant, nil))
assert.NotContains(t, out, "local://")
assert.Equal(t, 3, strings.Count(out, "/api/v1/files/presigned"))
}
+49
View File
@@ -0,0 +1,49 @@
package im
import (
"encoding/json"
"strings"
)
// ParseCredentials parses the JSONB credentials field into a map.
func ParseCredentials(data []byte) (map[string]any, error) {
if len(data) == 0 {
return map[string]any{}, nil
}
var creds map[string]any
if err := json.Unmarshal(data, &creds); err != nil {
return nil, err
}
return creds, nil
}
// GetString safely extracts a string value from a credentials map.
func GetString(creds map[string]any, key string) string {
if v, ok := creds[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
// GetBool reads a boolean from JSON credentials (bool, string "true"/"1", or non-zero number).
func GetBool(creds map[string]any, key string) bool {
v, ok := creds[key]
if !ok {
return false
}
switch x := v.(type) {
case bool:
return x
case string:
s := strings.TrimSpace(strings.ToLower(x))
return s == "true" || s == "1" || s == "yes"
case float64:
return x != 0
case int:
return x != 0
default:
return false
}
}
+968
View File
@@ -0,0 +1,968 @@
package dingtalk
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
"github.com/Tencent/WeKnora/internal/im"
"github.com/Tencent/WeKnora/internal/logger"
secutils "github.com/Tencent/WeKnora/internal/utils"
)
// httpClient is a shared HTTP client with a reasonable timeout for DingTalk API calls.
var httpClient = secutils.NewSSRFSafeHTTPClient(secutils.SSRFSafeHTTPClientConfig{
Timeout: 15 * time.Second,
MaxRedirects: 5,
})
// apiBaseURL is the DingTalk OpenAPI host. Overridable in tests.
var apiBaseURL = "https://api.dingtalk.com"
// minCardUpdateInterval is the minimum time between consecutive card streaming updates.
const minCardUpdateInterval = 500 * time.Millisecond
// dingtalkConvTypeGroup is the DingTalk conversation type value for group chats.
const dingtalkConvTypeGroup = "2"
// Compile-time checks.
var (
_ im.Adapter = (*Adapter)(nil)
_ im.StreamSender = (*Adapter)(nil)
_ im.FileDownloader = (*Adapter)(nil)
)
// Adapter implements im.Adapter for DingTalk.
type Adapter struct {
clientID string
clientSecret string
cardTemplateID string // optional: enables AI card streaming when set
// accessToken cache
tokenMu sync.RWMutex
token string
tokenExpAt time.Time
}
// NewWebhookAdapter creates a DingTalk adapter for HTTP callback mode.
func NewWebhookAdapter(clientID, clientSecret, cardTemplateID string) *Adapter {
startStreamReaper()
return &Adapter{
clientID: clientID,
clientSecret: clientSecret,
cardTemplateID: cardTemplateID,
}
}
// NewAdapter creates a DingTalk adapter for stream (websocket) mode.
// The stream connection itself is managed separately by the supervisor; the
// adapter only sends replies (via sessionWebhook or OpenAPI).
func NewAdapter(clientID, clientSecret, cardTemplateID string) *Adapter {
startStreamReaper()
return &Adapter{
clientID: clientID,
clientSecret: clientSecret,
cardTemplateID: cardTemplateID,
}
}
func (a *Adapter) Platform() im.Platform {
return im.PlatformDingtalk
}
func (a *Adapter) HandleURLVerification(c *gin.Context) bool {
return false
}
// VerifyCallback verifies the DingTalk webhook signature (HmacSHA256).
func (a *Adapter) VerifyCallback(c *gin.Context) error {
if a.clientSecret == "" {
return nil
}
timestamp := c.GetHeader("Timestamp")
sign := c.GetHeader("Sign")
if timestamp == "" || sign == "" {
return fmt.Errorf("missing timestamp or sign header")
}
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return fmt.Errorf("invalid timestamp: %w", err)
}
diff := time.Now().UnixMilli() - ts
if diff > 3600*1000 || diff < -3600*1000 {
return fmt.Errorf("timestamp expired")
}
stringToSign := timestamp + "\n" + a.clientSecret
h := hmac.New(sha256.New, []byte(a.clientSecret))
h.Write([]byte(stringToSign))
expectedSign := base64.StdEncoding.EncodeToString(h.Sum(nil))
if !hmac.Equal([]byte(sign), []byte(expectedSign)) {
return fmt.Errorf("invalid signature")
}
return nil
}
// DingTalk callback message structure.
type callbackMessage struct {
ConversationID string `json:"conversationId"`
ConversationType string `json:"conversationType"`
MsgID string `json:"msgId"`
Msgtype string `json:"msgtype"`
Text *textContent `json:"text"`
Content json.RawMessage `json:"content"`
SenderNick string `json:"senderNick"`
SenderStaffId string `json:"senderStaffId"`
SenderID string `json:"senderId"`
SessionWebhook string `json:"sessionWebhook"`
RobotCode string `json:"robotCode"`
AtUsers []atUser `json:"atUsers"`
IsInAtList bool `json:"isInAtList"`
ChatbotCorpId string `json:"chatbotCorpId"`
}
type textContent struct {
Content string `json:"content"`
}
type atUser struct {
DingtalkID string `json:"dingtalkId"`
StaffID string `json:"staffId"`
}
func (a *Adapter) ParseCallback(c *gin.Context) (*im.IncomingMessage, error) {
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
var msg callbackMessage
if err := json.Unmarshal(bodyBytes, &msg); err != nil {
return nil, fmt.Errorf("parse callback: %w", err)
}
return parseCallbackMessage(&msg), nil
}
// fileMessageContent is the `content` object DingTalk sends for file and
// picture messages. File messages carry fileName/spaceId/fileId; picture
// messages carry only downloadCode (original quality) and pictureDownloadCode.
// See https://open-dingtalk.github.io/developerpedia/docs/learn/bot/message/
type fileMessageContent struct {
DownloadCode string `json:"downloadCode"`
PictureDownloadCode string `json:"pictureDownloadCode"`
FileName string `json:"fileName"`
}
// richTextMessageContent is the payload DingTalk sends for msgtype=richText.
// Text and pictures are interleaved in their original display order. The
// unified IM message currently carries one downloadable file, so we retain the
// first picture while preserving all text fragments for the QA query.
type richTextMessageContent struct {
RichText []richTextElement `json:"richText"`
}
type richTextElement struct {
Text string `json:"text"`
Type string `json:"type"`
DownloadCode string `json:"downloadCode"`
PictureDownloadCode string `json:"pictureDownloadCode"`
}
type parsedRichText struct {
Text string
DownloadCode string
PictureCount int
}
func parseRichTextContent(msgtype string, content json.RawMessage) (parsedRichText, bool) {
if !strings.EqualFold(strings.TrimSpace(msgtype), "richText") {
return parsedRichText{}, false
}
var payload richTextMessageContent
if len(content) == 0 || json.Unmarshal(content, &payload) != nil {
return parsedRichText{}, false
}
textParts := make([]string, 0, len(payload.RichText))
result := parsedRichText{}
for _, item := range payload.RichText {
if text := strings.TrimSpace(item.Text); text != "" {
textParts = append(textParts, text)
}
code := pictureDownloadCode(item)
if code == "" {
continue
}
result.PictureCount++
if result.DownloadCode == "" {
result.DownloadCode = code
}
}
result.Text = strings.Join(textParts, "\n")
return result, true
}
func pictureDownloadCode(item richTextElement) string {
if !strings.EqualFold(strings.TrimSpace(item.Type), "picture") {
return ""
}
if item.DownloadCode != "" {
return item.DownloadCode
}
return item.PictureDownloadCode
}
type audioMessageContent struct {
Recognition string `json:"recognition"`
}
func parseAudioContent(msgtype string, content json.RawMessage) (string, bool) {
if !strings.EqualFold(strings.TrimSpace(msgtype), "audio") {
return "", false
}
var payload audioMessageContent
if len(content) == 0 || json.Unmarshal(content, &payload) != nil {
return "", false
}
text := strings.TrimSpace(payload.Recognition)
if text == "" {
return "", false
}
return text, true
}
// parseFileContent maps a DingTalk msgtype + content object to WeKnora's file
// message fields. Returns ok=false for non-file/picture message types so the
// caller keeps its text handling. Picture messages have no fileName; the IM
// service appends an extension after download.
func parseFileContent(msgtype string, content json.RawMessage) (im.MessageType, string, string, bool) {
var msgType im.MessageType
switch msgtype {
case "file":
msgType = im.MessageTypeFile
case "picture":
msgType = im.MessageTypeImage
default:
return "", "", "", false
}
var c fileMessageContent
if len(content) > 0 {
if err := json.Unmarshal(content, &c); err != nil {
return "", "", "", false
}
}
downloadCode := c.DownloadCode
if downloadCode == "" {
downloadCode = c.PictureDownloadCode
}
if downloadCode == "" {
return "", "", "", false
}
fileName := c.FileName
if msgType == im.MessageTypeImage {
fileName = ""
}
return msgType, fileName, downloadCode, true
}
// parseDownloadURL extracts the temporary downloadUrl from the response of the
// robot/messageFiles/download API.
func parseDownloadURL(raw json.RawMessage) (string, error) {
var r struct {
DownloadURL string `json:"downloadUrl"`
}
if err := json.Unmarshal(raw, &r); err != nil {
return "", fmt.Errorf("parse download response: %w", err)
}
if r.DownloadURL == "" {
return "", fmt.Errorf("download response has no downloadUrl: %s", string(raw))
}
return r.DownloadURL, nil
}
// DownloadFile downloads a file/picture the user sent to the robot. DingTalk
// does not deliver the bytes directly: the callback carries a downloadCode that
// is exchanged for a temporary downloadUrl via robot/messageFiles/download,
// which is then fetched. robotCode comes from the callback (webhook mode) or the
// app client ID (stream mode). Implements im.FileDownloader (issue #1771).
func (a *Adapter) DownloadFile(ctx context.Context, msg *im.IncomingMessage) (io.ReadCloser, string, error) {
downloadCode := msg.FileKey
if downloadCode == "" {
return nil, "", fmt.Errorf("no downloadCode in message")
}
robotCode := msg.Extra["robot_code"]
if robotCode == "" {
robotCode = a.clientID
}
respBody, err := a.dingtalkAPI(ctx, http.MethodPost, "/v1.0/robot/messageFiles/download", map[string]string{
"robotCode": robotCode,
"downloadCode": downloadCode,
})
if err != nil {
return nil, "", fmt.Errorf("request download url: %w", err)
}
downloadURL, err := parseDownloadURL(respBody)
if err != nil {
return nil, "", err
}
if err := validateFileDownloadURL(downloadURL); err != nil {
return nil, "", fmt.Errorf("download url rejected: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
return nil, "", fmt.Errorf("create download request: %w", err)
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, "", fmt.Errorf("download file: %w", err)
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return nil, "", fmt.Errorf("download file returned %d: %s", resp.StatusCode, string(body))
}
return resp.Body, msg.FileName, nil
}
func parseCallbackMessage(msg *callbackMessage) *im.IncomingMessage {
chatType := im.ChatTypeDirect
chatID := ""
if msg.ConversationType == dingtalkConvTypeGroup {
chatType = im.ChatTypeGroup
chatID = msg.ConversationID
}
userID := msg.SenderStaffId
if userID == "" {
userID = msg.SenderID
}
extra := map[string]string{
"session_webhook": msg.SessionWebhook,
"raw_msgtype": msg.Msgtype,
}
incoming := &im.IncomingMessage{
Platform: im.PlatformDingtalk,
UserID: userID,
UserName: msg.SenderNick,
ChatID: chatID,
ChatType: chatType,
MessageID: msg.MsgID,
Extra: extra,
}
if rich, ok := parseRichTextContent(msg.Msgtype, msg.Content); ok {
applyRichText(incoming, extra, rich, msg.MsgID, msg.RobotCode)
} else if msgType, fileName, downloadCode, ok := parseFileContent(msg.Msgtype, msg.Content); ok {
incoming.MessageType = msgType
incoming.FileName = defaultFileName(msgType, fileName, msg.MsgID)
incoming.FileKey = downloadCode
extra["robot_code"] = msg.RobotCode
} else if text, ok := parseAudioContent(msg.Msgtype, msg.Content); ok {
incoming.MessageType = im.MessageTypeText
incoming.Content = text
} else {
incoming.MessageType = im.MessageTypeText
if msg.Text != nil {
incoming.Content = strings.TrimSpace(msg.Text.Content)
}
}
return incoming
}
func applyRichText(
incoming *im.IncomingMessage,
extra map[string]string,
rich parsedRichText,
msgID, robotCode string,
) {
incoming.Content = rich.Text
if rich.DownloadCode != "" {
incoming.MessageType = im.MessageTypeImage
} else {
incoming.MessageType = im.MessageTypeText
}
if rich.PictureCount == 0 {
return
}
extra["rich_text_picture_count"] = strconv.Itoa(rich.PictureCount)
if rich.DownloadCode == "" {
return
}
incoming.FileKey = rich.DownloadCode
incoming.FileName = defaultFileName(im.MessageTypeImage, "", msgID)
extra["robot_code"] = robotCode
if rich.PictureCount > 1 {
incoming.Content = appendDroppedPictureHint(incoming.Content, rich.PictureCount)
}
}
func appendDroppedPictureHint(text string, pictureCount int) string {
hint := fmt.Sprintf("(该消息共 %d 张图片,当前仅处理第一张)", pictureCount)
if strings.TrimSpace(text) == "" {
return hint
}
return text + "\n" + hint
}
// defaultFileName gives picture messages (which carry no fileName) a name with a
// real stem derived from the message ID, mirroring the WeCom adapter. File
// messages keep their original name; if missing, fall back to the message ID so
// post-download extension resolution can still run.
func defaultFileName(msgType im.MessageType, fileName, msgID string) string {
if fileName != "" {
return fileName
}
if msgType == im.MessageTypeImage {
return msgID + ".png"
}
return msgID
}
// allowedDingTalkDownloadHostSuffixes lists CDN/OSS host suffixes DingTalk uses
// for temporary file download links returned by messageFiles/download.
var allowedDingTalkDownloadHostSuffixes = []string{
".aliyuncs.com",
".dingtalk.com",
}
// validateFileDownloadURL is overridable in tests (httptest uses loopback URLs).
var validateFileDownloadURL = defaultValidateFileDownloadURL
func defaultValidateFileDownloadURL(rawURL string) error {
if isAllowedDingTalkDownloadHost(rawURL) {
return nil
}
return secutils.ValidateURLForSSRF(rawURL)
}
func isAllowedDingTalkDownloadHost(rawURL string) bool {
u, err := url.Parse(rawURL)
if err != nil {
return false
}
hostname := strings.ToLower(u.Hostname())
for _, suffix := range allowedDingTalkDownloadHostSuffixes {
if strings.HasSuffix(hostname, suffix) {
return true
}
}
return false
}
// streamToIncoming builds an IncomingMessage from a DingTalk Stream mode
// callback. The Stream SDK does not expose robotCode, so file messages fall back
// to the app client ID (which is the robotCode for enterprise internal robots).
func streamToIncoming(data *chatbot.BotCallbackDataModel, fallbackRobotCode string) *im.IncomingMessage {
chatType := im.ChatTypeDirect
chatID := ""
if data.ConversationType == dingtalkConvTypeGroup {
chatType = im.ChatTypeGroup
chatID = data.ConversationId
}
userID := data.SenderStaffId
if userID == "" {
userID = data.SenderId
}
extra := map[string]string{
"session_webhook": data.SessionWebhook,
"raw_msgtype": data.Msgtype,
}
incoming := &im.IncomingMessage{
Platform: im.PlatformDingtalk,
UserID: userID,
UserName: data.SenderNick,
ChatID: chatID,
ChatType: chatType,
MessageID: data.MsgId,
Extra: extra,
}
// data.Content is a decoded interface{}; re-marshal it to JSON so the same
// parseFileContent helper used by the webhook path can read it.
var contentRaw json.RawMessage
if data.Content != nil {
if b, err := json.Marshal(data.Content); err == nil {
contentRaw = b
}
}
if rich, ok := parseRichTextContent(data.Msgtype, contentRaw); ok {
applyRichText(incoming, extra, rich, data.MsgId, fallbackRobotCode)
} else if msgType, fileName, downloadCode, ok := parseFileContent(data.Msgtype, contentRaw); ok {
incoming.MessageType = msgType
incoming.FileName = defaultFileName(msgType, fileName, data.MsgId)
incoming.FileKey = downloadCode
extra["robot_code"] = fallbackRobotCode
} else if text, ok := parseAudioContent(data.Msgtype, contentRaw); ok {
incoming.MessageType = im.MessageTypeText
incoming.Content = text
} else {
incoming.MessageType = im.MessageTypeText
incoming.Content = strings.TrimSpace(data.Text.Content)
}
return incoming
}
// ── Send reply ──
func (a *Adapter) SendReply(ctx context.Context, incoming *im.IncomingMessage, reply *im.ReplyMessage) error {
content := im.FormatIMDisplayContent(reply.Content, im.StreamDisplayFinal)
sessionWebhook := ""
if incoming.Extra != nil {
sessionWebhook = incoming.Extra["session_webhook"]
}
if sessionWebhook != "" {
return a.replyViaSessionWebhook(ctx, sessionWebhook, content)
}
return a.replyViaOpenAPI(ctx, incoming, content)
}
func (a *Adapter) replyViaSessionWebhook(ctx context.Context, webhookURL, content string) error {
if err := secutils.ValidateURLForSSRF(webhookURL); err != nil {
return fmt.Errorf("dingtalk sessionWebhook rejected by SSRF policy: %w", err)
}
body := map[string]interface{}{
"msgtype": "markdown",
"markdown": map[string]string{
"title": "Reply",
"text": content,
},
}
jsonBody, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal reply: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, webhookURL, bytes.NewReader(jsonBody))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("send reply: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("dingtalk sessionWebhook returned %d: %s", resp.StatusCode, string(respBody))
}
return nil
}
func (a *Adapter) replyViaOpenAPI(ctx context.Context, incoming *im.IncomingMessage, content string) error {
token, err := a.getAccessToken(ctx)
if err != nil {
return fmt.Errorf("get access token: %w", err)
}
msgParam, err := json.Marshal(map[string]string{"title": "Reply", "text": content})
if err != nil {
return fmt.Errorf("marshal msgParam: %w", err)
}
var apiURL string
body := map[string]interface{}{
"robotCode": a.clientID,
"msgKey": "sampleMarkdown",
"msgParam": string(msgParam),
}
if incoming.ChatType == im.ChatTypeGroup {
apiURL = "https://api.dingtalk.com/v1.0/robot/groupMessages/send"
body["openConversationId"] = incoming.ChatID
} else {
apiURL = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend"
body["userIds"] = []string{incoming.UserID}
}
jsonBody, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal body: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(jsonBody))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-acs-dingtalk-access-token", token)
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("send reply: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("dingtalk OpenAPI returned %d: %s", resp.StatusCode, string(respBody))
}
return nil
}
// getAccessToken returns a cached or fresh DingTalk access token.
func (a *Adapter) getAccessToken(ctx context.Context) (string, error) {
a.tokenMu.RLock()
if a.token != "" && time.Now().Before(a.tokenExpAt) {
token := a.token
a.tokenMu.RUnlock()
return token, nil
}
a.tokenMu.RUnlock()
a.tokenMu.Lock()
defer a.tokenMu.Unlock()
if a.token != "" && time.Now().Before(a.tokenExpAt) {
return a.token, nil
}
body := map[string]string{
"appKey": a.clientID,
"appSecret": a.clientSecret,
}
jsonBody, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
apiBaseURL+"/v1.0/oauth2/accessToken",
bytes.NewReader(jsonBody))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("dingtalk accessToken returned %d: %s", resp.StatusCode, string(respBody))
}
var result struct {
AccessToken string `json:"accessToken"`
ExpireIn int64 `json:"expireIn"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("decode token response: %w", err)
}
if result.AccessToken == "" {
return "", fmt.Errorf("empty access token from dingtalk")
}
a.token = result.AccessToken
a.tokenExpAt = time.Now().Add(time.Duration(result.ExpireIn)*time.Second - 5*time.Minute)
return a.token, nil
}
// ── DingTalk OpenAPI helpers for AI Card ──
func (a *Adapter) dingtalkAPI(ctx context.Context, method, path string, body interface{}) (json.RawMessage, error) {
token, err := a.getAccessToken(ctx)
if err != nil {
return nil, fmt.Errorf("get access token: %w", err)
}
jsonBody, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshal body: %w", err)
}
url := apiBaseURL + path
req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(jsonBody))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-acs-dingtalk-access-token", token)
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("dingtalk API %s returned %d: %s", path, resp.StatusCode, string(respBody))
}
return respBody, nil
}
// createAndDeliverCard creates an AI card and delivers it to the conversation.
func (a *Adapter) createAndDeliverCard(ctx context.Context, incoming *im.IncomingMessage) (string, error) {
outTrackID := uuid.New().String()
body := map[string]interface{}{
"cardTemplateId": a.cardTemplateID,
"outTrackId": outTrackID,
"callbackType": "STREAM",
"cardData": map[string]interface{}{
"cardParamMap": map[string]string{
"content": "",
},
},
"userIdType": 1,
}
if incoming.ChatType == im.ChatTypeGroup {
// Group chat
convID := incoming.ChatID
body["openSpaceId"] = "dtv1.card//IM_GROUP." + convID
body["imGroupOpenSpaceModel"] = map[string]interface{}{"supportForward": true}
body["imGroupOpenDeliverModel"] = map[string]interface{}{
"robotCode": a.clientID,
"extension": map[string]string{},
}
} else {
// Single chat (1:1 DM)
body["openSpaceId"] = "dtv1.card//IM_ROBOT." + incoming.UserID
body["imRobotOpenSpaceModel"] = map[string]interface{}{"supportForward": true}
body["imRobotOpenDeliverModel"] = map[string]interface{}{
"robotCode": a.clientID,
"spaceType": "IM_ROBOT",
"extension": map[string]string{},
}
}
_, err := a.dingtalkAPI(ctx, http.MethodPost, "/v1.0/card/instances/createAndDeliver", body)
if err != nil {
return "", fmt.Errorf("create card: %w", err)
}
return outTrackID, nil
}
// streamingUpdateCard pushes content to an existing AI card.
func (a *Adapter) streamingUpdateCard(ctx context.Context, outTrackID, content string, isFinalize bool) error {
body := map[string]interface{}{
"outTrackId": outTrackID,
"guid": uuid.New().String(),
"key": "content",
"content": content,
"isFull": true,
"isFinalize": isFinalize,
"isError": false,
}
_, err := a.dingtalkAPI(ctx, http.MethodPut, "/v1.0/card/streaming", body)
return err
}
// ── StreamSender implementation ──
type streamState struct {
mu sync.Mutex
content strings.Builder
sessionWebhook string
outTrackID string // non-empty when using AI card streaming
lastUpdate time.Time // for card update throttling
createdAt time.Time // for orphan stream detection
}
const (
streamOrphanTTL = 5 * time.Minute
streamReaperInterval = 1 * time.Minute
)
var (
streamsMu sync.Mutex
dStreams = map[string]*streamState{}
startReaperOnce sync.Once
reaperStopCh = make(chan struct{})
)
// startStreamReaper starts a background goroutine (once) that periodically
// removes orphaned stream entries. This prevents memory leaks when EndStream
// is never called due to panics or pipeline errors.
func startStreamReaper() {
startReaperOnce.Do(func() {
go func() {
ticker := time.NewTicker(streamReaperInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
cutoff := time.Now().Add(-streamOrphanTTL)
streamsMu.Lock()
for id, state := range dStreams {
if state.createdAt.Before(cutoff) {
delete(dStreams, id)
}
}
streamsMu.Unlock()
case <-reaperStopCh:
return
}
}
}()
})
}
func (a *Adapter) StartStream(ctx context.Context, incoming *im.IncomingMessage) (string, error) {
sessionWebhook := ""
if incoming.Extra != nil {
sessionWebhook = incoming.Extra["session_webhook"]
}
streamID := fmt.Sprintf("dt:%s:%s", incoming.UserID, incoming.MessageID)
state := &streamState{
sessionWebhook: sessionWebhook,
createdAt: time.Now(),
}
// If card template is configured, create an AI card for streaming
if a.cardTemplateID != "" {
outTrackID, err := a.createAndDeliverCard(ctx, incoming)
if err != nil {
logger.Warnf(ctx, "[DingTalk] Failed to create AI card, falling back to sessionWebhook: %v", err)
} else {
state.outTrackID = outTrackID
}
}
streamsMu.Lock()
dStreams[streamID] = state
streamsMu.Unlock()
logger.Infof(ctx, "[DingTalk] Streaming started: stream_id=%s, card=%v", streamID, state.outTrackID != "")
return streamID, nil
}
func (a *Adapter) UpdateStreamContent(ctx context.Context, incoming *im.IncomingMessage, streamID string, fullContent string) error {
if fullContent == "" {
return nil
}
streamsMu.Lock()
state, ok := dStreams[streamID]
streamsMu.Unlock()
if !ok {
return fmt.Errorf("unknown stream ID: %s", streamID)
}
state.mu.Lock()
state.content.Reset()
state.content.WriteString(fullContent)
if state.outTrackID == "" {
state.mu.Unlock()
return nil
}
if time.Since(state.lastUpdate) < minCardUpdateInterval {
state.mu.Unlock()
return nil
}
state.lastUpdate = time.Now()
outTrackID := state.outTrackID
state.mu.Unlock()
if err := a.streamingUpdateCard(ctx, outTrackID, fullContent, false); err != nil {
logger.Warnf(ctx, "[DingTalk] Failed to update card stream: %v", err)
}
return nil
}
func (a *Adapter) FinalizeStream(ctx context.Context, incoming *im.IncomingMessage, streamID string, finalContent string) error {
streamsMu.Lock()
state, ok := dStreams[streamID]
streamsMu.Unlock()
if !ok {
return fmt.Errorf("unknown stream ID: %s", streamID)
}
state.mu.Lock()
state.content.Reset()
state.content.WriteString(finalContent)
outTrackID := state.outTrackID
state.mu.Unlock()
if outTrackID != "" {
if err := a.streamingUpdateCard(ctx, outTrackID, finalContent, false); err != nil {
logger.Warnf(ctx, "[DingTalk] Failed to finalize card stream: %v", err)
}
}
return nil
}
func (a *Adapter) SendStreamChunk(ctx context.Context, incoming *im.IncomingMessage, streamID string, content string) error {
return a.UpdateStreamContent(ctx, incoming, streamID, content)
}
func (a *Adapter) EndStream(ctx context.Context, incoming *im.IncomingMessage, streamID string) error {
streamsMu.Lock()
state, ok := dStreams[streamID]
delete(dStreams, streamID)
streamsMu.Unlock()
if !ok {
return nil
}
state.mu.Lock()
fullContent := state.content.String()
outTrackID := state.outTrackID
sessionWebhook := state.sessionWebhook
state.mu.Unlock()
if outTrackID != "" {
if err := a.streamingUpdateCard(ctx, outTrackID, fullContent, true); err != nil {
logger.Warnf(ctx, "[DingTalk] Failed to finalize card stream: %v", err)
}
} else if sessionWebhook != "" {
if err := a.replyViaSessionWebhook(ctx, sessionWebhook, fullContent); err != nil {
logger.Warnf(ctx, "[DingTalk] Failed to end stream: %v", err)
}
} else {
if err := a.replyViaOpenAPI(ctx, incoming, fullContent); err != nil {
logger.Warnf(ctx, "[DingTalk] Failed to end stream via OpenAPI: %v", err)
}
}
logger.Infof(ctx, "[DingTalk] Streaming ended: stream_id=%s", streamID)
return nil
}
+488
View File
@@ -0,0 +1,488 @@
package dingtalk
import (
"encoding/json"
"strings"
"testing"
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
"github.com/Tencent/WeKnora/internal/im"
)
// Compile-time check: the adapter must implement FileDownloader so the IM
// service dispatches file messages to it (issue #1771).
var _ im.FileDownloader = (*Adapter)(nil)
func TestParseFileContent_File(t *testing.T) {
content := json.RawMessage(`{"spaceId":"223573","fileName":"foobar.zip","downloadCode":"LJYYbw==","fileId":"117848"}`)
msgType, fileName, downloadCode, ok := parseFileContent("file", content)
if !ok {
t.Fatalf("expected ok=true for file message")
}
if msgType != im.MessageTypeFile {
t.Errorf("msgType = %q, want %q", msgType, im.MessageTypeFile)
}
if fileName != "foobar.zip" {
t.Errorf("fileName = %q, want %q", fileName, "foobar.zip")
}
if downloadCode != "LJYYbw==" {
t.Errorf("downloadCode = %q, want %q", downloadCode, "LJYYbw==")
}
}
func TestParseFileContent_Picture(t *testing.T) {
content := json.RawMessage(`{"pictureDownloadCode":"pWjAks=","downloadCode":"mIofJE0E"}`)
msgType, fileName, downloadCode, ok := parseFileContent("picture", content)
if !ok {
t.Fatalf("expected ok=true for picture message")
}
if msgType != im.MessageTypeImage {
t.Errorf("msgType = %q, want %q", msgType, im.MessageTypeImage)
}
if fileName != "" {
t.Errorf("fileName = %q, want empty (pipeline appends extension)", fileName)
}
if downloadCode != "mIofJE0E" {
t.Errorf("downloadCode = %q, want %q", downloadCode, "mIofJE0E")
}
}
func TestParseFileContent_Text(t *testing.T) {
_, _, _, ok := parseFileContent("text", nil)
if ok {
t.Errorf("expected ok=false for text message")
}
}
func TestParseFileContent_EmptyDownloadCode(t *testing.T) {
_, _, _, ok := parseFileContent("file", json.RawMessage(`{"fileName":"a.pdf"}`))
if ok {
t.Errorf("expected ok=false when downloadCode is missing")
}
_, _, _, ok = parseFileContent("picture", json.RawMessage(`{}`))
if ok {
t.Errorf("expected ok=false when picture has no download code")
}
}
func TestParseFileContent_PictureDownloadCodeFallback(t *testing.T) {
content := json.RawMessage(`{"pictureDownloadCode":"pWjAks="}`)
msgType, fileName, downloadCode, ok := parseFileContent("picture", content)
if !ok {
t.Fatalf("expected ok=true when only pictureDownloadCode is present")
}
if msgType != im.MessageTypeImage {
t.Errorf("msgType = %q, want %q", msgType, im.MessageTypeImage)
}
if fileName != "" {
t.Errorf("fileName = %q, want empty", fileName)
}
if downloadCode != "pWjAks=" {
t.Errorf("downloadCode = %q, want %q", downloadCode, "pWjAks=")
}
}
func TestParseFileContent_InvalidJSON(t *testing.T) {
_, _, _, ok := parseFileContent("file", json.RawMessage(`not-json`))
if ok {
t.Errorf("expected ok=false for malformed content JSON")
}
}
func TestDefaultFileName(t *testing.T) {
if got := defaultFileName(im.MessageTypeImage, "", "pic-1"); got != "pic-1.png" {
t.Errorf("image default = %q, want pic-1.png", got)
}
if got := defaultFileName(im.MessageTypeFile, "spec.pdf", "m1"); got != "spec.pdf" {
t.Errorf("file with name = %q, want spec.pdf", got)
}
if got := defaultFileName(im.MessageTypeFile, "", "m2"); got != "m2" {
t.Errorf("file without name = %q, want m2", got)
}
}
func TestParseDownloadURL(t *testing.T) {
url, err := parseDownloadURL(json.RawMessage(`{"downloadUrl":"https://example.com/file?token=abc"}`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if url != "https://example.com/file?token=abc" {
t.Errorf("url = %q, want %q", url, "https://example.com/file?token=abc")
}
}
func TestParseDownloadURL_Missing(t *testing.T) {
_, err := parseDownloadURL(json.RawMessage(`{}`))
if err == nil {
t.Errorf("expected error when downloadUrl is missing")
}
}
func TestParseCallbackMessage_File(t *testing.T) {
msg := &callbackMessage{
MsgID: "m1",
Msgtype: "file",
RobotCode: "robot-123",
SenderStaffId: "staff-1",
SenderNick: "Alice",
ConversationType: "1",
Content: json.RawMessage(`{"fileName":"spec.pdf","downloadCode":"CODE1","spaceId":"s1"}`),
}
got := parseCallbackMessage(msg)
if got.MessageType != im.MessageTypeFile {
t.Errorf("MessageType = %q, want %q", got.MessageType, im.MessageTypeFile)
}
if got.FileName != "spec.pdf" {
t.Errorf("FileName = %q, want %q", got.FileName, "spec.pdf")
}
if got.FileKey != "CODE1" {
t.Errorf("FileKey = %q, want %q", got.FileKey, "CODE1")
}
if got.Extra["robot_code"] != "robot-123" {
t.Errorf("Extra[robot_code] = %q, want %q", got.Extra["robot_code"], "robot-123")
}
}
func TestParseCallbackMessage_Picture(t *testing.T) {
msg := &callbackMessage{
MsgID: "pic-1",
Msgtype: "picture",
RobotCode: "robot-123",
Content: json.RawMessage(`{"downloadCode":"PCODE"}`),
}
got := parseCallbackMessage(msg)
if got.MessageType != im.MessageTypeImage {
t.Errorf("MessageType = %q, want %q", got.MessageType, im.MessageTypeImage)
}
// Pictures carry no filename; derive one from the message ID so the KB entry
// has a real stem (matches the WeCom adapter behavior).
if got.FileName != "pic-1.png" {
t.Errorf("FileName = %q, want %q", got.FileName, "pic-1.png")
}
if got.FileKey != "PCODE" {
t.Errorf("FileKey = %q, want %q", got.FileKey, "PCODE")
}
}
func TestParseCallbackMessage_TextStillWorks(t *testing.T) {
msg := &callbackMessage{
MsgID: "m2",
Msgtype: "text",
SenderStaffId: "staff-1",
Text: &textContent{Content: " hello "},
}
got := parseCallbackMessage(msg)
if got.MessageType != im.MessageTypeText {
t.Errorf("MessageType = %q, want %q", got.MessageType, im.MessageTypeText)
}
if got.Content != "hello" {
t.Errorf("Content = %q, want %q", got.Content, "hello")
}
}
func TestParseCallbackMessage_RichTextPreservesTextAndPictureMetadata(t *testing.T) {
msg := &callbackMessage{
MsgID: "rich-1",
Msgtype: "richText",
RobotCode: "robot-123",
Content: json.RawMessage(`{
"richText":[
{"text":" 这个问题示例图如下: "},
{"pictureDownloadCode":"PIC-FALLBACK","downloadCode":"PIC-CODE","type":"picture"},
{"text":"通过以上示意图可以明白完整的交互流程"}
]
}`),
}
got := parseCallbackMessage(msg)
if got.MessageType != im.MessageTypeImage {
t.Fatalf("MessageType = %q, want %q", got.MessageType, im.MessageTypeImage)
}
if got.Content != "这个问题示例图如下:\n通过以上示意图可以明白完整的交互流程" {
t.Errorf("Content = %q", got.Content)
}
if got.FileKey != "PIC-CODE" {
t.Errorf("FileKey = %q, want PIC-CODE", got.FileKey)
}
if got.FileName != "rich-1.png" {
t.Errorf("FileName = %q, want rich-1.png", got.FileName)
}
if got.Extra["raw_msgtype"] != "richText" {
t.Errorf("raw_msgtype = %q, want richText", got.Extra["raw_msgtype"])
}
if got.Extra["rich_text_picture_count"] != "1" {
t.Errorf("rich_text_picture_count = %q, want 1", got.Extra["rich_text_picture_count"])
}
if got.Extra["robot_code"] != "robot-123" {
t.Errorf("robot_code = %q, want robot-123", got.Extra["robot_code"])
}
}
func TestParseCallbackMessage_RichTextPictureOnlyUsesImagePath(t *testing.T) {
msg := &callbackMessage{
MsgID: "rich-picture",
Msgtype: "richText",
Content: json.RawMessage(`{
"richText":[{"pictureDownloadCode":"PIC-CODE","type":"picture"}]
}`),
}
got := parseCallbackMessage(msg)
if got.MessageType != im.MessageTypeImage {
t.Fatalf("MessageType = %q, want %q", got.MessageType, im.MessageTypeImage)
}
if got.Content != "" {
t.Errorf("Content = %q, want empty", got.Content)
}
if got.FileKey != "PIC-CODE" {
t.Errorf("FileKey = %q, want PIC-CODE", got.FileKey)
}
}
func TestParseCallbackMessage_RichTextTextOnly(t *testing.T) {
msg := &callbackMessage{
MsgID: "rich-text",
Msgtype: "richText",
Content: json.RawMessage(`{"richText":[{"text":" 只发文字 "}]}`),
}
got := parseCallbackMessage(msg)
if got.MessageType != im.MessageTypeText {
t.Fatalf("MessageType = %q, want %q", got.MessageType, im.MessageTypeText)
}
if got.Content != "只发文字" {
t.Errorf("Content = %q, want 只发文字", got.Content)
}
if got.FileKey != "" {
t.Errorf("FileKey = %q, want empty", got.FileKey)
}
}
func TestParseCallbackMessage_RichTextOfficialPictureSample(t *testing.T) {
msg := &callbackMessage{
MsgID: "official-rich",
Msgtype: "richText",
RobotCode: "robot-123",
Content: json.RawMessage(`{
"richText":[
{"text":"你好"},
{"downloadCode":"OFFICIAL-PIC","type":"picture"}
]
}`),
}
got := parseCallbackMessage(msg)
if got.MessageType != im.MessageTypeImage {
t.Fatalf("MessageType = %q, want %q", got.MessageType, im.MessageTypeImage)
}
if got.Content != "你好" {
t.Errorf("Content = %q, want 你好", got.Content)
}
if got.FileKey != "OFFICIAL-PIC" {
t.Errorf("FileKey = %q, want OFFICIAL-PIC", got.FileKey)
}
}
func TestParseCallbackMessage_RichTextKeepsFirstPictureAndHints(t *testing.T) {
msg := &callbackMessage{
MsgID: "rich-multi",
Msgtype: "richText",
Content: json.RawMessage(`{
"richText":[
{"text":"对比这几张图"},
{"downloadCode":"PIC-1","type":"picture"},
{"downloadCode":"PIC-2","type":"picture"},
{"downloadCode":"PIC-3","type":"picture"}
]
}`),
}
got := parseCallbackMessage(msg)
if got.FileKey != "PIC-1" {
t.Errorf("FileKey = %q, want PIC-1", got.FileKey)
}
if got.Extra["rich_text_picture_count"] != "3" {
t.Errorf("rich_text_picture_count = %q, want 3", got.Extra["rich_text_picture_count"])
}
if !strings.Contains(got.Content, "对比这几张图") || !strings.Contains(got.Content, "共 3 张图片") {
t.Errorf("Content = %q, want text plus dropped-picture hint", got.Content)
}
}
func TestParseCallbackMessage_RichTextIgnoresDownloadCodeWithoutPictureType(t *testing.T) {
msg := &callbackMessage{
MsgID: "rich-notype",
Msgtype: "richText",
Content: json.RawMessage(`{"richText":[{"text":"hello","downloadCode":"NOT-A-PIC"}]}`),
}
got := parseCallbackMessage(msg)
if got.MessageType != im.MessageTypeText {
t.Fatalf("MessageType = %q, want %q", got.MessageType, im.MessageTypeText)
}
if got.FileKey != "" {
t.Errorf("FileKey = %q, want empty", got.FileKey)
}
}
func TestParseCallbackMessage_RichTextEmptyOrInvalidFallsBackToEmptyText(t *testing.T) {
empty := parseCallbackMessage(&callbackMessage{
MsgID: "rich-empty",
Msgtype: "richText",
Content: json.RawMessage(`{"richText":[]}`),
})
if empty.MessageType != im.MessageTypeText || empty.Content != "" {
t.Errorf("empty richText: type=%q content=%q", empty.MessageType, empty.Content)
}
invalid := parseCallbackMessage(&callbackMessage{
MsgID: "rich-bad",
Msgtype: "richText",
Content: json.RawMessage(`not-json`),
})
if invalid.MessageType != im.MessageTypeText || invalid.Content != "" {
t.Errorf("invalid richText: type=%q content=%q", invalid.MessageType, invalid.Content)
}
}
func TestParseCallbackMessage_AudioUsesRecognition(t *testing.T) {
msg := &callbackMessage{
MsgID: "audio-1",
Msgtype: "audio",
Content: json.RawMessage(`{"duration":4000,"downloadCode":"AUD-CODE","recognition":" 钉钉,让进步发生 "}`),
}
got := parseCallbackMessage(msg)
if got.MessageType != im.MessageTypeText {
t.Fatalf("MessageType = %q, want %q", got.MessageType, im.MessageTypeText)
}
if got.Content != "钉钉,让进步发生" {
t.Errorf("Content = %q, want recognition text", got.Content)
}
if got.Extra["raw_msgtype"] != "audio" {
t.Errorf("raw_msgtype = %q, want audio", got.Extra["raw_msgtype"])
}
}
func TestParseCallbackMessage_AudioWithoutRecognitionStaysEmpty(t *testing.T) {
got := parseCallbackMessage(&callbackMessage{
MsgID: "audio-empty",
Msgtype: "audio",
Content: json.RawMessage(`{"duration":1000,"downloadCode":"AUD-CODE"}`),
})
if got.MessageType != im.MessageTypeText || got.Content != "" {
t.Errorf("audio without recognition: type=%q content=%q", got.MessageType, got.Content)
}
}
func TestStreamToIncoming_File(t *testing.T) {
data := &chatbot.BotCallbackDataModel{
MsgId: "m3",
Msgtype: "file",
SenderStaffId: "staff-2",
SenderNick: "Bob",
ConversationType: "1",
Content: map[string]interface{}{
"fileName": "notes.docx",
"downloadCode": "CODE2",
},
}
got := streamToIncoming(data, "client-app-key")
if got.MessageType != im.MessageTypeFile {
t.Errorf("MessageType = %q, want %q", got.MessageType, im.MessageTypeFile)
}
if got.FileName != "notes.docx" {
t.Errorf("FileName = %q, want %q", got.FileName, "notes.docx")
}
if got.FileKey != "CODE2" {
t.Errorf("FileKey = %q, want %q", got.FileKey, "CODE2")
}
// Stream mode has no robotCode field; fall back to the app client ID.
if got.Extra["robot_code"] != "client-app-key" {
t.Errorf("Extra[robot_code] = %q, want %q", got.Extra["robot_code"], "client-app-key")
}
}
func TestStreamToIncoming_Picture(t *testing.T) {
data := &chatbot.BotCallbackDataModel{
MsgId: "spic-1",
Msgtype: "picture",
Content: map[string]interface{}{"downloadCode": "SPCODE"},
}
got := streamToIncoming(data, "client-app-key")
if got.MessageType != im.MessageTypeImage {
t.Errorf("MessageType = %q, want %q", got.MessageType, im.MessageTypeImage)
}
if got.FileName != "spic-1.png" {
t.Errorf("FileName = %q, want %q", got.FileName, "spic-1.png")
}
if got.FileKey != "SPCODE" {
t.Errorf("FileKey = %q, want %q", got.FileKey, "SPCODE")
}
}
func TestStreamToIncoming_TextStillWorks(t *testing.T) {
data := &chatbot.BotCallbackDataModel{
MsgId: "m4",
Msgtype: "text",
SenderStaffId: "staff-2",
Text: chatbot.BotCallbackDataTextModel{Content: " hi "},
}
got := streamToIncoming(data, "client-app-key")
if got.MessageType != im.MessageTypeText {
t.Errorf("MessageType = %q, want %q", got.MessageType, im.MessageTypeText)
}
if got.Content != "hi" {
t.Errorf("Content = %q, want %q", got.Content, "hi")
}
}
func TestStreamToIncoming_RichTextPreservesTextAndPictureMetadata(t *testing.T) {
data := &chatbot.BotCallbackDataModel{
MsgId: "stream-rich",
Msgtype: "richText",
Content: map[string]interface{}{
"richText": []interface{}{
map[string]interface{}{"text": "这种场景下,AI 去纹身去不干净"},
map[string]interface{}{"downloadCode": "STREAM-PIC", "type": "picture"},
},
},
}
got := streamToIncoming(data, "client-app-key")
if got.MessageType != im.MessageTypeImage {
t.Fatalf("MessageType = %q, want %q", got.MessageType, im.MessageTypeImage)
}
if got.Content != "这种场景下,AI 去纹身去不干净" {
t.Errorf("Content = %q", got.Content)
}
if got.FileKey != "STREAM-PIC" {
t.Errorf("FileKey = %q, want STREAM-PIC", got.FileKey)
}
if got.Extra["raw_msgtype"] != "richText" {
t.Errorf("raw_msgtype = %q, want richText", got.Extra["raw_msgtype"])
}
if got.Extra["robot_code"] != "client-app-key" {
t.Errorf("robot_code = %q, want client-app-key", got.Extra["robot_code"])
}
}
func TestStreamToIncoming_AudioUsesRecognition(t *testing.T) {
data := &chatbot.BotCallbackDataModel{
MsgId: "stream-audio",
Msgtype: "audio",
Content: map[string]interface{}{
"recognition": "语音转写结果",
"downloadCode": "STREAM-AUD",
},
}
got := streamToIncoming(data, "client-app-key")
if got.MessageType != im.MessageTypeText {
t.Fatalf("MessageType = %q, want %q", got.MessageType, im.MessageTypeText)
}
if got.Content != "语音转写结果" {
t.Errorf("Content = %q, want recognition text", got.Content)
}
}
+174
View File
@@ -0,0 +1,174 @@
package dingtalk
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/Tencent/WeKnora/internal/im"
)
func useTestHTTPClient(t *testing.T) {
t.Helper()
original := httpClient
httpClient = &http.Client{Timeout: 5 * time.Second}
t.Cleanup(func() { httpClient = original })
}
// TestDownloadFile_EndToEnd drives the full DownloadFile orchestration against a
// fake DingTalk OpenAPI: access-token fetch → messageFiles/download (downloadCode
// → temporary downloadUrl) → GET the temp URL for the bytes. This covers the HTTP
// path that the pure-function unit tests deliberately skip (issue #1771).
func TestDownloadFile_EndToEnd(t *testing.T) {
useTestHTTPClient(t)
fileBytes := []byte("%PDF-1.7 fake product spec bytes")
var downloadReq map[string]string
var tokenSeen string
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1.0/oauth2/accessToken":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"accessToken": "tok-abc",
"expireIn": 7200,
})
case "/v1.0/robot/messageFiles/download":
tokenSeen = r.Header.Get("x-acs-dingtalk-access-token")
_ = json.NewDecoder(r.Body).Decode(&downloadReq)
_ = json.NewEncoder(w).Encode(map[string]string{
"downloadUrl": srv.URL + "/temp/file",
})
case "/temp/file":
_, _ = w.Write(fileBytes)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
orig := apiBaseURL
apiBaseURL = srv.URL
defer func() { apiBaseURL = orig }()
origValidate := validateFileDownloadURL
validateFileDownloadURL = func(string) error { return nil }
defer func() { validateFileDownloadURL = origValidate }()
a := &Adapter{clientID: "cid", clientSecret: "sec"}
msg := &im.IncomingMessage{
MessageType: im.MessageTypeFile,
FileKey: "DL-CODE",
FileName: "spec.pdf",
Extra: map[string]string{"robot_code": "rc-1"},
}
reader, name, err := a.DownloadFile(context.Background(), msg)
if err != nil {
t.Fatalf("DownloadFile error: %v", err)
}
defer reader.Close()
got, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("read body: %v", err)
}
if string(got) != string(fileBytes) {
t.Errorf("downloaded bytes = %q, want %q", got, fileBytes)
}
if name != "spec.pdf" {
t.Errorf("resolved name = %q, want %q", name, "spec.pdf")
}
if tokenSeen != "tok-abc" {
t.Errorf("download request auth header = %q, want %q", tokenSeen, "tok-abc")
}
if downloadReq["robotCode"] != "rc-1" {
t.Errorf("robotCode sent = %q, want %q", downloadReq["robotCode"], "rc-1")
}
if downloadReq["downloadCode"] != "DL-CODE" {
t.Errorf("downloadCode sent = %q, want %q", downloadReq["downloadCode"], "DL-CODE")
}
}
// TestDownloadFile_TempURLError verifies a non-200 from the temporary download
// URL surfaces as an error rather than silently returning empty content.
func TestDownloadFile_TempURLError(t *testing.T) {
useTestHTTPClient(t)
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1.0/oauth2/accessToken":
_ = json.NewEncoder(w).Encode(map[string]interface{}{"accessToken": "tok", "expireIn": 7200})
case "/v1.0/robot/messageFiles/download":
_ = json.NewEncoder(w).Encode(map[string]string{"downloadUrl": srv.URL + "/temp/gone"})
case "/temp/gone":
http.Error(w, "expired", http.StatusForbidden)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
orig := apiBaseURL
apiBaseURL = srv.URL
defer func() { apiBaseURL = orig }()
origValidate := validateFileDownloadURL
validateFileDownloadURL = func(string) error { return nil }
defer func() { validateFileDownloadURL = origValidate }()
a := &Adapter{clientID: "cid", clientSecret: "sec"}
msg := &im.IncomingMessage{FileKey: "DL-CODE", FileName: "x.pdf", Extra: map[string]string{"robot_code": "rc"}}
if _, _, err := a.DownloadFile(context.Background(), msg); err == nil {
t.Errorf("expected error on non-200 download URL, got nil")
}
}
func TestDownloadFile_SSRFRejected(t *testing.T) {
useTestHTTPClient(t)
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1.0/oauth2/accessToken":
_ = json.NewEncoder(w).Encode(map[string]interface{}{"accessToken": "tok", "expireIn": 7200})
case "/v1.0/robot/messageFiles/download":
_ = json.NewEncoder(w).Encode(map[string]string{"downloadUrl": "http://127.0.0.1:1/internal"})
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
orig := apiBaseURL
apiBaseURL = srv.URL
defer func() { apiBaseURL = orig }()
a := &Adapter{clientID: "cid", clientSecret: "sec"}
msg := &im.IncomingMessage{FileKey: "DL-CODE", FileName: "x.pdf", Extra: map[string]string{"robot_code": "rc"}}
if _, _, err := a.DownloadFile(context.Background(), msg); err == nil {
t.Fatal("expected SSRF rejection error, got nil")
}
}
func TestIsAllowedDingTalkDownloadHost(t *testing.T) {
cases := []struct {
url string
allow bool
}{
{"https://wukong-abc.oss-cn-hangzhou.aliyuncs.com/file?sig=x", true},
{"https://api.dingtalk.com/temp/file", true},
{"http://127.0.0.1:8080/file", false},
}
for _, tc := range cases {
if got := isAllowedDingTalkDownloadHost(tc.url); got != tc.allow {
t.Errorf("isAllowedDingTalkDownloadHost(%q) = %v, want %v", tc.url, got, tc.allow)
}
}
}
+51
View File
@@ -0,0 +1,51 @@
package dingtalk
import (
"context"
"fmt"
"github.com/Tencent/WeKnora/internal/im"
)
// NewFactory returns an im.AdapterFactory for DingTalk channels.
// Supports "webhook" and "websocket" (stream mode, default).
func NewFactory() im.AdapterFactory {
return func(factoryCtx context.Context, channel *im.IMChannel, msgHandler func(context.Context, *im.IncomingMessage) error) (im.Adapter, context.CancelFunc, error) {
creds, err := im.ParseCredentials(channel.Credentials)
if err != nil {
return nil, nil, fmt.Errorf("parse dingtalk credentials: %w", err)
}
clientID := im.GetString(creds, "client_id")
clientSecret := im.GetString(creds, "client_secret")
cardTemplateID := im.GetString(creds, "card_template_id")
mode := im.ResolveMode(channel, "websocket")
switch mode {
case "webhook":
adapter := NewWebhookAdapter(clientID, clientSecret, cardTemplateID)
return adapter, nil, nil
case "websocket":
wsCtx, wsCancel := context.WithCancel(context.Background())
go im.RunSupervised(wsCtx, im.SupervisorConfig{
Name: fmt.Sprintf("DingTalk channel %s", channel.ID),
Connect: func(ctx context.Context) (func(), error) {
client := NewLongConnClient(clientID, clientSecret, msgHandler)
if err := client.Start(ctx); err != nil {
client.Close()
return nil, err
}
return client.Close, nil
},
})
adapter := NewAdapter(clientID, clientSecret, cardTemplateID)
return adapter, wsCancel, nil
default:
return nil, nil, fmt.Errorf("unsupported dingtalk mode: %s", mode)
}
}
}
+68
View File
@@ -0,0 +1,68 @@
package dingtalk
import (
"context"
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
dtsdk "github.com/open-dingtalk/dingtalk-stream-sdk-go/client"
"github.com/Tencent/WeKnora/internal/im"
"github.com/Tencent/WeKnora/internal/logger"
)
// MessageHandler is called when an IM message is received via stream connection.
type MessageHandler func(ctx context.Context, msg *im.IncomingMessage) error
// LongConnClient manages a DingTalk Stream mode connection.
type LongConnClient struct {
clientID string
clientSecret string
handler MessageHandler
streamClient *dtsdk.StreamClient
}
// NewLongConnClient creates a DingTalk stream client.
func NewLongConnClient(clientID, clientSecret string, handler MessageHandler) *LongConnClient {
cli := dtsdk.NewStreamClient(
dtsdk.WithAppCredential(&dtsdk.AppCredentialConfig{
ClientId: clientID,
ClientSecret: clientSecret,
}),
)
c := &LongConnClient{
clientID: clientID,
clientSecret: clientSecret,
handler: handler,
streamClient: cli,
}
cli.RegisterChatBotCallbackRouter(c.onChatBotMessage)
return c
}
// Start establishes the stream connection. The underlying SDK's Start is
// non-blocking: it dials the websocket, spawns its internal read/reconnect
// loops, and returns once the connection is established (or the attempt fails).
func (c *LongConnClient) Start(ctx context.Context) error {
logger.Infof(ctx, "[IM] DingTalk Stream connecting...")
return c.streamClient.Start(ctx)
}
// Close tears down the stream connection. AutoReconnect is disabled first so
// that closing the connection does not trigger the SDK's internal reconnect.
func (c *LongConnClient) Close() {
c.streamClient.AutoReconnect = false
c.streamClient.Close()
}
func (c *LongConnClient) onChatBotMessage(ctx context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error) {
incoming := streamToIncoming(data, c.clientID)
if err := c.handler(ctx, incoming); err != nil {
logger.Errorf(ctx, "[DingTalk] Handle message error: %v", err)
}
return nil, nil
}
File diff suppressed because it is too large Load Diff
+441
View File
@@ -0,0 +1,441 @@
package feishu
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestFeishuThreadID_ThreadedReply(t *testing.T) {
// Simulate: message is a reply in a thread (root_id is set)
msg := &feishuMessage{
MessageID: "msg-reply-1",
RootID: "msg-root-1",
ParentID: "msg-parent-1",
}
threadID := msg.RootID
if threadID == "" {
threadID = msg.MessageID
}
if threadID != "msg-root-1" {
t.Errorf("threadID = %q, want %q", threadID, "msg-root-1")
}
}
func TestFeishuThreadID_TopLevelMessage(t *testing.T) {
// Simulate: top-level message (root_id is empty)
msg := &feishuMessage{
MessageID: "msg-top-1",
RootID: "",
ParentID: "",
}
threadID := msg.RootID
if threadID == "" {
threadID = msg.MessageID
}
if threadID != "msg-top-1" {
t.Errorf("threadID = %q, want %q (should use MessageID as fallback)", threadID, "msg-top-1")
}
}
func TestFeishuMessageStruct_JSONFields(t *testing.T) {
// Verify the struct fields exist and have correct zero values
msg := feishuMessage{}
if msg.RootID != "" {
t.Errorf("RootID zero value = %q, want empty", msg.RootID)
}
if msg.ParentID != "" {
t.Errorf("ParentID zero value = %q, want empty", msg.ParentID)
}
if msg.MessageID != "" {
t.Errorf("MessageID zero value = %q, want empty", msg.MessageID)
}
}
func TestImageCacheKey_StripsQuery(t *testing.T) {
cases := map[string]string{
"https://host/a.png?sig=1&t=2": "https://host/a.png",
"https://host/a.png": "https://host/a.png",
}
for in, want := range cases {
if got := imageCacheKey(in); got != want {
t.Errorf("imageCacheKey(%q) = %q, want %q", in, got, want)
}
}
}
func TestResolveMarkdownImages_NoImageUnchanged(t *testing.T) {
a := &Adapter{region: RegionFeishu}
in := "hello **world** [link](https://example.com)"
if got := a.resolveMarkdownImages(context.Background(), "tok", in); got != in {
t.Errorf("content without image was modified: %q", got)
}
}
func TestResolveMarkdownImages_FallbackToLinkOnFailure(t *testing.T) {
a := &Adapter{region: RegionFeishu}
// A direct-IP loopback URL fails SSRF validation before any network call,
// so the image must degrade to a plain markdown link (never left as ![]()).
in := "see ![diagram](http://127.0.0.1/x.png) here"
got := a.resolveMarkdownImages(context.Background(), "tok", in)
if strings.Contains(got, "![") {
t.Errorf("failed image should not remain as image markdown: %q", got)
}
if !strings.Contains(got, "[diagram](http://127.0.0.1/x.png)") {
t.Errorf("expected link fallback with alt text, got: %q", got)
}
}
// An image with no alt text falls back to the region's own label, so Lark users
// do not get a Chinese link label.
func TestResolveMarkdownImages_FallbackLabelFollowsRegion(t *testing.T) {
for _, region := range []Region{RegionFeishu, RegionLark} {
a := &Adapter{region: region}
got := a.resolveMarkdownImages(context.Background(), "tok", "![](http://127.0.0.1/x.png)")
want := "[" + region.ImageFallbackLabel + "](http://127.0.0.1/x.png)"
if got != want {
t.Errorf("%s fallback = %q, want %q", region.Label, got, want)
}
}
}
func TestEndStreamFinalizesCardSummary(t *testing.T) {
useTestHTTPClient(t)
type recordedRequest struct {
method string
path string
authorization string
sequence int
content string
settings string
statePresent bool
}
var requests []recordedRequest
const cardID = "card-final-summary"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/open-apis/auth/v3/tenant_access_token/internal" {
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 0, "tenant_access_token": "test-token", "expire": 7200,
})
return
}
var body struct {
Sequence int `json:"sequence"`
Content string `json:"content"`
Settings string `json:"settings"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Errorf("decode %s: %v", r.URL.Path, err)
}
feishuStreamsMu.Lock()
_, statePresent := feishuStreams[cardID]
feishuStreamsMu.Unlock()
requests = append(requests, recordedRequest{
method: r.Method, path: r.URL.Path, authorization: r.Header.Get("Authorization"),
sequence: body.Sequence, content: body.Content, settings: body.Settings,
statePresent: statePresent,
})
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "msg": "ok"})
}))
defer srv.Close()
adapter, _ := NewAdapter(testRegion(srv.URL), "app", "secret", "", "", "")
feishuStreamsMu.Lock()
feishuStreams[cardID] = &feishuStreamState{}
feishuStreamsMu.Unlock()
t.Cleanup(func() {
feishuStreamsMu.Lock()
delete(feishuStreams, cardID)
feishuStreamsMu.Unlock()
})
ctx := context.Background()
if err := adapter.UpdateStreamContent(ctx, nil, cardID, "progress"); err != nil {
t.Fatalf("UpdateStreamContent: %v", err)
}
const finalContent = " 最终\n回答 ✅ "
if err := adapter.FinalizeStream(ctx, nil, cardID, finalContent); err != nil {
t.Fatalf("FinalizeStream: %v", err)
}
if err := adapter.EndStream(ctx, nil, cardID); err != nil {
t.Fatalf("EndStream: %v", err)
}
if len(requests) != 3 {
t.Fatalf("CardKit request count = %d, want 3", len(requests))
}
for i, request := range requests {
if request.authorization != "Bearer test-token" {
t.Errorf("request %d Authorization = %q", i, request.authorization)
}
if request.sequence != i+1 {
t.Errorf("request %d sequence = %d, want %d", i, request.sequence, i+1)
}
}
if requests[1].content != finalContent {
t.Errorf("final content = %q, want %q", requests[1].content, finalContent)
}
if requests[2].method != http.MethodPatch || requests[2].path != "/open-apis/cardkit/v1/cards/"+cardID+"/settings" {
t.Errorf("final request = %s %s", requests[2].method, requests[2].path)
}
if requests[2].statePresent {
t.Error("stream state still present when final settings were sent")
}
var settings struct {
Config *struct {
StreamingMode *bool `json:"streaming_mode"`
Summary struct {
Content string `json:"content"`
} `json:"summary"`
} `json:"config"`
}
if err := json.Unmarshal([]byte(requests[2].settings), &settings); err != nil {
t.Fatalf("decode final settings: %v", err)
}
if settings.Config == nil || settings.Config.StreamingMode == nil || *settings.Config.StreamingMode {
t.Fatalf("final settings did not disable streaming under config: %s", requests[2].settings)
}
if settings.Config.Summary.Content != "最终 回答 ✅" {
t.Errorf("summary = %q, want %q", settings.Config.Summary.Content, "最终 回答 ✅")
}
if err := adapter.EndStream(ctx, nil, "missing-card"); err != nil {
t.Fatalf("EndStream missing state: %v", err)
}
if len(requests) != 4 {
t.Fatalf("request count after missing state = %d, want 4", len(requests))
}
assertStreamingClosed(t, requests[3].settings, "")
if requests[3].sequence != 0 {
t.Errorf("missing-state sequence = %d, want 0", requests[3].sequence)
}
}
func TestEndStreamSummaryUsesLastSuccessfulContent(t *testing.T) {
useTestHTTPClient(t)
const cardID = "card-failed-final-content"
var finalSettings string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/open-apis/auth/v3/tenant_access_token/internal" {
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 0, "tenant_access_token": "test-token", "expire": 7200,
})
return
}
var body struct {
Content string `json:"content"`
Settings string `json:"settings"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Errorf("decode %s: %v", r.URL.Path, err)
return
}
if body.Content == "failed final content" {
_ = json.NewEncoder(w).Encode(map[string]any{"code": 1, "msg": "rejected"})
return
}
if r.Method == http.MethodPatch {
finalSettings = body.Settings
}
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "msg": "ok"})
}))
defer srv.Close()
adapter, _ := NewAdapter(testRegion(srv.URL), "app", "secret", "", "", "")
feishuStreamsMu.Lock()
feishuStreams[cardID] = &feishuStreamState{}
feishuStreamsMu.Unlock()
t.Cleanup(func() {
feishuStreamsMu.Lock()
delete(feishuStreams, cardID)
feishuStreamsMu.Unlock()
})
ctx := context.Background()
if err := adapter.UpdateStreamContent(ctx, nil, cardID, "visible progress"); err != nil {
t.Fatalf("UpdateStreamContent: %v", err)
}
if err := adapter.FinalizeStream(ctx, nil, cardID, "failed final content"); err == nil {
t.Fatal("FinalizeStream succeeded, want CardKit rejection")
}
if err := adapter.EndStream(ctx, nil, cardID); err != nil {
t.Fatalf("EndStream: %v", err)
}
var settings struct {
Config struct {
Summary struct {
Content string `json:"content"`
} `json:"summary"`
} `json:"config"`
}
if err := json.Unmarshal([]byte(finalSettings), &settings); err != nil {
t.Fatalf("decode final settings: %v", err)
}
if settings.Config.Summary.Content != "visible progress" {
t.Errorf("summary = %q, want last successful content", settings.Config.Summary.Content)
}
}
func TestEndStreamFinalizeOnlyUsesFinalContent(t *testing.T) {
useTestHTTPClient(t)
const cardID = "card-full-output"
var finalSettings string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/open-apis/auth/v3/tenant_access_token/internal" {
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 0, "tenant_access_token": "test-token", "expire": 7200,
})
return
}
var body struct {
Settings string `json:"settings"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Errorf("decode %s: %v", r.URL.Path, err)
return
}
if r.Method == http.MethodPatch {
finalSettings = body.Settings
}
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "msg": "ok"})
}))
defer srv.Close()
adapter, _ := NewAdapter(testRegion(srv.URL), "app", "secret", "", "", "")
feishuStreamsMu.Lock()
feishuStreams[cardID] = &feishuStreamState{}
feishuStreamsMu.Unlock()
t.Cleanup(func() {
feishuStreamsMu.Lock()
delete(feishuStreams, cardID)
feishuStreamsMu.Unlock()
})
ctx := context.Background()
if err := adapter.FinalizeStream(ctx, nil, cardID, "最终答案"); err != nil {
t.Fatalf("FinalizeStream: %v", err)
}
if err := adapter.EndStream(ctx, nil, cardID); err != nil {
t.Fatalf("EndStream: %v", err)
}
assertStreamingClosed(t, finalSettings, "最终答案")
}
func TestEndStreamOmitsEmptySummary(t *testing.T) {
useTestHTTPClient(t)
const cardID = "card-empty-summary"
var finalSettings string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/open-apis/auth/v3/tenant_access_token/internal" {
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 0, "tenant_access_token": "test-token", "expire": 7200,
})
return
}
var body struct {
Settings string `json:"settings"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Errorf("decode %s: %v", r.URL.Path, err)
return
}
if r.Method == http.MethodPatch {
finalSettings = body.Settings
}
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "msg": "ok"})
}))
defer srv.Close()
adapter, _ := NewAdapter(testRegion(srv.URL), "app", "secret", "", "", "")
feishuStreamsMu.Lock()
feishuStreams[cardID] = &feishuStreamState{}
feishuStreamsMu.Unlock()
t.Cleanup(func() {
feishuStreamsMu.Lock()
delete(feishuStreams, cardID)
feishuStreamsMu.Unlock()
})
if err := adapter.EndStream(context.Background(), nil, cardID); err != nil {
t.Fatalf("EndStream: %v", err)
}
assertStreamingClosed(t, finalSettings, "")
}
func TestCardSummaryPreview(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{name: "collapse whitespace", in: " 最终\n回答\t✅ ", want: "最终 回答 ✅"},
{name: "image keeps alt only", in: "![架构图](https://cdn.example/a.png?sig=secret) 说明", want: "架构图 说明"},
{name: "link keeps text only", in: "见 [文档](https://cdn.example/a.png?sig=secret) 说明", want: "见 文档 说明"},
{name: "unicode limit", in: strings.Repeat("界", 121), want: strings.Repeat("界", 120)},
{name: "empty", in: " \n\t ", want: ""},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := cardSummaryPreview(test.in); got != test.want {
t.Errorf("cardSummaryPreview() = %q, want %q", got, test.want)
}
})
}
}
type cardSettings struct {
Config *struct {
StreamingMode *bool `json:"streaming_mode"`
Summary *struct {
Content string `json:"content"`
} `json:"summary"`
} `json:"config"`
}
func decodeCardSettings(t *testing.T, raw string) cardSettings {
t.Helper()
var settings cardSettings
if err := json.Unmarshal([]byte(raw), &settings); err != nil {
t.Fatalf("decode settings %q: %v", raw, err)
}
var top map[string]json.RawMessage
if err := json.Unmarshal([]byte(raw), &top); err != nil {
t.Fatalf("decode settings map %q: %v", raw, err)
}
if _, ok := top["streaming_mode"]; ok {
t.Fatalf("streaming_mode must be nested under config: %s", raw)
}
return settings
}
func assertStreamingClosed(t *testing.T, raw, wantSummary string) {
t.Helper()
settings := decodeCardSettings(t, raw)
if settings.Config == nil || settings.Config.StreamingMode == nil || *settings.Config.StreamingMode {
t.Fatalf("settings did not disable streaming under config: %s", raw)
}
if wantSummary == "" {
if settings.Config.Summary != nil {
t.Fatalf("settings included empty summary: %s", raw)
}
return
}
if settings.Config.Summary == nil || settings.Config.Summary.Content != wantSummary {
t.Fatalf("summary = %v, want %q in %s", settings.Config.Summary, wantSummary, raw)
}
}
+69
View File
@@ -0,0 +1,69 @@
package feishu
import (
"context"
"fmt"
"github.com/Tencent/WeKnora/internal/im"
"github.com/Tencent/WeKnora/internal/logger"
)
// NewFactory returns an im.AdapterFactory for channels on the given region's
// cloud — RegionFeishu for 飞书, RegionLark for Lark. Both use the same
// credentials and modes; only the API host and tenant differ.
//
// The HTTP adapter is always created (needed for SendReply in both modes);
// "websocket" mode additionally runs a long-connection event stream.
func NewFactory(region Region) im.AdapterFactory {
return func(factoryCtx context.Context, channel *im.IMChannel, msgHandler func(context.Context, *im.IncomingMessage) error) (im.Adapter, context.CancelFunc, error) {
creds, err := im.ParseCredentials(channel.Credentials)
if err != nil {
return nil, nil, fmt.Errorf("parse %s credentials: %w", region.Platform, err)
}
appID := im.GetString(creds, "app_id")
appSecret := im.GetString(creds, "app_secret")
verificationToken := im.GetString(creds, "verification_token")
encryptKey := im.GetString(creds, "encrypt_key")
apiBaseURL := im.GetString(creds, "api_base_url")
// Always create the HTTP adapter (needed for SendReply in both modes).
// apiBaseURL is validated here; an invalid value fails the channel.
adapter, err := NewAdapter(region, appID, appSecret, verificationToken, encryptKey, apiBaseURL)
if err != nil {
return nil, nil, fmt.Errorf("create %s adapter: %w", region.Platform, err)
}
mode := im.ResolveMode(channel, "websocket")
switch mode {
case "webhook":
return adapter, nil, nil
case "websocket":
client := NewLongConnClient(region, appID, appSecret, apiBaseURL, msgHandler)
wsCtx, wsCancel := context.WithCancel(context.Background())
go func() {
if err := client.Start(wsCtx); err != nil && wsCtx.Err() == nil {
logger.Errorf(context.Background(), "[IM] %s long connection stopped for channel %s: %v",
region.Label, channel.ID, err)
}
}()
// stop tears down the connection for real. wsCancel alone is a
// no-op: the Feishu SDK's Start blocks on select{} and never
// observes ctx, so we must call Close() to actually close the
// socket and disable auto-reconnect. wsCancel is kept as a
// belt-and-braces fallback for the start goroutine.
stop := func() {
client.Close()
wsCancel()
}
return adapter, stop, nil
default:
return nil, nil, fmt.Errorf("unknown %s mode: %s", region.Platform, mode)
}
}
}
+353
View File
@@ -0,0 +1,353 @@
package feishu
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/Tencent/WeKnora/internal/im"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/larksuite/oapi-sdk-go/v3/event/dispatcher"
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
)
// MessageHandler is called when an IM message is received via long connection.
type MessageHandler func(ctx context.Context, msg *im.IncomingMessage) error
// LongConnClient manages a Feishu/Lark WebSocket long connection.
type LongConnClient struct {
region Region
appID string
wsClient *larkws.Client
}
// NewLongConnClient creates a long connection client on the given region's cloud.
// apiBaseURL overrides the SDK's bootstrap domain (empty uses the region
// default); set it to a reverse-proxy origin for private/internal deployments.
// When a message arrives, it converts it to IncomingMessage and calls handler.
func NewLongConnClient(region Region, appID, appSecret, apiBaseURL string, handler MessageHandler) *LongConnClient {
// Long connection mode does not require verificationToken or encryptKey;
// those are only used for webhook signature verification and decryption.
eventHandler := dispatcher.NewEventDispatcher("", "").
OnP2MessageReceiveV1(func(ctx context.Context, event *larkim.P2MessageReceiveV1) error {
msg := convertEvent(region, event)
if msg == nil {
return nil
}
return handler(ctx, msg)
})
sdkLogger := &feishuLoggerAdapter{region: region, appID: appID}
apiBaseURL = strings.TrimRight(strings.TrimSpace(apiBaseURL), "/")
if apiBaseURL == "" {
apiBaseURL = region.OpenBaseURL
}
// WithDomain points the SDK at the region's cloud (open.feishu.cn by
// default). apiBaseURL overrides it for deployments that reach Feishu
// through a reverse proxy.
wsClient := larkws.NewClient(appID, appSecret,
larkws.WithEventHandler(eventHandler),
larkws.WithAutoReconnect(true),
larkws.WithLogger(sdkLogger),
larkws.WithDomain(apiBaseURL),
)
return &LongConnClient{region: region, appID: appID, wsClient: wsClient}
}
// Start begins the WebSocket long connection. It blocks until ctx is cancelled.
func (c *LongConnClient) Start(ctx context.Context) error {
logger.Infof(ctx, "[IM] %s WebSocket connecting (app_id=%s)...", c.region.Label, c.appID)
return c.wsClient.Start(ctx)
}
// Close tears down the WebSocket long connection.
//
// This is the only reliable way to stop a long connection: the SDK's
// Start blocks on a bare select{} and neither Start, pingLoop, nor
// receiveMessageLoop observe the passed context, so cancelling ctx alone leaves
// the underlying socket alive and the SDK auto-reconnecting. Client.Close()
// (added in oapi-sdk-go v3.9.7) flips autoReconnect off and calls disconnect,
// which actually closes the socket. Callers should still cancel the start ctx
// as a belt-and-braces fallback for the start goroutine.
func (c *LongConnClient) Close() {
c.wsClient.Close()
}
// feishuLoggerAdapter bridges the Feishu/Lark SDK logger to our unified logger,
// replacing raw SDK connection messages with a consistent format.
type feishuLoggerAdapter struct {
region Region
appID string
}
func (l *feishuLoggerAdapter) Debug(ctx context.Context, args ...interface{}) {
logger.Debugf(ctx, "[%s] %s", l.region.Label, fmt.Sprint(args...))
}
func (l *feishuLoggerAdapter) Info(ctx context.Context, args ...interface{}) {
msg := fmt.Sprint(args...)
if strings.HasPrefix(msg, "connected to ") {
logger.Infof(ctx, "[IM] %s WebSocket connected successfully (app_id=%s)", l.region.Label, l.appID)
return
}
logger.Infof(ctx, "[%s] %s", l.region.Label, msg)
}
func (l *feishuLoggerAdapter) Warn(ctx context.Context, args ...interface{}) {
logger.Warnf(ctx, "[%s] %s", l.region.Label, fmt.Sprint(args...))
}
func (l *feishuLoggerAdapter) Error(ctx context.Context, args ...interface{}) {
logger.Errorf(ctx, "[%s] %s", l.region.Label, fmt.Sprint(args...))
}
// convertEvent converts a Feishu/Lark SDK event to a unified IncomingMessage.
// Supports text, file, image and post messages. Returns nil for other types.
func convertEvent(region Region, event *larkim.P2MessageReceiveV1) *im.IncomingMessage {
if event == nil || event.Event == nil || event.Event.Message == nil {
logger.Warnf(context.Background(), "[%s][RX] event dropped: nil event/event/message", region.Label)
return nil
}
msg := event.Event.Message
// Debug: log every raw receive event so we can see exactly what the platform
// delivers (or doesn't). This is the single chokepoint for all message
// types — adding it here catches text/file/image/post uniformly.
logger.Infof(context.Background(),
"[%s][RX] msg_type=%q chat_type=%q chat_id=%q msg_id=%q root_id=%q parent_id=%q thread_id=%q content=%q",
region.Label,
ptrStr(msg.MessageType), ptrStr(msg.ChatType), ptrStr(msg.ChatId),
ptrStr(msg.MessageId), ptrStr(msg.RootId), ptrStr(msg.ParentId),
ptrStr(msg.ThreadId), ptrStr(msg.Content),
)
if msg.MessageType == nil {
return nil
}
msgType := *msg.MessageType
// Sender info
openID := ""
if event.Event.Sender != nil && event.Event.Sender.SenderId != nil && event.Event.Sender.SenderId.OpenId != nil {
openID = *event.Event.Sender.SenderId.OpenId
}
// Chat type
chatType := im.ChatTypeDirect
chatID := ""
if msg.ChatType != nil && *msg.ChatType == "group" {
chatType = im.ChatTypeGroup
if msg.ChatId != nil {
chatID = *msg.ChatId
}
}
// Message ID
messageID := ""
if msg.MessageId != nil {
messageID = *msg.MessageId
}
switch msgType {
case "text":
return convertTextEvent(region, msg, openID, chatID, chatType, messageID)
case "file":
return convertFileEvent(region, msg, openID, chatID, chatType, messageID)
case "image":
return convertImageEvent(region, msg, openID, chatID, chatType, messageID)
case "post":
return convertPostEvent(region, msg, openID, chatID, chatType, messageID)
default:
return nil
}
}
// convertTextEvent handles text message type.
func convertTextEvent(
region Region, msg *larkim.EventMessage,
openID, chatID string, chatType im.ChatType, messageID string,
) *im.IncomingMessage {
var textContent struct {
Text string `json:"text"`
}
if msg.Content == nil {
return nil
}
if err := json.Unmarshal([]byte(*msg.Content), &textContent); err != nil {
return nil
}
content := textContent.Text
if chatType == im.ChatTypeGroup {
for strings.HasPrefix(content, "@_user_") {
idx := strings.Index(content, " ")
if idx >= 0 {
content = content[idx+1:]
} else {
break
}
}
}
return &im.IncomingMessage{
Platform: region.Platform,
MessageType: im.MessageTypeText,
UserID: openID,
ChatID: chatID,
ChatType: chatType,
Content: strings.TrimSpace(content),
MessageID: messageID,
}
}
// convertFileEvent handles file message type.
func convertFileEvent(
region Region, msg *larkim.EventMessage,
openID, chatID string, chatType im.ChatType, messageID string,
) *im.IncomingMessage {
if msg.Content == nil {
return nil
}
var fileContent struct {
FileKey string `json:"file_key"`
FileName string `json:"file_name"`
}
if err := json.Unmarshal([]byte(*msg.Content), &fileContent); err != nil {
return nil
}
if fileContent.FileKey == "" {
return nil
}
return &im.IncomingMessage{
Platform: region.Platform,
MessageType: im.MessageTypeFile,
UserID: openID,
ChatID: chatID,
ChatType: chatType,
MessageID: messageID,
FileKey: fileContent.FileKey,
FileName: fileContent.FileName,
}
}
// convertImageEvent handles image message type.
// Downloads via GetMessageResource API with type=image.
func convertImageEvent(
region Region, msg *larkim.EventMessage,
openID, chatID string, chatType im.ChatType, messageID string,
) *im.IncomingMessage {
if msg.Content == nil {
return nil
}
var imageContent struct {
ImageKey string `json:"image_key"`
}
if err := json.Unmarshal([]byte(*msg.Content), &imageContent); err != nil {
return nil
}
if imageContent.ImageKey == "" {
return nil
}
return &im.IncomingMessage{
Platform: region.Platform,
MessageType: im.MessageTypeImage,
UserID: openID,
ChatID: chatID,
ChatType: chatType,
MessageID: messageID,
FileKey: imageContent.ImageKey,
FileName: imageContent.ImageKey + ".png",
}
}
// convertPostEvent handles rich-text (post) message type.
// Extracts all plain text content and treats it as a text query for QA.
func convertPostEvent(
region Region, msg *larkim.EventMessage,
openID, chatID string, chatType im.ChatType, messageID string,
) *im.IncomingMessage {
if msg.Content == nil {
return nil
}
// Post content structure: {"title":"...", "content":[[{"tag":"text","text":"..."},{"tag":"a","href":"...","text":"..."}]]}
var postContent struct {
Title string `json:"title"`
Content [][]json.RawMessage `json:"content"`
}
if err := json.Unmarshal([]byte(*msg.Content), &postContent); err != nil {
return nil
}
var textParts []string
if postContent.Title != "" {
textParts = append(textParts, postContent.Title)
}
for _, line := range postContent.Content {
var lineText strings.Builder
for _, elem := range line {
var tag struct {
Tag string `json:"tag"`
Text string `json:"text"`
}
if err := json.Unmarshal(elem, &tag); err != nil {
continue
}
switch tag.Tag {
case "text", "a":
lineText.WriteString(tag.Text)
case "at":
// Skip @mentions
}
}
if t := strings.TrimSpace(lineText.String()); t != "" {
textParts = append(textParts, t)
}
}
content := strings.Join(textParts, "\n")
if chatType == im.ChatTypeGroup {
for strings.HasPrefix(content, "@_user_") {
idx := strings.Index(content, " ")
if idx >= 0 {
content = content[idx+1:]
} else {
break
}
}
}
content = strings.TrimSpace(content)
if content == "" {
return nil
}
return &im.IncomingMessage{
Platform: region.Platform,
MessageType: im.MessageTypeText,
UserID: openID,
ChatID: chatID,
ChatType: chatType,
Content: content,
MessageID: messageID,
}
}
// ptrStr safely dereferences a *string for logging; returns "" for nil so the
// log line stays readable instead of printing <nil>.
func ptrStr(s *string) string {
if s == nil {
return ""
}
return *s
}
+49
View File
@@ -0,0 +1,49 @@
package feishu
import "github.com/Tencent/WeKnora/internal/im"
// Open Platform API origins. Feishu and Lark are the same product deployed on
// two isolated clouds; the API surface is identical, only the host differs.
const (
feishuOpenBaseURL = "https://open.feishu.cn"
larkOpenBaseURL = "https://open.larksuite.com"
)
// Region selects which cloud an adapter talks to. Apps, tenants, tokens and
// resource keys (image_key, file_key, card_id) are scoped to a single cloud and
// are never valid on the other, so a Region is fixed per channel at creation.
type Region struct {
// Platform is the im.Platform reported on messages parsed by this adapter.
Platform im.Platform
// OpenBaseURL is the Open Platform API origin, without a trailing slash.
OpenBaseURL string
// Label prefixes log lines so operators can tell the clouds apart.
Label string
// ThinkingText is the placeholder shown in a streaming card before the
// first answer chunk arrives.
ThinkingText string
// ImageFallbackLabel labels the plain link an image degrades to when it
// cannot be uploaded to the platform.
ImageFallbackLabel string
}
var (
// RegionFeishu is the Chinese mainland cloud (飞书). Its users are addressed
// in Chinese, matching the console and app language.
RegionFeishu = Region{
Platform: im.PlatformFeishu,
OpenBaseURL: feishuOpenBaseURL,
Label: "Feishu",
ThinkingText: "正在思考...",
ImageFallbackLabel: "图片",
}
// RegionLark is the international cloud (Lark), addressed in English.
RegionLark = Region{
Platform: im.PlatformLark,
OpenBaseURL: larkOpenBaseURL,
Label: "Lark",
ThinkingText: "Thinking...",
ImageFallbackLabel: "Image",
}
)
+179
View File
@@ -0,0 +1,179 @@
package feishu
import (
"encoding/json"
"strings"
"testing"
"github.com/Tencent/WeKnora/internal/im"
)
// The two regions must stay distinct in every dimension that reaches the
// network or the session store: sending a Lark request to open.feishu.cn fails
// authentication, and reporting the wrong platform merges the two clouds'
// sessions together.
func TestRegions_AreDistinct(t *testing.T) {
if RegionFeishu.OpenBaseURL == RegionLark.OpenBaseURL {
t.Errorf("regions share OpenBaseURL %q", RegionFeishu.OpenBaseURL)
}
if RegionFeishu.Platform == RegionLark.Platform {
t.Errorf("regions share Platform %q", RegionFeishu.Platform)
}
if RegionFeishu.Platform != im.PlatformFeishu {
t.Errorf("RegionFeishu.Platform = %q, want %q", RegionFeishu.Platform, im.PlatformFeishu)
}
if RegionLark.Platform != im.PlatformLark {
t.Errorf("RegionLark.Platform = %q, want %q", RegionLark.Platform, im.PlatformLark)
}
}
func TestRegions_BaseURLHosts(t *testing.T) {
cases := []struct {
region Region
want string
}{
{RegionFeishu, "https://open.feishu.cn"},
{RegionLark, "https://open.larksuite.com"},
}
for _, c := range cases {
if c.region.OpenBaseURL != c.want {
t.Errorf("%s OpenBaseURL = %q, want %q", c.region.Label, c.region.OpenBaseURL, c.want)
}
if strings.HasSuffix(c.region.OpenBaseURL, "/") {
t.Errorf("%s OpenBaseURL has a trailing slash: %q", c.region.Label, c.region.OpenBaseURL)
}
}
}
// Every API call must land on the region's own cloud.
func TestAdapterAPI_UsesRegionHost(t *testing.T) {
cases := []struct {
region Region
wantPrefix string
}{
{RegionFeishu, "https://open.feishu.cn/open-apis/"},
{RegionLark, "https://open.larksuite.com/open-apis/"},
}
for _, c := range cases {
a, _ := NewAdapter(c.region, "cli_app", "secret", "", "", "")
got := a.api("/open-apis/im/v1/messages/%s/reply", "om_1")
want := c.wantPrefix + "im/v1/messages/om_1/reply"
if got != want {
t.Errorf("%s api() = %q, want %q", c.region.Label, got, want)
}
// A path with no format verbs must survive unchanged.
if got := a.api("/open-apis/cardkit/v1/cards"); got != c.wantPrefix+"cardkit/v1/cards" {
t.Errorf("%s api() = %q, want %q", c.region.Label, got, c.wantPrefix+"cardkit/v1/cards")
}
}
}
func TestAdapterPlatform_FollowsRegion(t *testing.T) {
aFeishu, _ := NewAdapter(RegionFeishu, "a", "b", "", "", "")
if got := aFeishu.Platform(); got != im.PlatformFeishu {
t.Errorf("Feishu adapter Platform() = %q, want %q", got, im.PlatformFeishu)
}
aLark, _ := NewAdapter(RegionLark, "a", "b", "", "", "")
if got := aLark.Platform(); got != im.PlatformLark {
t.Errorf("Lark adapter Platform() = %q, want %q", got, im.PlatformLark)
}
}
// A custom api_base_url overrides the region's Open Platform host, so internal
// deployments can route Feishu API calls through a reverse proxy.
func TestAdapterAPI_BaseURLOverride(t *testing.T) {
a := &Adapter{apiBaseURL: "https://feishu-proxy.example.internal"}
got := a.api("/open-apis/im/v1/messages/%s/reply", "om_1")
want := "https://feishu-proxy.example.internal/open-apis/im/v1/messages/om_1/reply"
if got != want {
t.Errorf("override api() = %q, want %q", got, want)
}
}
// NewAdapter falls back to the region default when api_base_url is empty, and
// the region default itself passes validation (no SSRF check on the default).
func TestNewAdapter_FallbackAndTrim(t *testing.T) {
a, err := NewAdapter(RegionFeishu, "a", "b", "", "", "")
if err != nil {
t.Fatalf("NewAdapter empty api_base_url: %v", err)
}
if a.apiBaseURL != RegionFeishu.OpenBaseURL {
t.Errorf("fallback apiBaseURL = %q, want %q", a.apiBaseURL, RegionFeishu.OpenBaseURL)
}
// The region default value also passes (treated as "no override").
a2, err := NewAdapter(RegionFeishu, "a", "b", "", "", RegionFeishu.OpenBaseURL)
if err != nil {
t.Fatalf("NewAdapter default api_base_url: %v", err)
}
if a2.apiBaseURL != RegionFeishu.OpenBaseURL {
t.Errorf("default apiBaseURL = %q", a2.apiBaseURL)
}
}
// validateAPIBaseURL rejects non-http(s) schemes; empty and the region default
// are allowed without further checks.
func TestValidateAPIBaseURL(t *testing.T) {
defaults := RegionFeishu.OpenBaseURL
if err := validateAPIBaseURL("", defaults); err != nil {
t.Errorf("empty should be allowed: %v", err)
}
if err := validateAPIBaseURL(defaults, defaults); err != nil {
t.Errorf("default should be allowed: %v", err)
}
if err := validateAPIBaseURL("ftp://example.com", defaults); err == nil {
t.Error("ftp scheme should be rejected")
}
if err := validateAPIBaseURL("gopher://example.com", defaults); err == nil {
t.Error("gopher scheme should be rejected")
}
}
// The streaming card placeholder follows the region so Lark users are not shown
// Chinese copy.
func TestBuildStreamingCardJSON_PlaceholderFollowsRegion(t *testing.T) {
for _, region := range []Region{RegionFeishu, RegionLark} {
raw := buildStreamingCardJSON(region)
var card struct {
Config struct {
StreamingMode bool `json:"streaming_mode"`
Summary struct {
Content string `json:"content"`
} `json:"summary"`
} `json:"config"`
Body struct {
Elements []struct {
Content string `json:"content"`
ElementID string `json:"element_id"`
} `json:"elements"`
} `json:"body"`
}
if err := json.Unmarshal([]byte(raw), &card); err != nil {
t.Fatalf("%s card is not valid JSON: %v", region.Label, err)
}
if !card.Config.StreamingMode {
t.Errorf("%s card has streaming_mode disabled", region.Label)
}
if card.Config.Summary.Content != region.ThinkingText {
t.Errorf("%s summary = %q, want %q", region.Label, card.Config.Summary.Content, region.ThinkingText)
}
if len(card.Body.Elements) != 1 {
t.Fatalf("%s card has %d elements, want 1", region.Label, len(card.Body.Elements))
}
el := card.Body.Elements[0]
if el.ElementID != streamingElementID {
t.Errorf("%s element_id = %q, want %q", region.Label, el.ElementID, streamingElementID)
}
if !strings.Contains(el.Content, region.ThinkingText) {
t.Errorf("%s element content %q does not contain %q", region.Label, el.Content, region.ThinkingText)
}
}
// Guard the point of the whole exercise: the copy actually differs.
if RegionFeishu.ThinkingText == RegionLark.ThinkingText {
t.Error("Feishu and Lark share ThinkingText; Lark users would see Chinese copy")
}
}
+195
View File
@@ -0,0 +1,195 @@
package feishu
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/Tencent/WeKnora/internal/im"
)
func useTestHTTPClient(t *testing.T) {
t.Helper()
original := httpClient
httpClient = &http.Client{Timeout: 5 * time.Second}
t.Cleanup(func() { httpClient = original })
}
// testRegion returns a Lark-shaped region pointed at a fake Open Platform, so
// the whole send path can be driven without touching the real cloud. Region
// carries the host as a field precisely so this is possible.
func testRegion(baseURL string) Region {
r := RegionLark
r.OpenBaseURL = baseURL
return r
}
// fakeOpenPlatform records what the adapter asked for, and answers as the real
// Open Platform would.
type fakeOpenPlatform struct {
mu sync.Mutex
// paths records every request path in order.
paths []string
// replyAuth is the Authorization header seen on the reply call.
replyAuth string
// replyBody is the decoded JSON body of the reply call.
replyBody map[string]any
// sendBody is the decoded JSON body of the fallback send call.
sendBody map[string]any
// sendQuery is the raw query string of the fallback send call.
sendQuery string
// replyCode is returned from the reply endpoint (0 = success).
replyCode int
}
func (f *fakeOpenPlatform) handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
f.paths = append(f.paths, r.URL.Path)
f.mu.Unlock()
switch {
case r.URL.Path == "/open-apis/auth/v3/tenant_access_token/internal":
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 0, "tenant_access_token": "t-lark-xyz", "expire": 7200,
})
case strings.HasSuffix(r.URL.Path, "/reply"):
f.mu.Lock()
f.replyAuth = r.Header.Get("Authorization")
_ = json.NewDecoder(r.Body).Decode(&f.replyBody)
code := f.replyCode
f.mu.Unlock()
_ = json.NewEncoder(w).Encode(map[string]any{"code": code, "msg": "mock"})
case r.URL.Path == "/open-apis/im/v1/messages":
f.mu.Lock()
f.sendQuery = r.URL.RawQuery
_ = json.NewDecoder(r.Body).Decode(&f.sendBody)
f.mu.Unlock()
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "msg": "ok"})
default:
http.NotFound(w, r)
}
})
}
func (f *fakeOpenPlatform) sawPath(want string) bool {
f.mu.Lock()
defer f.mu.Unlock()
for _, p := range f.paths {
if p == want {
return true
}
}
return false
}
// SendReply must fetch a token from the region's cloud and reply under the
// original message, carrying that token as a bearer credential.
func TestSendReply_EndToEnd_UsesRegionCloud(t *testing.T) {
useTestHTTPClient(t)
fake := &fakeOpenPlatform{}
srv := httptest.NewServer(fake.handler())
defer srv.Close()
a, _ := NewAdapter(testRegion(srv.URL), "cli_app", "secret", "", "", "")
incoming := &im.IncomingMessage{
Platform: im.PlatformLark,
UserID: "ou_user1",
ChatType: im.ChatTypeDirect,
MessageID: "om_msg1",
}
if err := a.SendReply(context.Background(), incoming, &im.ReplyMessage{Content: "hello lark"}); err != nil {
t.Fatalf("SendReply: %v", err)
}
if !fake.sawPath("/open-apis/auth/v3/tenant_access_token/internal") {
t.Errorf("no token fetch; paths = %v", fake.paths)
}
if !fake.sawPath("/open-apis/im/v1/messages/om_msg1/reply") {
t.Errorf("reply did not target the message; paths = %v", fake.paths)
}
if fake.replyAuth != "Bearer t-lark-xyz" {
t.Errorf("reply Authorization = %q, want %q", fake.replyAuth, "Bearer t-lark-xyz")
}
if got := fake.replyBody["msg_type"]; got != "text" {
t.Errorf("msg_type = %v, want text", got)
}
// content is a JSON-encoded string, per the Open Platform contract.
var content struct {
Text string `json:"text"`
}
raw, _ := fake.replyBody["content"].(string)
if err := json.Unmarshal([]byte(raw), &content); err != nil {
t.Fatalf("content is not a JSON string: %q", raw)
}
if content.Text != "hello lark" {
t.Errorf("text = %q, want %q", content.Text, "hello lark")
}
}
// A group that rejects reply-in-thread (230071) must still receive the answer
// via the plain send-message API, addressed to the chat.
func TestSendReply_EndToEnd_FallsBackToSendAPI(t *testing.T) {
useTestHTTPClient(t)
fake := &fakeOpenPlatform{replyCode: 230071}
srv := httptest.NewServer(fake.handler())
defer srv.Close()
a, _ := NewAdapter(testRegion(srv.URL), "cli_app", "secret", "", "", "")
incoming := &im.IncomingMessage{
Platform: im.PlatformLark,
UserID: "ou_user1",
ChatID: "oc_chat1",
ChatType: im.ChatTypeGroup,
MessageID: "om_msg1",
}
if err := a.SendReply(context.Background(), incoming, &im.ReplyMessage{Content: "grouped"}); err != nil {
t.Fatalf("SendReply: %v", err)
}
if !fake.sawPath("/open-apis/im/v1/messages") {
t.Fatalf("no fallback to send-message API; paths = %v", fake.paths)
}
if fake.sendQuery != "receive_id_type=chat_id" {
t.Errorf("send query = %q, want receive_id_type=chat_id", fake.sendQuery)
}
if got := fake.sendBody["receive_id"]; got != "oc_chat1" {
t.Errorf("receive_id = %v, want oc_chat1", got)
}
}
// A non-fallback-eligible error must surface, not be silently swallowed.
func TestSendReply_EndToEnd_HardErrorSurfaces(t *testing.T) {
useTestHTTPClient(t)
fake := &fakeOpenPlatform{replyCode: 99991663} // app ticket invalid — not retryable
srv := httptest.NewServer(fake.handler())
defer srv.Close()
a, _ := NewAdapter(testRegion(srv.URL), "cli_app", "secret", "", "", "")
incoming := &im.IncomingMessage{
Platform: im.PlatformLark, UserID: "ou_user1",
ChatType: im.ChatTypeDirect, MessageID: "om_msg1",
}
err := a.SendReply(context.Background(), incoming, &im.ReplyMessage{Content: "x"})
if err == nil {
t.Fatal("SendReply returned nil for a non-retryable API error")
}
if !strings.Contains(err.Error(), "99991663") {
t.Errorf("error %q does not mention the API code", err)
}
// The plain send API must not be tried for a hard error.
if fake.sawPath("/open-apis/im/v1/messages") {
t.Errorf("fell back to send-message API on a non-retryable error; paths = %v", fake.paths)
}
}
+202
View File
@@ -0,0 +1,202 @@
package im
import (
"bytes"
"context"
"io"
"strings"
"testing"
"unicode/utf8"
"github.com/Tencent/WeKnora/internal/types"
)
type attachmentTestAdapter struct {
*lifecycleTestAdapter
content []byte
fileName string
}
func (a *attachmentTestAdapter) DownloadFile(context.Context, *IncomingMessage) (io.ReadCloser, string, error) {
return io.NopCloser(bytes.NewReader(a.content)), a.fileName, nil
}
func TestFileMessageQAContent(t *testing.T) {
tests := []struct {
name string
msg *IncomingMessage
want string
}{
{
name: "preserves caption",
msg: &IncomingMessage{Content: "请总结这个文件", FileName: "report.pdf"},
want: "请总结这个文件",
},
{
name: "builds query for file-only event",
msg: &IncomingMessage{FileName: "report.pdf"},
want: "我上传了文件「report.pdf」。请确认已收到,并告知我接下来可以如何协助。",
},
{
name: "uses safe name when platform omits filename",
msg: &IncomingMessage{},
want: "我上传了文件「未命名文件」。请确认已收到,并告知我接下来可以如何协助。",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := fileMessageQAContent(tt.msg); got != tt.want {
t.Errorf("fileMessageQAContent() = %q, want %q", got, tt.want)
}
})
}
}
func TestEmptyIncomingMessageReply(t *testing.T) {
tests := []struct {
name string
msg *IncomingMessage
wantEmpty bool
wantHint string
}{
{
name: "text content is accepted",
msg: &IncomingMessage{MessageType: MessageTypeText, Content: " hello "},
wantEmpty: false,
},
{
name: "blank text is rejected",
msg: &IncomingMessage{MessageType: MessageTypeText, Content: " \n\t "},
wantEmpty: true,
wantHint: "未能识别这条消息中的文字内容。请改用纯文本发送;图片或文件请单独发送。",
},
{
name: "image without caption is accepted before QA content fill",
msg: &IncomingMessage{MessageType: MessageTypeImage, FileKey: "pic-1", FileName: "pic-1.png"},
wantEmpty: false,
},
{
name: "file without caption is accepted before QA content fill",
msg: &IncomingMessage{MessageType: MessageTypeFile, FileKey: "file-1", FileName: "spec.pdf"},
wantEmpty: false,
},
{
name: "file key is treated as an attachment even if type stays text",
msg: &IncomingMessage{MessageType: MessageTypeText, FileKey: "pic-1"},
wantEmpty: false,
},
{
name: "audio without recognition uses a voice-specific hint",
msg: &IncomingMessage{
MessageType: MessageTypeText,
Extra: map[string]string{"raw_msgtype": "audio"},
},
wantEmpty: true,
wantHint: "未能识别这条语音中的文字内容。请改用纯文本发送,或再说一遍。",
},
{
name: "video uses an unsupported-type hint",
msg: &IncomingMessage{
MessageType: MessageTypeText,
Extra: map[string]string{"raw_msgtype": "video"},
},
wantEmpty: true,
wantHint: "暂不支持视频消息。请改用纯文本发送;图片或文件请单独发送。",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotHint, gotEmpty := emptyIncomingMessageReply(tt.msg)
if gotEmpty != tt.wantEmpty {
t.Fatalf("empty = %v, want %v", gotEmpty, tt.wantEmpty)
}
if gotHint != tt.wantHint {
t.Fatalf("hint = %q, want %q", gotHint, tt.wantHint)
}
})
}
}
func TestEmptyIncomingMessageReplyAfterFileQAContent(t *testing.T) {
msg := &IncomingMessage{MessageType: MessageTypeImage, FileName: "shot.png"}
msg.Content = fileMessageQAContent(msg)
if hint, empty := emptyIncomingMessageReply(msg); empty {
t.Fatalf("image after fileMessageQAContent was rejected: hint=%q", hint)
}
}
func TestApplyIMAttachmentTruncationByLineCount(t *testing.T) {
lines := make([]string, maxIMAttachmentLines+1)
for i := range lines {
lines[i] = "line"
}
attachment := &types.MessageAttachment{}
applyIMAttachmentTruncation(strings.Join(lines, "\n"), attachment)
if !attachment.IsTruncated {
t.Fatal("expected content to be truncated")
}
if attachment.LineCount != maxIMAttachmentLines+1 {
t.Errorf("LineCount = %d, want %d", attachment.LineCount, maxIMAttachmentLines+1)
}
if got := len(strings.Split(attachment.Content, "\n")); got != maxIMAttachmentLines {
t.Errorf("kept lines = %d, want %d", got, maxIMAttachmentLines)
}
}
func TestApplyIMAttachmentTruncationLimitsLargeSingleLine(t *testing.T) {
attachment := &types.MessageAttachment{}
applyIMAttachmentTruncation(strings.Repeat("x", 20<<20), attachment)
if !attachment.IsTruncated {
t.Fatal("expected content to be truncated")
}
if got := len(attachment.Content); got != maxIMAttachmentContentBytes {
t.Fatalf("content bytes = %d, want %d", got, maxIMAttachmentContentBytes)
}
if attachment.LineCount != 1 {
t.Fatalf("LineCount = %d, want 1", attachment.LineCount)
}
}
func TestApplyIMAttachmentTruncationPreservesUTF8(t *testing.T) {
attachment := &types.MessageAttachment{}
applyIMAttachmentTruncation(strings.Repeat("中", maxIMAttachmentContentBytes), attachment)
if !attachment.IsTruncated {
t.Fatal("expected content to be truncated")
}
if !utf8.ValidString(attachment.Content) {
t.Fatal("truncated content is not valid UTF-8")
}
if len(attachment.Content) > maxIMAttachmentContentBytes {
t.Fatalf("content bytes = %d, exceeds %d", len(attachment.Content), maxIMAttachmentContentBytes)
}
}
func TestPrepareIMAttachmentsDetectsImageMIMEFromContent(t *testing.T) {
// The platform may name a JPEG resource with a .png suffix. The data URI
// must use its actual content type for vision model compatibility.
jpeg := []byte{0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 'J', 'F', 'I', 'F', 0x00, 0x01}
adapter := &attachmentTestAdapter{
lifecycleTestAdapter: &lifecycleTestAdapter{},
content: jpeg,
fileName: "platform-image.png",
}
attachments, imageURLs, _, err := (&Service{}).prepareIMAttachments(context.Background(), &IncomingMessage{
MessageType: MessageTypeImage,
FileName: "platform-image.png",
}, adapter)
if err != nil {
t.Fatalf("prepareIMAttachments() error = %v", err)
}
if len(attachments) != 1 {
t.Fatalf("attachment count = %d, want 1", len(attachments))
}
if len(imageURLs) != 1 || !strings.HasPrefix(imageURLs[0], "data:image/jpeg;base64,") {
t.Fatalf("image URL = %v, want JPEG data URI", imageURLs)
}
}
+391
View File
@@ -0,0 +1,391 @@
package im
import (
"context"
"errors"
"reflect"
"sync"
"testing"
"time"
"github.com/Tencent/WeKnora/internal/event"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
type fullOutputOrder struct {
mu sync.Mutex
steps []string
}
func lifecycleIMContext() context.Context {
ctx := context.WithValue(context.Background(), types.TenantIDContextKey, uint64(1))
ctx = context.WithValue(ctx, types.UserIDContextKey, "system-1")
return types.WithPrincipal(ctx, types.Principal{Type: types.PrincipalIMUser, ID: "test-user"})
}
func (o *fullOutputOrder) add(step string) {
o.mu.Lock()
defer o.mu.Unlock()
o.steps = append(o.steps, step)
}
func (o *fullOutputOrder) snapshot() []string {
o.mu.Lock()
defer o.mu.Unlock()
return append([]string(nil), o.steps...)
}
type fullOutputSessionService struct {
interfaces.SessionService
order *fullOutputOrder
answer string
hangUntilCancel bool
started chan struct{}
}
func (s *fullOutputSessionService) KnowledgeQA(ctx context.Context, req *types.QARequest, bus *event.EventBus) error {
s.order.add("qa")
if s.started != nil {
close(s.started)
}
if s.hangUntilCancel {
<-ctx.Done()
return ctx.Err()
}
return bus.Emit(ctx, event.Event{
ID: "answer-1",
Type: event.EventAgentFinalAnswer,
SessionID: req.Session.ID,
Data: event.AgentFinalAnswerData{
Content: s.answer,
Done: true,
},
})
}
type fullOutputMessageService struct {
interfaces.MessageService
}
func (s *fullOutputMessageService) CreateMessage(_ context.Context, msg *types.Message) (*types.Message, error) {
created := *msg
created.ID = created.Role + "-message"
return &created, nil
}
func (s *fullOutputMessageService) UpdateMessage(_ context.Context, _ *types.Message) error {
return nil
}
type fullOutputStreamManager struct {
interfaces.StreamManager
}
func (s *fullOutputStreamManager) AppendEvent(_ context.Context, _, _ string, _ interfaces.StreamEvent) error {
return nil
}
func (s *fullOutputStreamManager) GetEvents(
_ context.Context, _, _ string, from int,
) ([]interfaces.StreamEvent, int, error) {
return nil, from, nil
}
type fullOutputAdapter struct {
Adapter
order *fullOutputOrder
finalContent string
updates int
plainReplies int
plainContent string
startErr error
finalizeErr error
sendErr error
}
func (a *fullOutputAdapter) SendReply(ctx context.Context, _ *IncomingMessage, reply *ReplyMessage) error {
if err := ctx.Err(); err != nil {
return err
}
if a.sendErr != nil {
a.order.add("plain-reply")
return a.sendErr
}
a.plainReplies++
if reply != nil {
a.plainContent = reply.Content
}
a.order.add("plain-reply")
return nil
}
func (a *fullOutputAdapter) StartStream(ctx context.Context, _ *IncomingMessage) (string, error) {
if err := ctx.Err(); err != nil {
return "", err
}
a.order.add("start")
if a.startErr != nil {
return "", a.startErr
}
return "full-output-stream", nil
}
func (a *fullOutputAdapter) SupportsFullOutputProgress() bool {
return true
}
func (a *fullOutputAdapter) UpdateStreamContent(ctx context.Context, _ *IncomingMessage, _ string, _ string) error {
if err := ctx.Err(); err != nil {
return err
}
a.updates++
a.order.add("update")
return nil
}
func (a *fullOutputAdapter) FinalizeStream(ctx context.Context, _ *IncomingMessage, _ string, content string) error {
if err := ctx.Err(); err != nil {
return err
}
a.finalContent = content
a.order.add("finalize")
return a.finalizeErr
}
func (a *fullOutputAdapter) EndStream(ctx context.Context, _ *IncomingMessage, _ string) error {
if err := ctx.Err(); err != nil {
return err
}
a.order.add("end")
return nil
}
type fullOutputNoProgressAdapter struct {
*fullOutputAdapter
}
func (a *fullOutputNoProgressAdapter) SupportsFullOutputProgress() bool {
return false
}
func newFullOutputHarness(answer string) (*Service, *fullOutputAdapter, *fullOutputSessionService, *fullOutputOrder) {
order := &fullOutputOrder{}
adapter := &fullOutputAdapter{order: order}
sessionSvc := &fullOutputSessionService{order: order, answer: answer}
service := &Service{
sessionService: sessionSvc,
messageService: &fullOutputMessageService{},
streamManager: &fullOutputStreamManager{},
}
return service, adapter, sessionSvc, order
}
func TestHandleMessageFullOutputShowsPlaceholderWithoutIntermediateUpdates(t *testing.T) {
service, adapter, _, order := newFullOutputHarness("最终答案")
msg := &IncomingMessage{Platform: PlatformFeishu, UserID: "user-1", Content: "问题"}
session := &types.Session{ID: "session-1"}
err := service.handleMessageFullOutput(
context.Background(), msg, session, nil, nil, nil, nil, adapter, adapter, "user-key", nil,
)
if err != nil {
t.Fatalf("handleMessageFullOutput() error = %v", err)
}
wantOrder := []string{"start", "qa", "finalize", "end"}
if got := order.snapshot(); !reflect.DeepEqual(got, wantOrder) {
t.Fatalf("lifecycle order = %v, want %v", got, wantOrder)
}
if adapter.updates != 0 {
t.Fatalf("full output sent %d intermediate updates, want 0", adapter.updates)
}
if adapter.finalContent != "最终答案" {
t.Fatalf("final content = %q, want %q", adapter.finalContent, "最终答案")
}
if adapter.plainReplies != 0 {
t.Fatalf("plain fallback replies = %d, want 0", adapter.plainReplies)
}
}
func TestHandleMessageFullOutputReplacesPlaceholderAfterCancel(t *testing.T) {
service, adapter, sessionSvc, order := newFullOutputHarness("")
sessionSvc.hangUntilCancel = true
sessionSvc.started = make(chan struct{})
msg := &IncomingMessage{Platform: PlatformFeishu, UserID: "user-1", Content: "问题"}
session := &types.Session{ID: "session-1"}
ctx, cancel := context.WithCancel(lifecycleIMContext())
done := make(chan error, 1)
go func() {
done <- service.handleMessageFullOutput(
ctx, msg, session, nil, nil, nil, nil, adapter, adapter, "user-key", nil,
)
}()
select {
case <-sessionSvc.started:
case <-time.After(2 * time.Second):
t.Fatal("QA did not start")
}
cancel()
select {
case err := <-done:
if err != nil {
t.Fatalf("handleMessageFullOutput() error = %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for full-output after cancel")
}
wantOrder := []string{"start", "qa", "finalize", "end"}
if got := order.snapshot(); !reflect.DeepEqual(got, wantOrder) {
t.Fatalf("lifecycle order = %v, want %v", got, wantOrder)
}
if adapter.finalContent != imCancelledFallback {
t.Fatalf("final content = %q, want cancel fallback %q", adapter.finalContent, imCancelledFallback)
}
if adapter.plainReplies != 0 {
t.Fatalf("plain fallback replies = %d, want 0", adapter.plainReplies)
}
}
func TestHandleMessageFullOutputStartStreamErrorFallsBackToPlainReply(t *testing.T) {
service, adapter, _, order := newFullOutputHarness("最终答案")
adapter.startErr = errors.New("create card failed")
msg := &IncomingMessage{Platform: PlatformFeishu, UserID: "user-1", Content: "问题"}
session := &types.Session{ID: "session-1"}
err := service.handleMessageFullOutput(
context.Background(), msg, session, nil, nil, nil, nil, adapter, adapter, "user-key", nil,
)
if err != nil {
t.Fatalf("handleMessageFullOutput() error = %v", err)
}
wantOrder := []string{"start", "qa", "plain-reply"}
if got := order.snapshot(); !reflect.DeepEqual(got, wantOrder) {
t.Fatalf("lifecycle order = %v, want %v", got, wantOrder)
}
if adapter.plainReplies != 1 {
t.Fatalf("plain fallback replies = %d, want 1", adapter.plainReplies)
}
if adapter.plainContent != "最终答案" {
t.Fatalf("plain reply = %q, want %q", adapter.plainContent, "最终答案")
}
if adapter.finalContent != "" {
t.Fatalf("FinalizeStream ran after StartStream failure, content=%q", adapter.finalContent)
}
}
func TestHandleMessageFullOutputFinalizeFailureSendsPlainReply(t *testing.T) {
service, adapter, _, order := newFullOutputHarness("最终答案")
adapter.finalizeErr = errors.New("card update failed")
msg := &IncomingMessage{Platform: PlatformFeishu, UserID: "user-1", Content: "问题"}
session := &types.Session{ID: "session-1"}
err := service.handleMessageFullOutput(
context.Background(), msg, session, nil, nil, nil, nil, adapter, adapter, "user-key", nil,
)
if err != nil {
t.Fatalf("successful plain fallback should not return error, got %v", err)
}
wantOrder := []string{"start", "qa", "finalize", "end", "plain-reply"}
if got := order.snapshot(); !reflect.DeepEqual(got, wantOrder) {
t.Fatalf("lifecycle order = %v, want %v", got, wantOrder)
}
if adapter.plainReplies != 1 {
t.Fatalf("plain fallback replies = %d, want 1", adapter.plainReplies)
}
if adapter.plainContent != "最终答案" {
t.Fatalf("plain reply = %q, want %q", adapter.plainContent, "最终答案")
}
}
func TestHandleMessageFullOutputThinkOnlyReplacesWithNoAnswerFallback(t *testing.T) {
service, adapter, _, _ := newFullOutputHarness("<think>only reasoning</think>")
msg := &IncomingMessage{Platform: PlatformFeishu, UserID: "user-1", Content: "问题"}
session := &types.Session{ID: "session-1"}
err := service.handleMessageFullOutput(
context.Background(), msg, session, nil, nil, nil, nil, adapter, adapter, "user-key", nil,
)
if err != nil {
t.Fatalf("handleMessageFullOutput() error = %v", err)
}
if adapter.finalContent != imNoAnswerFallback {
t.Fatalf("final content = %q, want %q", adapter.finalContent, imNoAnswerFallback)
}
}
func TestExecuteQARequestFullOutputDispatchesToProgressSender(t *testing.T) {
service, adapter, _, order := newFullOutputHarness("最终答案")
service.db = newLifecycleTestDB(t)
channel := createLifecycleChannel(t, service.db, "progress-channel", "progress-agent")
ctx, cancel := context.WithCancel(lifecycleIMContext())
service.executeQARequest(&qaRequest{
ctx: ctx,
cancel: cancel,
msg: &IncomingMessage{Platform: PlatformFeishu, UserID: "user-1", Content: "问题"},
session: &types.Session{ID: "session-1"},
adapter: adapter,
channel: channel,
userKey: "user-key",
})
wantOrder := []string{"start", "qa", "finalize", "end"}
if got := order.snapshot(); !reflect.DeepEqual(got, wantOrder) {
t.Fatalf("lifecycle order = %v, want %v", got, wantOrder)
}
if adapter.plainReplies != 0 {
t.Fatalf("plain fallback replies = %d, want 0", adapter.plainReplies)
}
}
func TestExecuteQARequestFullOutputSkipsProgressWhenNotSupported(t *testing.T) {
service, inner, _, order := newFullOutputHarness("最终答案")
service.db = newLifecycleTestDB(t)
channel := createLifecycleChannel(t, service.db, "plain-channel", "plain-agent")
adapter := &fullOutputNoProgressAdapter{fullOutputAdapter: inner}
ctx, cancel := context.WithCancel(lifecycleIMContext())
service.executeQARequest(&qaRequest{
ctx: ctx,
cancel: cancel,
msg: &IncomingMessage{Platform: PlatformFeishu, UserID: "user-1", Content: "问题"},
session: &types.Session{ID: "session-1"},
adapter: adapter,
channel: channel,
userKey: "user-key",
})
wantOrder := []string{"qa", "plain-reply"}
if got := order.snapshot(); !reflect.DeepEqual(got, wantOrder) {
t.Fatalf("lifecycle order = %v, want %v", got, wantOrder)
}
if inner.plainContent != "最终答案" {
t.Fatalf("plain reply = %q, want %q", inner.plainContent, "最终答案")
}
}
func TestFormatIMOutboundAnswerOrFallbackAfterCleanup(t *testing.T) {
got := formatIMOutboundAnswerOrFallback(
context.Background(), "<think>only reasoning</think>", nil, nil,
)
if got != imNoAnswerFallback {
t.Fatalf("think-only final content = %q, want fallback %q", got, imNoAnswerFallback)
}
}
func TestIMQAFailureReply(t *testing.T) {
if got := imQAFailureReply(context.Canceled); got != imCancelledFallback {
t.Fatalf("canceled = %q, want %q", got, imCancelledFallback)
}
if got := imQAFailureReply(context.DeadlineExceeded); got != imCancelledFallback {
t.Fatalf("deadline = %q, want %q", got, imCancelledFallback)
}
if got := imQAFailureReply(errors.New("boom")); got != imErrorFallback {
t.Fatalf("other error = %q, want %q", got, imErrorFallback)
}
}
+51
View File
@@ -0,0 +1,51 @@
package im
import (
"context"
"testing"
"github.com/Tencent/WeKnora/internal/types"
)
func TestWithIMIdentity(t *testing.T) {
const tenantID uint64 = 42
msg := &IncomingMessage{Platform: PlatformFeishu, UserID: "open-id-1"}
ctx := withIMIdentity(context.Background(), tenantID, "channel-1", msg)
gotTenant, ok := types.TenantIDFromContext(ctx)
if !ok || gotTenant != tenantID {
t.Fatalf("TenantID = %d (ok=%v), want %d", gotTenant, ok, tenantID)
}
userID, ok := types.UserIDFromContext(ctx)
if !ok || userID == "" {
t.Fatalf("UserID = %q (ok=%v), want non-empty synthetic user", userID, ok)
}
if want := "system-42"; userID != want {
t.Fatalf("UserID = %q, want %q", userID, want)
}
// The synthetic shape must be recognised so RBAC code does not record it
// as a resource creator.
if !types.IsSyntheticUserID(userID) {
t.Fatalf("IsSyntheticUserID(%q) = false, want true", userID)
}
// Non-empty UserID is the gate the shared-KB resolution relies on; without
// it Organization-shared KBs are silently skipped on the IM path.
if role := types.TenantRoleFromContext(ctx); role != types.TenantRoleViewer {
t.Fatalf("TenantRole = %v, want %v", role, types.TenantRoleViewer)
}
principal, ok := types.PrincipalFromContext(ctx)
if !ok {
t.Fatalf("Principal missing")
}
if principal.Type != types.PrincipalIMUser || principal.ID != "42:channel-1:feishu:open-id-1" {
t.Fatalf("Principal = %#v, want im_user for the external IM user", principal)
}
if !types.IsMCPOAuthNonInteractive(ctx) {
t.Fatal("IM context should mark MCP OAuth as non-interactive")
}
}
+27
View File
@@ -0,0 +1,27 @@
package im
import (
"testing"
"github.com/stretchr/testify/assert"
)
// imImageConfigWarning warns only when IM channels are active AND APP_EXTERNAL_URL
// is unset — the config state that silently breaks resource:// images in IM.
func TestIMImageConfigWarning(t *testing.T) {
t.Run("no channels -> silent", func(t *testing.T) {
assert.Empty(t, imImageConfigWarning(0, ""))
})
t.Run("external URL set -> silent", func(t *testing.T) {
assert.Empty(t, imImageConfigWarning(3, "https://weknora.example.com"))
})
t.Run("external URL whitespace-only -> warns", func(t *testing.T) {
assert.NotEmpty(t, imImageConfigWarning(1, " "))
})
t.Run("channels active but external URL empty -> warns with count", func(t *testing.T) {
msg := imImageConfigWarning(2, "")
assert.Contains(t, msg, "APP_EXTERNAL_URL")
assert.Contains(t, msg, "2 IM channel")
assert.Contains(t, msg, "/r/")
})
}
+168
View File
@@ -0,0 +1,168 @@
package im
import (
"context"
"io"
"mime/multipart"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// stubIMFileService implements interfaces.FileService for IM resolver tests.
type stubIMFileService struct {
getFileURL func(ctx context.Context, filePath string) (string, error)
}
func (s *stubIMFileService) CheckConnectivity(context.Context) error { return nil }
func (s *stubIMFileService) SaveFile(context.Context, *multipart.FileHeader, uint64, string) (string, error) {
return "", nil
}
func (s *stubIMFileService) SaveBytes(context.Context, []byte, uint64, string, bool) (string, error) {
return "", nil
}
func (s *stubIMFileService) GetFile(context.Context, string) (io.ReadCloser, error) {
return nil, nil
}
func (s *stubIMFileService) GetFileURL(ctx context.Context, filePath string) (string, error) {
if s.getFileURL != nil {
return s.getFileURL(ctx, filePath)
}
return "https://global-storage.example/" + filePath, nil
}
func (s *stubIMFileService) DeleteFile(context.Context, string) error { return nil }
func (s *stubIMFileService) CopyFile(context.Context, string, uint64, string) (string, error) {
return "", nil
}
func TestBuildIMFileServiceForProvider_FallbackToGlobal(t *testing.T) {
stub := &stubIMFileService{}
tenant := &types.Tenant{
StorageEngineConfig: &types.StorageEngineConfig{
DefaultProvider: "cos",
COS: &types.COSEngineConfig{
SecretID: "id",
SecretKey: "key",
BucketName: "bucket",
Region: "ap-shanghai",
},
},
}
svc := buildIMFileServiceForProvider(tenant, "minio", stub)
require.NotNil(t, svc)
got, err := svc.GetFileURL(context.Background(), "minio://wizard-test/10000/exports/a.png")
require.NoError(t, err)
assert.Equal(t, "https://global-storage.example/minio://wizard-test/10000/exports/a.png", got)
}
func TestIMFileServiceResolver_CachesPerProvider(t *testing.T) {
stub := &stubIMFileService{}
tenant := &types.Tenant{
StorageEngineConfig: &types.StorageEngineConfig{
DefaultProvider: "cos",
COS: &types.COSEngineConfig{
SecretID: "id",
SecretKey: "key",
BucketName: "bucket",
Region: "ap-shanghai",
},
},
}
r := newIMFileServiceResolver(tenant, stub)
svc1 := r.ResolveFileService("minio://wizard-test/10000/a.png")
svc2 := r.ResolveFileService("minio://wizard-test/10000/b.png")
assert.Same(t, svc1, svc2, "same provider should reuse cached FileService")
svc3 := r.ResolveFileService("local://10000/c.png")
assert.NotSame(t, svc1, svc3, "different provider should use a different service")
}
func TestRewriteStorageURLs_MinIOFallbackViaGlobal(t *testing.T) {
stub := &stubIMFileService{
getFileURL: func(_ context.Context, filePath string) (string, error) {
return "https://minio.example/presigned?path=" + filePath, nil
},
}
tenant := &types.Tenant{
StorageEngineConfig: &types.StorageEngineConfig{
DefaultProvider: "cos",
COS: &types.COSEngineConfig{
SecretID: "id",
SecretKey: "key",
BucketName: "bucket",
Region: "ap-shanghai",
},
},
}
in := `![知识助理"知识库"管理视图界面](minio://wizard-test/10000/exports/c91cf852.png)`
resolver := newIMFileServiceResolver(tenant, stub)
out := rewriteStorageURLs(context.Background(), in, resolver)
assert.Contains(t, out, "https://minio.example/presigned")
assert.NotContains(t, out, "](minio://")
}
func TestRewriteStorageURLs_ScopedPath(t *testing.T) {
stub := &stubIMFileService{
getFileURL: func(_ context.Context, filePath string) (string, error) {
assert.Equal(t, "storage://backend-a/cos://bucket/ap-test/10000/exports/a.png", filePath)
return "https://storage.example/a.png", nil
},
}
input := "![img](storage://backend-a/cos://bucket/ap-test/10000/exports/a.png)"
output := rewriteStorageURLs(context.Background(), input, newIMFileServiceResolver(&types.Tenant{}, stub))
assert.Contains(t, output, "https://storage.example/a.png")
}
// When GetFileURL resolves a resource:// alias to a still-internal storage://
// path (no public HTTP URL), the rewrite must be a no-op rather than emit the
// unrenderable URL to the IM client.
func TestRewriteStorageURLs_NonHTTPResultIsNoOp(t *testing.T) {
stub := &stubIMFileService{
getFileURL: func(_ context.Context, _ string) (string, error) {
return "storage://7cb970a6/oss://bcjy/10000/exports/a.png", nil
},
}
in := "![img](resource://xifDo7NTSL300Lp1goVutw)"
out := rewriteStorageURLs(context.Background(), in, newIMFileServiceResolver(&types.Tenant{}, stub))
assert.Equal(t, in, out)
assert.NotContains(t, out, "storage://")
}
// URL schemes are case-insensitive (RFC 3986); an uppercase-scheme result (e.g.
// from an OBS_PROXY_DOMAIN configured as HTTPS://...) is renderable and must be
// substituted, not dropped as "non-HTTP".
func TestRewriteStorageURLs_UppercaseSchemeIsSubstituted(t *testing.T) {
stub := &stubIMFileService{
getFileURL: func(_ context.Context, _ string) (string, error) {
return "HTTPS://cdn.example.com/x.png", nil
},
}
in := "![img](resource://xifDo7NTSL300Lp1goVutw)"
out := rewriteStorageURLs(context.Background(), in, newIMFileServiceResolver(&types.Tenant{}, stub))
assert.Contains(t, out, "HTTPS://cdn.example.com/x.png")
assert.NotContains(t, out, "resource://")
}
func TestCleanIMContent_MinIOFallbackIntegration(t *testing.T) {
stub := &stubIMFileService{
getFileURL: func(_ context.Context, _ string) (string, error) {
return "https://minio.example/img.png", nil
},
}
tenant := &types.Tenant{
StorageEngineConfig: &types.StorageEngineConfig{DefaultProvider: "cos"},
}
in := "see ![x](minio://wizard-test/10000/exports/x.png) ok"
out := cleanIMContent(context.Background(), in, tenant, stub)
assert.Contains(t, out, "https://minio.example/img.png")
}
+357
View File
@@ -0,0 +1,357 @@
package mattermost
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"strings"
"sync"
"github.com/gin-gonic/gin"
"github.com/Tencent/WeKnora/internal/im"
"github.com/Tencent/WeKnora/internal/logger"
)
// Compile-time checks.
var (
_ im.Adapter = (*Adapter)(nil)
_ im.StreamSender = (*Adapter)(nil)
_ im.FileDownloader = (*Adapter)(nil)
)
const (
extraKeyThreadRoot = "thread_root_id"
extraKeyChannelID = "channel_id"
)
// Adapter implements im integration for Mattermost (outgoing webhook inbound + REST outbound).
type Adapter struct {
client *Client
outgoingToken string
botUserID string
// postReplyToMain: when true, bot replies are new top-level channel posts (visible in main timeline).
// When false (default), replies use root_id tied to the trigger post so they appear in Mattermost threads.
postReplyToMain bool
}
// NewAdapter creates a Mattermost adapter.
func NewAdapter(client *Client, outgoingToken, botUserID string, postReplyToMain bool) *Adapter {
return &Adapter{
client: client,
outgoingToken: strings.TrimSpace(outgoingToken),
botUserID: strings.TrimSpace(botUserID),
postReplyToMain: postReplyToMain,
}
}
// outgoingPayload matches Mattermost outgoing webhook parameters (JSON or form).
type outgoingPayload struct {
Token string `json:"token"`
UserID string `json:"user_id"`
UserName string `json:"user_name"`
ChannelID string `json:"channel_id"`
PostID string `json:"post_id"`
Text string `json:"text"`
RootID string `json:"root_id"`
FileIDsRaw json.RawMessage `json:"file_ids"`
}
func (a *Adapter) Platform() im.Platform {
return im.PlatformMattermost
}
func (a *Adapter) HandleURLVerification(c *gin.Context) bool {
return false
}
func (a *Adapter) VerifyCallback(c *gin.Context) error {
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
return fmt.Errorf("read body: %w", err)
}
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
payload, err := parseOutgoingBody(c.Request.Header.Get("Content-Type"), bodyBytes)
if err != nil {
return fmt.Errorf("parse outgoing payload: %w", err)
}
if a.outgoingToken != "" && payload.Token != a.outgoingToken {
return fmt.Errorf("invalid outgoing webhook token")
}
return nil
}
func (a *Adapter) ParseCallback(c *gin.Context) (*im.IncomingMessage, error) {
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
payload, err := parseOutgoingBody(c.Request.Header.Get("Content-Type"), bodyBytes)
if err != nil {
return nil, err
}
if a.botUserID != "" && payload.UserID == a.botUserID {
logger.Infof(c.Request.Context(), "[Mattermost] Skip callback: user_id matches bot_user_id (avoid self-reply loop)")
return nil, nil
}
if strings.TrimSpace(payload.Text) == "" && len(parseFileIDs(payload.FileIDsRaw)) == 0 {
logger.Infof(c.Request.Context(), "[Mattermost] Skip callback: empty text and no file_ids")
return nil, nil
}
var threadRoot string
if a.postReplyToMain {
threadRoot = ""
} else {
threadRoot = payload.RootID
if threadRoot == "" {
// Outgoing webhooks may omit root_id for thread replies.
// Fetch the post to resolve the actual thread root.
if actualRootID, err := a.client.GetPost(c.Request.Context(), payload.PostID); err == nil && actualRootID != "" {
threadRoot = actualRootID
} else {
// Top-level message: use its own PostID as thread root.
threadRoot = payload.PostID
}
}
}
msg := &im.IncomingMessage{
Platform: im.PlatformMattermost,
UserID: payload.UserID,
UserName: payload.UserName,
ChatID: payload.ChannelID,
ChatType: im.ChatTypeGroup,
Content: strings.TrimSpace(payload.Text),
MessageID: payload.PostID,
ThreadID: threadRoot,
Extra: map[string]string{
extraKeyThreadRoot: threadRoot,
extraKeyChannelID: payload.ChannelID,
},
}
fileIDs := parseFileIDs(payload.FileIDsRaw)
if len(fileIDs) > 0 {
msg.MessageType = im.MessageTypeFile
msg.FileKey = fileIDs[0]
if len(fileIDs) > 1 {
msg.Extra["file_ids"] = strings.Join(fileIDs, ",")
}
} else {
msg.MessageType = im.MessageTypeText
}
return msg, nil
}
func parseOutgoingBody(contentType string, body []byte) (*outgoingPayload, error) {
ct := strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0]))
switch {
case ct == "application/json" || strings.HasSuffix(ct, "+json"):
var p outgoingPayload
if err := json.Unmarshal(body, &p); err != nil {
return nil, err
}
return &p, nil
case ct == "application/x-www-form-urlencoded" || ct == "":
// Parse as form (Mattermost default for some configs).
values, err := parseFormBody(body)
if err != nil {
return nil, err
}
p := &outgoingPayload{
Token: values.Get("token"),
UserID: values.Get("user_id"),
UserName: values.Get("user_name"),
ChannelID: values.Get("channel_id"),
PostID: values.Get("post_id"),
Text: values.Get("text"),
RootID: values.Get("root_id"),
}
if f := values.Get("file_ids"); f != "" {
p.FileIDsRaw = json.RawMessage(jsonArrayFromCSV(f))
}
return p, nil
default:
// Try JSON fallback for unknown types.
var p outgoingPayload
if err := json.Unmarshal(body, &p); err == nil && (p.Token != "" || p.ChannelID != "") {
return &p, nil
}
return nil, fmt.Errorf("unsupported content-type: %s", contentType)
}
}
func parseFileIDs(raw json.RawMessage) []string {
if len(raw) == 0 {
return nil
}
var arr []string
if err := json.Unmarshal(raw, &arr); err == nil {
return arr
}
var s string
if err := json.Unmarshal(raw, &s); err == nil && s != "" {
return splitFileIDs(s)
}
return nil
}
func splitFileIDs(s string) []string {
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
func (a *Adapter) SendReply(ctx context.Context, incoming *im.IncomingMessage, reply *im.ReplyMessage) error {
channelID := incoming.ChatID
if channelID == "" {
channelID = incoming.Extra[extraKeyChannelID]
}
if channelID == "" {
return fmt.Errorf("missing channel_id")
}
threadRoot := ""
if incoming.Extra != nil {
threadRoot = incoming.Extra[extraKeyThreadRoot]
}
_, err := a.client.CreatePost(ctx, channelID, threadRoot, reply.Content)
return err
}
type mmStreamState struct {
mu sync.Mutex
content strings.Builder
postID string
channel string
}
var (
mmStreamsMu sync.Mutex
mmStreams = map[string]*mmStreamState{}
)
func (a *Adapter) StartStream(ctx context.Context, incoming *im.IncomingMessage) (string, error) {
channelID := incoming.ChatID
if channelID == "" {
channelID = incoming.Extra[extraKeyChannelID]
}
threadRoot := ""
if incoming.Extra != nil {
threadRoot = incoming.Extra[extraKeyThreadRoot]
}
postID, err := a.client.CreatePost(ctx, channelID, threadRoot, "正在思考...")
if err != nil {
return "", err
}
streamID := channelID + ":" + postID
mmStreamsMu.Lock()
mmStreams[streamID] = &mmStreamState{postID: postID, channel: channelID}
mmStreamsMu.Unlock()
logger.Infof(ctx, "[Mattermost] Streaming started: stream_id=%s", streamID)
return streamID, nil
}
func (a *Adapter) UpdateStreamContent(ctx context.Context, incoming *im.IncomingMessage, streamID string, fullContent string) error {
if fullContent == "" {
return nil
}
mmStreamsMu.Lock()
state, ok := mmStreams[streamID]
mmStreamsMu.Unlock()
if !ok {
return fmt.Errorf("unknown stream ID: %s", streamID)
}
state.mu.Lock()
state.content.Reset()
state.content.WriteString(fullContent)
postID := state.postID
state.mu.Unlock()
if err := a.client.PatchPostMessage(ctx, postID, fullContent); err != nil {
logger.Warnf(ctx, "[Mattermost] Patch post failed: %v", err)
}
return nil
}
func (a *Adapter) FinalizeStream(ctx context.Context, incoming *im.IncomingMessage, streamID string, finalContent string) error {
return a.UpdateStreamContent(ctx, incoming, streamID, finalContent)
}
func (a *Adapter) SendStreamChunk(ctx context.Context, incoming *im.IncomingMessage, streamID string, content string) error {
return a.UpdateStreamContent(ctx, incoming, streamID, content)
}
func (a *Adapter) EndStream(ctx context.Context, incoming *im.IncomingMessage, streamID string) error {
mmStreamsMu.Lock()
state, ok := mmStreams[streamID]
delete(mmStreams, streamID)
mmStreamsMu.Unlock()
if !ok {
return nil
}
state.mu.Lock()
full := state.content.String()
postID := state.postID
state.mu.Unlock()
if err := a.client.PatchPostMessage(ctx, postID, full); err != nil {
logger.Warnf(ctx, "[Mattermost] EndStream patch failed: %v", err)
}
logger.Infof(ctx, "[Mattermost] Streaming ended: post_id=%s", postID)
return nil
}
func (a *Adapter) DownloadFile(ctx context.Context, msg *im.IncomingMessage) (io.ReadCloser, string, error) {
if msg.FileKey == "" {
return nil, "", fmt.Errorf("file_key is required")
}
info, err := a.client.GetFileInfo(ctx, msg.FileKey)
if err != nil {
return nil, "", fmt.Errorf("file info: %w", err)
}
name := info.Name
if name == "" {
name = msg.FileName
}
if name == "" {
name = msg.FileKey
}
rc, err := a.client.GetFileReader(ctx, msg.FileKey)
if err != nil {
return nil, "", err
}
return rc, name, nil
}
+195
View File
@@ -0,0 +1,195 @@
package mattermost
import (
"context"
"encoding/json"
stderrors "errors"
"net/http"
"net/http/httptest"
"testing"
secutils "github.com/Tencent/WeKnora/internal/utils"
)
func withMattermostSSRFWhitelist(t *testing.T, whitelist string) {
t.Helper()
t.Setenv("SSRF_WHITELIST", whitelist)
secutils.ResetSSRFWhitelistForTest()
t.Cleanup(secutils.ResetSSRFWhitelistForTest)
}
func TestParseOutgoingBody_ThreadRoot(t *testing.T) {
tests := []struct {
name string
payload outgoingPayload
postReplyToMain bool
wantThreadRoot string
}{
{
name: "threaded reply has RootID",
payload: outgoingPayload{
PostID: "post-123",
RootID: "root-456",
},
postReplyToMain: false,
wantThreadRoot: "root-456",
},
{
name: "top-level message uses PostID as thread root",
payload: outgoingPayload{
PostID: "post-789",
RootID: "",
},
postReplyToMain: false,
wantThreadRoot: "post-789",
},
{
name: "postReplyToMain clears thread root",
payload: outgoingPayload{
PostID: "post-123",
RootID: "root-456",
},
postReplyToMain: true,
wantThreadRoot: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var threadRoot string
if tt.postReplyToMain {
threadRoot = ""
} else {
threadRoot = tt.payload.RootID
if threadRoot == "" {
threadRoot = tt.payload.PostID
}
}
if threadRoot != tt.wantThreadRoot {
t.Errorf("threadRoot = %q, want %q", threadRoot, tt.wantThreadRoot)
}
})
}
}
func TestNewClientRejectsPrivateSiteURL(t *testing.T) {
withMattermostSSRFWhitelist(t, "")
if _, err := NewClient("http://127.0.0.1:8065", "bot-token"); err == nil {
t.Fatal("expected private Mattermost site_url to be rejected")
}
}
func TestMattermostClientBlocksRedirectToInternalURL(t *testing.T) {
withMattermostSSRFWhitelist(t, "127.0.0.1")
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "http://169.254.169.254/latest/meta-data/", http.StatusFound)
}))
defer origin.Close()
client, err := NewClient(origin.URL, "bot-token")
if err != nil {
t.Fatalf("NewClient: %v", err)
}
_, err = client.CreatePost(context.Background(), "channel", "", "hello")
if err == nil {
t.Fatal("expected redirect to link-local metadata endpoint to be blocked")
}
if !stderrors.Is(err, secutils.ErrSSRFRedirectBlocked) {
t.Fatalf("error = %v, want ErrSSRFRedirectBlocked", err)
}
}
func TestParseOutgoingBody_JSON(t *testing.T) {
body := `{"token":"tok","user_id":"u1","user_name":"alice","channel_id":"ch1","post_id":"p1","text":"hello","root_id":"r1"}`
payload, err := parseOutgoingBody("application/json", []byte(body))
if err != nil {
t.Fatalf("parseOutgoingBody error: %v", err)
}
if payload.RootID != "r1" {
t.Errorf("RootID = %q, want %q", payload.RootID, "r1")
}
if payload.PostID != "p1" {
t.Errorf("PostID = %q, want %q", payload.PostID, "p1")
}
}
func TestParseOutgoingBody_FormEncoded(t *testing.T) {
body := "token=tok&user_id=u1&channel_id=ch1&post_id=p2&text=hello&root_id=r2"
payload, err := parseOutgoingBody("application/x-www-form-urlencoded", []byte(body))
if err != nil {
t.Fatalf("parseOutgoingBody error: %v", err)
}
if payload.RootID != "r2" {
t.Errorf("RootID = %q, want %q", payload.RootID, "r2")
}
if payload.PostID != "p2" {
t.Errorf("PostID = %q, want %q", payload.PostID, "p2")
}
}
func TestThreadIDInMessage(t *testing.T) {
// Simulate the adapter logic: build the message and verify ThreadID
payload := outgoingPayload{
Token: "tok",
UserID: "user-1",
UserName: "alice",
ChannelID: "ch-1",
PostID: "post-100",
Text: "hello",
RootID: "root-200",
}
threadRoot := payload.RootID
if threadRoot == "" {
threadRoot = payload.PostID
}
// Verify the Extra map and ThreadID would be consistent
extra := map[string]string{
extraKeyThreadRoot: threadRoot,
extraKeyChannelID: payload.ChannelID,
}
if extra[extraKeyThreadRoot] != "root-200" {
t.Errorf("Extra thread_root_id = %q, want %q", extra[extraKeyThreadRoot], "root-200")
}
// ThreadID should equal threadRoot
threadID := threadRoot
if threadID != "root-200" {
t.Errorf("ThreadID = %q, want %q", threadID, "root-200")
}
}
func TestParseFileIDs(t *testing.T) {
tests := []struct {
name string
raw json.RawMessage
want []string
}{
{"array", json.RawMessage(`["f1","f2"]`), []string{"f1", "f2"}},
{"string", json.RawMessage(`"f3"`), []string{"f3"}},
{"empty", nil, nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseFileIDs(tt.raw)
if len(got) != len(tt.want) {
t.Fatalf("parseFileIDs() len = %d, want %d", len(got), len(tt.want))
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("parseFileIDs()[%d] = %q, want %q", i, got[i], tt.want[i])
}
}
})
}
}
+245
View File
@@ -0,0 +1,245 @@
package mattermost
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
secutils "github.com/Tencent/WeKnora/internal/utils"
)
// Client calls Mattermost REST API v4.
type Client struct {
baseURL string
httpClient *http.Client
token string
}
// NewClient builds an API client. siteURL is the Mattermost server root (e.g. https://mm.example.com).
func NewClient(siteURL, botToken string) (*Client, error) {
siteURL = strings.TrimSpace(siteURL)
siteURL = strings.TrimRight(siteURL, "/")
if siteURL == "" {
return nil, fmt.Errorf("site_url is required")
}
parsed, err := url.Parse(siteURL)
if err != nil || parsed.Host == "" {
return nil, fmt.Errorf("invalid site_url: must be a valid http(s) URL")
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return nil, fmt.Errorf("invalid site_url: must use http or https")
}
if err := secutils.ValidateURLForSSRF(siteURL); err != nil {
return nil, fmt.Errorf(
"invalid site_url: %w (for private Mattermost deployments, add the hostname to SSRF_WHITELIST)",
err,
)
}
if strings.TrimSpace(botToken) == "" {
return nil, fmt.Errorf("bot_token is required")
}
return &Client{
baseURL: siteURL + "/api/v4",
httpClient: secutils.NewSSRFSafeHTTPClient(secutils.SSRFSafeHTTPClientConfig{
Timeout: 60 * time.Second,
MaxRedirects: 5,
}),
token: strings.TrimSpace(botToken),
}, nil
}
func (c *Client) authHeader(req *http.Request) {
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", "application/json")
}
// CreatePost creates a channel post. rootID is the thread root post id (empty for new top-level post).
func (c *Client) CreatePost(ctx context.Context, channelID, rootID, message string) (postID string, err error) {
body := map[string]string{
"channel_id": channelID,
"message": message,
}
if rootID != "" {
body["root_id"] = rootID
}
payload, err := json.Marshal(body)
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/posts", bytes.NewReader(payload))
if err != nil {
return "", err
}
c.authHeader(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read mattermost create post response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if resp.StatusCode == http.StatusForbidden {
return "", fmt.Errorf("mattermost create post: 403 forbidden — add the bot user to this Mattermost channel (Channel menu → Members → Add); body=%s", truncateForErr(respBody))
}
return "", fmt.Errorf("mattermost create post: status=%d body=%s", resp.StatusCode, truncateForErr(respBody))
}
var created struct {
ID string `json:"id"`
}
if err := json.Unmarshal(respBody, &created); err != nil {
return "", fmt.Errorf("decode create post: %w", err)
}
if created.ID == "" {
return "", fmt.Errorf("mattermost create post: empty id")
}
return created.ID, nil
}
// GetPost fetches a post by ID, returning its root_id (empty if top-level).
func (c *Client) GetPost(ctx context.Context, postID string) (rootID string, err error) {
url := fmt.Sprintf("%s/posts/%s", c.baseURL, postID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", err
}
c.authHeader(req)
req.Header.Del("Content-Type")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read mattermost get post response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("mattermost get post: status=%d body=%s", resp.StatusCode, truncateForErr(respBody))
}
var post struct {
ID string `json:"id"`
RootID string `json:"root_id"`
}
if err := json.Unmarshal(respBody, &post); err != nil {
return "", fmt.Errorf("decode get post: %w", err)
}
return post.RootID, nil
}
// PatchPostMessage updates a post's message field.
func (c *Client) PatchPostMessage(ctx context.Context, postID, message string) error {
payload, err := json.Marshal(map[string]string{"message": message})
if err != nil {
return err
}
url := fmt.Sprintf("%s/posts/%s/patch", c.baseURL, postID)
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(payload))
if err != nil {
return err
}
c.authHeader(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read mattermost patch post response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("mattermost patch post: status=%d body=%s", resp.StatusCode, truncateForErr(respBody))
}
return nil
}
// FileInfo holds metadata from GET /files/{id}/info.
type FileInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Size int64 `json:"size"`
}
// GetFileInfo fetches file metadata.
func (c *Client) GetFileInfo(ctx context.Context, fileID string) (*FileInfo, error) {
url := fmt.Sprintf("%s/files/%s/info", c.baseURL, fileID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
c.authHeader(req)
req.Header.Del("Content-Type")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read mattermost file info response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("mattermost file info: status=%d body=%s", resp.StatusCode, truncateForErr(respBody))
}
var info FileInfo
if err := json.Unmarshal(respBody, &info); err != nil {
return nil, fmt.Errorf("decode file info: %w", err)
}
return &info, nil
}
// GetFileReader opens a download stream for file content.
func (c *Client) GetFileReader(ctx context.Context, fileID string) (io.ReadCloser, error) {
url := fmt.Sprintf("%s/files/%s", c.baseURL, fileID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
c.authHeader(req)
req.Header.Del("Content-Type")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, fmt.Errorf("read mattermost get file error response (status=%d): %w", resp.StatusCode, readErr)
}
return nil, fmt.Errorf("mattermost get file: status=%d body=%s", resp.StatusCode, truncateForErr(body))
}
return resp.Body, nil
}
func truncateForErr(b []byte) string {
const max = 512
s := string(b)
if len(s) > max {
return s[:max] + "..."
}
return s
}
+43
View File
@@ -0,0 +1,43 @@
package mattermost
import (
"context"
"fmt"
"github.com/Tencent/WeKnora/internal/im"
)
// NewFactory returns an im.AdapterFactory for Mattermost channels.
// Only "webhook" mode is supported (outgoing webhook + REST API);
// the default mode is "webhook" (not "websocket" like other platforms).
func NewFactory() im.AdapterFactory {
return func(factoryCtx context.Context, channel *im.IMChannel, msgHandler func(context.Context, *im.IncomingMessage) error) (im.Adapter, context.CancelFunc, error) {
creds, err := im.ParseCredentials(channel.Credentials)
if err != nil {
return nil, nil, fmt.Errorf("parse mattermost credentials: %w", err)
}
mode := im.ResolveMode(channel, "webhook")
if mode != "webhook" {
return nil, nil, fmt.Errorf("unsupported mattermost mode: %s (only webhook is supported)", mode)
}
siteURL := im.GetString(creds, "site_url")
botToken := im.GetString(creds, "bot_token")
outgoingToken := im.GetString(creds, "outgoing_token")
botUserID := im.GetString(creds, "bot_user_id")
if outgoingToken == "" {
return nil, nil, fmt.Errorf("mattermost outgoing_token is required")
}
client, err := NewClient(siteURL, botToken)
if err != nil {
return nil, nil, err
}
postReplyToMain := im.GetBool(creds, "post_to_main")
adapter := NewAdapter(client, outgoingToken, botUserID, postReplyToMain)
return adapter, func() {}, nil
}
}
+27
View File
@@ -0,0 +1,27 @@
package mattermost
import (
"encoding/json"
"fmt"
"net/url"
)
func parseFormBody(body []byte) (url.Values, error) {
values, err := url.ParseQuery(string(body))
if err != nil {
return nil, fmt.Errorf("parse form: %w", err)
}
return values, nil
}
func jsonArrayFromCSV(csv string) []byte {
parts := splitFileIDs(csv)
if len(parts) == 0 {
return []byte("[]")
}
b, err := json.Marshal(parts)
if err != nil {
return []byte("[]")
}
return b
}
+9
View File
@@ -0,0 +1,9 @@
package im
// ResolveMode returns channel.Mode, falling back to def when it is empty.
func ResolveMode(channel *IMChannel, def string) string {
if channel.Mode == "" {
return def
}
return channel.Mode
}
+85
View File
@@ -0,0 +1,85 @@
package im
import (
"context"
"strings"
"testing"
)
// buildIMMCPAuthNotice with a nil OAuth manager falls back to a per-service
// console hint (no authorization URL). These cases exercise the dedup and
// empty-input handling without needing a live OAuth manager.
func TestBuildIMMCPAuthNotice(t *testing.T) {
svc := &Service{} // nil oauthManager => console-hint fallback
tests := []struct {
name string
input []imMCPAuthService
want string
contains []string
}{
{
name: "empty input",
input: nil,
want: "",
},
{
name: "all blank ids",
input: []imMCPAuthService{{ID: "", Name: "x"}, {ID: "", Name: "y"}},
want: "",
},
{
name: "single service",
input: []imMCPAuthService{{ID: "svc-1", Name: "GitHub MCP"}},
contains: []string{"GitHub MCP", "OAuth 授权"},
},
{
name: "dedupe by id",
input: []imMCPAuthService{
{ID: "a", Name: "A"},
{ID: "a", Name: "A"},
{ID: "b", Name: "B"},
},
contains: []string{"A", "B"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := svc.buildIMMCPAuthNotice(context.Background(), tt.input)
if tt.contains == nil {
if got != tt.want {
t.Fatalf("buildIMMCPAuthNotice() = %q, want %q", got, tt.want)
}
return
}
for _, sub := range tt.contains {
if !strings.Contains(got, sub) {
t.Fatalf("buildIMMCPAuthNotice() = %q, want substring %q", got, sub)
}
}
})
}
}
func TestAppendIMAuthNotice(t *testing.T) {
notice := "⚠️ 需要授权"
tests := []struct {
name string
body string
notice string
want string
}{
{"empty notice", "answer", "", "answer"},
{"empty body", "", notice, notice},
{"whitespace body", " \n ", notice, notice},
{"append with blank line", "answer", notice, "answer\n\n" + notice},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := appendIMAuthNotice(tt.body, tt.notice)
if got != tt.want {
t.Fatalf("appendIMAuthNotice(%q, %q) = %q, want %q", tt.body, tt.notice, got, tt.want)
}
})
}
}
+36
View File
@@ -0,0 +1,36 @@
package im
import (
"context"
"github.com/Tencent/WeKnora/internal/types"
"gorm.io/gorm"
)
// A digital human's default Agent carries its creator's personal connections.
// Tenant administrator privileges do not transfer that personal ownership.
func (s *Service) CheckPersonalChannelAgentAccess(ctx context.Context, agentID string, tenantID uint64) error {
var human types.DigitalHuman
err := s.db.WithContext(ctx).Select("id", "owner_id").Where("primary_agent_id = ? AND tenant_id = ?", agentID, tenantID).First(&human).Error
if err == gorm.ErrRecordNotFound {
return nil
}
if err != nil {
return err
}
userID, hasUser := types.UserIDFromContext(ctx)
if !hasUser || types.IsSyntheticUserID(userID) || userID != human.OwnerID {
return gorm.ErrRecordNotFound
}
return nil
}
func excludeOtherPersonalChannels(query *gorm.DB, ctx context.Context) *gorm.DB {
userID, hasUser := types.UserIDFromContext(ctx)
if !hasUser || types.IsSyntheticUserID(userID) {
userID = ""
}
return query.Where(`NOT EXISTS (SELECT 1 FROM digital_humans AS dh
WHERE dh.primary_agent_id = c.agent_id AND dh.tenant_id = c.tenant_id
AND dh.deleted_at IS NULL AND dh.owner_id <> ?)`, userID)
}
@@ -0,0 +1,39 @@
package im
import (
"context"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestDigitalHumanPersonalChannelAccessAcrossGenericIMEndpoints(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
sqlDB, err := db.DB()
require.NoError(t, err)
sqlDB.SetMaxOpenConns(1)
t.Cleanup(func() { sqlDB.Close() })
require.NoError(t, db.Exec("CREATE TABLE digital_humans (id TEXT, primary_agent_id TEXT, tenant_id INTEGER, owner_id TEXT, deleted_at DATETIME)").Error)
require.NoError(t, db.Exec("CREATE TABLE im_channels (id TEXT, agent_id TEXT, tenant_id INTEGER)").Error)
require.NoError(t, db.Exec("INSERT INTO digital_humans (id, primary_agent_id, tenant_id, owner_id) VALUES ('human', 'personal', 7, 'owner')").Error)
require.NoError(t, db.Exec("INSERT INTO im_channels VALUES ('personal-channel', 'personal', 7), ('shared-channel', 'shared', 7), ('different-tenant', 'personal', 8)").Error)
service := &Service{db: db}
owner := context.WithValue(context.Background(), types.UserIDContextKey, "owner")
require.NoError(t, service.CheckPersonalChannelAgentAccess(owner, "personal", 7))
for _, user := range []string{"other", "admin", "system-7", ""} {
ctx := context.WithValue(context.Background(), types.UserIDContextKey, user)
ctx = context.WithValue(ctx, types.TenantRoleContextKey, types.TenantRoleAdmin)
require.ErrorIs(t, service.CheckPersonalChannelAgentAccess(ctx, "personal", 7), gorm.ErrRecordNotFound)
require.NoError(t, service.CheckPersonalChannelAgentAccess(ctx, "shared", 7))
var ids []string
require.NoError(t, excludeOtherPersonalChannels(db.Table("im_channels AS c").Where("c.tenant_id = 7"), ctx).Order("id").Pluck("id", &ids).Error)
require.Equal(t, []string{"shared-channel"}, ids)
}
var ids []string
require.NoError(t, excludeOtherPersonalChannels(db.Table("im_channels AS c").Where("c.tenant_id = 7"), owner).Order("id").Pluck("id", &ids).Error)
require.Equal(t, []string{"personal-channel", "shared-channel"}, ids)
}
+409
View File
@@ -0,0 +1,409 @@
package im
import (
"context"
"fmt"
"sync"
"sync/atomic"
"time"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/redis/go-redis/v9"
)
const (
// defaultMaxQueueSize is the maximum number of pending QA requests in the queue.
defaultMaxQueueSize = 50
// defaultMaxPerUser limits how many requests a single user can have queued.
defaultMaxPerUser = 3
// defaultWorkers is the default number of concurrent QA workers.
defaultWorkers = 5
// queueTimeout is how long a request can wait in the queue before being discarded.
queueTimeout = 60 * time.Second
// redisQueueUserTTL is the TTL for per-user queue counters in Redis.
redisQueueUserTTL = 5 * time.Minute
// globalGateTTL is the TTL for the global active-worker counter in Redis.
// Acts as a safety net: if all instances crash without decrementing, the
// counter self-heals after this duration.
globalGateTTL = 5 * time.Minute
// globalGateRetryInterval is how long a worker waits before retrying when the
// global concurrency limit is reached.
globalGateRetryInterval = 500 * time.Millisecond
)
// qaRequest represents a QA request waiting in the queue.
type qaRequest struct {
ctx context.Context
cancel context.CancelFunc
msg *IncomingMessage
session *types.Session
agent *types.CustomAgent
adapter Adapter
channel *IMChannel
channelID string
// tenant is used to resolve provider:// URLs in outbound replies (scheme-aware).
tenant *types.Tenant
// userKey is "channelID:userID:chatID", used for per-user limits and /stop.
userKey string
enqueuedAt time.Time
}
// QueueMetrics exposes observable queue state.
type QueueMetrics struct {
// Depth is the current number of requests waiting in the queue.
Depth int
// ActiveWorkers is the number of workers currently executing a QA request.
ActiveWorkers int64
// TotalEnqueued is the cumulative number of requests enqueued.
TotalEnqueued int64
// TotalProcessed is the cumulative number of requests dequeued and executed.
TotalProcessed int64
// TotalRejected is the cumulative number of requests rejected (queue full / per-user limit).
TotalRejected int64
// TotalTimeout is the cumulative number of requests discarded due to queue timeout.
TotalTimeout int64
}
// qaQueue is a bounded, per-user-limited request queue with a fixed worker pool.
type qaQueue struct {
mu sync.Mutex
cond *sync.Cond
queue []*qaRequest
maxSize int
maxPerUser int
workers int
perUser map[string]int // userKey → queued count
closed bool
// redis is the optional Redis client for global per-user counting.
// When nil, only local per-user limits are enforced.
redis *redis.Client
// globalMaxWorkers is the maximum number of QA requests executing
// concurrently across all instances. 0 means no global limit.
// Enforced via Redis INCR/DECR on RedisKeyGlobalGate.
globalMaxWorkers int
// metrics
activeWorkers atomic.Int64
totalEnqueued atomic.Int64
totalProcessed atomic.Int64
totalRejected atomic.Int64
totalTimeout atomic.Int64
// handler is called by workers to execute the QA request.
handler func(req *qaRequest)
}
// newQAQueue creates a new bounded queue with the given worker count.
// globalMaxWorkers controls cross-instance concurrency (0 = no limit).
// redisClient may be nil for single-instance mode.
func newQAQueue(workers, maxSize, maxPerUser, globalMaxWorkers int, handler func(req *qaRequest), redisClient *redis.Client) *qaQueue {
q := &qaQueue{
queue: make([]*qaRequest, 0, maxSize),
maxSize: maxSize,
maxPerUser: maxPerUser,
workers: workers,
globalMaxWorkers: globalMaxWorkers,
perUser: make(map[string]int),
redis: redisClient,
handler: handler,
}
q.cond = sync.NewCond(&q.mu)
return q
}
// Start launches the worker goroutines and the metrics reporter. Call Stop to shut down.
func (q *qaQueue) Start(stopCh <-chan struct{}) {
for i := 0; i < q.workers; i++ {
go q.runWorker(i)
}
go q.metricsLoop(stopCh)
}
// Stop signals all workers to exit after draining.
func (q *qaQueue) Stop() {
q.mu.Lock()
q.closed = true
q.mu.Unlock()
q.cond.Broadcast()
}
// Enqueue adds a request to the queue. Returns the queue position (0-based)
// or an error if the queue is full or per-user limit is reached.
func (q *qaQueue) Enqueue(req *qaRequest) (position int, err error) {
// Check global per-user limit via Redis before acquiring local lock.
if q.redis != nil {
if err := q.redisCheckAndIncrUser(context.Background(), req.userKey); err != nil {
q.totalRejected.Add(1)
return 0, err
}
}
q.mu.Lock()
defer q.mu.Unlock()
if q.closed {
q.redisDecrUser(context.Background(), req.userKey)
return 0, fmt.Errorf("queue is closed")
}
if len(q.queue) >= q.maxSize {
q.redisDecrUser(context.Background(), req.userKey)
q.totalRejected.Add(1)
return 0, fmt.Errorf("queue full (%d/%d)", len(q.queue), q.maxSize)
}
// Local per-user check: only useful when Redis is nil (single-instance mode).
// When Redis is available, redisCheckAndIncrUser already enforces the global
// per-user limit across all instances, making this local check redundant.
if q.redis == nil && q.perUser[req.userKey] >= q.maxPerUser {
q.totalRejected.Add(1)
return 0, fmt.Errorf("per-user queue limit reached (%d/%d)", q.perUser[req.userKey], q.maxPerUser)
}
req.enqueuedAt = time.Now()
q.queue = append(q.queue, req)
if q.redis == nil {
q.perUser[req.userKey]++
}
q.totalEnqueued.Add(1)
pos := len(q.queue) - 1
q.cond.Signal()
return pos, nil
}
// Remove cancels and removes a queued request by userKey.
// Returns true if a request was found and removed.
func (q *qaQueue) Remove(userKey string) bool {
q.mu.Lock()
defer q.mu.Unlock()
for i, req := range q.queue {
if req.userKey == userKey {
req.cancel()
q.queue = append(q.queue[:i], q.queue[i+1:]...)
if q.redis == nil {
q.perUser[userKey]--
if q.perUser[userKey] <= 0 {
delete(q.perUser, userKey)
}
}
q.redisDecrUser(context.Background(), userKey)
return true
}
}
return false
}
// Metrics returns a snapshot of the queue's observable state.
func (q *qaQueue) Metrics() QueueMetrics {
q.mu.Lock()
depth := len(q.queue)
q.mu.Unlock()
return QueueMetrics{
Depth: depth,
ActiveWorkers: q.activeWorkers.Load(),
TotalEnqueued: q.totalEnqueued.Load(),
TotalProcessed: q.totalProcessed.Load(),
TotalRejected: q.totalRejected.Load(),
TotalTimeout: q.totalTimeout.Load(),
}
}
func (q *qaQueue) runWorker(id int) {
for {
req := q.dequeue()
if req == nil {
return // queue closed
}
// Skip requests that have been cancelled or timed out while queued.
if req.ctx.Err() != nil {
q.totalTimeout.Add(1)
q.redisDecrUser(context.Background(), req.userKey)
continue
}
waitDuration := time.Since(req.enqueuedAt)
if waitDuration > queueTimeout {
q.totalTimeout.Add(1)
q.redisDecrUser(context.Background(), req.userKey)
logger.Warnf(req.ctx, "[IM] Queue timeout: user=%s waited=%s, discarding", req.msg.UserID, waitDuration)
_ = req.adapter.SendReply(req.ctx, req.msg, &ReplyMessage{
Content: "您的消息等待超时,请重新发送。",
IsFinal: true,
})
req.cancel()
continue
}
logger.Infof(req.ctx, "[IM] Dequeued: worker=%d user=%s waited=%s depth=%d",
id, req.msg.UserID, waitDuration, q.Metrics().Depth)
// Acquire global concurrency slot (blocks until a slot opens or request is cancelled).
if !q.acquireGlobalGate(req.ctx) {
// Context cancelled while waiting for a global slot — treat as timeout.
q.totalTimeout.Add(1)
q.redisDecrUser(context.Background(), req.userKey)
logger.Warnf(req.ctx, "[IM] Global gate wait cancelled: worker=%d user=%s", id, req.msg.UserID)
req.cancel()
continue
}
q.activeWorkers.Add(1)
q.handler(req)
q.activeWorkers.Add(-1)
q.totalProcessed.Add(1)
q.releaseGlobalGate()
q.redisDecrUser(context.Background(), req.userKey)
}
}
func (q *qaQueue) dequeue() *qaRequest {
q.mu.Lock()
defer q.mu.Unlock()
for len(q.queue) == 0 && !q.closed {
q.cond.Wait()
}
if q.closed && len(q.queue) == 0 {
return nil
}
req := q.queue[0]
q.queue = q.queue[1:]
if q.redis == nil {
q.perUser[req.userKey]--
if q.perUser[req.userKey] <= 0 {
delete(q.perUser, req.userKey)
}
}
return req
}
// ── Redis global concurrency gate ────────────────────────────────────────────
// globalGateScript atomically increments the global active-worker counter and
// checks whether the limit is exceeded. Returns 1 if the slot was acquired, 0
// if the limit is reached. On success the caller MUST call releaseGlobalGate.
//
// KEYS[1] = RedisKeyGlobalGate
// ARGV[1] = max allowed concurrent workers
// ARGV[2] = TTL in milliseconds (safety net)
var globalGateScript = redis.NewScript(`
local key = KEYS[1]
local maxW = tonumber(ARGV[1])
local ttlMs = tonumber(ARGV[2])
local count = redis.call('INCR', key)
redis.call('PEXPIRE', key, ttlMs)
if count <= maxW then
return 1
end
redis.call('DECR', key)
return 0
`)
// acquireGlobalGate blocks until a global concurrency slot is available.
// Returns true if the slot was acquired, false if ctx was cancelled while waiting.
// When globalMaxWorkers is 0 or Redis is nil, it returns true immediately (no limit).
func (q *qaQueue) acquireGlobalGate(ctx context.Context) bool {
if q.globalMaxWorkers <= 0 || q.redis == nil {
return true
}
for {
result, err := globalGateScript.Run(ctx, q.redis,
[]string{RedisKeyGlobalGate},
q.globalMaxWorkers, globalGateTTL.Milliseconds(),
).Int64()
if err != nil {
// Redis error — skip global check to avoid blocking the worker.
logger.Warnf(ctx, "[IM] Global gate Redis error (proceeding without limit): %v", err)
return true
}
if result == 1 {
return true
}
// Global limit reached — wait and retry.
select {
case <-ctx.Done():
return false
case <-time.After(globalGateRetryInterval):
}
}
}
// releaseGlobalGate decrements the global active-worker counter.
func (q *qaQueue) releaseGlobalGate() {
if q.globalMaxWorkers <= 0 || q.redis == nil {
return
}
q.redis.Decr(context.Background(), RedisKeyGlobalGate)
}
// ── Redis global per-user counting ──────────────────────────────────────────
// redisCheckAndIncrUser atomically increments the global per-user counter and
// returns an error if the limit is exceeded. On success the caller MUST later
// call redisDecrUser to release the slot.
func (q *qaQueue) redisCheckAndIncrUser(ctx context.Context, userKey string) error {
if q.redis == nil {
return nil
}
key := RedisKeyQueueUser + userKey
count, err := q.redis.Incr(ctx, key).Result()
if err != nil {
// Redis error — skip global check, rely on local limit.
return nil
}
q.redis.Expire(ctx, key, redisQueueUserTTL)
if count > int64(q.maxPerUser) {
q.redis.Decr(ctx, key)
return fmt.Errorf("global per-user queue limit reached (%d/%d)", count, q.maxPerUser)
}
return nil
}
// redisDecrUser releases one slot in the global per-user counter.
func (q *qaQueue) redisDecrUser(ctx context.Context, userKey string) {
if q.redis == nil {
return
}
key := RedisKeyQueueUser + userKey
q.redis.Decr(ctx, key)
}
// ── Metrics logging ─────────────────────────────────────────────────────────
const metricsLogInterval = 30 * time.Second
// metricsLoop periodically logs queue metrics for operational visibility.
func (q *qaQueue) metricsLoop(stopCh <-chan struct{}) {
ticker := time.NewTicker(metricsLogInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
m := q.Metrics()
// Only log when there is activity to avoid noise.
if m.Depth > 0 || m.ActiveWorkers > 0 {
logger.Infof(context.Background(),
"[IM] Queue metrics: depth=%d active_workers=%d enqueued=%d processed=%d rejected=%d timeout=%d",
m.Depth, m.ActiveWorkers, m.TotalEnqueued, m.TotalProcessed, m.TotalRejected, m.TotalTimeout)
}
case <-stopCh:
return
}
}
}
+127
View File
@@ -0,0 +1,127 @@
package qqbot
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"strings"
"github.com/gin-gonic/gin"
"github.com/Tencent/WeKnora/internal/im"
)
var _ im.Adapter = (*Adapter)(nil)
type Adapter struct {
client *Client
}
func NewAdapter(client *Client) *Adapter {
return &Adapter{client: client}
}
func (a *Adapter) Platform() im.Platform {
return im.PlatformQQBot
}
func (a *Adapter) HandleURLVerification(c *gin.Context) bool {
return false
}
func (a *Adapter) VerifyCallback(c *gin.Context) error {
return nil
}
func (a *Adapter) ParseCallback(c *gin.Context) (*im.IncomingMessage, error) {
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
var payload gatewayPayload
if err := json.Unmarshal(bodyBytes, &payload); err != nil {
return nil, fmt.Errorf("parse payload: %w", err)
}
return parseGatewayPayload(&payload)
}
func (a *Adapter) SendReply(ctx context.Context, incoming *im.IncomingMessage, reply *im.ReplyMessage) error {
content := strings.TrimSpace(im.FormatIMDisplayContent(reply.Content, im.StreamDisplayFinal))
if content == "" {
return nil
}
msgID := ""
if incoming.Extra != nil {
msgID = incoming.Extra[extraKeyMessageID]
}
if incoming.ChatType == im.ChatTypeGroup {
return a.client.SendGroupMessage(ctx, incoming.ChatID, content, msgID)
}
return a.client.SendC2CMessage(ctx, incoming.UserID, content, msgID)
}
func parseGatewayPayload(payload *gatewayPayload) (*im.IncomingMessage, error) {
if payload == nil || payload.Op != opDispatch {
return nil, nil
}
var event messageEvent
if err := json.Unmarshal(payload.D, &event); err != nil {
return nil, err
}
switch payload.T {
case eventC2CMessageCreate:
return parseC2CMessage(&event), nil
case eventGroupAtMessageCreate:
return parseGroupMessage(&event), nil
default:
return nil, nil
}
}
func parseC2CMessage(event *messageEvent) *im.IncomingMessage {
userID := firstNonEmpty(event.Author.UserOpenID, event.Author.ID)
return &im.IncomingMessage{
Platform: im.PlatformQQBot,
MessageType: im.MessageTypeText,
UserID: userID,
UserName: event.Author.Username,
ChatID: "",
ChatType: im.ChatTypeDirect,
Content: strings.TrimSpace(event.Content),
MessageID: event.ID,
Extra: map[string]string{
extraKeyMessageID: event.ID,
extraKeyChatKind: "c2c",
},
}
}
func parseGroupMessage(event *messageEvent) *im.IncomingMessage {
return &im.IncomingMessage{
Platform: im.PlatformQQBot,
MessageType: im.MessageTypeText,
UserID: firstNonEmpty(event.Author.MemberOpenID, event.Author.UserOpenID, event.Author.ID),
UserName: event.Author.Username,
ChatID: event.GroupOpenID,
ChatType: im.ChatTypeGroup,
Content: strings.TrimSpace(event.Content),
MessageID: event.ID,
Extra: map[string]string{
extraKeyMessageID: event.ID,
extraKeyChatKind: "group",
},
}
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
+137
View File
@@ -0,0 +1,137 @@
package qqbot
import (
"encoding/json"
"testing"
"github.com/Tencent/WeKnora/internal/im"
secutils "github.com/Tencent/WeKnora/internal/utils"
)
func withQQBotSSRFWhitelist(t *testing.T, whitelist string) {
t.Helper()
t.Setenv("SSRF_WHITELIST", whitelist)
secutils.ResetSSRFWhitelistForTest()
t.Cleanup(secutils.ResetSSRFWhitelistForTest)
}
func TestParseGatewayPayloadC2CMessage(t *testing.T) {
event := messageEvent{
ID: "msg-1",
Content: " hello ",
Author: qqbotAuthor{UserOpenID: "user-openid", Username: "tester"},
}
raw, err := json.Marshal(event)
if err != nil {
t.Fatal(err)
}
msg, err := parseGatewayPayload(&gatewayPayload{Op: opDispatch, T: eventC2CMessageCreate, D: raw})
if err != nil {
t.Fatal(err)
}
if msg == nil {
t.Fatal("expected message")
}
if msg.Platform != im.PlatformQQBot || msg.ChatType != im.ChatTypeDirect || msg.UserID != "user-openid" || msg.Content != "hello" {
t.Fatalf("unexpected message: %#v", msg)
}
if msg.Extra[extraKeyMessageID] != "msg-1" || msg.Extra[extraKeyChatKind] != "c2c" {
t.Fatalf("unexpected extra: %#v", msg.Extra)
}
}
func TestParseGatewayPayloadGroupMessage(t *testing.T) {
event := messageEvent{
ID: "msg-2",
Content: " group hello ",
GroupOpenID: "group-openid",
Author: qqbotAuthor{MemberOpenID: "member-openid"},
}
raw, err := json.Marshal(event)
if err != nil {
t.Fatal(err)
}
msg, err := parseGatewayPayload(&gatewayPayload{Op: opDispatch, T: eventGroupAtMessageCreate, D: raw})
if err != nil {
t.Fatal(err)
}
if msg == nil {
t.Fatal("expected message")
}
if msg.ChatType != im.ChatTypeGroup || msg.ChatID != "group-openid" || msg.UserID != "member-openid" || msg.Content != "group hello" {
t.Fatalf("unexpected message: %#v", msg)
}
}
func TestParseExpiresIn(t *testing.T) {
cases := []struct {
name string
raw string
want int
}{
{name: "number", raw: `3600`, want: 3600},
{name: "string", raw: `"1800"`, want: 1800},
{name: "empty", raw: ``, want: 7200},
{name: "invalid", raw: `"bad"`, want: 7200},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := parseExpiresIn(json.RawMessage(tc.raw)); got != tc.want {
t.Fatalf("parseExpiresIn() = %d, want %d", got, tc.want)
}
})
}
}
func TestNewClientRejectsPrivateAPIBaseURL(t *testing.T) {
withQQBotSSRFWhitelist(t, "")
if _, err := NewClient("app", "secret", "http://127.0.0.1:8080", ""); err == nil {
t.Fatal("expected private qqbot api_base_url to be rejected")
}
}
func TestNewClientRejectsInsecureGatewayURL(t *testing.T) {
withQQBotSSRFWhitelist(t, "127.0.0.1")
if _, err := NewClient("app", "secret", "http://127.0.0.1:8080", "ws://127.0.0.1/gateway"); err == nil {
t.Fatal("expected non-wss qqbot gateway_url to be rejected")
}
}
func TestNewClientAllowsWhitelistedPrivateDeployment(t *testing.T) {
withQQBotSSRFWhitelist(t, "127.0.0.1")
if _, err := NewClient("app", "secret", "http://127.0.0.1:8080", "wss://127.0.0.1/gateway"); err != nil {
t.Fatalf("expected whitelisted private qqbot endpoints to pass: %v", err)
}
}
func TestSendMessageRequestUsesMarkdownMessage(t *testing.T) {
req := sendMessageRequest{
Content: "fallback",
MsgType: 2,
Markdown: &markdownMessage{Content: "# title\n**answer**"},
MsgID: "msg-1",
MsgSeq: 1,
}
raw, err := json.Marshal(req)
if err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(raw, &payload); err != nil {
t.Fatal(err)
}
if payload["msg_type"] != float64(2) {
t.Fatalf("msg_type = %v, want 2", payload["msg_type"])
}
markdown, ok := payload["markdown"].(map[string]any)
if !ok || markdown["content"] != "# title\n**answer**" {
t.Fatalf("markdown payload = %#v", payload["markdown"])
}
}
+222
View File
@@ -0,0 +1,222 @@
package qqbot
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"time"
secutils "github.com/Tencent/WeKnora/internal/utils"
)
type Client struct {
appID string
clientSecret string
apiBaseURL string
gatewayURL string
httpClient *http.Client
mu sync.Mutex
accessToken string
expiresAt time.Time
}
func NewClient(appID, clientSecret, apiBaseURL, gatewayURL string) (*Client, error) {
appID = strings.TrimSpace(appID)
clientSecret = strings.TrimSpace(clientSecret)
if appID == "" {
return nil, fmt.Errorf("qqbot app_id is required")
}
if clientSecret == "" {
return nil, fmt.Errorf("qqbot client_secret is required")
}
if apiBaseURL == "" {
apiBaseURL = defaultAPIBaseURL
}
apiBaseURL = strings.TrimRight(strings.TrimSpace(apiBaseURL), "/")
if err := validateHTTPAPIBaseURL(apiBaseURL); err != nil {
return nil, err
}
gatewayURL = strings.TrimSpace(gatewayURL)
if err := validateGatewayURL(gatewayURL); err != nil {
return nil, err
}
return &Client{
appID: appID,
clientSecret: clientSecret,
apiBaseURL: apiBaseURL,
gatewayURL: gatewayURL,
httpClient: secutils.NewSSRFSafeHTTPClient(secutils.SSRFSafeHTTPClientConfig{
Timeout: 15 * time.Second,
MaxRedirects: 5,
}),
}, nil
}
func (c *Client) GatewayURL(ctx context.Context) (string, error) {
if c.gatewayURL != "" {
return c.gatewayURL, nil
}
var result gatewayResponse
if err := c.doJSON(ctx, http.MethodGet, defaultGatewayURL, nil, &result); err != nil {
return "", err
}
if result.URL == "" {
return "", fmt.Errorf("empty qqbot gateway url")
}
if err := validateGatewayURL(result.URL); err != nil {
return "", fmt.Errorf("invalid qqbot gateway url: %w", err)
}
return result.URL, nil
}
func validateHTTPAPIBaseURL(raw string) error {
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return fmt.Errorf("invalid qqbot api_base_url: must be a valid http(s) URL")
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("invalid qqbot api_base_url: must use http or https")
}
if err := secutils.ValidateURLForSSRF(raw); err != nil {
return fmt.Errorf("invalid qqbot api_base_url: %w (for private deployments, add the hostname to SSRF_WHITELIST)", err)
}
return nil
}
func validateGatewayURL(raw string) error {
if strings.TrimSpace(raw) == "" {
return nil
}
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return fmt.Errorf("gateway_url must be a valid wss URL")
}
if u.Scheme != "wss" {
return fmt.Errorf("gateway_url must use wss")
}
checkURL := *u
checkURL.Scheme = "https"
if err := secutils.ValidateURLForSSRF(checkURL.String()); err != nil {
return fmt.Errorf(
"gateway_url failed SSRF validation: %w (for private deployments, add the hostname to SSRF_WHITELIST)",
err,
)
}
return nil
}
func (c *Client) SendC2CMessage(ctx context.Context, openID, content, msgID string) error {
path := fmt.Sprintf("/v2/users/%s/messages", openID)
return c.sendText(ctx, path, content, msgID)
}
func (c *Client) SendGroupMessage(ctx context.Context, groupOpenID, content, msgID string) error {
path := fmt.Sprintf("/v2/groups/%s/messages", groupOpenID)
return c.sendText(ctx, path, content, msgID)
}
func (c *Client) sendText(ctx context.Context, path, content, msgID string) error {
body := sendMessageRequest{
MsgType: 2,
Markdown: &markdownMessage{Content: content},
MsgID: msgID,
MsgSeq: 1,
}
return c.doJSON(ctx, http.MethodPost, c.apiBaseURL+path, body, nil)
}
func (c *Client) doJSON(ctx context.Context, method, url string, body any, out any) error {
var reader *bytes.Reader
if body != nil {
payload, err := json.Marshal(body)
if err != nil {
return err
}
reader = bytes.NewReader(payload)
} else {
reader = bytes.NewReader(nil)
}
req, err := http.NewRequestWithContext(ctx, method, url, reader)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if !strings.Contains(url, "getAppAccessToken") {
token, err := c.AccessToken(ctx)
if err != nil {
return err
}
req.Header.Set("Authorization", "QQBot "+token)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("qqbot api %s %s failed: %s", method, url, resp.Status)
}
if out == nil {
return nil
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return fmt.Errorf("decode qqbot response: %w", err)
}
return nil
}
func (c *Client) AccessToken(ctx context.Context) (string, error) {
c.mu.Lock()
if c.accessToken != "" && time.Until(c.expiresAt) > time.Minute {
token := c.accessToken
c.mu.Unlock()
return token, nil
}
c.mu.Unlock()
body := map[string]string{
"appId": c.appID,
"clientSecret": c.clientSecret,
}
var result tokenResponse
if err := c.doJSON(ctx, http.MethodPost, appTokenURL, body, &result); err != nil {
return "", err
}
if result.AccessToken == "" {
return "", fmt.Errorf("empty qqbot access token: code=%d message=%s", result.Code, result.Message)
}
expiresIn := parseExpiresIn(result.ExpiresIn)
c.mu.Lock()
c.accessToken = result.AccessToken
c.expiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
c.mu.Unlock()
return result.AccessToken, nil
}
func parseExpiresIn(raw json.RawMessage) int {
if len(raw) == 0 {
return 7200
}
var number int
if err := json.Unmarshal(raw, &number); err == nil && number > 0 {
return number
}
var text string
if err := json.Unmarshal(raw, &text); err == nil {
var parsed int
if _, err := fmt.Sscanf(text, "%d", &parsed); err == nil && parsed > 0 {
return parsed
}
}
return 7200
}
+47
View File
@@ -0,0 +1,47 @@
package qqbot
import (
"context"
"fmt"
"github.com/Tencent/WeKnora/internal/im"
"github.com/Tencent/WeKnora/internal/logger"
)
func NewFactory() im.AdapterFactory {
return func(factoryCtx context.Context, channel *im.IMChannel, msgHandler func(context.Context, *im.IncomingMessage) error) (im.Adapter, context.CancelFunc, error) {
creds, err := im.ParseCredentials(channel.Credentials)
if err != nil {
return nil, nil, fmt.Errorf("parse qqbot credentials: %w", err)
}
mode := im.ResolveMode(channel, "websocket")
if mode != "websocket" {
return nil, nil, fmt.Errorf("unsupported qqbot mode: %s (only websocket is supported)", mode)
}
client, err := NewClient(
im.GetString(creds, "app_id"),
im.GetString(creds, "client_secret"),
im.GetString(creds, "api_base_url"),
im.GetString(creds, "gateway_url"),
)
if err != nil {
return nil, nil, err
}
longConn := NewLongConnClient(client, msgHandler)
wsCtx, wsCancel := context.WithCancel(context.Background())
go func() {
if err := longConn.Start(wsCtx); err != nil && wsCtx.Err() == nil {
logger.Errorf(context.Background(), "[IM] QQBot long connection stopped for channel %s: %v", channel.ID, err)
}
}()
adapter := NewAdapter(client)
return adapter, func() {
wsCancel()
longConn.Stop()
}, nil
}
}
+199
View File
@@ -0,0 +1,199 @@
package qqbot
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
"github.com/Tencent/WeKnora/internal/im"
"github.com/Tencent/WeKnora/internal/logger"
secutils "github.com/Tencent/WeKnora/internal/utils"
ws "github.com/gorilla/websocket"
)
type MessageHandler func(ctx context.Context, msg *im.IncomingMessage) error
type LongConnClient struct {
client *Client
handler MessageHandler
mu sync.Mutex
conn *ws.Conn
seq *int64
closed bool
}
func NewLongConnClient(client *Client, handler MessageHandler) *LongConnClient {
return &LongConnClient{client: client, handler: handler}
}
func (c *LongConnClient) Start(ctx context.Context) error {
logger.Infof(ctx, "[IM] QQBot WebSocket connecting...")
attempt := 0
for {
if ctx.Err() != nil {
return ctx.Err()
}
if err := c.connectAndRun(ctx); err != nil {
if ctx.Err() != nil || c.isClosed() {
return ctx.Err()
}
attempt++
delay := reconnectDelay(attempt)
logger.Warnf(ctx, "[QQBot] connection lost: %v, reconnecting in %v", err, delay)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(delay):
}
continue
}
attempt = 0
}
}
func (c *LongConnClient) Stop() {
c.mu.Lock()
defer c.mu.Unlock()
c.closed = true
if c.conn != nil {
_ = c.conn.Close()
c.conn = nil
}
}
func (c *LongConnClient) connectAndRun(ctx context.Context) error {
gatewayURL, err := c.client.GatewayURL(ctx)
if err != nil {
return err
}
dialer := *ws.DefaultDialer
dialer.NetDialContext = secutils.SSRFSafeDialContext
conn, _, err := dialer.DialContext(ctx, gatewayURL, nil)
if err != nil {
return err
}
c.mu.Lock()
c.conn = conn
c.mu.Unlock()
defer func() {
c.mu.Lock()
if c.conn == conn {
c.conn = nil
}
c.mu.Unlock()
_ = conn.Close()
}()
for {
_, data, err := conn.ReadMessage()
if err != nil {
return err
}
var payload gatewayPayload
if err := json.Unmarshal(data, &payload); err != nil {
logger.Warnf(ctx, "[QQBot] invalid payload: %v", err)
continue
}
if payload.S != nil {
c.seq = payload.S
}
switch payload.Op {
case opHello:
if err := c.handleHello(ctx, conn, payload.D); err != nil {
return err
}
case opDispatch:
msg, err := parseGatewayPayload(&payload)
if err != nil {
logger.Warnf(ctx, "[QQBot] parse event failed: %v", err)
continue
}
if msg != nil {
if err := c.handler(ctx, msg); err != nil {
logger.Errorf(ctx, "[QQBot] handle message failed: %v", err)
}
}
case opReconnect, opInvalidSession:
return fmt.Errorf("gateway requested reconnect op=%d", payload.Op)
case opHeartbeatACK:
}
}
}
func (c *LongConnClient) handleHello(ctx context.Context, conn *ws.Conn, raw json.RawMessage) error {
var hello helloData
if err := json.Unmarshal(raw, &hello); err != nil {
return err
}
if hello.HeartbeatInterval <= 0 {
hello.HeartbeatInterval = 45000
}
token, err := c.client.AccessToken(ctx)
if err != nil {
return err
}
identify := identifyData{
Token: "QQBot " + token,
Intents: intentGroupAndC2C,
Shard: []int{0, 1},
}
payloadBytes, err := json.Marshal(identify)
if err != nil {
return err
}
if err := conn.WriteJSON(gatewayPayload{Op: opIdentify, D: payloadBytes}); err != nil {
return err
}
go c.heartbeatLoop(ctx, conn, time.Duration(hello.HeartbeatInterval)*time.Millisecond)
return nil
}
func (c *LongConnClient) heartbeatLoop(ctx context.Context, conn *ws.Conn, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
heartbeat, err := c.heartbeatPayload()
if err == nil {
err = conn.WriteJSON(heartbeat)
}
if err != nil {
return
}
}
}
}
func (c *LongConnClient) heartbeatPayload() (gatewayPayload, error) {
c.mu.Lock()
seq := c.seq
c.mu.Unlock()
data, err := json.Marshal(seq)
if err != nil {
return gatewayPayload{}, err
}
return gatewayPayload{Op: opHeartbeat, D: data}, nil
}
func (c *LongConnClient) isClosed() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.closed
}
func reconnectDelay(attempt int) time.Duration {
if attempt <= 0 {
return time.Second
}
delay := time.Duration(attempt) * time.Second
if delay > 30*time.Second {
return 30 * time.Second
}
return delay
}
+88
View File
@@ -0,0 +1,88 @@
package qqbot
import "encoding/json"
const (
defaultAPIBaseURL = "https://api.sgroup.qq.com"
appTokenURL = "https://bots.qq.com/app/getAppAccessToken"
defaultGatewayURL = "https://api.sgroup.qq.com/gateway"
opDispatch = 0
opHeartbeat = 1
opIdentify = 2
opReconnect = 7
opInvalidSession = 9
opHello = 10
opHeartbeatACK = 11
intentGroupAndC2C = 1 << 25
eventC2CMessageCreate = "C2C_MESSAGE_CREATE"
eventGroupAtMessageCreate = "GROUP_AT_MESSAGE_CREATE"
extraKeyMessageID = "message_id"
extraKeyChatKind = "chat_kind"
)
type gatewayPayload struct {
ID string `json:"id,omitempty"`
Op int `json:"op"`
D json.RawMessage `json:"d,omitempty"`
S *int64 `json:"s,omitempty"`
T string `json:"t,omitempty"`
}
type helloData struct {
HeartbeatInterval int `json:"heartbeat_interval"`
}
type identifyData struct {
Token string `json:"token"`
Intents int `json:"intents"`
Shard []int `json:"shard,omitempty"`
}
type tokenResponse struct {
AccessToken string `json:"access_token"`
ExpiresIn json.RawMessage `json:"expires_in"`
Code int `json:"code"`
Message string `json:"message"`
}
type gatewayResponse struct {
URL string `json:"url"`
}
type messageEvent struct {
ID string `json:"id"`
Content string `json:"content"`
GroupOpenID string `json:"group_openid"`
Author qqbotAuthor `json:"author"`
Attachments []qqAttachment `json:"attachments"`
}
type qqbotAuthor struct {
UserOpenID string `json:"user_openid"`
MemberOpenID string `json:"member_openid"`
ID string `json:"id"`
Username string `json:"username"`
Bot bool `json:"bot"`
}
type qqAttachment struct {
ContentType string `json:"content_type"`
Filename string `json:"filename"`
Size int64 `json:"size"`
URL string `json:"url"`
}
type sendMessageRequest struct {
Content string `json:"content,omitempty"`
MsgType int `json:"msg_type"`
Markdown *markdownMessage `json:"markdown,omitempty"`
MsgID string `json:"msg_id,omitempty"`
MsgSeq int `json:"msg_seq,omitempty"`
}
type markdownMessage struct {
Content string `json:"content,omitempty"`
}
File diff suppressed because it is too large Load Diff
+70
View File
@@ -0,0 +1,70 @@
package im
import (
"context"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
type stubIMAgentService struct {
interfaces.CustomAgentService
agent *types.CustomAgent
}
func (s *stubIMAgentService) GetAgentByID(_ context.Context, id string) (*types.CustomAgent, error) {
if s.agent != nil && s.agent.ID == id {
return s.agent, nil
}
return nil, nil
}
func TestSetChannelAgentID(t *testing.T) {
svc := &Service{
agentService: &stubIMAgentService{
agent: &types.CustomAgent{ID: "agent-new", TenantID: 42},
},
}
channel := &IMChannel{
ID: "im-1",
TenantID: 42,
AgentID: "agent-old",
}
if err := svc.SetChannelAgentID(context.Background(), channel, "agent-new"); err != nil {
t.Fatalf("SetChannelAgentID() error = %v", err)
}
if channel.AgentID != "agent-new" {
t.Fatalf("channel.AgentID = %q, want agent-new", channel.AgentID)
}
}
func TestSetChannelAgentIDRejectsForeignTenant(t *testing.T) {
svc := &Service{
agentService: &stubIMAgentService{
agent: &types.CustomAgent{ID: "agent-new", TenantID: 99},
},
}
channel := &IMChannel{
ID: "im-1",
TenantID: 42,
AgentID: "agent-old",
}
if err := svc.SetChannelAgentID(context.Background(), channel, "agent-new"); err == nil {
t.Fatal("SetChannelAgentID() expected error for foreign tenant agent")
}
if channel.AgentID != "agent-old" {
t.Fatalf("channel.AgentID = %q, want unchanged agent-old", channel.AgentID)
}
}
func TestSetChannelAgentIDRequiresAgentID(t *testing.T) {
svc := &Service{agentService: &stubIMAgentService{}}
channel := &IMChannel{ID: "im-1", TenantID: 42, AgentID: "agent-old"}
if err := svc.SetChannelAgentID(context.Background(), channel, " "); err == nil {
t.Fatal("SetChannelAgentID() expected error for empty agent_id")
}
}
+442
View File
@@ -0,0 +1,442 @@
package im
import (
"context"
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/Tencent/WeKnora/internal/types"
"github.com/alicebob/miniredis/v2"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
type lifecycleTestAdapter struct{}
func (*lifecycleTestAdapter) Platform() Platform { return Platform("test") }
func (*lifecycleTestAdapter) VerifyCallback(*gin.Context) error { return nil }
func (*lifecycleTestAdapter) ParseCallback(*gin.Context) (*IncomingMessage, error) {
return nil, nil
}
func (*lifecycleTestAdapter) SendReply(context.Context, *IncomingMessage, *ReplyMessage) error {
return nil
}
func (*lifecycleTestAdapter) HandleURLVerification(*gin.Context) bool { return false }
type lifecycleFactoryCounters struct {
starts atomic.Int32
stops atomic.Int32
}
func (c *lifecycleFactoryCounters) factory() AdapterFactory {
return func(context.Context, *IMChannel, func(context.Context, *IncomingMessage) error) (Adapter, context.CancelFunc, error) {
c.starts.Add(1)
var once sync.Once
return &lifecycleTestAdapter{}, func() {
once.Do(func() { c.stops.Add(1) })
}, nil
}
}
func newLifecycleTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:im-lifecycle-%d?mode=memory&cache=shared", time.Now().UnixNano())), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
// IMChannel's production schema uses PostgreSQL's uuid_generate_v4()
// default, which SQLite cannot parse. Keep an equivalent minimal table for
// lifecycle tests; IDs are assigned explicitly below.
if err := db.Exec(`CREATE TABLE im_channels (
id TEXT PRIMARY KEY,
tenant_id INTEGER NOT NULL,
agent_id TEXT NOT NULL,
platform TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
enabled NUMERIC NOT NULL DEFAULT 1,
mode TEXT NOT NULL DEFAULT 'websocket',
output_mode TEXT NOT NULL DEFAULT 'stream',
knowledge_base_id TEXT DEFAULT '',
bot_identity TEXT NOT NULL DEFAULT '',
session_mode TEXT NOT NULL DEFAULT 'user',
credentials TEXT NOT NULL DEFAULT '{}',
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME
)`).Error; err != nil {
t.Fatalf("create im_channels: %v", err)
}
if err := db.AutoMigrate(&types.CustomAgent{}); err != nil {
t.Fatalf("create custom_agents: %v", err)
}
return db
}
func newLifecycleTestService(db *gorm.DB, redisClient *redis.Client, instanceID string) *Service {
return &Service{
db: db,
channels: make(map[string]*channelState),
leaderRetries: make(map[string]*leaderRetryState),
adapterFactories: make(map[string]AdapterFactory),
redis: redisClient,
instanceID: instanceID,
stopCh: make(chan struct{}),
}
}
func createLifecycleChannel(t *testing.T, db *gorm.DB, id, agentID string) *IMChannel {
t.Helper()
if err := db.FirstOrCreate(&types.CustomAgent{ID: agentID, TenantID: 1, Name: agentID}, "id = ? AND tenant_id = ?", agentID, 1).Error; err != nil {
t.Fatalf("create channel agent: %v", err)
}
channel := &IMChannel{
ID: id,
TenantID: 1,
AgentID: agentID,
Platform: "test",
Enabled: true,
Mode: "webhook",
OutputMode: "full",
SessionMode: string(SessionModeUser),
Credentials: types.JSON(`{"token":"v1"}`),
}
if err := db.Create(channel).Error; err != nil {
t.Fatalf("create channel: %v", err)
}
return channel
}
func TestEnsureChannelAdapterRefreshesStaleConfig(t *testing.T) {
db := newLifecycleTestDB(t)
channel := createLifecycleChannel(t, db, "channel-refresh", "agent-old")
counters := &lifecycleFactoryCounters{}
svc := newLifecycleTestService(db, nil, "instance-one")
svc.RegisterAdapterFactory("test", counters.factory())
t.Cleanup(svc.Stop)
if err := svc.StartChannel(channel); err != nil {
t.Fatalf("start initial channel: %v", err)
}
if err := db.Model(&IMChannel{}).Where("id = ?", channel.ID).
Updates(map[string]any{"agent_id": "agent-new", "credentials": types.JSON(`{"token":"v2"}`)}).Error; err != nil {
t.Fatalf("update durable channel: %v", err)
}
_, fresh, err := svc.EnsureChannelAdapter(channel.ID)
if err != nil {
t.Fatalf("ensure channel adapter: %v", err)
}
if fresh.AgentID != "agent-new" || string(fresh.Credentials) != `{"token":"v2"}` {
t.Fatalf("stale runtime config returned: agent=%q credentials=%s", fresh.AgentID, fresh.Credentials)
}
if counters.starts.Load() != 2 || counters.stops.Load() != 1 {
t.Fatalf("runtime was not rebuilt exactly once: starts=%d stops=%d", counters.starts.Load(), counters.stops.Load())
}
}
func TestEnsureChannelAdapterStopsDisabledCachedChannel(t *testing.T) {
db := newLifecycleTestDB(t)
channel := createLifecycleChannel(t, db, "channel-disabled", "agent")
counters := &lifecycleFactoryCounters{}
svc := newLifecycleTestService(db, nil, "instance-one")
svc.RegisterAdapterFactory("test", counters.factory())
t.Cleanup(svc.Stop)
if err := svc.StartChannel(channel); err != nil {
t.Fatalf("start channel: %v", err)
}
if err := db.Model(&IMChannel{}).Where("id = ?", channel.ID).Update("enabled", false).Error; err != nil {
t.Fatalf("disable channel: %v", err)
}
if _, _, err := svc.EnsureChannelAdapter(channel.ID); err == nil {
t.Fatal("EnsureChannelAdapter() expected disabled error")
}
if _, _, ok := svc.GetChannelAdapter(channel.ID); ok {
t.Fatal("disabled channel remained in runtime map")
}
if counters.stops.Load() != 1 {
t.Fatalf("cleanup calls = %d, want 1", counters.stops.Load())
}
}
func TestEnsureChannelAdapterKeepsRuntimeOnDatabaseFailure(t *testing.T) {
db := newLifecycleTestDB(t)
channel := createLifecycleChannel(t, db, "channel-db-failure", "agent")
counters := &lifecycleFactoryCounters{}
svc := newLifecycleTestService(db, nil, "instance-one")
svc.RegisterAdapterFactory("test", counters.factory())
t.Cleanup(svc.Stop)
if err := svc.StartChannel(channel); err != nil {
t.Fatalf("start channel: %v", err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("resolve sql DB: %v", err)
}
if err := sqlDB.Close(); err != nil {
t.Fatalf("close test DB: %v", err)
}
if _, _, err := svc.EnsureChannelAdapter(channel.ID); err == nil {
t.Fatal("EnsureChannelAdapter() expected database error")
}
if _, _, ok := svc.GetChannelAdapter(channel.ID); !ok {
t.Fatal("transient database failure tore down the cached runtime")
}
if counters.stops.Load() != 0 {
t.Fatalf("cleanup calls = %d, want 0 before service shutdown", counters.stops.Load())
}
}
func TestChannelConfigEventReloadsOtherReplica(t *testing.T) {
db := newLifecycleTestDB(t)
channel := createLifecycleChannel(t, db, "channel-pubsub", "agent-old")
redisServer := miniredis.RunT(t)
redisOne := redis.NewClient(&redis.Options{Addr: redisServer.Addr()})
redisTwo := redis.NewClient(&redis.Options{Addr: redisServer.Addr()})
t.Cleanup(func() { _ = redisOne.Close(); _ = redisTwo.Close() })
countersOne := &lifecycleFactoryCounters{}
countersTwo := &lifecycleFactoryCounters{}
svcOne := newLifecycleTestService(db, redisOne, "instance-one")
svcTwo := newLifecycleTestService(db, redisTwo, "instance-two")
svcOne.RegisterAdapterFactory("test", countersOne.factory())
svcTwo.RegisterAdapterFactory("test", countersTwo.factory())
svcOne.startChannelConfigSubscriber()
svcTwo.startChannelConfigSubscriber()
t.Cleanup(svcOne.Stop)
t.Cleanup(svcTwo.Stop)
if err := svcOne.StartChannel(channel); err != nil {
t.Fatalf("start channel on instance one: %v", err)
}
copyForTwo := *channel
if err := svcTwo.StartChannel(&copyForTwo); err != nil {
t.Fatalf("start channel on instance two: %v", err)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
counts, err := redisOne.PubSubNumSub(context.Background(), RedisChannelConfig).Result()
if err == nil && counts[RedisChannelConfig] == 2 {
break
}
time.Sleep(10 * time.Millisecond)
}
updated := *channel
updated.AgentID = "agent-new"
if err := db.FirstOrCreate(&types.CustomAgent{ID: updated.AgentID, TenantID: 1, Name: updated.AgentID}, "id = ? AND tenant_id = ?", updated.AgentID, 1).Error; err != nil {
t.Fatalf("create reassigned agent: %v", err)
}
updated.Credentials = types.JSON(`{"token":"v2"}`)
if err := svcOne.UpdateChannel(&updated); err != nil {
t.Fatalf("update channel: %v", err)
}
deadline = time.Now().Add(2 * time.Second)
reloaded := false
for time.Now().Before(deadline) {
_, runtimeChannel, ok := svcTwo.GetChannelAdapter(channel.ID)
if ok && runtimeChannel.AgentID == "agent-new" && string(runtimeChannel.Credentials) == `{"token":"v2"}` {
if countersTwo.starts.Load() < 2 || countersTwo.stops.Load() < 1 {
t.Fatalf("replica config changed without rebuilding runtime: starts=%d stops=%d", countersTwo.starts.Load(), countersTwo.stops.Load())
}
reloaded = true
break
}
time.Sleep(10 * time.Millisecond)
}
if !reloaded {
t.Fatal("second replica did not reload the published channel change")
}
if _, err := svcOne.ToggleChannel(channel.ID, channel.TenantID); err != nil {
t.Fatalf("disable channel: %v", err)
}
deadline = time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if _, _, ok := svcTwo.GetChannelAdapter(channel.ID); !ok {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("second replica did not stop the disabled channel")
}
func TestServiceStopIsIdempotent(t *testing.T) {
db := newLifecycleTestDB(t)
channel := createLifecycleChannel(t, db, "channel-stop", "agent")
counters := &lifecycleFactoryCounters{}
svc := newLifecycleTestService(db, nil, "instance-one")
svc.RegisterAdapterFactory("test", counters.factory())
if err := svc.StartChannel(channel); err != nil {
t.Fatalf("start channel: %v", err)
}
svc.Stop()
svc.Stop()
if counters.stops.Load() != 1 {
t.Fatalf("cleanup calls = %d, want exactly 1", counters.stops.Load())
}
if err := svc.StartChannel(channel); err == nil {
t.Fatal("StartChannel() succeeded after service shutdown")
}
}
// A factory can block for seconds while dialing, so Stop() may drain the
// channel map after StartChannel's pre-flight check but before registration.
// The adapter created in that window must be torn down instead of outliving
// shutdown with an open connection.
func TestStartChannelDoesNotLeakAdapterWhenStoppedDuringFactory(t *testing.T) {
db := newLifecycleTestDB(t)
channel := createLifecycleChannel(t, db, "channel-stop-race", "agent")
counters := &lifecycleFactoryCounters{}
svc := newLifecycleTestService(db, nil, "instance-one")
factoryEntered := make(chan struct{})
releaseFactory := make(chan struct{})
inner := counters.factory()
svc.RegisterAdapterFactory("test", func(
ctx context.Context,
ch *IMChannel,
handler func(context.Context, *IncomingMessage) error,
) (Adapter, context.CancelFunc, error) {
close(factoryEntered)
<-releaseFactory
return inner(ctx, ch, handler)
})
startErr := make(chan error, 1)
go func() { startErr <- svc.StartChannel(channel) }()
<-factoryEntered
svc.Stop()
close(releaseFactory)
if err := <-startErr; err == nil {
t.Fatal("StartChannel() succeeded even though the service was stopped")
}
if _, _, ok := svc.GetChannelAdapter(channel.ID); ok {
t.Fatal("adapter was registered after shutdown")
}
if counters.starts.Load() != 1 {
t.Fatalf("factory starts = %d, want 1", counters.starts.Load())
}
if counters.stops.Load() != 1 {
t.Fatalf("cleanup calls = %d, want 1 so the connection is not leaked", counters.stops.Load())
}
}
func TestSameChannelRuntimeConfigUsesSemanticCredentials(t *testing.T) {
now := time.Now()
cached := &IMChannel{
ID: "channel",
TenantID: 1,
AgentID: "agent",
Platform: "test",
Enabled: true,
Mode: "webhook",
OutputMode: "full",
SessionMode: string(SessionModeUser),
Credentials: types.JSON(`{"token":"secret","timeout":10}`),
UpdatedAt: now,
}
fresh := *cached
fresh.Credentials = types.JSON(`{"timeout":10,"token":"secret"}`)
fresh.UpdatedAt = now.Truncate(time.Microsecond)
if !sameChannelRuntimeConfig(cached, &fresh) {
t.Fatal("semantic-equivalent credentials or timestamp precision triggered a rebuild")
}
fresh.Credentials = types.JSON(`{"timeout":10,"token":"changed"}`)
if sameChannelRuntimeConfig(cached, &fresh) {
t.Fatal("changed credentials did not trigger a rebuild")
}
}
func TestLeaderElectionFailsClosedWhenRedisUnavailable(t *testing.T) {
db := newLifecycleTestDB(t)
channel := createLifecycleChannel(t, db, "channel-redis-down", "agent")
channel.Mode = "websocket"
counters := &lifecycleFactoryCounters{}
redisClient := redis.NewClient(&redis.Options{Addr: "127.0.0.1:1"})
_ = redisClient.Close()
svc := newLifecycleTestService(db, redisClient, "instance-one")
svc.RegisterAdapterFactory("test", counters.factory())
t.Cleanup(svc.Stop)
if err := svc.StartChannel(channel); err != nil {
t.Fatalf("StartChannel() should schedule a retry, got %v", err)
}
if err := svc.StartChannel(channel); err != nil {
t.Fatalf("second StartChannel() should replace the retry, got %v", err)
}
if counters.starts.Load() != 0 {
t.Fatalf("factory starts = %d, want 0 while leader election is unavailable", counters.starts.Load())
}
svc.mu.RLock()
retryCount := len(svc.leaderRetries)
svc.mu.RUnlock()
if retryCount != 1 {
t.Fatalf("leader retry goroutines = %d, want one per channel", retryCount)
}
}
func TestLeadershipLossStopsAdapterAndSchedulesRecovery(t *testing.T) {
db := newLifecycleTestDB(t)
channel := createLifecycleChannel(t, db, "channel-leader-loss", "agent")
channel.Mode = "websocket"
if err := db.Model(&IMChannel{}).Where("id = ?", channel.ID).
Update("mode", channel.Mode).Error; err != nil {
t.Fatalf("persist websocket mode: %v", err)
}
redisServer := miniredis.RunT(t)
redisClient := redis.NewClient(&redis.Options{Addr: redisServer.Addr()})
t.Cleanup(func() { _ = redisClient.Close() })
counters := &lifecycleFactoryCounters{}
svc := newLifecycleTestService(db, redisClient, "instance-one")
svc.RegisterAdapterFactory("test", counters.factory())
t.Cleanup(svc.Stop)
if err := svc.StartChannel(channel); err != nil {
t.Fatalf("start websocket channel: %v", err)
}
if counters.starts.Load() != 1 {
t.Fatalf("factory starts = %d, want 1", counters.starts.Load())
}
// Simulate another owner replacing the lease before the next renewal.
key := RedisKeyLeader + channel.ID
redisServer.Set(key, "instance-two")
svc.handleWSLeadershipLoss(channel.ID)
if _, _, ok := svc.GetChannelAdapter(channel.ID); ok {
t.Fatal("adapter remained active after leadership loss")
}
if counters.stops.Load() != 1 {
t.Fatalf("adapter stops = %d, want 1", counters.stops.Load())
}
svc.mu.RLock()
retryCount := len(svc.leaderRetries)
svc.mu.RUnlock()
if retryCount != 1 {
t.Fatalf("leader retry goroutines = %d, want one recovery path", retryCount)
}
// Repeated loss handling after teardown must not create duplicate retries.
svc.handleWSLeadershipLoss(channel.ID)
svc.mu.RLock()
retryCount = len(svc.leaderRetries)
svc.mu.RUnlock()
if retryCount != 1 {
t.Fatalf("leader retry goroutines after duplicate loss = %d, want one", retryCount)
}
}
+106
View File
@@ -0,0 +1,106 @@
package im
import (
"testing"
"github.com/Tencent/WeKnora/internal/types"
)
func TestFormatQuotedContext(t *testing.T) {
tests := []struct {
name string
quote *QuotedMessage
want string
}{
{
name: "nil quote",
quote: nil,
want: "",
},
{
name: "empty content no NonTextType",
quote: &QuotedMessage{Content: ""},
want: "",
},
{
name: "non-text image quote generates instruction",
quote: &QuotedMessage{NonTextType: "image"},
want: "用户引用了一条图片消息,但你无法查看该内容。请直接告知用户你目前无法处理图片消息,建议用户用文字描述问题。不要猜测该消息的内容。",
},
{
name: "non-text file quote generates instruction",
quote: &QuotedMessage{NonTextType: "file"},
want: "用户引用了一条文件消息,但你无法查看该内容。请直接告知用户你目前无法处理文件消息,建议用户用文字描述问题。不要猜测该消息的内容。",
},
{
name: "non-text unknown type uses fallback label",
quote: &QuotedMessage{NonTextType: "location"},
want: "用户引用了一条该类型的消息,但你无法查看该内容。请直接告知用户你目前无法处理该类型的消息,建议用户用文字描述问题。不要猜测该消息的内容。",
},
{
name: "bot message",
quote: &QuotedMessage{Content: "bot reply text", IsBotMessage: true},
want: "以下是用户引用的你(机器人)之前的回复,仅作为上下文参考:\n<quoted_message>\nbot reply text\n</quoted_message>",
},
{
name: "user message",
quote: &QuotedMessage{Content: "user message text", IsBotMessage: false},
want: "以下是用户引用的一条历史消息,仅作为上下文参考:\n<quoted_message>\nuser message text\n</quoted_message>",
},
{
name: "truncation at 500 runes",
quote: &QuotedMessage{
Content: string(make([]rune, 600)),
IsBotMessage: false,
},
want: "以下是用户引用的一条历史消息,仅作为上下文参考:\n<quoted_message>\n" + string(make([]rune, 500)) + "...\n</quoted_message>",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := formatQuotedContext(tt.quote)
if got != tt.want {
t.Errorf("formatQuotedContext() length = %d, want length %d", len(got), len(tt.want))
if len(got) < 200 && len(tt.want) < 200 {
t.Errorf("got = %q, want %q", got, tt.want)
}
}
})
}
}
func TestBuildIMQARequest_QuotedContext(t *testing.T) {
session := &types.Session{ID: "s1"}
t.Run("nil quote produces empty QuotedContext", func(t *testing.T) {
req := buildIMQARequest(session, "hello", "a1", "u1", nil, nil, nil)
if req.QuotedContext != "" {
t.Errorf("QuotedContext = %q, want empty", req.QuotedContext)
}
if req.Query != "hello" {
t.Errorf("Query = %q, want %q", req.Query, "hello")
}
})
t.Run("bot quote sets QuotedContext with bot label", func(t *testing.T) {
quote := &QuotedMessage{Content: "bot reply", IsBotMessage: true}
req := buildIMQARequest(session, "follow up", "a1", "u1", nil, nil, quote)
if req.Query != "follow up" {
t.Errorf("Query = %q, want %q", req.Query, "follow up")
}
want := "以下是用户引用的你(机器人)之前的回复,仅作为上下文参考:\n<quoted_message>\nbot reply\n</quoted_message>"
if req.QuotedContext != want {
t.Errorf("QuotedContext = %q, want %q", req.QuotedContext, want)
}
})
t.Run("user quote sets QuotedContext with user label", func(t *testing.T) {
quote := &QuotedMessage{Content: "user msg", IsBotMessage: false}
req := buildIMQARequest(session, "question", "a1", "u1", nil, nil, quote)
want := "以下是用户引用的一条历史消息,仅作为上下文参考:\n<quoted_message>\nuser msg\n</quoted_message>"
if req.QuotedContext != want {
t.Errorf("QuotedContext = %q, want %q", req.QuotedContext, want)
}
})
}
@@ -0,0 +1,47 @@
package im
import (
"context"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
type channelIsolationSessionService struct {
interfaces.SessionService
db *gorm.DB
}
func (s *channelIsolationSessionService) CreateSession(ctx context.Context, session *types.Session) (*types.Session, error) {
return session, s.db.WithContext(ctx).Create(session).Error
}
func TestSameAgentConversationsStayIsolatedBetweenConnectedAccounts(t *testing.T) {
for _, mode := range []string{"user", "thread"} {
t.Run(mode, func(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&types.Session{}))
require.NoError(t, db.Exec(`CREATE TABLE im_channel_sessions (
id TEXT PRIMARY KEY, platform TEXT, user_id TEXT, chat_id TEXT,
thread_id TEXT, session_id TEXT, tenant_id INTEGER, agent_id TEXT,
im_channel_id TEXT, status TEXT, metadata TEXT, created_at DATETIME,
updated_at DATETIME, deleted_at DATETIME
)`).Error)
svc := &Service{db: db, sessionService: &channelIsolationSessionService{db: db}}
msg := &IncomingMessage{Platform: PlatformFeishu, UserID: "same-user", ChatID: "same-chat", ThreadID: "same-thread"}
first, err := svc.resolveSession(context.Background(), msg, 1, "shared-agent", "account-a", mode)
require.NoError(t, err)
second, err := svc.resolveSession(context.Background(), msg, 1, "shared-agent", "account-b", mode)
require.NoError(t, err)
require.NotEqual(t, first.SessionID, second.SessionID)
again, err := svc.resolveSession(context.Background(), msg, 1, "shared-agent", "account-a", mode)
require.NoError(t, err)
require.Equal(t, first.SessionID, again.SessionID)
})
}
}
+49
View File
@@ -0,0 +1,49 @@
package im
import (
"errors"
"fmt"
"testing"
apperrors "github.com/Tencent/WeKnora/internal/errors"
"gorm.io/gorm"
)
// TestIsSessionNotFound guards the recovery path for issue #1499.
//
// The session repository translates gorm.ErrRecordNotFound into
// apperrors.ErrSessionNotFound, so an `errors.Is(err, gorm.ErrRecordNotFound)`
// check on the value returned by SessionService.GetSession would silently
// miss — leaving the IM bot permanently unresponsive after the user deletes
// the underlying session from the UI.
func TestIsSessionNotFound(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{"app sentinel as returned by sessionService.GetSession today", apperrors.ErrSessionNotFound, true},
{"wrapped app sentinel", fmt.Errorf("get session: %w", apperrors.ErrSessionNotFound), true},
{"raw gorm sentinel (safety net)", gorm.ErrRecordNotFound, true},
{"wrapped gorm sentinel", fmt.Errorf("query session: %w", gorm.ErrRecordNotFound), true},
{"unrelated error", errors.New("boom"), false},
{"nil", nil, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isSessionNotFound(tt.err); got != tt.want {
t.Errorf("isSessionNotFound(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
// TestErrSessionNotFoundIsNotGormErrRecordNotFound documents the invariant
// that motivated the bug: the two sentinels are distinct error values, so
// callers must explicitly match the application sentinel.
func TestErrSessionNotFoundIsNotGormErrRecordNotFound(t *testing.T) {
if errors.Is(apperrors.ErrSessionNotFound, gorm.ErrRecordNotFound) {
t.Fatal("apperrors.ErrSessionNotFound must not unwrap to gorm.ErrRecordNotFound; " +
"if this changes, the IM recovery path can be simplified accordingly")
}
}
+293
View File
@@ -0,0 +1,293 @@
package im
import (
"reflect"
"strings"
"testing"
"github.com/Tencent/WeKnora/internal/event"
"github.com/Tencent/WeKnora/internal/types"
)
func TestMakeUserKey_UserMode(t *testing.T) {
tests := []struct {
name string
channelID string
userID string
chatID string
threadID string
want string
}{
{
name: "user mode with empty threadID",
channelID: "ch-1",
userID: "user-1",
chatID: "chat-1",
threadID: "",
want: "ch-1:user-1:chat-1",
},
{
name: "user mode with empty chatID (DM)",
channelID: "ch-1",
userID: "user-1",
chatID: "",
threadID: "",
want: "ch-1:user-1:",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := makeUserKey(tt.channelID, tt.userID, tt.chatID, tt.threadID)
if got != tt.want {
t.Errorf("makeUserKey() = %q, want %q", got, tt.want)
}
})
}
}
func TestMakeUserKey_ThreadMode(t *testing.T) {
tests := []struct {
name string
channelID string
userID string
chatID string
threadID string
want string
}{
{
name: "thread mode with Slack thread_ts",
channelID: "ch-1",
userID: "user-1",
chatID: "chat-1",
threadID: "1234567890.123456",
want: "ch-1:user-1:chat-1:1234567890.123456",
},
{
name: "thread mode with Mattermost root_id",
channelID: "ch-2",
userID: "user-2",
chatID: "chat-2",
threadID: "abc123def456",
want: "ch-2:user-2:chat-2:abc123def456",
},
{
name: "thread mode with Telegram topic ID",
channelID: "ch-3",
userID: "user-3",
chatID: "chat-3",
threadID: "42",
want: "ch-3:user-3:chat-3:42",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := makeUserKey(tt.channelID, tt.userID, tt.chatID, tt.threadID)
if got != tt.want {
t.Errorf("makeUserKey() = %q, want %q", got, tt.want)
}
})
}
}
func TestMakeUserKey_ThreadIDGuard(t *testing.T) {
// Verify that the same user+chat produces different keys with different threadIDs
keyA := makeUserKey("ch", "user", "chat", "thread-A")
keyB := makeUserKey("ch", "user", "chat", "thread-B")
keyNone := makeUserKey("ch", "user", "chat", "")
if keyA == keyB {
t.Error("different threadIDs should produce different keys")
}
if keyA == keyNone {
t.Error("thread key should differ from non-thread key")
}
if keyB == keyNone {
t.Error("thread key should differ from non-thread key")
}
}
func TestMakeUserKey_SameThreadDifferentUsers(t *testing.T) {
// In thread mode, different users in the same thread produce different keys
// (this is intentional: /stop only cancels the caller's own request)
keyUserA := makeUserKey("ch", "alice", "chat", "thread-1")
keyUserB := makeUserKey("ch", "bob", "chat", "thread-1")
if keyUserA == keyUserB {
t.Error("different users in same thread should have different keys")
}
}
func TestIncomingMessageThreadID(t *testing.T) {
// Verify ThreadID field works correctly on IncomingMessage
msg := &IncomingMessage{
Platform: PlatformSlack,
UserID: "U123",
ChatID: "C456",
MessageID: "1234567890.123456",
ThreadID: "1234567890.123456",
}
if msg.ThreadID != msg.MessageID {
t.Errorf("Slack ThreadID should equal MessageID for top-level, got ThreadID=%q MessageID=%q",
msg.ThreadID, msg.MessageID)
}
// Mattermost: ThreadID from Extra
msgMM := &IncomingMessage{
Platform: PlatformMattermost,
UserID: "user-1",
ChatID: "channel-1",
MessageID: "post-123",
ThreadID: "root-456",
Extra: map[string]string{
"thread_root_id": "root-456",
},
}
if msgMM.ThreadID != msgMM.Extra["thread_root_id"] {
t.Error("Mattermost ThreadID should match Extra thread_root_id")
}
}
func TestBuildIMLastRequestStateFromAgent(t *testing.T) {
agent := &types.CustomAgent{
ID: "agent-1",
Config: types.CustomAgentConfig{
AgentMode: types.AgentModeSmartReasoning,
ModelID: "model-1",
KnowledgeBases: []string{"kb-agent"},
WebSearchEnabled: true,
},
}
state := buildIMLastRequestState(agent.ID, agent, nil)
if state.AgentID != "agent-1" {
t.Fatalf("AgentID = %q, want agent-1", state.AgentID)
}
if !state.AgentEnabled {
t.Fatal("AgentEnabled = false, want true")
}
if state.ModelID != "model-1" {
t.Fatalf("ModelID = %q, want model-1", state.ModelID)
}
if !state.WebSearchEnabled {
t.Fatal("WebSearchEnabled = false, want true")
}
if !reflect.DeepEqual(state.KnowledgeBaseIDs, []string{"kb-agent"}) {
t.Fatalf("KnowledgeBaseIDs = %#v, want [kb-agent]", state.KnowledgeBaseIDs)
}
}
func TestBuildIMLastRequestStateKeepsExplicitKBs(t *testing.T) {
agent := &types.CustomAgent{
ID: "agent-1",
Config: types.CustomAgentConfig{
AgentMode: types.AgentModeQuickAnswer,
ModelID: "model-1",
KnowledgeBases: []string{"kb-agent"},
},
}
state := buildIMLastRequestState(agent.ID, agent, []string{"kb-explicit"})
if state.AgentEnabled {
t.Fatal("AgentEnabled = true, want false for quick-answer agent")
}
if !reflect.DeepEqual(state.KnowledgeBaseIDs, []string{"kb-explicit"}) {
t.Fatalf("KnowledgeBaseIDs = %#v, want [kb-explicit]", state.KnowledgeBaseIDs)
}
}
func TestCreateIMMessagePayloadsShareRequestShape(t *testing.T) {
userMsg := createIMUserMessagePayload("session-1", "hello", "request-1")
assistantMsg := createIMAssistantMessagePayload("session-1", "request-1")
if userMsg.SessionID != "session-1" || assistantMsg.SessionID != "session-1" {
t.Fatalf("SessionID mismatch: user=%q assistant=%q", userMsg.SessionID, assistantMsg.SessionID)
}
if userMsg.RequestID != assistantMsg.RequestID || userMsg.RequestID != "request-1" {
t.Fatalf("RequestID mismatch: user=%q assistant=%q", userMsg.RequestID, assistantMsg.RequestID)
}
if userMsg.Role != "user" || assistantMsg.Role != "assistant" {
t.Fatalf("Role mismatch: user=%q assistant=%q", userMsg.Role, assistantMsg.Role)
}
if userMsg.Channel != "im" || assistantMsg.Channel != "im" {
t.Fatalf("Channel mismatch: user=%q assistant=%q", userMsg.Channel, assistantMsg.Channel)
}
if !userMsg.IsCompleted {
t.Fatal("user message should be completed")
}
if assistantMsg.IsCompleted {
t.Fatal("assistant placeholder should not be completed")
}
if userMsg.Content != "hello" {
t.Fatalf("user content = %q, want hello", userMsg.Content)
}
if assistantMsg.Content != "" {
t.Fatalf("assistant placeholder content = %q, want empty", assistantMsg.Content)
}
}
func TestApplyIMCompleteDataToMessage(t *testing.T) {
msg := &types.Message{ID: "assistant-1", Role: "assistant"}
ref := &types.SearchResult{ID: "chunk-1", KnowledgeID: "knowledge-1"}
steps := []types.AgentStep{{
Iteration: 1,
ReasoningContent: "thinking",
}}
applyIMCompleteDataToMessage(msg, event.AgentCompleteData{
MessageID: "assistant-1",
TotalDurationMs: 1234,
KnowledgeRefs: []interface{}{ref},
AgentSteps: steps,
})
if !msg.IsCompleted {
t.Fatal("message should be marked completed")
}
if msg.AgentDurationMs != 1234 {
t.Fatalf("AgentDurationMs = %d, want 1234", msg.AgentDurationMs)
}
if len(msg.KnowledgeReferences) != 1 || msg.KnowledgeReferences[0].ID != "chunk-1" {
t.Fatalf("KnowledgeReferences = %#v, want chunk-1", msg.KnowledgeReferences)
}
if len(msg.AgentSteps) != 1 || msg.AgentSteps[0].ReasoningContent != "thinking" {
t.Fatalf("AgentSteps = %#v, want one thinking step", msg.AgentSteps)
}
}
func TestPickIMStoredAnswerPrefersFirstNonEmpty(t *testing.T) {
got := pickIMStoredAnswer("", "outer", "live", "complete")
if got != "outer" {
t.Fatalf("pickIMStoredAnswer = %q, want outer", got)
}
got = pickIMStoredAnswer("", "", "live", "complete")
if got != "live" {
t.Fatalf("pickIMStoredAnswer = %q, want live", got)
}
}
func TestMergeIMAgentAnswerBuffersUsesLiveThenComplete(t *testing.T) {
var builder, outer, live strings.Builder
live.WriteString("live answer")
mergeIMAgentAnswerBuffers(&builder, &outer, &live, "complete final")
if builder.String() != "live answer" {
t.Fatalf("builder = %q, want live answer", builder.String())
}
if outer.String() != "live answer" {
t.Fatalf("outer = %q, want live answer", outer.String())
}
builder.Reset()
outer.Reset()
live.Reset()
mergeIMAgentAnswerBuffers(&builder, &outer, &live, "complete final")
if builder.String() != "complete final" {
t.Fatalf("builder = %q, want complete final", builder.String())
}
}
+155
View File
@@ -0,0 +1,155 @@
package im
import "testing"
func TestShortID(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{"empty", "", ""},
{"shorter than 8", "abc", "abc"},
{"exactly 8", "12345678", "12345678"},
{"longer than 8 keeps suffix", "aaaaaaaaXXXXXXXX", "XXXXXXXX"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := shortID(tt.in); got != tt.want {
t.Errorf("shortID(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
func TestBuildUserSessionTitle(t *testing.T) {
tests := []struct {
name string
msg *IncomingMessage
want string
}{
{
name: "group with username",
msg: &IncomingMessage{
Platform: "feishu",
UserName: "李四",
ChatType: ChatTypeGroup,
ChatID: "oc_aaaaaaaaaaaaaaaa",
},
want: "李四 · group aaaaaaaa",
},
{
name: "direct message with username",
msg: &IncomingMessage{
Platform: "feishu",
UserName: "李四",
ChatType: ChatTypeDirect,
},
want: "李四 · dm",
},
{
name: "group without username falls back to user id",
msg: &IncomingMessage{
Platform: "wecom",
UserID: "WeCom_ZhangSan",
ChatType: ChatTypeGroup,
ChatID: "wc_group_1234",
},
want: "user ZhangSan · group oup_1234",
},
{
name: "no user identity at all",
msg: &IncomingMessage{
Platform: "slack",
ChatType: ChatTypeDirect,
},
want: "user · dm",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := buildUserSessionTitle(tt.msg); got != tt.want {
t.Errorf("buildUserSessionTitle() = %q, want %q", got, tt.want)
}
})
}
}
func TestBuildThreadSessionTitle(t *testing.T) {
tests := []struct {
name string
msg *IncomingMessage
want string
}{
{
name: "chat + thread",
msg: &IncomingMessage{
Platform: "slack",
ChatID: "C0123456789",
ThreadID: "1700000000.111000",
},
want: "chat 23456789 · thread 0.111000",
},
{
name: "thread without chat id",
msg: &IncomingMessage{
Platform: "feishu",
ThreadID: "om_thread_abcdefgh",
},
want: "thread abcdefgh",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := buildThreadSessionTitle(tt.msg); got != tt.want {
t.Errorf("buildThreadSessionTitle() = %q, want %q", got, tt.want)
}
})
}
}
func TestIMInitialSessionTitle(t *testing.T) {
tests := []struct {
name string
msg *IncomingMessage
want string
}{
{
name: "text message starts untitled so it gets a content-based title later",
msg: &IncomingMessage{
Platform: "feishu",
UserName: "李四",
ChatType: ChatTypeDirect,
Content: "如何配置单点登录?",
},
want: "",
},
{
name: "whitespace-only content falls back to the identity title",
msg: &IncomingMessage{
Platform: "feishu",
UserName: "李四",
ChatType: ChatTypeDirect,
Content: " ",
},
want: "李四 · dm",
},
{
name: "non-text message (no content) falls back to the identity title",
msg: &IncomingMessage{
Platform: "wecom",
UserID: "WeCom_ZhangSan",
ChatType: ChatTypeGroup,
ChatID: "wc_group_1234",
},
want: "user ZhangSan · group oup_1234",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := imInitialSessionTitle(tt.msg, buildUserSessionTitle); got != tt.want {
t.Errorf("imInitialSessionTitle() = %q, want %q", got, tt.want)
}
})
}
}
+343
View File
@@ -0,0 +1,343 @@
package slack
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"github.com/gin-gonic/gin"
"github.com/slack-go/slack"
"github.com/slack-go/slack/slackevents"
"github.com/Tencent/WeKnora/internal/im"
"github.com/Tencent/WeKnora/internal/logger"
)
// Compile-time checks.
var (
_ im.Adapter = (*Adapter)(nil)
_ im.StreamSender = (*Adapter)(nil)
_ im.FileDownloader = (*Adapter)(nil)
)
// Adapter implements im.Adapter and im.StreamSender for Slack.
// It delegates to the Slack LongConnClient for Socket Mode.
type Adapter struct {
client *LongConnClient
api *slack.Client
signingSecret string
}
// NewAdapter creates an adapter backed by a Slack long connection client.
func NewAdapter(client *LongConnClient, api *slack.Client) *Adapter {
return &Adapter{
client: client,
api: api,
}
}
// NewWebhookAdapter creates an adapter for Slack Events API via Webhook.
func NewWebhookAdapter(api *slack.Client, signingSecret string) *Adapter {
return &Adapter{
api: api,
signingSecret: signingSecret,
}
}
func parseIncomingMessage(user, channel, text, ts string, chatType im.ChatType, files []slack.File) *im.IncomingMessage {
content := text
if chatType == im.ChatTypeGroup {
// Slack mentions are in the format <@U12345678>
for strings.HasPrefix(content, "<@") {
idx := strings.Index(content, ">")
if idx >= 0 {
content = strings.TrimSpace(content[idx+1:])
} else {
break
}
}
}
msg := &im.IncomingMessage{
Platform: im.PlatformSlack,
UserID: user,
ChatID: channel,
ChatType: chatType,
Content: strings.TrimSpace(content),
MessageID: ts,
ThreadID: ts,
}
if len(files) > 0 {
file := files[0]
msg.FileKey = file.ID
msg.FileName = file.Name
msg.FileSize = int64(file.Size)
msg.Extra = map[string]string{
"url_private_download": file.URLPrivateDownload,
}
if strings.HasPrefix(file.Mimetype, "image/") {
msg.MessageType = im.MessageTypeImage
} else {
msg.MessageType = im.MessageTypeFile
}
} else {
msg.MessageType = im.MessageTypeText
}
return msg
}
func (a *Adapter) Platform() im.Platform {
return im.PlatformSlack
}
func (a *Adapter) VerifyCallback(c *gin.Context) error {
if a.signingSecret == "" {
return nil
}
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
return fmt.Errorf("read body: %w", err)
}
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
sv, err := slack.NewSecretsVerifier(c.Request.Header, a.signingSecret)
if err != nil {
return fmt.Errorf("new secrets verifier: %w", err)
}
if _, err := sv.Write(bodyBytes); err != nil {
return fmt.Errorf("write body to verifier: %w", err)
}
if err := sv.Ensure(); err != nil {
return fmt.Errorf("verify signature: %w", err)
}
return nil
}
func (a *Adapter) ParseCallback(c *gin.Context) (*im.IncomingMessage, error) {
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
eventsAPIEvent, err := slackevents.ParseEvent(json.RawMessage(bodyBytes), slackevents.OptionNoVerifyToken())
if err != nil {
return nil, fmt.Errorf("parse event: %w", err)
}
if eventsAPIEvent.Type == slackevents.CallbackEvent {
var rawEvent struct {
Event struct {
Files []slack.File `json:"files"`
} `json:"event"`
}
_ = json.Unmarshal(bodyBytes, &rawEvent)
files := rawEvent.Event.Files
innerEvent := eventsAPIEvent.InnerEvent
switch ev := innerEvent.Data.(type) {
case *slackevents.AppMentionEvent:
threadTs := ev.ThreadTimeStamp
if threadTs == "" {
threadTs = ev.TimeStamp
}
return parseIncomingMessage(ev.User, ev.Channel, ev.Text, threadTs, im.ChatTypeGroup, files), nil
case *slackevents.MessageEvent:
if ev.BotID != "" || (ev.SubType != "" && ev.SubType != "file_share") {
return nil, nil
}
chatType := im.ChatTypeDirect
if ev.ChannelType == "channel" || ev.ChannelType == "group" {
chatType = im.ChatTypeGroup
}
threadTs := ev.ThreadTimeStamp
if threadTs == "" {
threadTs = ev.TimeStamp
}
return parseIncomingMessage(ev.User, ev.Channel, ev.Text, threadTs, chatType, files), nil
}
}
return nil, nil
}
func (a *Adapter) HandleURLVerification(c *gin.Context) bool {
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
return false
}
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
var body struct {
Type string `json:"type"`
Challenge string `json:"challenge"`
}
if err := json.Unmarshal(bodyBytes, &body); err != nil {
return false
}
if body.Type == "url_verification" {
c.JSON(http.StatusOK, gin.H{"challenge": body.Challenge})
return true
}
return false
}
func (a *Adapter) SendReply(ctx context.Context, incoming *im.IncomingMessage, reply *im.ReplyMessage) error {
channelID := incoming.ChatID
if channelID == "" {
channelID = incoming.UserID
}
options := []slack.MsgOption{slack.MsgOptionText(reply.Content, false)}
if incoming.MessageID != "" {
options = append(options, slack.MsgOptionTS(incoming.MessageID))
}
_, _, err := a.api.PostMessageContext(ctx, channelID, options...)
if err != nil {
return fmt.Errorf("slack post message: %w", err)
}
return nil
}
// slackStreamState tracks per-stream accumulated content.
type slackStreamState struct {
mu sync.Mutex
content strings.Builder
ts string // The timestamp of the message being updated
channel string // The channel ID
}
var (
slackStreamsMu sync.Mutex
slackStreams = map[string]*slackStreamState{}
)
func (a *Adapter) StartStream(ctx context.Context, incoming *im.IncomingMessage) (string, error) {
channelID := incoming.ChatID
if channelID == "" {
channelID = incoming.UserID
}
options := []slack.MsgOption{slack.MsgOptionText("正在思考...", false)}
if incoming.MessageID != "" {
options = append(options, slack.MsgOptionTS(incoming.MessageID))
}
// Send initial "Thinking..." message
_, ts, err := a.api.PostMessageContext(ctx, channelID, options...)
if err != nil {
return "", fmt.Errorf("slack start stream: %w", err)
}
streamID := fmt.Sprintf("%s:%s", channelID, ts)
slackStreamsMu.Lock()
slackStreams[streamID] = &slackStreamState{
ts: ts,
channel: channelID,
}
slackStreamsMu.Unlock()
logger.Infof(ctx, "[Slack] Streaming started: stream_id=%s", streamID)
return streamID, nil
}
func (a *Adapter) UpdateStreamContent(ctx context.Context, incoming *im.IncomingMessage, streamID string, fullContent string) error {
if fullContent == "" {
return nil
}
slackStreamsMu.Lock()
state, ok := slackStreams[streamID]
slackStreamsMu.Unlock()
if !ok {
return fmt.Errorf("unknown stream ID: %s", streamID)
}
state.mu.Lock()
state.content.Reset()
state.content.WriteString(fullContent)
channel := state.channel
ts := state.ts
state.mu.Unlock()
_, _, _, err := a.api.UpdateMessageContext(ctx, channel, ts, slack.MsgOptionText(fullContent, false))
if err != nil {
logger.Warnf(ctx, "[Slack] Failed to update stream content: %v", err)
}
return nil
}
func (a *Adapter) FinalizeStream(ctx context.Context, incoming *im.IncomingMessage, streamID string, finalContent string) error {
return a.UpdateStreamContent(ctx, incoming, streamID, finalContent)
}
func (a *Adapter) SendStreamChunk(ctx context.Context, incoming *im.IncomingMessage, streamID string, content string) error {
return a.UpdateStreamContent(ctx, incoming, streamID, content)
}
func (a *Adapter) EndStream(ctx context.Context, incoming *im.IncomingMessage, streamID string) error {
slackStreamsMu.Lock()
state, ok := slackStreams[streamID]
delete(slackStreams, streamID)
slackStreamsMu.Unlock()
if !ok {
return nil
}
state.mu.Lock()
fullContent := state.content.String()
state.mu.Unlock()
_, _, _, err := a.api.UpdateMessageContext(ctx, state.channel, state.ts, slack.MsgOptionText(fullContent, false))
if err != nil {
logger.Warnf(ctx, "[Slack] Failed to end stream: %v", err)
}
logger.Infof(ctx, "[Slack] Streaming ended: stream_id=%s", streamID)
return nil
}
func (a *Adapter) DownloadFile(ctx context.Context, msg *im.IncomingMessage) (io.ReadCloser, string, error) {
if msg.FileKey == "" {
return nil, "", fmt.Errorf("file_key is required")
}
downloadURL := ""
if msg.Extra != nil {
downloadURL = msg.Extra["url_private_download"]
}
if downloadURL == "" {
file, _, _, err := a.api.GetFileInfoContext(ctx, msg.FileKey, 0, 0)
if err != nil {
return nil, "", fmt.Errorf("get file info: %w", err)
}
downloadURL = file.URLPrivateDownload
}
if downloadURL == "" {
return nil, "", fmt.Errorf("no download URL available for file %s", msg.FileKey)
}
pr, pw := io.Pipe()
go func() {
err := a.api.GetFileContext(ctx, downloadURL, pw)
pw.CloseWithError(err)
}()
return pr, msg.FileName, nil
}
+70
View File
@@ -0,0 +1,70 @@
package slack
import (
"testing"
"github.com/Tencent/WeKnora/internal/im"
slacklib "github.com/slack-go/slack"
)
func TestParseIncomingMessage_ThreadID(t *testing.T) {
tests := []struct {
name string
ts string
wantThreadID string
wantMessageID string
}{
{
name: "top-level message uses own timestamp",
ts: "1234567890.123456",
wantThreadID: "1234567890.123456",
wantMessageID: "1234567890.123456",
},
{
name: "threaded reply uses thread_ts",
ts: "1234567890.000001",
wantThreadID: "1234567890.000001",
wantMessageID: "1234567890.000001",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
msg := parseIncomingMessage("U123", "C456", "hello", tt.ts, im.ChatTypeGroup, nil)
if msg.ThreadID != tt.wantThreadID {
t.Errorf("ThreadID = %q, want %q", msg.ThreadID, tt.wantThreadID)
}
if msg.MessageID != tt.wantMessageID {
t.Errorf("MessageID = %q, want %q", msg.MessageID, tt.wantMessageID)
}
})
}
}
func TestParseIncomingMessage_ThreadID_WithFiles(t *testing.T) {
files := []slacklib.File{
{ID: "F123", Name: "test.pdf", Size: 1024, Mimetype: "application/pdf"},
}
msg := parseIncomingMessage("U123", "C456", "", "1234567890.999", im.ChatTypeDirect, files)
if msg.ThreadID != "1234567890.999" {
t.Errorf("ThreadID = %q, want %q", msg.ThreadID, "1234567890.999")
}
if msg.MessageType != im.MessageTypeFile {
t.Errorf("MessageType = %q, want %q", msg.MessageType, im.MessageTypeFile)
}
}
func TestParseIncomingMessage_MentionStripping(t *testing.T) {
// Ensure mention stripping doesn't affect ThreadID
msg := parseIncomingMessage("U123", "C456", "<@U999> hello world", "ts-123", im.ChatTypeGroup, nil)
if msg.ThreadID != "ts-123" {
t.Errorf("ThreadID = %q, want %q", msg.ThreadID, "ts-123")
}
if msg.Content != "hello world" {
t.Errorf("Content = %q, want %q", msg.Content, "hello world")
}
}
+52
View File
@@ -0,0 +1,52 @@
package slack
import (
"context"
"fmt"
slackpkg "github.com/slack-go/slack"
"github.com/Tencent/WeKnora/internal/im"
"github.com/Tencent/WeKnora/internal/logger"
)
// NewFactory returns an im.AdapterFactory for Slack channels.
// Supports "webhook" (Events API) and "websocket" (Socket Mode, default).
func NewFactory() im.AdapterFactory {
return func(factoryCtx context.Context, channel *im.IMChannel, msgHandler func(context.Context, *im.IncomingMessage) error) (im.Adapter, context.CancelFunc, error) {
creds, err := im.ParseCredentials(channel.Credentials)
if err != nil {
return nil, nil, fmt.Errorf("parse slack credentials: %w", err)
}
mode := im.ResolveMode(channel, "websocket")
switch mode {
case "webhook":
api := slackpkg.New(im.GetString(creds, "bot_token"))
adapter := NewWebhookAdapter(api, im.GetString(creds, "signing_secret"))
return adapter, func() {}, nil
case "websocket":
client := NewLongConnClient(
im.GetString(creds, "app_token"),
im.GetString(creds, "bot_token"),
msgHandler,
)
adapter := NewAdapter(client, client.GetAPI())
wsCtx, wsCancel := context.WithCancel(context.Background())
go func() {
if err := client.Start(wsCtx); err != nil && wsCtx.Err() == nil {
logger.Errorf(context.Background(), "[IM] Slack long connection stopped for channel %s: %v", channel.ID, err)
}
}()
return adapter, wsCancel, nil
default:
return nil, nil, fmt.Errorf("unsupported slack mode: %s", mode)
}
}
}
+148
View File
@@ -0,0 +1,148 @@
package slack
import (
"context"
"encoding/json"
"github.com/slack-go/slack"
"github.com/slack-go/slack/slackevents"
"github.com/slack-go/slack/socketmode"
"github.com/Tencent/WeKnora/internal/im"
"github.com/Tencent/WeKnora/internal/logger"
)
// MessageHandler is called when an IM message is received via long connection.
type MessageHandler func(ctx context.Context, msg *im.IncomingMessage) error
// LongConnClient manages a Slack Socket Mode long connection.
type LongConnClient struct {
appToken string
botToken string
handler MessageHandler
api *slack.Client
client *socketmode.Client
}
// NewLongConnClient creates a Slack long connection client.
func NewLongConnClient(appToken, botToken string, handler MessageHandler) *LongConnClient {
api := slack.New(
botToken,
slack.OptionAppLevelToken(appToken),
)
client := socketmode.New(
api,
socketmode.OptionDebug(false), // true for debugging
)
return &LongConnClient{
appToken: appToken,
botToken: botToken,
handler: handler,
api: api,
client: client,
}
}
// GetAPI returns the underlying slack API client.
func (c *LongConnClient) GetAPI() *slack.Client {
return c.api
}
// Start begins the WebSocket long connection. It blocks until ctx is cancelled.
func (c *LongConnClient) Start(ctx context.Context) error {
logger.Infof(ctx, "[IM] Slack WebSocket connecting...")
go func() {
for {
select {
case <-ctx.Done():
return
case evt := <-c.client.Events:
switch evt.Type {
case socketmode.EventTypeConnecting:
logger.Infof(ctx, "[Slack] Connecting to Slack with Socket Mode...")
case socketmode.EventTypeConnectionError:
logger.Errorf(ctx, "[Slack] Connection failed. Retrying later...")
case socketmode.EventTypeConnected:
logger.Infof(ctx, "[IM] Slack WebSocket connected successfully")
case socketmode.EventTypeEventsAPI:
eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent)
if !ok {
logger.Warnf(ctx, "[Slack] Ignored %+v", evt)
continue
}
// Acknowledge the event
c.client.Ack(*evt.Request)
// Handle the event
c.handleEvent(ctx, eventsAPIEvent, evt.Request.Payload)
}
}
}
}()
return c.client.RunContext(ctx)
}
func (c *LongConnClient) handleEvent(ctx context.Context, eventsAPIEvent slackevents.EventsAPIEvent, rawPayload json.RawMessage) {
logger.Infof(ctx, "[Slack] Received event type: %s", eventsAPIEvent.Type)
switch eventsAPIEvent.Type {
case slackevents.CallbackEvent:
var rawEvent struct {
Event struct {
Files []slack.File `json:"files"`
} `json:"event"`
}
_ = json.Unmarshal(rawPayload, &rawEvent)
files := rawEvent.Event.Files
innerEvent := eventsAPIEvent.InnerEvent
logger.Infof(ctx, "[Slack] Received inner event type: %s", innerEvent.Type)
switch ev := innerEvent.Data.(type) {
case *slackevents.AppMentionEvent:
logger.Infof(ctx, "[Slack] AppMentionEvent: user=%s channel=%s text=%s", ev.User, ev.Channel, ev.Text)
// Handle @bot mention in a channel
threadTs := ev.ThreadTimeStamp
if threadTs == "" {
threadTs = ev.TimeStamp
}
c.processMessage(ctx, ev.User, ev.Channel, ev.Text, threadTs, im.ChatTypeGroup, files)
case *slackevents.MessageEvent:
logger.Infof(ctx, "[Slack] MessageEvent: user=%s channel=%s text=%s subtype=%s bot_id=%s", ev.User, ev.Channel, ev.Text, ev.SubType, ev.BotID)
if ev.BotID != "" {
return
}
if ev.SubType != "" && ev.SubType != "file_share" {
return
}
chatType := im.ChatTypeDirect
if ev.ChannelType == "channel" || ev.ChannelType == "group" {
chatType = im.ChatTypeGroup
}
threadTs := ev.ThreadTimeStamp
if threadTs == "" {
threadTs = ev.TimeStamp
}
c.processMessage(ctx, ev.User, ev.Channel, ev.Text, threadTs, chatType, files)
default:
logger.Warnf(ctx, "[Slack] Unhandled inner event type: %T", innerEvent.Data)
}
default:
logger.Warnf(ctx, "[Slack] Unhandled event type: %s", eventsAPIEvent.Type)
}
}
func (c *LongConnClient) processMessage(ctx context.Context, user, channel, text, ts string, chatType im.ChatType, files []slack.File) {
incoming := parseIncomingMessage(user, channel, text, ts, chatType, files)
if err := c.handler(ctx, incoming); err != nil {
logger.Errorf(ctx, "[Slack] Handle message error: %v", err)
}
}
+142
View File
@@ -0,0 +1,142 @@
package im
import (
"context"
"strings"
"sync"
"testing"
)
// recordingStreamSender records streaming calls for TDD verification.
type recordingStreamSender struct {
mu sync.Mutex
streamID string
chunkContents []string // full display content per UpdateStreamContent call
finalContent string
ended bool
}
func (m *recordingStreamSender) StartStream(_ context.Context, _ *IncomingMessage) (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.streamID = "rec-stream-1"
return m.streamID, nil
}
func (m *recordingStreamSender) UpdateStreamContent(_ context.Context, _ *IncomingMessage, _ string, fullContent string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.chunkContents = append(m.chunkContents, fullContent)
return nil
}
func (m *recordingStreamSender) FinalizeStream(_ context.Context, _ *IncomingMessage, _ string, finalContent string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.finalContent = finalContent
return nil
}
func (m *recordingStreamSender) EndStream(_ context.Context, _ *IncomingMessage, _ string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.ended = true
return nil
}
func (m *recordingStreamSender) snapshot() (chunks []string, final string, ended bool) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]string, len(m.chunkContents))
copy(out, m.chunkContents)
return out, m.finalContent, m.ended
}
func TestStreamDisplayPipeline_agentScenario_redGreen(t *testing.T) {
// Simulates the agent IM stream lifecycle aligned with Web:
// 1) intermediate updates show styled thinking + tools
// 2) final replace shows answer only (no collapsed think header)
rawIntermediate := "<think>\n分析 Civilization VI 问题\n正在调用 搜索关键词...\n搜索关键词\n</think>\n\n"
rec := &recordingStreamSender{}
ctx := context.Background()
incoming := &IncomingMessage{Platform: PlatformWeCom, UserID: "u1"}
streamID, err := rec.StartStream(ctx, incoming)
if err != nil {
t.Fatalf("StartStream: %v", err)
}
intermediate := FormatIMDisplayContent(rawIntermediate, StreamDisplayIntermediate)
if err := rec.UpdateStreamContent(ctx, incoming, streamID, intermediate); err != nil {
t.Fatalf("UpdateStreamContent intermediate: %v", err)
}
final := FormatIMFinalFromParts(IMStreamParts{
Mode: IMStreamModeAgent,
AgentInner: "分析 Civilization VI 问题\n",
AgentToolSteps: []IMToolStep{
{ToolName: "grep_chunks", Success: true},
{ToolName: "knowledge_search", Success: true},
},
Answer: "《文明6》是回合制策略游戏。",
})
if err := rec.FinalizeStream(ctx, incoming, streamID, final); err != nil {
t.Fatalf("FinalizeStream: %v", err)
}
if err := rec.EndStream(ctx, incoming, streamID); err != nil {
t.Fatalf("EndStream: %v", err)
}
chunks, finalized, ended := rec.snapshot()
if !ended {
t.Fatal("stream should be ended")
}
if len(chunks) != 1 {
t.Fatalf("expected 1 intermediate update, got %d", len(chunks))
}
if chunks[0] == final {
t.Fatal("intermediate update should differ from final answer-only content")
}
if finalized != "《文明6》是回合制策略游戏。" {
t.Fatalf("FinalizeStream content = %q", finalized)
}
if !strings.Contains(finalized, "《文明6》是回合制策略游戏。") {
t.Fatalf("FinalizeStream must include answer, got: %q", finalized)
}
}
func TestStreamDisplayPipeline_quickQA_redGreen(t *testing.T) {
during := IMStreamParts{
Mode: IMStreamModeQuickQA,
PipelineToolSteps: []IMToolStep{
{ToolName: "query_understand", Success: true},
{ToolName: "knowledge_search", Pending: true, Arguments: map[string]any{"query": "test"}},
},
}
done := IMStreamParts{
Mode: IMStreamModeQuickQA,
PipelineToolSteps: []IMToolStep{
{ToolName: "query_understand", Success: true},
{ToolName: "knowledge_search", Success: true, Arguments: map[string]any{"query": "test"}},
},
Answer: "答案是 B。",
}
intermediate := FormatIMIntermediateFromParts(during, false)
if intermediate == "" {
t.Fatal("quick QA should show pipeline progress while streaming")
}
if !strings.Contains(intermediate, "问题理解") {
t.Fatalf("quick QA pipeline should show query_understand step, got: %q", intermediate)
}
if strings.Contains(intermediate, "思考过程") {
t.Fatalf("quick QA pipeline should not use agent think header, got: %q", intermediate)
}
final := FormatIMFinalFromParts(done)
if final != "答案是 B。" {
t.Fatalf("quick QA final = %q, want answer only", final)
}
}
+166
View File
@@ -0,0 +1,166 @@
package im
import (
"context"
"strings"
"testing"
)
// imStreamDisplayState mirrors the display buffers in handleMessageStream for lifecycle tests.
type imStreamDisplayState struct {
useAgent bool
agentDone bool
agentInner streamSection
agentLiveAnswer strings.Builder
answerOuter strings.Builder
agentToolSteps []IMToolStep
agentToolIdx map[string]int
pipelineToolSteps []IMToolStep
pipelineIdx map[string]int
}
func newIMStreamDisplayState(useAgent bool) *imStreamDisplayState {
return &imStreamDisplayState{
useAgent: useAgent,
agentToolIdx: make(map[string]int),
pipelineIdx: make(map[string]int),
}
}
func (s *imStreamDisplayState) retractAgentLiveAnswer() {
if s.agentLiveAnswer.Len() == 0 {
return
}
if s.agentInner.text.Len() > 0 {
s.agentInner.ensureNewlineBefore()
}
s.agentInner.write(s.agentLiveAnswer.String())
s.agentLiveAnswer.Reset()
}
func (s *imStreamDisplayState) parts() IMStreamParts {
mode := IMStreamModeQuickQA
if s.useAgent {
mode = IMStreamModeAgent
}
return IMStreamParts{
Mode: mode,
PipelineToolSteps: s.pipelineToolSteps,
AgentInner: s.agentInner.text.String(),
AgentToolSteps: s.agentToolSteps,
LiveAnswer: s.agentLiveAnswer.String(),
Answer: s.answerOuter.String(),
}
}
func (s *imStreamDisplayState) intermediate() string {
return FormatIMIntermediateFromParts(s.parts(), s.useAgent && !s.agentDone)
}
func (s *imStreamDisplayState) final() string {
return FormatIMFinalFromParts(s.parts())
}
// TestIMStreamLifecycle_agentToolRetract simulates handleMessageStream display assembly:
// live answer → tool retract → tool result → final answer → complete.
func TestIMStreamLifecycle_agentToolRetract(t *testing.T) {
state := newIMStreamDisplayState(true)
state.agentLiveAnswer.WriteString("好的,让我先搜索知识库。")
liveOnly := state.intermediate()
if liveOnly != "好的,让我先搜索知识库。" {
t.Fatalf("live answer phase = %q", liveOnly)
}
if strings.Contains(liveOnly, "思考过程") {
t.Fatal("think header must not appear before tool retract")
}
state.retractAgentLiveAnswer()
upsertIMToolStep(&state.agentToolSteps, state.agentToolIdx, "tool-1", func(step *IMToolStep) {
step.ToolName = "grep_chunks"
step.Pending = true
})
duringTools := state.intermediate()
if !strings.Contains(duringTools, "思考过程") {
t.Fatalf("after retract should show think block, got: %q", duringTools)
}
if !strings.Contains(duringTools, "好的,让我先搜索知识库") {
t.Fatalf("retracted preamble missing, got: %q", duringTools)
}
upsertIMToolStep(&state.agentToolSteps, state.agentToolIdx, "tool-1", func(step *IMToolStep) {
step.ToolName = "grep_chunks"
step.Pending = false
step.Success = true
})
state.agentLiveAnswer.WriteString("根据检索结果,《文明6》是回合制策略游戏。")
withLiveAnswer := state.intermediate()
if !strings.Contains(withLiveAnswer, "根据检索结果") {
t.Fatalf("live answer after tools missing, got: %q", withLiveAnswer)
}
state.agentDone = true
state.answerOuter.WriteString("《文明6》是回合制策略游戏。")
final := state.final()
if !strings.Contains(final, "《文明6》是回合制策略游戏。") {
t.Fatalf("final answer missing, got: %q", final)
}
rec := &recordingStreamSender{}
ctx := context.Background()
incoming := &IncomingMessage{Platform: PlatformWeCom, UserID: "u1"}
streamID, err := rec.StartStream(ctx, incoming)
if err != nil {
t.Fatalf("StartStream: %v", err)
}
if err := rec.UpdateStreamContent(ctx, incoming, streamID, duringTools); err != nil {
t.Fatalf("UpdateStreamContent: %v", err)
}
if err := rec.FinalizeStream(ctx, incoming, streamID, final); err != nil {
t.Fatalf("FinalizeStream: %v", err)
}
if err := rec.EndStream(ctx, incoming, streamID); err != nil {
t.Fatalf("EndStream: %v", err)
}
chunks, finalized, ended := rec.snapshot()
if !ended {
t.Fatal("stream should end")
}
if len(chunks) != 1 {
t.Fatalf("expected 1 intermediate update, got %d", len(chunks))
}
if chunks[0] == finalized {
t.Fatal("intermediate and final should differ")
}
}
// TestIMStreamLifecycle_quickQAPipeline verifies quick-QA pipeline steps collapse to answer-only final.
func TestIMStreamLifecycle_quickQAPipeline(t *testing.T) {
state := newIMStreamDisplayState(false)
upsertIMToolStep(&state.pipelineToolSteps, state.pipelineIdx, "qu-1", func(step *IMToolStep) {
step.ToolName = "query_understand"
step.Pending = false
step.Success = true
})
upsertIMToolStep(&state.pipelineToolSteps, state.pipelineIdx, "ks-1", func(step *IMToolStep) {
step.ToolName = "knowledge_search"
step.Pending = true
step.Arguments = map[string]any{"query": "文明6"}
})
intermediate := state.intermediate()
if intermediate == "" {
t.Fatal("pipeline progress should be visible")
}
if strings.Contains(intermediate, "思考过程") {
t.Fatalf("quick QA must not use agent think header, got: %q", intermediate)
}
state.answerOuter.WriteString("答案是 B。")
final := state.final()
if final != "答案是 B。" {
t.Fatalf("final = %q, want answer only", final)
}
}
+24
View File
@@ -0,0 +1,24 @@
package im
import "strings"
// streamSection tracks newline state for one IM stream text buffer.
type streamSection struct {
lastCharNewline bool
text strings.Builder
}
func (s *streamSection) write(str string) {
if str == "" {
return
}
s.text.WriteString(str)
s.lastCharNewline = str[len(str)-1] == '\n'
}
func (s *streamSection) ensureNewlineBefore() {
if !s.lastCharNewline {
s.text.WriteByte('\n')
s.lastCharNewline = true
}
}
+150
View File
@@ -0,0 +1,150 @@
package im
import (
"context"
"sync"
"testing"
"time"
)
// mockStreamSender is a test double that records streaming calls.
type mockStreamSender struct {
mu sync.Mutex
started bool
streamID string
updates []string
finalContent string
ended bool
}
func (m *mockStreamSender) StartStream(_ context.Context, _ *IncomingMessage) (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.started = true
m.streamID = "test-stream-1"
return m.streamID, nil
}
func (m *mockStreamSender) UpdateStreamContent(_ context.Context, _ *IncomingMessage, _ string, fullContent string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.updates = append(m.updates, fullContent)
return nil
}
func (m *mockStreamSender) FinalizeStream(_ context.Context, _ *IncomingMessage, _ string, finalContent string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.finalContent = finalContent
return nil
}
func (m *mockStreamSender) EndStream(_ context.Context, _ *IncomingMessage, _ string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.ended = true
return nil
}
func (m *mockStreamSender) getUpdates() []string {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]string, len(m.updates))
copy(out, m.updates)
return out
}
func TestStreamSenderInterface(t *testing.T) {
mock := &mockStreamSender{}
ctx := context.Background()
incoming := &IncomingMessage{
Platform: PlatformFeishu,
UserID: "test-user",
Content: "hello",
}
streamID, err := mock.StartStream(ctx, incoming)
if err != nil {
t.Fatalf("StartStream failed: %v", err)
}
if streamID == "" {
t.Fatal("expected non-empty stream ID")
}
// Replace-based updates send the full visible content each time.
updates := []string{"Hello", "Hello, world", "Hello, world!"}
for _, content := range updates {
if err := mock.UpdateStreamContent(ctx, incoming, streamID, content); err != nil {
t.Fatalf("UpdateStreamContent failed: %v", err)
}
}
if err := mock.FinalizeStream(ctx, incoming, streamID, "Hello, world!"); err != nil {
t.Fatalf("FinalizeStream failed: %v", err)
}
if err := mock.EndStream(ctx, incoming, streamID); err != nil {
t.Fatalf("EndStream failed: %v", err)
}
if !mock.started {
t.Error("expected stream to be started")
}
if !mock.ended {
t.Error("expected stream to be ended")
}
if mock.finalContent != "Hello, world!" {
t.Errorf("finalContent = %q, want %q", mock.finalContent, "Hello, world!")
}
got := mock.getUpdates()
if len(got) != len(updates) {
t.Fatalf("expected %d updates, got %d", len(updates), len(got))
}
for i, want := range updates {
if got[i] != want {
t.Errorf("update[%d] = %q, want %q", i, got[i], want)
}
}
}
func TestStreamFlushBatching(t *testing.T) {
mock := &mockStreamSender{}
ctx := context.Background()
incoming := &IncomingMessage{
Platform: PlatformFeishu,
UserID: "test-user",
Content: "test",
}
streamID, _ := mock.StartStream(ctx, incoming)
var buf string
tokens := []string{"Hello", " ", "world", "!"}
for _, tok := range tokens {
buf += tok
}
if err := mock.UpdateStreamContent(ctx, incoming, streamID, buf); err != nil {
t.Fatalf("UpdateStreamContent failed: %v", err)
}
got := mock.getUpdates()
if len(got) != 1 {
t.Fatalf("expected 1 batched update, got %d", len(got))
}
if got[0] != "Hello world!" {
t.Errorf("batched update = %q, want %q", got[0], "Hello world!")
}
}
func TestStreamFlushIntervalConstant(t *testing.T) {
// Verify the flush interval is set to a reasonable value
if streamFlushInterval < 100*time.Millisecond {
t.Errorf("streamFlushInterval too small: %v (may cause API rate limiting)", streamFlushInterval)
}
if streamFlushInterval > 2*time.Second {
t.Errorf("streamFlushInterval too large: %v (poor user experience)", streamFlushInterval)
}
}
+97
View File
@@ -0,0 +1,97 @@
package im
import (
"context"
"time"
"github.com/Tencent/WeKnora/internal/logger"
)
// defaultRecycleInterval is how often a supervised long connection is proactively
// torn down and rebuilt. Some IM SDKs (e.g. DingTalk, Feishu) delegate reconnection
// to internal logic that can silently enter a "zombie" state on long-running
// connections — the connection object stays alive but no longer receives messages.
// Periodically recreating the connection bounds the worst-case outage to this
// interval, on top of whatever auto-reconnect the SDK already provides.
const defaultRecycleInterval = 6 * time.Hour
// defaultSupervisorRetryDelay is the backoff applied after a failed connect attempt.
const defaultSupervisorRetryDelay = 5 * time.Second
// SupervisorConfig configures RunSupervised.
type SupervisorConfig struct {
// Name identifies the supervised connection in logs (e.g. "DingTalk channel xxx").
Name string
// MaxConnAge is the interval between proactive reconnections.
// Defaults to defaultRecycleInterval when <= 0.
MaxConnAge time.Duration
// RetryDelay is the backoff after a failed connect attempt.
// Defaults to defaultSupervisorRetryDelay when <= 0.
RetryDelay time.Duration
// Connect establishes a fresh connection and returns a stop function that
// tears it down cleanly (preventing any further internal reconnects).
// It may wrap either a blocking or a non-blocking SDK Start: the only
// contract is that it returns once the connection has been established
// (or the attempt has failed), along with a stop function.
Connect func(ctx context.Context) (stop func(), err error)
}
// RunSupervised manages the lifecycle of a long-lived IM connection.
//
// It keeps a connection alive by (re)establishing it via cfg.Connect, then
// proactively recycling it every cfg.MaxConnAge. The underlying SDK is still
// expected to handle transient drops via its own auto-reconnect; the periodic
// recycle is a safety net that eliminates stuck/zombie connections that the SDK
// fails to recover on its own.
//
// RunSupervised blocks until ctx is cancelled. On cancellation it tears down the
// active connection via the stop function before returning, so callers can rely
// on cancelling ctx to fully stop the connection.
func RunSupervised(ctx context.Context, cfg SupervisorConfig) {
maxAge := cfg.MaxConnAge
if maxAge <= 0 {
maxAge = defaultRecycleInterval
}
retryDelay := cfg.RetryDelay
if retryDelay <= 0 {
retryDelay = defaultSupervisorRetryDelay
}
for {
if ctx.Err() != nil {
return
}
stop, err := cfg.Connect(ctx)
if err != nil {
if ctx.Err() != nil {
return
}
logger.Warnf(ctx, "[IM] %s connect failed: %v, retrying in %v", cfg.Name, err, retryDelay)
select {
case <-time.After(retryDelay):
continue
case <-ctx.Done():
return
}
}
logger.Infof(ctx, "[IM] %s connection established (recycle in %v)", cfg.Name, maxAge)
select {
case <-time.After(maxAge):
logger.Infof(ctx, "[IM] %s periodic reconnect to refresh connection", cfg.Name)
if stop != nil {
stop()
}
case <-ctx.Done():
if stop != nil {
stop()
}
return
}
}
}
+505
View File
@@ -0,0 +1,505 @@
package telegram
import (
"bytes"
"context"
"crypto/subtle"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/Tencent/WeKnora/internal/im"
"github.com/Tencent/WeKnora/internal/logger"
secutils "github.com/Tencent/WeKnora/internal/utils"
)
// Compile-time checks.
var (
_ im.Adapter = (*Adapter)(nil)
_ im.StreamSender = (*Adapter)(nil)
_ im.FileDownloader = (*Adapter)(nil)
)
// Adapter implements im.Adapter for Telegram Bot API.
type Adapter struct {
botToken string
secretToken string // optional X-Telegram-Bot-Api-Secret-Token for webhook verification
client *LongConnClient
}
// NewWebhookAdapter creates a Telegram adapter for webhook mode.
func NewWebhookAdapter(botToken, secretToken string) *Adapter {
startStreamReaper()
return &Adapter{
botToken: botToken,
secretToken: secretToken,
}
}
// NewAdapter creates a Telegram adapter backed by a long-polling client.
func NewAdapter(client *LongConnClient, botToken string) *Adapter {
startStreamReaper()
return &Adapter{
botToken: botToken,
client: client,
}
}
func (a *Adapter) Platform() im.Platform {
return im.PlatformTelegram
}
func (a *Adapter) HandleURLVerification(c *gin.Context) bool {
return false // Telegram does not require URL verification challenges.
}
func (a *Adapter) VerifyCallback(c *gin.Context) error {
if a.secretToken == "" {
return nil
}
token := c.GetHeader("X-Telegram-Bot-Api-Secret-Token")
if subtle.ConstantTimeCompare([]byte(token), []byte(a.secretToken)) != 1 {
return fmt.Errorf("invalid secret token")
}
return nil
}
// telegramUpdate represents an incoming Telegram update (subset of fields).
type telegramUpdate struct {
UpdateID int `json:"update_id"`
Message *telegramMsg `json:"message"`
}
type telegramMsg struct {
MessageID int `json:"message_id"`
MessageThreadID int `json:"message_thread_id"`
From *telegramUser `json:"from"`
Chat telegramChat `json:"chat"`
Text string `json:"text"`
Document *telegramDoc `json:"document"`
Photo []telegramPhoto `json:"photo"`
}
type telegramUser struct {
ID int64 `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Username string `json:"username"`
}
type telegramChat struct {
ID int64 `json:"id"`
Type string `json:"type"` // "private", "group", "supergroup", "channel"
}
type telegramDoc struct {
FileID string `json:"file_id"`
FileName string `json:"file_name"`
FileSize int64 `json:"file_size"`
MimeType string `json:"mime_type"`
}
type telegramPhoto struct {
FileID string `json:"file_id"`
FileSize int `json:"file_size"`
Width int `json:"width"`
Height int `json:"height"`
}
func (a *Adapter) ParseCallback(c *gin.Context) (*im.IncomingMessage, error) {
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
var update telegramUpdate
if err := json.Unmarshal(bodyBytes, &update); err != nil {
return nil, fmt.Errorf("parse update: %w", err)
}
return parseUpdate(&update), nil
}
func parseUpdate(update *telegramUpdate) *im.IncomingMessage {
if update.Message == nil {
return nil
}
return parseTelegramMessage(update.Message)
}
func parseTelegramMessage(msg *telegramMsg) *im.IncomingMessage {
if msg == nil {
return nil
}
chatType := im.ChatTypeDirect
chatID := ""
if msg.Chat.Type == "group" || msg.Chat.Type == "supergroup" {
chatType = im.ChatTypeGroup
chatID = fmt.Sprintf("%d", msg.Chat.ID)
}
userID := ""
userName := ""
if msg.From != nil {
userID = fmt.Sprintf("%d", msg.From.ID)
userName = strings.TrimSpace(msg.From.FirstName + " " + msg.From.LastName)
if userName == "" {
userName = msg.From.Username
}
}
threadID := ""
if msg.MessageThreadID != 0 {
threadID = fmt.Sprintf("%d", msg.MessageThreadID)
}
incoming := &im.IncomingMessage{
Platform: im.PlatformTelegram,
UserID: userID,
UserName: userName,
ChatID: chatID,
ChatType: chatType,
MessageID: fmt.Sprintf("%d", msg.MessageID),
ThreadID: threadID,
MessageType: im.MessageTypeText,
Content: msg.Text,
}
// For group messages, strip bot mention prefix (e.g., "/command@botname text" -> "text")
if chatType == im.ChatTypeGroup {
content := strings.TrimSpace(msg.Text)
// Remove @bot mentions
if idx := strings.Index(content, " "); idx > 0 && strings.Contains(content[:idx], "@") {
content = strings.TrimSpace(content[idx+1:])
}
incoming.Content = content
}
// Handle document
if msg.Document != nil {
incoming.MessageType = im.MessageTypeFile
incoming.FileKey = msg.Document.FileID
incoming.FileName = msg.Document.FileName
incoming.FileSize = msg.Document.FileSize
}
// Handle photo (use the largest photo)
if len(msg.Photo) > 0 {
largest := msg.Photo[len(msg.Photo)-1]
incoming.MessageType = im.MessageTypeImage
incoming.FileKey = largest.FileID
incoming.FileName = "photo.jpg"
incoming.FileSize = int64(largest.FileSize)
}
return incoming
}
// resolveChatID returns ChatID if set, otherwise falls back to UserID (for direct messages).
func resolveChatID(incoming *im.IncomingMessage) string {
if incoming.ChatID != "" {
return incoming.ChatID
}
return incoming.UserID
}
// ── Send reply ──
func (a *Adapter) SendReply(ctx context.Context, incoming *im.IncomingMessage, reply *im.ReplyMessage) error {
chatID := resolveChatID(incoming)
text := im.FormatIMDisplayContent(reply.Content, im.StreamDisplayFinal)
body := map[string]interface{}{
"chat_id": chatID,
"text": text,
"parse_mode": "Markdown",
}
if incoming.ThreadID != "" {
if tid, err := strconv.Atoi(incoming.ThreadID); err == nil {
body["message_thread_id"] = tid
}
}
return a.callAPI(ctx, "sendMessage", body)
}
func (a *Adapter) sendMessage(ctx context.Context, chatID, text, replyToMessageID string) error {
body := map[string]interface{}{
"chat_id": chatID,
"text": text,
"parse_mode": "Markdown",
}
if replyToMessageID != "" {
body["reply_to_message_id"] = replyToMessageID
}
return a.callAPI(ctx, "sendMessage", body)
}
func (a *Adapter) editMessage(ctx context.Context, chatID, messageID, text, parseMode string) error {
body := map[string]interface{}{
"chat_id": chatID,
"message_id": json.Number(messageID),
"text": text,
}
if parseMode != "" {
body["parse_mode"] = parseMode
}
return a.callAPI(ctx, "editMessageText", body)
}
// httpClient is a shared HTTP client with a reasonable timeout for Telegram API calls.
var httpClient = secutils.NewSSRFSafeHTTPClient(secutils.SSRFSafeHTTPClientConfig{
Timeout: 15 * time.Second,
MaxRedirects: 5,
})
// callAPI calls the Telegram Bot API, discarding the result.
func (a *Adapter) callAPI(ctx context.Context, method string, body interface{}) error {
return a.callAPIWithResult(ctx, method, body, nil)
}
// callAPIWithResult calls the Telegram Bot API and optionally decodes the result field.
func (a *Adapter) callAPIWithResult(ctx context.Context, method string, body interface{}, result interface{}) error {
url := fmt.Sprintf("https://api.telegram.org/bot%s/%s", a.botToken, method)
jsonBody, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal body: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()
var apiResp struct {
OK bool `json:"ok"`
Result json.RawMessage `json:"result"`
}
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return fmt.Errorf("decode response: %w", err)
}
if !apiResp.OK {
return fmt.Errorf("telegram API %s failed: %s", method, string(apiResp.Result))
}
if result != nil {
if err := json.Unmarshal(apiResp.Result, result); err != nil {
return fmt.Errorf("decode result: %w", err)
}
}
return nil
}
// ── StreamSender implementation (edit message in-place) ──
// minEditInterval is the minimum time between consecutive editMessageText calls
// to avoid hitting Telegram's rate limit (~30 msg/sec global, ~20 edit/min per chat).
const minEditInterval = 500 * time.Millisecond
type streamState struct {
mu sync.Mutex
content strings.Builder
msgID string // Telegram message ID of the "thinking" message
chatID string
lastEdit time.Time // last successful editMessageText timestamp
createdAt time.Time // for orphan stream detection
}
const (
streamOrphanTTL = 5 * time.Minute
streamReaperInterval = 1 * time.Minute
)
var (
streamsMu sync.Mutex
streams = map[string]*streamState{}
startReaperOnce sync.Once
reaperStopCh = make(chan struct{})
)
// startStreamReaper starts a background goroutine (once) that periodically
// removes orphaned stream entries. This prevents memory leaks when EndStream
// is never called due to panics or pipeline errors.
func startStreamReaper() {
startReaperOnce.Do(func() {
go func() {
ticker := time.NewTicker(streamReaperInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
cutoff := time.Now().Add(-streamOrphanTTL)
streamsMu.Lock()
for id, state := range streams {
if state.createdAt.Before(cutoff) {
delete(streams, id)
}
}
streamsMu.Unlock()
case <-reaperStopCh:
return
}
}
}()
})
}
func (a *Adapter) StartStream(ctx context.Context, incoming *im.IncomingMessage) (string, error) {
chatID := resolveChatID(incoming)
// Send initial "thinking" message
body := map[string]interface{}{
"chat_id": chatID,
"text": "正在思考...",
}
if incoming.ThreadID != "" {
if tid, err := strconv.Atoi(incoming.ThreadID); err == nil {
body["message_thread_id"] = tid
}
}
var sentMsg struct {
MessageID int `json:"message_id"`
}
if err := a.callAPIWithResult(ctx, "sendMessage", body, &sentMsg); err != nil {
return "", fmt.Errorf("telegram start stream: %w", err)
}
msgID := fmt.Sprintf("%d", sentMsg.MessageID)
streamID := fmt.Sprintf("%s:%s", chatID, msgID)
streamsMu.Lock()
streams[streamID] = &streamState{
msgID: msgID,
chatID: chatID,
createdAt: time.Now(),
}
streamsMu.Unlock()
logger.Infof(ctx, "[Telegram] Streaming started: stream_id=%s", streamID)
return streamID, nil
}
func (a *Adapter) UpdateStreamContent(ctx context.Context, incoming *im.IncomingMessage, streamID string, fullContent string) error {
if fullContent == "" {
return nil
}
streamsMu.Lock()
state, ok := streams[streamID]
streamsMu.Unlock()
if !ok {
return fmt.Errorf("unknown stream ID: %s", streamID)
}
state.mu.Lock()
if time.Since(state.lastEdit) < minEditInterval {
state.content.Reset()
state.content.WriteString(fullContent)
state.mu.Unlock()
return nil
}
state.content.Reset()
state.content.WriteString(fullContent)
chatID := state.chatID
msgID := state.msgID
state.lastEdit = time.Now()
state.mu.Unlock()
if err := a.editMessage(ctx, chatID, msgID, fullContent, ""); err != nil {
logger.Warnf(ctx, "[Telegram] Failed to update stream content: %v", err)
}
return nil
}
func (a *Adapter) FinalizeStream(ctx context.Context, incoming *im.IncomingMessage, streamID string, finalContent string) error {
streamsMu.Lock()
state, ok := streams[streamID]
streamsMu.Unlock()
if !ok {
return fmt.Errorf("unknown stream ID: %s", streamID)
}
state.mu.Lock()
state.content.Reset()
state.content.WriteString(finalContent)
chatID := state.chatID
msgID := state.msgID
state.mu.Unlock()
if err := a.editMessage(ctx, chatID, msgID, finalContent, "Markdown"); err != nil {
logger.Warnf(ctx, "[Telegram] Markdown finalize failed, retrying plain: %v", err)
if retryErr := a.editMessage(ctx, chatID, msgID, finalContent, ""); retryErr != nil {
logger.Warnf(ctx, "[Telegram] Failed to finalize stream: %v", retryErr)
}
}
return nil
}
func (a *Adapter) SendStreamChunk(ctx context.Context, incoming *im.IncomingMessage, streamID string, content string) error {
return a.UpdateStreamContent(ctx, incoming, streamID, content)
}
func (a *Adapter) EndStream(ctx context.Context, incoming *im.IncomingMessage, streamID string) error {
streamsMu.Lock()
_, ok := streams[streamID]
delete(streams, streamID)
streamsMu.Unlock()
if !ok {
return nil
}
logger.Infof(ctx, "[Telegram] Streaming ended: stream_id=%s", streamID)
return nil
}
// ── FileDownloader implementation ──
func (a *Adapter) DownloadFile(ctx context.Context, msg *im.IncomingMessage) (io.ReadCloser, string, error) {
if msg.FileKey == "" {
return nil, "", fmt.Errorf("file_key is required")
}
// Get file path via getFile API
var fileInfo struct {
FilePath string `json:"file_path"`
FileSize int64 `json:"file_size"`
}
if err := a.callAPIWithResult(ctx, "getFile", map[string]string{"file_id": msg.FileKey}, &fileInfo); err != nil {
return nil, "", fmt.Errorf("get file info: %w", err)
}
// Download the file
downloadURL := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", a.botToken, fileInfo.FilePath)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
return nil, "", fmt.Errorf("create download request: %w", err)
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, "", fmt.Errorf("download file: %w", err)
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, "", fmt.Errorf("download failed with status %d", resp.StatusCode)
}
return resp.Body, msg.FileName, nil
}
+108
View File
@@ -0,0 +1,108 @@
package telegram
import (
"testing"
"github.com/Tencent/WeKnora/internal/im"
)
func TestParseTelegramMessage_ForumTopicThread(t *testing.T) {
msg := &telegramMsg{
MessageID: 100,
MessageThreadID: 42, // Forum topic ID
From: &telegramUser{ID: 1001, FirstName: "Alice"},
Chat: telegramChat{ID: -9999, Type: "supergroup"},
Text: "hello",
}
incoming := parseTelegramMessage(msg)
if incoming == nil {
t.Fatal("expected non-nil message")
}
if incoming.ThreadID != "42" {
t.Errorf("ThreadID = %q, want %q", incoming.ThreadID, "42")
}
}
func TestParseTelegramMessage_NonForumGroup(t *testing.T) {
msg := &telegramMsg{
MessageID: 200,
MessageThreadID: 0, // not a Forum group
From: &telegramUser{ID: 1002, FirstName: "Bob"},
Chat: telegramChat{ID: -8888, Type: "group"},
Text: "hello",
}
incoming := parseTelegramMessage(msg)
if incoming == nil {
t.Fatal("expected non-nil message")
}
// Non-Forum groups: ThreadID should be empty
if incoming.ThreadID != "" {
t.Errorf("ThreadID = %q, want empty for non-Forum group", incoming.ThreadID)
}
}
func TestParseTelegramMessage_DirectMessage(t *testing.T) {
msg := &telegramMsg{
MessageID: 300,
From: &telegramUser{ID: 1003, FirstName: "Carol"},
Chat: telegramChat{ID: 1003, Type: "private"},
Text: "hi bot",
}
incoming := parseTelegramMessage(msg)
if incoming == nil {
t.Fatal("expected non-nil message")
}
if incoming.ThreadID != "" {
t.Errorf("ThreadID = %q, want empty for DM", incoming.ThreadID)
}
if incoming.ChatType != im.ChatTypeDirect {
t.Errorf("ChatType = %q, want %q", incoming.ChatType, im.ChatTypeDirect)
}
}
func TestParseTelegramMessage_NilMessage(t *testing.T) {
incoming := parseTelegramMessage(nil)
if incoming != nil {
t.Error("expected nil for nil message")
}
}
func TestParseTelegramMessage_Document(t *testing.T) {
msg := &telegramMsg{
MessageID: 400,
MessageThreadID: 7, // in a Forum topic
From: &telegramUser{ID: 1004, FirstName: "Dave"},
Chat: telegramChat{ID: -7777, Type: "supergroup"},
Document: &telegramDoc{
FileID: "doc-123",
FileName: "report.pdf",
FileSize: 2048,
},
}
incoming := parseTelegramMessage(msg)
if incoming == nil {
t.Fatal("expected non-nil message")
}
if incoming.ThreadID != "7" {
t.Errorf("ThreadID = %q, want %q", incoming.ThreadID, "7")
}
if incoming.MessageType != im.MessageTypeFile {
t.Errorf("MessageType = %q, want %q", incoming.MessageType, im.MessageTypeFile)
}
}
func TestParseUpdate_NilMessage(t *testing.T) {
update := &telegramUpdate{UpdateID: 1, Message: nil}
incoming := parseUpdate(update)
if incoming != nil {
t.Error("expected nil for nil update.Message")
}
}
+47
View File
@@ -0,0 +1,47 @@
package telegram
import (
"context"
"fmt"
"github.com/Tencent/WeKnora/internal/im"
"github.com/Tencent/WeKnora/internal/logger"
)
// NewFactory returns an im.AdapterFactory for Telegram bot channels.
// Supports "webhook" and "websocket" (long polling, default).
func NewFactory() im.AdapterFactory {
return func(factoryCtx context.Context, channel *im.IMChannel, msgHandler func(context.Context, *im.IncomingMessage) error) (im.Adapter, context.CancelFunc, error) {
creds, err := im.ParseCredentials(channel.Credentials)
if err != nil {
return nil, nil, fmt.Errorf("parse telegram credentials: %w", err)
}
botToken := im.GetString(creds, "bot_token")
mode := im.ResolveMode(channel, "websocket")
switch mode {
case "webhook":
secretToken := im.GetString(creds, "secret_token")
adapter := NewWebhookAdapter(botToken, secretToken)
return adapter, nil, nil
case "websocket":
client := NewLongConnClient(botToken, msgHandler)
wsCtx, wsCancel := context.WithCancel(context.Background())
go func() {
if err := client.Start(wsCtx); err != nil && wsCtx.Err() == nil {
logger.Errorf(context.Background(), "[IM] Telegram long polling stopped for channel %s: %v", channel.ID, err)
}
}()
adapter := NewAdapter(client, botToken)
return adapter, wsCancel, nil
default:
return nil, nil, fmt.Errorf("unsupported telegram mode: %s", mode)
}
}
}
+120
View File
@@ -0,0 +1,120 @@
package telegram
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/Tencent/WeKnora/internal/im"
"github.com/Tencent/WeKnora/internal/logger"
secutils "github.com/Tencent/WeKnora/internal/utils"
)
// MessageHandler is called when an IM message is received via long polling.
type MessageHandler func(ctx context.Context, msg *im.IncomingMessage) error
// LongConnClient manages a Telegram long-polling connection.
type LongConnClient struct {
botToken string
handler MessageHandler
offset int
httpClient *http.Client
}
// NewLongConnClient creates a Telegram long-polling client.
func NewLongConnClient(botToken string, handler MessageHandler) *LongConnClient {
return &LongConnClient{
botToken: botToken,
handler: handler,
httpClient: secutils.NewSSRFSafeHTTPClient(secutils.SSRFSafeHTTPClientConfig{
Timeout: 35 * time.Second,
MaxRedirects: 5,
}),
}
}
// Start begins the long-polling loop. It blocks until ctx is cancelled.
func (c *LongConnClient) Start(ctx context.Context) error {
logger.Infof(ctx, "[IM] Telegram long polling connecting...")
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
updates, err := c.getUpdates(ctx)
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
logger.Errorf(ctx, "[Telegram] getUpdates error: %v", err)
// Back off on error
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(3 * time.Second):
}
continue
}
for _, update := range updates {
if update.UpdateID >= c.offset {
c.offset = update.UpdateID + 1
}
msg := parseUpdate(&update)
if msg == nil {
continue
}
if err := c.handler(ctx, msg); err != nil {
logger.Errorf(ctx, "[Telegram] Handle message error: %v", err)
}
}
}
}
func (c *LongConnClient) getUpdates(ctx context.Context) ([]telegramUpdate, error) {
url := fmt.Sprintf("https://api.telegram.org/bot%s/getUpdates", c.botToken)
body := map[string]interface{}{
"offset": c.offset,
"timeout": 30,
"allowed_updates": []string{"message"},
}
jsonBody, err := json.Marshal(body)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var apiResp struct {
OK bool `json:"ok"`
Description string `json:"description"`
Result []telegramUpdate `json:"result"`
}
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
if !apiResp.OK {
return nil, fmt.Errorf("getUpdates failed: %s", apiResp.Description)
}
return apiResp.Result, nil
}
+275
View File
@@ -0,0 +1,275 @@
package im
import (
"regexp"
"strings"
)
// StreamDisplayPhase controls how raw IM stream content is rendered.
type StreamDisplayPhase int
const (
// StreamDisplayIntermediate shows in-progress thinking/tools (Web: expanded progress).
StreamDisplayIntermediate StreamDisplayPhase = iota
// StreamDisplayFinal shows answer-only text (Web: collapsed/hidden intermediate steps).
StreamDisplayFinal
)
var (
thinkBlockRe = regexp.MustCompile(`(?s)<think>.*?</think>`)
thinkOpenTailRe = regexp.MustCompile(`(?s)<think>.*$`)
)
// ragPipelineToolNames mirrors frontend RAG_PIPELINE_TOOL_NAMES.
var ragPipelineToolNames = map[string]bool{
"query_understand": true,
"knowledge_search": true,
}
// IsRAGPipelineToolName reports whether a tool is part of the quick-QA progress UI.
func IsRAGPipelineToolName(name string) bool {
return ragPipelineToolNames[name]
}
// StripThinkBlocks removes <think> blocks for final IM display.
func StripThinkBlocks(content string) string {
if content == "" {
return ""
}
cleaned := thinkBlockRe.ReplaceAllString(content, "")
cleaned = thinkOpenTailRe.ReplaceAllString(cleaned, "")
return trimIMOuterWhitespace(cleaned)
}
// IMStreamMode distinguishes agent reasoning from quick-QA RAG pipeline display.
type IMStreamMode int
const (
IMStreamModeAgent IMStreamMode = iota
IMStreamModeQuickQA
)
// IMStreamParts separates stream content for IM display.
type IMStreamParts struct {
Mode IMStreamMode
// Quick-QA (RagPipelineProgress on Web):
PipelineToolSteps []IMToolStep // query_understand / knowledge_search rows
ReasoningInner string // model reasoning_content — separate "思考" section
// Agent (AgentStreamDisplay on Web):
AgentInner string // retracted preambles + thoughts ("思考过程")
AgentToolSteps []IMToolStep // tool progress lines (one row per tool_call_id)
LiveAnswer string // optimistic answer stream before tools retract it
Answer string // final answer (complete / knowledge QA)
}
func agentThinkContent(parts IMStreamParts) string {
toolLines := renderIMToolSteps(parts.AgentToolSteps, FormatIMToolLine)
return mergeIMNarrativeAndTools(parts.AgentInner, toolLines)
}
func quickQAPipelineContent(parts IMStreamParts) string {
return renderIMToolSteps(parts.PipelineToolSteps, FormatIMRagPipelineLine)
}
// RAGThinkingStyle matches Web RagPipelineProgress thinking row (agent.think).
var RAGThinkingStyle = ThinkBlockStyle{
ThinkingHeader: "> 💭 **思考中...**\n",
ThoughtHeader: "> 💭 **思考**\n",
LinePrefix: "> ",
LineSuffix: "",
Separator: "\n---\n\n",
}
// WrapThinkBlock wraps inner content with think tags for formatting.
func WrapThinkBlock(inner string) string {
inner = strings.TrimSpace(inner)
if inner == "" {
return ""
}
return "<think>\n" + inner + "\n</think>"
}
// BuildIMAgentStreamRaw assembles agent-mode raw content.
func BuildIMAgentStreamRaw(parts IMStreamParts, agentInProgress bool) string {
thinkTagged := WrapThinkBlock(agentThinkContent(parts))
if agentInProgress || strings.TrimSpace(parts.Answer) == "" {
return thinkTagged
}
if thinkTagged == "" {
return parts.Answer
}
return thinkTagged + "\n\n" + parts.Answer
}
// formatIMAgentIntermediate mirrors Web AgentStreamDisplay:
// stream answer text first; when tools run, retract into "思考过程";
// once tools exist, keep the think block visible above the streaming answer.
func formatIMAgentIntermediate(parts IMStreamParts) string {
think := strings.TrimSpace(agentThinkContent(parts))
live := strings.TrimSpace(parts.LiveAnswer)
var sections []string
if think != "" {
sections = append(sections, FormatIMDisplayContent(WrapThinkBlock(think), StreamDisplayIntermediate))
}
if live != "" {
sections = append(sections, live)
}
return strings.Join(sections, MarkdownThinkStyle.Separator)
}
// formatIMQuickQAIntermediate mirrors Web RagPipelineProgress:
// pipeline steps as plain lines, model reasoning in a separate "思考" block,
// and once answer starts streaming the progress collapses to answer-only preview.
func formatIMQuickQAIntermediate(parts IMStreamParts) string {
if answer := strings.TrimSpace(parts.Answer); answer != "" {
return answer
}
var sections []string
if p := strings.TrimSpace(quickQAPipelineContent(parts)); p != "" {
sections = append(sections, p)
}
if r := strings.TrimSpace(parts.ReasoningInner); r != "" {
sections = append(sections, TransformThinkBlocks(WrapThinkBlock(r), RAGThinkingStyle))
}
return strings.Join(sections, "\n\n")
}
// FormatIMIntermediateFromParts formats in-progress IM stream display.
func FormatIMIntermediateFromParts(parts IMStreamParts, agentInProgress bool) string {
if parts.Mode == IMStreamModeQuickQA {
return formatIMQuickQAIntermediate(parts)
}
_ = agentInProgress
return formatIMAgentIntermediate(parts)
}
// FormatIMFinalFromParts returns the final replace frame (answer-only for all modes).
func FormatIMFinalFromParts(parts IMStreamParts) string {
answer := strings.TrimSpace(parts.Answer)
if answer != "" {
return answer
}
// Fallback for embedded think tags in non-agent answers.
return FormatIMDisplayContent(BuildIMAgentStreamRaw(parts, false), StreamDisplayFinal)
}
// FormatIMDisplayContent formats raw stream content for the given display phase.
func FormatIMDisplayContent(raw string, phase StreamDisplayPhase) string {
switch phase {
case StreamDisplayFinal:
return StripThinkBlocks(raw)
default:
return TransformThinkBlocks(raw, MarkdownThinkStyle)
}
}
func trimIMOuterWhitespace(s string) string {
return strings.TrimSpace(s)
}
// ThinkBlockStyle controls how <think>...</think> blocks are rendered.
type ThinkBlockStyle struct {
// ThinkingHeader is shown when the think block is still in-progress (no closing tag yet).
ThinkingHeader string // e.g. "> 💭 **思考中...**\n" or "💭 _思考中..._\n"
// ThoughtHeader is shown before the think block content.
ThoughtHeader string // e.g. "> 💭 **思考过程**\n" or "💭 *思考过程*\n"
// LinePrefix is prepended to each line of think content.
LinePrefix string // e.g. "> " or "> _"
// LineSuffix is appended to each line of think content (before newline).
LineSuffix string // e.g. "" or "_"
// Separator is inserted between the think block and the rest of the content.
Separator string // e.g. "\n---\n\n" or "\n"
}
// MarkdownThinkStyle renders think blocks as markdown blockquotes.
// Used by DingTalk and Feishu/Lark.
//
// Known limitation: the headers below are Chinese for every platform, including
// ones whose users are typically not Chinese speakers (Lark, Slack, Telegram,
// Mattermost). Localizing them means threading a locale into
// FormatIMDisplayContent, which is called from the shared streaming path with no
// platform context — a cross-platform change rather than a per-platform patch.
var MarkdownThinkStyle = ThinkBlockStyle{
ThinkingHeader: "> 💭 **思考中...**\n",
ThoughtHeader: "> 💭 **思考过程**\n",
LinePrefix: "> ",
LineSuffix: "",
Separator: "\n---\n\n",
}
// TelegramThinkStyle renders think blocks as blockquotes for Telegram.
// Uses the same blockquote format as other platforms for reliable rendering
// during streaming (where incomplete markdown can cause API failures).
var TelegramThinkStyle = ThinkBlockStyle{
ThinkingHeader: "> 💭 *思考中...*\n",
ThoughtHeader: "> 💭 *思考过程*\n",
LinePrefix: "> ",
LineSuffix: "",
Separator: "\n---\n\n",
}
// TransformThinkBlocks converts <think>...</think> blocks using the given style.
// Handles both complete blocks and in-progress blocks (where </think> has not
// yet arrived during streaming).
func TransformThinkBlocks(content string, style ThinkBlockStyle) string {
const (
openTag = "<think>"
closeTag = "</think>"
)
openIdx := strings.Index(content, openTag)
if openIdx < 0 {
return content
}
before := content[:openIdx]
after := content[openIdx+len(openTag):]
closeIdx := strings.Index(after, closeTag)
thinkClosed := closeIdx >= 0
var thinkContent, rest string
if thinkClosed {
thinkContent = after[:closeIdx]
rest = after[closeIdx+len(closeTag):]
} else {
thinkContent = after
}
thinkContent = strings.TrimSpace(thinkContent)
var result strings.Builder
result.WriteString(before)
if thinkContent == "" {
if !thinkClosed {
result.WriteString(style.ThinkingHeader)
return result.String()
}
result.WriteString(strings.TrimLeft(rest, "\n"))
return result.String()
}
result.WriteString(style.ThoughtHeader)
for _, line := range strings.Split(thinkContent, "\n") {
result.WriteString(style.LinePrefix)
result.WriteString(line)
result.WriteString(style.LineSuffix)
result.WriteString("\n")
}
if thinkClosed {
rest = strings.TrimLeft(rest, "\n")
if rest != "" {
result.WriteString(style.Separator)
result.WriteString(rest)
}
}
return result.String()
}
+282
View File
@@ -0,0 +1,282 @@
package im
import (
"strings"
"testing"
)
func TestStripThinkBlocks(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{name: "no think blocks", input: "Hello, world!", want: "Hello, world!"},
{name: "empty", input: "", want: ""},
{
name: "single block before answer",
input: "<think>reasoning</think>The answer is 42.",
want: "The answer is 42.",
},
{
name: "multiline think with tools",
input: "<think>\n让我先搜索知识库\n正在调用 搜索关键词...\n搜索关键词:「文明」\n</think>\n\n文明6是一款策略游戏。",
want: "文明6是一款策略游戏。",
},
{
name: "multiple blocks",
input: "<think>first</think>Part 1. <think>second</think>Part 2.",
want: "Part 1. Part 2.",
},
{name: "only think block", input: "<think>just thinking</think>", want: ""},
{
name: "unclosed think block",
input: "<think>still streaming",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := StripThinkBlocks(tt.input)
if got != tt.want {
t.Fatalf("StripThinkBlocks() = %q, want %q", got, tt.want)
}
})
}
}
func TestFormatIMDisplayContent_intermediate_showsThinkingStyled(t *testing.T) {
raw := "<think>\n分析用户问题\n正在调用 知识库检索...\n</think>\n\n"
got := FormatIMDisplayContent(raw, StreamDisplayIntermediate)
if !strings.Contains(got, "思考过程") {
t.Fatalf("intermediate display should include thinking header, got: %q", got)
}
if !strings.Contains(got, "分析用户问题") {
t.Fatalf("intermediate display should include thinking body, got: %q", got)
}
if !strings.Contains(got, "知识库检索") {
t.Fatalf("intermediate display should include tool progress, got: %q", got)
}
if strings.Contains(got, "<think>") {
t.Fatalf("intermediate display must not leak raw think tags, got: %q", got)
}
}
func TestFormatIMDisplayContent_intermediate_inProgressThink(t *testing.T) {
raw := "<think>\n正在推理\n正在调用 搜索关键词...\n"
got := FormatIMDisplayContent(raw, StreamDisplayIntermediate)
if !strings.Contains(got, "思考") {
t.Fatalf("open think block should show thinking header, got: %q", got)
}
if strings.Contains(got, "<think>") {
t.Fatalf("must not leak raw tags, got: %q", got)
}
}
func TestFormatIMDisplayContent_intermediate_showsAnswerPreview(t *testing.T) {
raw := "<think>\n检索中\n</think>\n\n文明6是回合制策略游戏。"
got := FormatIMDisplayContent(raw, StreamDisplayIntermediate)
if !strings.Contains(got, "文明6是回合制策略游戏") {
t.Fatalf("intermediate display should preview answer after think block, got: %q", got)
}
}
func TestFormatIMDisplayContent_final_stripsThinkingAndTools(t *testing.T) {
raw := "<think>\n让我先搜索知识库\n正在调用 搜索关键词...\n搜索关键词:「文明」\n</think>\n\n文明6是一款策略游戏。"
got := FormatIMDisplayContent(raw, StreamDisplayFinal)
want := "文明6是一款策略游戏。"
if got != want {
t.Fatalf("final display = %q, want %q", got, want)
}
}
func TestFormatIMDisplayContent_final_plainAnswerUnchanged(t *testing.T) {
raw := "这是最终答案。"
got := FormatIMDisplayContent(raw, StreamDisplayFinal)
if got != raw {
t.Fatalf("final display = %q, want %q", got, raw)
}
}
func TestFormatIMDisplayContent_final_ragPipelineHidden(t *testing.T) {
raw := "<think>\n正在理解问题...\n已完成问题理解\n正在检索知识库...\n检索知识库:「query」 · 找到 3 个结果\n</think>\n\n根据知识库,答案是 A。"
got := FormatIMDisplayContent(raw, StreamDisplayFinal)
if strings.Contains(got, "问题理解") || strings.Contains(got, "知识库检索") {
t.Fatalf("final display must not contain RAG pipeline steps, got: %q", got)
}
if got != "根据知识库,答案是 A。" {
t.Fatalf("final display = %q", got)
}
}
func TestFormatIMAgentIntermediate_answerFirstBeforeTools(t *testing.T) {
parts := IMStreamParts{
Mode: IMStreamModeAgent,
LiveAnswer: "好的,让我先搜索知识库。",
}
got := FormatIMIntermediateFromParts(parts, true)
if got != "好的,让我先搜索知识库。" {
t.Fatalf("should stream as plain answer, got: %q", got)
}
if strings.Contains(got, "思考过程") {
t.Fatal("think header must not appear while answer is live")
}
}
func TestFormatIMAgentIntermediate_retractIntoThinkOnTools(t *testing.T) {
parts := IMStreamParts{
Mode: IMStreamModeAgent,
AgentInner: "好的,让我先搜索知识库。\n",
AgentToolSteps: []IMToolStep{
{ToolName: "grep_chunks", Pending: true},
{ToolName: "knowledge_search", Success: true, Arguments: map[string]any{"query": "文明6"}},
},
}
got := FormatIMIntermediateFromParts(parts, true)
if !strings.Contains(got, "思考过程") {
t.Fatalf("after tool retract should show think block, got: %q", got)
}
if !strings.Contains(got, "好的,让我先搜索知识库") {
t.Fatalf("retracted preamble should be inside think, got: %q", got)
}
if !strings.Contains(got, "搜索关键词") {
t.Fatalf("tool lines should be inside think, got: %q", got)
}
if !strings.Contains(got, "文明6") {
t.Fatalf("tool query should be inside think, got: %q", got)
}
}
func TestFormatIMAgentIntermediate_newAnswerAfterTools(t *testing.T) {
parts := IMStreamParts{
Mode: IMStreamModeAgent,
AgentInner: "好的,让我搜索\n",
AgentToolSteps: []IMToolStep{
{ToolName: "knowledge_search", Success: true, Arguments: map[string]any{"query": "文明6"}},
},
LiveAnswer: "根据检索结果,文明6是…",
}
got := FormatIMIntermediateFromParts(parts, true)
if !strings.Contains(got, "根据检索结果,文明6是…") {
t.Fatalf("should still stream live answer, got: %q", got)
}
if !strings.Contains(got, "思考过程") {
t.Fatalf("think block should stay visible above answer, got: %q", got)
}
if !strings.Contains(got, "文明6") {
t.Fatalf("tool query should remain in think block, got: %q", got)
}
}
func TestBuildIMStreamRaw_agentInProgress_mergesToolsAndNarrativeIntoThink(t *testing.T) {
parts := IMStreamParts{
Mode: IMStreamModeAgent,
AgentInner: "用户又问文明6\n",
AgentToolSteps: []IMToolStep{
{ToolName: "grep_chunks", Pending: true},
{ToolName: "knowledge_search", Success: true},
},
}
got := FormatIMIntermediateFromParts(parts, true)
if !strings.Contains(got, "搜索关键词") {
t.Fatalf("tool progress should be inside think block, got: %q", got)
}
if !strings.Contains(got, "思考过程") {
t.Fatalf("agent tooling phase should show 思考过程, got: %q", got)
}
}
func TestFormatIMQuickQA_separatesPipelineAndThinking(t *testing.T) {
parts := IMStreamParts{
Mode: IMStreamModeQuickQA,
PipelineToolSteps: []IMToolStep{
{ToolName: "query_understand", Pending: true},
{ToolName: "knowledge_search", Success: true, Arguments: map[string]any{"query": "文明6"}},
},
ReasoningInner: "分析问题意图…",
}
got := FormatIMIntermediateFromParts(parts, false)
if strings.Contains(got, "思考过程") {
t.Fatalf("quick QA should not use agent 思考过程 header, got: %q", got)
}
if !strings.Contains(got, "> 💭 **思考**") {
t.Fatalf("quick QA reasoning should use separate 思考 section, got: %q", got)
}
if !strings.Contains(got, "分析问题意图") {
t.Fatalf("reasoning body missing, got: %q", got)
}
if !strings.Contains(got, "正在理解问题") {
t.Fatalf("pipeline steps missing, got: %q", got)
}
if !strings.Contains(got, "文明6") {
t.Fatalf("pipeline query missing, got: %q", got)
}
}
func TestFormatIMQuickQA_collapsesToAnswerWhenStreaming(t *testing.T) {
parts := IMStreamParts{
Mode: IMStreamModeQuickQA,
PipelineToolSteps: []IMToolStep{
{ToolName: "query_understand", Success: true},
{ToolName: "knowledge_search", Success: true},
},
Answer: "文明6是回合制策略游戏。",
}
got := FormatIMIntermediateFromParts(parts, false)
if got != "文明6是回合制策略游戏。" {
t.Fatalf("quick QA should collapse to answer preview, got: %q", got)
}
}
func TestFormatIMFinalFromParts_agentAnswerOnly(t *testing.T) {
parts := IMStreamParts{
Mode: IMStreamModeAgent,
AgentInner: "好的,让我搜索\n",
AgentToolSteps: []IMToolStep{
{ToolName: "grep_chunks", Pending: true},
{ToolName: "knowledge_search", Success: true},
},
LiveAnswer: "不应出现在最终消息",
Answer: "文明6是一款策略游戏。",
}
got := FormatIMFinalFromParts(parts)
if got != "文明6是一款策略游戏。" {
t.Fatalf("final should be answer-only, got: %q", got)
}
if strings.Contains(got, "思考过程") {
t.Fatalf("final must not include collapsed think header, got: %q", got)
}
}
func TestFormatIMFinalFromParts_usesAnswerOnly(t *testing.T) {
parts := IMStreamParts{
Mode: IMStreamModeQuickQA,
PipelineToolSteps: []IMToolStep{{ToolName: "query_understand", Success: true}},
ReasoningInner: "推理中",
AgentToolSteps: []IMToolStep{{ToolName: "grep_chunks", Pending: true}},
Answer: "文明6是一款策略游戏。",
}
got := FormatIMFinalFromParts(parts)
if got != "文明6是一款策略游戏。" {
t.Fatalf("final display = %q", got)
}
}
func TestIsRAGPipelineToolName_matchesWeb(t *testing.T) {
for _, name := range []string{"query_understand", "knowledge_search"} {
if !IsRAGPipelineToolName(name) {
t.Fatalf("%q should be a RAG pipeline tool", name)
}
}
if IsRAGPipelineToolName("grep_chunks") {
t.Fatal("grep_chunks is agent tool, not RAG pipeline progress tool")
}
}
+772
View File
@@ -0,0 +1,772 @@
package im
import (
"encoding/json"
"fmt"
"strings"
)
// IMToolStep tracks one tool invocation for IM display (mirrors Web agent stream events).
type IMToolStep struct {
ToolCallID string
ToolName string
Pending bool
Success bool
Arguments map[string]any
Data map[string]interface{}
Output string
}
// imLocalizedToolName returns zh-CN labels aligned with frontend agentStream.tools.
func imLocalizedToolName(toolName string) string {
if name, ok := imToolNameLabels[toolName]; ok {
return name
}
if strings.HasPrefix(toolName, "mcp_") {
return formatMCPToolName(toolName)
}
return toolName
}
var imToolNameLabels = map[string]string{
"search_knowledge": "知识库检索",
"knowledge_search": "知识库检索",
"grep_chunks": "搜索关键词",
"web_search": "网络搜索",
"web_fetch": "网页抓取",
"get_document_info": "获取文档信息",
"list_knowledge_chunks": "查看知识分块",
"get_related_documents": "查找相关文档",
"get_document_content": "获取文档内容",
"wiki_search": "Wiki 搜索",
"wiki_read_page": "Wiki 阅读",
"wiki_read_source_doc": "精读源文档",
"todo_write": "计划管理",
"knowledge_graph_extract": "知识图谱抽取",
"thinking": "思考",
"image_analysis": "查看图片内容",
"query_understand": "理解问题",
"query_knowledge_graph": "知识图谱查询",
"read_skill": "读取技能",
"execute_skill_script": "执行技能脚本",
"list_sandbox_files": "列出沙箱文件",
"read_sandbox_file": "读取沙箱文件",
"write_sandbox_file": "写入沙箱文件",
"edit_sandbox_file": "编辑沙箱文件",
"shell_exec": "执行沙箱命令",
"data_analysis": "数据分析",
"data_schema": "数据结构",
"database_query": "数据库查询",
}
func formatMCPToolName(rawName string) string {
rest := strings.TrimPrefix(rawName, "mcp_")
if rest == "" {
return rawName
}
parts := strings.Split(rest, "_")
for i, p := range parts {
if p == "" {
continue
}
parts[i] = strings.ToUpper(p[:1]) + p[1:]
}
return strings.Join(parts, " ")
}
func collectQueryStrings(value any) []string {
if value == nil {
return nil
}
switch v := value.(type) {
case string:
trimmed := strings.TrimSpace(v)
if trimmed == "" {
return nil
}
if strings.HasPrefix(trimmed, "[") {
var parsed []any
if err := json.Unmarshal([]byte(trimmed), &parsed); err == nil {
var out []string
for _, item := range parsed {
if s, ok := item.(string); ok && strings.TrimSpace(s) != "" {
out = append(out, strings.TrimSpace(s))
}
}
return out
}
}
return []string{trimmed}
case []any:
var out []string
for _, item := range v {
if s, ok := item.(string); ok && strings.TrimSpace(s) != "" {
out = append(out, strings.TrimSpace(s))
}
}
return out
case []string:
var out []string
for _, s := range v {
if strings.TrimSpace(s) != "" {
out = append(out, strings.TrimSpace(s))
}
}
return out
default:
return nil
}
}
func imGetQueryText(args any) string {
if args == nil {
return ""
}
parsed := args
if s, ok := args.(string); ok {
var obj map[string]any
if err := json.Unmarshal([]byte(s), &obj); err != nil {
return ""
}
parsed = obj
}
record, ok := parsed.(map[string]any)
if !ok {
return ""
}
seen := make(map[string]struct{})
var queries []string
for _, q := range collectQueryStrings(record["query"]) {
if _, dup := seen[q]; dup {
continue
}
seen[q] = struct{}{}
queries = append(queries, q)
}
for _, q := range collectQueryStrings(record["queries"]) {
if _, dup := seen[q]; dup {
continue
}
seen[q] = struct{}{}
queries = append(queries, q)
}
return strings.Join(queries, ",")
}
func imGetWikiPageText(args any) string {
if args == nil {
return ""
}
parsed := args
if s, ok := args.(string); ok {
var obj map[string]any
if err := json.Unmarshal([]byte(s), &obj); err != nil {
return ""
}
parsed = obj
}
record, ok := parsed.(map[string]any)
if !ok {
return ""
}
seen := make(map[string]struct{})
var slugs []string
for _, slug := range collectQueryStrings(record["slug"]) {
if _, dup := seen[slug]; dup {
continue
}
seen[slug] = struct{}{}
slugs = append(slugs, slug)
}
for _, slug := range collectQueryStrings(record["slugs"]) {
if _, dup := seen[slug]; dup {
continue
}
seen[slug] = struct{}{}
slugs = append(slugs, slug)
}
return strings.Join(slugs, "、")
}
func imGetGrepPatterns(args any) []string {
if args == nil {
return nil
}
record, ok := args.(map[string]any)
if !ok {
return nil
}
if queries := collectQueryStrings(record["queries"]); len(queries) > 0 {
return queries
}
if patterns := collectQueryStrings(record["patterns"]); len(patterns) > 0 {
return patterns
}
if q := imGetQueryText(record); q != "" {
return []string{q}
}
if pattern, ok := record["pattern"].(string); ok && strings.TrimSpace(pattern) != "" {
return []string{strings.TrimSpace(pattern)}
}
return nil
}
func imGetWebSearchQuery(step IMToolStep) string {
if q := imGetQueryText(step.Arguments); q != "" {
return q
}
return imGetQueryText(step.Data)
}
func imGetGrepPatternsFromStep(step IMToolStep) []string {
if patterns := imGetGrepPatterns(step.Arguments); len(patterns) > 0 {
return patterns
}
return imGetGrepPatterns(step.Data)
}
func imAppendQueryTitle(base, query string) string {
if query == "" {
return base
}
return fmt.Sprintf("%s:「%s」", base, query)
}
func imAppendPatternsTitle(base string, patterns []string) string {
if len(patterns) == 0 {
return base
}
display := patterns
more := ""
if len(patterns) > 2 {
display = patterns[:2]
more = fmt.Sprintf(" +%d", len(patterns)-2)
}
return fmt.Sprintf("%s:「%s%s」", base, strings.Join(display, "、"), more)
}
// FormatIMToolLine formats one agent tool step (no emoji; aligned with Web getToolTitle).
func FormatIMToolLine(step IMToolStep) string {
title := imAgentToolTitle(step)
if title == "" {
return ""
}
if step.Pending {
return title
}
if summary := imToolResultSummary(step); summary != "" {
return title + " · " + summary
}
return title
}
// FormatIMRagPipelineLine formats quick-QA RAG pipeline steps (Web RagPipelineProgress).
func FormatIMRagPipelineLine(step IMToolStep) string {
toolName := step.ToolName
query := imGetQueryText(step.Arguments)
if query == "" {
query = imGetQueryText(step.Data)
}
switch toolName {
case "query_understand":
if step.Pending {
return "正在理解问题..."
}
return "已完成问题理解"
case "knowledge_search", "search_knowledge":
source := imRetrievalSearchSource(step)
if step.Pending {
switch source {
case imRetrievalSourceWeb:
if query != "" {
return fmt.Sprintf("正在检索网络:「%s」", query)
}
return "正在检索网络..."
case imRetrievalSourceMixed:
if query != "" {
return fmt.Sprintf("正在检索知识库和网络:「%s」", query)
}
return "正在检索知识库和网络..."
default:
if query != "" {
return fmt.Sprintf("正在检索知识库:「%s」", query)
}
return "正在检索知识库..."
}
}
base := imRetrievalDoneTitle(source, step.Success)
line := imAppendQueryTitle(base, query)
if summary := imKnowledgeSearchSummary(step.Data); summary != "" {
return line + " · " + summary
}
return line
default:
return ""
}
}
func imSandboxMutationTitle(step IMToolStep, pending bool) string {
name := imLocalizedToolName(step.ToolName)
path := imSandboxFilePath(step)
stat := imSandboxDiffStat(step)
base := name
if pending {
base = name + "..."
}
if path != "" {
base = imAppendQueryTitle(name, path)
if pending {
base += "..."
}
}
if stat != "" {
return base + " " + stat
}
return base
}
func imSandboxFilePath(step IMToolStep) string {
if step.Data != nil {
if p, ok := step.Data["path"].(string); ok && strings.TrimSpace(p) != "" {
return strings.TrimSpace(p)
}
}
if step.Arguments != nil {
if p, ok := step.Arguments["path"].(string); ok && strings.TrimSpace(p) != "" {
return strings.TrimSpace(p)
}
}
return ""
}
func imSandboxDiffStat(step IMToolStep) string {
added := imIntField(step.Arguments, "added_lines")
removed := imIntField(step.Arguments, "removed_lines")
if added == 0 && removed == 0 {
added = imIntField(step.Data, "added_lines")
removed = imIntField(step.Data, "removed_lines")
}
switch {
case added > 0 && removed > 0:
return fmt.Sprintf("+%d -%d", added, removed)
case added > 0:
return fmt.Sprintf("+%d", added)
case removed > 0:
return fmt.Sprintf("-%d", removed)
default:
return ""
}
}
func imAgentToolTitle(step IMToolStep) string {
if step.Pending {
switch step.ToolName {
case "image_analysis":
return "正在查看图片内容..."
case "wiki_search", "wiki_read_page":
return imLocalizedToolName(step.ToolName) + "..."
case "write_sandbox_file", "edit_sandbox_file":
return imSandboxMutationTitle(step, true)
default:
return fmt.Sprintf("正在调用 %s...", imLocalizedToolName(step.ToolName))
}
}
toolName := step.ToolName
isSearchTool := toolName == "search_knowledge" || toolName == "knowledge_search" || toolName == "wiki_search"
if isSearchTool {
base := imToolStatusDescription(step)
query := imGetQueryText(step.Arguments)
if query == "" {
query = imGetQueryText(step.Data)
}
return imAppendQueryTitle(base, query)
}
if toolName == "web_search" {
base := imToolStatusDescription(step)
return imAppendQueryTitle(base, imGetWebSearchQuery(step))
}
if toolName == "grep_chunks" {
base := imToolStatusDescription(step)
return imAppendPatternsTitle(base, imGetGrepPatternsFromStep(step))
}
if toolName == "wiki_read_page" {
pageLabel := ""
if step.Data != nil {
if title, ok := step.Data["title"].(string); ok {
pageLabel = strings.TrimSpace(title)
}
}
if pageLabel == "" {
pageLabel = imGetWikiPageText(step.Arguments)
}
if pageLabel == "" {
pageLabel = imGetWikiPageText(step.Data)
}
base := imToolStatusDescription(step)
return imAppendQueryTitle(base, pageLabel)
}
if toolName == "write_sandbox_file" || toolName == "edit_sandbox_file" {
return imSandboxMutationTitle(step, false)
}
if summary := imToolHeaderSummary(step); summary != "" {
return summary
}
return imToolStatusDescription(step)
}
func imToolStatusDescription(step IMToolStep) string {
success := step.Success
toolName := step.ToolName
switch toolName {
case "search_knowledge", "knowledge_search":
if success {
return "检索知识库"
}
return "检索知识库失败"
case "wiki_search", "wiki_read_page":
name := imLocalizedToolName(toolName)
if success {
return name
}
return fmt.Sprintf("调用 %s 失败", name)
case "web_search":
if success {
return "网络搜索"
}
return "网络搜索失败"
case "grep_chunks":
if success {
return "搜索关键词"
}
return "搜索关键词失败"
case "get_document_info":
if success {
return "获取文档信息"
}
return "获取文档信息失败"
case "get_document_content", "wiki_read_source_doc":
if success {
return "获取文档内容"
}
return "获取文档内容失败"
case "thinking":
if success {
return "完成思考"
}
return "思考失败"
case "todo_write":
if success {
return "更新任务列表"
}
return "更新任务列表失败"
case "image_analysis":
if success {
return "已查看图片内容"
}
return "图片内容查看失败"
case "query_understand":
if success {
return "已完成问题理解"
}
return fmt.Sprintf("调用 %s 失败", imLocalizedToolName(toolName))
default:
name := imLocalizedToolName(toolName)
if success {
return fmt.Sprintf("调用 %s", name)
}
return fmt.Sprintf("调用 %s 失败", name)
}
}
func imToolHeaderSummary(step IMToolStep) string {
if step.Pending || !step.Success {
return ""
}
toolName := step.ToolName
data := step.Data
switch toolName {
case "search_knowledge", "knowledge_search":
return ""
case "get_document_info":
if data != nil {
if title, ok := data["title"].(string); ok && strings.TrimSpace(title) != "" {
return fmt.Sprintf("获取文档:%s", strings.TrimSpace(title))
}
}
case "list_knowledge_chunks":
if data != nil {
if question, ok := data["faq_question"].(string); ok && strings.TrimSpace(question) != "" {
return fmt.Sprintf("查看 FAQ:%s", strings.TrimSpace(question))
}
if _, ok := data["fetched_chunks"]; ok {
title := "文档"
if t, ok := data["knowledge_title"].(string); ok && strings.TrimSpace(t) != "" {
title = strings.TrimSpace(t)
} else if id, ok := data["knowledge_id"].(string); ok && strings.TrimSpace(id) != "" {
title = strings.TrimSpace(id)
}
return fmt.Sprintf("查看 %s", title)
}
}
}
return ""
}
func imToolResultSummary(step IMToolStep) string {
if step.Pending || !step.Success {
return ""
}
switch step.ToolName {
case "search_knowledge", "knowledge_search":
return imKnowledgeSearchSummary(step.Data)
case "web_search":
return imWebSearchSummary(step.Data)
case "grep_chunks":
return imGrepSearchSummary(step.Data)
case "list_knowledge_chunks":
return imKnowledgeChunksSummary(step.Data)
default:
return briefToolSummary(step.Output)
}
}
const (
imRetrievalSourceKnowledge = "knowledge"
imRetrievalSourceWeb = "web"
imRetrievalSourceMixed = "mixed"
)
func imRetrievalSearchSource(step IMToolStep) string {
if source := imSearchSourceFromData(step.Data); source != "" {
return source
}
if step.Arguments != nil {
if source, ok := step.Arguments["search_source"].(string); ok && source != "" {
return source
}
}
return imRetrievalSourceKnowledge
}
func imSearchSourceFromData(data map[string]interface{}) string {
if data == nil {
return ""
}
if source, ok := data["search_source"].(string); ok {
return strings.TrimSpace(source)
}
return ""
}
func imRetrievalDoneTitle(source string, success bool) string {
switch source {
case imRetrievalSourceWeb:
if success {
return "网络检索"
}
return "网络检索失败"
case imRetrievalSourceMixed:
if success {
return "检索知识库和网络"
}
return "检索失败"
default:
if success {
return "检索知识库"
}
return "检索知识库失败"
}
}
func imIntField(data map[string]interface{}, key string) int {
if data == nil {
return 0
}
switch v := data[key].(type) {
case int:
return v
case int64:
return int(v)
case float64:
return int(v)
default:
return 0
}
}
func imKnowledgeSearchSummary(data map[string]interface{}) string {
if data == nil {
return ""
}
count := imResultCount(data)
if count == 0 {
return "未找到匹配的内容"
}
source := imSearchSourceFromData(data)
webCount := imIntField(data, "web_count")
docCount := imIntField(data, "doc_count")
if source == imRetrievalSourceWeb || (webCount > 0 && docCount == 0) {
return fmt.Sprintf("找到 %d 条网页", count)
}
if kbCounts, ok := data["kb_counts"].(map[string]interface{}); ok && len(kbCounts) > 0 {
return fmt.Sprintf("找到 %d 个结果,来自 %d 个文件", count, len(kbCounts))
}
if source == imRetrievalSourceMixed && docCount > 0 && webCount > 0 {
return fmt.Sprintf("找到 %d 个结果(%d 篇文档,%d 条网页)", count, docCount, webCount)
}
return fmt.Sprintf("找到 %d 个结果", count)
}
func imWebSearchSummary(data map[string]interface{}) string {
if data == nil {
return ""
}
count := imResultCount(data)
if count == 0 {
return ""
}
return fmt.Sprintf("找到 %d 个网络搜索结果", count)
}
func imGrepSearchSummary(data map[string]interface{}) string {
if data == nil {
return ""
}
totalChunks := 0
if v, ok := data["total_matches"].(float64); ok {
totalChunks = int(v)
} else if v, ok := data["total_matches"].(int); ok {
totalChunks = v
}
if totalChunks == 0 {
return "未找到匹配的内容"
}
docCount := imGrepDocumentCount(data)
return fmt.Sprintf("找到 %d 个匹配片段,来自 %d 个文档", totalChunks, docCount)
}
func imGrepDocumentCount(data map[string]interface{}) int {
if v, ok := data["document_count"].(float64); ok && v >= 0 {
return int(v)
}
if v, ok := data["document_count"].(int); ok && v >= 0 {
return v
}
if results, ok := data["knowledge_results"].([]interface{}); ok && len(results) > 0 {
return len(results)
}
if results, ok := data["chunk_results"].([]interface{}); ok && len(results) > 0 {
return len(results)
}
return 0
}
func imKnowledgeChunksSummary(data map[string]interface{}) string {
if data == nil {
return ""
}
fetched, ok := data["fetched_chunks"]
if !ok {
return ""
}
fetchedN := imNumericValue(fetched)
totalN := imNumericValue(data["total_chunks"])
summary := fmt.Sprintf("已加载 %d / %v 个分块", fetchedN, formatIMOptionalInt(totalN, data["total_chunks"]))
pageSize := imNumericValue(data["page_size"])
if totalN > pageSize && pageSize > 0 {
page := imNumericValue(data["page"])
if page <= 0 {
page = 1
}
summary += fmt.Sprintf(" · 第 %d 页,每页 %d 个", page, pageSize)
}
return summary
}
func imResultCount(data map[string]interface{}) int {
if results, ok := data["results"].([]interface{}); ok && len(results) > 0 {
return len(results)
}
if count, ok := data["count"].(float64); ok && count > 0 {
return int(count)
}
if count, ok := data["count"].(int); ok && count > 0 {
return count
}
return 0
}
func imNumericValue(v any) int {
switch n := v.(type) {
case int:
return n
case int64:
return int(n)
case float64:
return int(n)
default:
return 0
}
}
func formatIMOptionalInt(n int, raw any) string {
if raw == nil {
return "?"
}
if _, ok := raw.(string); ok && n == 0 {
return "?"
}
if n == 0 {
return "?"
}
return fmt.Sprintf("%d", n)
}
func renderIMToolSteps(steps []IMToolStep, format func(IMToolStep) string) string {
if len(steps) == 0 {
return ""
}
var b strings.Builder
for _, step := range steps {
line := format(step)
if line == "" {
continue
}
b.WriteString(line)
b.WriteByte('\n')
}
return strings.TrimRight(b.String(), "\n")
}
func mergeIMNarrativeAndTools(narrative string, toolLines string) string {
narrative = strings.TrimSpace(narrative)
toolLines = strings.TrimSpace(toolLines)
switch {
case narrative != "" && toolLines != "":
return narrative + "\n" + toolLines
case toolLines != "":
return toolLines
default:
return narrative
}
}
func upsertIMToolStep(steps *[]IMToolStep, index map[string]int, id string, update func(*IMToolStep)) {
if i, ok := index[id]; ok {
update(&(*steps)[i])
return
}
step := IMToolStep{ToolCallID: id}
update(&step)
index[id] = len(*steps)
*steps = append(*steps, step)
}
+118
View File
@@ -0,0 +1,118 @@
package im
import (
"strings"
"testing"
)
func TestFormatIMToolLine_pendingWithQuery(t *testing.T) {
line := FormatIMToolLine(IMToolStep{
ToolName: "knowledge_search",
Pending: true,
Arguments: map[string]any{"query": "文明6"},
})
if line != "正在调用 知识库检索..." {
t.Fatalf("pending line = %q", line)
}
}
func TestFormatIMToolLine_searchDoneWithQueryAndSummary(t *testing.T) {
line := FormatIMToolLine(IMToolStep{
ToolName: "knowledge_search",
Success: true,
Arguments: map[string]any{
"query": "文明6",
},
Data: map[string]interface{}{
"results": []interface{}{map[string]interface{}{}, map[string]interface{}{}, map[string]interface{}{}},
"kb_counts": map[string]interface{}{"a": 1, "b": 1},
},
})
if !strings.Contains(line, "检索知识库:「文明6」") {
t.Fatalf("title missing query: %q", line)
}
if !strings.Contains(line, "找到 3 个结果,来自 2 个文件") {
t.Fatalf("summary missing: %q", line)
}
}
func TestFormatIMToolLine_grepPatterns(t *testing.T) {
line := FormatIMToolLine(IMToolStep{
ToolName: "grep_chunks",
Success: true,
Arguments: map[string]any{
"patterns": []any{"文明", "策略"},
},
Data: map[string]interface{}{
"total_matches": float64(5),
"document_count": float64(2),
},
})
if line != "搜索关键词:「文明、策略」 · 找到 5 个匹配片段,来自 2 个文档" {
t.Fatalf("grep line = %q", line)
}
}
func TestFormatIMRagPipelineLine_queryUnderstand(t *testing.T) {
pending := FormatIMRagPipelineLine(IMToolStep{
ToolName: "query_understand",
Pending: true,
})
if pending != "正在理解问题..." {
t.Fatalf("pending = %q", pending)
}
done := FormatIMRagPipelineLine(IMToolStep{
ToolName: "query_understand",
Success: true,
})
if done != "已完成问题理解" {
t.Fatalf("done = %q", done)
}
}
func TestFormatIMRagPipelineLine_searchWithQuery(t *testing.T) {
line := FormatIMRagPipelineLine(IMToolStep{
ToolName: "knowledge_search",
Pending: true,
Arguments: map[string]any{"query": "讯飞开放平台"},
})
if line != "正在检索知识库:「讯飞开放平台」" {
t.Fatalf("line = %q", line)
}
}
func TestFormatIMRagPipelineLine_webSearchWithQuery(t *testing.T) {
line := FormatIMRagPipelineLine(IMToolStep{
ToolName: "knowledge_search",
Pending: true,
Arguments: map[string]any{"query": "任素汐演唱会", "search_source": "web"},
})
if line != "正在检索网络:「任素汐演唱会」" {
t.Fatalf("line = %q", line)
}
}
func TestIMGetQueryText_joinsUniqueQueries(t *testing.T) {
got := imGetQueryText(map[string]any{
"query": "foo",
"queries": []any{"foo", "bar"},
})
if got != "foo,bar" {
t.Fatalf("query text = %q", got)
}
}
func TestFormatIMToolLine_writeSandboxPendingShowsDiffStat(t *testing.T) {
line := FormatIMToolLine(IMToolStep{
ToolName: "write_sandbox_file",
Pending: true,
Arguments: map[string]any{
"path": "/workspace/output/a.py",
"added_lines": 12,
"removed_lines": 0,
},
})
if line != "写入沙箱文件:「/workspace/output/a.py」... +12" {
t.Fatalf("pending write line = %q", line)
}
}
+250
View File
@@ -0,0 +1,250 @@
package im
import (
"crypto/sha256"
"encoding/json"
"fmt"
"net/url"
"strings"
"time"
"github.com/Tencent/WeKnora/internal/types"
"github.com/google/uuid"
"gorm.io/gorm"
)
// IMChannel represents an IM channel configuration stored in the database.
// Each channel binds to an agent and contains platform-specific credentials.
type IMChannel struct {
ID string `json:"id" gorm:"type:varchar(36);primaryKey;default:uuid_generate_v4()"`
TenantID uint64 `json:"tenant_id" gorm:"not null;index:idx_im_channels_tenant"`
AgentID string `json:"agent_id" gorm:"type:varchar(36);not null;index:idx_im_channels_agent"`
Platform string `json:"platform" gorm:"type:varchar(20);not null"`
Name string `json:"name" gorm:"type:varchar(255);not null;default:''"`
Enabled bool `json:"enabled" gorm:"not null"`
Mode string `json:"mode" gorm:"type:varchar(20);not null;default:'websocket'"`
OutputMode string `json:"output_mode" gorm:"type:varchar(20);not null;default:'stream'"`
KnowledgeBaseID string `json:"knowledge_base_id" gorm:"type:varchar(36);default:''"`
BotIdentity string `json:"bot_identity" gorm:"type:varchar(255);not null;default:'';uniqueIndex:idx_im_channels_bot_identity,where:deleted_at IS NULL AND bot_identity != ''"`
SessionMode string `json:"session_mode" gorm:"type:varchar(20);not null;default:'user'"`
Credentials types.JSON `json:"credentials" gorm:"type:jsonb;not null;default:'{}'"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
}
func (IMChannel) TableName() string {
return "im_channels"
}
// IMChannelSummary is the HTTP-safe list shape for IM channels. Credentials
// are never included — use Admin+ Create/Update responses to read back
// values immediately after a mutation.
type IMChannelSummary struct {
ID string `json:"id"`
TenantID uint64 `json:"tenant_id"`
AgentID string `json:"agent_id"`
Platform string `json:"platform"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
Mode string `json:"mode"`
OutputMode string `json:"output_mode"`
KnowledgeBaseID string `json:"knowledge_base_id"`
BotIdentity string `json:"bot_identity"`
SessionMode string `json:"session_mode"`
CredentialsConfigured bool `json:"credentials_configured"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// SummarizeIMChannel converts a stored channel into its list response shape.
func SummarizeIMChannel(ch IMChannel) IMChannelSummary {
return IMChannelSummary{
ID: ch.ID,
TenantID: ch.TenantID,
AgentID: ch.AgentID,
Platform: ch.Platform,
Name: ch.Name,
Enabled: ch.Enabled,
Mode: ch.Mode,
OutputMode: ch.OutputMode,
KnowledgeBaseID: ch.KnowledgeBaseID,
BotIdentity: ch.BotIdentity,
SessionMode: ch.SessionMode,
CredentialsConfigured: imCredentialsConfigured(ch.Credentials),
CreatedAt: ch.CreatedAt,
UpdatedAt: ch.UpdatedAt,
}
}
// SummarizeIMChannels converts stored channels into list response shapes.
func SummarizeIMChannels(channels []IMChannel) []IMChannelSummary {
out := make([]IMChannelSummary, 0, len(channels))
for _, ch := range channels {
out = append(out, SummarizeIMChannel(ch))
}
return out
}
func imCredentialsConfigured(cred types.JSON) bool {
s := strings.TrimSpace(string(cred))
return s != "" && s != "{}"
}
func (ch *IMChannel) BeforeCreate(tx *gorm.DB) error {
if ch.ID == "" {
ch.ID = uuid.New().String()
}
if ch.Mode == "" {
if ch.Platform == "mattermost" || ch.Platform == "yunzhijia" {
ch.Mode = "webhook"
} else {
ch.Mode = "websocket"
}
}
if ch.OutputMode == "" {
ch.OutputMode = "stream"
}
if ch.SessionMode == "" {
ch.SessionMode = string(SessionModeUser)
}
if err := ch.validateSessionMode(); err != nil {
return err
}
ch.BotIdentity = ch.computeBotIdentity()
return nil
}
// BeforeSave ensures bot_identity is recomputed and session_mode is validated
// on every save (create + update).
func (ch *IMChannel) BeforeSave(tx *gorm.DB) error {
if ch.SessionMode == "" {
ch.SessionMode = string(SessionModeUser)
}
if err := ch.validateSessionMode(); err != nil {
return err
}
ch.BotIdentity = ch.computeBotIdentity()
return nil
}
// validateSessionMode checks that SessionMode holds a supported value.
func (ch *IMChannel) validateSessionMode() error {
switch SessionMode(ch.SessionMode) {
case SessionModeUser, SessionModeThread:
return nil
default:
return fmt.Errorf("invalid session_mode: %s", ch.SessionMode)
}
}
// computeBotIdentity derives a unique bot identity string from the channel's
// platform, mode, and credentials. Returns "" if no identity can be extracted.
func (ch *IMChannel) computeBotIdentity() string {
creds := make(map[string]interface{})
if err := json.Unmarshal([]byte(ch.Credentials), &creds); err != nil {
return ""
}
str := func(key string) string {
if v, ok := creds[key]; ok {
switch val := v.(type) {
case string:
return val
case float64:
return fmt.Sprintf("%.0f", val)
}
}
return ""
}
switch ch.Platform {
case "wecom":
switch ch.Mode {
case "websocket":
if botID := str("bot_id"); botID != "" {
return "wecom:ws:" + botID
}
case "webhook":
corpID := str("corp_id")
agentID := str("corp_agent_id")
if corpID != "" && agentID != "" {
return "wecom:wh:" + corpID + ":" + agentID
}
}
// Feishu and Lark app_ids live in separate clouds and never collide, so the
// platform prefix keeps the same app_id on both from looking like one bot.
case "feishu", "lark":
if appID := str("app_id"); appID != "" {
return ch.Platform + ":" + appID
}
case "telegram":
if botToken := str("bot_token"); botToken != "" {
// Use the bot ID part (before the colon) as identity.
if idx := strings.Index(botToken, ":"); idx > 0 {
return "telegram:" + botToken[:idx]
}
return "telegram:" + botToken
}
case "dingtalk":
if clientID := str("client_id"); clientID != "" {
return "dingtalk:" + clientID
}
case "mattermost":
if tok := str("outgoing_token"); tok != "" {
return "mattermost:wh:" + tok
}
case "wechat":
if botID := str("ilink_bot_id"); botID != "" {
return "wechat:" + botID
}
case "qqbot":
if appID := str("app_id"); appID != "" {
return "qqbot:" + appID
}
case "yunzhijia":
if sendMsgURL := str("send_msg_url"); sendMsgURL != "" {
parsed, err := url.Parse(sendMsgURL)
if err != nil {
return ""
}
if token := strings.TrimSpace(parsed.Query().Get("yzjtoken")); token != "" {
return fmt.Sprintf("yunzhijia:%x", sha256.Sum256([]byte(token)))
}
}
}
return ""
}
// ChannelSession maps an IM channel (user+chat combination) to a WeKnora session.
// This allows the IM integration to maintain conversation continuity.
type ChannelSession struct {
ID string `json:"id" gorm:"type:varchar(36);primaryKey;default:uuid_generate_v4()"`
Platform string `json:"platform" gorm:"type:varchar(20);not null"`
UserID string `json:"user_id" gorm:"type:varchar(128);not null"`
ChatID string `json:"chat_id" gorm:"type:varchar(128);not null;default:''"`
ThreadID string `json:"thread_id" gorm:"type:varchar(128);not null;default:''"`
SessionID string `json:"session_id" gorm:"type:varchar(36);not null;index"`
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
AgentID string `json:"agent_id" gorm:"type:varchar(36);default:''"`
IMChannelID string `json:"im_channel_id" gorm:"type:varchar(36);default:''"`
Status string `json:"status" gorm:"type:varchar(20);not null;default:'active'"`
Metadata types.JSON `json:"metadata" gorm:"type:jsonb;default:'{}'"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
}
func (ChannelSession) TableName() string {
return "im_channel_sessions"
}
func (cs *ChannelSession) BeforeCreate(tx *gorm.DB) error {
if cs.ID == "" {
cs.ID = uuid.New().String()
}
if cs.Status == "" {
cs.Status = "active"
}
return nil
}
+34
View File
@@ -0,0 +1,34 @@
package im
import (
"encoding/json"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSummarizeIMChannel_OmitsCredentials(t *testing.T) {
ch := IMChannel{
ID: "ch-1",
TenantID: 1,
AgentID: "agent-1",
Platform: "feishu",
Name: "support",
Credentials: types.JSON(`{"app_secret":"top-secret"}`),
}
summary := SummarizeIMChannel(ch)
body, err := json.Marshal(summary)
require.NoError(t, err)
assert.True(t, summary.CredentialsConfigured)
assert.NotContains(t, string(body), "top-secret")
assert.NotContains(t, string(body), `"credentials":`)
}
func TestSummarizeIMChannel_EmptyCredentialsNotConfigured(t *testing.T) {
summary := SummarizeIMChannel(IMChannel{Credentials: types.JSON("{}")})
assert.False(t, summary.CredentialsConfigured)
}
+223
View File
@@ -0,0 +1,223 @@
package im
import (
"crypto/sha256"
"fmt"
"testing"
"gorm.io/gorm"
)
func TestValidateSessionMode(t *testing.T) {
tests := []struct {
name string
sessionMode string
wantErr bool
}{
{"user mode", "user", false},
{"thread mode", "thread", false},
{"empty defaults to user in BeforeCreate", "", false},
{"invalid mode", "invalid", true},
{"random string", "foo", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ch := &IMChannel{SessionMode: tt.sessionMode}
err := ch.validateSessionMode()
if tt.sessionMode == "" {
// empty is handled by BeforeCreate, not validateSessionMode
// validateSessionMode treats empty as invalid
if err == nil {
t.Error("expected error for empty session_mode in validateSessionMode")
}
return
}
if (err != nil) != tt.wantErr {
t.Errorf("validateSessionMode(%q) error = %v, wantErr %v", tt.sessionMode, err, tt.wantErr)
}
})
}
}
func TestIMChannelBeforeCreate_SessionModeDefault(t *testing.T) {
tests := []struct {
name string
inputMode string
expectedMode string
}{
{"empty defaults to user", "", "user"},
{"user preserved", "user", "user"},
{"thread preserved", "thread", "thread"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ch := &IMChannel{
TenantID: 1,
AgentID: "agent-1",
Platform: "slack",
SessionMode: tt.inputMode,
Credentials: []byte("{}"),
}
err := ch.BeforeCreate(&gorm.DB{})
if err != nil {
t.Fatalf("BeforeCreate error: %v", err)
}
if ch.SessionMode != tt.expectedMode {
t.Errorf("SessionMode = %q, want %q", ch.SessionMode, tt.expectedMode)
}
})
}
}
func TestIMChannelBeforeCreate_InvalidSessionMode(t *testing.T) {
ch := &IMChannel{
TenantID: 1,
AgentID: "agent-1",
Platform: "slack",
SessionMode: "invalid",
Credentials: []byte("{}"),
}
err := ch.BeforeCreate(&gorm.DB{})
if err == nil {
t.Error("expected error for invalid session_mode")
}
}
func TestIMChannelBeforeSave_SessionModeValidation(t *testing.T) {
tests := []struct {
name string
sessionMode string
wantMode string
wantErr bool
}{
{"empty defaults to user", "", "user", false},
{"user preserved", "user", "user", false},
{"thread preserved", "thread", "thread", false},
{"invalid rejected", "invalid", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ch := &IMChannel{
SessionMode: tt.sessionMode,
Credentials: []byte("{}"),
}
err := ch.BeforeSave(&gorm.DB{})
if tt.wantErr {
if err == nil {
t.Error("expected error")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ch.SessionMode != tt.wantMode {
t.Errorf("SessionMode = %q, want %q", ch.SessionMode, tt.wantMode)
}
})
}
}
func TestSessionModeConstants(t *testing.T) {
if SessionModeUser != "user" {
t.Errorf("SessionModeUser = %q, want %q", SessionModeUser, "user")
}
if SessionModeThread != "thread" {
t.Errorf("SessionModeThread = %q, want %q", SessionModeThread, "thread")
}
}
// BotIdentity carries a unique index, so Feishu and Lark channels must never
// derive the same identity — otherwise a Lark bot would be rejected as a
// duplicate of an unrelated Feishu bot that happens to share an app_id.
func TestComputeBotIdentity_FeishuAndLarkAreDistinct(t *testing.T) {
const creds = `{"app_id":"cli_a1b2c3","app_secret":"s"}`
feishu := &IMChannel{Platform: "feishu", Credentials: []byte(creds)}
lark := &IMChannel{Platform: "lark", Credentials: []byte(creds)}
feishuID := feishu.computeBotIdentity()
larkID := lark.computeBotIdentity()
if feishuID != "feishu:cli_a1b2c3" {
t.Errorf("feishu identity = %q, want %q", feishuID, "feishu:cli_a1b2c3")
}
if larkID != "lark:cli_a1b2c3" {
t.Errorf("lark identity = %q, want %q", larkID, "lark:cli_a1b2c3")
}
if feishuID == larkID {
t.Errorf("feishu and lark share identity %q", feishuID)
}
}
func TestComputeBotIdentity_LarkWithoutAppID(t *testing.T) {
ch := &IMChannel{Platform: "lark", Credentials: []byte(`{"app_secret":"s"}`)}
if got := ch.computeBotIdentity(); got != "" {
t.Errorf("identity = %q, want empty when app_id is missing", got)
}
}
func TestIMChannelComputeBotIdentity_YunzhijiaUsesYZJToken(t *testing.T) {
makeChannel := func(sendMsgURL string) *IMChannel {
return &IMChannel{
Platform: "yunzhijia",
Credentials: []byte(fmt.Sprintf(
`{"send_msg_url":%q}`,
sendMsgURL,
)),
}
}
first := makeChannel("https://open.yunzhijia.com/gateway/robot/webhook/send?yzjtoken=token-a&foo=1")
second := makeChannel("https://open.yunzhijia.com/gateway/robot/webhook/send?yzjtoken=token-b&foo=1")
firstWant := fmt.Sprintf("yunzhijia:%x", sha256.Sum256([]byte("token-a")))
secondWant := fmt.Sprintf("yunzhijia:%x", sha256.Sum256([]byte("token-b")))
if first.computeBotIdentity() != firstWant {
t.Errorf("first identity = %q, want %q", first.computeBotIdentity(), firstWant)
}
if second.computeBotIdentity() != secondWant {
t.Errorf("second identity = %q, want %q", second.computeBotIdentity(), secondWant)
}
if first.computeBotIdentity() == second.computeBotIdentity() {
t.Error("different yzjtoken values must produce different Yunzhijia bot identities")
}
sameTokenDifferentQuery := makeChannel(
"https://open.yunzhijia.com/gateway/robot/webhook/send?foo=2&yzjtoken=token-a",
)
if sameTokenDifferentQuery.computeBotIdentity() != first.computeBotIdentity() {
t.Error("same yzjtoken must produce the same Yunzhijia bot identity")
}
missingToken := makeChannel("https://open.yunzhijia.com/gateway/robot/webhook/send?foo=1")
if missingToken.computeBotIdentity() != "" {
t.Errorf("missing yzjtoken identity = %q, want empty", missingToken.computeBotIdentity())
}
}
func TestChannelSessionThreadIDField(t *testing.T) {
cs := ChannelSession{
Platform: "slack",
UserID: "U123",
ChatID: "C456",
ThreadID: "1234567890.123456",
}
if cs.ThreadID != "1234567890.123456" {
t.Errorf("ThreadID = %q, want %q", cs.ThreadID, "1234567890.123456")
}
// empty ThreadID for user-mode sessions
csUser := ChannelSession{
Platform: "slack",
UserID: "U123",
ChatID: "C456",
}
if csUser.ThreadID != "" {
t.Errorf("ThreadID = %q, want empty", csUser.ThreadID)
}
}
+329
View File
@@ -0,0 +1,329 @@
// Adapter implements im.Adapter and im.FileDownloader for WeChat personal
// accounts via the Tencent iLink Bot API.
//
// WeChat iLink uses HTTP long-polling for receiving messages (no WebSocket,
// no Webhook). Sending is done via REST API.
//
// API base: https://ilinkai.weixin.qq.com
// API paths: /ilink/bot/getupdates, /ilink/bot/sendmessage, etc.
// Auth: Bearer token obtained via QR code login flow.
package wechat
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
"github.com/Tencent/WeKnora/internal/im"
"github.com/Tencent/WeKnora/internal/logger"
secutils "github.com/Tencent/WeKnora/internal/utils"
"github.com/gin-gonic/gin"
)
const (
ilinkBaseURL = "https://ilinkai.weixin.qq.com"
// cdnBaseURL is the Weixin CDN base for media download/upload.
cdnBaseURL = "https://novac2c.cdn.weixin.qq.com/c2c"
// defaultBotType is the bot_type for iLink get_bot_qrcode / get_qrcode_status.
defaultBotType = "3"
// channelVersion is sent in base_info with every API request.
channelVersion = "weknora-1.0.0"
)
var ilinkHTTPClient = secutils.NewSSRFSafeHTTPClient(secutils.SSRFSafeHTTPClientConfig{
Timeout: 30 * time.Second,
MaxRedirects: 5,
})
// BuildCDNDownloadURL constructs a CDN download URL from an encrypt_query_param.
func BuildCDNDownloadURL(encryptQueryParam string) string {
return cdnBaseURL + "/download?encrypted_query_param=" + url.QueryEscape(encryptQueryParam)
}
// Compile-time interface checks.
var (
_ im.Adapter = (*Adapter)(nil)
_ im.FileDownloader = (*Adapter)(nil)
)
// baseInfo is included in every outgoing API request body.
type baseInfo struct {
ChannelVersion string `json:"channel_version"`
}
func newBaseInfo() baseInfo {
return baseInfo{ChannelVersion: channelVersion}
}
// Adapter implements im.Adapter for WeChat via iLink Bot API.
type Adapter struct {
botToken string
ilinkBotID string
}
// NewAdapter creates a new WeChat iLink adapter.
func NewAdapter(botToken, ilinkBotID string) *Adapter {
return &Adapter{
botToken: botToken,
ilinkBotID: ilinkBotID,
}
}
func (a *Adapter) Platform() im.Platform {
return im.PlatformWeChat
}
// VerifyCallback is not supported — WeChat iLink uses long-polling, not webhooks.
func (a *Adapter) VerifyCallback(c *gin.Context) error {
return fmt.Errorf("WeChat adapter does not support webhook callbacks")
}
// ParseCallback is not supported — messages arrive via long-polling.
func (a *Adapter) ParseCallback(c *gin.Context) (*im.IncomingMessage, error) {
return nil, fmt.Errorf("WeChat adapter does not support webhook callbacks")
}
// HandleURLVerification is not applicable for WeChat.
func (a *Adapter) HandleURLVerification(c *gin.Context) bool {
return false
}
// SendReply sends a text reply to the user via iLink /ilink/bot/sendmessage API.
func (a *Adapter) SendReply(ctx context.Context, incoming *im.IncomingMessage, reply *im.ReplyMessage) error {
contextToken := ""
if incoming.Extra != nil {
contextToken = incoming.Extra["context_token"]
}
// Build the send message request matching the iLink protocol
payload := map[string]interface{}{
"msg": map[string]interface{}{
"from_user_id": "",
"to_user_id": incoming.UserID,
"client_id": fmt.Sprintf("weknora_%d", time.Now().UnixNano()),
"message_type": 2, // BOT
"message_state": 2, // FINISH
"item_list": []map[string]interface{}{
{
"type": 1, // TEXT
"text_item": map[string]string{"text": reply.Content},
},
},
"context_token": contextToken,
},
"base_info": newBaseInfo(),
}
return a.ilinkPost(ctx, "/ilink/bot/sendmessage", payload)
}
// SendTyping sends a typing indicator to the user.
func (a *Adapter) SendTyping(ctx context.Context, incoming *im.IncomingMessage) error {
userID := incoming.UserID
contextToken := ""
if incoming.Extra != nil {
contextToken = incoming.Extra["context_token"]
}
_ = contextToken // typing may not need context_token
payload := map[string]interface{}{
"ilink_user_id": userID,
"status": 1, // TYPING
"base_info": newBaseInfo(),
}
return a.ilinkPost(ctx, "/ilink/bot/sendtyping", payload)
}
// DownloadFile downloads a media file from the iLink CDN.
// Files are AES-128-ECB encrypted; the key is provided in the message Extra.
func (a *Adapter) DownloadFile(ctx context.Context, msg *im.IncomingMessage) (io.ReadCloser, string, error) {
if msg.FileKey == "" {
return nil, "", fmt.Errorf("no file URL in message")
}
if err := secutils.ValidateURLForSSRF(msg.FileKey); err != nil {
return nil, "", fmt.Errorf("file URL rejected by SSRF policy: %w", err)
}
fileName := msg.FileName
if fileName == "" {
fileName = msg.FileKey
}
// Download the file
req, err := http.NewRequestWithContext(ctx, http.MethodGet, msg.FileKey, nil)
if err != nil {
return nil, "", fmt.Errorf("create download request: %w", err)
}
resp, err := ilinkHTTPClient.Do(req)
if err != nil {
return nil, "", fmt.Errorf("download file: %w", err)
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, "", fmt.Errorf("download failed: status=%d", resp.StatusCode)
}
// If no AES key provided, return raw content
aesKeyB64 := ""
if msg.Extra != nil {
aesKeyB64 = msg.Extra["aes_key"]
}
if aesKeyB64 == "" {
return resp.Body, fileName, nil
}
// Read and decrypt with AES-128-ECB
encryptedData, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return nil, "", fmt.Errorf("read encrypted file: %w", err)
}
// Parse the AES key: base64 → raw bytes (16) or hex string (32 chars → 16 bytes)
aesKey, err := parseAESKey(aesKeyB64)
if err != nil {
return nil, "", fmt.Errorf("parse aes key: %w", err)
}
logger.Debugf(ctx, "[WeChat] Decrypting file: name=%s encrypted_size=%d", fileName, len(encryptedData))
decrypted, err := decryptAES128ECB(encryptedData, aesKey)
if err != nil {
return nil, "", fmt.Errorf("decrypt file: %w", err)
}
return io.NopCloser(bytes.NewReader(decrypted)), fileName, nil
}
// parseAESKey decodes an AES key from various formats seen in iLink responses.
//
// Three formats are encountered:
// 1. base64(raw 16 bytes) → CDNMedia.aes_key for file/voice/video
// 2. base64(hex string of 32 chars) → CDNMedia.aes_key (alternative)
// 3. raw hex string (32 chars) → ImageItem.aeskey field (NOT base64-encoded)
//
// The function auto-detects the format and always returns a 16-byte key.
func parseAESKey(aesKeyStr string) ([]byte, error) {
if aesKeyStr == "" {
return nil, fmt.Errorf("empty aes key")
}
// Case 3: raw hex string (32 hex chars = 16 bytes)
if len(aesKeyStr) == 32 && isHex(aesKeyStr) {
return hexDecode(aesKeyStr)
}
// Case 1 & 2: base64-encoded
decoded, err := base64.StdEncoding.DecodeString(aesKeyStr)
if err != nil {
decoded, err = base64.RawStdEncoding.DecodeString(aesKeyStr)
if err != nil {
// Last resort: maybe it's a hex string of other length
if isHex(aesKeyStr) && len(aesKeyStr)%2 == 0 {
return hexDecode(aesKeyStr)
}
return nil, fmt.Errorf("cannot decode aes key (len=%d): %w", len(aesKeyStr), err)
}
}
// base64 decoded to exactly 16 raw bytes → direct key
if len(decoded) == 16 {
return decoded, nil
}
// base64 decoded to 32 ASCII hex chars → parse hex to get 16 bytes
if len(decoded) == 32 && isHex(string(decoded)) {
return hexDecode(string(decoded))
}
return nil, fmt.Errorf("aes key decoded to %d bytes (expected 16 raw or 32 hex), input len=%d", len(decoded), len(aesKeyStr))
}
// isHex returns true if s contains only hexadecimal characters.
func isHex(s string) bool {
for _, c := range s {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
return false
}
}
return len(s) > 0
}
// hexDecode decodes a hex string to bytes.
func hexDecode(s string) ([]byte, error) {
if len(s)%2 != 0 {
return nil, fmt.Errorf("odd-length hex string: %d", len(s))
}
result := make([]byte, len(s)/2)
for i := 0; i < len(result); i++ {
var b byte
_, err := fmt.Sscanf(s[i*2:i*2+2], "%02x", &b)
if err != nil {
return nil, fmt.Errorf("hex decode at pos %d: %w", i, err)
}
result[i] = b
}
return result, nil
}
// ilinkPost sends a POST request to the iLink API with authentication headers.
func (a *Adapter) ilinkPost(ctx context.Context, path string, payload interface{}) error {
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ilinkBaseURL+path, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
a.setAuthHeaders(req, body)
resp, err := ilinkHTTPClient.Do(req)
if err != nil {
return fmt.Errorf("ilink request %s: %w", path, err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("ilink api %s returned status %d: %s", path, resp.StatusCode, string(respBody))
}
return nil
}
// setAuthHeaders sets the required iLink Bot authentication headers.
func (a *Adapter) setAuthHeaders(req *http.Request, body []byte) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("AuthorizationType", "ilink_bot_token")
if a.botToken != "" {
req.Header.Set("Authorization", "Bearer "+a.botToken)
}
req.Header.Set("X-WECHAT-UIN", generateWeChatUIN())
if body != nil {
req.Header.Set("Content-Length", fmt.Sprintf("%d", len(body)))
}
}
// generateWeChatUIN generates a random X-WECHAT-UIN header value.
// Format: random uint32 → decimal string → base64.
func generateWeChatUIN() string {
buf := make([]byte, 4)
_, _ = rand.Read(buf)
n := binary.BigEndian.Uint32(buf)
return base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%d", n)))
}
+72
View File
@@ -0,0 +1,72 @@
// Package wechat implements the WeChat personal account IM adapter for WeKnora
// via the Tencent iLink Bot API (ilinkai.weixin.qq.com).
package wechat
import (
"crypto/aes"
"fmt"
)
// decryptAES128ECB decrypts data encrypted with AES-128-ECB (no padding removal).
// iLink media files are encrypted with a 16-byte AES key using ECB mode.
func decryptAES128ECB(ciphertext, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("new aes cipher: %w", err)
}
bs := block.BlockSize()
if len(ciphertext) == 0 || len(ciphertext)%bs != 0 {
return nil, fmt.Errorf("ciphertext length %d is not a multiple of block size %d", len(ciphertext), bs)
}
plaintext := make([]byte, len(ciphertext))
for i := 0; i < len(ciphertext); i += bs {
block.Decrypt(plaintext[i:i+bs], ciphertext[i:i+bs])
}
// Remove PKCS#7 padding if present
if len(plaintext) > 0 {
padLen := int(plaintext[len(plaintext)-1])
if padLen > 0 && padLen <= bs && padLen <= len(plaintext) {
valid := true
for i := 0; i < padLen; i++ {
if plaintext[len(plaintext)-1-i] != byte(padLen) {
valid = false
break
}
}
if valid {
plaintext = plaintext[:len(plaintext)-padLen]
}
}
}
return plaintext, nil
}
// encryptAES128ECB encrypts data with AES-128-ECB and PKCS#7 padding.
// Used for uploading media files to the iLink CDN.
func encryptAES128ECB(plaintext, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("new aes cipher: %w", err)
}
bs := block.BlockSize()
// PKCS#7 padding
padLen := bs - len(plaintext)%bs
padded := make([]byte, len(plaintext)+padLen)
copy(padded, plaintext)
for i := len(plaintext); i < len(padded); i++ {
padded[i] = byte(padLen)
}
ciphertext := make([]byte, len(padded))
for i := 0; i < len(padded); i += bs {
block.Encrypt(ciphertext[i:i+bs], padded[i:i+bs])
}
return ciphertext, nil
}
+39
View File
@@ -0,0 +1,39 @@
package wechat
import (
"context"
"fmt"
"github.com/Tencent/WeKnora/internal/im"
"github.com/Tencent/WeKnora/internal/logger"
)
// NewFactory returns an im.AdapterFactory for WeChat channels (iLink bot).
// WeChat only supports a long-polling mode, so there is no mode branch.
func NewFactory() im.AdapterFactory {
return func(factoryCtx context.Context, channel *im.IMChannel, msgHandler func(context.Context, *im.IncomingMessage) error) (im.Adapter, context.CancelFunc, error) {
creds, err := im.ParseCredentials(channel.Credentials)
if err != nil {
return nil, nil, fmt.Errorf("parse wechat credentials: %w", err)
}
botToken := im.GetString(creds, "bot_token")
ilinkBotID := im.GetString(creds, "ilink_bot_id")
if botToken == "" || ilinkBotID == "" {
return nil, nil, fmt.Errorf("wechat credentials require bot_token and ilink_bot_id")
}
adapter := NewAdapter(botToken, ilinkBotID)
client := NewLongPollClient(botToken, ilinkBotID, msgHandler)
pollCtx, pollCancel := context.WithCancel(context.Background())
go func() {
if err := client.Start(pollCtx); err != nil && pollCtx.Err() == nil {
logger.Errorf(context.Background(), "[IM] WeChat long-poll stopped for channel %s: %v", channel.ID, err)
}
}()
return adapter, pollCancel, nil
}
}
+376
View File
@@ -0,0 +1,376 @@
// Long-polling client for the WeChat iLink Bot API.
//
// Flow:
// 1. POST /ilink/bot/getupdates with get_updates_buf + base_info
// 2. Parse response msgs[] into IncomingMessage
// 3. Call msgHandler for each message
// 4. Update cursor (get_updates_buf) for next poll
// 5. On error, exponential backoff retry
//
// Token expiry: errcode -14 signals the token is no longer valid.
package wechat
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/Tencent/WeKnora/internal/im"
"github.com/Tencent/WeKnora/internal/logger"
secutils "github.com/Tencent/WeKnora/internal/utils"
)
const (
longPollTimeout = 35 * time.Second
longPollHTTPTimeout = 40 * time.Second // slightly longer than poll timeout
reconnectBaseDelay = 1 * time.Second
reconnectMaxDelay = 30 * time.Second
maxReconnectAttempts = -1 // infinite
)
// ErrTokenExpired indicates the bot token has expired and a re-login is required.
var ErrTokenExpired = fmt.Errorf("wechat bot token expired")
// LongPollClient receives messages from WeChat via HTTP long-polling.
type LongPollClient struct {
botToken string
ilinkBotID string
handler func(ctx context.Context, msg *im.IncomingMessage) error
httpClient *http.Client
cursor string // get_updates_buf: opaque cursor for pagination
}
// NewLongPollClient creates a new WeChat long-polling client.
func NewLongPollClient(botToken, ilinkBotID string, handler func(ctx context.Context, msg *im.IncomingMessage) error) *LongPollClient {
return &LongPollClient{
botToken: botToken,
ilinkBotID: ilinkBotID,
handler: handler,
httpClient: secutils.NewSSRFSafeHTTPClient(secutils.SSRFSafeHTTPClientConfig{
Timeout: longPollHTTPTimeout,
MaxRedirects: 5,
}),
}
}
// Start begins the long-polling loop. It reconnects automatically on transient errors.
// Returns ErrTokenExpired when the bot token expires (errcode -14).
func (c *LongPollClient) Start(ctx context.Context) error {
logger.Infof(ctx, "[IM] WeChat long-poll starting (bot_id=%s)...", c.ilinkBotID)
attempts := 0
for {
if ctx.Err() != nil {
return ctx.Err()
}
pollStart := time.Now()
err := c.poll(ctx)
if err == nil {
// Successful poll — reset attempts
attempts = 0
continue
}
if err == ErrTokenExpired {
logger.Warnf(ctx, "[WeChat] Bot token expired, stopping long-poll")
return err
}
if ctx.Err() != nil {
return ctx.Err()
}
// If we ran for a while before failing, reset backoff
if time.Since(pollStart) > reconnectMaxDelay {
attempts = 0
}
attempts++
if maxReconnectAttempts >= 0 && attempts >= maxReconnectAttempts {
return fmt.Errorf("max reconnect attempts reached: %w", err)
}
delay := pollReconnectDelay(attempts)
logger.Warnf(ctx, "[WeChat] Poll error (%v), retrying in %v (attempt %d)...", err, delay, attempts)
select {
case <-time.After(delay):
case <-ctx.Done():
return ctx.Err()
}
}
}
// poll performs a single long-poll request to /ilink/bot/getupdates.
func (c *LongPollClient) poll(ctx context.Context) error {
payload := map[string]interface{}{
"get_updates_buf": c.cursor,
"base_info": newBaseInfo(),
}
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ilinkBaseURL+"/ilink/bot/getupdates", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("AuthorizationType", "ilink_bot_token")
req.Header.Set("Authorization", "Bearer "+c.botToken)
req.Header.Set("X-WECHAT-UIN", generateWeChatUIN())
req.Header.Set("Content-Length", fmt.Sprintf("%d", len(body)))
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("poll request: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("getupdates returned status %d: %s", resp.StatusCode, string(respBody))
}
var result getUpdatesResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return fmt.Errorf("decode response: %w", err)
}
// Token expired
if result.ErrCode == -14 {
return ErrTokenExpired
}
if result.Ret != 0 && result.ErrCode != 0 {
return fmt.Errorf("getupdates error: ret=%d errcode=%d msg=%s", result.Ret, result.ErrCode, result.ErrMsg)
}
// Update cursor for next poll
if result.GetUpdatesBuf != "" {
c.cursor = result.GetUpdatesBuf
}
// Process messages
for i := range result.Msgs {
msg := &result.Msgs[i]
incoming := c.parseMessage(msg)
if incoming == nil {
continue
}
// Handle in a detached goroutine so we don't block polling
go func(m *im.IncomingMessage) {
if err := c.handler(ctx, m); err != nil {
logger.Errorf(ctx, "[WeChat] Handle message error: %v", err)
}
}(incoming)
}
return nil
}
// parseMessage converts a WeixinMessage from getupdates to a unified IncomingMessage.
func (c *LongPollClient) parseMessage(msg *weixinMessage) *im.IncomingMessage {
contextToken := msg.ContextToken
// Only process user messages (message_type=1), skip bot messages (message_type=2)
if msg.MessageType == 2 {
return nil
}
if len(msg.ItemList) == 0 {
return nil
}
// Process the first item
item := msg.ItemList[0]
switch item.Type {
case 1: // TEXT
content := ""
if item.TextItem != nil {
content = strings.TrimSpace(item.TextItem.Text)
}
if content == "" {
return nil
}
return &im.IncomingMessage{
Platform: im.PlatformWeChat,
MessageType: im.MessageTypeText,
UserID: msg.FromUserID,
ChatType: im.ChatTypeDirect,
Content: content,
MessageID: fmt.Sprintf("%d", msg.MessageID),
Extra: map[string]string{"context_token": contextToken},
}
case 2: // IMAGE
if item.ImageItem == nil || item.ImageItem.Media == nil {
return nil
}
encryptParam := item.ImageItem.Media.EncryptQueryParam
if encryptParam == "" {
return nil
}
// Build full CDN download URL from encrypt_query_param
downloadURL := BuildCDNDownloadURL(encryptParam)
// For images, prefer aeskey (hex format) from image_item, else media.aes_key (base64)
aesKey := ""
if item.ImageItem.AESKey != "" {
// hex → base64 for uniform handling
aesKey = item.ImageItem.AESKey
} else if item.ImageItem.Media.AESKey != "" {
aesKey = item.ImageItem.Media.AESKey
}
return &im.IncomingMessage{
Platform: im.PlatformWeChat,
MessageType: im.MessageTypeImage,
UserID: msg.FromUserID,
ChatType: im.ChatTypeDirect,
MessageID: fmt.Sprintf("%d", msg.MessageID),
FileKey: downloadURL,
FileName: fmt.Sprintf("%d.png", msg.MessageID),
Extra: map[string]string{
"context_token": contextToken,
"aes_key": aesKey,
},
}
case 3: // VOICE (speech-to-text)
if item.VoiceItem != nil && item.VoiceItem.Text != "" {
return &im.IncomingMessage{
Platform: im.PlatformWeChat,
MessageType: im.MessageTypeText,
UserID: msg.FromUserID,
ChatType: im.ChatTypeDirect,
Content: strings.TrimSpace(item.VoiceItem.Text),
MessageID: fmt.Sprintf("%d", msg.MessageID),
Extra: map[string]string{"context_token": contextToken},
}
}
return nil
case 4: // FILE
if item.FileItem == nil || item.FileItem.Media == nil {
return nil
}
encryptParam := item.FileItem.Media.EncryptQueryParam
if encryptParam == "" {
return nil
}
// Build full CDN download URL from encrypt_query_param
downloadURL := BuildCDNDownloadURL(encryptParam)
fileName := item.FileItem.FileName
if fileName == "" {
fileName = fmt.Sprintf("file_%d", msg.MessageID)
}
var fileSize int64
if item.FileItem.Len != "" {
fmt.Sscanf(item.FileItem.Len, "%d", &fileSize)
}
return &im.IncomingMessage{
Platform: im.PlatformWeChat,
MessageType: im.MessageTypeFile,
UserID: msg.FromUserID,
ChatType: im.ChatTypeDirect,
MessageID: fmt.Sprintf("%d", msg.MessageID),
FileKey: downloadURL,
FileName: fileName,
FileSize: fileSize,
Extra: map[string]string{
"context_token": contextToken,
"aes_key": item.FileItem.Media.AESKey,
},
}
default:
return nil
}
}
func pollReconnectDelay(attempt int) time.Duration {
if attempt < 1 {
return reconnectBaseDelay
}
// Cap the exponent to avoid int64 overflow: base (1e9 ns) * 2^shift
// overflows when shift ≥ 34, producing a negative duration that would
// bypass the max-delay check and cause a busy reconnect loop.
shift := attempt - 1
if shift > 30 {
return reconnectMaxDelay
}
delay := reconnectBaseDelay * (1 << shift)
if delay > reconnectMaxDelay {
delay = reconnectMaxDelay
}
return delay
}
// ── iLink API response types (matches proto: GetUpdatesResp, WeixinMessage) ──
type getUpdatesResponse struct {
Ret int `json:"ret"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
Msgs []weixinMessage `json:"msgs"`
GetUpdatesBuf string `json:"get_updates_buf"`
}
type weixinMessage struct {
Seq int `json:"seq"`
MessageID int64 `json:"message_id"`
FromUserID string `json:"from_user_id"`
ToUserID string `json:"to_user_id"`
ClientID string `json:"client_id"`
CreateTimeMs int64 `json:"create_time_ms"`
SessionID string `json:"session_id"`
MessageType int `json:"message_type"` // 1=USER, 2=BOT
MessageState int `json:"message_state"` // 0=NEW, 1=GENERATING, 2=FINISH
ItemList []messageItem `json:"item_list"`
ContextToken string `json:"context_token"`
}
type messageItem struct {
Type int `json:"type"` // 1=TEXT, 2=IMAGE, 3=VOICE, 4=FILE, 5=VIDEO
TextItem *textItem `json:"text_item,omitempty"`
ImageItem *imageItem `json:"image_item,omitempty"`
VoiceItem *voiceItem `json:"voice_item,omitempty"`
FileItem *fileItem `json:"file_item,omitempty"`
}
type textItem struct {
Text string `json:"text"`
}
type cdnMedia struct {
EncryptQueryParam string `json:"encrypt_query_param"`
AESKey string `json:"aes_key"`
}
type imageItem struct {
Media *cdnMedia `json:"media,omitempty"`
AESKey string `json:"aeskey"` // hex string, preferred for inbound decryption
URL string `json:"url,omitempty"`
}
type voiceItem struct {
Media *cdnMedia `json:"media,omitempty"`
Text string `json:"text"` // speech-to-text result
}
type fileItem struct {
Media *cdnMedia `json:"media,omitempty"`
FileName string `json:"file_name"`
Len string `json:"len"` // plaintext bytes as string
}
+165
View File
@@ -0,0 +1,165 @@
// QR code login flow for WeChat iLink Bot API.
//
// API endpoints (all relative to the iLink base URL):
//
// GET /ilink/bot/get_bot_qrcode?bot_type=3 → returns {qrcode, qrcode_img_content}
// GET /ilink/bot/get_qrcode_status?qrcode=xxx → returns {status, bot_token, ilink_bot_id, ...}
//
// Flow:
// 1. Call GetLoginQRCode to obtain a QR code URL and opaque qrcode token
// 2. Display qrcode_img_content URL to user (in frontend)
// 3. Long-poll PollQRCodeStatus until user scans and confirms
// 4. On success, receive bot_token + ilink_bot_id + ilink_user_id + baseurl
package wechat
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
secutils "github.com/Tencent/WeKnora/internal/utils"
)
// QRCodeResult holds the result of requesting a login QR code.
type QRCodeResult struct {
// QRCodeURL is the URL to render as a QR code image (qrcode_img_content).
QRCodeURL string `json:"qrcode_url"`
// QRCode is the opaque token used to poll for scan status.
QRCode string `json:"qrcode"`
}
// LoginResult holds the credentials returned after successful QR code scan.
type LoginResult struct {
Status string `json:"status"` // "wait", "scaned", "confirmed", "expired"
BotToken string `json:"bot_token"` // Bearer token for API calls
ILinkBotID string `json:"ilink_bot_id"` // Bot identifier
ILinkUserID string `json:"ilink_user_id"` // User identifier
BaseURL string `json:"baseurl"` // API base URL (may override default)
}
// QRCodeService handles WeChat QR code login operations.
type QRCodeService struct {
client *http.Client
}
// NewQRCodeService creates a new QR code service.
func NewQRCodeService() *QRCodeService {
return &QRCodeService{
client: secutils.NewSSRFSafeHTTPClient(secutils.SSRFSafeHTTPClientConfig{
Timeout: pollTimeout,
MaxRedirects: 5,
}),
}
}
// GetLoginQRCode requests a new login QR code from iLink API.
// GET /ilink/bot/get_bot_qrcode?bot_type=3
func (s *QRCodeService) GetLoginQRCode(ctx context.Context) (*QRCodeResult, error) {
u := ilinkBaseURL + "/ilink/bot/get_bot_qrcode?bot_type=" + url.QueryEscape(defaultBotType)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
resp, err := s.client.Do(req)
if err != nil {
return nil, fmt.Errorf("request qrcode: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response body: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("qrcode API returned status %d: %s", resp.StatusCode, string(body))
}
var result struct {
QRCode string `json:"qrcode"`
QRCodeImgContent string `json:"qrcode_img_content"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("decode response: %w (body: %s)", err, string(body))
}
if result.QRCode == "" {
return nil, fmt.Errorf("empty qrcode in response: %s", string(body))
}
return &QRCodeResult{
QRCodeURL: result.QRCodeImgContent,
QRCode: result.QRCode,
}, nil
}
// pollTimeout is the client-side timeout for the long-poll get_qrcode_status
// request. The iLink server may hold the request up to 35s, so we set a
// slightly longer timeout. We use a DETACHED context (not the gin request
// context) to avoid "context canceled" when gin's own timeout fires first.
const pollTimeout = 38 * time.Second
// PollQRCodeStatus checks the scan status of a QR code.
// GET /ilink/bot/get_qrcode_status?qrcode=xxx
// This is a long-poll endpoint: the server holds the connection until there
// is a status change or ~35 seconds elapse.
// Status values: "wait", "scaned", "confirmed", "expired"
func (s *QRCodeService) PollQRCodeStatus(ctx context.Context, qrcode string) (*LoginResult, error) {
u := ilinkBaseURL + "/ilink/bot/get_qrcode_status?qrcode=" + url.QueryEscape(qrcode)
// Use a DETACHED context with our own timeout so we are not bound by the
// caller's (gin) request context which may be shorter than the iLink
// long-poll hold time.
pollCtx, cancel := context.WithTimeout(context.Background(), pollTimeout)
defer cancel()
req, err := http.NewRequestWithContext(pollCtx, http.MethodGet, u, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("iLink-App-ClientVersion", "1")
resp, err := s.client.Do(req)
if err != nil {
// Client-side timeout is normal for long-poll; return "wait" status
if pollCtx.Err() != nil {
return &LoginResult{Status: "wait"}, nil
}
return nil, fmt.Errorf("request qrcode status: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response body: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("qrcode status API returned status %d: %s", resp.StatusCode, string(body))
}
var result struct {
Status string `json:"status"` // "wait", "scaned", "confirmed", "expired"
BotToken string `json:"bot_token"`
ILinkBotID string `json:"ilink_bot_id"`
ILinkUserID string `json:"ilink_user_id"`
BaseURL string `json:"baseurl"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("decode response: %w (body: %s)", err, string(body))
}
return &LoginResult{
Status: result.Status,
BotToken: result.BotToken,
ILinkBotID: result.ILinkBotID,
ILinkUserID: result.ILinkUserID,
BaseURL: result.BaseURL,
}, nil
}
+79
View File
@@ -0,0 +1,79 @@
package wecom
import (
"context"
"fmt"
"github.com/Tencent/WeKnora/internal/im"
"github.com/Tencent/WeKnora/internal/logger"
)
// NewFactory returns an im.AdapterFactory for WeCom channels.
// Supports two modes: "webhook" (HTTP callback) and "websocket" (long connection, default).
func NewFactory() im.AdapterFactory {
return func(factoryCtx context.Context, channel *im.IMChannel, msgHandler func(context.Context, *im.IncomingMessage) error) (im.Adapter, context.CancelFunc, error) {
creds, err := im.ParseCredentials(channel.Credentials)
if err != nil {
return nil, nil, fmt.Errorf("parse wecom credentials: %w", err)
}
mode := im.ResolveMode(channel, "websocket")
switch mode {
case "webhook":
corpAgentID := 0
if v, ok := creds["corp_agent_id"]; ok {
switch val := v.(type) {
case float64:
corpAgentID = int(val)
case int:
corpAgentID = val
}
}
adapter, err := NewWebhookAdapter(
im.GetString(creds, "corp_id"),
im.GetString(creds, "agent_secret"),
im.GetString(creds, "token"),
im.GetString(creds, "encoding_aes_key"),
corpAgentID,
im.GetString(creds, "api_base_url"),
)
if err != nil {
return nil, nil, err
}
return adapter, nil, nil
case "websocket":
client, err := NewLongConnClient(
im.GetString(creds, "bot_id"),
im.GetString(creds, "bot_secret"),
im.GetString(creds, "ws_endpoint"),
im.GetString(creds, "bot_name"),
msgHandler,
)
if err != nil {
return nil, nil, err
}
wsCtx, wsCancel := context.WithCancel(context.Background())
go func() {
if err := client.Start(wsCtx); err != nil && wsCtx.Err() == nil {
logger.Errorf(context.Background(), "[IM] WeCom long connection stopped for channel %s: %v", channel.ID, err)
}
}()
adapter := NewWSAdapter(client)
// Cancelling the context alone does not unblock gorilla/websocket's
// ReadMessage. Close the socket first so disabling, reconfiguring, or
// shutting down a channel stops message delivery synchronously.
stop := func() {
client.Stop()
wsCancel()
}
return adapter, stop, nil
default:
return nil, nil, fmt.Errorf("unknown WeCom mode: %s", mode)
}
}
}
+835
View File
@@ -0,0 +1,835 @@
// WeCom Intelligent Bot long connection client.
//
// Protocol reference: https://developer.work.weixin.qq.com/document/path/101463
// Node.js SDK reference: https://github.com/WecomTeam/aibot-node-sdk
//
// Flow:
// 1. Connect to wss://openws.work.weixin.qq.com
// 2. Send aibot_subscribe with bot_id + secret
// 3. Receive aibot_msg_callback / aibot_event_callback frames
// 4. Reply via aibot_respond_msg on the same WebSocket
// 5. Heartbeat via ping/pong every 30s
package wecom
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/Tencent/WeKnora/internal/im"
"github.com/Tencent/WeKnora/internal/logger"
secutils "github.com/Tencent/WeKnora/internal/utils"
ws "github.com/gorilla/websocket"
)
const (
defaultWSEndpoint = "wss://openws.work.weixin.qq.com"
cmdSubscribe = "aibot_subscribe"
cmdPing = "ping"
cmdMsgCallback = "aibot_msg_callback"
cmdEventCallback = "aibot_event_callback"
cmdResponse = "aibot_respond_msg"
defaultHeartbeatInterval = 30 * time.Second
defaultReconnectBaseDelay = 1 * time.Second
defaultReconnectMaxDelay = 30 * time.Second
defaultMaxReconnectAttempts = -1 // infinite
// readTimeout is how long the receive loop waits for any message (including
// heartbeat pong) before treating the connection as dead. Set to 3× heartbeat
// interval so a single missed pong does not cause a spurious reconnect.
readTimeout = 3 * defaultHeartbeatInterval
// writeTimeout bounds a single frame write. Without it a stalled peer can
// block a write while holding c.mu, which in turn blocks Stop() and — since
// the IM service tears channels down while holding its own channel-map lock
// — every other channel operation in the process.
writeTimeout = 10 * time.Second
)
// wsFrame is the JSON frame exchanged over the WeCom bot WebSocket.
type wsFrame struct {
Cmd string `json:"cmd,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Body json.RawMessage `json:"body,omitempty"`
ErrCode int `json:"errcode,omitempty"`
ErrMsg string `json:"errmsg,omitempty"`
}
// botMessage is the body of an aibot_msg_callback frame.
// Supports text, image, file, voice, and mixed message types.
// Reference: https://developer.work.weixin.qq.com/document/path/100719
type botMessage struct {
MsgID string `json:"msgid"`
AiBotID string `json:"aibotid"`
ChatID string `json:"chatid"`
ChatType string `json:"chattype"` // "single" or "group"
MsgType string `json:"msgtype"` // "text", "image", "file", "voice", "video", "mixed", "stream"
CreateTime int64 `json:"create_time"`
From struct {
UserID string `json:"userid"`
} `json:"from"`
Text struct {
Content string `json:"content"`
} `json:"text"`
Image struct {
URL string `json:"url"` // encrypted download URL, valid for 5 minutes
AESKey string `json:"aeskey"` // per-message AES key for decrypting downloaded content
} `json:"image"`
File struct {
URL string `json:"url"` // encrypted download URL, valid for 5 minutes
AESKey string `json:"aeskey"` // per-message AES key for decrypting downloaded content
} `json:"file"`
Voice struct {
Content string `json:"content"` // speech-to-text result
} `json:"voice"`
Video struct {
URL string `json:"url"` // encrypted download URL, valid for 5 minutes
AESKey string `json:"aeskey"` // per-message AES key for decrypting downloaded content
} `json:"video"`
Mixed struct {
MsgItem []botMixedItem `json:"msg_item"`
} `json:"mixed"`
Quote *botMessage `json:"quote,omitempty"` // quoted message (optional)
Event struct {
EventType string `json:"eventtype"`
} `json:"event"`
}
// botMixedItem is one element in a mixed (text+image) message.
type botMixedItem struct {
MsgType string `json:"msgtype"` // "text" or "image"
Text struct {
Content string `json:"content"`
} `json:"text"`
Image struct {
URL string `json:"url"`
AESKey string `json:"aeskey"`
} `json:"image"`
}
// streamReplyBody is the body for a streaming text reply.
type streamReplyBody struct {
MsgType string `json:"msgtype"`
Stream struct {
ID string `json:"id"`
Finish bool `json:"finish"`
Content string `json:"content"`
} `json:"stream"`
}
// MessageHandler is called when an IM message is received via long connection.
type MessageHandler func(ctx context.Context, msg *im.IncomingMessage) error
// LongConnClient manages a WeCom intelligent bot WebSocket long connection.
type LongConnClient struct {
botID string
secret string
endpoint string
extraAllowedHost string // hostname from custom endpoint for SSRF allowlist
handler MessageHandler
conn *ws.Conn
mu sync.Mutex
closed atomic.Bool
reqSeq atomic.Int64
// streamBufs tracks accumulated content per stream ID.
// WeCom stream protocol is replace-based: each frame's content replaces
// the previously displayed text, so we must send the full accumulated text.
streamBufsMu sync.Mutex
streamBufs map[string]*strings.Builder
// botDisplayName caches the bot's display name for @mention stripping.
// Set from credentials "bot_name", or learned from double-space messages.
botDisplayName atomic.Value // string
}
// NewLongConnClient creates a WeCom long connection client.
// wsEndpoint overrides the default WebSocket URL; empty uses the public cloud endpoint.
// botName is the bot's display name for @mention stripping; empty to auto-detect.
func NewLongConnClient(botID, secret, wsEndpoint, botName string, handler MessageHandler) (*LongConnClient, error) {
if wsEndpoint == "" {
wsEndpoint = defaultWSEndpoint
}
wsEndpoint = strings.TrimRight(wsEndpoint, "/")
if err := validateEndpointURL(wsEndpoint, defaultWSEndpoint, "wss"); err != nil {
return nil, fmt.Errorf("invalid ws_endpoint: %w", err)
}
c := &LongConnClient{
botID: botID,
secret: secret,
endpoint: wsEndpoint,
extraAllowedHost: extraHostFromEndpoint(wsEndpoint, defaultWSEndpoint),
handler: handler,
}
if botName != "" {
c.botDisplayName.Store(botName)
}
return c, nil
}
// Start connects and runs the long connection loop. It reconnects automatically on failure.
func (c *LongConnClient) Start(ctx context.Context) error {
logger.Infof(ctx, "[IM] WeCom WebSocket connecting (bot_id=%s)...", c.botID)
attempts := 0
for {
if ctx.Err() != nil {
return ctx.Err()
}
connectedAt := time.Now()
err := c.connectAndRun(ctx)
if c.closed.Load() {
return nil
}
if ctx.Err() != nil {
return ctx.Err()
}
// If the connection was up for longer than the max backoff window,
// the disconnect is likely transient — reset so we retry quickly.
if time.Since(connectedAt) > defaultReconnectMaxDelay {
attempts = 0
}
attempts++
if defaultMaxReconnectAttempts >= 0 && attempts >= defaultMaxReconnectAttempts {
return fmt.Errorf("max reconnect attempts reached: %w", err)
}
delay := reconnectDelay(attempts)
logger.Warnf(ctx, "[WeCom] Connection lost (%v), reconnecting in %v (attempt %d)...", err, delay, attempts)
select {
case <-time.After(delay):
case <-ctx.Done():
return ctx.Err()
}
}
}
// Stop gracefully closes the connection.
func (c *LongConnClient) Stop() {
c.closed.Store(true)
c.closeConn()
}
// SendReply sends a text reply through the WebSocket connection.
// This is used by the IM service to reply to messages in long connection mode.
func (c *LongConnClient) SendReply(ctx context.Context, incoming *im.IncomingMessage, reply *im.ReplyMessage) error {
var reqID string
if incoming.Extra != nil {
reqID = incoming.Extra["req_id"]
}
if reqID == "" {
return fmt.Errorf("missing req_id in incoming message extra")
}
// Generate a unique stream ID for this reply
streamID := fmt.Sprintf("stream_%d", c.reqSeq.Add(1))
body := streamReplyBody{MsgType: "stream"}
body.Stream.ID = streamID
body.Stream.Finish = true
body.Stream.Content = reply.Content
bodyBytes, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal reply body: %w", err)
}
frame := wsFrame{
Cmd: cmdResponse,
Headers: map[string]string{"req_id": reqID},
Body: bodyBytes,
}
return c.writeJSON(frame)
}
// ──────────────────────────────────────────────────────────────────────
// Streaming support: send answer chunks over WebSocket in real-time
// ──────────────────────────────────────────────────────────────────────
// StartStream begins a streaming reply session.
// Returns a stream ID that must be used in subsequent chunk/end calls.
func (c *LongConnClient) StartStream(ctx context.Context, incoming *im.IncomingMessage) (string, error) {
if incoming.Extra == nil || incoming.Extra["req_id"] == "" {
return "", fmt.Errorf("missing req_id in incoming message extra")
}
streamID := fmt.Sprintf("stream_%d", c.reqSeq.Add(1))
// Initialize the accumulation buffer for this stream
c.streamBufsMu.Lock()
if c.streamBufs == nil {
c.streamBufs = make(map[string]*strings.Builder)
}
c.streamBufs[streamID] = &strings.Builder{}
c.streamBufsMu.Unlock()
return streamID, nil
}
// UpdateStreamContent replaces the user-visible stream text (WeCom replace protocol).
func (c *LongConnClient) UpdateStreamContent(ctx context.Context, incoming *im.IncomingMessage, streamID string, fullContent string) error {
if fullContent == "" {
return nil
}
c.streamBufsMu.Lock()
buf, ok := c.streamBufs[streamID]
if !ok {
c.streamBufsMu.Unlock()
return fmt.Errorf("unknown stream ID: %s", streamID)
}
buf.Reset()
buf.WriteString(fullContent)
c.streamBufsMu.Unlock()
return c.sendStreamFrame(incoming, streamID, fullContent, false)
}
// FinalizeStream replaces the display with answer-only content before the stream ends.
func (c *LongConnClient) FinalizeStream(ctx context.Context, incoming *im.IncomingMessage, streamID string, finalContent string) error {
return c.UpdateStreamContent(ctx, incoming, streamID, finalContent)
}
// SendStreamChunk is deprecated; kept as an alias for UpdateStreamContent.
func (c *LongConnClient) SendStreamChunk(ctx context.Context, incoming *im.IncomingMessage, streamID string, content string) error {
return c.UpdateStreamContent(ctx, incoming, streamID, content)
}
// EndStream sends the final frame with the full accumulated content and cleans up.
// It retries briefly if the connection is temporarily unavailable during a reconnect.
func (c *LongConnClient) EndStream(ctx context.Context, incoming *im.IncomingMessage, streamID string) error {
c.streamBufsMu.Lock()
buf, ok := c.streamBufs[streamID]
var fullContent string
if ok {
fullContent = buf.String()
delete(c.streamBufs, streamID)
}
c.streamBufsMu.Unlock()
err := c.sendStreamFrame(incoming, streamID, fullContent, true)
if err == nil {
return nil
}
// Retry up to 3 times with short delays to ride out a reconnection window.
for i := 0; i < 3; i++ {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(500 * time.Millisecond):
}
if retryErr := c.sendStreamFrame(incoming, streamID, fullContent, true); retryErr == nil {
return nil
}
}
return err
}
func (c *LongConnClient) sendStreamFrame(incoming *im.IncomingMessage, streamID, content string, finish bool) error {
var reqID string
if incoming.Extra != nil {
reqID = incoming.Extra["req_id"]
}
if reqID == "" {
return fmt.Errorf("missing req_id in incoming message extra")
}
body := streamReplyBody{MsgType: "stream"}
body.Stream.ID = streamID
body.Stream.Finish = finish
body.Stream.Content = content
bodyBytes, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal stream body: %w", err)
}
frame := wsFrame{
Cmd: cmdResponse,
Headers: map[string]string{"req_id": reqID},
Body: bodyBytes,
}
return c.writeJSON(frame)
}
func (c *LongConnClient) connectAndRun(ctx context.Context) error {
dialer := *ws.DefaultDialer
dialer.NetDialContext = secutils.SSRFSafeDialContext
conn, _, err := dialer.DialContext(ctx, c.endpoint, nil)
if err != nil {
return fmt.Errorf("dial: %w", err)
}
c.mu.Lock()
c.conn = conn
c.mu.Unlock()
defer func() {
c.closeConnIf(conn)
// NOTE: streamBufs is intentionally NOT cleared on reconnect.
// Active streams survive reconnections — the WeCom replace-based
// protocol means the next UpdateStreamContent will resend the full
// accumulated content on the new connection. EndStream always
// cleans up the buffer, so there is no memory leak.
}()
// A Stop() that lands between the dial and the assignment above finds no
// connection to close, so re-check here to avoid running a receive loop
// that nobody will tear down until the read deadline expires.
if c.closed.Load() {
return fmt.Errorf("client stopped")
}
// Authenticate
if err := c.authenticate(ctx); err != nil {
return fmt.Errorf("authenticate: %w", err)
}
logger.Infof(ctx, "[IM] WeCom WebSocket connected successfully (bot_id=%s)", c.botID)
// Start heartbeat
heartbeatCtx, heartbeatCancel := context.WithCancel(ctx)
defer heartbeatCancel()
go c.heartbeatLoop(heartbeatCtx, conn)
// Message receive loop with read deadline.
// The deadline is reset on every successful read; if no message arrives
// within readTimeout (including heartbeat pong frames), the connection
// is considered dead and we fall through to reconnect.
for {
_ = conn.SetReadDeadline(time.Now().Add(readTimeout))
_, message, err := conn.ReadMessage()
if err != nil {
return fmt.Errorf("read message: %w", err)
}
var frame wsFrame
if err := json.Unmarshal(message, &frame); err != nil {
logger.Warnf(ctx, "[WeCom] Failed to unmarshal frame: %v", err)
continue
}
switch frame.Cmd {
case cmdMsgCallback, cmdEventCallback:
// Detach from connection ctx so in-flight messages survive reconnects.
go c.handleCallback(context.WithoutCancel(ctx), conn, frame)
default:
// pong or other control frames — ignore
}
}
}
func (c *LongConnClient) authenticate(ctx context.Context) error {
authBody, _ := json.Marshal(map[string]string{
"bot_id": c.botID,
"secret": c.secret,
})
reqID := fmt.Sprintf("%s_%d", cmdSubscribe, time.Now().UnixNano())
frame := wsFrame{
Cmd: cmdSubscribe,
Headers: map[string]string{"req_id": reqID},
Body: authBody,
}
if err := c.writeJSON(frame); err != nil {
return fmt.Errorf("send subscribe: %w", err)
}
// Read auth response
c.mu.Lock()
conn := c.conn
c.mu.Unlock()
if conn == nil {
return fmt.Errorf("connection closed")
}
_ = conn.SetReadDeadline(time.Now().Add(10 * time.Second))
_, msg, err := conn.ReadMessage()
_ = conn.SetReadDeadline(time.Time{}) // clear deadline
if err != nil {
return fmt.Errorf("read auth response: %w", err)
}
var resp wsFrame
if err := json.Unmarshal(msg, &resp); err != nil {
return fmt.Errorf("unmarshal auth response: %w", err)
}
if resp.ErrCode != 0 {
return fmt.Errorf("auth failed: code=%d msg=%s", resp.ErrCode, resp.ErrMsg)
}
return nil
}
func (c *LongConnClient) heartbeatLoop(ctx context.Context, conn *ws.Conn) {
ticker := time.NewTicker(defaultHeartbeatInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
reqID := fmt.Sprintf("%s_%d", cmdPing, time.Now().UnixNano())
frame := wsFrame{
Cmd: cmdPing,
Headers: map[string]string{"req_id": reqID},
}
if err := c.writeJSON(frame); err != nil {
logger.Warnf(ctx, "[WeCom] Heartbeat failed: %v, closing connection to trigger reconnect", err)
c.closeConnIf(conn)
return
}
}
}
}
func (c *LongConnClient) handleCallback(ctx context.Context, conn *ws.Conn, frame wsFrame) {
// Log raw message body for debugging
logger.Debugf(ctx, "[WeCom] Raw callback body: %s", string(frame.Body))
var msg botMessage
if err := json.Unmarshal(frame.Body, &msg); err != nil {
logger.Warnf(ctx, "[WeCom] Failed to unmarshal callback body: %v", err)
return
}
logger.Debugf(ctx, "[WeCom] Parsed message: msgid=%s msgtype=%s from=%s chattype=%s text=%q image_url=%q file_url=%q voice=%q mixed_items=%d",
msg.MsgID, msg.MsgType, msg.From.UserID, msg.ChatType,
msg.Text.Content, msg.Image.URL, msg.File.URL, msg.Voice.Content, len(msg.Mixed.MsgItem))
// Handle server-side events (e.g. disconnected_event) before normal messages.
if msg.MsgType == "event" {
switch msg.Event.EventType {
case "disconnected_event":
logger.Warnf(ctx, "[WeCom] Server sent disconnected_event, closing connection to trigger reconnect")
c.closeConnIf(conn)
default:
logger.Infof(ctx, "[WeCom] Ignoring event type: %s", msg.Event.EventType)
}
return
}
chatType := im.ChatTypeDirect
chatID := ""
isGroup := msg.ChatType == "group"
if isGroup {
chatType = im.ChatTypeGroup
chatID = msg.ChatID
}
// Preserve req_id in Extra for reply routing
reqID := ""
if frame.Headers != nil {
reqID = frame.Headers["req_id"]
}
var incoming *im.IncomingMessage
switch msg.MsgType {
case "text":
// WeCom does not strip @mention in group chat; strip it so slash
// commands (/stop, /clear) are recognized.
textContent := msg.Text.Content
if isGroup {
textContent = c.stripAtMention(textContent)
}
incoming = &im.IncomingMessage{
Platform: im.PlatformWeCom,
MessageType: im.MessageTypeText,
UserID: msg.From.UserID,
UserName: msg.From.UserID,
ChatID: chatID,
ChatType: chatType,
Content: strings.TrimSpace(textContent),
MessageID: msg.MsgID,
Extra: map[string]string{"req_id": reqID},
}
case "voice":
// WeCom returns speech-to-text content directly — treat as text query
if msg.Voice.Content == "" {
logger.Infof(ctx, "[WeCom] Ignoring voice message with empty content")
return
}
incoming = &im.IncomingMessage{
Platform: im.PlatformWeCom,
MessageType: im.MessageTypeText,
UserID: msg.From.UserID,
UserName: msg.From.UserID,
ChatID: chatID,
ChatType: chatType,
Content: strings.TrimSpace(msg.Voice.Content),
MessageID: msg.MsgID,
Extra: map[string]string{"req_id": reqID},
}
case "image":
if msg.Image.URL == "" {
logger.Infof(ctx, "[WeCom] Ignoring image message with empty URL")
return
}
incoming = &im.IncomingMessage{
Platform: im.PlatformWeCom,
MessageType: im.MessageTypeImage,
UserID: msg.From.UserID,
UserName: msg.From.UserID,
ChatID: chatID,
ChatType: chatType,
MessageID: msg.MsgID,
FileKey: msg.Image.URL, // store encrypted URL in FileKey
FileName: msg.MsgID + ".png",
Extra: map[string]string{"req_id": reqID, "aes_key": msg.Image.AESKey},
}
case "file":
if msg.File.URL == "" {
logger.Infof(ctx, "[WeCom] Ignoring file message with empty URL")
return
}
incoming = &im.IncomingMessage{
Platform: im.PlatformWeCom,
MessageType: im.MessageTypeFile,
UserID: msg.From.UserID,
UserName: msg.From.UserID,
ChatID: chatID,
ChatType: chatType,
MessageID: msg.MsgID,
FileKey: msg.File.URL, // store encrypted URL in FileKey
FileName: msg.MsgID, // WeCom doesn't provide file name directly
Extra: map[string]string{"req_id": reqID, "aes_key": msg.File.AESKey},
}
case "mixed":
// Extract text parts for QA content, and detect if any images are present
incoming = c.convertMixedMessage(&msg, chatID, chatType, reqID)
if incoming == nil {
logger.Infof(ctx, "[WeCom] Ignoring empty mixed message")
return
}
default:
logger.Infof(ctx, "[WeCom] Ignoring unsupported message type: %s", msg.MsgType)
return
}
// Populate quote context if the incoming message has a quoted/replied message.
if incoming != nil && msg.Quote != nil {
incoming.Quote = buildQuotedMessage(msg.Quote, msg.AiBotID)
if incoming.Quote != nil {
logger.Infof(ctx, "[WeCom] Quote detected: msgid=%s sender=%s is_bot=%v content_len=%d non_text_type=%s",
msg.Quote.MsgID, msg.Quote.From.UserID, incoming.Quote.IsBotMessage, len(incoming.Quote.Content), incoming.Quote.NonTextType)
// Debug: log raw IDs for bot identity verification during initial rollout
logger.Debugf(ctx, "[WeCom] Quote identity debug: quote.from.userid=%q quote.aibotid=%q msg.aibotid=%q",
msg.Quote.From.UserID, msg.Quote.AiBotID, msg.AiBotID)
}
}
if err := c.handler(ctx, incoming); err != nil {
logger.Errorf(ctx, "[WeCom] Handle message error: %v", err)
}
}
// convertMixedMessage converts a WeCom mixed (text+image) message.
// Extracts all text content for QA; if there's only images, treat as image message.
func (c *LongConnClient) convertMixedMessage(msg *botMessage, chatID string, chatType im.ChatType, reqID string) *im.IncomingMessage {
isGroup := chatType == im.ChatTypeGroup
var textParts []string
var firstImageURL string
var firstImageAESKey string
for _, item := range msg.Mixed.MsgItem {
switch item.MsgType {
case "text":
t := strings.TrimSpace(item.Text.Content)
if isGroup {
t = c.stripAtMention(t)
}
if t != "" {
textParts = append(textParts, t)
}
case "image":
if firstImageURL == "" && item.Image.URL != "" {
firstImageURL = item.Image.URL
firstImageAESKey = item.Image.AESKey
}
}
}
// If there's text content, treat as text message (QA query)
if len(textParts) > 0 {
return &im.IncomingMessage{
Platform: im.PlatformWeCom,
MessageType: im.MessageTypeText,
UserID: msg.From.UserID,
UserName: msg.From.UserID,
ChatID: chatID,
ChatType: chatType,
Content: strings.Join(textParts, "\n"),
MessageID: msg.MsgID,
Extra: map[string]string{"req_id": reqID},
}
}
// Only images, treat as image message (save to KB)
if firstImageURL != "" {
return &im.IncomingMessage{
Platform: im.PlatformWeCom,
MessageType: im.MessageTypeImage,
UserID: msg.From.UserID,
UserName: msg.From.UserID,
ChatID: chatID,
ChatType: chatType,
MessageID: msg.MsgID,
FileKey: firstImageURL,
FileName: msg.MsgID + ".png",
Extra: map[string]string{"req_id": reqID, "aes_key": firstImageAESKey},
}
}
return nil
}
// closeConn forcibly closes the active WebSocket, which unblocks any pending
// ReadMessage call in the receive loop and triggers a reconnection.
//
// c.mu is released before Close because closing a TLS connection writes a
// close_notify alert and can block for seconds on a stalled peer. Holding c.mu
// across that write stalls Stop(), and the IM service tears channels down while
// holding its own channel-map lock, so the delay spreads to every other channel
// operation in the process.
func (c *LongConnClient) closeConn() {
c.mu.Lock()
conn := c.conn
c.conn = nil
c.mu.Unlock()
if conn != nil {
_ = conn.Close()
}
}
// closeConnIf closes conn only while it is still the active connection.
// Callers bound to a single connection generation — the heartbeat loop and the
// detached callback handlers — must use this instead of closeConn: their
// goroutines can outlive the connection that spawned them, and an unconditional
// close would tear down a healthy connection opened by a later reconnect.
func (c *LongConnClient) closeConnIf(conn *ws.Conn) {
if conn == nil {
return
}
c.mu.Lock()
active := c.conn == conn
if active {
c.conn = nil
}
c.mu.Unlock()
if active {
_ = conn.Close()
}
}
// stripAtMentionBasic removes a leading "@Name" prefix from group chat content.
// Strategies: double-space split → heuristic (space + "/" or CJK) → first @word.
// Used directly by the webhook adapter (stateless) and as the base for
// LongConnClient.stripAtMention (stateful).
func stripAtMentionBasic(content string) string {
content = strings.TrimSpace(content)
if !strings.HasPrefix(content, "@") {
return content
}
// Some WeCom clients insert two spaces between @mention and user text.
if idx := strings.Index(content, " "); idx > 0 {
return strings.TrimSpace(content[idx+2:])
}
// Heuristic: bot names are ASCII words; user content starts with "/"
// or non-ASCII (CJK). Scan for the transition.
for i := 1; i < len(content); i++ {
if content[i] == ' ' && i+1 < len(content) {
if next := content[i+1]; next == '/' || next >= 0x80 {
return strings.TrimSpace(content[i+1:])
}
}
}
// Fallback: strip first @word.
if idx := strings.Index(content, " "); idx > 0 {
return strings.TrimSpace(content[idx+1:])
}
return content
}
// stripAtMention removes the leading "@BotName" prefix from group chat messages.
// Bot names may contain spaces (e.g., "WeKnora Bot"), so this adds two strategies
// on top of stripAtMentionBasic: (1) double-space split with bot-name learning,
// (2) cached/configured bot name prefix match.
// Concurrent calls are safe; atomic.Value races are benign (same bot name).
func (c *LongConnClient) stripAtMention(content string) string {
content = strings.TrimSpace(content)
if !strings.HasPrefix(content, "@") {
return content
}
// Strategy 1: double-space separator. Learn bot name on first occurrence
// (skip if already cached to avoid false positives from user double-spaces).
if idx := strings.Index(content, " "); idx > 0 {
botName := content[1:idx] // between "@" and " "
if cached, _ := c.botDisplayName.Load().(string); cached == "" && botName != "" {
c.botDisplayName.Store(botName)
}
return strings.TrimSpace(content[idx+2:])
}
// Strategy 2: cached/configured bot name with word-boundary check.
if name, _ := c.botDisplayName.Load().(string); name != "" {
prefix := "@" + name
if strings.HasPrefix(content, prefix) && (len(content) == len(prefix) || content[len(prefix)] == ' ') {
return strings.TrimSpace(content[len(prefix):])
}
}
// Strategy 3: delegate to stateless helper (heuristic scan + first-@word fallback).
return stripAtMentionBasic(content)
}
func (c *LongConnClient) writeJSON(v interface{}) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn == nil {
return fmt.Errorf("connection closed")
}
_ = c.conn.SetWriteDeadline(time.Now().Add(writeTimeout))
return c.conn.WriteJSON(v)
}
func reconnectDelay(attempt int) time.Duration {
if attempt < 1 {
return defaultReconnectBaseDelay
}
// Cap the exponent to avoid int64 overflow: base (1e9 ns) * 2^shift
// overflows when shift ≥ 34, producing a negative duration that would
// bypass the max-delay check and cause a busy reconnect loop.
// Any shift ≥ 5 already exceeds the 30s max, so 30 is a safe ceiling.
shift := attempt - 1
if shift > 30 {
return defaultReconnectMaxDelay
}
delay := defaultReconnectBaseDelay * (1 << shift)
if delay > defaultReconnectMaxDelay {
delay = defaultReconnectMaxDelay
}
return delay
}
+152
View File
@@ -0,0 +1,152 @@
package wecom
import (
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
ws "github.com/gorilla/websocket"
)
// newTestConn dials a throwaway WebSocket server and returns the client side.
// The server drains frames until the client goes away.
func newTestConn(t *testing.T) *ws.Conn {
t.Helper()
upgrader := ws.Upgrader{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
serverConn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer func() { _ = serverConn.Close() }()
for {
if _, _, err := serverConn.ReadMessage(); err != nil {
return
}
}
}))
t.Cleanup(server.Close)
conn, _, err := ws.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http"), nil)
if err != nil {
t.Fatalf("dial test websocket: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
return conn
}
// connUsable reports whether conn still accepts writes.
func connUsable(t *testing.T, conn *ws.Conn) bool {
t.Helper()
_ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
return conn.WriteMessage(ws.TextMessage, []byte("probe")) == nil
}
func TestCloseConnClosesAndClearsActiveConnection(t *testing.T) {
conn := newTestConn(t)
c := &LongConnClient{conn: conn}
c.closeConn()
if c.conn != nil {
t.Error("closeConn left c.conn set")
}
if connUsable(t, conn) {
t.Error("closeConn did not close the connection")
}
}
func TestStopClosesActiveConnection(t *testing.T) {
conn := newTestConn(t)
c := &LongConnClient{conn: conn}
c.Stop()
if !c.closed.Load() {
t.Error("Stop did not mark the client closed")
}
if c.conn != nil {
t.Error("Stop left c.conn set")
}
if connUsable(t, conn) {
t.Error("Stop did not close the connection")
}
}
func TestCloseConnIfClosesActiveConnection(t *testing.T) {
conn := newTestConn(t)
c := &LongConnClient{conn: conn}
c.closeConnIf(conn)
if c.conn != nil {
t.Error("closeConnIf left c.conn set")
}
if connUsable(t, conn) {
t.Error("closeConnIf did not close the active connection")
}
}
// A heartbeat loop or detached callback handler from a previous connection
// generation must not tear down the connection opened by a later reconnect.
func TestCloseConnIfIgnoresSupersededConnection(t *testing.T) {
stale := newTestConn(t)
active := newTestConn(t)
c := &LongConnClient{conn: active}
c.closeConnIf(stale)
if c.conn != active {
t.Fatal("closeConnIf cleared the active connection on behalf of a stale caller")
}
if !connUsable(t, active) {
t.Fatal("closeConnIf closed the active connection on behalf of a stale caller")
}
}
func TestCloseConnIfIgnoresNilConnection(t *testing.T) {
conn := newTestConn(t)
c := &LongConnClient{conn: conn}
c.closeConnIf(nil)
if c.conn != conn {
t.Error("closeConnIf(nil) cleared the active connection")
}
if !connUsable(t, conn) {
t.Error("closeConnIf(nil) closed the active connection")
}
}
// Exercises the close and write paths together so the race detector can catch
// any regression that reintroduces unsynchronized access to c.conn.
func TestCloseConnRacesWithWriteJSON(t *testing.T) {
for i := 0; i < 20; i++ {
conn := newTestConn(t)
c := &LongConnClient{conn: conn}
var wg sync.WaitGroup
wg.Add(3)
go func() {
defer wg.Done()
_ = c.writeJSON(wsFrame{Cmd: cmdPing})
}()
go func() {
defer wg.Done()
c.closeConnIf(conn)
}()
go func() {
defer wg.Done()
c.closeConn()
}()
wg.Wait()
if c.conn != nil {
t.Fatal("connection pointer still set after concurrent closes")
}
}
}
+157
View File
@@ -0,0 +1,157 @@
package wecom
import (
"testing"
)
func TestStripAtMentionBasic(t *testing.T) {
tests := []struct {
name string
content string
want string
}{
{"no mention", "hello world", "hello world"},
{"empty string", "", ""},
{"mention only no content", "@Bot", "@Bot"},
{"single-word bot double-space", "@Bot /stop", "/stop"},
{"single-word bot single-space", "@Bot /stop", "/stop"},
{"single-word bot double-space chinese", "@Bot 你好世界", "你好世界"},
{"multi-word bot double-space", "@WeKnora Bot /stop", "/stop"},
{"multi-word bot double-space chinese", "@WeKnora Bot 什么是上下文工程", "什么是上下文工程"},
{"multi-word bot single-space command", "@WeKnora Bot /stop", "/stop"},
{"multi-word bot single-space chinese", "@WeKnora Bot 什么是老登", "什么是老登"},
{"leading whitespace", " @Bot hello ", "hello"},
{"double-space in user content", "@Bot hello world", "hello world"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := stripAtMentionBasic(tt.content)
if got != tt.want {
t.Errorf("stripAtMentionBasic(%q) = %q, want %q", tt.content, got, tt.want)
}
})
}
}
func TestLongConnClient_StripAtMention(t *testing.T) {
t.Run("learns bot name from double-space then handles single-space", func(t *testing.T) {
c := &LongConnClient{}
// First message: double-space → learn "WeKnora Bot"
got := c.stripAtMention("@WeKnora Bot 什么是上下文工程")
if got != "什么是上下文工程" {
t.Errorf("first message: got %q, want %q", got, "什么是上下文工程")
}
// Verify bot name was cached
if name, _ := c.botDisplayName.Load().(string); name != "WeKnora Bot" {
t.Errorf("cached bot name = %q, want %q", name, "WeKnora Bot")
}
// Second message: single-space → should use cached name
got = c.stripAtMention("@WeKnora Bot /stop")
if got != "/stop" {
t.Errorf("second message: got %q, want %q", got, "/stop")
}
// Single-space with chinese content
got = c.stripAtMention("@WeKnora Bot 什么是B+树")
if got != "什么是B+树" {
t.Errorf("chinese single-space: got %q, want %q", got, "什么是B+树")
}
})
t.Run("preconfigured bot name", func(t *testing.T) {
c := &LongConnClient{}
c.botDisplayName.Store("WeKnora Bot")
// Single-space should work immediately without learning
got := c.stripAtMention("@WeKnora Bot /stop")
if got != "/stop" {
t.Errorf("got %q, want %q", got, "/stop")
}
})
t.Run("cached name must not partial-match a longer bot name", func(t *testing.T) {
c := &LongConnClient{}
c.botDisplayName.Store("Bot")
// "@BotX /stop" should NOT match cached "Bot" — "BotX" is a different name.
// Falls through to Strategy 3 (strip first @word).
got := c.stripAtMention("@BotX /stop")
if got != "/stop" {
t.Errorf("got %q, want %q", got, "/stop")
}
})
t.Run("NewLongConnClient with bot name", func(t *testing.T) {
c, err := NewLongConnClient("id", "secret", "", "My Bot", nil)
if err != nil {
t.Fatal(err)
}
got := c.stripAtMention("@My Bot /help")
if got != "/help" {
t.Errorf("got %q, want %q", got, "/help")
}
})
t.Run("does not overwrite cached name from user double-spaces", func(t *testing.T) {
c := &LongConnClient{}
c.botDisplayName.Store("Bot")
// User content has double space — should NOT overwrite "Bot" with "Bot hello"
got := c.stripAtMention("@Bot hello world")
if got != "hello world" {
t.Errorf("got %q, want %q", got, "hello world")
}
// Cached name should still be "Bot"
if name, _ := c.botDisplayName.Load().(string); name != "Bot" {
t.Errorf("cached bot name = %q, want %q", name, "Bot")
}
})
t.Run("no mention passthrough", func(t *testing.T) {
c := &LongConnClient{}
got := c.stripAtMention("hello world")
if got != "hello world" {
t.Errorf("got %q, want %q", got, "hello world")
}
})
t.Run("empty string", func(t *testing.T) {
c := &LongConnClient{}
got := c.stripAtMention("")
if got != "" {
t.Errorf("got %q, want %q", got, "")
}
})
t.Run("cold start single-space command", func(t *testing.T) {
c := &LongConnClient{}
// No cached name, single space → heuristic detects " /" boundary
got := c.stripAtMention("@WeKnora Bot /stop")
if got != "/stop" {
t.Errorf("got %q, want %q", got, "/stop")
}
})
t.Run("cold start single-space chinese", func(t *testing.T) {
c := &LongConnClient{}
// No cached name, single space → heuristic detects non-ASCII boundary
got := c.stripAtMention("@WeKnora Bot 什么是老登")
if got != "什么是老登" {
t.Errorf("got %q, want %q", got, "什么是老登")
}
})
t.Run("cold start all-ascii content falls back to first word", func(t *testing.T) {
c := &LongConnClient{}
// No cached name, all ASCII → heuristic can't find boundary, strips first @word
got := c.stripAtMention("@WeKnora Bot hello")
if got != "Bot hello" {
t.Errorf("got %q, want %q", got, "Bot hello")
}
})
}
+71
View File
@@ -0,0 +1,71 @@
package wecom
import (
"strings"
"github.com/Tencent/WeKnora/internal/im"
)
// extractQuoteContent extracts text content from a quoted botMessage.
// Only returns actual textual content. Non-text types (image, file, video, etc.)
// return empty string so that buildQuotedMessage discards them — injecting
// "cannot see image" placeholders causes LLM hallucination from conversation history.
func extractQuoteContent(quote *botMessage) string {
if quote == nil {
return ""
}
switch quote.MsgType {
case "text":
return quote.Text.Content
case "voice":
return quote.Voice.Content // STT result; empty if recognition failed
case "mixed":
// Only keep text parts; skip images/other non-text items entirely.
var parts []string
for _, item := range quote.Mixed.MsgItem {
if item.MsgType == "text" && item.Text.Content != "" {
parts = append(parts, item.Text.Content)
}
}
return strings.Join(parts, "\n")
default:
// image, file, video, unknown — no textual content available, discard.
return ""
}
}
// isQuoteFromBot determines whether a quoted message was sent by the bot.
// Uses two comparison strategies since WeCom's field mapping is undocumented.
func isQuoteFromBot(quote *botMessage, aiBotID string) bool {
if quote == nil {
return false
}
if quote.From.UserID != "" && aiBotID != "" && quote.From.UserID == aiBotID {
return true
}
if quote.AiBotID != "" && quote.AiBotID == aiBotID {
return true
}
return false
}
// buildQuotedMessage constructs a QuotedMessage from a botMessage quote.
// Returns nil only if quote itself is nil.
// For non-text quotes (image, file, video), Content is empty and NonTextType
// is set so downstream can generate an LLM instruction instead of a placeholder.
func buildQuotedMessage(quote *botMessage, aiBotID string) *im.QuotedMessage {
if quote == nil {
return nil
}
content := extractQuoteContent(quote)
result := &im.QuotedMessage{
MessageID: quote.MsgID,
Content: content,
SenderID: quote.From.UserID,
IsBotMessage: isQuoteFromBot(quote, aiBotID),
}
if content == "" {
result.NonTextType = quote.MsgType
}
return result
}
+256
View File
@@ -0,0 +1,256 @@
package wecom
import (
"testing"
)
func TestExtractQuoteContent(t *testing.T) {
tests := []struct {
name string
msg *botMessage
want string
}{
{
name: "nil quote",
msg: nil,
want: "",
},
{
name: "text message",
msg: &botMessage{MsgType: "text", Text: struct {
Content string `json:"content"`
}{Content: "hello world"}},
want: "hello world",
},
{
name: "voice with STT content",
msg: &botMessage{MsgType: "voice", Voice: struct {
Content string `json:"content"`
}{Content: "speech text"}},
want: "speech text",
},
{
name: "voice without STT content",
msg: &botMessage{MsgType: "voice"},
want: "",
},
{
name: "image returns empty (discarded to prevent hallucination)",
msg: &botMessage{MsgType: "image"},
want: "",
},
{
name: "file returns empty",
msg: &botMessage{MsgType: "file"},
want: "",
},
{
name: "video returns empty",
msg: &botMessage{MsgType: "video"},
want: "",
},
{
name: "unknown type returns empty",
msg: &botMessage{MsgType: "location"},
want: "",
},
{
name: "mixed text and image keeps only text",
msg: &botMessage{
MsgType: "mixed",
Mixed: struct {
MsgItem []botMixedItem `json:"msg_item"`
}{
MsgItem: []botMixedItem{
{MsgType: "text", Text: struct {
Content string `json:"content"`
}{Content: "part1"}},
{MsgType: "image"},
{MsgType: "text", Text: struct {
Content string `json:"content"`
}{Content: "part2"}},
},
},
},
want: "part1\npart2",
},
{
name: "mixed with only images returns empty",
msg: &botMessage{
MsgType: "mixed",
Mixed: struct {
MsgItem []botMixedItem `json:"msg_item"`
}{
MsgItem: []botMixedItem{
{MsgType: "image"},
},
},
},
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractQuoteContent(tt.msg)
if got != tt.want {
t.Errorf("extractQuoteContent() = %q, want %q", got, tt.want)
}
})
}
}
func TestIsQuoteFromBot(t *testing.T) {
tests := []struct {
name string
quote *botMessage
aiBotID string
want bool
}{
{
name: "nil quote",
quote: nil,
aiBotID: "bot123",
want: false,
},
{
name: "matching from.userid",
quote: &botMessage{From: struct {
UserID string `json:"userid"`
}{UserID: "bot123"}, AiBotID: ""},
aiBotID: "bot123",
want: true,
},
{
name: "matching aibotid",
quote: &botMessage{From: struct {
UserID string `json:"userid"`
}{UserID: "other"}, AiBotID: "bot123"},
aiBotID: "bot123",
want: true,
},
{
name: "no match",
quote: &botMessage{From: struct {
UserID string `json:"userid"`
}{UserID: "user456"}, AiBotID: ""},
aiBotID: "bot123",
want: false,
},
{
name: "empty aiBotID",
quote: &botMessage{From: struct {
UserID string `json:"userid"`
}{UserID: "bot123"}},
aiBotID: "",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isQuoteFromBot(tt.quote, tt.aiBotID)
if got != tt.want {
t.Errorf("isQuoteFromBot() = %v, want %v", got, tt.want)
}
})
}
}
func TestBuildQuotedMessage(t *testing.T) {
t.Run("nil quote returns nil", func(t *testing.T) {
if got := buildQuotedMessage(nil, "bot1"); got != nil {
t.Errorf("expected nil, got %+v", got)
}
})
t.Run("text quote builds correctly", func(t *testing.T) {
msg := &botMessage{
MsgID: "msg-001",
MsgType: "text",
AiBotID: "bot1",
From: struct {
UserID string `json:"userid"`
}{UserID: "bot1"},
Text: struct {
Content string `json:"content"`
}{Content: "hello"},
}
got := buildQuotedMessage(msg, "bot1")
if got == nil {
t.Fatal("expected non-nil")
}
if got.MessageID != "msg-001" {
t.Errorf("MessageID = %q, want %q", got.MessageID, "msg-001")
}
if got.Content != "hello" {
t.Errorf("Content = %q, want %q", got.Content, "hello")
}
if got.SenderID != "bot1" {
t.Errorf("SenderID = %q, want %q", got.SenderID, "bot1")
}
if !got.IsBotMessage {
t.Error("IsBotMessage = false, want true")
}
if got.NonTextType != "" {
t.Errorf("NonTextType = %q, want empty for text quote", got.NonTextType)
}
})
t.Run("image quote sets NonTextType", func(t *testing.T) {
msg := &botMessage{
MsgID: "msg-img",
MsgType: "image",
From: struct {
UserID string `json:"userid"`
}{UserID: "user456"},
}
got := buildQuotedMessage(msg, "bot1")
if got == nil {
t.Fatal("expected non-nil for image quote")
}
if got.Content != "" {
t.Errorf("Content = %q, want empty", got.Content)
}
if got.NonTextType != "image" {
t.Errorf("NonTextType = %q, want %q", got.NonTextType, "image")
}
})
t.Run("video quote sets NonTextType", func(t *testing.T) {
msg := &botMessage{
MsgID: "msg-vid",
MsgType: "video",
From: struct {
UserID string `json:"userid"`
}{UserID: "user456"},
}
got := buildQuotedMessage(msg, "bot1")
if got == nil {
t.Fatal("expected non-nil for video quote")
}
if got.NonTextType != "video" {
t.Errorf("NonTextType = %q, want %q", got.NonTextType, "video")
}
})
t.Run("user quote is not bot", func(t *testing.T) {
msg := &botMessage{
MsgID: "msg-002",
MsgType: "text",
From: struct {
UserID string `json:"userid"`
}{UserID: "user456"},
Text: struct {
Content string `json:"content"`
}{Content: "question"},
}
got := buildQuotedMessage(msg, "bot1")
if got == nil {
t.Fatal("expected non-nil")
}
if got.IsBotMessage {
t.Error("IsBotMessage = true, want false")
}
})
}
+37
View File
@@ -0,0 +1,37 @@
package wecom
import (
"testing"
"time"
)
// TestReconnectDelayOverflow guards against the bug where large attempt
// counters produced a negative time.Duration, bypassing the max-delay
// cap and causing a busy reconnect loop (e.g. "reconnecting in -1s").
func TestReconnectDelayOverflow(t *testing.T) {
tests := []struct {
name string
attempt int
want time.Duration
}{
{"zero attempt clamps to base", 0, defaultReconnectBaseDelay},
{"negative attempt clamps to base", -5, defaultReconnectBaseDelay},
{"attempt 1 = base", 1, defaultReconnectBaseDelay},
{"attempt 2 = 2× base", 2, 2 * defaultReconnectBaseDelay},
{"attempt 5 = 16s", 5, 16 * time.Second},
{"attempt 6 caps at max", 6, defaultReconnectMaxDelay},
{"attempt 100 caps at max", 100, defaultReconnectMaxDelay},
{"attempt 4601 caps at max (regression)", 4601, defaultReconnectMaxDelay},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := reconnectDelay(tt.attempt)
if got != tt.want {
t.Errorf("reconnectDelay(%d) = %v, want %v", tt.attempt, got, tt.want)
}
if got <= 0 {
t.Errorf("reconnectDelay(%d) returned non-positive %v — would cause busy loop", tt.attempt, got)
}
})
}
}
+705
View File
@@ -0,0 +1,705 @@
// Package wecom implements the WeCom (企业微信) IM adapter for WeKnora.
//
// WeCom Smart Bot flow:
// 1. User sends a message to the bot (direct or @mention in group)
// 2. WeCom calls our callback URL with the encrypted message
// 3. We decrypt, parse, and return an immediate response (or stream response)
// 4. For streaming: respond with msgtype="stream", WeCom pulls subsequent chunks via refresh callbacks
//
// Reference: https://developer.work.weixin.qq.com/document/path/101031
package wecom
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/sha1"
"encoding/base64"
"encoding/binary"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"path"
"sort"
"strings"
"sync"
"time"
"github.com/Tencent/WeKnora/internal/im"
"github.com/Tencent/WeKnora/internal/logger"
secutils "github.com/Tencent/WeKnora/internal/utils"
"github.com/gin-gonic/gin"
)
var httpClient = secutils.NewSSRFSafeHTTPClient(secutils.SSRFSafeHTTPClientConfig{
Timeout: 30 * time.Second,
MaxRedirects: 5,
})
const (
defaultAPIBaseURL = "https://qyapi.weixin.qq.com"
wecomPKCS7BlockSize = 32
)
// extraHostFromEndpoint returns the lowercased hostname from endpoint if it
// differs from defaultEndpoint; otherwise returns "". Used to extend the SSRF
// allowlist for private deployments.
func extraHostFromEndpoint(endpoint, defaultEndpoint string) string {
if endpoint == "" || endpoint == defaultEndpoint {
return ""
}
if u, err := url.Parse(endpoint); err == nil {
return strings.ToLower(u.Hostname())
}
return ""
}
// validateEndpointURL checks that a custom endpoint URL uses a secure scheme
// and does not point to a private/internal address, preventing accidental
// credential leakage (e.g. access tokens sent to a rogue server).
func validateEndpointURL(endpoint, defaultEndpoint, requiredScheme string) error {
if endpoint == "" || endpoint == defaultEndpoint {
return nil
}
u, err := url.Parse(endpoint)
if err != nil {
return fmt.Errorf("invalid endpoint URL: %w", err)
}
if u.Scheme != requiredScheme {
return fmt.Errorf("endpoint must use %s:// scheme, got %s://", requiredScheme, u.Scheme)
}
if err := secutils.ValidateURLForSSRF(endpoint); err != nil {
return fmt.Errorf("%w (for private deployments on internal networks, add the hostname to SSRF_WHITELIST)", err)
}
return nil
}
// WebhookAdapter implements im.Adapter for WeCom in webhook (self-built app callback) mode.
// Messages arrive via HTTP callback; replies are sent via the WeCom REST API.
type WebhookAdapter struct {
corpID string
token string
encodingAESKey string
aesKey []byte
agentSecret string
corpAgentID int
apiBaseURL string // WeCom API base URL (e.g. "https://qyapi.weixin.qq.com")
extraAllowedHost string // hostname from apiBaseURL for SSRF allowlist (empty if default)
// Token cache
tokenMu sync.Mutex
tokenCache string
tokenExpAt time.Time
}
// Compile-time check that WebhookAdapter implements im.FileDownloader.
var _ im.FileDownloader = (*WebhookAdapter)(nil)
// NewWebhookAdapter creates a new WeCom webhook adapter.
// apiBaseURL overrides the default WeCom API base URL; empty uses the public cloud endpoint.
func NewWebhookAdapter(corpID, agentSecret, token, encodingAESKey string, corpAgentID int, apiBaseURL string) (*WebhookAdapter, error) {
// Decode the AES key from base64
aesKey, err := base64.StdEncoding.DecodeString(encodingAESKey + "=")
if err != nil {
return nil, fmt.Errorf("decode encoding_aes_key: %w", err)
}
if apiBaseURL == "" {
apiBaseURL = defaultAPIBaseURL
}
apiBaseURL = strings.TrimRight(apiBaseURL, "/")
if err := validateEndpointURL(apiBaseURL, defaultAPIBaseURL, "https"); err != nil {
return nil, fmt.Errorf("invalid api_base_url: %w", err)
}
return &WebhookAdapter{
corpID: corpID,
token: token,
encodingAESKey: encodingAESKey,
aesKey: aesKey,
agentSecret: agentSecret,
corpAgentID: corpAgentID,
apiBaseURL: apiBaseURL,
extraAllowedHost: extraHostFromEndpoint(apiBaseURL, defaultAPIBaseURL),
}, nil
}
// Platform returns the platform identifier.
func (a *WebhookAdapter) Platform() im.Platform {
return im.PlatformWeCom
}
// VerifyCallback verifies the WeCom callback signature.
func (a *WebhookAdapter) VerifyCallback(c *gin.Context) error {
timestamp := c.Query("timestamp")
nonce := c.Query("nonce")
msgSignature := c.Query("msg_signature")
// For GET requests (URL verification), use echostr
// For POST requests (message callback), use request body's Encrypt field
var encrypt string
if c.Request.Method == http.MethodGet {
encrypt = c.Query("echostr")
} else {
var body callbackRequestBody
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
return fmt.Errorf("read request body: %w", err)
}
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
if err := xml.Unmarshal(bodyBytes, &body); err != nil {
return fmt.Errorf("unmarshal xml body: %w", err)
}
encrypt = body.Encrypt
}
if !a.verifySignature(msgSignature, timestamp, nonce, encrypt) {
return fmt.Errorf("invalid signature")
}
return nil
}
// HandleURLVerification handles the WeCom URL verification (GET request).
func (a *WebhookAdapter) HandleURLVerification(c *gin.Context) bool {
if c.Request.Method != http.MethodGet {
return false
}
echoStr := c.Query("echostr")
if echoStr == "" {
return false
}
// Decrypt the echostr and return it
decrypted, err := a.decrypt(echoStr)
if err != nil {
logger.Errorf(c.Request.Context(), "[WeCom] Failed to decrypt echostr: %v", err)
c.String(http.StatusBadRequest, "decrypt failed")
return true
}
c.String(http.StatusOK, string(decrypted))
return true
}
// ParseCallback parses a WeCom callback into a unified IncomingMessage.
func (a *WebhookAdapter) ParseCallback(c *gin.Context) (*im.IncomingMessage, error) {
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
var body callbackRequestBody
if err := xml.Unmarshal(bodyBytes, &body); err != nil {
return nil, fmt.Errorf("unmarshal xml: %w", err)
}
// Decrypt the message
decrypted, err := a.decrypt(body.Encrypt)
if err != nil {
return nil, fmt.Errorf("decrypt message: %w", err)
}
// Log raw decrypted message for debugging
logger.Debugf(c.Request.Context(), "[WeCom] Raw decrypted callback: %s", string(decrypted))
var msg wecomMessage
if err := xml.Unmarshal(decrypted, &msg); err != nil {
return nil, fmt.Errorf("unmarshal decrypted message: %w", err)
}
logger.Debugf(c.Request.Context(), "[WeCom] Parsed webhook message: msgid=%s msgtype=%s from=%s content=%q picurl=%q mediaid=%q",
msg.MsgID, msg.MsgType, msg.FromUserName, msg.Content, msg.PicUrl, msg.MediaId)
// Determine chat type
chatType := im.ChatTypeDirect
chatID := ""
isGroup := msg.ChatID != ""
if isGroup {
chatType = im.ChatTypeGroup
chatID = msg.ChatID
}
switch msg.MsgType {
case "text":
// Strip @mention in group chat (same issue as long connection mode).
// Webhook adapter has no persistent state, so use the standalone helper.
textContent := msg.Content
if isGroup {
textContent = stripAtMentionBasic(textContent)
}
return &im.IncomingMessage{
Platform: im.PlatformWeCom,
MessageType: im.MessageTypeText,
UserID: msg.FromUserName,
UserName: msg.FromUserName,
ChatID: chatID,
ChatType: chatType,
Content: strings.TrimSpace(textContent),
MessageID: msg.MsgID,
}, nil
case "image":
// Image via webhook: has PicUrl (direct download) and MediaId
if msg.PicUrl == "" && msg.MediaId == "" {
return nil, nil
}
fileKey := msg.PicUrl
if fileKey == "" {
fileKey = msg.MediaId
}
return &im.IncomingMessage{
Platform: im.PlatformWeCom,
MessageType: im.MessageTypeImage,
UserID: msg.FromUserName,
UserName: msg.FromUserName,
ChatID: chatID,
ChatType: chatType,
MessageID: msg.MsgID,
FileKey: fileKey,
FileName: msg.MsgID + ".png",
}, nil
default:
logger.Infof(c.Request.Context(), "[WeCom] Ignoring unsupported message type: %s", msg.MsgType)
return nil, nil
}
}
// SendReply sends a reply message via WeCom API.
// For group chats, it tries the appchat API first to reply in the group,
// then falls back to sending a direct message to the user.
func (a *WebhookAdapter) SendReply(ctx context.Context, incoming *im.IncomingMessage, reply *im.ReplyMessage) error {
accessToken, err := a.getAccessToken(ctx)
if err != nil {
return fmt.Errorf("get access token: %w", err)
}
// For group chats, try sending to the group via appchat API first.
// This works for groups created via /cgi-bin/appchat/create.
if incoming.ChatType == im.ChatTypeGroup && incoming.ChatID != "" {
if err := a.sendToAppChat(ctx, accessToken, incoming.ChatID, reply); err == nil {
return nil
}
logger.Debugf(ctx, "[WeCom] appchat/send failed for chat=%s, falling back to touser: %v", incoming.ChatID, err)
}
// Fallback (or direct message): send to the user directly.
return a.sendToUser(ctx, accessToken, incoming.UserID, reply)
}
// sendToAppChat sends a message to a WeCom group chat via the appchat API.
// Reference: https://developer.work.weixin.qq.com/document/path/90248
func (a *WebhookAdapter) sendToAppChat(ctx context.Context, accessToken, chatID string, reply *im.ReplyMessage) error {
payload := map[string]interface{}{
"chatid": chatID,
"msgtype": "markdown",
"markdown": map[string]string{
"content": reply.Content,
},
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal payload: %w", err)
}
sendURL := fmt.Sprintf("%s/cgi-bin/appchat/send?access_token=%s", a.apiBaseURL, accessToken)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, sendURL, bytes.NewReader(payloadBytes))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("send appchat message: %w", err)
}
defer resp.Body.Close()
var result struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return fmt.Errorf("decode response: %w", err)
}
if result.ErrCode != 0 {
return fmt.Errorf("appchat api error: code=%d msg=%s", result.ErrCode, result.ErrMsg)
}
return nil
}
// sendToUser sends a message directly to a user via the application message API.
// Reference: https://developer.work.weixin.qq.com/document/path/90236
func (a *WebhookAdapter) sendToUser(ctx context.Context, accessToken, userID string, reply *im.ReplyMessage) error {
payload := map[string]interface{}{
"touser": userID,
"msgtype": "markdown",
"agentid": a.corpAgentID,
"markdown": map[string]string{
"content": reply.Content,
},
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal payload: %w", err)
}
sendURL := fmt.Sprintf("%s/cgi-bin/message/send?access_token=%s", a.apiBaseURL, accessToken)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, sendURL, bytes.NewReader(payloadBytes))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("send message: %w", err)
}
defer resp.Body.Close()
var result struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return fmt.Errorf("decode response: %w", err)
}
if result.ErrCode != 0 {
return fmt.Errorf("wecom api error: code=%d msg=%s", result.ErrCode, result.ErrMsg)
}
return nil
}
// getAccessToken retrieves the WeCom access token with caching.
// WeCom tokens expire in 7200 seconds (2 hours); we cache with a safety margin.
func (a *WebhookAdapter) getAccessToken(ctx context.Context) (string, error) {
a.tokenMu.Lock()
defer a.tokenMu.Unlock()
if a.tokenCache != "" && time.Now().Before(a.tokenExpAt) {
return a.tokenCache, nil
}
tokenURL := fmt.Sprintf("%s/cgi-bin/gettoken?corpid=%s&corpsecret=%s",
a.apiBaseURL, a.corpID, a.agentSecret)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, tokenURL, nil)
if err != nil {
return "", fmt.Errorf("create request: %w", err)
}
resp, err := httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("request access token: %w", err)
}
defer resp.Body.Close()
var result struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"` // seconds
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("decode token response: %w", err)
}
if result.ErrCode != 0 {
return "", fmt.Errorf("get token error: code=%d msg=%s", result.ErrCode, result.ErrMsg)
}
a.tokenCache = result.AccessToken
// Cache with 5-minute safety margin
ttl := time.Duration(result.ExpiresIn) * time.Second
if ttl > 5*time.Minute {
ttl -= 5 * time.Minute
}
a.tokenExpAt = time.Now().Add(ttl)
return a.tokenCache, nil
}
// verifySignature verifies the WeCom callback signature using constant-time comparison.
func (a *WebhookAdapter) verifySignature(signature, timestamp, nonce, encrypt string) bool {
parts := []string{a.token, timestamp, nonce, encrypt}
sort.Strings(parts)
combined := strings.Join(parts, "")
hash := sha1.New()
hash.Write([]byte(combined))
computed := fmt.Sprintf("%x", hash.Sum(nil))
return hmac.Equal([]byte(computed), []byte(signature))
}
// decrypt decrypts a WeCom AES-encrypted message.
func (a *WebhookAdapter) decrypt(encrypted string) ([]byte, error) {
ciphertext, err := base64.StdEncoding.DecodeString(encrypted)
if err != nil {
return nil, fmt.Errorf("base64 decode: %w", err)
}
block, err := aes.NewCipher(a.aesKey)
if err != nil {
return nil, fmt.Errorf("new cipher: %w", err)
}
if len(ciphertext) < aes.BlockSize {
return nil, fmt.Errorf("ciphertext too short")
}
if len(ciphertext)%aes.BlockSize != 0 {
return nil, fmt.Errorf("ciphertext length is not a multiple of AES block size")
}
iv := a.aesKey[:aes.BlockSize]
mode := cipher.NewCBCDecrypter(block, iv)
mode.CryptBlocks(ciphertext, ciphertext)
// Remove and verify PKCS#7 padding
padLen := int(ciphertext[len(ciphertext)-1])
if padLen > wecomPKCS7BlockSize || padLen == 0 || padLen > len(ciphertext) {
return nil, fmt.Errorf("invalid padding")
}
for i := 0; i < padLen; i++ {
if ciphertext[len(ciphertext)-1-i] != byte(padLen) {
return nil, fmt.Errorf("invalid padding")
}
}
plaintext := ciphertext[:len(ciphertext)-padLen]
// WeCom format: random(16) + msg_len(4) + msg + corp_id
if len(plaintext) < 20 {
return nil, fmt.Errorf("plaintext too short")
}
msgLen := binary.BigEndian.Uint32(plaintext[16:20])
if uint32(len(plaintext)) < 20+msgLen {
return nil, fmt.Errorf("message length mismatch")
}
msgBytes := plaintext[20 : 20+msgLen]
// Verify corp_id from plaintext tail
corpIDBytes := plaintext[20+msgLen:]
if string(corpIDBytes) != a.corpID {
return nil, fmt.Errorf("corp_id mismatch: expected %s, got %s", a.corpID, string(corpIDBytes))
}
return msgBytes, nil
}
// callbackRequestBody is the XML structure of a WeCom callback request body.
type callbackRequestBody struct {
XMLName xml.Name `xml:"xml"`
ToUserName string `xml:"ToUserName"`
Encrypt string `xml:"Encrypt"`
AgentID string `xml:"AgentID"`
}
// wecomMessage is the decrypted WeCom message structure.
// Supports text, image, voice, video, location, and link message types.
// Reference: https://developer.work.weixin.qq.com/document/path/90375
type wecomMessage struct {
XMLName xml.Name `xml:"xml"`
ToUserName string `xml:"ToUserName"`
FromUserName string `xml:"FromUserName"`
CreateTime int64 `xml:"CreateTime"`
MsgType string `xml:"MsgType"`
Content string `xml:"Content"` // text
PicUrl string `xml:"PicUrl"` // image: download URL
MediaId string `xml:"MediaId"` // image/voice/video: media ID for download
Format string `xml:"Format"` // voice: audio format (amr/speex)
ThumbMediaId string `xml:"ThumbMediaId"` // video: thumbnail media ID
MsgID string `xml:"MsgId"`
AgentID string `xml:"AgentID"`
ChatID string `xml:"ChatId"`
}
// ──────────────────────────────────────────────────────────────────────
// File download support for WeCom webhook mode
// ──────────────────────────────────────────────────────────────────────
// DownloadFile downloads a file/image from WeCom.
// For webhook mode, images come with MediaId (temporary media) which can be
// downloaded via the GetMedia API, or PicUrl for direct download.
func (a *WebhookAdapter) DownloadFile(ctx context.Context, msg *im.IncomingMessage) (io.ReadCloser, string, error) {
if msg.FileKey == "" {
return nil, "", fmt.Errorf("no file key (URL or media_id) in message")
}
fileName := msg.FileName
if fileName == "" {
fileName = msg.FileKey
}
// If FileKey looks like a URL, download directly
if strings.HasPrefix(msg.FileKey, "http://") || strings.HasPrefix(msg.FileKey, "https://") {
return downloadFromURL(ctx, msg.FileKey, fileName, a.extraAllowedHost)
}
// Otherwise treat as media_id, download via temporary media API
accessToken, err := a.getAccessToken(ctx)
if err != nil {
return nil, "", fmt.Errorf("get access token: %w", err)
}
apiURL := fmt.Sprintf("%s/cgi-bin/media/get?access_token=%s&media_id=%s",
a.apiBaseURL, accessToken, msg.FileKey)
return downloadFromURL(ctx, apiURL, fileName, a.extraAllowedHost)
}
// downloadFromURL performs a GET request and returns the response body.
// It tries to resolve the real filename from HTTP response headers:
// 1. Content-Disposition: attachment; filename="xxx.pdf"
// 2. Content-Type → extension mapping (fallback for platforms like WeCom that
// don't provide the original filename in the callback JSON)
func downloadFromURL(ctx context.Context, rawURL, fileName string, extraAllowedHost string) (io.ReadCloser, string, error) {
// SSRF protection: reject internal/private URLs unless on the WeCom API allowlist.
if !isAllowedIMAPIHost(rawURL, extraAllowedHost) {
if err := secutils.ValidateURLForSSRF(rawURL); err != nil {
return nil, "", fmt.Errorf("URL rejected for security reasons: %v", err)
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, "", fmt.Errorf("create request: %w", err)
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, "", fmt.Errorf("download: %w", err)
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, "", fmt.Errorf("download failed: status=%d", resp.StatusCode)
}
logger.Debugf(ctx, "[WeCom] Download response: status=%d content-type=%s content-disposition=%s",
resp.StatusCode, resp.Header.Get("Content-Type"), resp.Header.Get("Content-Disposition"))
// Try to extract filename from Content-Disposition header.
// Supports both standard filename and RFC 5987 filename* parameters.
if cd := resp.Header.Get("Content-Disposition"); cd != "" {
if _, params, err := mime.ParseMediaType(cd); err == nil {
// Prefer filename* (RFC 5987, already decoded by mime.ParseMediaType)
if fn := params["filename"]; fn != "" {
fileName = fn
}
} else {
// Fallback: manual extraction for malformed headers
if idx := strings.Index(cd, "filename="); idx >= 0 {
extracted := strings.Trim(cd[idx+len("filename="):], "\" ")
if extracted != "" {
fileName = extracted
}
}
}
}
// URL-decode the filename if it contains percent-encoded characters.
// Some servers (e.g. WeCom COS) return URL-encoded Chinese filenames.
if strings.Contains(fileName, "%") {
if decoded, err := url.QueryUnescape(fileName); err == nil && decoded != "" {
fileName = decoded
}
}
// Also try to extract a meaningful filename from the URL path itself,
// in case Content-Disposition is missing but the URL contains the real name.
if !strings.Contains(fileName, ".") {
if u, err := url.Parse(rawURL); err == nil {
base := path.Base(u.Path)
if base != "" && base != "." && base != "/" && strings.Contains(base, ".") {
// URL-decode the path component as well
if decoded, err := url.QueryUnescape(base); err == nil {
fileName = decoded
} else {
fileName = base
}
}
}
}
// If filename still has no extension, try to infer from Content-Type.
// This handles platforms (e.g. WeCom aibot) where the callback only provides
// a hash ID as the filename without any extension.
if !strings.Contains(fileName, ".") {
if ext := contentTypeToExt(resp.Header.Get("Content-Type")); ext != "" {
fileName = fileName + "." + ext
}
}
return resp.Body, fileName, nil
}
// allowedIMAPIHosts lists IM platform API hosts that are trusted for file downloads.
// URLs pointing to these hosts bypass isSSRFSafeURL checks because the WeCom API
// itself returns these URLs in callback payloads (e.g. temporary media links).
var allowedIMAPIHosts = []string{
"qyapi.weixin.qq.com",
"api.weixin.qq.com",
"open.work.weixin.qq.com",
"novac2c.cdn.weixin.qq.com",
"ilinkai.weixin.qq.com",
}
// isAllowedIMAPIHost returns true if rawURL points to a known IM platform API host.
// extraHost is an optional additional trusted hostname (e.g. from a private deployment).
func isAllowedIMAPIHost(rawURL string, extraHost string) bool {
u, err := url.Parse(rawURL)
if err != nil {
return false
}
hostname := strings.ToLower(u.Hostname())
if extraHost != "" && hostname == extraHost {
return true
}
for _, allowed := range allowedIMAPIHosts {
if hostname == allowed {
return true
}
}
return false
}
// contentTypeToExt maps common Content-Type values to file extensions.
func contentTypeToExt(ct string) string {
// Normalize: take only the media type, ignore parameters like charset
if idx := strings.Index(ct, ";"); idx >= 0 {
ct = strings.TrimSpace(ct[:idx])
}
ct = strings.ToLower(ct)
mapping := map[string]string{
"application/pdf": "pdf",
"application/msword": "doc",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
"application/vnd.ms-excel": "xls",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
"application/vnd.ms-powerpoint": "ppt",
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
"text/plain": "txt",
"text/markdown": "md",
"text/csv": "csv",
"image/png": "png",
"image/jpeg": "jpg",
"image/gif": "gif",
"image/webp": "webp",
}
return mapping[ct]
}
+85
View File
@@ -0,0 +1,85 @@
package wecom
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"encoding/binary"
"testing"
)
func TestWebhookAdapterDecryptPadding(t *testing.T) {
key := []byte("0123456789abcdef0123456789abcdef")
corpID := "ww_corp_id"
adapter := &WebhookAdapter{aesKey: key, corpID: corpID}
tests := []struct {
name string
message string
wantPadLen int
}{
{
name: "padding below AES block size",
message: "message with seven-byte pad",
wantPadLen: 7,
},
{
name: "padding above AES block size",
message: "message with 19",
wantPadLen: 19,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
encrypted, padLen := encryptWeComTestPayload(t, key, corpID, []byte(tt.message))
if padLen != tt.wantPadLen {
t.Fatalf("test fixture padding = %d, want %d", padLen, tt.wantPadLen)
}
got, err := adapter.decrypt(encrypted)
if err != nil {
t.Fatalf("decrypt() error = %v", err)
}
if !bytes.Equal(got, []byte(tt.message)) {
t.Fatalf("decrypt() = %q, want %q", got, tt.message)
}
})
}
}
func TestWebhookAdapterDecryptRejectsNonBlockAlignedCiphertext(t *testing.T) {
adapter := &WebhookAdapter{
aesKey: []byte("0123456789abcdef0123456789abcdef"),
corpID: "ww_corp_id",
}
encrypted := base64.StdEncoding.EncodeToString(make([]byte, aes.BlockSize+1))
if _, err := adapter.decrypt(encrypted); err == nil {
t.Fatal("decrypt() succeeded for ciphertext with a non-block-aligned length")
}
}
func encryptWeComTestPayload(t *testing.T, key []byte, corpID string, message []byte) (string, int) {
t.Helper()
plaintext := make([]byte, 16)
messageLength := make([]byte, 4)
binary.BigEndian.PutUint32(messageLength, uint32(len(message)))
plaintext = append(plaintext, messageLength...)
plaintext = append(plaintext, message...)
plaintext = append(plaintext, []byte(corpID)...)
padLen := wecomPKCS7BlockSize - len(plaintext)%wecomPKCS7BlockSize
plaintext = append(plaintext, bytes.Repeat([]byte{byte(padLen)}, padLen)...)
block, err := aes.NewCipher(key)
if err != nil {
t.Fatalf("new cipher: %v", err)
}
ciphertext := make([]byte, len(plaintext))
cipher.NewCBCEncrypter(block, key[:aes.BlockSize]).CryptBlocks(ciphertext, plaintext)
return base64.StdEncoding.EncodeToString(ciphertext), padLen
}
+181
View File
@@ -0,0 +1,181 @@
package wecom
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"fmt"
"io"
"github.com/Tencent/WeKnora/internal/im"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/gin-gonic/gin"
)
// Compile-time checks.
var (
_ im.Adapter = (*WSAdapter)(nil)
_ im.StreamSender = (*WSAdapter)(nil)
_ im.FileDownloader = (*WSAdapter)(nil)
)
// WSAdapter implements im.Adapter and im.StreamSender for WeCom in WebSocket
// (long connection) mode. It delegates to the WebSocket LongConnClient.
// The webhook methods (VerifyCallback, ParseCallback, HandleURLVerification) are no-ops
// since messages arrive via WebSocket, not HTTP.
type WSAdapter struct {
client *LongConnClient
}
// NewWSAdapter creates an adapter backed by a WeCom long connection client.
func NewWSAdapter(client *LongConnClient) *WSAdapter {
return &WSAdapter{client: client}
}
func (a *WSAdapter) Platform() im.Platform {
return im.PlatformWeCom
}
func (a *WSAdapter) VerifyCallback(c *gin.Context) error {
return fmt.Errorf("WeCom bot adapter does not support webhook callbacks")
}
func (a *WSAdapter) ParseCallback(c *gin.Context) (*im.IncomingMessage, error) {
return nil, fmt.Errorf("WeCom bot adapter does not support webhook callbacks")
}
func (a *WSAdapter) HandleURLVerification(c *gin.Context) bool {
return false
}
func (a *WSAdapter) SendReply(ctx context.Context, incoming *im.IncomingMessage, reply *im.ReplyMessage) error {
return a.client.SendReply(ctx, incoming, reply)
}
// ── StreamSender implementation ──
func (a *WSAdapter) StartStream(ctx context.Context, incoming *im.IncomingMessage) (string, error) {
return a.client.StartStream(ctx, incoming)
}
func (a *WSAdapter) UpdateStreamContent(ctx context.Context, incoming *im.IncomingMessage, streamID string, fullContent string) error {
return a.client.UpdateStreamContent(ctx, incoming, streamID, fullContent)
}
func (a *WSAdapter) FinalizeStream(ctx context.Context, incoming *im.IncomingMessage, streamID string, finalContent string) error {
return a.client.FinalizeStream(ctx, incoming, streamID, finalContent)
}
func (a *WSAdapter) SendStreamChunk(ctx context.Context, incoming *im.IncomingMessage, streamID string, content string) error {
return a.client.UpdateStreamContent(ctx, incoming, streamID, content)
}
func (a *WSAdapter) EndStream(ctx context.Context, incoming *im.IncomingMessage, streamID string) error {
return a.client.EndStream(ctx, incoming, streamID)
}
// ── FileDownloader implementation ──
// WeCom aibot provides AES-256-CBC encrypted URLs for image/file/video messages.
// Each message carries its own aeskey for decryption.
func (a *WSAdapter) DownloadFile(ctx context.Context, msg *im.IncomingMessage) (io.ReadCloser, string, error) {
if msg.FileKey == "" {
return nil, "", fmt.Errorf("no file URL in message")
}
fileName := msg.FileName
if fileName == "" {
fileName = msg.FileKey
}
// Download the (encrypted) file content
reader, fileName, err := downloadFromURL(ctx, msg.FileKey, fileName, a.client.extraAllowedHost)
if err != nil {
return nil, "", err
}
// If an AES key is provided, the downloaded content is AES-256-CBC encrypted
// and must be decrypted before use. This is the case for WeCom aibot long
// connection mode where each file/image message carries a per-message aeskey.
aesKeyB64 := msg.Extra["aes_key"]
if aesKeyB64 == "" {
// No encryption — return raw content (e.g. webhook mode uses media API)
return reader, fileName, nil
}
// Read all encrypted content
encryptedData, err := io.ReadAll(reader)
reader.Close()
if err != nil {
return nil, "", fmt.Errorf("read encrypted file: %w", err)
}
logger.Debugf(ctx, "[WeCom] Decrypting file: name=%s encrypted_size=%d aes_key_len=%d",
fileName, len(encryptedData), len(aesKeyB64))
// Decrypt
decrypted, err := decryptAESCBC(encryptedData, aesKeyB64)
if err != nil {
return nil, "", fmt.Errorf("decrypt file: %w", err)
}
logger.Debugf(ctx, "[WeCom] File decrypted: name=%s decrypted_size=%d", fileName, len(decrypted))
return io.NopCloser(bytes.NewReader(decrypted)), fileName, nil
}
// decryptAESCBC decrypts data encrypted with AES-256-CBC using PKCS#7 padding.
// The aesKeyB64 is the base64-encoded AES key provided per-message by WeCom.
// IV is the first 16 bytes of the decoded AES key.
func decryptAESCBC(ciphertext []byte, aesKeyB64 string) ([]byte, error) {
// WeCom's per-message aeskey is base64-encoded (43 chars → 32 bytes after decode)
aesKey, err := base64.StdEncoding.DecodeString(aesKeyB64 + "=")
if err != nil {
// Try without padding
aesKey, err = base64.RawStdEncoding.DecodeString(aesKeyB64)
if err != nil {
return nil, fmt.Errorf("base64 decode aes key: %w", err)
}
}
if len(aesKey) < 16 {
return nil, fmt.Errorf("aes key too short: %d bytes", len(aesKey))
}
block, err := aes.NewCipher(aesKey)
if err != nil {
return nil, fmt.Errorf("new aes cipher: %w", err)
}
if len(ciphertext) < aes.BlockSize {
return nil, fmt.Errorf("ciphertext too short: %d bytes", len(ciphertext))
}
if len(ciphertext)%aes.BlockSize != 0 {
return nil, fmt.Errorf("ciphertext not a multiple of block size: %d bytes", len(ciphertext))
}
// IV = first 16 bytes of the AES key
iv := aesKey[:aes.BlockSize]
mode := cipher.NewCBCDecrypter(block, iv)
plaintext := make([]byte, len(ciphertext))
mode.CryptBlocks(plaintext, ciphertext)
// Remove PKCS#7 padding
if len(plaintext) == 0 {
return nil, fmt.Errorf("empty plaintext after decryption")
}
padLen := int(plaintext[len(plaintext)-1])
if padLen > aes.BlockSize || padLen == 0 || padLen > len(plaintext) {
// No valid PKCS#7 padding — return as-is (some implementations may not pad)
return plaintext, nil
}
// Verify padding bytes
for i := 0; i < padLen; i++ {
if plaintext[len(plaintext)-1-i] != byte(padLen) {
// Invalid padding — return as-is
return plaintext, nil
}
}
return plaintext[:len(plaintext)-padLen], nil
}
+610
View File
@@ -0,0 +1,610 @@
package yunzhijia
import (
"bytes"
"context"
"crypto/hmac"
"encoding/json"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"path/filepath"
"strings"
"sync"
"time"
"unicode"
"unicode/utf8"
"github.com/gin-gonic/gin"
"github.com/Tencent/WeKnora/internal/im"
"github.com/Tencent/WeKnora/internal/logger"
)
// textMessageType is the Yunzhijia message type value for plain text messages.
const textMessageType = 2
// markdownFormatType requests Yunzhijia to render Content as Markdown.
const markdownFormatType = "markdown"
// Compile-time check.
var _ im.Adapter = (*Adapter)(nil)
var _ im.FileDownloader = (*Adapter)(nil)
var yunzhijiaAuthURL = "https://yunzhijia.com/api/oauth2_v12/auth/getAppAccessToken"
var yunzhijiaDownloadFileBaseURL = "https://yunzhijia.com/gateway/docrest/doc/file/downloadfileOpen"
var validateDownloadFileURL = func(rawURL string) error {
_, err := validateEndpointURL(rawURL, "https", "yunzhijia.com")
return err
}
// maxDownloadFileSize caps the size of a file downloaded from Yunzhijia and read
// into memory, to avoid unbounded memory usage from a large/malicious response.
const maxDownloadFileSize = 32 << 20 // 32 MiB
// maxDownloadRedirects limits how many redirects DownloadFile follows manually.
// The shared httpClient disables automatic redirects (SSRF safety), but the
// Yunzhijia download endpoint may 302 to a signed URL, so we follow a single hop
// while re-validating the target host stays within the allowed suffix.
const maxDownloadRedirects = 1
// Adapter implements im.Adapter for Yunzhijia (云之家).
type Adapter struct {
sendMsgURL string
secret string
appID string
appSecret string
httpClient *http.Client
allowedWebhookHostSuffix string
tokenMu sync.Mutex
accessToken string
accessTokenExpiresAt time.Time
}
// NewAdapter creates a Yunzhijia adapter.
func NewAdapter(sendMsgURL, secret, appID, appSecret string, timeoutSeconds int, allowedHostSuffix string) *Adapter {
if timeoutSeconds <= 0 {
timeoutSeconds = 10
}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = nil
transport.DialContext = safeDialContext
return &Adapter{
sendMsgURL: strings.TrimSpace(sendMsgURL),
secret: strings.TrimSpace(secret),
appID: strings.TrimSpace(appID),
appSecret: strings.TrimSpace(appSecret),
allowedWebhookHostSuffix: strings.TrimSpace(allowedHostSuffix),
httpClient: &http.Client{
Timeout: time.Duration(timeoutSeconds) * time.Second,
Transport: transport,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
},
}
}
func (a *Adapter) Platform() im.Platform {
return im.PlatformYunzhijia
}
func (a *Adapter) HandleURLVerification(c *gin.Context) bool {
return false
}
// VerifyCallback verifies the Yunzhijia callback signature (HmacSHA1).
// If secret is not configured, verification is skipped.
func (a *Adapter) VerifyCallback(c *gin.Context) error {
if a.secret == "" {
return nil
}
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
return fmt.Errorf("read body: %w", err)
}
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
var msg callbackMessage
if err := json.Unmarshal(bodyBytes, &msg); err != nil {
return fmt.Errorf("parse callback for verification: %w", err)
}
// Read the sign header (case-insensitive via gin's GetHeader).
sign := c.GetHeader("sign")
if sign == "" {
sign = c.GetHeader("Sign")
}
if sign == "" {
sign = c.GetHeader("SIGN")
}
if sign == "" {
return fmt.Errorf("missing sign header")
}
expected := computeSignature(a.secret, &msg)
if !hmac.Equal([]byte(sign), []byte(expected)) {
return fmt.Errorf("invalid signature")
}
return nil
}
// ParseCallback parses a Yunzhijia webhook callback into an IncomingMessage.
// Returns nil for non-text messages or empty content.
func (a *Adapter) ParseCallback(c *gin.Context) (*im.IncomingMessage, error) {
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
var msg callbackMessage
if err := json.Unmarshal(bodyBytes, &msg); err != nil {
return nil, fmt.Errorf("parse callback: %w", err)
}
return toIncomingMessage(c.Request.Context(), &msg), nil
}
func toIncomingMessage(ctx context.Context, msg *callbackMessage) *im.IncomingMessage {
if msg.Type != textMessageType {
logger.Infof(ctx,
"[Yunzhijia] Skip non-text message: type=%d msgId=%s", msg.Type, msg.MsgID)
return nil
}
param, err := parseMessageParam(msg.MsgParam)
if err != nil {
logger.Warnf(ctx, "[Yunzhijia] Failed to parse msgParam: msgId=%s err=%v", msg.MsgID, err)
}
if param != nil {
// Keep topic diagnostics to identifiers only: they are sufficient to
// validate reply-root stability without recording user message content.
logger.Infof(ctx, "[Yunzhijia] Thread callback: msg_id=%s reply_msg_id=%s reply_root_msg_id=%s",
msg.MsgID, param.ReplyMsgID, param.ReplyRootMsgID)
}
image, hasImage := param.firstImage()
content := strings.TrimSpace(msg.Content)
if content == "" && !hasImage {
logger.Infof(ctx,
"[Yunzhijia] Skip empty content: msgId=%s", msg.MsgID)
return nil
}
// Conversation bots should only receive messages explicitly addressed to them.
var mentioned bool
content, mentioned = cleanAtMention(content, msg.RobotName)
if !mentioned {
mentioned = messageParamMentionsRobot(param, msg.RobotID)
}
if !mentioned {
logger.Infof(ctx, "[Yunzhijia] Skip message without robot mention: msgId=%s", msg.MsgID)
return nil
}
if content == "" && !hasImage {
logger.Infof(ctx,
"[Yunzhijia] Skip after cleaning @mention: msgId=%s", msg.MsgID)
return nil
}
userID := firstNonEmpty(msg.OperatorOpenid, msg.OperatorOID, msg.OpenID, msg.SenderID, msg.OperatorID, msg.OperatorUserID)
userName := firstNonEmpty(msg.OperatorName, msg.SenderName)
chatType := im.ChatTypeGroup
chatID := firstNonEmpty(msg.GroupID, msg.RobotID)
incoming := &im.IncomingMessage{
Platform: im.PlatformYunzhijia,
MessageType: im.MessageTypeText,
UserID: userID,
UserName: userName,
ChatID: chatID,
ChatType: chatType,
Content: content,
MessageID: msg.MsgID,
ThreadID: threadIDForMessage(msg.MsgID, param),
Extra: map[string]string{
"robot_id": msg.RobotID,
"robot_name": msg.RobotName,
"group_id": msg.GroupID,
"group_type": fmt.Sprintf("%d", msg.GroupType),
"operator_name": userName,
"time": fmt.Sprintf("%d", msg.Time),
},
}
if hasImage {
incoming.MessageType = im.MessageTypeImage
incoming.FileKey = image.Data
incoming.FileName = defaultImageFileName(msg.MsgID)
incoming.Extra["yunzhijia_image_width"] = fmt.Sprintf("%d", image.Width)
incoming.Extra["yunzhijia_image_height"] = fmt.Sprintf("%d", image.Height)
}
return incoming
}
// threadIDForMessage returns the message-thread identifier received from
// Yunzhijia. A top-level message starts a new thread with its own msgId;
// replies carry replyRootMsgId (normally the bot message at the thread root).
func threadIDForMessage(messageID string, param *messageParam) string {
if param != nil && param.ReplyRootMsgID != "" {
return param.ReplyRootMsgID
}
return messageID
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
func messageParamMentionsRobot(param *messageParam, robotID string) bool {
if param == nil || robotID == "" {
return false
}
for _, notifyTo := range param.NotifyTo {
if notifyTo == robotID {
return true
}
}
for _, desc := range param.Desc {
if desc.Type == "at" && desc.Data == robotID {
return true
}
}
return false
}
func defaultImageFileName(msgID string) string {
if msgID == "" {
return "yunzhijia-image.png"
}
return msgID + ".png"
}
// cleanAtMention removes @RobotName from the beginning of user content.
func cleanAtMention(content, robotName string) (string, bool) {
if robotName == "" {
return content, false
}
prefix := "@" + robotName
trimmed := strings.TrimLeft(content, " \t")
if !strings.HasPrefix(trimmed, prefix) {
return content, false
}
rest := trimmed[len(prefix):]
if rest == "" {
return "", true
}
separator, _ := utf8.DecodeRuneInString(rest)
if !unicode.IsSpace(separator) && !strings.ContainsRune("::,,", separator) {
return content, false
}
return strings.TrimLeftFunc(rest, func(r rune) bool {
return unicode.IsSpace(r) || strings.ContainsRune("::,,", r)
}), true
}
// SendReply sends a reply to Yunzhijia via the configured sendMsgUrl.
func (a *Adapter) SendReply(ctx context.Context, incoming *im.IncomingMessage, reply *im.ReplyMessage) error {
if a.sendMsgURL == "" {
return fmt.Errorf("yunzhijia send_msg_url is not configured")
}
// Validate the send URL to prevent SSRF.
if err := a.validateSendURL(); err != nil {
return err
}
payload := sendMessagePayload{
MsgType: textMessageType,
Content: reply.Content,
// WeKnora replies are authored in Markdown by default (see im.ReplyMessage.Content),
// so request Markdown rendering from Yunzhijia unless explicitly overridden via
// reply.Extra["yunzhijia_format_type"] (empty string disables the param entirely).
Param: &sendMessageParam{FormatType: markdownFormatType},
}
if reply.Extra != nil {
if formatType, ok := reply.Extra["yunzhijia_format_type"]; ok {
payload.Param.FormatType = formatType
}
}
if incoming.MessageID != "" {
payload.ParamType = 3
payload.Param.ReplyMsgID = incoming.MessageID
payload.Param.IsReference = true
payload.Param.ReplySummary = incoming.Content
payload.Param.ReplyPersonName = incoming.UserName
} else if payload.Param.FormatType == "" {
// Keep the prior opt-out behaviour for non-reference replies.
payload.Param = nil
}
// When groupType == 3, don't set notifyParams (per reference implementation).
groupType := ""
if incoming.Extra != nil {
groupType = incoming.Extra["group_type"]
}
if groupType != "3" && incoming.UserID != "" {
payload.NotifyParams = []notifyParam{
{
Type: "openIds",
Values: []string{incoming.UserID},
},
}
}
jsonBody, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal reply: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.sendMsgURL, bytes.NewReader(jsonBody))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := a.httpClient.Do(req)
if err != nil {
return fmt.Errorf("send reply: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("yunzhijia sendMsgUrl returned %d: %s", resp.StatusCode, string(respBody))
}
return nil
}
// DownloadFile downloads a file/image resource sent by Yunzhijia.
func (a *Adapter) DownloadFile(ctx context.Context, msg *im.IncomingMessage) (io.ReadCloser, string, error) {
if msg.FileKey == "" {
return nil, "", fmt.Errorf("yunzhijia file id is required")
}
fileID := strings.TrimSpace(msg.FileKey)
if err := validateFileID(fileID); err != nil {
return nil, "", fmt.Errorf("invalid yunzhijia file id: %w", err)
}
accessToken, err := a.getAppAccessToken(ctx)
if err != nil {
return nil, "", err
}
downloadURL := buildDownloadFileURL(fileID)
if err := validateDownloadFileURL(downloadURL); err != nil {
return nil, "", fmt.Errorf("download url rejected: %w", err)
}
resp, err := a.fetchDownload(ctx, downloadURL, accessToken)
if err != nil {
return nil, "", err
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
resp.Body.Close()
return nil, "", fmt.Errorf("download file returned %d: %s", resp.StatusCode, string(body))
}
fileName := msg.FileName
if fileName == "" {
fileName = fileID
}
fileName = resolveDownloadFileName(fileName, resp)
return newLimitedReadCloser(resp.Body, maxDownloadFileSize), fileName, nil
}
// fetchDownload performs the download request, following at most maxDownloadRedirects
// redirects. Each redirect target is re-validated against the allowed host suffix so
// the request cannot be redirected off the trusted domain. The bearer token is only
// sent on the initial request and never forwarded across a redirect.
func (a *Adapter) fetchDownload(ctx context.Context, downloadURL, accessToken string) (*http.Response, error) {
currentURL := downloadURL
for redirects := 0; ; redirects++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, currentURL, nil)
if err != nil {
return nil, fmt.Errorf("create download request: %w", err)
}
if redirects == 0 {
req.Header.Set("Authorization", "Bearer "+accessToken)
}
resp, err := a.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("download file: %w", err)
}
if resp.StatusCode < http.StatusMultipleChoices || resp.StatusCode >= http.StatusBadRequest {
return resp, nil
}
// Redirect (3xx): re-validate the target before following.
location := strings.TrimSpace(resp.Header.Get("Location"))
resp.Body.Close()
if redirects >= maxDownloadRedirects {
return nil, fmt.Errorf("download file: too many redirects")
}
if location == "" {
return nil, fmt.Errorf("download file: redirect without Location")
}
resolved, err := resolveRedirectURL(currentURL, location)
if err != nil {
return nil, fmt.Errorf("download file: invalid redirect location: %w", err)
}
if err := validateDownloadFileURL(resolved); err != nil {
return nil, fmt.Errorf("download redirect rejected: %w", err)
}
currentURL = resolved
}
}
func resolveRedirectURL(base, location string) (string, error) {
baseURL, err := url.Parse(base)
if err != nil {
return "", err
}
locURL, err := url.Parse(location)
if err != nil {
return "", err
}
return baseURL.ResolveReference(locURL).String(), nil
}
// newLimitedReadCloser wraps rc so that reading more than limit bytes returns an
// error instead of silently truncating, while Close still closes the underlying body.
func newLimitedReadCloser(rc io.ReadCloser, limit int64) io.ReadCloser {
return &limitedReadCloser{r: io.LimitReader(rc, limit+1), body: rc, limit: limit}
}
type limitedReadCloser struct {
r io.Reader
body io.Closer
limit int64
read int64
}
func (l *limitedReadCloser) Read(p []byte) (int, error) {
n, err := l.r.Read(p)
l.read += int64(n)
if l.read > l.limit {
return n, fmt.Errorf("yunzhijia file exceeds max download size of %d bytes", l.limit)
}
return n, err
}
func (l *limitedReadCloser) Close() error {
return l.body.Close()
}
func buildDownloadFileURL(fileID string) string {
u, _ := url.Parse(yunzhijiaDownloadFileBaseURL)
query := url.Values{}
query.Set("fileId", fileID)
u.RawQuery = query.Encode()
return u.String()
}
func validateFileID(fileID string) error {
if fileID == "" {
return fmt.Errorf("empty")
}
if len(fileID) > 256 {
return fmt.Errorf("too long")
}
if strings.ContainsAny(fileID, `/\?#&`) {
return fmt.Errorf("contains path or query separators")
}
for _, r := range fileID {
if unicode.IsControl(r) || unicode.IsSpace(r) {
return fmt.Errorf("contains whitespace or control character")
}
}
return nil
}
func (a *Adapter) getAppAccessToken(ctx context.Context) (string, error) {
if a.appID == "" || a.appSecret == "" {
return "", fmt.Errorf("yunzhijia app_id and app_secret are required to download files")
}
a.tokenMu.Lock()
if a.accessToken != "" && time.Now().Before(a.accessTokenExpiresAt) {
token := a.accessToken
a.tokenMu.Unlock()
return token, nil
}
a.tokenMu.Unlock()
payload := map[string]any{
"appId": a.appID,
"secret": a.appSecret,
"timestamp": time.Now().UnixMilli(),
}
body, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("marshal yunzhijia token request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, yunzhijiaAuthURL, bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("create yunzhijia token request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := a.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("request yunzhijia app access token: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read yunzhijia token response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("yunzhijia token endpoint returned %d: %s", resp.StatusCode, string(respBody))
}
var tokenResp appAccessTokenResponse
if err := json.Unmarshal(respBody, &tokenResp); err != nil {
return "", fmt.Errorf("parse yunzhijia token response: %w", err)
}
if !tokenResp.Success || tokenResp.ErrorCode != 0 || tokenResp.Data.AccessToken == "" {
return "", fmt.Errorf("yunzhijia token response failed: errorCode=%d error=%v", tokenResp.ErrorCode, tokenResp.Error)
}
expiresIn := tokenResp.Data.ExpireIn
if expiresIn <= 0 {
expiresIn = 3600
}
expiresAt := time.Now().Add(time.Duration(expiresIn) * time.Second)
if expiresIn > 120 {
expiresAt = expiresAt.Add(-60 * time.Second)
}
a.tokenMu.Lock()
a.accessToken = tokenResp.Data.AccessToken
a.accessTokenExpiresAt = expiresAt
a.tokenMu.Unlock()
return tokenResp.Data.AccessToken, nil
}
func resolveDownloadFileName(fallback string, resp *http.Response) string {
fileName := fallback
if cd := resp.Header.Get("Content-Disposition"); cd != "" {
if _, params, err := mime.ParseMediaType(cd); err == nil {
if name := strings.TrimSpace(params["filename"]); name != "" {
fileName = name
}
}
}
if filepath.Ext(fileName) == "" {
switch strings.ToLower(strings.TrimSpace(strings.Split(resp.Header.Get("Content-Type"), ";")[0])) {
case "image/jpeg":
fileName += ".jpg"
case "image/png":
fileName += ".png"
case "image/gif":
fileName += ".gif"
}
}
return fileName
}
// validateSendURL checks that sendMsgUrl is safe to call (HTTPS, no internal IPs, allowed host).
func (a *Adapter) validateSendURL() error {
_, err := validateEndpointURL(a.sendMsgURL, "https", a.allowedWebhookHostSuffix)
if err != nil {
return fmt.Errorf("invalid send_msg_url: %w", err)
}
return nil
}
+490
View File
@@ -0,0 +1,490 @@
package yunzhijia
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/Tencent/WeKnora/internal/im"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func TestVerifyCallbackSignature(t *testing.T) {
gin.SetMode(gin.TestMode)
msg := callbackMessage{
Type: 2, RobotID: "robot", RobotName: "WeKnora", OperatorOpenid: "user",
OperatorName: "User", Time: 123, MsgID: "message", Content: "@WeKnora hello",
}
body, err := json.Marshal(msg)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/callback", bytes.NewReader(body))
req.Header.Set("Sign", computeSignature("secret", &msg))
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = req
adapter := NewAdapter("https://www.yunzhijia.com/send", "secret", "", "", 10, "yunzhijia.com")
if err := adapter.VerifyCallback(c); err != nil {
t.Fatalf("VerifyCallback() error = %v", err)
}
parsed, err := adapter.ParseCallback(c)
if err != nil {
t.Fatalf("ParseCallback() error = %v", err)
}
if parsed == nil || parsed.Content != "hello" {
t.Fatalf("parsed message = %#v, want content hello", parsed)
}
}
func TestToIncomingMessageRequiresRobotMention(t *testing.T) {
msg := &callbackMessage{
Type: 2, RobotID: "robot", RobotName: "WeKnora", OperatorOpenid: "user",
OperatorName: "User", Time: 123, MsgID: "message", Content: "hello",
}
if got := toIncomingMessage(t.Context(), msg); got != nil {
t.Fatalf("toIncomingMessage() = %#v, want nil without robot mention", got)
}
}
func TestCleanAtMentionRequiresNameBoundary(t *testing.T) {
if _, mentioned := cleanAtMention("@WeKnoraPlus hello", "WeKnora"); mentioned {
t.Fatal("longer user name must not be treated as a robot mention")
}
if got, mentioned := cleanAtMention("@WeKnora:你好", "WeKnora"); !mentioned || got != "你好" {
t.Fatalf("cleanAtMention() = %q, %v; want 你好, true", got, mentioned)
}
}
func TestToIncomingMessageParsesMsgParamImage(t *testing.T) {
msg := &callbackMessage{
Type: 2,
MsgType: 23,
RobotID: "BOT-1",
RobotName: "Websocket",
GroupID: "group-1",
OperatorOpenid: "user",
OperatorName: "User",
Time: 123,
MsgID: "message",
Content: "@Websocket 图片测试一下看看[图片]",
GroupType: 2,
MsgParam: `{"desc":[{"data":"BOT-1","length":10,"start":0,"type":"at"},` +
`{"data":"file-1","h":1032,"length":4,"start":19,"type":"image","w":1920}],` +
`"notifyTo":["BOT-1"],"notifyType":1}`,
}
got := toIncomingMessage(t.Context(), msg)
if got == nil {
t.Fatal("toIncomingMessage() returned nil")
}
if got.MessageType != im.MessageTypeImage {
t.Fatalf("MessageType = %q, want image", got.MessageType)
}
if got.FileKey != "file-1" {
t.Fatalf("FileKey = %q, want file-1", got.FileKey)
}
if got.FileName != "message.png" {
t.Fatalf("FileName = %q, want message.png", got.FileName)
}
if got.ChatID != "group-1" {
t.Fatalf("ChatID = %q, want group-1", got.ChatID)
}
if got.Content != "图片测试一下看看[图片]" {
t.Fatalf("Content = %q", got.Content)
}
if got.Extra["yunzhijia_image_width"] != "1920" || got.Extra["yunzhijia_image_height"] != "1032" {
t.Fatalf("image dimensions extra = %#v", got.Extra)
}
}
func TestToIncomingMessageUsesYunzhijiaReplyRootAsThreadID(t *testing.T) {
msg := &callbackMessage{
Type: 2,
RobotID: "BOT-1",
RobotName: "Websocket",
GroupID: "group-1",
OperatorOpenid: "user-b",
Time: 123,
MsgID: "message-b",
Content: "@Websocket follow up",
MsgParam: `{"replyMsgId":"BOT-answer-1","replyRootMsgId":"BOT-answer-1","replyPersonId":"BOT-1"}`,
}
got := toIncomingMessage(t.Context(), msg)
if got == nil {
t.Fatal("toIncomingMessage() returned nil")
}
if got.ThreadID != "BOT-answer-1" {
t.Fatalf("ThreadID = %q, want BOT-answer-1", got.ThreadID)
}
}
func TestThreadIDForTopLevelMessage(t *testing.T) {
if got := threadIDForMessage("message-a", &messageParam{}); got != "message-a" {
t.Fatalf("threadIDForMessage() = %q, want message-a", got)
}
}
func TestToIncomingMessageAcceptsStringNotifyType(t *testing.T) {
msg := &callbackMessage{
Type: 2, RobotID: "BOT-1", RobotName: "Websocket", OperatorOpenid: "user",
Time: 123, MsgID: "message", Content: "reply text",
MsgParam: `{"notifyTo":["BOT-1"],"notifyType":"1","replyMsgId":"BOT-answer"}`,
}
if got := toIncomingMessage(t.Context(), msg); got == nil {
t.Fatal("toIncomingMessage() = nil; string notifyType must not prevent bot mention detection")
}
}
func TestToIncomingMessageAcceptsMsgParamMention(t *testing.T) {
msg := &callbackMessage{
Type: 2,
RobotID: "BOT-1",
RobotName: "Websocket",
OperatorOpenid: "user",
OperatorName: "User",
Time: 123,
MsgID: "message",
Content: "图片测试一下看看[图片]",
MsgParam: `{"desc":[{"data":"BOT-1","type":"at"},{"data":"file-1","type":"image"}]}`,
}
got := toIncomingMessage(t.Context(), msg)
if got == nil {
t.Fatal("toIncomingMessage() returned nil")
}
if got.FileKey != "file-1" {
t.Fatalf("FileKey = %q, want file-1", got.FileKey)
}
}
func TestToIncomingMessageAcceptsImageWithoutText(t *testing.T) {
msg := &callbackMessage{
Type: 2,
RobotID: "BOT-1",
RobotName: "Websocket",
OperatorOpenid: "user",
OperatorName: "User",
Time: 123,
MsgID: "message",
Content: "",
MsgParam: `{"desc":[{"data":"BOT-1","type":"at"},{"data":"file-1","type":"image"}]}`,
}
got := toIncomingMessage(t.Context(), msg)
if got == nil {
t.Fatal("toIncomingMessage() returned nil")
}
if got.MessageType != im.MessageTypeImage || got.FileKey != "file-1" || got.Content != "" {
t.Fatalf("incoming = %#v", got)
}
}
func TestSendReplyAcceptsAny2xxAndBuildsPayload(t *testing.T) {
adapter := NewAdapter("https://www.yunzhijia.com/send", "", "", "", 10, "yunzhijia.com")
var payload sendMessagePayload
adapter.httpClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
t.Fatalf("decode payload: %v", err)
}
return &http.Response{
StatusCode: http.StatusNoContent,
Body: io.NopCloser(bytes.NewReader(nil)),
Header: make(http.Header),
}, nil
})}
incoming := &im.IncomingMessage{UserID: "user", Extra: map[string]string{"group_type": "1"}}
if err := adapter.SendReply(context.Background(), incoming, &im.ReplyMessage{Content: "answer"}); err != nil {
t.Fatalf("SendReply() error = %v", err)
}
if payload.MsgType != textMessageType || payload.Content != "answer" {
t.Fatalf("payload = %#v", payload)
}
if len(payload.NotifyParams) != 1 || len(payload.NotifyParams[0].Values) != 1 || payload.NotifyParams[0].Values[0] != "user" {
t.Fatalf("notify params = %#v", payload.NotifyParams)
}
if payload.Param == nil || payload.Param.FormatType != markdownFormatType {
t.Fatalf("expected markdown format param by default, got %#v", payload.Param)
}
}
func TestSendReplyReferencesIncomingMessage(t *testing.T) {
adapter := NewAdapter("https://www.yunzhijia.com/send", "", "", "", 10, "yunzhijia.com")
var payload sendMessagePayload
adapter.httpClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
t.Fatalf("decode payload: %v", err)
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"data":{"msgId":"BOT-answer-1"}}`)),
Header: make(http.Header),
}, nil
})}
incoming := &im.IncomingMessage{MessageID: "message-a", Content: "question", UserName: "Alice"}
reply := &im.ReplyMessage{Content: "answer"}
if err := adapter.SendReply(context.Background(), incoming, reply); err != nil {
t.Fatalf("SendReply() error = %v", err)
}
if payload.ParamType != 3 || payload.Param == nil || payload.Param.ReplyMsgID != "message-a" || !payload.Param.IsReference {
t.Fatalf("reference payload = %#v", payload)
}
}
func TestSendReplyFormatTypeOverride(t *testing.T) {
adapter := NewAdapter("https://www.yunzhijia.com/send", "", "", "", 10, "yunzhijia.com")
var payload sendMessagePayload
adapter.httpClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
t.Fatalf("decode payload: %v", err)
}
return &http.Response{
StatusCode: http.StatusNoContent,
Body: io.NopCloser(bytes.NewReader(nil)),
Header: make(http.Header),
}, nil
})}
incoming := &im.IncomingMessage{UserID: "user"}
// Empty string explicitly disables the param.
if err := adapter.SendReply(context.Background(), incoming, &im.ReplyMessage{
Content: "answer",
Extra: map[string]string{"yunzhijia_format_type": ""},
}); err != nil {
t.Fatalf("SendReply() error = %v", err)
}
if payload.Param != nil {
t.Fatalf("expected no param when format type overridden to empty, got %#v", payload.Param)
}
// Non-empty override replaces the default markdown formatType.
if err := adapter.SendReply(context.Background(), incoming, &im.ReplyMessage{
Content: "answer",
Extra: map[string]string{"yunzhijia_format_type": "text"},
}); err != nil {
t.Fatalf("SendReply() error = %v", err)
}
if payload.Param == nil || payload.Param.FormatType != "text" {
t.Fatalf("expected overridden format type 'text', got %#v", payload.Param)
}
}
func TestBuildDownloadFileURL(t *testing.T) {
got := buildDownloadFileURL("file-1")
if got != "https://yunzhijia.com/gateway/docrest/doc/file/downloadfileOpen?fileId=file-1" {
t.Fatalf("download URL = %q", got)
}
}
func TestDownloadFile(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/oauth2_v12/auth/getAppAccessToken":
if r.Method != http.MethodPost {
t.Fatalf("token method = %s", r.Method)
}
var tokenReq map[string]any
if err := json.NewDecoder(r.Body).Decode(&tokenReq); err != nil {
t.Fatalf("decode token request: %v", err)
}
if tokenReq["appId"] != "app-id" || tokenReq["secret"] != "app-secret" {
t.Fatalf("token request = %#v", tokenReq)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":{"accessToken":"token-1","expireIn":7136},"error":null,"errorCode":0,"success":true}`))
case "/gateway/docrest/doc/file/downloadfileOpen":
if r.Header.Get("Authorization") != "Bearer token-1" {
t.Fatalf("Authorization = %q", r.Header.Get("Authorization"))
}
if r.URL.Query().Get("fileId") != "file-1" {
t.Fatalf("query = %q", r.URL.RawQuery)
}
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write([]byte("image-bytes"))
default:
t.Fatalf("path = %q", r.URL.Path)
}
}))
defer server.Close()
oldAuthURL := yunzhijiaAuthURL
oldBaseURL := yunzhijiaDownloadFileBaseURL
oldValidator := validateDownloadFileURL
yunzhijiaAuthURL = server.URL + "/api/oauth2_v12/auth/getAppAccessToken"
yunzhijiaDownloadFileBaseURL = server.URL + "/gateway/docrest/doc/file/downloadfileOpen"
validateDownloadFileURL = func(string) error { return nil }
defer func() {
yunzhijiaAuthURL = oldAuthURL
yunzhijiaDownloadFileBaseURL = oldBaseURL
validateDownloadFileURL = oldValidator
}()
adapter := NewAdapter("https://www.yunzhijia.com/send", "", "app-id", "app-secret", 10, "yunzhijia.com")
adapter.httpClient = server.Client()
reader, fileName, err := adapter.DownloadFile(context.Background(), &im.IncomingMessage{
FileKey: "file-1",
FileName: "message.png",
})
if err != nil {
t.Fatalf("DownloadFile() error = %v", err)
}
defer reader.Close()
body, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("read body: %v", err)
}
if string(body) != "image-bytes" || fileName != "message.png" {
t.Fatalf("body=%q fileName=%q", string(body), fileName)
}
}
func TestDownloadFileFollowsSingleRedirect(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/oauth2_v12/auth/getAppAccessToken":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":{"accessToken":"token-1","expireIn":7136},"error":null,"errorCode":0,"success":true}`))
case "/gateway/docrest/doc/file/downloadfileOpen":
http.Redirect(w, r, "/cdn/file-1", http.StatusFound)
case "/cdn/file-1":
// Bearer token must NOT be forwarded across the redirect.
if r.Header.Get("Authorization") != "" {
t.Fatalf("Authorization forwarded to redirect target: %q", r.Header.Get("Authorization"))
}
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write([]byte("image-bytes"))
default:
t.Fatalf("path = %q", r.URL.Path)
}
}))
defer server.Close()
oldAuthURL := yunzhijiaAuthURL
oldBaseURL := yunzhijiaDownloadFileBaseURL
oldValidator := validateDownloadFileURL
yunzhijiaAuthURL = server.URL + "/api/oauth2_v12/auth/getAppAccessToken"
yunzhijiaDownloadFileBaseURL = server.URL + "/gateway/docrest/doc/file/downloadfileOpen"
validateDownloadFileURL = func(string) error { return nil }
defer func() {
yunzhijiaAuthURL = oldAuthURL
yunzhijiaDownloadFileBaseURL = oldBaseURL
validateDownloadFileURL = oldValidator
}()
adapter := NewAdapter("https://www.yunzhijia.com/send", "", "app-id", "app-secret", 10, "yunzhijia.com")
adapter.httpClient = noRedirectClient(server)
reader, _, err := adapter.DownloadFile(context.Background(), &im.IncomingMessage{FileKey: "file-1", FileName: "message.png"})
if err != nil {
t.Fatalf("DownloadFile() error = %v", err)
}
defer reader.Close()
body, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("read body: %v", err)
}
if string(body) != "image-bytes" {
t.Fatalf("body = %q", string(body))
}
}
// noRedirectClient returns a client bound to the test server transport that does
// not auto-follow redirects, mirroring the production adapter's CheckRedirect.
func noRedirectClient(server *httptest.Server) *http.Client {
client := server.Client()
client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
}
return client
}
func TestDownloadFileRejectsRedirectOffAllowedHost(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/oauth2_v12/auth/getAppAccessToken":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":{"accessToken":"token-1","expireIn":7136},"error":null,"errorCode":0,"success":true}`))
case "/gateway/docrest/doc/file/downloadfileOpen":
http.Redirect(w, r, "https://evil.example.com/steal", http.StatusFound)
default:
t.Fatalf("path = %q", r.URL.Path)
}
}))
defer server.Close()
oldAuthURL := yunzhijiaAuthURL
oldBaseURL := yunzhijiaDownloadFileBaseURL
oldValidator := validateDownloadFileURL
yunzhijiaAuthURL = server.URL + "/api/oauth2_v12/auth/getAppAccessToken"
yunzhijiaDownloadFileBaseURL = server.URL + "/gateway/docrest/doc/file/downloadfileOpen"
// Allow the test server host but reject anything off-host, mimicking the
// real yunzhijia.com suffix check for redirect targets.
validateDownloadFileURL = func(rawURL string) error {
if strings.Contains(rawURL, "evil.example.com") {
return fmt.Errorf("host not allowed")
}
return nil
}
defer func() {
yunzhijiaAuthURL = oldAuthURL
yunzhijiaDownloadFileBaseURL = oldBaseURL
validateDownloadFileURL = oldValidator
}()
adapter := NewAdapter("https://www.yunzhijia.com/send", "", "app-id", "app-secret", 10, "yunzhijia.com")
adapter.httpClient = noRedirectClient(server)
_, _, err := adapter.DownloadFile(context.Background(), &im.IncomingMessage{FileKey: "file-1"})
if err == nil || !strings.Contains(err.Error(), "redirect rejected") {
t.Fatalf("DownloadFile() error = %v, want redirect rejected", err)
}
}
func TestLimitedReadCloserEnforcesMaxSize(t *testing.T) {
reader := newLimitedReadCloser(io.NopCloser(strings.NewReader("0123456789")), 4)
defer reader.Close()
if _, err := io.ReadAll(reader); err == nil || !strings.Contains(err.Error(), "max download size") {
t.Fatalf("read error = %v, want max download size error", err)
}
ok := newLimitedReadCloser(io.NopCloser(strings.NewReader("1234")), 4)
defer ok.Close()
if got, err := io.ReadAll(ok); err != nil || string(got) != "1234" {
t.Fatalf("read got=%q err=%v, want full read within limit", string(got), err)
}
}
func TestDownloadFileRequiresAppCredentials(t *testing.T) {
adapter := NewAdapter("https://www.yunzhijia.com/send", "", "", "", 10, "yunzhijia.com")
_, _, err := adapter.DownloadFile(context.Background(), &im.IncomingMessage{FileKey: "file-1"})
if err == nil || !strings.Contains(err.Error(), "app_id and app_secret") {
t.Fatalf("DownloadFile() error = %v, want app credential error", err)
}
}
func TestValidateFileIDRejectsURLishValues(t *testing.T) {
for _, fileID := range []string{"", "a/b", "a?b", "a&b", "a b"} {
if err := validateFileID(fileID); err == nil {
t.Fatalf("validateFileID(%q) = nil, want error", fileID)
}
}
if err := validateFileID("6a605afe5dda8e000121fcea"); err != nil {
t.Fatalf("validateFileID(valid) error = %v", err)
}
}
+77
View File
@@ -0,0 +1,77 @@
package yunzhijia
import (
"context"
"fmt"
"strconv"
"github.com/Tencent/WeKnora/internal/im"
"github.com/Tencent/WeKnora/internal/logger"
)
// NewFactory returns an im.AdapterFactory for Yunzhijia channels.
// Webhook remains the default; WebSocket uses yzjtoken from send_msg_url.
func NewFactory() im.AdapterFactory {
return func(factoryCtx context.Context, channel *im.IMChannel, msgHandler func(context.Context, *im.IncomingMessage) error) (im.Adapter, context.CancelFunc, error) {
creds, err := im.ParseCredentials(channel.Credentials)
if err != nil {
return nil, nil, fmt.Errorf("parse yunzhijia credentials: %w", err)
}
sendMsgURL := im.GetString(creds, "send_msg_url")
if sendMsgURL == "" {
return nil, nil, fmt.Errorf("yunzhijia send_msg_url is required")
}
secret := im.GetString(creds, "secret")
appID := im.GetString(creds, "app_id")
appSecret := im.GetString(creds, "app_secret")
allowedHostSuffix := im.GetString(creds, "allowed_webhook_host_suffix")
timeoutSeconds := positiveIntCredential(creds, "timeout_seconds", 10)
adapter := NewAdapter(sendMsgURL, secret, appID, appSecret, timeoutSeconds, allowedHostSuffix)
if err := adapter.validateSendURL(); err != nil {
return nil, nil, err
}
mode := im.ResolveMode(channel, "webhook")
switch mode {
case "webhook":
return adapter, nil, nil
case "websocket":
webSocketURL, err := deriveWebSocketURL(sendMsgURL, allowedHostSuffix)
if err != nil {
return nil, nil, err
}
client := NewLongConnClient(webSocketURL, msgHandler)
wsCtx, wsCancel := context.WithCancel(context.Background())
go func() {
if err := client.Start(wsCtx); err != nil && wsCtx.Err() == nil {
logger.Errorf(context.Background(), "[IM] Yunzhijia WebSocket stopped for channel %s: %v", channel.ID, err)
}
}()
stop := func() {
client.Stop()
wsCancel()
}
return adapter, stop, nil
default:
return nil, nil, fmt.Errorf("unsupported yunzhijia mode: %s", mode)
}
}
}
func positiveIntCredential(creds map[string]any, key string, fallback int) int {
switch value := creds[key].(type) {
case float64:
if value > 0 {
return int(value)
}
case string:
if parsed, err := strconv.Atoi(value); err == nil && parsed > 0 {
return parsed
}
}
return fallback
}
+32
View File
@@ -0,0 +1,32 @@
package yunzhijia
import (
"crypto/hmac"
"crypto/sha1"
"encoding/base64"
"fmt"
"strings"
)
// computeSignature builds the signature string from the callback fields
// and returns the Base64(HMAC_SHA1(secret, signatureString)).
//
// The signature string is formed by joining the following fields in order
// with commas: robotId, robotName, operatorOpenid, operatorName, time, msgId, content.
func computeSignature(secret string, msg *callbackMessage) string {
// Build signature string: field1,field2,...,field7
parts := []string{
msg.RobotID,
msg.RobotName,
msg.OperatorOpenid,
msg.OperatorName,
fmt.Sprintf("%d", msg.Time),
msg.MsgID,
msg.Content,
}
signatureString := strings.Join(parts, ",")
mac := hmac.New(sha1.New, []byte(secret))
mac.Write([]byte(signatureString))
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
}
+108
View File
@@ -0,0 +1,108 @@
package yunzhijia
import "encoding/json"
// callbackMessage is the JSON structure that Yunzhijia posts to the webhook.
type callbackMessage struct {
Type int `json:"type"`
MsgType int `json:"msgType"`
EID string `json:"eid"`
ClientID string `json:"clientId"`
RobotID string `json:"robotId"`
RobotName string `json:"robotName"`
OpenID string `json:"openId"`
GroupID string `json:"groupId"`
OperatorOpenid string `json:"operatorOpenid"`
OperatorOID string `json:"operatorOid"`
OperatorID string `json:"operatorId"`
OperatorUserID string `json:"operatorUserId"`
OperatorName string `json:"operatorName"`
SenderID string `json:"senderId"`
SenderName string `json:"senderName"`
Time int64 `json:"time"`
MsgID string `json:"msgId"`
Content string `json:"content"`
GroupType int `json:"groupType"`
MsgParam string `json:"msgParam"`
}
type messageParam struct {
Desc []messageParamDesc `json:"desc"`
NotifyTo []string `json:"notifyTo"`
// Yunzhijia sends this as either a number or a string depending on the
// message client. We do not use its value, but it must be tolerant so a
// reply's notifyTo list remains available for mention detection.
NotifyType json.RawMessage `json:"notifyType"`
ReplyMsgID string `json:"replyMsgId"`
ReplyRootMsgID string `json:"replyRootMsgId"`
}
type messageParamDesc struct {
Type string `json:"type"`
Data string `json:"data"`
Start int `json:"start"`
Length int `json:"length"`
Width int `json:"w"`
Height int `json:"h"`
}
// sendMessagePayload is the JSON body POSTed to sendMsgUrl to reply.
type sendMessagePayload struct {
MsgType int `json:"msgtype"`
Content string `json:"content"`
NotifyParams []notifyParam `json:"notifyParams,omitempty"`
ParamType int `json:"paramType,omitempty"`
Param *sendMessageParam `json:"param,omitempty"`
}
// notifyParam specifies recipients in a Yunzhijia send message request.
type notifyParam struct {
Type string `json:"type"`
Values []string `json:"values"`
}
// sendMessageParam carries extra rendering options for a Yunzhijia reply,
// such as requesting Markdown rendering of Content via formatType.
type sendMessageParam struct {
FormatType string `json:"formatType,omitempty"`
ReplyMsgID string `json:"replyMsgId,omitempty"`
IsReference bool `json:"isReference,omitempty"`
ReplySummary string `json:"replySummary,omitempty"`
ReplyPersonName string `json:"replyPersonName,omitempty"`
}
type appAccessTokenResponse struct {
Data appAccessTokenData `json:"data"`
Error any `json:"error"`
ErrorCode int `json:"errorCode"`
Success bool `json:"success"`
}
type appAccessTokenData struct {
AccessToken string `json:"accessToken"`
ExpireIn int64 `json:"expireIn"`
RefreshToken string `json:"refreshToken"`
}
func (m *messageParam) firstImage() (messageParamDesc, bool) {
if m == nil {
return messageParamDesc{}, false
}
for _, desc := range m.Desc {
if desc.Type == "image" && desc.Data != "" {
return desc, true
}
}
return messageParamDesc{}, false
}
func parseMessageParam(raw string) (*messageParam, error) {
if raw == "" {
return nil, nil
}
var param messageParam
if err := json.Unmarshal([]byte(raw), &param); err != nil {
return nil, err
}
return &param, nil
}
+103
View File
@@ -0,0 +1,103 @@
package yunzhijia
import (
"context"
"fmt"
"net"
"net/url"
"strings"
"github.com/Tencent/WeKnora/internal/ipclass"
)
func deriveWebSocketURL(sendMsgURL, allowedHostSuffix string) (string, error) {
u, err := validateEndpointURL(sendMsgURL, "https", allowedHostSuffix)
if err != nil {
return "", fmt.Errorf("invalid send_msg_url for websocket: %w", err)
}
token := strings.TrimSpace(u.Query().Get("yzjtoken"))
if token == "" {
return "", fmt.Errorf("send_msg_url is missing yzjtoken")
}
wsURL := &url.URL{
Scheme: "wss",
Host: u.Host,
Path: "/xuntong/websocket",
}
query := url.Values{}
query.Set("yzjtoken", token)
wsURL.RawQuery = query.Encode()
return wsURL.String(), nil
}
func validateEndpointURL(rawURL, requiredScheme, allowedHostSuffix string) (*url.URL, error) {
u, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil {
return nil, err
}
if !strings.EqualFold(u.Scheme, requiredScheme) {
return nil, fmt.Errorf("URL must use %s", requiredScheme)
}
if u.User != nil {
return nil, fmt.Errorf("URL must not contain user information")
}
host := strings.ToLower(strings.TrimSuffix(u.Hostname(), "."))
if host == "" {
return nil, fmt.Errorf("URL has empty host")
}
if host == "localhost" || strings.HasSuffix(host, ".localhost") || net.ParseIP(host) != nil {
return nil, fmt.Errorf("URL host must be a DNS name")
}
suffix := strings.ToLower(strings.Trim(strings.TrimSpace(allowedHostSuffix), "."))
if suffix == "" {
return nil, fmt.Errorf("allowed host suffix is required")
}
if host != suffix && !strings.HasSuffix(host, "."+suffix) {
return nil, fmt.Errorf("URL host %q does not match allowed suffix %q", host, suffix)
}
return u, nil
}
func safeDialContext(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, fmt.Errorf("split dial address: %w", err)
}
addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, fmt.Errorf("resolve endpoint host %q: %w", host, err)
}
if len(addresses) == 0 {
return nil, fmt.Errorf("endpoint host %q resolved to no addresses", host)
}
for _, address := range addresses {
if !isPublicIP(address.IP) {
return nil, fmt.Errorf("endpoint host %q resolves to a non-public address", host)
}
}
dialer := &net.Dialer{}
var lastErr error
for _, address := range addresses {
conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(address.IP.String(), port))
if dialErr == nil {
return conn, nil
}
lastErr = dialErr
}
return nil, fmt.Errorf("dial endpoint host %q: %w", host, lastErr)
}
// isPublicIP reports whether a resolved endpoint address may be dialled. The
// webhook endpoint is tenant-supplied, so this is an SSRF check and shares
// internal/ipclass with the other outbound guards rather than re-deriving
// "private" from net.IP's predicates, which miss carrier-grade NAT, IPv6
// site-local, and the encodings that embed an IPv4 link-local address.
func isPublicIP(ip net.IP) bool {
return ipclass.IsPublic(ip)
}
+80
View File
@@ -0,0 +1,80 @@
package yunzhijia
import (
"net"
"testing"
)
func TestDeriveWebSocketURL(t *testing.T) {
got, err := deriveWebSocketURL(
"https://dev.kdweibo.cn:8443/gateway/robot/webhook/send?yzjtype=12&yzjtoken=token%20value",
"kdweibo.cn",
)
if err != nil {
t.Fatalf("deriveWebSocketURL() error = %v", err)
}
want := "wss://dev.kdweibo.cn:8443/xuntong/websocket?yzjtoken=token+value"
if got != want {
t.Fatalf("deriveWebSocketURL() = %q, want %q", got, want)
}
}
func TestDeriveWebSocketURLRejectsMissingToken(t *testing.T) {
_, err := deriveWebSocketURL("https://www.yunzhijia.com/gateway/robot/webhook/send", "yunzhijia.com")
if err == nil {
t.Fatal("deriveWebSocketURL() expected missing-token error")
}
}
func TestValidateEndpointURLHostSuffixBoundary(t *testing.T) {
valid := []string{
"https://yunzhijia.com/path",
"https://www.yunzhijia.com/path",
}
for _, rawURL := range valid {
if _, err := validateEndpointURL(rawURL, "https", "yunzhijia.com"); err != nil {
t.Errorf("validateEndpointURL(%q) unexpected error: %v", rawURL, err)
}
}
invalid := []string{
"http://www.yunzhijia.com/path",
"https://evilyunzhijia.com/path",
"https://127.0.0.1/path",
"https://localhost/path",
}
for _, rawURL := range invalid {
if _, err := validateEndpointURL(rawURL, "https", "yunzhijia.com"); err == nil {
t.Errorf("validateEndpointURL(%q) expected error", rawURL)
}
}
}
func TestValidateEndpointURLRequiresHostSuffix(t *testing.T) {
if _, err := validateEndpointURL("https://www.yunzhijia.com/path", "https", ""); err == nil {
t.Fatal("validateEndpointURL() expected missing host suffix error")
}
}
func TestIsPublicIP(t *testing.T) {
for _, rawIP := range []string{"127.0.0.1", "10.0.0.1", "169.254.169.254", "::1", "fc00::1"} {
if isPublicIP(net.ParseIP(rawIP)) {
t.Errorf("isPublicIP(%q) = true, want false", rawIP)
}
}
if !isPublicIP(net.ParseIP("8.8.8.8")) {
t.Error("isPublicIP(8.8.8.8) = false, want true")
}
}
func TestPositiveIntCredential(t *testing.T) {
if got := positiveIntCredential(map[string]any{"timeout": float64(25)}, "timeout", 10); got != 25 {
t.Fatalf("numeric timeout = %d, want 25", got)
}
if got := positiveIntCredential(map[string]any{"timeout": "30"}, "timeout", 10); got != 30 {
t.Fatalf("string timeout = %d, want 30", got)
}
if got := positiveIntCredential(map[string]any{"timeout": float64(0)}, "timeout", 10); got != 10 {
t.Fatalf("invalid timeout = %d, want fallback 10", got)
}
}
+337
View File
@@ -0,0 +1,337 @@
package yunzhijia
import (
"bytes"
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/Tencent/WeKnora/internal/im"
"github.com/Tencent/WeKnora/internal/logger"
ws "github.com/gorilla/websocket"
)
const (
webSocketHeartbeatInterval = 15 * time.Second
webSocketReadTimeout = 45 * time.Second
webSocketHandshakeTimeout = 10 * time.Second
webSocketMaxMessageSize = 1 << 20
webSocketMaxInvalidFrames = 3
webSocketMessageQueueSize = 64
)
var webSocketReconnectDelays = [...]time.Duration{
1 * time.Second,
2 * time.Second,
5 * time.Second,
10 * time.Second,
30 * time.Second,
60 * time.Second,
}
type webSocketFrame struct {
message *callbackMessage
ack []byte
control string
}
type LongConnClient struct {
url string
handler func(context.Context, *im.IncomingMessage) error
dialer *ws.Dialer
messages chan *im.IncomingMessage
mu sync.Mutex
conn *ws.Conn
closed atomic.Bool
}
func NewLongConnClient(webSocketURL string, handler func(context.Context, *im.IncomingMessage) error) *LongConnClient {
dialer := *ws.DefaultDialer
dialer.HandshakeTimeout = webSocketHandshakeTimeout
dialer.Proxy = nil
dialer.NetDialContext = safeDialContext
return &LongConnClient{
url: webSocketURL,
handler: handler,
dialer: &dialer,
messages: make(chan *im.IncomingMessage, webSocketMessageQueueSize),
}
}
func (c *LongConnClient) Start(ctx context.Context) error {
logger.Infof(ctx, "[IM] Yunzhijia WebSocket connecting")
go c.handleMessages(ctx)
attempt := 0
for {
if ctx.Err() != nil || c.closed.Load() {
return nil
}
connectedAt := time.Now()
err := c.connectAndRun(ctx)
if ctx.Err() != nil || c.closed.Load() {
return nil
}
if time.Since(connectedAt) >= webSocketReconnectDelays[len(webSocketReconnectDelays)-1] {
attempt = 0
}
delay := webSocketReconnectDelay(attempt)
attempt++
logger.Warnf(ctx, "[Yunzhijia] WebSocket connection lost: %v; reconnecting in %v", err, delay)
select {
case <-ctx.Done():
return nil
case <-time.After(delay):
}
}
}
func (c *LongConnClient) Stop() {
c.closed.Store(true)
c.closeConn()
}
func (c *LongConnClient) connectAndRun(ctx context.Context) error {
conn, _, err := c.dialer.DialContext(ctx, c.url, nil)
if err != nil {
return fmt.Errorf("dial websocket: %w", err)
}
c.mu.Lock()
c.conn = conn
c.mu.Unlock()
defer func() {
c.mu.Lock()
if c.conn == conn {
c.conn = nil
}
c.mu.Unlock()
_ = conn.Close()
}()
conn.SetReadLimit(webSocketMaxMessageSize)
_ = conn.SetReadDeadline(time.Now().Add(webSocketReadTimeout))
conn.SetPongHandler(func(string) error {
return conn.SetReadDeadline(time.Now().Add(webSocketReadTimeout))
})
heartbeatCtx, cancelHeartbeat := context.WithCancel(ctx)
defer cancelHeartbeat()
go c.heartbeatLoop(heartbeatCtx, conn)
logger.Infof(ctx, "[IM] Yunzhijia WebSocket connected")
invalidFrames := 0
for {
messageType, data, err := conn.ReadMessage()
if err != nil {
return fmt.Errorf("read websocket message: %w", err)
}
_ = conn.SetReadDeadline(time.Now().Add(webSocketReadTimeout))
if messageType != ws.TextMessage {
invalidFrames++
if invalidFrames >= webSocketMaxInvalidFrames {
return fmt.Errorf("too many non-text websocket frames")
}
continue
}
frame, err := parseWebSocketFrame(data)
if err != nil {
invalidFrames++
logger.Warnf(ctx, "[Yunzhijia] Invalid WebSocket frame: %v", err)
if invalidFrames >= webSocketMaxInvalidFrames {
return fmt.Errorf("too many invalid websocket frames")
}
continue
}
invalidFrames = 0
if len(frame.ack) > 0 {
if err := c.writeText(conn, frame.ack); err != nil {
return fmt.Errorf("send websocket ack: %w", err)
}
}
if frame.message == nil {
continue
}
incoming := toIncomingMessage(ctx, frame.message)
if incoming == nil {
continue
}
select {
case c.messages <- incoming:
case <-ctx.Done():
return nil
}
}
}
func (c *LongConnClient) handleMessages(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case msg := <-c.messages:
if err := c.handler(ctx, msg); err != nil {
logger.Errorf(ctx, "[Yunzhijia] Handle WebSocket message failed: %v", err)
}
}
}
}
func (c *LongConnClient) heartbeatLoop(ctx context.Context, conn *ws.Conn) {
ticker := time.NewTicker(webSocketHeartbeatInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := conn.WriteControl(ws.PingMessage, nil, time.Now().Add(5*time.Second)); err != nil {
logger.Warnf(ctx, "[Yunzhijia] WebSocket heartbeat failed: %v", err)
_ = conn.Close()
return
}
}
}
}
func (c *LongConnClient) writeText(conn *ws.Conn, data []byte) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn != conn {
return fmt.Errorf("websocket connection changed")
}
return conn.WriteMessage(ws.TextMessage, data)
}
func (c *LongConnClient) closeConn() {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn != nil {
_ = c.conn.Close()
c.conn = nil
}
}
func webSocketReconnectDelay(attempt int) time.Duration {
if attempt < 0 {
attempt = 0
}
if attempt >= len(webSocketReconnectDelays) {
return webSocketReconnectDelays[len(webSocketReconnectDelays)-1]
}
return webSocketReconnectDelays[attempt]
}
func parseWebSocketFrame(data []byte) (*webSocketFrame, error) {
trimmed := bytes.TrimSpace(data)
if len(trimmed) == 0 {
return nil, fmt.Errorf("empty frame")
}
plain := strings.ToLower(string(trimmed))
if plain == "ping" || plain == "pong" {
return &webSocketFrame{control: plain}, nil
}
var stringPayload string
if err := json.Unmarshal(trimmed, &stringPayload); err == nil {
control := strings.ToLower(strings.TrimSpace(stringPayload))
if control == "ping" || control == "pong" {
return &webSocketFrame{control: control}, nil
}
return nil, fmt.Errorf("unknown string frame")
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(trimmed, &fields); err != nil {
return nil, fmt.Errorf("decode frame: %w", err)
}
if msg := decodeBusinessMessage(trimmed); msg != nil {
return &webSocketFrame{message: msg}, nil
}
typeName := rawString(fields["type"])
if strings.EqualFold(typeName, "robotMessage") {
if msg := decodeBusinessMessage(fields["msg"]); msg != nil {
return &webSocketFrame{message: msg}, nil
}
return nil, fmt.Errorf("robotMessage envelope has invalid msg")
}
cmd := strings.ToLower(strings.TrimSpace(rawString(fields["cmd"])))
typeName = strings.ToLower(strings.TrimSpace(typeName))
event := strings.ToLower(strings.TrimSpace(rawString(fields["event"])))
control := cmd
if control == "" {
control = typeName
}
if control == "" {
control = event
}
frame := &webSocketFrame{control: control}
if cmd == "directpush" || typeName == "msgchg" {
var needAck bool
_ = json.Unmarshal(fields["needAck"], &needAck)
var seq int64
if needAck && json.Unmarshal(fields["seq"], &seq) == nil {
frame.ack, _ = json.Marshal(struct {
Cmd string `json:"cmd"`
Seq int64 `json:"seq"`
}{Cmd: "ack", Seq: seq})
}
return frame, nil
}
if control != "" {
return frame, nil
}
return nil, fmt.Errorf("frame has no business message or control type")
}
func decodeBusinessMessage(data []byte) *callbackMessage {
if len(data) == 0 {
return nil
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(data, &fields); err != nil {
return nil
}
for _, key := range []string{"robotId", "robotName", "operatorOpenid", "operatorName", "msgId", "content"} {
var value string
if json.Unmarshal(fields[key], &value) != nil {
return nil
}
}
var messageType int
if json.Unmarshal(fields["type"], &messageType) != nil {
return nil
}
var messageTime int64
if json.Unmarshal(fields["time"], &messageTime) != nil {
return nil
}
var msg callbackMessage
if err := json.Unmarshal(data, &msg); err != nil {
return nil
}
return &msg
}
func rawString(raw json.RawMessage) string {
var value string
if len(raw) > 0 && json.Unmarshal(raw, &value) == nil {
return value
}
return ""
}
+191
View File
@@ -0,0 +1,191 @@
package yunzhijia
import (
"context"
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/Tencent/WeKnora/internal/im"
ws "github.com/gorilla/websocket"
)
const testBusinessMessage = `{
"type": 2,
"robotId": "bot-1",
"robotName": "WeKnora",
"operatorOpenid": "user-1",
"operatorName": "User",
"time": 1719648000000,
"msgId": "msg-1",
"content": "@WeKnora hello",
"groupType": 1
}`
func TestParseWebSocketFrameDirectMessage(t *testing.T) {
frame, err := parseWebSocketFrame([]byte(testBusinessMessage))
if err != nil {
t.Fatalf("parseWebSocketFrame() error = %v", err)
}
if frame.message == nil || frame.message.MsgID != "msg-1" {
t.Fatalf("message = %#v, want msg-1", frame.message)
}
}
func TestParseWebSocketFrameRobotMessageEnvelope(t *testing.T) {
frame, err := parseWebSocketFrame([]byte(`{"type":"robotMessage","msg":` + testBusinessMessage + `}`))
if err != nil {
t.Fatalf("parseWebSocketFrame() error = %v", err)
}
if frame.message == nil || frame.message.OperatorOpenid != "user-1" {
t.Fatalf("message = %#v, want user-1", frame.message)
}
}
func TestParseWebSocketFrameBuildsDirectPushACK(t *testing.T) {
frame, err := parseWebSocketFrame([]byte(`{"cmd":"directPush","needAck":true,"seq":42}`))
if err != nil {
t.Fatalf("parseWebSocketFrame() error = %v", err)
}
var ack struct {
Cmd string `json:"cmd"`
Seq int64 `json:"seq"`
}
if err := json.Unmarshal(frame.ack, &ack); err != nil {
t.Fatalf("unmarshal ack: %v", err)
}
if ack.Cmd != "ack" || ack.Seq != 42 {
t.Fatalf("ack = %#v, want cmd=ack seq=42", ack)
}
}
func TestParseWebSocketFrameControlAndInvalid(t *testing.T) {
frame, err := parseWebSocketFrame([]byte(`{"event":"pong"}`))
if err != nil || frame.control != "pong" {
t.Fatalf("control frame = %#v, err=%v", frame, err)
}
if _, err := parseWebSocketFrame([]byte(`{"unknown":true}`)); err == nil {
t.Fatal("expected invalid frame error")
}
}
func TestToIncomingMessageSharedByWebhookAndWebSocket(t *testing.T) {
var msg callbackMessage
if err := json.Unmarshal([]byte(testBusinessMessage), &msg); err != nil {
t.Fatal(err)
}
incoming := toIncomingMessage(t.Context(), &msg)
if incoming == nil {
t.Fatal("toIncomingMessage() returned nil")
}
if incoming.Content != "hello" {
t.Fatalf("content = %q, want hello", incoming.Content)
}
if incoming.MessageID != "msg-1" || incoming.UserID != "user-1" {
t.Fatalf("incoming = %#v", incoming)
}
}
func TestWebSocketReconnectDelayCaps(t *testing.T) {
if got := webSocketReconnectDelay(-1); got != webSocketReconnectDelays[0] {
t.Fatalf("negative attempt delay = %v", got)
}
last := webSocketReconnectDelays[len(webSocketReconnectDelays)-1]
if got := webSocketReconnectDelay(100); got != last {
t.Fatalf("capped delay = %v, want %v", got, last)
}
}
func TestLongConnClientProcessesMessagesInOrder(t *testing.T) {
started := make(chan string, 2)
releaseFirst := make(chan struct{})
client := NewLongConnClient("wss://example.com/ws", func(_ context.Context, msg *im.IncomingMessage) error {
started <- msg.MessageID
if msg.MessageID == "first" {
<-releaseFirst
}
return nil
})
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
go client.handleMessages(ctx)
client.messages <- &im.IncomingMessage{MessageID: "first"}
client.messages <- &im.IncomingMessage{MessageID: "second"}
select {
case got := <-started:
if got != "first" {
t.Fatalf("first handled message = %q, want first", got)
}
case <-time.After(time.Second):
t.Fatal("first message was not handled")
}
select {
case got := <-started:
t.Fatalf("second message started before first completed: %q", got)
case <-time.After(25 * time.Millisecond):
}
close(releaseFirst)
select {
case got := <-started:
if got != "second" {
t.Fatalf("second handled message = %q, want second", got)
}
case <-time.After(time.Second):
t.Fatal("second message was not handled")
}
}
func TestLongConnClientAcknowledgesFrameAndStops(t *testing.T) {
ackReceived := make(chan struct{})
var ackOnce sync.Once
upgrader := ws.Upgrader{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
if err := conn.WriteMessage(ws.TextMessage, []byte(`{"cmd":"directPush","needAck":true,"seq":42}`)); err != nil {
return
}
_, data, err := conn.ReadMessage()
if err == nil && string(data) == `{"cmd":"ack","seq":42}` {
ackOnce.Do(func() { close(ackReceived) })
}
}))
defer server.Close()
client := NewLongConnClient("ws"+strings.TrimPrefix(server.URL, "http"), func(context.Context, *im.IncomingMessage) error {
return nil
})
dialer := &net.Dialer{}
client.dialer.NetDialContext = dialer.DialContext
ctx, cancel := context.WithCancel(t.Context())
done := make(chan error, 1)
go func() { done <- client.Start(ctx) }()
select {
case <-ackReceived:
case <-time.After(time.Second):
t.Fatal("websocket ACK was not received")
}
cancel()
client.Stop()
select {
case err := <-done:
if err != nil {
t.Fatalf("Start() error after stop = %v", err)
}
case <-time.After(time.Second):
t.Fatal("websocket client did not stop promptly")
}
}