Skip to content

Add WeCom AIBot channel implementation and tests#893

Open
reevoid wants to merge 21 commits intosipeed:mainfrom
reevoid:rui-dev
Open

Add WeCom AIBot channel implementation and tests#893
reevoid wants to merge 21 commits intosipeed:mainfrom
reevoid:rui-dev

Conversation

@reevoid
Copy link

@reevoid reevoid commented Feb 28, 2026

📝 Description

  • Introduced WeCom AIBot channel with configuration options in config.go.
  • Added default values for WeCom AIBot in defaults.go.
  • Registered WeCom AIBot channel factory in init.go.
  • Created comprehensive unit tests for WeCom AIBot channel functionalities including creation, start/stop behavior,
  • webhook path handling, message encryption/decryption, and signature generation.

🗣️ Type of Change

  • 🐞 Bug fix (non-breaking change which fixes an issue)
  • ✨ New feature (non-breaking change which adds functionality)
  • 📖 Documentation update
  • ⚡ Code refactoring (no functional changes, no api changes)

🤖 AI Code Generation

  • 🤖 Fully AI-generated (100% AI, 0% Human)
  • 🛠️ Mostly AI-generated (AI draft, Human verified/modified)
  • 👨‍💻 Mostly Human-written (Human lead, AI assisted or none)

📚 Technical Context (Skip for Docs)

📸 Evidence (Optional)

Click to view Logs/Screenshots image

☑️ Checklist

  • My code/docs follow the style of this project.
  • I have performed a self-review of my own changes.
  • I have updated the documentation accordingly.

- Introduced WeCom AIBot channel configuration in config.go with relevant fields.
- Implemented WeCom AIBot channel factory registration in init.go.
- Created unit tests for WeCom AIBot channel functionalities including initialization, start/stop behavior, webhook path handling, message encryption/decryption, and signature generation.
- Set default values for WeCom AIBot configuration in defaults.go.
Copilot AI review requested due to automatic review settings February 28, 2026 05:29
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a new WeCom AI Bot (智能机器人) channel implementation that uses the official WeCom AI Bot API, which differs from the existing wecom channel by using a polling-based streaming protocol for responses rather than simple passive replies.

Changes:

  • Adds WeComAIBotConfig struct and WeComAIBot field in ChannelsConfig, with default values defined
  • Implements the WeCom AI Bot channel in aibot.go, featuring a streaming task queue, encryption/decryption, and a fallback response_url delivery mechanism when the stream deadline is reached
  • Adds unit tests, factory registration, channel manager integration, and an example config entry for the new channel

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 11 comments.

Show a summary per file
File Description
pkg/channels/wecom/aibot.go Core implementation of the WeCom AI Bot channel
pkg/channels/wecom/aibot_test.go Unit tests for the new channel
pkg/channels/wecom/init.go Registers the wecom_aibot factory
pkg/channels/manager.go Adds initialization guard for wecom_aibot
pkg/config/config.go Adds WeComAIBotConfig struct and field in ChannelsConfig
pkg/config/defaults.go Sets default values for WeComAIBotConfig
config/config.example.json Adds example wecom_aibot config block

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 977 to 989
// generateSignature generates message signature using common function
func (c *WeComAIBotChannel) generateSignature(timestamp, nonce, encrypt string) string {
// Sort parameters
params := []string{c.config.Token, timestamp, nonce, encrypt}
sort.Strings(params)

// Concatenate
str := strings.Join(params, "")

// SHA1 hash
hash := sha1.Sum([]byte(str))
return fmt.Sprintf("%x", hash)
}
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generateSignature method is a duplication of the logic inside verifySignature in common.go. Both sort the same parameters and compute a SHA1 hash. The generateSignature method could simply call an internal helper that both functions share, or generateSignature could be a package-level function in common.go reused here. This duplication makes maintenance harder as any future change to the signing algorithm would need to be applied in both places.

Copilot uses AI. Check for mistakes.
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
"webhook_host": "0.0.0.0",
"webhook_port": 18791,
"webhook_path": "/webhook/wecom-aibot",
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The wecom_aibot entry in config.example.json is missing the allow_from and reply_timeout fields that are present in the WeComAIBotConfig struct (and in the defaults) and are included in every other WeCom channel entry (wecom and wecom_app). Omitting these from the example config makes it less discoverable for users who want to restrict which users the bot responds to.

Suggested change
"webhook_path": "/webhook/wecom-aibot",
"webhook_path": "/webhook/wecom-aibot",
"allow_from": [],
"reply_timeout": 5,

Copilot uses AI. Check for mistakes.
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ALLOW_FROM"`
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REPLY_TIMEOUT"`
MaxSteps int `json:"max_steps" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_MAX_STEPS"` // Maximum streaming steps
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The MaxSteps field in WeComAIBotConfig is defined in the config struct and set in defaults, but is never referenced in aibot.go. This means the configuration option has no effect. Either it should be used to control the number of processing steps (as suggested by the comment "Maximum streaming steps"), or it should be removed from the config to avoid confusing users.

Suggested change
MaxSteps int `json:"max_steps" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_MAX_STEPS"` // Maximum streaming steps

Copilot uses AI. Check for mistakes.
Comment on lines 1 to 218
package wecom

import (
"context"
"testing"

"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
)

func TestNewWeComAIBotChannel(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())
}
})

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 TestWeComAIBotChannelStartStop(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()

// Test Start
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")
}

// Test Stop
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")
}
}

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)

expectedPath := "/webhook/wecom-aibot"
if ch.WebhookPath() != expectedPath {
t.Errorf("Expected webhook path '%s', got '%s'", expectedPath, ch.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)

if ch.WebhookPath() != customPath {
t.Errorf("Expected webhook path '%s', got '%s'", customPath, ch.WebhookPath())
}
})
}

