feat(im): add WeChat integration with QR code login and long-polling support

- Extended the IMChannel interface to include WeChat as a platform, supporting long-polling mode and full output mode.
- Implemented WeChat QR code login functionality, allowing users to authenticate via a QR code.
- Added new API endpoints for generating and polling the status of WeChat QR codes.
- Updated the frontend to handle WeChat-specific UI elements and interactions, including dynamic form adjustments based on the selected platform.
- Enhanced localization files to include WeChat-related translations.
- Introduced a WeChat adapter for message handling and file downloading via the iLink Bot API.
This commit is contained in:
wizardchen
2026-03-23 18:27:21 +08:00
committed by lyingbug
parent 45ad36845e
commit d1c7da97f9
16 changed files with 1424 additions and 65 deletions
+26 -2
View File
@@ -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 });
}
+263 -10
View File
@@ -89,13 +89,14 @@
<!-- Platform -->
<div class="form-item">
<label class="form-label">{{ $t('agentEditor.im.platform') }}</label>
<t-radio-group v-model="formData.platform" :disabled="!!editingChannel">
<t-radio-group v-model="formData.platform" :disabled="!!editingChannel" @change="onPlatformChange">
<t-radio-button value="wecom">{{ $t('agentEditor.im.wecom') }}</t-radio-button>
<t-radio-button value="feishu">{{ $t('agentEditor.im.feishu') }}</t-radio-button>
<t-radio-button value="slack">{{ $t('agentEditor.im.slack') }}</t-radio-button>
<t-radio-button value="telegram">{{ $t('agentEditor.im.telegram') }}</t-radio-button>
<t-radio-button value="dingtalk">{{ $t('agentEditor.im.dingtalk') }}</t-radio-button>
<t-radio-button value="mattermost">{{ $t('agentEditor.im.mattermost') }}</t-radio-button>
<t-radio-button value="wechat">{{ $t('agentEditor.im.wechat') }}</t-radio-button>
</t-radio-group>
</div>
@@ -105,8 +106,8 @@
<t-input v-model="formData.name" :placeholder="$t('agentEditor.im.channelNamePlaceholder')" />
</div>
<!-- Mode -->
<div class="form-item">
<!-- Mode (hidden for WeChat) -->
<div v-if="formData.platform !== 'wechat'" class="form-item">
<label class="form-label">{{ $t('agentEditor.im.mode') }}</label>
<t-radio-group v-model="formData.mode">
<t-radio-button value="websocket" :disabled="formData.platform === 'mattermost'">WebSocket</t-radio-button>
@@ -116,8 +117,8 @@
<p v-else class="form-hint">{{ $t('agentEditor.im.modeHint') }}</p>
</div>
<!-- Output mode -->
<div class="form-item">
<!-- Output mode (hidden for WeChat) -->
<div v-if="formData.platform !== 'wechat'" class="form-item">
<label class="form-label">{{ $t('agentEditor.im.outputMode') }}</label>
<t-radio-group v-model="formData.output_mode">
<t-radio-button value="stream">{{ $t('agentEditor.im.outputStream') }}</t-radio-button>
@@ -339,16 +340,62 @@
<p class="form-hint">{{ $t('agentEditor.im.mattermostPostToMainHint') }}</p>
</div>
</template>
<!-- WeChat credentials (QR code binding) -->
<template v-if="formData.platform === 'wechat'">
<p class="form-hint">{{ $t('agentEditor.im.wechatHint') }}</p>
<!-- Already bound state -->
<div v-if="wechatBound" class="wechat-bound-status">
<t-icon name="check-circle-filled" class="bound-icon" />
<span>{{ $t('agentEditor.im.wechatBindSuccess') }}</span>
<t-button size="small" variant="outline" theme="default" @click="startWeChatBinding">
{{ $t('agentEditor.im.wechatRebind') }}
</t-button>
</div>
<!-- QR code binding flow -->
<div v-else class="wechat-qr-section">
<!-- Initial state: show bind button -->
<div v-if="!wechatQRImgUrl" class="wechat-bind-action">
<t-button theme="default" variant="outline" :loading="wechatLoading" @click="startWeChatBinding">
<template #icon><t-icon name="scan" /></template>
{{ $t('agentEditor.im.wechatScanBind') }}
</t-button>
</div>
<!-- QR code displayed -->
<div v-else class="wechat-qr-display">
<div class="qr-container">
<img :src="wechatQRImgUrl" alt="WeChat QR Code" class="qr-image" />
<div v-if="wechatQRStatus === 'expired'" class="qr-expired-overlay" @click="startWeChatBinding">
<t-icon name="refresh" class="refresh-icon" />
<span>{{ $t('agentEditor.im.wechatQRExpired') }}</span>
</div>
</div>
<p class="qr-hint">
<template v-if="wechatQRStatus === 'scaned'">
{{ $t('agentEditor.im.wechatBinding') }}
</template>
<template v-else>
{{ $t('agentEditor.im.wechatScanning') }}
</template>
</p>
</div>
</div>
</template>
</div>
</t-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue';
import { ref, onMounted, watch, onUnmounted, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { MessagePlugin } from 'tdesign-vue-next';
import { listIMChannels, createIMChannel, updateIMChannel, deleteIMChannel, toggleIMChannel } from '@/api/agent';
import {
listIMChannels, createIMChannel, updateIMChannel, deleteIMChannel, toggleIMChannel,
getWeChatQRCode, pollWeChatQRCodeStatus,
} from '@/api/agent';
import { listKnowledgeBases } from '@/api/knowledge-base';
import type { IMChannel } from '@/api/agent';
@@ -366,12 +413,21 @@ const editingChannel = ref<IMChannel | null>(null);
// Knowledge base options for file-to-KB feature
const knowledgeBases = ref<{ id: string; name: string }[]>([]);
// WeChat QR code binding state
const wechatQRContent = ref(''); // raw text to encode as QR code
const wechatQRImgUrl = ref(''); // generated QR image URL
const wechatQRCode = ref(''); // opaque token for polling status
const wechatQRStatus = ref<string>('');
const wechatLoading = ref(false);
let wechatPollActive = false;
let wechatPollTimer: ReturnType<typeof setTimeout> | null = null;
const defaultCredentials = (): Record<string, any> => ({});
const formData = ref({
platform: 'wecom' as 'wecom' | 'feishu' | 'slack' | 'telegram' | 'dingtalk' | 'mattermost',
platform: 'wecom' as 'wecom' | 'feishu' | 'slack' | 'telegram' | 'dingtalk' | 'mattermost' | 'wechat',
name: '',
mode: 'websocket' as 'webhook' | 'websocket',
mode: 'websocket' as 'webhook' | 'websocket' | 'longpoll',
output_mode: 'stream' as 'stream' | 'full',
session_mode: 'user' as 'user' | 'thread',
knowledge_base_id: '',
@@ -401,6 +457,101 @@ watch(
}
},
);
// Whether WeChat credentials are already bound
const wechatBound = computed(() => {
return formData.value.platform === 'wechat' &&
formData.value.credentials.bot_token &&
formData.value.credentials.ilink_bot_id;
});
function onPlatformChange(val: string | number | boolean) {
formData.value.credentials = defaultCredentials();
stopWeChatPolling();
wechatQRContent.value = '';
wechatQRImgUrl.value = '';
wechatQRCode.value = '';
wechatQRStatus.value = '';
// WeChat uses fixed mode/output
if (val === 'wechat') {
formData.value.mode = 'longpoll';
formData.value.output_mode = 'full';
} else {
formData.value.mode = 'websocket';
formData.value.output_mode = 'stream';
}
}
async function startWeChatBinding() {
stopWeChatPolling();
wechatLoading.value = true;
wechatQRContent.value = '';
wechatQRImgUrl.value = '';
wechatQRStatus.value = '';
try {
const res = await getWeChatQRCode();
// qrcode_url is the text content to encode as QR code (e.g. a weixin:// URL)
wechatQRContent.value = res.data.qrcode_url;
wechatQRCode.value = res.data.qrcode;
wechatQRStatus.value = 'wait';
// Generate QR code image via public API (no extra npm dependency needed)
wechatQRImgUrl.value = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(res.data.qrcode_url)}`;
// Start long-polling for scan status
startStatusPolling();
} catch (e: any) {
MessagePlugin.error(e?.message || 'Failed to generate QR code');
} finally {
wechatLoading.value = false;
}
}
function startStatusPolling() {
wechatPollActive = true;
pollOnce();
}
async function pollOnce() {
if (!wechatPollActive) return;
try {
const statusRes = await pollWeChatQRCodeStatus(wechatQRCode.value);
if (!wechatPollActive) return;
wechatQRStatus.value = statusRes.data.status;
if (statusRes.data.status === 'confirmed' && statusRes.data.credentials) {
formData.value.credentials = {
bot_token: statusRes.data.credentials.bot_token,
ilink_bot_id: statusRes.data.credentials.ilink_bot_id,
ilink_user_id: statusRes.data.credentials.ilink_user_id,
};
stopWeChatPolling();
wechatQRContent.value = '';
wechatQRImgUrl.value = '';
MessagePlugin.success(t('agentEditor.im.wechatBindSuccess'));
return;
}
if (statusRes.data.status === 'expired') {
stopWeChatPolling();
return;
}
} catch {
// transient error
}
// Schedule next poll with a short delay (the backend already long-polled ~35s)
if (wechatPollActive) {
wechatPollTimer = setTimeout(pollOnce, 500);
}
}
function stopWeChatPolling() {
wechatPollActive = false;
if (wechatPollTimer) {
clearTimeout(wechatPollTimer);
wechatPollTimer = null;
}
}
async function loadChannels() {
loading.value = true;
@@ -461,6 +612,11 @@ function editChannel(channel: IMChannel) {
function resetForm() {
editingChannel.value = null;
stopWeChatPolling();
wechatQRContent.value = '';
wechatQRImgUrl.value = '';
wechatQRCode.value = '';
wechatQRStatus.value = '';
formData.value = {
platform: 'wecom',
name: '',
@@ -474,6 +630,12 @@ function resetForm() {
async function handleSave() {
try {
// For WeChat, validate that credentials are bound
if (formData.value.platform === 'wechat' && !formData.value.credentials.bot_token) {
MessagePlugin.warning(t('agentEditor.im.wechatScanBind'));
return;
}
if (editingChannel.value) {
await updateIMChannel(editingChannel.value.id, {
name: formData.value.name,
@@ -527,6 +689,10 @@ async function handleDelete(id: string) {
onMounted(() => {
loadChannels();
});
onUnmounted(() => {
stopWeChatPolling();
});
</script>
<style scoped lang="less">
@@ -670,6 +836,11 @@ onMounted(() => {
background: rgba(25, 42, 77, 0.08);
color: #192a4d;
}
&.wechat {
background: rgba(7, 193, 96, 0.08);
color: #07c160;
}
}
.channel-name {
@@ -815,4 +986,86 @@ onMounted(() => {
color: var(--td-text-color-placeholder);
}
}
</style>
// --- 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;
}
</style>
+10 -1
View File
@@ -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',
+9 -1
View File
@@ -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: "思考",
+27
View File
@@ -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 {
+23 -16
View File
@@ -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("{}")
+72
View File
@@ -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})
}
+1
View File
@@ -17,6 +17,7 @@ const (
PlatformTelegram Platform = "telegram"
PlatformDingtalk Platform = "dingtalk"
PlatformMattermost Platform = "mattermost"
PlatformWeChat Platform = "wechat"
)
// SessionMode determines how IM sessions are resolved.
+64 -35
View File
@@ -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 {
+4
View File
@@ -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 ""
}
+322
View File
@@ -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)))
}
+72
View File
@@ -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
}
+363
View File
@@ -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
}
+159
View File
@@ -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
}
+2
View File
@@ -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.
+7
View File
@@ -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