mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
75d86721a3
* feat(wecom_aibot): make processing message configurable * docs(wecom): document ai bot processing message * test(wecom_aibot): adapt webhook tests to channel interface * fix: lint err
559 lines
16 KiB
Go
559 lines
16 KiB
Go
package wecom
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/bus"
|
|
"github.com/sipeed/picoclaw/pkg/channels"
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
|
)
|
|
|
|
// ---- Webhook mode tests ----
|
|
|
|
func TestNewWeComAIBotChannel_WebhookMode(t *testing.T) {
|
|
t.Run("success with valid config", func(t *testing.T) {
|
|
cfg := config.WeComAIBotConfig{
|
|
Enabled: true,
|
|
Token: "test_token",
|
|
EncodingAESKey: "testkey1234567890123456789012345678901234567",
|
|
WebhookPath: "/webhook/test",
|
|
}
|
|
|
|
messageBus := bus.NewMessageBus()
|
|
ch, err := NewWeComAIBotChannel(cfg, messageBus)
|
|
if err != nil {
|
|
t.Fatalf("Expected no error, got %v", err)
|
|
}
|
|
if ch == nil {
|
|
t.Fatal("Expected channel to be created")
|
|
}
|
|
if ch.Name() != "wecom_aibot" {
|
|
t.Errorf("Expected name 'wecom_aibot', got '%s'", ch.Name())
|
|
}
|
|
// Webhook mode must implement WebhookHandler.
|
|
if _, ok := ch.(channels.WebhookHandler); !ok {
|
|
t.Error("Webhook mode channel should implement WebhookHandler")
|
|
}
|
|
})
|
|
|
|
t.Run("error with missing token", func(t *testing.T) {
|
|
cfg := config.WeComAIBotConfig{
|
|
Enabled: true,
|
|
EncodingAESKey: "testkey1234567890123456789012345678901234567",
|
|
}
|
|
messageBus := bus.NewMessageBus()
|
|
_, err := NewWeComAIBotChannel(cfg, messageBus)
|
|
if err == nil {
|
|
t.Fatal("Expected error for missing token, got nil")
|
|
}
|
|
})
|
|
|
|
t.Run("error with missing encoding key", func(t *testing.T) {
|
|
cfg := config.WeComAIBotConfig{
|
|
Enabled: true,
|
|
Token: "test_token",
|
|
}
|
|
messageBus := bus.NewMessageBus()
|
|
_, err := NewWeComAIBotChannel(cfg, messageBus)
|
|
if err == nil {
|
|
t.Fatal("Expected error for missing encoding key, got nil")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestWeComAIBotWebhookChannelStartStop(t *testing.T) {
|
|
cfg := config.WeComAIBotConfig{
|
|
Enabled: true,
|
|
Token: "test_token",
|
|
EncodingAESKey: "testkey1234567890123456789012345678901234567",
|
|
}
|
|
|
|
messageBus := bus.NewMessageBus()
|
|
ch, err := NewWeComAIBotChannel(cfg, messageBus)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create channel: %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
if err := ch.Start(ctx); err != nil {
|
|
t.Fatalf("Failed to start channel: %v", err)
|
|
}
|
|
if !ch.IsRunning() {
|
|
t.Error("Expected channel to be running after Start")
|
|
}
|
|
|
|
if err := ch.Stop(ctx); err != nil {
|
|
t.Fatalf("Failed to stop channel: %v", err)
|
|
}
|
|
if ch.IsRunning() {
|
|
t.Error("Expected channel to be stopped after Stop")
|
|
}
|
|
}
|
|
|
|
func TestWeComAIBotChannelWebhookPath(t *testing.T) {
|
|
t.Run("default path", func(t *testing.T) {
|
|
cfg := config.WeComAIBotConfig{
|
|
Enabled: true,
|
|
Token: "test_token",
|
|
EncodingAESKey: "testkey1234567890123456789012345678901234567",
|
|
}
|
|
messageBus := bus.NewMessageBus()
|
|
ch, _ := NewWeComAIBotChannel(cfg, messageBus)
|
|
|
|
wh, ok := ch.(channels.WebhookHandler)
|
|
if !ok {
|
|
t.Fatal("Expected channel to implement WebhookHandler")
|
|
}
|
|
expectedPath := "/webhook/wecom-aibot"
|
|
if wh.WebhookPath() != expectedPath {
|
|
t.Errorf("Expected webhook path '%s', got '%s'", expectedPath, wh.WebhookPath())
|
|
}
|
|
})
|
|
|
|
t.Run("custom path", func(t *testing.T) {
|
|
customPath := "/custom/webhook"
|
|
cfg := config.WeComAIBotConfig{
|
|
Enabled: true,
|
|
Token: "test_token",
|
|
EncodingAESKey: "testkey1234567890123456789012345678901234567",
|
|
WebhookPath: customPath,
|
|
}
|
|
messageBus := bus.NewMessageBus()
|
|
ch, _ := NewWeComAIBotChannel(cfg, messageBus)
|
|
|
|
wh, ok := ch.(channels.WebhookHandler)
|
|
if !ok {
|
|
t.Fatal("Expected channel to implement WebhookHandler")
|
|
}
|
|
if wh.WebhookPath() != customPath {
|
|
t.Errorf("Expected webhook path '%s', got '%s'", customPath, wh.WebhookPath())
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestWeComAIBotChannelGetStreamResponseProcessingMessage(t *testing.T) {
|
|
validAESKey := "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG"
|
|
|
|
t.Run("uses default processing message", func(t *testing.T) {
|
|
cfg := config.WeComAIBotConfig{
|
|
Enabled: true,
|
|
Token: "test_token",
|
|
EncodingAESKey: validAESKey,
|
|
}
|
|
|
|
messageBus := bus.NewMessageBus()
|
|
channel, err := NewWeComAIBotChannel(cfg, messageBus)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create channel: %v", err)
|
|
}
|
|
ch, ok := channel.(*WeComAIBotChannel)
|
|
if !ok {
|
|
t.Fatal("Expected webhook mode channel")
|
|
}
|
|
|
|
task := &streamTask{
|
|
StreamID: "stream-default",
|
|
ChatID: "chat-default",
|
|
Deadline: time.Now().Add(-time.Second),
|
|
}
|
|
ch.streamTasks[task.StreamID] = task
|
|
ch.chatTasks[task.ChatID] = []*streamTask{task}
|
|
|
|
resp := decodeStreamResponse(t, ch, ch.getStreamResponse(task, "1234567890", "nonce"))
|
|
|
|
if !resp.Stream.Finish {
|
|
t.Fatal("Expected finished stream response after deadline")
|
|
}
|
|
if resp.Stream.Content != config.DefaultWeComAIBotProcessingMessage {
|
|
t.Fatalf("Expected default processing message %q, got %q",
|
|
config.DefaultWeComAIBotProcessingMessage, resp.Stream.Content)
|
|
}
|
|
if !task.StreamClosed {
|
|
t.Fatal("Expected task stream to be marked closed")
|
|
}
|
|
if _, ok := ch.streamTasks[task.StreamID]; ok {
|
|
t.Fatal("Expected closed stream task to be removed from streamTasks")
|
|
}
|
|
if len(ch.chatTasks[task.ChatID]) != 1 {
|
|
t.Fatalf("Expected task to remain queued for response_url delivery, got %d entries",
|
|
len(ch.chatTasks[task.ChatID]))
|
|
}
|
|
})
|
|
|
|
t.Run("uses custom processing message", func(t *testing.T) {
|
|
cfg := config.WeComAIBotConfig{
|
|
Enabled: true,
|
|
Token: "test_token",
|
|
EncodingAESKey: validAESKey,
|
|
ProcessingMessage: "Please wait a moment. The result will be delivered in a follow-up message.",
|
|
}
|
|
|
|
messageBus := bus.NewMessageBus()
|
|
channel, err := NewWeComAIBotChannel(cfg, messageBus)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create channel: %v", err)
|
|
}
|
|
ch, ok := channel.(*WeComAIBotChannel)
|
|
if !ok {
|
|
t.Fatal("Expected webhook mode channel")
|
|
}
|
|
|
|
task := &streamTask{
|
|
StreamID: "stream-custom",
|
|
ChatID: "chat-custom",
|
|
Deadline: time.Now().Add(-time.Second),
|
|
}
|
|
|
|
resp := decodeStreamResponse(t, ch, ch.getStreamResponse(task, "1234567890", "nonce"))
|
|
|
|
if resp.Stream.Content != cfg.ProcessingMessage {
|
|
t.Fatalf("Expected custom processing message %q, got %q", cfg.ProcessingMessage, resp.Stream.Content)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestGenerateStreamID(t *testing.T) {
|
|
cfg := config.WeComAIBotConfig{
|
|
Enabled: true,
|
|
Token: "test_token",
|
|
EncodingAESKey: "testkey1234567890123456789012345678901234567",
|
|
}
|
|
messageBus := bus.NewMessageBus()
|
|
ch, _ := NewWeComAIBotChannel(cfg, messageBus)
|
|
webhookCh, ok := ch.(*WeComAIBotChannel)
|
|
if !ok {
|
|
t.Fatal("Expected webhook mode channel")
|
|
}
|
|
|
|
ids := make(map[string]bool)
|
|
for i := 0; i < 100; i++ {
|
|
id := webhookCh.generateStreamID()
|
|
if len(id) != 10 {
|
|
t.Errorf("Expected stream ID length 10, got %d", len(id))
|
|
}
|
|
if ids[id] {
|
|
t.Errorf("Duplicate stream ID generated: %s", id)
|
|
}
|
|
ids[id] = true
|
|
}
|
|
}
|
|
|
|
func TestEncryptDecrypt(t *testing.T) {
|
|
cfg := config.WeComAIBotConfig{
|
|
Enabled: true,
|
|
Token: "test_token",
|
|
EncodingAESKey: "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG", // 43 characters
|
|
}
|
|
messageBus := bus.NewMessageBus()
|
|
ch, _ := NewWeComAIBotChannel(cfg, messageBus)
|
|
webhookCh, ok := ch.(*WeComAIBotChannel)
|
|
if !ok {
|
|
t.Fatal("Expected webhook mode channel")
|
|
}
|
|
|
|
plaintext := "Hello, World!"
|
|
receiveid := ""
|
|
|
|
encrypted, err := webhookCh.encryptMessage(plaintext, receiveid)
|
|
if err != nil {
|
|
t.Fatalf("Failed to encrypt message: %v", err)
|
|
}
|
|
if encrypted == "" {
|
|
t.Fatal("Encrypted message is empty")
|
|
}
|
|
|
|
decrypted, err := decryptMessageWithVerify(encrypted, cfg.EncodingAESKey, receiveid)
|
|
if err != nil {
|
|
t.Fatalf("Failed to decrypt message: %v", err)
|
|
}
|
|
if decrypted != plaintext {
|
|
t.Errorf("Expected decrypted message '%s', got '%s'", plaintext, decrypted)
|
|
}
|
|
}
|
|
|
|
func TestGenerateSignature(t *testing.T) {
|
|
token := "test_token"
|
|
timestamp := "1234567890"
|
|
nonce := "test_nonce"
|
|
encrypt := "encrypted_msg"
|
|
|
|
signature := computeSignature(token, timestamp, nonce, encrypt)
|
|
if signature == "" {
|
|
t.Error("Generated signature is empty")
|
|
}
|
|
if !verifySignature(token, signature, timestamp, nonce, encrypt) {
|
|
t.Error("Generated signature does not verify correctly")
|
|
}
|
|
}
|
|
|
|
func decodeStreamResponse(t *testing.T, ch *WeComAIBotChannel, encryptedResponse string) WeComAIBotStreamResponse {
|
|
t.Helper()
|
|
|
|
var wrapped WeComAIBotEncryptedResponse
|
|
if err := json.Unmarshal([]byte(encryptedResponse), &wrapped); err != nil {
|
|
t.Fatalf("Failed to unmarshal encrypted response: %v", err)
|
|
}
|
|
|
|
plaintext, err := decryptMessageWithVerify(wrapped.Encrypt, ch.config.EncodingAESKey, "")
|
|
if err != nil {
|
|
t.Fatalf("Failed to decrypt response: %v", err)
|
|
}
|
|
|
|
var resp WeComAIBotStreamResponse
|
|
if err := json.Unmarshal([]byte(plaintext), &resp); err != nil {
|
|
t.Fatalf("Failed to unmarshal decrypted response: %v", err)
|
|
}
|
|
|
|
return resp
|
|
}
|
|
|
|
// ---- WebSocket long-connection mode tests ----
|
|
|
|
func TestNewWeComAIBotChannel_WSMode(t *testing.T) {
|
|
t.Run("success with bot_id and secret", func(t *testing.T) {
|
|
cfg := config.WeComAIBotConfig{
|
|
Enabled: true,
|
|
BotID: "test_bot_id",
|
|
Secret: "test_secret",
|
|
}
|
|
messageBus := bus.NewMessageBus()
|
|
ch, err := NewWeComAIBotChannel(cfg, messageBus)
|
|
if err != nil {
|
|
t.Fatalf("Expected no error, got %v", err)
|
|
}
|
|
if ch == nil {
|
|
t.Fatal("Expected channel to be created")
|
|
}
|
|
if ch.Name() != "wecom_aibot" {
|
|
t.Errorf("Expected name 'wecom_aibot', got '%s'", ch.Name())
|
|
}
|
|
// WebSocket mode must NOT implement WebhookHandler.
|
|
if _, ok := ch.(channels.WebhookHandler); ok {
|
|
t.Error("WebSocket mode channel should NOT implement WebhookHandler")
|
|
}
|
|
})
|
|
|
|
t.Run("ws mode takes priority over webhook fields", func(t *testing.T) {
|
|
cfg := config.WeComAIBotConfig{
|
|
Enabled: true,
|
|
BotID: "test_bot_id",
|
|
Secret: "test_secret",
|
|
Token: "also_set",
|
|
EncodingAESKey: "testkey1234567890123456789012345678901234567",
|
|
}
|
|
messageBus := bus.NewMessageBus()
|
|
ch, err := NewWeComAIBotChannel(cfg, messageBus)
|
|
if err != nil {
|
|
t.Fatalf("Expected no error, got %v", err)
|
|
}
|
|
if _, ok := ch.(*WeComAIBotWSChannel); !ok {
|
|
t.Error("Expected WebSocket mode channel when both BotID+Secret and Token+Key are set")
|
|
}
|
|
})
|
|
|
|
t.Run("error with missing bot_id", func(t *testing.T) {
|
|
cfg := config.WeComAIBotConfig{
|
|
Enabled: true,
|
|
Secret: "test_secret",
|
|
}
|
|
messageBus := bus.NewMessageBus()
|
|
_, err := NewWeComAIBotChannel(cfg, messageBus)
|
|
// Missing bot_id alone means neither WS mode nor webhook mode is fully configured.
|
|
if err == nil {
|
|
t.Fatal("Expected error for missing bot_id, got nil")
|
|
}
|
|
})
|
|
|
|
t.Run("error with missing secret", func(t *testing.T) {
|
|
cfg := config.WeComAIBotConfig{
|
|
Enabled: true,
|
|
BotID: "test_bot_id",
|
|
}
|
|
messageBus := bus.NewMessageBus()
|
|
_, err := NewWeComAIBotChannel(cfg, messageBus)
|
|
if err == nil {
|
|
t.Fatal("Expected error for missing secret, got nil")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestWeComAIBotWSChannelStartStop(t *testing.T) {
|
|
cfg := config.WeComAIBotConfig{
|
|
Enabled: true,
|
|
BotID: "test_bot_id",
|
|
Secret: "test_secret",
|
|
}
|
|
messageBus := bus.NewMessageBus()
|
|
ch, err := NewWeComAIBotChannel(cfg, messageBus)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create channel: %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Start launches a background goroutine; it should not block or return an error.
|
|
if err := ch.Start(ctx); err != nil {
|
|
t.Fatalf("Failed to start channel: %v", err)
|
|
}
|
|
if !ch.IsRunning() {
|
|
t.Error("Expected channel to be running after Start")
|
|
}
|
|
|
|
// Stop should work regardless of whether the WebSocket actually connected.
|
|
if err := ch.Stop(ctx); err != nil {
|
|
t.Fatalf("Failed to stop channel: %v", err)
|
|
}
|
|
if ch.IsRunning() {
|
|
t.Error("Expected channel to be stopped after Stop")
|
|
}
|
|
}
|
|
|
|
func TestGenerateRandomID(t *testing.T) {
|
|
ids := make(map[string]bool)
|
|
for i := 0; i < 200; i++ {
|
|
id := generateRandomID(10)
|
|
if len(id) != 10 {
|
|
t.Errorf("Expected ID length 10, got %d", len(id))
|
|
}
|
|
if ids[id] {
|
|
t.Errorf("Duplicate ID generated: %s", id)
|
|
}
|
|
ids[id] = true
|
|
}
|
|
}
|
|
|
|
func TestWSGenerateID(t *testing.T) {
|
|
ids := make(map[string]bool)
|
|
for i := 0; i < 200; i++ {
|
|
id := wsGenerateID()
|
|
if len(id) != 10 {
|
|
t.Errorf("Expected ID length 10, got %d", len(id))
|
|
}
|
|
if ids[id] {
|
|
t.Errorf("Duplicate wsGenerateID result: %s", id)
|
|
}
|
|
ids[id] = true
|
|
}
|
|
}
|
|
|
|
// ---- Webhook streaming fallback tests ----
|
|
|
|
// makeWebhookChannel creates a started WeComAIBotChannel for testing.
|
|
func makeWebhookChannel(t *testing.T) *WeComAIBotChannel {
|
|
t.Helper()
|
|
cfg := config.WeComAIBotConfig{
|
|
Enabled: true,
|
|
Token: "test_token",
|
|
EncodingAESKey: "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG",
|
|
}
|
|
ch, err := NewWeComAIBotChannel(cfg, bus.NewMessageBus())
|
|
if err != nil {
|
|
t.Fatalf("create channel: %v", err)
|
|
}
|
|
wc := ch.(*WeComAIBotChannel)
|
|
wc.ctx, wc.cancel = context.WithCancel(context.Background())
|
|
return wc
|
|
}
|
|
|
|
// makeStreamTask creates and registers a streamTask for testing.
|
|
func makeStreamTask(t *testing.T, ch *WeComAIBotChannel, streamID, chatID string, deadline time.Time) *streamTask {
|
|
t.Helper()
|
|
task := &streamTask{
|
|
StreamID: streamID,
|
|
ChatID: chatID,
|
|
Deadline: deadline,
|
|
answerCh: make(chan string, 1),
|
|
}
|
|
task.ctx, task.cancel = context.WithCancel(ch.ctx)
|
|
ch.taskMu.Lock()
|
|
ch.streamTasks[streamID] = task
|
|
ch.chatTasks[chatID] = append(ch.chatTasks[chatID], task)
|
|
ch.taskMu.Unlock()
|
|
return task
|
|
}
|
|
|
|
// TestGetStreamResponse_ImmediateAnswer verifies that when the agent has already
|
|
// placed its answer in answerCh, getStreamResponse returns a finish=true response
|
|
// and fully removes the task.
|
|
func TestGetStreamResponse_ImmediateAnswer(t *testing.T) {
|
|
ch := makeWebhookChannel(t)
|
|
defer ch.cancel()
|
|
|
|
task := makeStreamTask(t, ch, "stream-1", "chat-1", time.Now().Add(30*time.Second))
|
|
task.answerCh <- "hello from agent"
|
|
|
|
result := ch.getStreamResponse(task, "ts123", "nonce123")
|
|
if result == "" {
|
|
t.Fatal("expected non-empty encrypted response")
|
|
}
|
|
|
|
ch.taskMu.RLock()
|
|
_, exists := ch.streamTasks["stream-1"]
|
|
ch.taskMu.RUnlock()
|
|
if exists {
|
|
t.Error("task should have been removed from streamTasks after normal finish")
|
|
}
|
|
if !task.Finished {
|
|
t.Error("task.Finished should be true after normal finish")
|
|
}
|
|
}
|
|
|
|
// TestGetStreamResponse_DeadlinePassed verifies that when the stream deadline has
|
|
// elapsed (no agent reply yet), getStreamResponse closes the stream but keeps the
|
|
// task alive so the response_url fallback can still deliver the answer.
|
|
func TestGetStreamResponse_DeadlinePassed(t *testing.T) {
|
|
ch := makeWebhookChannel(t)
|
|
defer ch.cancel()
|
|
|
|
task := makeStreamTask(t, ch, "stream-2", "chat-2", time.Now().Add(-time.Millisecond))
|
|
|
|
result := ch.getStreamResponse(task, "ts456", "nonce456")
|
|
if result == "" {
|
|
t.Fatal("expected non-empty encrypted response")
|
|
}
|
|
|
|
ch.taskMu.RLock()
|
|
_, stillStreaming := ch.streamTasks["stream-2"]
|
|
ch.taskMu.RUnlock()
|
|
if stillStreaming {
|
|
t.Error("task should have been removed from streamTasks after deadline")
|
|
}
|
|
if !task.StreamClosed {
|
|
t.Error("task.StreamClosed should be true after deadline")
|
|
}
|
|
if task.Finished {
|
|
t.Error("task.Finished must remain false: agent reply still expected via response_url")
|
|
}
|
|
}
|
|
|
|
// TestGetStreamResponse_StillPending verifies that when neither the agent has
|
|
// replied nor the deadline has passed, getStreamResponse returns without altering
|
|
// task state (client should poll again).
|
|
func TestGetStreamResponse_StillPending(t *testing.T) {
|
|
ch := makeWebhookChannel(t)
|
|
defer ch.cancel()
|
|
|
|
task := makeStreamTask(t, ch, "stream-3", "chat-3", time.Now().Add(30*time.Second))
|
|
|
|
result := ch.getStreamResponse(task, "ts789", "nonce789")
|
|
if result == "" {
|
|
t.Fatal("expected non-empty encrypted response")
|
|
}
|
|
|
|
ch.taskMu.RLock()
|
|
_, exists := ch.streamTasks["stream-3"]
|
|
ch.taskMu.RUnlock()
|
|
if !exists {
|
|
t.Error("pending task should still be in streamTasks")
|
|
}
|
|
if task.Finished || task.StreamClosed {
|
|
t.Error("pending task should not be finished or stream-closed")
|
|
}
|
|
// Cleanup.
|
|
ch.removeTask(task)
|
|
}
|