func TestGenerateStreamID(t *testing.T) {
cfg := config.WeComAIBotConfig{
Enabled: true,
Token: "test_token",
EncodingAESKey: "testkey1234567890123456789012345678901234567",
}

messageBus := bus.NewMessageBus()
ch, _ := NewWeComAIBotChannel(cfg, messageBus)

// Generate multiple IDs and check they are unique
ids := make(map[string]bool)
for i := 0; i < 100; i++ {
id := ch.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) {
// Use a valid 43-character base64 key (企业微信标准格式)
cfg := config.WeComAIBotConfig{
Enabled: true,
Token: "test_token",
EncodingAESKey: "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG", // 43 characters
}

messageBus := bus.NewMessageBus()
ch, _ := NewWeComAIBotChannel(cfg, messageBus)

plaintext := "Hello, World!"
receiveid := ""

// Encrypt
encrypted, err := ch.encryptMessage(plaintext, receiveid)
if err != nil {
t.Fatalf("Failed to encrypt message: %v", err)
}

if encrypted == "" {
t.Fatal("Encrypted message is empty")
}

// Decrypt
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) {
cfg := config.WeComAIBotConfig{
Enabled: true,
Token: "test_token",
EncodingAESKey: "testkey1234567890123456789012345678901234567",
}

messageBus := bus.NewMessageBus()
ch, _ := NewWeComAIBotChannel(cfg, messageBus)

timestamp := "1234567890"
nonce := "test_nonce"
encrypt := "encrypted_msg"

signature := ch.generateSignature(timestamp, nonce, encrypt)

if signature == "" {
t.Error("Generated signature is empty")
}

// Verify signature using verifySignature function
if !verifySignature(cfg.Token, signature, timestamp, nonce, encrypt) {
t.Error("Generated signature does not verify correctly")
}
}
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test file for aibot.go is significantly less comprehensive than the analogous bot_test.go. In bot_test.go, there are tests for HTTP handler behavior (TestWeComBotHandleVerification, TestWeComBotHandleMessageCallback, TestWeComBotProcessMessage), as well as allowlist and signature verification. The aibot_test.go is missing tests for: webhook handler endpoints (including error cases like missing parameters and invalid signatures), the Send() method's stream delivery behavior, and the getStreamResponse/handleStreamMessage polling logic. Given that this is a new, complex streaming protocol, these are important behaviors to test.

Copilot uses AI. Check for mistakes.
Comment on lines +284 to +299
// handleVerification handles the URL verification request from WeCom
func (c *WeComAIBotChannel) handleVerification(
ctx context.Context,
w http.ResponseWriter,
r *http.Request,
) {
msgSignature := r.URL.Query().Get("msg_signature")
timestamp := r.URL.Query().Get("timestamp")
nonce := r.URL.Query().Get("nonce")
echostr := r.URL.Query().Get("echostr")

logger.DebugCF("wecom_aibot", "URL verification request", map[string]any{
"msg_signature": msgSignature,
"timestamp": timestamp,
"nonce": nonce,
})
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neither handleVerification nor handleMessageCallback validate that the required query parameters (msg_signature, timestamp, nonce) are non-empty before processing. In contrast, the existing bot.go handler returns HTTP 400 when any of these parameters are missing. This inconsistency means malformed requests will proceed to signature verification with empty values, which could lead to confusing error messages.

Copilot uses AI. Check for mistakes.
}

// encryptEmptyResponse returns empty encrypted response
func (c *WeComAIBotChannel) encryptEmptyResponse(timestamp, nonce string) string {
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The encryptEmptyResponse function accepts timestamp and nonce parameters but ignores them entirely, always returning an empty string. All callers pass values for these parameters but receive an empty string back, which is then written as an empty HTTP response body. This function should either use the parameters to build a properly encrypted empty response, or have its signature simplified to take no parameters since they are unused. Sending an empty body in response to a WeCom callback may cause the platform to retry or report an error.

Suggested change
func (c *WeComAIBotChannel) encryptEmptyResponse(timestamp, nonce string) string {
func (c *WeComAIBotChannel) encryptEmptyResponse(timestamp, nonce string) string {
logger.DebugCF("wecom_aibot", "Returning empty encrypted response", map[string]any{
"timestamp": timestamp,
"nonce": nonce,
})

Copilot uses AI. Check for mistakes.
Comment on lines 970 to 972
if padding == 0 {
padding = blockSize
}
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pkcs7Pad function's if padding == 0 branch (line 970-972) is unreachable dead code. The expression blockSize - (len(data) % blockSize) produces values in the range [1, blockSize] — it can never be zero because len(data) % blockSize is in [0, blockSize-1]. When the result is blockSize (i.e., when the data length is a multiple of blockSize), a full block of padding is correctly added, which is already the desired behavior. The dead branch should be removed to avoid confusion.

Suggested change
if padding == 0 {
padding = blockSize
}

Copilot uses AI. Check for mistakes.
Comment on lines 623 to 626
// Echo back the image (simple demo behavior)
// streamID := c.generateStreamID()
// return c.encryptImageResponse(streamID, timestamp, nonce, imageData)

Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lines 622-625 contain commented-out code referencing a non-existent function encryptImageResponse. These stale comment lines should be removed to keep the code clean.

Suggested change
// Echo back the image (simple demo behavior)
// streamID := c.generateStreamID()
// return c.encryptImageResponse(streamID, timestamp, nonce, imageData)

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings February 28, 2026 06:38
Copy link
Collaborator

@alexhoshina alexhoshina left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

StreamClosed tasks are only removed from streamTasks, not from chatTasks

In getStreamResponse(), when closeStreamOnly == true:
Only delete from streamTasks
delete(c.streamTasks, task.StreamID)
But the task remains in chatTasks. If the agent never replies (e.g., provider timeout, LLM error), this task will stay in chatTasks until cleanupOldTasks after 1 hour
before being cleaned up.

But more crucially: cleanupOldTasks only iterates through streamTasks for cleanup—for tasks that have StreamClosed=true and have been removed from streamTasks,
the first stage of cleanup does not touch them. Although the second stage (iterating through chatTasks) handles tasks that are Finished or have timed out, tasks with StreamClosed=true, Finished=false, and
ChatTasks created less than 1 hour ago will not be cleaned up. If a large number of requests time out in a short period, chatTasks will accumulate a significant number of dangling tasks.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated 10 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

sender := bus.SenderInfo{
Platform: "wecom_aibot",
PlatformID: userID,
CanonicalID: "wecom_aibot:" + userID,
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CanonicalID for the sender is constructed manually as "wecom_aibot:" + userID instead of using identity.BuildCanonicalID("wecom_aibot", userID) (as done in bot.go line 407). The identity.BuildCanonicalID function lowercases and trims the platform name, which ensures consistent canonical IDs across all channels. Manual string concatenation skips this normalization and diverges from the convention used throughout the codebase.

Copilot uses AI. Check for mistakes.
Comment on lines 592 to 626
// Download and decrypt image
_, err := c.downloadAndDecryptImage(ctx, imageURL)
if err != nil {
logger.ErrorCF("wecom_aibot", "Failed to process image", map[string]any{
"error": err,
"url": imageURL,
})
return c.encryptResponse("", timestamp, nonce, WeComAIBotStreamResponse{
MsgType: "stream",
Stream: struct {
ID string `json:"id"`
Finish bool `json:"finish"`
Content string `json:"content,omitempty"`
MsgItem []struct {
MsgType string `json:"msgtype"`
Image *struct {
Base64 string `json:"base64"`
MD5 string `json:"md5"`
} `json:"image,omitempty"`
} `json:"msg_item,omitempty"`
}{
ID: c.generateStreamID(),
Finish: true,
Content: fmt.Sprintf(
"Image received (URL: %s), but image messages are not yet supported",
imageURL,
),
},
})
}

// Echo back the image (simple demo behavior)
// streamID := c.generateStreamID()
// return c.encryptImageResponse(streamID, timestamp, nonce, imageData)

Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In handleImageMessage, when image download and decryption succeeds, the result is discarded with _ and the function still returns a "not yet supported" error message to the user. Meanwhile, the download is performed unconditionally, consuming network resources for every image message. Since image handling is not yet implemented, the download is wasteful and unnecessary. Remove the downloadAndDecryptImage call and the associated commented-out code (lines 623–625) until image support is actually implemented.

Suggested change
// Download and decrypt image
_, err := c.downloadAndDecryptImage(ctx, imageURL)
if err != nil {
logger.ErrorCF("wecom_aibot", "Failed to process image", map[string]any{
"error": err,
"url": imageURL,
})
return c.encryptResponse("", timestamp, nonce, WeComAIBotStreamResponse{
MsgType: "stream",
Stream: struct {
ID string `json:"id"`
Finish bool `json:"finish"`
Content string `json:"content,omitempty"`
MsgItem []struct {
MsgType string `json:"msgtype"`
Image *struct {
Base64 string `json:"base64"`
MD5 string `json:"md5"`
} `json:"image,omitempty"`
} `json:"msg_item,omitempty"`
}{
ID: c.generateStreamID(),
Finish: true,
Content: fmt.Sprintf(
"Image received (URL: %s), but image messages are not yet supported",
imageURL,
),
},
})
}
// Echo back the image (simple demo behavior)
// streamID := c.generateStreamID()
// return c.encryptImageResponse(streamID, timestamp, nonce, imageData)

Copilot uses AI. Check for mistakes.
| **DingTalk** | Medium (app credentials) |
| **LINE** | Medium (credentials + webhook URL) |
| **WeCom** | Medium (CorpID + webhook setup) |
| **WeCom AI Bot** | Medium (Token + AES key) |
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "WeCom" row in the channel difficulty table (line 306) has been replaced entirely with "WeCom AI Bot", removing the original WeCom Bot entry. Since WeCom Bot is still a supported channel and there is still a "WeCom Bot" setup section below in the same README, the table should include a separate row for WeCom Bot as it did before, and a new row for WeCom AI Bot should be added alongside it. The current diff creates an inconsistency between the table (which only lists WeCom AI Bot) and the Quick Setup section (which covers all three WeCom variants).

Copilot uses AI. Check for mistakes.
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")

client := &http.Client{Timeout: 15 * time.Second}
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In sendViaResponseURL, the context.WithTimeout (line 826) and the http.Client.Timeout (line 835) are both set to 15 seconds independently. The http.Client.Timeout is redundant here because the request context already carries the 15-second deadline. Using both is not incorrect, but it creates a potential confusion — the effective timeout will be whichever expires first (in this case both are the same). Remove the Timeout field from the http.Client struct or use a longer value so the context deadline fully governs the behavior, consistent with the pattern used in other HTTP calls in the codebase.

Suggested change
client := &http.Client{Timeout: 15 * time.Second}
client := &http.Client{}

Copilot uses AI. Check for mistakes.
"reasoning_channel_id": ""
},
"wecom_aibot": {
"_comment": "WeCom AI Bot (智能机器人) - Official WeCom AI Bot integration, supports proactive messaging and private chats.",
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says "supports proactive messaging and private chats" but the documentation in docs/channels/wecom/wecom_aibot/README.zh.md (feature comparison table, line 10) shows that WeCom AI Bot also supports group chats. The comment should be updated to mention group chat support.

Suggested change
"_comment": "WeCom AI Bot (智能机器人) - Official WeCom AI Bot integration, supports proactive messaging and private chats.",
"_comment": "WeCom AI Bot (智能机器人) - Official WeCom AI Bot integration, supports proactive messaging, private chats, and group chats.",

Copilot uses AI. Check for mistakes.
Comment on lines 970 to 972
if padding == 0 {
padding = blockSize
}
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In pkcs7Pad, the condition if padding == 0 { padding = blockSize } is dead code. The expression blockSize - (len(data) % blockSize) produces a value in the range [1, blockSize] because len(data) % blockSize is in the range [0, blockSize-1]; the result is therefore always at least 1 and never 0. The dead check is misleading. Remove it to clarify the intent.

Suggested change
if padding == 0 {
padding = blockSize
}

Copilot uses AI. Check for mistakes.
**Option 3: WeCom AI Bot (AI Bot)** - Official AI Bot, streaming replies, supports group & private chat

See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detailed setup instructions.
See [WeCom AI Bot Configuration Guide](docs/channels/wecom/wecom_aibot/README.zh.md) for detailed setup instructions.
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The English README.md (and similarly README.fr.md, README.ja.md, README.pt-br.md, README.vi.md) now links the WeCom configuration guide to docs/channels/wecom/wecom_aibot/README.zh.md — a Chinese-language file. English-speaking users following the link will be directed to a Chinese guide. An English version of the documentation should be created (e.g. README.md in the same directory), or the link should point to the existing Chinese file with a note about the language.

Suggested change
See [WeCom AI Bot Configuration Guide](docs/channels/wecom/wecom_aibot/README.zh.md) for detailed setup instructions.
See [WeCom AI Bot Configuration Guide (Chinese)](docs/channels/wecom/wecom_aibot/README.zh.md) for detailed setup instructions (documentation currently available in Chinese only).

Copilot uses AI. Check for mistakes.
"webhook_host": "0.0.0.0",
"webhook_port": 18791,
"webhook_path": "/webhook/wecom-aibot",
"max_steps": 10,
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The wecom_aibot example configuration is missing the allow_from and reply_timeout fields that are present in the wecom and wecom_app example configurations. While these fields have defaults, including them in the example config would help users understand that these options exist, consistent with the other WeCom channel examples.

Suggested change
"max_steps": 10,
"max_steps": 10,
"allow_from": [],
"reply_timeout": 5,

Copilot uses AI. Check for mistakes.
reevoid and others added 2 commits February 28, 2026 15:08
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings February 28, 2026 07:26
reevoid and others added 2 commits February 28, 2026 15:29
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated 7 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Token: "",
EncodingAESKey: "",
WebhookHost: "0.0.0.0",
WebhookPort: 18791,
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The WeComAIBot channel is assigned the same default port (18791) as the LINE channel. If a user enables both channels simultaneously with default configurations, the second channel to start will fail to bind to the port.

The existing channels use distinct ports: MaixCam=18790, LINE=18791, WeComApp=18792, WeCom=18793. The WeComAIBot default port should be a new unique value (e.g., 18794) to avoid conflicts.

Suggested change
WebhookPort: 18791,
WebhookPort: 18794,

Copilot uses AI. Check for mistakes.
"token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
"webhook_host": "0.0.0.0",
"webhook_port": 18791,
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The WeComAIBot example configuration uses port 18791, which conflicts with the default port for LINE. The example should use a unique port (e.g., 18794) to avoid misleading users into a port conflict when they also have LINE enabled.

Suggested change
"webhook_port": 18791,
"webhook_port": 18794,

Copilot uses AI. Check for mistakes.
Comment on lines +180 to +181
// Send delivers the agent reply into the active streamTask for msg.ChatID.
// It writes into the earliest unfinished task in the queue (FIFO per chatID).
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Send() method is missing an IsRunning() guard. Both WeComBotChannel.Send() and WeComAppChannel.Send() check if !c.IsRunning() { return channels.ErrNotRunning } as their first statement. Without this guard, calling Send() on a stopped channel will proceed past the lock, potentially access c.ctx (which is nil before Start() is ever called) inside sendViaResponseURL, causing a nil-pointer panic. This was flagged in a previous review and acknowledged, but has not been fixed.

Copilot uses AI. Check for mistakes.
Comment on lines 982 to 984
if padding == 0 {
padding = blockSize
}
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In pkcs7Pad, the condition if padding == 0 { padding = blockSize } is unreachable dead code. When len(data) % blockSize == 0, the expression blockSize - 0 evaluates to blockSize (not 0), so padding is already blockSize before the if check. The dead branch can be removed to keep the code clean.

Suggested change
if padding == 0 {
padding = blockSize
}

Copilot uses AI. Check for mistakes.
Comment on lines +483 to +485
if chatID == "" {
chatID = userID
}
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a significant discrepancy between the documentation and the code. The documentation (docs/channels/wecom/wecom_aibot/README.zh.md lines 78-80 and README.md line 648) states the timeout occurs after "5 minutes 30 seconds" (5.5 min), but the actual deadline in handleTextMessage is only 30 seconds (time.Now().Add(30 * time.Second)). The code comment on line 483 says "WeCom stops sending stream-refresh callbacks after 6 minutes", but the deadline is set far shorter than that. Either the deadline should be increased (e.g., to ~5.5 minutes) to match the documented behavior, or the documentation should be corrected to reflect the 30-second actual limit.

Copilot uses AI. Check for mistakes.
Comment on lines 78 to 80
**超时处理**(任务超过 5 分 30 秒):

若 Agent 处理时间超过约 5 分 30 秒(企业微信最大轮询窗口为 6 分钟),PicoClaw 会:
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation in README.zh.md line 80 states the timeout occurs when "Agent 处理时间超过约 5 分 30 秒(企业微信最大轮询窗口为 6 分钟)", and the English README says "Long tasks (>5.5 min) automatically switch to response_url push delivery." However, the actual code in aibot.go sets a 30-second deadline, not 5.5 minutes. The documentation misleads users into expecting a much longer wait before fallback.

Copilot uses AI. Check for mistakes.
README.md Outdated
picoclaw gateway
```

> **Note**: WeCom AI Bot uses streaming pull protocol — no reply timeout concerns. Long tasks (>5.5 min) automatically switch to `response_url` push delivery.
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The note states "Long tasks (>5.5 min) automatically switch to response_url push delivery", but the actual stream deadline in aibot.go is 30 seconds. The documentation is incorrect — tasks switch to response_url mode after 30 seconds, not 5.5 minutes.

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings February 28, 2026 07:45
reevoid and others added 2 commits February 28, 2026 15:50
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, 10)
for i := range b {
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In generateStreamID(), the error from rand.Int is silently discarded with _. If the cryptographic random number generator fails, n will be nil and the subsequent n.Int64() call will panic with a nil pointer dereference. While crypto/rand failures are rare, they can happen in low-entropy environments. The error should be handled, similar to how encryptMessage() handles the same error at line 952.

Suggested change
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
if err != nil {
// Log the error and fall back to a deterministic character to avoid panic
logger.Errorf("failed to generate random stream ID character: %v", err)
b[i] = letters[0]
continue
}

Copilot uses AI. Check for mistakes.
Comment on lines 638 to 643
Finish: true,
Content: fmt.Sprintf(
"Image received (URL: %s), but image messages are not yet supported",
imageURL,
),
},
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The image URL is embedded directly in the response message sent back to the user (at both lines 639-642 and 668-671). WeCom image URLs typically contain access tokens or session-specific credentials. Exposing the full URL to the end user via the chat message could leak authentication material. The response message should not include the URL itself — for example, just respond with "Image received, but image messages are not yet supported."

Copilot uses AI. Check for mistakes.
Comment on lines 1164 to 1175
// 1. Absolute expiry: task was created more than taskMaxLifetime ago.
// 2. Grace expiry: stream closed more than streamClosedGracePeriod ago
// (agent had enough time to reply; it is not coming back).
for chatID, queue := range c.chatTasks {
filtered := queue[:0]
for _, t := range queue {
absoluteExpired := t.CreatedTime.Before(cutoff)
graceExpired := t.StreamClosed &&
!t.StreamClosedAt.IsZero() &&
t.StreamClosedAt.Before(now.Add(-streamClosedGracePeriod))
if !t.Finished && !absoluteExpired && !graceExpired {
filtered = append(filtered, t)
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are multiple data races between c.taskMu and task.mu protecting the same streamTask fields:

  1. task.Finished: Written by removeTask() under task.mu (line 819-821), but read in Send() at line 192 under c.taskMu only, and read in cleanupOldTasks() at line 1171 under c.taskMu only.

  2. task.StreamClosed / task.StreamClosedAt: Written by getStreamResponse() under task.mu (lines 783-786), but read in cleanupOldTasks() at lines 1168-1170 under c.taskMu only.

In all these cases, different goroutines access these fields under different locks, which means the accesses are not mutually exclusive. The fix is to consistently protect these fields under a single lock — either always use c.taskMu when reading/writing them, or always acquire task.mu in the cleanup and Send() paths.

Copilot uses AI. Check for mistakes.
Comment on lines 448 to 466
return c.encryptResponse("", timestamp, nonce, WeComAIBotStreamResponse{
MsgType: "stream",
Stream: struct {
ID string `json:"id"`
Finish bool `json:"finish"`
Content string `json:"content,omitempty"`
MsgItem []struct {
MsgType string `json:"msgtype"`
Image *struct {
Base64 string `json:"base64"`
MD5 string `json:"md5"`
} `json:"image,omitempty"`
} `json:"msg_item,omitempty"`
}{
ID: c.generateStreamID(),
Finish: true,
Content: "Unsupported message type: " + msg.MsgType,
},
})
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The WeComAIBotStreamInfo named type was introduced (following feedback that was acknowledged as correct), and WeComAIBotStreamResponse.Stream now uses it. However, all call sites (at least 7 locations: lines 450, 579, 625, 654, 685, 723, 795) still construct the Stream field using anonymous struct literals instead of the named WeComAIBotStreamInfo type. This defeats the purpose of the refactor. All those sites should use WeComAIBotStreamInfo{ID: ..., Finish: ..., Content: ...} instead.

Copilot uses AI. Check for mistakes.
Comment on lines 616 to 674
// Download and decrypt image
_, err := c.downloadAndDecryptImage(ctx, imageURL)
if err != nil {
logger.ErrorCF("wecom_aibot", "Failed to process image", map[string]any{
"error": err,
"url": imageURL,
})
return c.encryptResponse("", timestamp, nonce, WeComAIBotStreamResponse{
MsgType: "stream",
Stream: struct {
ID string `json:"id"`
Finish bool `json:"finish"`
Content string `json:"content,omitempty"`
MsgItem []struct {
MsgType string `json:"msgtype"`
Image *struct {
Base64 string `json:"base64"`
MD5 string `json:"md5"`
} `json:"image,omitempty"`
} `json:"msg_item,omitempty"`
}{
ID: c.generateStreamID(),
Finish: true,
Content: fmt.Sprintf(
"Image received (URL: %s), but image messages are not yet supported",
imageURL,
),
},
})
}

// Echo back the image (simple demo behavior)
// streamID := c.generateStreamID()
// return c.encryptImageResponse(streamID, timestamp, nonce, imageData)

// For now, just acknowledge receipt without echoing the image
return c.encryptResponse("", timestamp, nonce, WeComAIBotStreamResponse{
MsgType: "stream",
Stream: struct {
ID string `json:"id"`
Finish bool `json:"finish"`
Content string `json:"content,omitempty"`
MsgItem []struct {
MsgType string `json:"msgtype"`
Image *struct {
Base64 string `json:"base64"`
MD5 string `json:"md5"`
} `json:"image,omitempty"`
} `json:"msg_item,omitempty"`
}{
ID: c.generateStreamID(),
Finish: true,
Content: fmt.Sprintf(
"Image received (URL: %s), but image messages are not yet supported",
imageURL,
),
},
})
}
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whether downloadAndDecryptImage succeeds or fails, both branches at lines 623-644 and 652-673 return the identical "Image received (URL: ...), but image messages are not yet supported" response. This means the function is called, it may download and attempt to decrypt (consuming network and CPU), but the result is completely discarded regardless. This makes downloadAndDecryptImage a dead code path in this function — either remove it or add real handling for the success case.

Copilot uses AI. Check for mistakes.
@reevoid
Copy link
Author

reevoid commented Feb 28, 2026

@alexhoshina I have addressed all the issues mentioned above. Please review again to see if it can be merged now.

@alexhoshina
Copy link
Collaborator

# Function in aibot.go Corresponding in common.go Relationship Recommendation
1 generateSignature (line 1014) verifySignature (line 19) Core logic is identical; one returns a string, the other returns a bool. Extract computeSignature() into common.go.
2 encryptMessage (line 946) decryptMessageWithVerify (line 46) Mirror functions for encryption/decryption; AES key decoding, IV, and message format are identical. Extract shared key decoding and message formatting logic.
3 pkcs7Pad (line 1004) pkcs7Unpad (line 115) Mutual inverses (padding vs. unpadding). Move pkcs7Pad to common.go to pair with pkcs7Unpad.
4 AES decryption inside downloadAndDecryptImage (lines 1077–1107) AES decryption inside decryptMessageWithVerify (lines 56–88) Completely duplicated code. Extract decryptAESCBC() into common.go.

imageURL := msg.Image.URL

// Download and decrypt image
_, err := c.downloadAndDecryptImage(ctx, imageURL)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After downloading the image fails, an error response is returned, but if it succeeds, the code also returns the exact same "not yet supported" message. The success path does not use the downloaded data—the decrypted imageData
is discarded (_). Moreover, even if it fails, the subsequent code will still execute (due to a missing return), resulting in sending two responses.

Comment on lines +199 to +201
for len(queue) > 0 && queue[0].Finished {
queue = queue[1:]
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only clean up the Finished tasks at the head of the queue during Send() calls; if there are finished tasks in the middle, do not clean them up. Long-term operation may cause the chatTasks queue to expand.

Copilot AI review requested due to automatic review settings February 28, 2026 14:57
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 16 out of 16 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 455 to 473
return c.encryptResponse("", timestamp, nonce, WeComAIBotStreamResponse{
MsgType: "stream",
Stream: struct {
ID string `json:"id"`
Finish bool `json:"finish"`
Content string `json:"content,omitempty"`
MsgItem []struct {
MsgType string `json:"msgtype"`
Image *struct {
Base64 string `json:"base64"`
MD5 string `json:"md5"`
} `json:"image,omitempty"`
} `json:"msg_item,omitempty"`
}{
ID: c.generateStreamID(),
Finish: true,
Content: "Unsupported message type: " + msg.MsgType,
},
})
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The WeComAIBotStreamInfo named type was introduced at line 105–116 precisely to avoid repeating the anonymous struct type at every call site (as flagged in prior review). However, none of the 7 call sites (lines 457, 586, 632, 661, 692, 730, 800) have been updated to use WeComAIBotStreamInfo{}. Each still repeats the full anonymous struct literal including all JSON tags. For example, the getStreamResponse function at line 800 still constructs the struct anonymously instead of using WeComAIBotStreamInfo{ID: ..., Finish: ..., Content: ...}. The type was defined but never actually applied, leaving the same maintainability problem the prior feedback was addressing.

Copilot uses AI. Check for mistakes.
Comment on lines 621 to 680
imageURL := msg.Image.URL

// Download and decrypt image
_, err := c.downloadAndDecryptImage(ctx, imageURL)
if err != nil {
logger.ErrorCF("wecom_aibot", "Failed to process image", map[string]any{
"error": err,
"url": imageURL,
})
return c.encryptResponse("", timestamp, nonce, WeComAIBotStreamResponse{
MsgType: "stream",
Stream: struct {
ID string `json:"id"`
Finish bool `json:"finish"`
Content string `json:"content,omitempty"`
MsgItem []struct {
MsgType string `json:"msgtype"`
Image *struct {
Base64 string `json:"base64"`
MD5 string `json:"md5"`
} `json:"image,omitempty"`
} `json:"msg_item,omitempty"`
}{
ID: c.generateStreamID(),
Finish: true,
Content: fmt.Sprintf(
"Image received (URL: %s), but image messages are not yet supported",
imageURL,
),
},
})
}

// Echo back the image (simple demo behavior)
// streamID := c.generateStreamID()
// return c.encryptImageResponse(streamID, timestamp, nonce, imageData)

// For now, just acknowledge receipt without echoing the image
return c.encryptResponse("", timestamp, nonce, WeComAIBotStreamResponse{
MsgType: "stream",
Stream: struct {
ID string `json:"id"`
Finish bool `json:"finish"`
Content string `json:"content,omitempty"`
MsgItem []struct {
MsgType string `json:"msgtype"`
Image *struct {
Base64 string `json:"base64"`
MD5 string `json:"md5"`
} `json:"image,omitempty"`
} `json:"msg_item,omitempty"`
}{
ID: c.generateStreamID(),
Finish: true,
Content: fmt.Sprintf(
"Image received (URL: %s), but image messages are not yet supported",
imageURL,
),
},
})
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The handleImageMessage function (line 609) calls downloadAndDecryptImage to download and decrypt the image even when the result is always discarded with _. Whether the download succeeds or fails, the function returns the same "Image received (URL: ...), but image messages are not yet supported" response in both code paths (lines 626–651 and 659–680). The download and decrypt step is dead work — it consumes network bandwidth and CPU on every image message but the result is never used. The call should be removed until image support is actually implemented. The commented-out code at lines 654–656 also suggests this was known as incomplete.

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings February 28, 2026 15:35
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 16 out of 16 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +89 to +101
// encryptAESCBC encrypts plaintext using AES-CBC with the given key, mirroring
// decryptAESCBC. IV = aesKey[:aes.BlockSize]. The caller must PKCS7-pad the
// plaintext to a multiple of aes.BlockSize before calling.
func encryptAESCBC(aesKey, plaintext []byte) ([]byte, error) {
block, err := aes.NewCipher(aesKey)
if err != nil {
return nil, fmt.Errorf("failed to create cipher: %w", err)
}
iv := aesKey[:aes.BlockSize]
ciphertext := make([]byte, len(plaintext))
cipher.NewCBCEncrypter(block, iv).CryptBlocks(ciphertext, plaintext)
return ciphertext, nil
}
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pkcs7Pad function in common.go uses a blockSize parameter but the function argument shadows the package-level blockSize constant. Since encryptMessage in aibot.go always calls pkcs7Pad(frame, blockSize) passing the package-level constant (32), this will always pad to 32-byte blocks, which is correct. However, the AES block cipher expects input that's a multiple of 16 (AES block size), not 32. The encryptAESCBC function doesn't validate that its input is padded to a multiple of aes.BlockSize (16), whereas decryptAESCBC validates len(ciphertext) % aes.BlockSize != 0. Since blockSize (32) is a multiple of aes.BlockSize (16), this works correctly in practice. But there is a subtle mismatch: the caller pads to 32-byte multiples and the encrypt function expects AES block (16-byte) multiples. The WeCom spec requires 32-byte block padding, so this is technically correct, but adding the same validation to encryptAESCBC (or a note in the docstring) would make the expectation explicit.

Copilot uses AI. Check for mistakes.
Comment on lines +885 to +941
// downloadAndDecryptImage downloads and decrypts an encrypted image
func (c *WeComAIBotChannel) downloadAndDecryptImage(
ctx context.Context,
imageURL string,
) ([]byte, error) {
// Download image
req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}

client := &http.Client{
Timeout: 15 * time.Second,
}

resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to download image: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("download failed with status: %d", resp.StatusCode)
}

// Limit image download to 20 MB to prevent memory exhaustion
const maxImageSize = 20 << 20 // 20 MB
encryptedData, err := io.ReadAll(io.LimitReader(resp.Body, maxImageSize+1))
if err != nil {
return nil, fmt.Errorf("failed to read image data: %w", err)
}
if len(encryptedData) > maxImageSize {
return nil, fmt.Errorf("image too large (exceeds %d MB)", maxImageSize>>20)
}

logger.DebugCF("wecom_aibot", "Image downloaded", map[string]any{
"size": len(encryptedData),
})

// Decode AES key
aesKey, err := decodeWeComAESKey(c.config.EncodingAESKey)
if err != nil {
return nil, err
}

// Decrypt image (AES-CBC with IV = first 16 bytes of key, PKCS7 padding stripped)
decryptedData, err := decryptAESCBC(aesKey, encryptedData)
if err != nil {
return nil, fmt.Errorf("failed to decrypt image: %w", err)
}

logger.DebugCF("wecom_aibot", "Image decrypted", map[string]any{
"size": len(decryptedData),
})

return decryptedData, nil
}
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The downloadAndDecryptImage method is defined but never called anywhere. The handleImageMessage function currently just sends a "not yet supported" response without invoking this method. Since the image handling is marked as "not yet fully implemented" in the code, this function is dead code that will cause a linter warning (e.g., from staticcheck). Either remove it for now and add it back when image support is actually implemented, or add a // TODO: comment explaining when it will be used to suppress the warning.

Copilot uses AI. Check for mistakes.
Comment on lines +319 to +331
msgSignature := r.URL.Query().Get("msg_signature")
timestamp := r.URL.Query().Get("timestamp")
nonce := r.URL.Query().Get("nonce")
echostr := r.URL.Query().Get("echostr")

logger.DebugCF("wecom_aibot", "URL verification request", map[string]any{
"msg_signature": msgSignature,
"timestamp": timestamp,
"nonce": nonce,
})

// Verify signature
if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) {
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The handleVerification method does not validate that required URL query parameters (msg_signature, timestamp, nonce, echostr) are non-empty before proceeding. Both bot.go (line 199-201) and app.go (line 458-462) check for missing parameters first and return HTTP 400 Bad Request. Without this check, a request with all empty query params would pass verifySignature only if token is also empty (which can't happen since NewWeComAIBotChannel enforces a non-empty token), but the error returned would be a misleading "Signature verification failed" (HTTP 401) rather than "Missing parameters" (HTTP 400). More importantly, decryptMessageWithVerify would then be called with an empty echostr, resulting in an internal server error instead of a clear bad request response.

Copilot uses AI. Check for mistakes.
Comment on lines 157 to 168
"wecom_aibot": {
"_comment": "WeCom AI Bot (智能机器人) - Official WeCom AI Bot integration, supports proactive messaging and private chats.",
"enabled": false,
"token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
"webhook_host": "0.0.0.0",
"webhook_port": 18791,
"webhook_path": "/webhook/wecom-aibot",
"max_steps": 10,
"welcome_message": "Hello! I'm your AI assistant. How can I help you today?",
"reasoning_channel_id": ""
}
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The wecom_aibot section in config.example.json is missing the allow_from and reply_timeout fields. Every other channel in this file includes allow_from, and the other WeCom channels (wecom and wecom_app) both include reply_timeout. This inconsistency makes it less obvious to users that these configuration options exist for wecom_aibot. The fields are supported by the config struct (WeComAIBotConfig) and the channel implementation.

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings February 28, 2026 15:46
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 16 out of 16 changed files in this pull request and generated 11 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

| **DingTalk** | Médio (credenciais do app) |
| **LINE** | Médio (credenciais + webhook URL) |
| **WeCom** | Médio (CorpID + configuração webhook) |
| **WeCom AI Bot** | Médio (Token + chave AES) |
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The WeCom table row was replaced with "WeCom AI Bot" (same as README.md), removing coverage of WeCom Bot and WeCom App from the overview table. The "See [WeCom AI Bot Configuration Guide]" link replaces the former WeCom App guide link, leaving no dedicated link for WeCom App or WeCom Bot detailed docs.

Copilot uses AI. Check for mistakes.
| **DingTalk** | Moyen (identifiants de l'application) |
| **LINE** | Moyen (identifiants + URL de webhook) |
| **WeCom** | Moyen (CorpID + configuration webhook) |
| **WeCom AI Bot** | Moyen (Token + clé AES) |
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The WeCom table row was replaced with "WeCom AI Bot", removing coverage of WeCom Bot and WeCom App from the overview table. The "See [WeCom AI Bot Configuration Guide]" link replaces the former WeCom App guide link, leaving no dedicated link for WeCom App or WeCom Bot detailed docs.

Copilot uses AI. Check for mistakes.
Comment on lines +157 to 167
"wecom_aibot": {
"_comment": "WeCom AI Bot (智能机器人) - Official WeCom AI Bot integration, supports proactive messaging and private chats.",
"enabled": false,
"token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
"webhook_path": "/webhook/wecom-aibot",
"max_steps": 10,
"welcome_message": "Hello! I'm your AI assistant. How can I help you today?",
"reasoning_channel_id": ""
}
},
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The wecom_aibot entry in config.example.json omits allow_from and reply_timeout fields that are present in both the wecom and wecom_app example entries. For consistency and to make the example easier to use as a template, these fields should be included in the example, just as they are in the wecom and wecom_app blocks.

Copilot uses AI. Check for mistakes.
Comment on lines +501 to +557
streamID := c.generateStreamID()

// WeCom stops sending stream-refresh callbacks after 6 minutes.
// Set a slightly shorter deadline so we can send a timeout notice before it gives up.
deadline := time.Now().Add(30 * time.Second)

// Each task gets its own context derived from the channel lifetime context.
// Canceling taskCancel interrupts the agent goroutine when the task is removed.
taskCtx, taskCancel := context.WithCancel(c.ctx)

task := &streamTask{
StreamID: streamID,
ChatID: chatID,
ResponseURL: msg.ResponseURL,
Question: content,
CreatedTime: time.Now(),
Deadline: deadline,
Finished: false,
answerCh: make(chan string, 1),
ctx: taskCtx,
cancel: taskCancel,
}

c.taskMu.Lock()
c.streamTasks[streamID] = task
c.chatTasks[chatID] = append(c.chatTasks[chatID], task)
c.taskMu.Unlock()

// Publish to agent asynchronously; agent will call Send() with reply.
// Use task.ctx (not c.ctx) so the agent goroutine is canceled when the task is removed.
go func() {
sender := bus.SenderInfo{
Platform: "wecom_aibot",
PlatformID: userID,
CanonicalID: identity.BuildCanonicalID("wecom_aibot", userID),
DisplayName: userID,
}
peerKind := "direct"
if msg.ChatType == "group" {
peerKind = "group"
}
peer := bus.Peer{Kind: peerKind, ID: chatID}
metadata := map[string]string{
"channel": "wecom_aibot",
"chat_type": msg.ChatType,
"msg_type": "text",
"msgid": msg.MsgID,
"aibotid": msg.AIBotID,
"stream_id": streamID,
"response_url": msg.ResponseURL,
}
c.HandleMessage(task.ctx, peer, msg.MsgID, userID, chatID,
content, nil, metadata, sender)
}()

// Return first streaming response immediately (finish=false, content empty)
return c.getStreamResponse(task, timestamp, nonce)
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In handleTextMessage, a streamTask is created and added to the task maps before checking whether the sender is allowed. The allow-list check happens indirectly inside the goroutine via HandleMessage. If the sender is not allowed, HandleMessage returns early without ever sending a reply, so task.answerCh is never written. This means the task will sit in the map responding with finish=false until its 30-second deadline, at which point it transitions to response_url mode and remains in chatTasks for an additional 10 minutes (streamClosedGracePeriod) before cleanup. You should call IsAllowedSender before creating the stream task and return an appropriate encrypted response immediately if the sender is blocked, just like bot.go does at line 410.

Copilot uses AI. Check for mistakes.
| **DingTalk** | Trung bình (app credentials) |
| **LINE** | Trung bình (credentials + webhook URL) |
| **WeCom** | Trung bình (CorpID + cấu hình webhook) |
| **WeCom AI Bot** | Trung bình (Token + khóa AES) |
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The WeCom table row was replaced with "WeCom AI Bot" (same as README.md), removing coverage of WeCom Bot and WeCom App from the overview table. Additionally the "See [WeCom AI Bot Configuration Guide]" link replaces the former WeCom App guide link, leaving no dedicated link for WeCom App or WeCom Bot detailed docs.

Copilot uses AI. Check for mistakes.

// HealthPath returns the health check endpoint path
func (c *WeComAIBotChannel) HealthPath() string {
return c.WebhookPath() + "/health"
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The HealthPath() method returns c.WebhookPath() + "/health" (e.g., /webhook/wecom-aibot/health), which deviates from the established pattern in the same package: bot.go returns /health/wecom and app.go returns /health/wecom-app. Both use fixed, separate paths instead of composing them from the webhook path. Consider adopting the same convention (e.g., returning /health/wecom-aibot) for consistency.

Suggested change
return c.WebhookPath() + "/health"
return "/health/wecom-aibot"

Copilot uses AI. Check for mistakes.
Comment on lines +894 to +895
for i := range b {
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In generateStreamID, the error from rand.Int is silently ignored (assigned to _). If the call fails, n is nil and n.Int64() will panic. Compare this to packWeComFrame in common.go (line 109-113), which properly handles the same rand.Int error. Consider handling the error or using a simpler approach like crypto/rand.Read directly.

Suggested change
for i := range b {
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
max := big.NewInt(int64(len(letters)))
for i := range b {
n, err := rand.Int(rand.Reader, max)
if err != nil {
logger.ErrorCF("wecom_aibot", "Failed to generate random stream ID", map[string]any{
"error": err,
})
return ""
}

Copilot uses AI. Check for mistakes.
| **DingTalk** | 普通(アプリ認証情報) |
| **LINE** | 普通(認証情報 + Webhook URL) |
| **WeCom** | 普通(CorpID + Webhook設定) |
| **WeCom AI Bot** | 普通(Token + AES キー) |
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The WeCom table row was replaced with "WeCom AI Bot", removing coverage of WeCom Bot and WeCom App from the overview table. The "See [WeCom AI Bot Configuration Guide]" link replaces the former WeCom App guide link, leaving no dedicated link for WeCom App or WeCom Bot detailed docs.

Copilot uses AI. Check for mistakes.
Comment on lines +901 to +957
// downloadAndDecryptImage downloads and decrypts an encrypted image
func (c *WeComAIBotChannel) downloadAndDecryptImage(
ctx context.Context,
imageURL string,
) ([]byte, error) {
// Download image
req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}

client := &http.Client{
Timeout: 15 * time.Second,
}

resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to download image: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("download failed with status: %d", resp.StatusCode)
}

// Limit image download to 20 MB to prevent memory exhaustion
const maxImageSize = 20 << 20 // 20 MB
encryptedData, err := io.ReadAll(io.LimitReader(resp.Body, maxImageSize+1))
if err != nil {
return nil, fmt.Errorf("failed to read image data: %w", err)
}
if len(encryptedData) > maxImageSize {
return nil, fmt.Errorf("image too large (exceeds %d MB)", maxImageSize>>20)
}

logger.DebugCF("wecom_aibot", "Image downloaded", map[string]any{
"size": len(encryptedData),
})

// Decode AES key
aesKey, err := decodeWeComAESKey(c.config.EncodingAESKey)
if err != nil {
return nil, err
}

// Decrypt image (AES-CBC with IV = first 16 bytes of key, PKCS7 padding stripped)
decryptedData, err := decryptAESCBC(aesKey, encryptedData)
if err != nil {
return nil, fmt.Errorf("failed to decrypt image: %w", err)
}

logger.DebugCF("wecom_aibot", "Image decrypted", map[string]any{
"size": len(decryptedData),
})

return decryptedData, nil
}
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The downloadAndDecryptImage method (and the related WeComAIBotMsgItem / WeComAIBotMsgItemImage types) are never called or used anywhere in the codebase. handleImageMessage acknowledges image messages but does not decrypt or forward the image. This dead code increases maintenance burden and could confuse reviewers. Either wire the function into handleImageMessage to forward image content to the agent, or remove the function and the associated types until image support is fully implemented.

Copilot uses AI. Check for mistakes.
Comment on lines +172 to +174
if padding == 0 {
padding = blockSize
}
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In pkcs7Pad, the expression blockSize - (len(data) % blockSize) evaluates to a value in the range [1, blockSize] — it is never 0. The guard if padding == 0 { padding = blockSize } is therefore unreachable dead code. Remove it to avoid confusion about the function's invariants.

Suggested change
if padding == 0 {
padding = blockSize
}

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings February 28, 2026 16:02
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 16 out of 16 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

}
req.Header.Set("Content-Type", "application/json; charset=utf-8")

client := &http.Client{Timeout: 15 * time.Second}
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In sendViaResponseURL, both a context timeout (context.WithTimeout(c.ctx, 15*time.Second)) and an http.Client{Timeout: 15*time.Second} are set to the same duration. Since the request is already bounded by the context timeout derived from c.ctx, the client.Timeout is redundant. The effective timeout is whichever fires first (they're both 15s, so it doesn't change behavior), but having two independent timeout mechanisms with identical values is confusing and unnecessary. The http.Client.Timeout should be removed, relying solely on the context for cancellation, which is the idiomatic Go pattern when using http.NewRequestWithContext.

Suggested change
client := &http.Client{Timeout: 15 * time.Second}
client := &http.Client{}

Copilot uses AI. Check for mistakes.
Comment on lines +172 to +174
if padding == 0 {
padding = blockSize
}
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pkcs7Pad function contains unreachable code. The expression blockSize - (len(data) % blockSize) always yields a value in the range [1, blockSize] (since len(data) % blockSize is in [0, blockSize-1]), so padding can never be 0. The if padding == 0 branch (lines 172-174) is dead code and should be removed to avoid misleading readers into thinking a zero-padding case is possible.

Suggested change
if padding == 0 {
padding = blockSize
}

Copilot uses AI. Check for mistakes.
picoclaw gateway
```

> **Lưu ý**: WeCom AI Bot sử dụng giao thức pull streaming — không lo timeout phản hồi. Tác vụ dài (>5,5 phút) tự động chuyển sang gửi qua `response_url`.
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The timeout threshold is inconsistently documented across the localized READMEs. The code at aibot.go:510 uses a 30-second deadline (deadline := time.Now().Add(30 * time.Second)), and the English README correctly states ">30 seconds". However, the Vietnamese (README.vi.md), French (README.fr.md), and Portuguese (README.pt-br.md) READMEs all incorrectly state ">5.5 minutes" as the threshold. These should be updated to say ">30 seconds" to match the actual code behavior.

Copilot uses AI. Check for mistakes.
picoclaw gateway
```

> **Note** : WeCom AI Bot utilise le protocole pull en streaming — pas de problème de timeout. Les tâches longues (>5,5 min) basculent automatiquement vers la livraison via `response_url`.
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The timeout threshold stated here (">5,5 min") is incorrect. The actual code deadline is 30 seconds (aibot.go:510), and the English README.md correctly states ">30 seconds". This should be updated to ">30 secondes" to match the actual behavior.

Copilot uses AI. Check for mistakes.
picoclaw gateway
```

> **Nota**: O WeCom AI Bot usa protocolo de pull em streaming — sem preocupações com timeout de resposta. Tarefas longas (>5,5 min) alternam automaticamente para entrega via `response_url`.
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The timeout threshold stated here (">5,5 min") is incorrect. The actual code deadline is 30 seconds (aibot.go:510), and the English README.md correctly states ">30 seconds". This should be updated to ">30 segundos" to match the actual behavior.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants