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 @@ - -
+ +
WebSocket @@ -116,8 +117,8 @@

{{ $t('agentEditor.im.modeHint') }}

- -
+ +
{{ $t('agentEditor.im.outputStream') }} @@ -339,16 +340,62 @@

{{ $t('agentEditor.im.mattermostPostToMainHint') }}

+ +
\ 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