mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-09-01 14:53:07 +08:00
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:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: "思考",
|
||||
|
||||
@@ -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
@@ -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("{}")
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user