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
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:
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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], "/")
|
||||
}
|
||||
@@ -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></image_original>
|
||||
<image_caption>a cat</image_caption>
|
||||
</image>`,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"without image_original",
|
||||
`<image url="local://1/img.png">
|
||||
<image_caption>a cat</image_caption>
|
||||
</image>`,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"no image tags",
|
||||
"just plain text ",
|
||||
"just plain text ",
|
||||
},
|
||||
{
|
||||
"mixed content",
|
||||
`before <image url="x"><image_original></image_original></image> after`,
|
||||
"before  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>, 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 := ""
|
||||
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 := ""
|
||||
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  content",
|
||||
-1,
|
||||
},
|
||||
{
|
||||
"truncated URL inside markdown image",
|
||||
"text  {
|
||||
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" +
|
||||
"\n\n" +
|
||||
"### 2️⃣ 知识管理\n- 支持创建\n\n"
|
||||
part2 := `\n\n### 3️⃣ API\n\n` +
|
||||
"\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 
|
||||
}
|
||||
|
||||
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 := ``
|
||||
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\n\n### 2\n"
|
||||
part2 := `
|
||||
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, " 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{
|
||||
``,
|
||||
}
|
||||
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\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\n\n### 2\n\n" +
|
||||
`` + "\n\n### 3\n\n" +
|
||||
"\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"))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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  here"
|
||||
got := a.resolveMarkdownImages(context.Background(), "tok", in)
|
||||
if strings.Contains(got, "") {
|
||||
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", "")
|
||||
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: " 说明", 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
)
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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/")
|
||||
})
|
||||
}
|
||||
@@ -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 := ``
|
||||
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 := ""
|
||||
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 := ""
|
||||
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 := ""
|
||||
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  ok"
|
||||
out := cleanIMContent(context.Background(), in, tenant, stub)
|
||||
assert.Contains(t, out, "https://minio.example/img.png")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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(©ForTwo); 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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), ¶m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ¶m, nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user