diff --git a/frontend/src/api/agent/index.ts b/frontend/src/api/agent/index.ts
index 1d6fb0806..38667d07c 100644
--- a/frontend/src/api/agent/index.ts
+++ b/frontend/src/api/agent/index.ts
@@ -183,10 +183,10 @@ export interface IMChannel {
id: string;
tenant_id?: number;
agent_id: string;
- platform: 'wecom' | 'feishu' | 'slack' | 'telegram' | 'dingtalk' | 'mattermost';
+ platform: 'wecom' | 'feishu' | 'slack' | 'telegram' | 'dingtalk' | 'mattermost' | 'wechat';
name: string;
enabled: boolean;
- mode: 'webhook' | 'websocket';
+ mode: 'webhook' | 'websocket' | 'longpoll';
output_mode: 'stream' | 'full';
session_mode?: 'user' | 'thread';
knowledge_base_id?: string;
@@ -237,3 +237,27 @@ export function getSuggestedQuestions(
const qs = query.toString();
return get<{ data: { questions: SuggestedQuestion[] } }>(`/api/v1/agents/${agentId}/suggested-questions${qs ? '?' + qs : ''}`);
}
+// ===== WeChat QR Code Login =====
+
+export interface WeChatQRCodeResult {
+ qrcode_url: string;
+ qrcode: string;
+}
+
+export interface WeChatQRCodeStatus {
+ status: 'wait' | 'scaned' | 'confirmed' | 'expired';
+ credentials?: {
+ bot_token: string;
+ ilink_bot_id: string;
+ ilink_user_id: string;
+ };
+ baseurl?: string;
+}
+
+export function getWeChatQRCode() {
+ return post<{ data: WeChatQRCodeResult }>('/api/v1/wechat/qrcode');
+}
+
+export function pollWeChatQRCodeStatus(qrcode: string) {
+ return post<{ data: WeChatQRCodeStatus }>('/api/v1/wechat/qrcode/status', { qrcode });
+}
diff --git a/frontend/src/components/IMChannelPanel.vue b/frontend/src/components/IMChannelPanel.vue
index ea4e05975..24668ab6b 100644
--- a/frontend/src/components/IMChannelPanel.vue
+++ b/frontend/src/components/IMChannelPanel.vue
@@ -89,13 +89,14 @@
-
+
{{ $t('agentEditor.im.wecom') }}
{{ $t('agentEditor.im.feishu') }}
{{ $t('agentEditor.im.slack') }}
{{ $t('agentEditor.im.telegram') }}
{{ $t('agentEditor.im.dingtalk') }}
{{ $t('agentEditor.im.mattermost') }}
+ {{ $t('agentEditor.im.wechat') }}
@@ -105,8 +106,8 @@
-
-
\ No newline at end of file
+
+// --- WeChat QR code binding ---
+.wechat-bound-status {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 12px 16px;
+ background: rgba(7, 193, 96, 0.06);
+ border: 1px solid rgba(7, 193, 96, 0.2);
+ border-radius: 8px;
+ font-size: 14px;
+ color: var(--td-text-color-primary);
+
+ .bound-icon {
+ font-size: 18px;
+ color: #07c160;
+ }
+}
+
+.wechat-qr-section {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 12px;
+}
+
+.wechat-bind-action {
+ padding: 24px 0;
+}
+
+.wechat-qr-display {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 12px;
+ padding: 16px 0;
+}
+
+.qr-container {
+ position: relative;
+ width: 200px;
+ height: 200px;
+ border: 1px solid var(--td-component-stroke);
+ border-radius: 8px;
+ overflow: hidden;
+ // QR code images are always black-on-white; force white background
+ // so the code remains scannable in dark mode.
+ background: #fff;
+
+ .qr-image {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ }
+}
+
+.qr-expired-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ background: rgba(0, 0, 0, 0.6);
+ color: #fff;
+ cursor: pointer;
+ font-size: 12px;
+
+ .refresh-icon {
+ font-size: 24px;
+ }
+}
+
+.qr-hint {
+ font-size: 13px;
+ color: var(--td-text-color-secondary);
+ text-align: center;
+}
+
diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts
index 5562ac465..cf7ff963c 100755
--- a/frontend/src/i18n/locales/en-US.ts
+++ b/frontend/src/i18n/locales/en-US.ts
@@ -3314,12 +3314,14 @@ export default {
},
im: {
title: 'IM Integration',
- description: 'Connect agent to instant messaging platforms like WeCom, Feishu, Slack, Telegram, DingTalk, and Mattermost',
+ description: 'Connect agent to instant messaging platforms like WeCom, Feishu, Slack, Telegram, DingTalk, Mattermost and WeChat',
feishu: 'Feishu',
slack: 'Slack',
telegram: 'Telegram',
dingtalk: 'DingTalk',
mattermost: 'Mattermost',
+ wecom: 'WeCom',
+ wechat: 'WeChat',
addChannel: 'Add Channel',
editChannel: 'Edit Channel',
deleteConfirm: 'Are you sure you want to delete this channel? This action cannot be undone.',
@@ -3354,6 +3356,13 @@ export default {
sessionModeUser: 'Per User (default)',
sessionModeThread: 'Per Thread',
sessionModeHint: 'User mode: each person has their own conversation. Use /clear to start fresh. Thread mode: each message thread is a separate conversation. Multiple people can collaborate in the same thread.',
+ wechatScanBind: 'Scan to bind WeChat',
+ wechatScanning: 'Scan the QR code with WeChat',
+ wechatBindSuccess: 'WeChat bound successfully',
+ wechatRebind: 'Rebind',
+ wechatHint: 'Requires iOS WeChat 8.0.70+, direct messages only',
+ wechatQRExpired: 'QR code expired, please try again',
+ wechatBinding: 'Binding...',
},
mcp: {
label: 'MCP Services',
diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts
index 846b9a361..75902aecc 100755
--- a/frontend/src/i18n/locales/zh-CN.ts
+++ b/frontend/src/i18n/locales/zh-CN.ts
@@ -3289,13 +3289,14 @@ export default {
},
im: {
title: "IM 集成",
- description: "将智能体接入即时通讯平台,支持企业微信、飞书、Slack、Telegram、钉钉和 Mattermost",
+ description: "将智能体接入即时通讯平台,支持企业微信、飞书、Slack、Telegram、钉钉、Mattermost和微信",
wecom: "企业微信",
feishu: "飞书",
slack: "Slack",
telegram: "Telegram",
dingtalk: "钉钉",
mattermost: "Mattermost",
+ wechat: "微信",
addChannel: "添加渠道",
editChannel: "编辑渠道",
deleteConfirm: "确定删除该渠道?删除后无法恢复。",
@@ -3330,6 +3331,13 @@ export default {
sessionModeUser: "按用户(默认)",
sessionModeThread: "按话题",
sessionModeHint: "用户模式:每个用户独立对话,使用 /clear 开始新对话。话题模式:每个消息话题独立对话,同一话题中多人可协作。",
+ wechatScanBind: "扫码绑定微信",
+ wechatScanning: "请使用微信扫描二维码",
+ wechatBindSuccess: "微信绑定成功",
+ wechatRebind: "重新绑定",
+ wechatHint: "需要 iOS 微信 8.0.70+ 版本,仅支持单聊消息",
+ wechatQRExpired: "二维码已过期,请重新获取",
+ wechatBinding: "绑定中...",
},
tools: {
thinking: "思考",
diff --git a/internal/container/container.go b/internal/container/container.go
index 761b09b60..56ba10599 100644
--- a/internal/container/container.go
+++ b/internal/container/container.go
@@ -61,6 +61,7 @@ import (
"github.com/Tencent/WeKnora/internal/im/mattermost"
"github.com/Tencent/WeKnora/internal/im/slack"
"github.com/Tencent/WeKnora/internal/im/telegram"
+ "github.com/Tencent/WeKnora/internal/im/wechat"
"github.com/Tencent/WeKnora/internal/im/wecom"
"github.com/Tencent/WeKnora/internal/infrastructure/docparser"
infra_web_search "github.com/Tencent/WeKnora/internal/infrastructure/web_search"
@@ -1277,6 +1278,32 @@ func registerIMAdapterFactories(imService *imPkg.Service) {
adapter := mattermost.NewAdapter(client, outgoingToken, botUserID, postReplyToMain)
return adapter, func() {}, nil
})
+ // Register WeChat adapter factory
+ imService.RegisterAdapterFactory("wechat", func(factoryCtx context.Context, channel *imPkg.IMChannel, msgHandler func(context.Context, *imPkg.IncomingMessage) error) (imPkg.Adapter, context.CancelFunc, error) {
+ creds, err := parseCredentials(channel.Credentials)
+ if err != nil {
+ return nil, nil, fmt.Errorf("parse wechat credentials: %w", err)
+ }
+
+ botToken := getString(creds, "bot_token")
+ ilinkBotID := getString(creds, "ilink_bot_id")
+
+ if botToken == "" || ilinkBotID == "" {
+ return nil, nil, fmt.Errorf("wechat credentials require bot_token and ilink_bot_id")
+ }
+
+ adapter := wechat.NewAdapter(botToken, ilinkBotID)
+ client := wechat.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
+ })
// Load and start all enabled channels from database
if err := imService.LoadAndStartChannels(); err != nil {
diff --git a/internal/handler/im.go b/internal/handler/im.go
index 98b7e48d9..5813a6dcb 100644
--- a/internal/handler/im.go
+++ b/internal/handler/im.go
@@ -14,6 +14,7 @@ import (
// validIMPlatforms is the set of supported IM platforms.
var validIMPlatforms = map[string]bool{
"wecom": true, "feishu": true, "slack": true, "telegram": true, "dingtalk": true, "mattermost": true,
+ "wechat": true,
}
// IMHandler handles IM platform callback requests and channel CRUD.
@@ -45,13 +46,13 @@ func (h *IMHandler) CreateIMChannel(c *gin.Context) {
}
var req struct {
- Platform string `json:"platform" binding:"required"`
- Name string `json:"name"`
- Mode string `json:"mode"`
- OutputMode string `json:"output_mode"`
- KnowledgeBaseID string `json:"knowledge_base_id"`
- Credentials types.JSON `json:"credentials"`
- Enabled *bool `json:"enabled"`
+ Platform string `json:"platform" binding:"required"`
+ Name string `json:"name"`
+ Mode string `json:"mode"`
+ OutputMode string `json:"output_mode"`
+ KnowledgeBaseID string `json:"knowledge_base_id"`
+ Credentials types.JSON `json:"credentials"`
+ Enabled *bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -59,7 +60,7 @@ func (h *IMHandler) CreateIMChannel(c *gin.Context) {
}
if !validIMPlatforms[req.Platform] {
- c.JSON(http.StatusBadRequest, gin.H{"error": "platform must be 'wecom', 'feishu', 'slack', 'telegram', 'dingtalk' or 'mattermost'"})
+ c.JSON(http.StatusBadRequest, gin.H{"error": "platform must be 'wecom', 'feishu', 'slack', 'telegram', 'dingtalk', 'mattermost' or 'wechat'"})
return
}
@@ -77,15 +78,21 @@ func (h *IMHandler) CreateIMChannel(c *gin.Context) {
if req.Enabled != nil {
channel.Enabled = *req.Enabled
}
- if channel.Mode == "" {
- if channel.Platform == "mattermost" {
- channel.Mode = "webhook"
- } else {
- channel.Mode = "websocket"
+ // WeChat uses long-polling mode and full output only
+ if req.Platform == "wechat" {
+ channel.Mode = "longpoll"
+ channel.OutputMode = "full"
+ } else {
+ if channel.Mode == "" {
+ if channel.Platform == "mattermost" {
+ channel.Mode = "webhook"
+ } else {
+ channel.Mode = "websocket"
+ }
+ }
+ if channel.OutputMode == "" {
+ channel.OutputMode = "stream"
}
- }
- if channel.OutputMode == "" {
- channel.OutputMode = "stream"
}
if channel.Credentials == nil {
channel.Credentials = types.JSON("{}")
diff --git a/internal/handler/wechat_qrcode.go b/internal/handler/wechat_qrcode.go
new file mode 100644
index 000000000..8a05dbed5
--- /dev/null
+++ b/internal/handler/wechat_qrcode.go
@@ -0,0 +1,72 @@
+package handler
+
+import (
+ "net/http"
+
+ "github.com/Tencent/WeKnora/internal/im/wechat"
+ "github.com/Tencent/WeKnora/internal/logger"
+ "github.com/gin-gonic/gin"
+)
+
+// qrCodeService is a singleton for WeChat QR code operations.
+var qrCodeService = wechat.NewQRCodeService()
+
+// WeChatGetQRCode generates a QR code for WeChat login.
+// POST /api/v1/wechat/qrcode
+func (h *IMHandler) WeChatGetQRCode(c *gin.Context) {
+ ctx := c.Request.Context()
+
+ result, err := qrCodeService.GetLoginQRCode(ctx)
+ if err != nil {
+ logger.Errorf(ctx, "[WeChat] Failed to generate QR code: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate QR code: " + err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "data": gin.H{
+ "qrcode_url": result.QRCodeURL,
+ "qrcode": result.QRCode,
+ },
+ })
+}
+
+// WeChatPollQRCodeStatus checks the scan status of a WeChat QR code.
+// POST /api/v1/wechat/qrcode/status
+func (h *IMHandler) WeChatPollQRCodeStatus(c *gin.Context) {
+ ctx := c.Request.Context()
+
+ var req struct {
+ QRCode string `json:"qrcode" binding:"required"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "qrcode is required"})
+ return
+ }
+
+ result, err := qrCodeService.PollQRCodeStatus(ctx, req.QRCode)
+ if err != nil {
+ logger.Errorf(ctx, "[WeChat] Failed to poll QR code status: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check QR code status"})
+ return
+ }
+
+ resp := gin.H{
+ "status": result.Status,
+ }
+
+ // Only include credentials when login is confirmed
+ if result.Status == "confirmed" {
+ resp["credentials"] = gin.H{
+ "bot_token": result.BotToken,
+ "ilink_bot_id": result.ILinkBotID,
+ "ilink_user_id": result.ILinkUserID,
+ }
+ // Include baseurl if the server returned one (may override default)
+ if result.BaseURL != "" {
+ resp["baseurl"] = result.BaseURL
+ }
+ }
+
+ c.JSON(http.StatusOK, gin.H{"data": resp})
+}
diff --git a/internal/im/adapter.go b/internal/im/adapter.go
index e35eb6e80..f8691f371 100644
--- a/internal/im/adapter.go
+++ b/internal/im/adapter.go
@@ -17,6 +17,7 @@ const (
PlatformTelegram Platform = "telegram"
PlatformDingtalk Platform = "dingtalk"
PlatformMattermost Platform = "mattermost"
+ PlatformWeChat Platform = "wechat"
)
// SessionMode determines how IM sessions are resolved.
diff --git a/internal/im/service.go b/internal/im/service.go
index 3c693df01..73fe43c16 100644
--- a/internal/im/service.go
+++ b/internal/im/service.go
@@ -423,12 +423,14 @@ func (s *Service) StartChannel(channel *IMChannel) error {
}
s.mu.Unlock()
- // For WebSocket channels, try leader election to avoid duplicate connections.
- if channel.Mode == "websocket" && s.redis != nil {
+ // For WebSocket / long-poll channels, try leader election to avoid
+ // duplicate connections. Only one instance should actively poll or
+ // maintain a persistent connection for each channel.
+ if (channel.Mode == "websocket" || channel.Mode == "longpoll") && s.redis != nil {
acquired := s.tryAcquireWSLeader(channel.ID)
if !acquired {
logger.Infof(context.Background(),
- "[IM] Channel %s WebSocket owned by another instance, will retry", channel.ID)
+ "[IM] Channel %s %s owned by another instance, will retry", channel.ID, channel.Mode)
go s.wsLeaderRetryLoop(channel)
return nil
}
@@ -451,9 +453,9 @@ func (s *Service) startChannelInternal(channel *IMChannel, factory AdapterFactor
return fmt.Errorf("create adapter: %w", err)
}
- // Start leader renewal goroutine for WebSocket channels.
+ // Start leader renewal goroutine for WebSocket / long-poll channels.
var leaderCancel context.CancelFunc
- if channel.Mode == "websocket" && s.redis != nil {
+ if (channel.Mode == "websocket" || channel.Mode == "longpoll") && s.redis != nil {
leaderCtx, lCancel := context.WithCancel(context.Background())
leaderCancel = lCancel
go s.wsLeaderRenewLoop(leaderCtx, channel.ID)
@@ -490,8 +492,18 @@ func (s *Service) stopChannelLocked(channelID string, cs *channelState) {
cs.Cancel()
}
delete(s.channels, channelID)
- s.releaseWSLeader(channelID)
- logger.Infof(context.Background(), "[IM] Stopped channel: id=%s", channelID)
+ // For long-poll channels, do NOT release the leader lock immediately.
+ // Let it expire naturally via TTL so the old poll goroutine has time to
+ // fully drain before another instance takes over. This prevents a brief
+ // dual-writer window where both old and new instances process messages.
+ // For websocket channels, the connection closes synchronously, so
+ // immediate release is safe.
+ if cs.Channel != nil && cs.Channel.Mode == "longpoll" {
+ logger.Infof(context.Background(), "[IM] Stopped longpoll channel: id=%s (leader lock will expire via TTL)", channelID)
+ } else {
+ s.releaseWSLeader(channelID)
+ logger.Infof(context.Background(), "[IM] Stopped channel: id=%s", channelID)
+ }
}
// ── WebSocket leader election ───────────────────────────────────────────────
@@ -2022,18 +2034,6 @@ func (s *Service) handleFileMessage(ctx context.Context, msg *IncomingMessage, a
fmt.Sprintf("❌ 不支持的文件类型「%s」。\n\n支持的类型:PDF、Word、TXT、Markdown、Excel、CSV、PPT、图片。", ext))
}
- displayName := msg.FileName
- if ext == "" {
- displayName = "文件"
- }
-
- // Send "processing started" notification (streaming)
- if err := s.sendSmartReply(ctx, adapter, msg, channel,
- fmt.Sprintf("用户发送了一个文件「%s」,系统正在处理并保存到知识库中,需要告知用户请稍候。", displayName),
- fmt.Sprintf("📥 已收到%s,正在处理并保存到知识库,请稍候...", displayName)); err != nil {
- logger.Warnf(ctx, "[IM] Failed to send file processing start notification: %v", err)
- }
-
// Process asynchronously to avoid blocking the message handler
go s.processFileToKnowledgeBase(context.WithoutCancel(ctx), msg, downloader, adapter, channel)
@@ -2116,18 +2116,20 @@ func (s *Service) processFileToKnowledgeBase(ctx context.Context, msg *IncomingM
// It uses sendSmartReply to generate a friendly, streaming reply via the channel's LLM.
// Falls back to a static template if the LLM is unavailable.
func (s *Service) sendFileResult(ctx context.Context, adapter Adapter, msg *IncomingMessage, fileName string, success bool, errDetail string, channel *IMChannel) {
+ typeName := fileTypeName(fileName)
+
var fallback string
if success {
- fallback = fmt.Sprintf("✅ 文件「%s」已保存到知识库,正在解析中,完成后会通知你~", fileName)
+ fallback = fmt.Sprintf("✅ %s已保存到知识库,正在解析中,完成后会通知你~", typeName)
} else {
- fallback = fmt.Sprintf("❌ 文件「%s」处理失败:%s", fileName, errDetail)
+ fallback = fmt.Sprintf("❌ %s处理失败:%s", typeName, errDetail)
}
var situation string
if success {
- situation = fmt.Sprintf("用户上传的文件「%s」已成功保存到知识库,但还需要后台解析文档内容(这需要一些时间)。请告知用户文件已收到,正在解析处理中,解析完成后会自动推送结果。", fileName)
+ situation = fmt.Sprintf("用户上传的%s已成功保存到知识库,但还需要后台解析文档内容(这需要一些时间)。请告知用户文件已收到,正在解析处理中,解析完成后会自动推送结果。", typeName)
} else {
- situation = fmt.Sprintf("用户上传的文件「%s」处理失败,原因:%s。", fileName, errDetail)
+ situation = fmt.Sprintf("用户上传的%s处理失败,原因:%s。", typeName, errDetail)
}
if err := s.sendSmartReply(ctx, adapter, msg, channel, situation, fallback); err != nil {
@@ -2346,6 +2348,8 @@ func (s *Service) watchAndSendSummary(
return
}
+ typeName := fileTypeName(fileName)
+
switch knowledge.ParseStatus {
case types.ParseStatusFailed:
// Parsing failed — notify user and stop watching
@@ -2354,8 +2358,8 @@ func (s *Service) watchAndSendSummary(
errMsg = "文档解析失败"
}
_ = s.sendSmartReply(ctx, adapter, msg, channel,
- fmt.Sprintf("用户之前上传的文件「%s」解析失败了,错误原因:%s。请安慰用户并建议重试。", fileName, errMsg),
- fmt.Sprintf("⚠️ 文件「%s」解析失败:%s", fileName, errMsg))
+ fmt.Sprintf("用户之前上传的%s解析失败了,错误原因:%s。请安慰用户并建议重试。", typeName, errMsg),
+ fmt.Sprintf("⚠️ %s解析失败:%s", typeName, errMsg))
return
case types.ParseStatusCompleted:
@@ -2367,12 +2371,12 @@ func (s *Service) watchAndSendSummary(
// still show it if present.
if knowledge.Description != "" && knowledge.Description != fileName {
_ = s.sendSmartReply(ctx, adapter, msg, channel,
- fmt.Sprintf("用户之前上传的文件「%s」已解析完成。以下是文件的完整摘要内容:\n%s\n\n请生成一条通知消息,包含:1) 告知文件已解析完成;2) 用 Markdown 格式(标题、列表、加粗等)结构化展示上述摘要内容,不要删减或概括;3) 提示用户可以针对该文件提问。", fileName, knowledge.Description),
- fmt.Sprintf("📄 文件「%s」已解析完成。\n\n**摘要:**\n\n%s\n\n---\n可以针对该文件进行提问。", fileName, knowledge.Description))
+ fmt.Sprintf("用户之前上传的%s已解析完成。以下是文件的完整摘要内容:\n%s\n\n请生成一条通知消息,包含:1) 告知文件已解析完成;2) 用 Markdown 格式(标题、列表、加粗等)结构化展示上述摘要内容,不要删减或概括;3) 提示用户可以针对该文件提问。", typeName, knowledge.Description),
+ fmt.Sprintf("📄 %s已解析完成。\n\n**摘要:**\n\n%s\n\n---\n可以针对该文件进行提问。", typeName, knowledge.Description))
} else {
_ = s.sendSmartReply(ctx, adapter, msg, channel,
- fmt.Sprintf("用户之前上传的文件「%s」已解析完成,现在可以开始针对该文件进行提问了。", fileName),
- fmt.Sprintf("📄 文件「%s」已解析完成,可以开始提问了!", fileName))
+ fmt.Sprintf("用户之前上传的%s已解析完成,现在可以开始针对该文件进行提问了。", typeName),
+ fmt.Sprintf("📄 %s已解析完成,可以开始提问了!", typeName))
}
return
@@ -2383,8 +2387,8 @@ func (s *Service) watchAndSendSummary(
case types.SummaryStatusFailed:
_ = s.sendSmartReply(ctx, adapter, msg, channel,
- fmt.Sprintf("用户之前上传的文件「%s」已解析完成,但摘要生成失败了。不过文件已可用于提问。", fileName),
- fmt.Sprintf("📄 文件「%s」已解析完成,可以开始提问了!(摘要生成失败)", fileName))
+ fmt.Sprintf("用户之前上传的%s已解析完成,但摘要生成失败了。不过文件已可用于提问。", typeName),
+ fmt.Sprintf("📄 %s已解析完成,可以开始提问了!(摘要生成失败)", typeName))
return
default:
@@ -2416,13 +2420,14 @@ func (s *Service) sendSummaryNotification(
summary = knowledge.Title
}
+ typeName := fileTypeName(fileName)
var situation, fallback string
if summary != "" && summary != fileName {
- situation = fmt.Sprintf("用户之前上传的文件「%s」已解析完成。以下是文件的完整摘要内容:\n%s\n\n请生成一条通知消息,包含:1) 告知文件已解析完成;2) 用 Markdown 格式(标题、列表、加粗等)结构化展示上述摘要内容,不要删减或概括;3) 提示用户可以针对该文件提问。", fileName, summary)
- fallback = fmt.Sprintf("📄 文件「%s」已解析完成。\n\n**摘要:**\n\n%s\n\n---\n可以针对该文件进行提问。", fileName, summary)
+ situation = fmt.Sprintf("用户之前上传的%s已解析完成。以下是文件的完整摘要内容:\n%s\n\n请生成一条通知消息,包含:1) 告知文件已解析完成;2) 用 Markdown 格式(标题、列表、加粗等)结构化展示上述摘要内容,不要删减或概括;3) 提示用户可以针对该文件提问。", typeName, summary)
+ fallback = fmt.Sprintf("📄 %s已解析完成。\n\n**摘要:**\n\n%s\n\n---\n可以针对该文件进行提问。", typeName, summary)
} else {
- situation = fmt.Sprintf("用户之前上传的文件「%s」已解析完成,现在可以开始针对该文件进行提问了。", fileName)
- fallback = fmt.Sprintf("📄 文件「%s」已解析完成,可以开始提问了!", fileName)
+ situation = fmt.Sprintf("用户之前上传的%s已解析完成,现在可以开始针对该文件进行提问了。", typeName)
+ fallback = fmt.Sprintf("📄 %s已解析完成,可以开始提问了!", typeName)
}
if err := s.sendSmartReply(ctx, adapter, msg, channel, situation, fallback); err != nil {
@@ -2457,6 +2462,30 @@ func imPlatformToChannel(platform string) string {
}
}
+// fileTypeName returns a human-readable file type name based on the file extension.
+func fileTypeName(filename string) string {
+ switch fileExtension(filename) {
+ case "pdf":
+ return "PDF 文档"
+ case "doc", "docx":
+ return "Word 文档"
+ case "txt":
+ return "文本文件"
+ case "md", "markdown":
+ return "Markdown 文档"
+ case "png", "jpg", "jpeg", "gif":
+ return "图片"
+ case "csv":
+ return "CSV 表格"
+ case "xls", "xlsx":
+ return "Excel 表格"
+ case "ppt", "pptx":
+ return "PPT 演示文稿"
+ default:
+ return "文件"
+ }
+}
+
// newInMemoryFileHeader wraps in-memory file content as a *multipart.FileHeader
// so it can be passed to CreateKnowledgeFromFile which expects a multipart upload.
func newInMemoryFileHeader(filename string, data []byte) *multipart.FileHeader {
diff --git a/internal/im/types.go b/internal/im/types.go
index a574b1368..092a38d92 100644
--- a/internal/im/types.go
+++ b/internal/im/types.go
@@ -136,6 +136,10 @@ func (ch *IMChannel) computeBotIdentity() string {
if tok := str("outgoing_token"); tok != "" {
return "mattermost:wh:" + tok
}
+ case "wechat":
+ if botID := str("ilink_bot_id"); botID != "" {
+ return "wechat:" + botID
+ }
}
return ""
}
diff --git a/internal/im/wechat/adapter.go b/internal/im/wechat/adapter.go
new file mode 100644
index 000000000..4a3c3fdb0
--- /dev/null
+++ b/internal/im/wechat/adapter.go
@@ -0,0 +1,322 @@
+// 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"
+ "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 = &http.Client{Timeout: 30 * time.Second}
+
+// 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")
+ }
+
+ 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)))
+}
diff --git a/internal/im/wechat/crypto.go b/internal/im/wechat/crypto.go
new file mode 100644
index 000000000..839ec7a28
--- /dev/null
+++ b/internal/im/wechat/crypto.go
@@ -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
+}
diff --git a/internal/im/wechat/longpoll.go b/internal/im/wechat/longpoll.go
new file mode 100644
index 000000000..76edb4d87
--- /dev/null
+++ b/internal/im/wechat/longpoll.go
@@ -0,0 +1,363 @@
+// 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"
+ "math"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/Tencent/WeKnora/internal/im"
+ "github.com/Tencent/WeKnora/internal/logger"
+)
+
+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: &http.Client{Timeout: longPollHTTPTimeout},
+ }
+}
+
+// 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 {
+ delay := reconnectBaseDelay * time.Duration(math.Pow(2, float64(attempt-1)))
+ 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
+}
diff --git a/internal/im/wechat/qrcode.go b/internal/im/wechat/qrcode.go
new file mode 100644
index 000000000..176de300a
--- /dev/null
+++ b/internal/im/wechat/qrcode.go
@@ -0,0 +1,159 @@
+// 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"
+)
+
+// 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: &http.Client{Timeout: pollTimeout},
+ }
+}
+
+// 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
+}
diff --git a/internal/im/wecom/webhook_adapter.go b/internal/im/wecom/webhook_adapter.go
index b088cc43a..a46bb20a7 100644
--- a/internal/im/wecom/webhook_adapter.go
+++ b/internal/im/wecom/webhook_adapter.go
@@ -595,6 +595,8 @@ 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.
diff --git a/internal/router/router.go b/internal/router/router.go
index 36794a403..5b5bdaf06 100644
--- a/internal/router/router.go
+++ b/internal/router/router.go
@@ -641,6 +641,13 @@ func RegisterIMChannelRoutes(r *gin.RouterGroup, imHandler *handler.IMHandler) {
channels.DELETE("/:id", imHandler.DeleteIMChannel)
channels.POST("/:id/toggle", imHandler.ToggleIMChannel)
}
+
+ // WeChat QR code login (requires authentication)
+ wechatGroup := r.Group("/wechat")
+ {
+ wechatGroup.POST("/qrcode", imHandler.WeChatGetQRCode)
+ wechatGroup.POST("/qrcode/status", imHandler.WeChatPollQRCodeStatus)
+ }
}
// serveFrontendStatic registers a middleware that serves the frontend SPA