mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-04-08 15:00:31 +08:00
feat: support weixin channel
This commit is contained in:
39
README.md
39
README.md
@@ -7,7 +7,7 @@
|
||||
[中文] | [<a href="docs/en/README.md">English</a>] | [<a href="docs/ja/README.md">日本語</a>]
|
||||
</p>
|
||||
|
||||
**CowAgent** 是基于大模型的超级AI助理,能够主动思考和任务规划、操作计算机和外部资源、创造和执行Skills、拥有长期记忆并不断成长。CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、文件等多模态消息,可接入网页、飞书、钉钉、企微智能机器人、QQ、企微自建应用、微信公众号中使用,7*24小时运行于你的个人电脑或服务器中。
|
||||
**CowAgent** 是基于大模型的超级AI助理,能够主动思考和任务规划、操作计算机和外部资源、创造和执行Skills、拥有长期记忆并不断成长。CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、文件等多模态消息,可接入微信、飞书、钉钉、企微智能机器人、QQ、企微自建应用、微信公众号、网页中使用,7*24小时运行于你的个人电脑或服务器中。
|
||||
|
||||
<p align="center">
|
||||
<a href="https://cowagent.ai/">🌐 官网</a> ·
|
||||
@@ -27,7 +27,7 @@
|
||||
- ✅ **技能系统:** 实现了Skills创建和运行的引擎,内置多种技能,并支持通过自然语言对话完成自定义Skills开发
|
||||
- ✅ **多模态消息:** 支持对文本、图片、语音、文件等多类型消息进行解析、处理、生成、发送等操作
|
||||
- ✅ **多模型接入:** 支持OpenAI, Claude, Gemini, DeepSeek, MiniMax、GLM、Qwen、Kimi、Doubao等国内外主流模型厂商
|
||||
- ✅ **多端部署:** 支持运行在本地计算机或服务器,可集成到飞书、钉钉、企业微信、QQ、微信公众号、网页中使用
|
||||
- ✅ **多端部署:** 支持运行在本地计算机或服务器,可集成到微信、飞书、钉钉、企业微信、QQ、微信公众号、网页中使用
|
||||
|
||||
## 声明
|
||||
|
||||
@@ -147,7 +147,7 @@ pip3 install -r requirements-optional.txt
|
||||
```bash
|
||||
# config.json 文件内容示例
|
||||
{
|
||||
"channel_type": "web", # 接入渠道类型,默认为web,支持修改为:feishu,dingtalk,wecom_bot,qq,wechatcom_app,wechatmp_service,wechatmp,terminal
|
||||
"channel_type": "weixin", # 接入渠道类型,默认为weixin, 支持修改为 feishu,dingtalk,wecom_bot,qq,wechatcom_app,wechatmp_service,wechatmp,terminal
|
||||
"model": "MiniMax-M2.7", # 模型名称
|
||||
"minimax_api_key": "", # MiniMax API Key
|
||||
"zhipu_ai_api_key": "", # 智谱GLM API Key
|
||||
@@ -628,7 +628,24 @@ Coding Plan 是各厂商推出的编程包月套餐,所有厂商均可通过 O
|
||||
支持同时可接入多个通道,配置时可通过逗号进行分割,例如 `"channel_type": "feishu,dingtalk"`。
|
||||
|
||||
<details>
|
||||
<summary>1. Web</summary>
|
||||
<summary>1. Weixin - 微信</summary>
|
||||
|
||||
接入个人微信,扫码登录即可使用,无需公网 IP,支持文本、图片、语音、文件等消息收发。
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_type": "weixin"
|
||||
}
|
||||
```
|
||||
|
||||
启动后终端会显示二维码,使用微信扫码授权即可,也可以在 Web 控制台的「通道」页面中扫码接入。登录凭证会自动保存至 `~/.weixin_cow_credentials.json`,下次启动无需重新扫码,如需重新登录删除该文件后重启即可。
|
||||
|
||||
详细步骤和参数说明参考 [微信接入](https://docs.cowagent.ai/channels/weixin)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>2. Web</summary>
|
||||
|
||||
项目启动后会默认运行Web控制台,配置如下:
|
||||
|
||||
@@ -645,7 +662,7 @@ Coding Plan 是各厂商推出的编程包月套餐,所有厂商均可通过 O
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>2. Feishu - 飞书</summary>
|
||||
<summary>3. Feishu - 飞书</summary>
|
||||
|
||||
飞书支持两种事件接收模式:WebSocket 长连接(推荐)和 Webhook。
|
||||
|
||||
@@ -681,7 +698,7 @@ Coding Plan 是各厂商推出的编程包月套餐,所有厂商均可通过 O
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>3. DingTalk - 钉钉</summary>
|
||||
<summary>4. DingTalk - 钉钉</summary>
|
||||
|
||||
钉钉需要在开放平台创建智能机器人应用,将以下配置填入 `config.json`:
|
||||
|
||||
@@ -696,7 +713,7 @@ Coding Plan 是各厂商推出的编程包月套餐,所有厂商均可通过 O
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>4. WeCom Bot - 企微智能机器人</summary>
|
||||
<summary>5. WeCom Bot - 企微智能机器人</summary>
|
||||
|
||||
企微智能机器人使用 WebSocket 长连接模式,无需公网 IP 和域名,配置简单:
|
||||
|
||||
@@ -712,7 +729,7 @@ Coding Plan 是各厂商推出的编程包月套餐,所有厂商均可通过 O
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>5. QQ - QQ 机器人</summary>
|
||||
<summary>6. QQ - QQ 机器人</summary>
|
||||
|
||||
QQ 机器人使用 WebSocket 长连接模式,无需公网 IP 和域名,支持 QQ 单聊、群聊和频道消息:
|
||||
|
||||
@@ -728,7 +745,7 @@ QQ 机器人使用 WebSocket 长连接模式,无需公网 IP 和域名,支
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>6. WeCom App - 企业微信应用</summary>
|
||||
<summary>7. WeCom App - 企业微信应用</summary>
|
||||
|
||||
企业微信自建应用接入需在后台创建应用并启用消息回调,配置示例:
|
||||
|
||||
@@ -748,7 +765,7 @@ QQ 机器人使用 WebSocket 长连接模式,无需公网 IP 和域名,支
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>7. WeChat MP - 微信公众号</summary>
|
||||
<summary>8. WeChat MP - 微信公众号</summary>
|
||||
|
||||
本项目支持订阅号和服务号两种公众号,通过服务号(`wechatmp_service`)体验更佳。
|
||||
|
||||
@@ -783,7 +800,7 @@ QQ 机器人使用 WebSocket 长连接模式,无需公网 IP 和域名,支
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>8. Terminal - 终端</summary>
|
||||
<summary>9. Terminal - 终端</summary>
|
||||
|
||||
修改 `config.json` 中的 `channel_type` 字段:
|
||||
|
||||
|
||||
2
app.py
2
app.py
@@ -229,6 +229,8 @@ def _clear_singleton_cache(channel_name: str):
|
||||
const.DINGTALK: "channel.dingtalk.dingtalk_channel.DingTalkChanel",
|
||||
const.WECOM_BOT: "channel.wecom_bot.wecom_bot_channel.WecomBotChannel",
|
||||
const.QQ: "channel.qq.qq_channel.QQChannel",
|
||||
const.WEIXIN: "channel.weixin.weixin_channel.WeixinChannel",
|
||||
"wx": "channel.weixin.weixin_channel.WeixinChannel",
|
||||
}
|
||||
module_path = cls_map.get(channel_name)
|
||||
if not module_path:
|
||||
|
||||
@@ -39,6 +39,10 @@ def create_channel(channel_type) -> Channel:
|
||||
elif channel_type == const.QQ:
|
||||
from channel.qq.qq_channel import QQChannel
|
||||
ch = QQChannel()
|
||||
elif channel_type in (const.WEIXIN, "wx"):
|
||||
from channel.weixin.weixin_channel import WeixinChannel
|
||||
ch = WeixinChannel()
|
||||
channel_type = const.WEIXIN
|
||||
else:
|
||||
raise RuntimeError
|
||||
ch.channel_type = channel_type
|
||||
|
||||
@@ -51,6 +51,11 @@ const I18N = {
|
||||
channels_empty: '暂未接入任何通道', channels_empty_desc: '点击右上角「接入通道」按钮开始配置',
|
||||
channels_disconnect_confirm: '确认断开该通道?配置将保留但通道会停止运行。',
|
||||
channels_connected: '已接入', channels_connecting: '接入中...',
|
||||
weixin_scan_title: '微信扫码登录', weixin_scan_desc: '请使用微信扫描下方二维码',
|
||||
weixin_scan_loading: '正在获取二维码...', weixin_scan_waiting: '等待扫码...',
|
||||
weixin_scan_scanned: '已扫码,请在手机上确认', weixin_scan_expired: '二维码已过期,正在刷新...',
|
||||
weixin_scan_success: '登录成功,正在启动通道...', weixin_scan_fail: '获取二维码失败',
|
||||
weixin_qr_tip: '二维码约2分钟后过期',
|
||||
tasks_title: '定时任务', tasks_desc: '查看和管理定时任务',
|
||||
tasks_coming: '即将推出', tasks_coming_desc: '定时任务管理功能即将在此提供',
|
||||
logs_title: '日志', logs_desc: '实时日志输出 (run.log)',
|
||||
@@ -97,6 +102,11 @@ const I18N = {
|
||||
channels_empty: 'No channels connected', channels_empty_desc: 'Click the "Connect" button above to get started',
|
||||
channels_disconnect_confirm: 'Disconnect this channel? Config will be preserved but the channel will stop.',
|
||||
channels_connected: 'Connected', channels_connecting: 'Connecting...',
|
||||
weixin_scan_title: 'WeChat QR Login', weixin_scan_desc: 'Scan the QR code below with WeChat',
|
||||
weixin_scan_loading: 'Loading QR code...', weixin_scan_waiting: 'Waiting for scan...',
|
||||
weixin_scan_scanned: 'Scanned, please confirm on your phone', weixin_scan_expired: 'QR code expired, refreshing...',
|
||||
weixin_scan_success: 'Login successful, starting channel...', weixin_scan_fail: 'Failed to load QR code',
|
||||
weixin_qr_tip: 'QR code expires in ~2 minutes',
|
||||
tasks_title: 'Scheduled Tasks', tasks_desc: 'View and manage scheduled tasks',
|
||||
tasks_coming: 'Coming Soon', tasks_coming_desc: 'Scheduled task management will be available here',
|
||||
logs_title: 'Logs', logs_desc: 'Real-time log output (run.log)',
|
||||
@@ -1583,6 +1593,8 @@ function loadChannelsView() {
|
||||
}
|
||||
|
||||
function renderActiveChannels() {
|
||||
stopWeixinQrPoll();
|
||||
stopWeixinStatusPoll();
|
||||
const container = document.getElementById('channels-content');
|
||||
container.innerHTML = '';
|
||||
closeAddChannelPanel();
|
||||
@@ -1608,17 +1620,30 @@ function renderActiveChannels() {
|
||||
card.id = `channel-card-${ch.name}`;
|
||||
|
||||
const fieldsHtml = buildChannelFieldsHtml(ch.name, ch.fields || []);
|
||||
const hasFields = (ch.fields || []).length > 0;
|
||||
|
||||
const weixinWaiting = ch.name === 'weixin' && ch.login_status && ch.login_status !== 'logged_in';
|
||||
let statusDot, statusText;
|
||||
if (weixinWaiting) {
|
||||
statusDot = 'bg-amber-400 animate-pulse';
|
||||
statusText = ch.login_status === 'scanned'
|
||||
? `<span class="text-xs text-primary-500">${t('weixin_scan_scanned')}</span>`
|
||||
: `<span class="text-xs text-amber-500">${t('weixin_scan_waiting')}</span>`;
|
||||
} else {
|
||||
statusDot = 'bg-primary-400';
|
||||
statusText = `<span class="text-xs text-primary-500">${t('channels_connected')}</span>`;
|
||||
}
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="flex items-center gap-4 mb-5">
|
||||
<div class="flex items-center gap-4${hasFields || weixinWaiting ? ' mb-5' : ''}">
|
||||
<div class="w-10 h-10 rounded-xl bg-${ch.color}-50 dark:bg-${ch.color}-900/20 flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas ${ch.icon} text-${ch.color}-500 text-base"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold text-slate-800 dark:text-slate-100">${escapeHtml(label)}</span>
|
||||
<span class="w-2 h-2 rounded-full bg-primary-400"></span>
|
||||
<span class="text-xs text-primary-500">${t('channels_connected')}</span>
|
||||
<span class="w-2 h-2 rounded-full ${statusDot}"></span>
|
||||
${statusText}
|
||||
</div>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5 font-mono">${escapeHtml(ch.name)}</p>
|
||||
</div>
|
||||
@@ -1630,7 +1655,14 @@ function renderActiveChannels() {
|
||||
${t('channels_disconnect')}
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
${weixinWaiting ? `<div id="weixin-active-qr" class="flex flex-col items-center py-2">
|
||||
<button onclick="showWeixinActiveQr()"
|
||||
class="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium
|
||||
cursor-pointer transition-colors duration-150">
|
||||
${t('weixin_scan_title')}
|
||||
</button>
|
||||
</div>` : ''}
|
||||
${hasFields ? `<div class="space-y-4">
|
||||
${fieldsHtml}
|
||||
<div class="flex items-center justify-end gap-3 pt-1">
|
||||
<span id="ch-status-${ch.name}" class="text-xs text-primary-500 opacity-0 transition-opacity duration-300"></span>
|
||||
@@ -1639,10 +1671,14 @@ function renderActiveChannels() {
|
||||
cursor-pointer transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
id="ch-save-${ch.name}">${t('channels_save')}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
</div>` : ''}`;
|
||||
|
||||
container.appendChild(card);
|
||||
bindSecretFieldEvents(card);
|
||||
|
||||
if (weixinWaiting) {
|
||||
startWeixinActiveStatusPoll();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1828,6 +1864,7 @@ function openAddChannelPanel() {
|
||||
}
|
||||
|
||||
function closeAddChannelPanel() {
|
||||
stopWeixinQrPoll();
|
||||
const panel = document.getElementById('channels-add-panel');
|
||||
if (panel) {
|
||||
panel.classList.add('hidden');
|
||||
@@ -1836,6 +1873,7 @@ function closeAddChannelPanel() {
|
||||
}
|
||||
|
||||
function onAddChannelSelect(chName) {
|
||||
stopWeixinQrPoll();
|
||||
const fieldsContainer = document.getElementById('add-channel-fields');
|
||||
const actions = document.getElementById('add-channel-actions');
|
||||
|
||||
@@ -1845,6 +1883,16 @@ function onAddChannelSelect(chName) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (chName === 'weixin') {
|
||||
actions.classList.add('hidden');
|
||||
fieldsContainer.innerHTML = `
|
||||
<div id="weixin-qr-panel" class="flex flex-col items-center py-4">
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mb-4">${t('weixin_scan_loading')}</p>
|
||||
</div>`;
|
||||
startWeixinQrLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
const ch = channelsData.find(c => c.name === chName);
|
||||
if (!ch) return;
|
||||
|
||||
@@ -1900,6 +1948,172 @@ function submitAddChannel() {
|
||||
});
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// WeChat QR Login
|
||||
// =====================================================================
|
||||
let _weixinQrPollTimer = null;
|
||||
let _weixinStatusPollTimer = null;
|
||||
|
||||
function stopWeixinStatusPoll() {
|
||||
if (_weixinStatusPollTimer) {
|
||||
clearTimeout(_weixinStatusPollTimer);
|
||||
_weixinStatusPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startWeixinActiveStatusPoll() {
|
||||
stopWeixinStatusPoll();
|
||||
_weixinStatusPollTimer = setTimeout(() => {
|
||||
fetch('/api/channels').then(r => r.json()).then(data => {
|
||||
if (data.status !== 'success') return;
|
||||
const wx = (data.channels || []).find(c => c.name === 'weixin');
|
||||
if (!wx || !wx.active) return;
|
||||
if (wx.login_status === 'logged_in') {
|
||||
channelsData = data.channels;
|
||||
renderActiveChannels();
|
||||
} else {
|
||||
const ch = channelsData.find(c => c.name === 'weixin');
|
||||
if (ch) ch.login_status = wx.login_status;
|
||||
startWeixinActiveStatusPoll();
|
||||
}
|
||||
}).catch(() => { startWeixinActiveStatusPoll(); });
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function showWeixinActiveQr() {
|
||||
const container = document.getElementById('weixin-active-qr');
|
||||
if (!container) return;
|
||||
container.innerHTML = `
|
||||
<div id="weixin-qr-panel" class="flex flex-col items-center py-2">
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mb-4">${t('weixin_scan_loading')}</p>
|
||||
</div>`;
|
||||
stopWeixinStatusPoll();
|
||||
startWeixinQrLogin();
|
||||
}
|
||||
|
||||
function stopWeixinQrPoll() {
|
||||
if (_weixinQrPollTimer) {
|
||||
clearTimeout(_weixinQrPollTimer);
|
||||
_weixinQrPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startWeixinQrLogin() {
|
||||
stopWeixinQrPoll();
|
||||
fetch('/api/weixin/qrlogin')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const panel = document.getElementById('weixin-qr-panel');
|
||||
if (!panel) return;
|
||||
if (data.status !== 'success') {
|
||||
panel.innerHTML = `<p class="text-sm text-red-500">${t('weixin_scan_fail')}: ${data.message || ''}</p>`;
|
||||
return;
|
||||
}
|
||||
renderWeixinQr(data.qr_image || data.qrcode_url, 'waiting');
|
||||
if (data.source === 'channel') {
|
||||
startWeixinActiveStatusPoll();
|
||||
} else {
|
||||
pollWeixinQrStatus();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
const panel = document.getElementById('weixin-qr-panel');
|
||||
if (panel) panel.innerHTML = `<p class="text-sm text-red-500">${t('weixin_scan_fail')}</p>`;
|
||||
});
|
||||
}
|
||||
|
||||
function renderWeixinQr(qrcodeUrl, status) {
|
||||
const panel = document.getElementById('weixin-qr-panel');
|
||||
if (!panel) return;
|
||||
|
||||
let statusText = t('weixin_scan_waiting');
|
||||
let statusColor = 'text-slate-500 dark:text-slate-400';
|
||||
if (status === 'scanned') {
|
||||
statusText = t('weixin_scan_scanned');
|
||||
statusColor = 'text-primary-500';
|
||||
} else if (status === 'expired') {
|
||||
statusText = t('weixin_scan_expired');
|
||||
statusColor = 'text-amber-500';
|
||||
} else if (status === 'confirmed') {
|
||||
statusText = t('weixin_scan_success');
|
||||
statusColor = 'text-primary-500';
|
||||
}
|
||||
|
||||
panel.innerHTML = `
|
||||
<div class="flex flex-col items-center">
|
||||
<p class="text-sm font-medium text-slate-700 dark:text-slate-200 mb-1">${t('weixin_scan_title')}</p>
|
||||
<p class="text-xs text-slate-400 dark:text-slate-500 mb-4">${t('weixin_scan_desc')}</p>
|
||||
<div class="bg-white p-3 rounded-xl shadow-sm border border-slate-100 dark:border-slate-700 mb-3">
|
||||
<img src="${escapeHtml(qrcodeUrl)}" alt="QR Code" class="w-52 h-52" style="image-rendering: pixelated;"/>
|
||||
</div>
|
||||
<p class="text-xs ${statusColor} mb-1">${statusText}</p>
|
||||
<p class="text-xs text-slate-400 dark:text-slate-500">${t('weixin_qr_tip')}</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function pollWeixinQrStatus() {
|
||||
_weixinQrPollTimer = setTimeout(() => {
|
||||
fetch('/api/weixin/qrlogin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'poll' })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const panel = document.getElementById('weixin-qr-panel');
|
||||
if (!panel) { stopWeixinQrPoll(); return; }
|
||||
|
||||
if (data.status !== 'success') {
|
||||
pollWeixinQrStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
const qrStatus = data.qr_status;
|
||||
if (qrStatus === 'confirmed') {
|
||||
renderWeixinQr('', 'confirmed');
|
||||
panel.innerHTML = `
|
||||
<div class="flex flex-col items-center py-4">
|
||||
<div class="w-12 h-12 rounded-full bg-primary-50 dark:bg-primary-900/30 flex items-center justify-center mb-3">
|
||||
<i class="fas fa-check text-primary-500 text-lg"></i>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-primary-600 dark:text-primary-400">${t('weixin_scan_success')}</p>
|
||||
</div>`;
|
||||
connectWeixinAfterQr();
|
||||
} else if (qrStatus === 'expired' && (data.qr_image || data.qrcode_url)) {
|
||||
renderWeixinQr(data.qr_image || data.qrcode_url, 'waiting');
|
||||
pollWeixinQrStatus();
|
||||
} else if (qrStatus === 'scaned') {
|
||||
const img = panel.querySelector('img');
|
||||
const currentSrc = img ? img.src : '';
|
||||
renderWeixinQr(currentSrc, 'scanned');
|
||||
pollWeixinQrStatus();
|
||||
} else {
|
||||
pollWeixinQrStatus();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
pollWeixinQrStatus();
|
||||
});
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function connectWeixinAfterQr() {
|
||||
fetch('/api/channels', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'connect', channel: 'weixin', config: {} })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
const ch = channelsData.find(c => c.name === 'weixin');
|
||||
if (ch) ch.active = true;
|
||||
setTimeout(() => renderActiveChannels(), 1500);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Scheduler View
|
||||
// =====================================================================
|
||||
|
||||
@@ -353,13 +353,15 @@ class WebChannel(ChatChannel):
|
||||
# 打印可用渠道类型提示
|
||||
logger.info(
|
||||
"[WebChannel] 全部可用通道如下,可修改 config.json 配置文件中的 channel_type 字段进行切换,多个通道用逗号分隔:")
|
||||
logger.info("[WebChannel] 1. web - 网页")
|
||||
logger.info("[WebChannel] 2. terminal - 终端")
|
||||
logger.info("[WebChannel] 3. feishu - 飞书")
|
||||
logger.info("[WebChannel] 4. dingtalk - 钉钉")
|
||||
logger.info("[WebChannel] 5. wechatcom_app - 企微自建应用")
|
||||
logger.info("[WebChannel] 6. wechatmp - 个人公众号")
|
||||
logger.info("[WebChannel] 7. wechatmp_service - 企业公众号")
|
||||
logger.info("[WebChannel] 1. weixin - 微信")
|
||||
logger.info("[WebChannel] 2. web - 网页")
|
||||
logger.info("[WebChannel] 3. terminal - 终端")
|
||||
logger.info("[WebChannel] 4. feishu - 飞书")
|
||||
logger.info("[WebChannel] 5. dingtalk - 钉钉")
|
||||
logger.info("[WebChannel] 6. wecom_bot - 企微智能机器人")
|
||||
logger.info("[WebChannel] 7. wechatcom_app - 企微自建应用")
|
||||
logger.info("[WebChannel] 8. wechatmp - 个人公众号")
|
||||
logger.info("[WebChannel] 9. wechatmp_service - 企业公众号")
|
||||
logger.info("[WebChannel] ✅ Web控制台已运行")
|
||||
logger.info(f"[WebChannel] 🌐 本地访问: http://localhost:{port}")
|
||||
logger.info(f"[WebChannel] 🌍 服务器访问: http://YOUR_IP:{port} (请将YOUR_IP替换为服务器IP)")
|
||||
@@ -380,6 +382,7 @@ class WebChannel(ChatChannel):
|
||||
'/chat', 'ChatHandler',
|
||||
'/config', 'ConfigHandler',
|
||||
'/api/channels', 'ChannelsHandler',
|
||||
'/api/weixin/qrlogin', 'WeixinQrHandler',
|
||||
'/api/tools', 'ToolsHandler',
|
||||
'/api/skills', 'SkillsHandler',
|
||||
'/api/memory', 'MemoryHandler',
|
||||
@@ -685,6 +688,12 @@ class ChannelsHandler:
|
||||
"""API for managing external channel configurations (feishu, dingtalk, etc)."""
|
||||
|
||||
CHANNEL_DEFS = OrderedDict([
|
||||
("weixin", {
|
||||
"label": {"zh": "微信", "en": "WeChat"},
|
||||
"icon": "fa-comment",
|
||||
"color": "emerald",
|
||||
"fields": [],
|
||||
}),
|
||||
("feishu", {
|
||||
"label": {"zh": "飞书", "en": "Feishu"},
|
||||
"icon": "fa-paper-plane",
|
||||
@@ -750,6 +759,20 @@ class ChannelsHandler:
|
||||
}),
|
||||
])
|
||||
|
||||
@staticmethod
|
||||
def _get_weixin_login_status() -> str:
|
||||
try:
|
||||
import sys
|
||||
app_module = sys.modules.get('__main__') or sys.modules.get('app')
|
||||
mgr = getattr(app_module, '_channel_mgr', None) if app_module else None
|
||||
if mgr:
|
||||
ch = mgr.get_channel("weixin")
|
||||
if ch and hasattr(ch, 'login_status'):
|
||||
return ch.login_status
|
||||
except Exception:
|
||||
pass
|
||||
return "unknown"
|
||||
|
||||
@staticmethod
|
||||
def _mask_secret(value: str) -> str:
|
||||
if not value or len(value) <= 8:
|
||||
@@ -789,14 +812,17 @@ class ChannelsHandler:
|
||||
"value": display_val,
|
||||
"default": f.get("default", ""),
|
||||
})
|
||||
channels.append({
|
||||
ch_info = {
|
||||
"name": ch_name,
|
||||
"label": ch_def["label"],
|
||||
"icon": ch_def["icon"],
|
||||
"color": ch_def["color"],
|
||||
"active": ch_name in active_channels,
|
||||
"fields": fields_out,
|
||||
})
|
||||
}
|
||||
if ch_name == "weixin" and ch_name in active_channels:
|
||||
ch_info["login_status"] = self._get_weixin_login_status()
|
||||
channels.append(ch_info)
|
||||
return json.dumps({"status": "success", "channels": channels}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.error(f"[WebChannel] Channels API error: {e}")
|
||||
@@ -1016,6 +1042,157 @@ class ChannelsHandler:
|
||||
}, ensure_ascii=False)
|
||||
|
||||
|
||||
class WeixinQrHandler:
|
||||
"""Handle WeChat QR code login from the web console.
|
||||
|
||||
GET /api/weixin/qrlogin → fetch a new QR code
|
||||
POST /api/weixin/qrlogin → poll QR status or start channel after login
|
||||
"""
|
||||
|
||||
_qr_state = {}
|
||||
|
||||
@staticmethod
|
||||
def _qr_to_data_uri(data: str) -> str:
|
||||
"""Generate a QR code as a PNG data URI."""
|
||||
try:
|
||||
import qrcode as qr_lib
|
||||
import io
|
||||
import base64
|
||||
qr = qr_lib.QRCode(error_correction=qr_lib.constants.ERROR_CORRECT_L, box_size=6, border=2)
|
||||
qr.add_data(data)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
b64 = base64.b64encode(buf.getvalue()).decode("ascii")
|
||||
return f"data:image/png;base64,{b64}"
|
||||
except ImportError:
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _get_running_channel():
|
||||
try:
|
||||
import sys
|
||||
app_module = sys.modules.get('__main__') or sys.modules.get('app')
|
||||
mgr = getattr(app_module, '_channel_mgr', None) if app_module else None
|
||||
if mgr:
|
||||
return mgr.get_channel("weixin")
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def GET(self):
|
||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||
try:
|
||||
running_ch = self._get_running_channel()
|
||||
if running_ch and hasattr(running_ch, '_current_qr_url') and running_ch._current_qr_url:
|
||||
qr_image = self._qr_to_data_uri(running_ch._current_qr_url)
|
||||
return json.dumps({
|
||||
"status": "success",
|
||||
"qrcode_url": running_ch._current_qr_url,
|
||||
"qr_image": qr_image,
|
||||
"source": "channel",
|
||||
})
|
||||
|
||||
from channel.weixin.weixin_api import WeixinApi, DEFAULT_BASE_URL
|
||||
base_url = conf().get("weixin_base_url", DEFAULT_BASE_URL)
|
||||
api = WeixinApi(base_url=base_url)
|
||||
qr_resp = api.fetch_qr_code()
|
||||
qrcode = qr_resp.get("qrcode", "")
|
||||
qrcode_url = qr_resp.get("qrcode_img_content", "")
|
||||
if not qrcode:
|
||||
return json.dumps({"status": "error", "message": "No QR code returned"})
|
||||
qr_image = self._qr_to_data_uri(qrcode_url)
|
||||
WeixinQrHandler._qr_state = {
|
||||
"qrcode": qrcode,
|
||||
"qrcode_url": qrcode_url,
|
||||
"base_url": base_url,
|
||||
}
|
||||
return json.dumps({"status": "success", "qrcode_url": qrcode_url, "qr_image": qr_image})
|
||||
except Exception as e:
|
||||
logger.error(f"[WebChannel] WeixinQr GET error: {e}")
|
||||
return json.dumps({"status": "error", "message": str(e)})
|
||||
|
||||
def POST(self):
|
||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||
try:
|
||||
body = json.loads(web.data())
|
||||
action = body.get("action", "poll")
|
||||
|
||||
if action == "poll":
|
||||
return self._poll_status()
|
||||
elif action == "refresh":
|
||||
return self.GET()
|
||||
else:
|
||||
return json.dumps({"status": "error", "message": f"unknown action: {action}"})
|
||||
except Exception as e:
|
||||
logger.error(f"[WebChannel] WeixinQr POST error: {e}")
|
||||
return json.dumps({"status": "error", "message": str(e)})
|
||||
|
||||
def _poll_status(self):
|
||||
state = WeixinQrHandler._qr_state
|
||||
qrcode = state.get("qrcode", "")
|
||||
base_url = state.get("base_url", "")
|
||||
if not qrcode:
|
||||
return json.dumps({"status": "error", "message": "No active QR session"})
|
||||
|
||||
from channel.weixin.weixin_api import WeixinApi, DEFAULT_BASE_URL
|
||||
api = WeixinApi(base_url=base_url or DEFAULT_BASE_URL)
|
||||
try:
|
||||
status_resp = api.poll_qr_status(qrcode, timeout=10)
|
||||
except Exception as e:
|
||||
return json.dumps({"status": "error", "message": str(e)})
|
||||
|
||||
qr_status = status_resp.get("status", "wait")
|
||||
|
||||
if qr_status == "confirmed":
|
||||
bot_token = status_resp.get("bot_token", "")
|
||||
bot_id = status_resp.get("ilink_bot_id", "")
|
||||
result_base_url = status_resp.get("baseurl", base_url)
|
||||
user_id = status_resp.get("ilink_user_id", "")
|
||||
|
||||
if not bot_token or not bot_id:
|
||||
return json.dumps({"status": "error", "message": "Login confirmed but missing token"})
|
||||
|
||||
cred_path = os.path.expanduser(
|
||||
conf().get("weixin_credentials_path", "~/.weixin_cow_credentials.json")
|
||||
)
|
||||
from channel.weixin.weixin_channel import _save_credentials
|
||||
_save_credentials(cred_path, {
|
||||
"token": bot_token,
|
||||
"base_url": result_base_url,
|
||||
"bot_id": bot_id,
|
||||
"user_id": user_id,
|
||||
})
|
||||
conf()["weixin_token"] = bot_token
|
||||
conf()["weixin_base_url"] = result_base_url
|
||||
|
||||
WeixinQrHandler._qr_state = {}
|
||||
logger.info(f"[WebChannel] WeChat QR login confirmed: bot_id={bot_id}")
|
||||
|
||||
return json.dumps({
|
||||
"status": "success",
|
||||
"qr_status": "confirmed",
|
||||
"bot_id": bot_id,
|
||||
})
|
||||
|
||||
if qr_status == "expired":
|
||||
new_resp = api.fetch_qr_code()
|
||||
new_qrcode = new_resp.get("qrcode", "")
|
||||
new_qrcode_url = new_resp.get("qrcode_img_content", "")
|
||||
new_qr_image = self._qr_to_data_uri(new_qrcode_url)
|
||||
WeixinQrHandler._qr_state["qrcode"] = new_qrcode
|
||||
WeixinQrHandler._qr_state["qrcode_url"] = new_qrcode_url
|
||||
return json.dumps({
|
||||
"status": "success",
|
||||
"qr_status": "expired",
|
||||
"qrcode_url": new_qrcode_url,
|
||||
"qr_image": new_qr_image,
|
||||
})
|
||||
|
||||
return json.dumps({"status": "success", "qr_status": qr_status})
|
||||
|
||||
|
||||
def _get_workspace_root():
|
||||
"""Resolve the agent workspace directory."""
|
||||
from common.utils import expand_path
|
||||
|
||||
0
channel/weixin/__init__.py
Normal file
0
channel/weixin/__init__.py
Normal file
385
channel/weixin/weixin_api.py
Normal file
385
channel/weixin/weixin_api.py
Normal file
@@ -0,0 +1,385 @@
|
||||
"""
|
||||
Weixin HTTP JSON API client.
|
||||
|
||||
Implements the ilink bot protocol:
|
||||
- getUpdates (long-poll)
|
||||
- sendMessage
|
||||
- getUploadUrl
|
||||
- getConfig
|
||||
- sendTyping
|
||||
- QR login (get_bot_qrcode / get_qrcode_status)
|
||||
|
||||
CDN media upload with AES-128-ECB encryption.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
import random
|
||||
import struct
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import requests
|
||||
|
||||
from common.log import logger
|
||||
|
||||
DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com"
|
||||
CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c"
|
||||
DEFAULT_LONG_POLL_TIMEOUT = 35
|
||||
DEFAULT_API_TIMEOUT = 15
|
||||
QR_POLL_TIMEOUT = 35
|
||||
BOT_TYPE = "3"
|
||||
|
||||
|
||||
def _random_wechat_uin() -> str:
|
||||
val = random.randint(0, 0xFFFFFFFF)
|
||||
return base64.b64encode(str(val).encode("utf-8")).decode("utf-8")
|
||||
|
||||
|
||||
def _build_headers(token: str = "") -> dict:
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"AuthorizationType": "ilink_bot_token",
|
||||
"X-WECHAT-UIN": _random_wechat_uin(),
|
||||
}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
return headers
|
||||
|
||||
|
||||
def _ensure_trailing_slash(url: str) -> str:
|
||||
return url if url.endswith("/") else url + "/"
|
||||
|
||||
|
||||
class WeixinApi:
|
||||
"""Stateless HTTP client for the Weixin ilink bot API."""
|
||||
|
||||
def __init__(self, base_url: str = DEFAULT_BASE_URL, token: str = "",
|
||||
cdn_base_url: str = CDN_BASE_URL):
|
||||
self.base_url = base_url
|
||||
self.token = token
|
||||
self.cdn_base_url = cdn_base_url
|
||||
|
||||
def _post(self, endpoint: str, body: dict, timeout: int = DEFAULT_API_TIMEOUT) -> dict:
|
||||
url = _ensure_trailing_slash(self.base_url) + endpoint
|
||||
headers = _build_headers(self.token)
|
||||
try:
|
||||
resp = requests.post(url, json=body, headers=headers, timeout=timeout)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except requests.exceptions.Timeout:
|
||||
logger.debug(f"[Weixin] API timeout: {endpoint}")
|
||||
return {"ret": 0, "msgs": []}
|
||||
except Exception as e:
|
||||
logger.error(f"[Weixin] API error {endpoint}: {e}")
|
||||
raise
|
||||
|
||||
# ── getUpdates (long-poll) ─────────────────────────────────────────
|
||||
|
||||
def get_updates(self, get_updates_buf: str = "", timeout: int = DEFAULT_LONG_POLL_TIMEOUT) -> dict:
|
||||
return self._post("ilink/bot/getupdates", {
|
||||
"get_updates_buf": get_updates_buf,
|
||||
}, timeout=timeout + 5)
|
||||
|
||||
# ── sendMessage ────────────────────────────────────────────────────
|
||||
|
||||
def send_text(self, to: str, text: str, context_token: str) -> dict:
|
||||
return self._post("ilink/bot/sendmessage", {
|
||||
"msg": {
|
||||
"from_user_id": "",
|
||||
"to_user_id": to,
|
||||
"client_id": uuid.uuid4().hex[:16],
|
||||
"message_type": 2, # BOT
|
||||
"message_state": 2, # FINISH
|
||||
"item_list": [{"type": 1, "text_item": {"text": text}}],
|
||||
"context_token": context_token,
|
||||
}
|
||||
})
|
||||
|
||||
def send_image_item(self, to: str, context_token: str,
|
||||
encrypt_query_param: str, aes_key_b64: str,
|
||||
ciphertext_size: int, text: str = "") -> dict:
|
||||
items = []
|
||||
if text:
|
||||
items.append({"type": 1, "text_item": {"text": text}})
|
||||
items.append({
|
||||
"type": 2,
|
||||
"image_item": {
|
||||
"media": {
|
||||
"encrypt_query_param": encrypt_query_param,
|
||||
"aes_key": aes_key_b64,
|
||||
"encrypt_type": 1,
|
||||
},
|
||||
"mid_size": ciphertext_size,
|
||||
}
|
||||
})
|
||||
return self._send_items(to, context_token, items)
|
||||
|
||||
def send_file_item(self, to: str, context_token: str,
|
||||
encrypt_query_param: str, aes_key_b64: str,
|
||||
file_name: str, file_size: int, text: str = "") -> dict:
|
||||
items = []
|
||||
if text:
|
||||
items.append({"type": 1, "text_item": {"text": text}})
|
||||
items.append({
|
||||
"type": 4,
|
||||
"file_item": {
|
||||
"media": {
|
||||
"encrypt_query_param": encrypt_query_param,
|
||||
"aes_key": aes_key_b64,
|
||||
"encrypt_type": 1,
|
||||
},
|
||||
"file_name": file_name,
|
||||
"len": str(file_size),
|
||||
}
|
||||
})
|
||||
return self._send_items(to, context_token, items)
|
||||
|
||||
def send_video_item(self, to: str, context_token: str,
|
||||
encrypt_query_param: str, aes_key_b64: str,
|
||||
ciphertext_size: int, text: str = "") -> dict:
|
||||
items = []
|
||||
if text:
|
||||
items.append({"type": 1, "text_item": {"text": text}})
|
||||
items.append({
|
||||
"type": 5,
|
||||
"video_item": {
|
||||
"media": {
|
||||
"encrypt_query_param": encrypt_query_param,
|
||||
"aes_key": aes_key_b64,
|
||||
"encrypt_type": 1,
|
||||
},
|
||||
"video_size": ciphertext_size,
|
||||
}
|
||||
})
|
||||
return self._send_items(to, context_token, items)
|
||||
|
||||
def _send_items(self, to: str, context_token: str, items: list) -> dict:
|
||||
return self._post("ilink/bot/sendmessage", {
|
||||
"msg": {
|
||||
"from_user_id": "",
|
||||
"to_user_id": to,
|
||||
"client_id": uuid.uuid4().hex[:16],
|
||||
"message_type": 2,
|
||||
"message_state": 2,
|
||||
"item_list": items,
|
||||
"context_token": context_token,
|
||||
}
|
||||
})
|
||||
|
||||
# ── getUploadUrl ───────────────────────────────────────────────────
|
||||
|
||||
def get_upload_url(self, filekey: str, media_type: int, to_user_id: str,
|
||||
rawsize: int, rawfilemd5: str, filesize: int,
|
||||
aeskey: str,
|
||||
thumb_rawsize: int = 0, thumb_rawfilemd5: str = "",
|
||||
thumb_filesize: int = 0) -> dict:
|
||||
body = {
|
||||
"filekey": filekey,
|
||||
"media_type": media_type,
|
||||
"to_user_id": to_user_id,
|
||||
"rawsize": rawsize,
|
||||
"rawfilemd5": rawfilemd5,
|
||||
"filesize": filesize,
|
||||
"aeskey": aeskey,
|
||||
}
|
||||
if thumb_rawsize > 0:
|
||||
body["thumb_rawsize"] = thumb_rawsize
|
||||
body["thumb_rawfilemd5"] = thumb_rawfilemd5
|
||||
body["thumb_filesize"] = thumb_filesize
|
||||
else:
|
||||
body["no_need_thumb"] = True
|
||||
return self._post("ilink/bot/getuploadurl", body)
|
||||
|
||||
# ── getConfig / sendTyping ─────────────────────────────────────────
|
||||
|
||||
def get_config(self, user_id: str, context_token: str = "") -> dict:
|
||||
return self._post("ilink/bot/getconfig", {
|
||||
"ilink_user_id": user_id,
|
||||
"context_token": context_token,
|
||||
}, timeout=10)
|
||||
|
||||
def send_typing(self, user_id: str, typing_ticket: str, status: int = 1) -> dict:
|
||||
return self._post("ilink/bot/sendtyping", {
|
||||
"ilink_user_id": user_id,
|
||||
"typing_ticket": typing_ticket,
|
||||
"status": status,
|
||||
}, timeout=10)
|
||||
|
||||
# ── QR Login ───────────────────────────────────────────────────────
|
||||
|
||||
def fetch_qr_code(self) -> dict:
|
||||
url = _ensure_trailing_slash(self.base_url) + f"ilink/bot/get_bot_qrcode?bot_type={BOT_TYPE}"
|
||||
resp = requests.get(url, timeout=15)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
def poll_qr_status(self, qrcode: str, timeout: int = QR_POLL_TIMEOUT) -> dict:
|
||||
url = (_ensure_trailing_slash(self.base_url) +
|
||||
f"ilink/bot/get_qrcode_status?qrcode={requests.utils.quote(qrcode)}")
|
||||
headers = {"iLink-App-ClientVersion": "1"}
|
||||
try:
|
||||
resp = requests.get(url, headers=headers, timeout=timeout)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except requests.exceptions.Timeout:
|
||||
return {"status": "wait"}
|
||||
|
||||
|
||||
# ── AES-128-ECB helpers ─────────────────────────────────────────────
|
||||
|
||||
def _aes_ecb_encrypt(data: bytes, key: bytes) -> bytes:
|
||||
from Crypto.Cipher import AES
|
||||
pad_len = 16 - (len(data) % 16)
|
||||
padded = data + bytes([pad_len] * pad_len)
|
||||
cipher = AES.new(key, AES.MODE_ECB)
|
||||
return cipher.encrypt(padded)
|
||||
|
||||
|
||||
def _aes_ecb_decrypt(data: bytes, key: bytes) -> bytes:
|
||||
from Crypto.Cipher import AES
|
||||
cipher = AES.new(key, AES.MODE_ECB)
|
||||
decrypted = cipher.decrypt(data)
|
||||
pad_len = decrypted[-1]
|
||||
if pad_len > 16:
|
||||
return decrypted
|
||||
return decrypted[:-pad_len]
|
||||
|
||||
|
||||
def _file_md5(file_path: str) -> str:
|
||||
h = hashlib.md5()
|
||||
with open(file_path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(8192), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def _md5_bytes(data: bytes) -> str:
|
||||
return hashlib.md5(data).hexdigest()
|
||||
|
||||
|
||||
def upload_media_to_cdn(api: WeixinApi, file_path: str, to_user_id: str,
|
||||
media_type: int) -> dict:
|
||||
"""
|
||||
Upload a local file to the Weixin CDN.
|
||||
|
||||
Args:
|
||||
api: WeixinApi instance
|
||||
file_path: local file path
|
||||
to_user_id: target user id
|
||||
media_type: 1=IMAGE, 2=VIDEO, 3=FILE
|
||||
|
||||
Returns:
|
||||
dict with keys: encrypt_query_param, aes_key_b64, ciphertext_size, raw_size
|
||||
"""
|
||||
aes_key = os.urandom(16)
|
||||
aes_key_hex = aes_key.hex()
|
||||
|
||||
with open(file_path, "rb") as f:
|
||||
raw_data = f.read()
|
||||
|
||||
raw_size = len(raw_data)
|
||||
raw_md5 = _md5_bytes(raw_data)
|
||||
encrypted = _aes_ecb_encrypt(raw_data, aes_key)
|
||||
cipher_size = len(encrypted)
|
||||
filekey = uuid.uuid4().hex
|
||||
|
||||
thumb_rawsize = 0
|
||||
thumb_rawfilemd5 = ""
|
||||
thumb_filesize = 0
|
||||
|
||||
if media_type == 1: # IMAGE - generate a tiny thumbnail
|
||||
try:
|
||||
from PIL import Image
|
||||
import io
|
||||
img = Image.open(file_path)
|
||||
img.thumbnail((100, 100))
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=60)
|
||||
thumb_raw = buf.getvalue()
|
||||
thumb_rawsize = len(thumb_raw)
|
||||
thumb_rawfilemd5 = _md5_bytes(thumb_raw)
|
||||
thumb_encrypted = _aes_ecb_encrypt(thumb_raw, aes_key)
|
||||
thumb_filesize = len(thumb_encrypted)
|
||||
except Exception as e:
|
||||
logger.warning(f"[Weixin] Thumbnail generation failed, skipping: {e}")
|
||||
|
||||
resp = api.get_upload_url(
|
||||
filekey=filekey,
|
||||
media_type=media_type,
|
||||
to_user_id=to_user_id,
|
||||
rawsize=raw_size,
|
||||
rawfilemd5=raw_md5,
|
||||
filesize=cipher_size,
|
||||
aeskey=aes_key_hex,
|
||||
thumb_rawsize=thumb_rawsize,
|
||||
thumb_rawfilemd5=thumb_rawfilemd5,
|
||||
thumb_filesize=thumb_filesize,
|
||||
)
|
||||
|
||||
upload_param = resp.get("upload_param", "")
|
||||
if not upload_param:
|
||||
raise RuntimeError(f"[Weixin] getUploadUrl returned no upload_param: {resp}")
|
||||
|
||||
cdn_url = api.cdn_base_url + "?" + upload_param
|
||||
put_resp = requests.put(cdn_url, data=encrypted, headers={
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Length": str(cipher_size),
|
||||
}, timeout=60)
|
||||
put_resp.raise_for_status()
|
||||
|
||||
# Upload thumbnail if we have one
|
||||
thumb_upload_param = resp.get("thumb_upload_param", "")
|
||||
if thumb_upload_param and thumb_filesize > 0:
|
||||
thumb_cdn_url = api.cdn_base_url + "?" + thumb_upload_param
|
||||
try:
|
||||
requests.put(thumb_cdn_url, data=thumb_encrypted, headers={
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Length": str(thumb_filesize),
|
||||
}, timeout=30)
|
||||
except Exception as e:
|
||||
logger.warning(f"[Weixin] Thumbnail upload failed (non-fatal): {e}")
|
||||
|
||||
return {
|
||||
"encrypt_query_param": upload_param,
|
||||
"aes_key_b64": base64.b64encode(aes_key).decode("utf-8"),
|
||||
"ciphertext_size": cipher_size,
|
||||
"raw_size": raw_size,
|
||||
}
|
||||
|
||||
|
||||
def download_media_from_cdn(cdn_base_url: str, encrypt_query_param: str,
|
||||
aes_key: str, save_path: str) -> str:
|
||||
"""
|
||||
Download and decrypt a media file from Weixin CDN.
|
||||
|
||||
Args:
|
||||
cdn_base_url: CDN base URL
|
||||
encrypt_query_param: encrypted query parameter from message
|
||||
aes_key: hex or base64 encoded AES key
|
||||
save_path: path to save decrypted file
|
||||
|
||||
Returns:
|
||||
save_path on success
|
||||
"""
|
||||
url = cdn_base_url + "?" + encrypt_query_param
|
||||
resp = requests.get(url, timeout=60)
|
||||
resp.raise_for_status()
|
||||
|
||||
# Determine key format (hex string or base64)
|
||||
try:
|
||||
key_bytes = bytes.fromhex(aes_key)
|
||||
if len(key_bytes) != 16:
|
||||
raise ValueError()
|
||||
except (ValueError, TypeError):
|
||||
key_bytes = base64.b64decode(aes_key)
|
||||
if len(key_bytes) != 16:
|
||||
raise ValueError(f"Invalid AES key length: {len(key_bytes)}")
|
||||
|
||||
decrypted = _aes_ecb_decrypt(resp.content, key_bytes)
|
||||
|
||||
os.makedirs(os.path.dirname(save_path), exist_ok=True)
|
||||
with open(save_path, "wb") as f:
|
||||
f.write(decrypted)
|
||||
return save_path
|
||||
542
channel/weixin/weixin_channel.py
Normal file
542
channel/weixin/weixin_channel.py
Normal file
@@ -0,0 +1,542 @@
|
||||
"""
|
||||
Weixin channel implementation.
|
||||
|
||||
Uses HTTP long-poll (getUpdates) to receive messages and sendMessage to reply.
|
||||
Login via QR code scan through the ilink bot API.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import requests
|
||||
|
||||
from bridge.context import Context, ContextType
|
||||
from bridge.reply import Reply, ReplyType
|
||||
from channel.chat_channel import ChatChannel, check_prefix
|
||||
from channel.weixin.weixin_api import (
|
||||
WeixinApi, upload_media_to_cdn,
|
||||
DEFAULT_BASE_URL, CDN_BASE_URL,
|
||||
)
|
||||
from channel.weixin.weixin_message import WeixinMessage
|
||||
from common.expired_dict import ExpiredDict
|
||||
from common.log import logger
|
||||
from common.singleton import singleton
|
||||
from config import conf
|
||||
|
||||
MAX_CONSECUTIVE_FAILURES = 3
|
||||
BACKOFF_DELAY = 30
|
||||
RETRY_DELAY = 2
|
||||
SESSION_EXPIRED_ERRCODE = -14
|
||||
|
||||
|
||||
def _load_credentials(cred_path: str) -> dict:
|
||||
"""Load saved credentials from JSON file."""
|
||||
try:
|
||||
if os.path.exists(cred_path):
|
||||
with open(cred_path, "r") as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.warning(f"[Weixin] Failed to load credentials: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def _save_credentials(cred_path: str, data: dict):
|
||||
"""Save credentials to JSON file."""
|
||||
os.makedirs(os.path.dirname(cred_path), exist_ok=True)
|
||||
with open(cred_path, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
try:
|
||||
os.chmod(cred_path, 0o600)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@singleton
|
||||
class WeixinChannel(ChatChannel):
|
||||
|
||||
LOGIN_STATUS_IDLE = "idle"
|
||||
LOGIN_STATUS_WAITING = "waiting_scan"
|
||||
LOGIN_STATUS_SCANNED = "scanned"
|
||||
LOGIN_STATUS_OK = "logged_in"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.api = None
|
||||
self._stop_event = threading.Event()
|
||||
self._poll_thread = None
|
||||
self._context_tokens = {} # user_id -> context_token
|
||||
self._received_msgs = ExpiredDict(60 * 60 * 7.1)
|
||||
self._get_updates_buf = ""
|
||||
self._credentials_path = ""
|
||||
self.login_status = self.LOGIN_STATUS_IDLE
|
||||
self._current_qr_url = ""
|
||||
|
||||
conf()["single_chat_prefix"] = [""]
|
||||
|
||||
# ── Lifecycle ──────────────────────────────────────────────────────
|
||||
|
||||
def startup(self):
|
||||
base_url = conf().get("weixin_base_url", DEFAULT_BASE_URL)
|
||||
cdn_base_url = conf().get("weixin_cdn_base_url", CDN_BASE_URL)
|
||||
token = conf().get("weixin_token", "")
|
||||
|
||||
self._credentials_path = os.path.expanduser(
|
||||
conf().get("weixin_credentials_path", "~/.weixin_cow_credentials.json")
|
||||
)
|
||||
|
||||
if not token:
|
||||
creds = _load_credentials(self._credentials_path)
|
||||
token = creds.get("token", "")
|
||||
if creds.get("base_url"):
|
||||
base_url = creds["base_url"]
|
||||
|
||||
if not token:
|
||||
logger.info("[Weixin] No token found, starting QR login...")
|
||||
self.login_status = self.LOGIN_STATUS_WAITING
|
||||
login_result = self._qr_login(base_url)
|
||||
if not login_result:
|
||||
self.login_status = self.LOGIN_STATUS_IDLE
|
||||
err = "[Weixin] QR login failed. Set weixin_token in config or run login again."
|
||||
logger.error(err)
|
||||
self.report_startup_error(err)
|
||||
return
|
||||
token = login_result["token"]
|
||||
base_url = login_result.get("base_url", base_url)
|
||||
|
||||
self.api = WeixinApi(base_url=base_url, token=token, cdn_base_url=cdn_base_url)
|
||||
self.login_status = self.LOGIN_STATUS_OK
|
||||
|
||||
logger.info(f"[Weixin] 微信通道已启动,凭证保存在 {self._credentials_path},"
|
||||
f"如需重新扫码登录请删除该文件后重启")
|
||||
self.report_startup_success()
|
||||
|
||||
self._stop_event.clear()
|
||||
self._poll_loop()
|
||||
|
||||
def stop(self):
|
||||
logger.info("[Weixin] stop() called")
|
||||
self._stop_event.set()
|
||||
|
||||
def _relogin(self) -> bool:
|
||||
"""Re-login after session expiry. Returns True on success."""
|
||||
base_url = self.api.base_url if self.api else DEFAULT_BASE_URL
|
||||
if os.path.exists(self._credentials_path):
|
||||
try:
|
||||
os.remove(self._credentials_path)
|
||||
except Exception:
|
||||
pass
|
||||
self.login_status = self.LOGIN_STATUS_WAITING
|
||||
result = self._qr_login(base_url)
|
||||
if not result:
|
||||
self.login_status = self.LOGIN_STATUS_IDLE
|
||||
return False
|
||||
self.api = WeixinApi(
|
||||
base_url=result.get("base_url", base_url),
|
||||
token=result["token"],
|
||||
cdn_base_url=self.api.cdn_base_url if self.api else CDN_BASE_URL,
|
||||
)
|
||||
self.login_status = self.LOGIN_STATUS_OK
|
||||
self._context_tokens.clear()
|
||||
return True
|
||||
|
||||
# ── QR Login ───────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _print_qr(qrcode_url: str):
|
||||
"""Print QR code to terminal for scanning."""
|
||||
print("\n" + "=" * 60)
|
||||
print(" 请使用微信扫描二维码登录 (二维码约2分钟后过期)")
|
||||
print("=" * 60)
|
||||
try:
|
||||
import qrcode as qr_lib
|
||||
qr = qr_lib.QRCode(error_correction=qr_lib.constants.ERROR_CORRECT_L, box_size=1, border=1)
|
||||
qr.add_data(qrcode_url)
|
||||
qr.make(fit=True)
|
||||
qr.print_ascii(invert=True)
|
||||
except ImportError:
|
||||
print(f"\n 二维码链接: {qrcode_url}")
|
||||
print(" (安装 'qrcode' 包可在终端显示二维码)\n")
|
||||
|
||||
def _qr_login(self, base_url: str) -> dict:
|
||||
"""Perform interactive QR code login. Returns dict with token/base_url or empty dict."""
|
||||
api = WeixinApi(base_url=base_url)
|
||||
try:
|
||||
qr_resp = api.fetch_qr_code()
|
||||
except Exception as e:
|
||||
logger.error(f"[Weixin] Failed to fetch QR code: {e}")
|
||||
return {}
|
||||
|
||||
qrcode = qr_resp.get("qrcode", "")
|
||||
qrcode_url = qr_resp.get("qrcode_img_content", "")
|
||||
|
||||
if not qrcode:
|
||||
logger.error("[Weixin] No QR code returned from server")
|
||||
return {}
|
||||
|
||||
self._current_qr_url = qrcode_url
|
||||
logger.info(f"[Weixin] QR code URL: {qrcode_url}")
|
||||
self._print_qr(qrcode_url)
|
||||
print(" 等待扫码...\n")
|
||||
|
||||
scanned_printed = False
|
||||
|
||||
while True:
|
||||
try:
|
||||
status_resp = api.poll_qr_status(qrcode)
|
||||
except Exception as e:
|
||||
logger.error(f"[Weixin] QR status poll error: {e}")
|
||||
return {}
|
||||
|
||||
status = status_resp.get("status", "wait")
|
||||
|
||||
if status == "wait":
|
||||
pass
|
||||
elif status == "scaned":
|
||||
self.login_status = self.LOGIN_STATUS_SCANNED
|
||||
if not scanned_printed:
|
||||
print(" 已扫码,请在手机上确认...")
|
||||
scanned_printed = True
|
||||
elif status == "expired":
|
||||
print(" 二维码已过期,正在刷新...")
|
||||
try:
|
||||
qr_resp = api.fetch_qr_code()
|
||||
qrcode = qr_resp.get("qrcode", "")
|
||||
qrcode_url = qr_resp.get("qrcode_img_content", "")
|
||||
scanned_printed = False
|
||||
self._current_qr_url = qrcode_url
|
||||
logger.info(f"[Weixin] New QR code: {qrcode_url}")
|
||||
self._print_qr(qrcode_url)
|
||||
except Exception as e:
|
||||
logger.error(f"[Weixin] QR refresh failed: {e}")
|
||||
return {}
|
||||
elif status == "confirmed":
|
||||
bot_token = status_resp.get("bot_token", "")
|
||||
bot_id = status_resp.get("ilink_bot_id", "")
|
||||
result_base_url = status_resp.get("baseurl", base_url)
|
||||
user_id = status_resp.get("ilink_user_id", "")
|
||||
|
||||
if not bot_token or not bot_id:
|
||||
logger.error("[Weixin] Login confirmed but missing token/bot_id")
|
||||
return {}
|
||||
|
||||
self._current_qr_url = ""
|
||||
print(f"\n ✅ 微信登录成功!bot_id={bot_id}")
|
||||
logger.info(f"[Weixin] Login confirmed: bot_id={bot_id}")
|
||||
|
||||
creds = {
|
||||
"token": bot_token,
|
||||
"base_url": result_base_url,
|
||||
"bot_id": bot_id,
|
||||
"user_id": user_id,
|
||||
}
|
||||
_save_credentials(self._credentials_path, creds)
|
||||
logger.info(f"[Weixin] Credentials saved to {self._credentials_path}")
|
||||
|
||||
return {"token": bot_token, "base_url": result_base_url}
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
logger.warning("[Weixin] QR login timed out")
|
||||
return {}
|
||||
|
||||
# ── Long-poll loop ─────────────────────────────────────────────────
|
||||
|
||||
def _poll_loop(self):
|
||||
"""Main long-poll loop: getUpdates -> parse -> produce."""
|
||||
logger.info("[Weixin] Starting long-poll loop")
|
||||
consecutive_failures = 0
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
resp = self.api.get_updates(self._get_updates_buf)
|
||||
|
||||
ret = resp.get("ret", 0)
|
||||
errcode = resp.get("errcode", 0)
|
||||
|
||||
is_error = (ret != 0) or (errcode != 0)
|
||||
if is_error:
|
||||
if errcode == SESSION_EXPIRED_ERRCODE or ret == SESSION_EXPIRED_ERRCODE:
|
||||
logger.error("[Weixin] Session expired (errcode -14), starting re-login...")
|
||||
if self._relogin():
|
||||
logger.info("[Weixin] Re-login successful, resuming long-poll")
|
||||
self._get_updates_buf = ""
|
||||
consecutive_failures = 0
|
||||
continue
|
||||
else:
|
||||
logger.error("[Weixin] Re-login failed, will retry in 5 minutes")
|
||||
self._stop_event.wait(300)
|
||||
continue
|
||||
|
||||
consecutive_failures += 1
|
||||
errmsg = resp.get("errmsg", "")
|
||||
logger.error(f"[Weixin] getUpdates error: ret={ret} errcode={errcode} "
|
||||
f"errmsg={errmsg} ({consecutive_failures}/{MAX_CONSECUTIVE_FAILURES})")
|
||||
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
|
||||
consecutive_failures = 0
|
||||
self._stop_event.wait(BACKOFF_DELAY)
|
||||
else:
|
||||
self._stop_event.wait(RETRY_DELAY)
|
||||
continue
|
||||
|
||||
consecutive_failures = 0
|
||||
|
||||
# Update sync cursor
|
||||
new_buf = resp.get("get_updates_buf", "")
|
||||
if new_buf:
|
||||
self._get_updates_buf = new_buf
|
||||
|
||||
# Process messages
|
||||
msgs = resp.get("msgs", [])
|
||||
for raw_msg in msgs:
|
||||
try:
|
||||
self._process_message(raw_msg)
|
||||
except Exception as e:
|
||||
logger.error(f"[Weixin] Failed to process message: {e}", exc_info=True)
|
||||
|
||||
except Exception as e:
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
consecutive_failures += 1
|
||||
logger.error(f"[Weixin] getUpdates exception: {e} "
|
||||
f"({consecutive_failures}/{MAX_CONSECUTIVE_FAILURES})")
|
||||
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
|
||||
consecutive_failures = 0
|
||||
self._stop_event.wait(BACKOFF_DELAY)
|
||||
else:
|
||||
self._stop_event.wait(RETRY_DELAY)
|
||||
|
||||
logger.info("[Weixin] Long-poll loop ended")
|
||||
|
||||
def _process_message(self, raw_msg: dict):
|
||||
"""Parse a single inbound message and produce to the handling queue."""
|
||||
msg_type = raw_msg.get("message_type", 0)
|
||||
if msg_type != 1: # Only process USER messages (type=1)
|
||||
return
|
||||
|
||||
msg_id = str(raw_msg.get("message_id", raw_msg.get("seq", "")))
|
||||
if self._received_msgs.get(msg_id):
|
||||
return
|
||||
self._received_msgs[msg_id] = True
|
||||
|
||||
from_user = raw_msg.get("from_user_id", "")
|
||||
context_token = raw_msg.get("context_token", "")
|
||||
|
||||
if context_token and from_user:
|
||||
self._context_tokens[from_user] = context_token
|
||||
|
||||
cdn_base_url = self.api.cdn_base_url if self.api else CDN_BASE_URL
|
||||
try:
|
||||
wx_msg = WeixinMessage(raw_msg, cdn_base_url=cdn_base_url)
|
||||
except Exception as e:
|
||||
logger.error(f"[Weixin] Failed to parse WeixinMessage: {e}", exc_info=True)
|
||||
return
|
||||
|
||||
logger.info(f"[Weixin] Received: from={from_user} ctype={wx_msg.ctype} "
|
||||
f"content={str(wx_msg.content)[:50]}")
|
||||
|
||||
# File cache logic
|
||||
from channel.file_cache import get_file_cache
|
||||
file_cache = get_file_cache()
|
||||
session_id = from_user
|
||||
|
||||
if wx_msg.ctype == ContextType.IMAGE:
|
||||
if hasattr(wx_msg, "image_path") and wx_msg.image_path:
|
||||
file_cache.add(session_id, wx_msg.image_path, file_type="image")
|
||||
logger.info(f"[Weixin] Image cached for session {session_id}")
|
||||
return
|
||||
|
||||
if wx_msg.ctype == ContextType.FILE:
|
||||
wx_msg.prepare()
|
||||
file_cache.add(session_id, wx_msg.content, file_type="file")
|
||||
logger.info(f"[Weixin] File cached for session {session_id}: {wx_msg.content}")
|
||||
return
|
||||
|
||||
if wx_msg.ctype == ContextType.TEXT:
|
||||
cached_files = file_cache.get(session_id)
|
||||
if cached_files:
|
||||
refs = []
|
||||
for fi in cached_files:
|
||||
ftype, fpath = fi["type"], fi["path"]
|
||||
if ftype == "image":
|
||||
refs.append(f"[图片: {fpath}]")
|
||||
elif ftype == "video":
|
||||
refs.append(f"[视频: {fpath}]")
|
||||
else:
|
||||
refs.append(f"[文件: {fpath}]")
|
||||
wx_msg.content = wx_msg.content + "\n" + "\n".join(refs)
|
||||
file_cache.clear(session_id)
|
||||
|
||||
context = self._compose_context(
|
||||
wx_msg.ctype,
|
||||
wx_msg.content,
|
||||
isgroup=False,
|
||||
msg=wx_msg,
|
||||
no_need_at=True,
|
||||
)
|
||||
if context:
|
||||
self.produce(context)
|
||||
|
||||
# ── _compose_context ───────────────────────────────────────────────
|
||||
|
||||
def _compose_context(self, ctype: ContextType, content, **kwargs):
|
||||
context = Context(ctype, content)
|
||||
context.kwargs = kwargs
|
||||
if "channel_type" not in context:
|
||||
context["channel_type"] = self.channel_type
|
||||
if "origin_ctype" not in context:
|
||||
context["origin_ctype"] = ctype
|
||||
|
||||
cmsg = context["msg"]
|
||||
context["session_id"] = cmsg.from_user_id
|
||||
context["receiver"] = cmsg.other_user_id
|
||||
|
||||
if ctype == ContextType.TEXT:
|
||||
img_match_prefix = check_prefix(content, conf().get("image_create_prefix"))
|
||||
if img_match_prefix:
|
||||
content = content.replace(img_match_prefix, "", 1)
|
||||
context.type = ContextType.IMAGE_CREATE
|
||||
else:
|
||||
context.type = ContextType.TEXT
|
||||
context.content = content.strip()
|
||||
|
||||
return context
|
||||
|
||||
# ── Send reply ─────────────────────────────────────────────────────
|
||||
|
||||
def send(self, reply: Reply, context: Context):
|
||||
receiver = context.get("receiver", "")
|
||||
msg = context.get("msg")
|
||||
context_token = self._get_context_token(receiver, msg)
|
||||
|
||||
if not context_token:
|
||||
logger.error(f"[Weixin] No context_token for receiver={receiver}, cannot send")
|
||||
return
|
||||
|
||||
if reply.type == ReplyType.TEXT:
|
||||
self._send_text(reply.content, receiver, context_token)
|
||||
elif reply.type in (ReplyType.IMAGE_URL, ReplyType.IMAGE):
|
||||
self._send_image(reply.content, receiver, context_token)
|
||||
elif reply.type == ReplyType.FILE:
|
||||
self._send_file(reply.content, receiver, context_token)
|
||||
elif reply.type in (ReplyType.VIDEO, ReplyType.VIDEO_URL):
|
||||
self._send_video(reply.content, receiver, context_token)
|
||||
else:
|
||||
logger.warning(f"[Weixin] Unsupported reply type: {reply.type}, fallback to text")
|
||||
self._send_text(str(reply.content), receiver, context_token)
|
||||
|
||||
def _get_context_token(self, receiver: str, msg=None) -> str:
|
||||
"""Get the context_token for a receiver, required for all sends."""
|
||||
if msg and hasattr(msg, "context_token") and msg.context_token:
|
||||
return msg.context_token
|
||||
return self._context_tokens.get(receiver, "")
|
||||
|
||||
def _send_text(self, text: str, receiver: str, context_token: str):
|
||||
try:
|
||||
self.api.send_text(receiver, text, context_token)
|
||||
logger.debug(f"[Weixin] Text sent to {receiver}, len={len(text)}")
|
||||
except Exception as e:
|
||||
logger.error(f"[Weixin] Failed to send text: {e}")
|
||||
|
||||
def _send_image(self, img_path_or_url: str, receiver: str, context_token: str):
|
||||
local_path = self._resolve_media_path(img_path_or_url)
|
||||
if not local_path:
|
||||
self._send_text("[Image send failed: file not found]", receiver, context_token)
|
||||
return
|
||||
try:
|
||||
result = upload_media_to_cdn(self.api, local_path, receiver, media_type=1)
|
||||
self.api.send_image_item(
|
||||
to=receiver,
|
||||
context_token=context_token,
|
||||
encrypt_query_param=result["encrypt_query_param"],
|
||||
aes_key_b64=result["aes_key_b64"],
|
||||
ciphertext_size=result["ciphertext_size"],
|
||||
)
|
||||
logger.info(f"[Weixin] Image sent to {receiver}")
|
||||
except Exception as e:
|
||||
logger.error(f"[Weixin] Image send failed: {e}")
|
||||
self._send_text("[Image send failed]", receiver, context_token)
|
||||
|
||||
def _send_file(self, file_path_or_url: str, receiver: str, context_token: str):
|
||||
local_path = self._resolve_media_path(file_path_or_url)
|
||||
if not local_path:
|
||||
self._send_text("[File send failed: file not found]", receiver, context_token)
|
||||
return
|
||||
try:
|
||||
result = upload_media_to_cdn(self.api, local_path, receiver, media_type=3)
|
||||
self.api.send_file_item(
|
||||
to=receiver,
|
||||
context_token=context_token,
|
||||
encrypt_query_param=result["encrypt_query_param"],
|
||||
aes_key_b64=result["aes_key_b64"],
|
||||
file_name=os.path.basename(local_path),
|
||||
file_size=result["raw_size"],
|
||||
)
|
||||
logger.info(f"[Weixin] File sent to {receiver}")
|
||||
except Exception as e:
|
||||
logger.error(f"[Weixin] File send failed: {e}")
|
||||
self._send_text("[File send failed]", receiver, context_token)
|
||||
|
||||
def _send_video(self, video_path_or_url: str, receiver: str, context_token: str):
|
||||
local_path = self._resolve_media_path(video_path_or_url)
|
||||
if not local_path:
|
||||
self._send_text("[Video send failed: file not found]", receiver, context_token)
|
||||
return
|
||||
try:
|
||||
result = upload_media_to_cdn(self.api, local_path, receiver, media_type=2)
|
||||
self.api.send_video_item(
|
||||
to=receiver,
|
||||
context_token=context_token,
|
||||
encrypt_query_param=result["encrypt_query_param"],
|
||||
aes_key_b64=result["aes_key_b64"],
|
||||
ciphertext_size=result["ciphertext_size"],
|
||||
)
|
||||
logger.info(f"[Weixin] Video sent to {receiver}")
|
||||
except Exception as e:
|
||||
logger.error(f"[Weixin] Video send failed: {e}")
|
||||
self._send_text("[Video send failed]", receiver, context_token)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_media_path(path_or_url: str) -> str:
|
||||
"""Resolve a file path or URL to a local file path. Downloads if needed."""
|
||||
if not path_or_url:
|
||||
return ""
|
||||
|
||||
local_path = path_or_url
|
||||
if local_path.startswith("file://"):
|
||||
local_path = local_path[7:]
|
||||
|
||||
if local_path.startswith(("http://", "https://")):
|
||||
try:
|
||||
resp = requests.get(local_path, timeout=60)
|
||||
resp.raise_for_status()
|
||||
ct = resp.headers.get("Content-Type", "")
|
||||
ext = ".bin"
|
||||
if "jpeg" in ct or "jpg" in ct:
|
||||
ext = ".jpg"
|
||||
elif "png" in ct:
|
||||
ext = ".png"
|
||||
elif "gif" in ct:
|
||||
ext = ".gif"
|
||||
elif "webp" in ct:
|
||||
ext = ".webp"
|
||||
elif "mp4" in ct:
|
||||
ext = ".mp4"
|
||||
elif "pdf" in ct:
|
||||
ext = ".pdf"
|
||||
|
||||
tmp_path = f"/tmp/wx_media_{uuid.uuid4().hex[:8]}{ext}"
|
||||
with open(tmp_path, "wb") as f:
|
||||
f.write(resp.content)
|
||||
return tmp_path
|
||||
except Exception as e:
|
||||
logger.error(f"[Weixin] Failed to download media: {e}")
|
||||
return ""
|
||||
|
||||
if os.path.exists(local_path):
|
||||
return local_path
|
||||
|
||||
logger.warning(f"[Weixin] Media file not found: {local_path}")
|
||||
return ""
|
||||
200
channel/weixin/weixin_message.py
Normal file
200
channel/weixin/weixin_message.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""
|
||||
Weixin ChatMessage implementation.
|
||||
|
||||
Parses WeixinMessage from the getUpdates API into the unified ChatMessage format.
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from bridge.context import ContextType
|
||||
from channel.chat_message import ChatMessage
|
||||
from channel.weixin.weixin_api import download_media_from_cdn, CDN_BASE_URL
|
||||
from common.log import logger
|
||||
from common.utils import expand_path
|
||||
from config import conf
|
||||
|
||||
|
||||
# MessageItemType constants from the Weixin protocol
|
||||
ITEM_TEXT = 1
|
||||
ITEM_IMAGE = 2
|
||||
ITEM_VOICE = 3
|
||||
ITEM_FILE = 4
|
||||
ITEM_VIDEO = 5
|
||||
|
||||
|
||||
def _get_tmp_dir() -> str:
|
||||
ws_root = expand_path(conf().get("agent_workspace", "~/cow"))
|
||||
tmp_dir = os.path.join(ws_root, "tmp")
|
||||
os.makedirs(tmp_dir, exist_ok=True)
|
||||
return tmp_dir
|
||||
|
||||
|
||||
class WeixinMessage(ChatMessage):
|
||||
"""Message wrapper for Weixin channel."""
|
||||
|
||||
def __init__(self, msg: dict, cdn_base_url: str = CDN_BASE_URL):
|
||||
super().__init__(msg)
|
||||
|
||||
self.msg_id = str(msg.get("message_id", msg.get("seq", uuid.uuid4().hex[:8])))
|
||||
self.create_time = msg.get("create_time_ms", 0)
|
||||
self.context_token = msg.get("context_token", "")
|
||||
self.is_group = False # Weixin plugin only supports direct chat
|
||||
self.is_at = False
|
||||
|
||||
from_user_id = msg.get("from_user_id", "")
|
||||
to_user_id = msg.get("to_user_id", "")
|
||||
|
||||
self.from_user_id = from_user_id
|
||||
self.from_user_nickname = from_user_id
|
||||
self.to_user_id = to_user_id
|
||||
self.to_user_nickname = to_user_id
|
||||
self.other_user_id = from_user_id
|
||||
self.other_user_nickname = from_user_id
|
||||
self.actual_user_id = from_user_id
|
||||
self.actual_user_nickname = from_user_id
|
||||
|
||||
item_list = msg.get("item_list", [])
|
||||
|
||||
# Parse items: find text and media
|
||||
text_body = ""
|
||||
media_item = None
|
||||
media_type = None
|
||||
ref_text = ""
|
||||
|
||||
for item in item_list:
|
||||
itype = item.get("type", 0)
|
||||
|
||||
if itype == ITEM_TEXT:
|
||||
text_item = item.get("text_item", {})
|
||||
text_body = text_item.get("text", "")
|
||||
|
||||
ref = item.get("ref_msg")
|
||||
if ref:
|
||||
ref_title = ref.get("title", "")
|
||||
ref_mi = ref.get("message_item", {})
|
||||
ref_body = ""
|
||||
if ref_mi.get("type") == ITEM_TEXT:
|
||||
ref_body = ref_mi.get("text_item", {}).get("text", "")
|
||||
if ref_title or ref_body:
|
||||
parts = [p for p in [ref_title, ref_body] if p]
|
||||
ref_text = f"[引用: {' | '.join(parts)}]\n"
|
||||
# If ref is a media item, treat it as the media to download
|
||||
if ref_mi.get("type") in (ITEM_IMAGE, ITEM_VIDEO, ITEM_FILE):
|
||||
media_item = ref_mi
|
||||
media_type = ref_mi.get("type")
|
||||
|
||||
elif itype == ITEM_VOICE:
|
||||
voice_item = item.get("voice_item", {})
|
||||
voice_text = voice_item.get("text", "")
|
||||
if voice_text:
|
||||
text_body = voice_text
|
||||
else:
|
||||
# Voice without transcription - download the audio
|
||||
media_item = item
|
||||
media_type = ITEM_VOICE
|
||||
|
||||
elif itype in (ITEM_IMAGE, ITEM_VIDEO, ITEM_FILE):
|
||||
if not media_item:
|
||||
media_item = item
|
||||
media_type = itype
|
||||
|
||||
# Determine ctype and content
|
||||
if media_item and not text_body:
|
||||
self._setup_media(media_item, media_type, cdn_base_url)
|
||||
elif media_item and text_body:
|
||||
# Text + media: download media, attach as file ref in text
|
||||
self.ctype = ContextType.TEXT
|
||||
media_path = self._download_media(media_item, media_type, cdn_base_url)
|
||||
if media_path:
|
||||
if media_type == ITEM_IMAGE:
|
||||
text_body += f"\n[图片: {media_path}]"
|
||||
elif media_type == ITEM_VIDEO:
|
||||
text_body += f"\n[视频: {media_path}]"
|
||||
else:
|
||||
text_body += f"\n[文件: {media_path}]"
|
||||
self.content = ref_text + text_body
|
||||
else:
|
||||
self.ctype = ContextType.TEXT
|
||||
self.content = ref_text + text_body
|
||||
|
||||
def _setup_media(self, item: dict, media_type: int, cdn_base_url: str):
|
||||
"""Set up message as a media type, with lazy download via _prepare_fn."""
|
||||
if media_type == ITEM_IMAGE:
|
||||
self.ctype = ContextType.IMAGE
|
||||
image_path = self._download_media(item, ITEM_IMAGE, cdn_base_url)
|
||||
if image_path:
|
||||
self.content = image_path
|
||||
self.image_path = image_path
|
||||
else:
|
||||
self.ctype = ContextType.TEXT
|
||||
self.content = "[Image download failed]"
|
||||
|
||||
elif media_type == ITEM_VIDEO:
|
||||
self.ctype = ContextType.FILE
|
||||
save_path = os.path.join(_get_tmp_dir(), f"wx_{self.msg_id}.mp4")
|
||||
self.content = save_path
|
||||
|
||||
def _download():
|
||||
path = self._download_media(item, ITEM_VIDEO, cdn_base_url)
|
||||
if path:
|
||||
self.content = path
|
||||
self._prepare_fn = _download
|
||||
|
||||
elif media_type == ITEM_FILE:
|
||||
self.ctype = ContextType.FILE
|
||||
file_name = item.get("file_item", {}).get("file_name", f"wx_{self.msg_id}")
|
||||
save_path = os.path.join(_get_tmp_dir(), file_name)
|
||||
self.content = save_path
|
||||
|
||||
def _download():
|
||||
path = self._download_media(item, ITEM_FILE, cdn_base_url)
|
||||
if path:
|
||||
self.content = path
|
||||
self._prepare_fn = _download
|
||||
|
||||
elif media_type == ITEM_VOICE:
|
||||
self.ctype = ContextType.VOICE
|
||||
save_path = os.path.join(_get_tmp_dir(), f"wx_{self.msg_id}.silk")
|
||||
self.content = save_path
|
||||
|
||||
def _download():
|
||||
path = self._download_media(item, ITEM_VOICE, cdn_base_url)
|
||||
if path:
|
||||
self.content = path
|
||||
self._prepare_fn = _download
|
||||
|
||||
def _download_media(self, item: dict, media_type: int, cdn_base_url: str) -> str:
|
||||
"""Download media from CDN, returns local file path or empty string."""
|
||||
type_key_map = {
|
||||
ITEM_IMAGE: "image_item",
|
||||
ITEM_VIDEO: "video_item",
|
||||
ITEM_FILE: "file_item",
|
||||
ITEM_VOICE: "voice_item",
|
||||
}
|
||||
key = type_key_map.get(media_type, "")
|
||||
info = item.get(key, {})
|
||||
media = info.get("media", {})
|
||||
|
||||
encrypt_param = media.get("encrypt_query_param", "")
|
||||
# aes_key can be in image_item.aeskey (hex) or media.aes_key (b64)
|
||||
aes_key = info.get("aeskey", "") or media.get("aes_key", "")
|
||||
|
||||
if not encrypt_param or not aes_key:
|
||||
logger.warning(f"[Weixin] Missing CDN params for media download (type={media_type})")
|
||||
return ""
|
||||
|
||||
ext_map = {ITEM_IMAGE: ".jpg", ITEM_VIDEO: ".mp4", ITEM_FILE: "", ITEM_VOICE: ".silk"}
|
||||
ext = ext_map.get(media_type, "")
|
||||
if media_type == ITEM_FILE:
|
||||
ext = os.path.splitext(info.get("file_name", ""))[1] or ".bin"
|
||||
|
||||
save_path = os.path.join(_get_tmp_dir(), f"wx_{self.msg_id}{ext}")
|
||||
|
||||
try:
|
||||
download_media_from_cdn(cdn_base_url, encrypt_param, aes_key, save_path)
|
||||
logger.info(f"[Weixin] Media downloaded: {save_path}")
|
||||
return save_path
|
||||
except Exception as e:
|
||||
logger.error(f"[Weixin] Media download failed: {e}")
|
||||
return ""
|
||||
@@ -193,3 +193,4 @@ FEISHU = "feishu"
|
||||
DINGTALK = "dingtalk"
|
||||
WECOM_BOT = "wecom_bot"
|
||||
QQ = "qq"
|
||||
WEIXIN = "weixin"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"channel_type": "web",
|
||||
"channel_type": "weixin",
|
||||
"model": "MiniMax-M2.7",
|
||||
"minimax_api_key": "",
|
||||
"zhipu_ai_api_key": "",
|
||||
|
||||
10
config.py
10
config.py
@@ -153,10 +153,15 @@ available_setting = {
|
||||
# 企微智能机器人配置(长连接模式)
|
||||
"wecom_bot_id": "", # 企微智能机器人BotID
|
||||
"wecom_bot_secret": "", # 企微智能机器人长连接Secret
|
||||
# 微信配置
|
||||
"weixin_token": "", # 微信登录后获取的bot_token,留空则启动时自动扫码登录
|
||||
"weixin_base_url": "https://ilinkai.weixin.qq.com", # Weixin ilink API base URL
|
||||
"weixin_cdn_base_url": "https://novac2c.cdn.weixin.qq.com/c2c", # CDN base URL
|
||||
"weixin_credentials_path": "~/.weixin_cow_credentials.json", # credentials file path
|
||||
# chatgpt指令自定义触发词
|
||||
"clear_memory_commands": ["#清除记忆"], # 重置会话指令,必须以#开头
|
||||
# channel配置
|
||||
"channel_type": "", # 通道类型,支持多渠道同时运行。单个: "feishu",多个: "feishu, dingtalk" 或 ["feishu", "dingtalk"]。可选值: web,feishu,dingtalk,wecom_bot,wechatmp,wechatmp_service,wechatcom_app
|
||||
"channel_type": "", # 通道类型,支持多渠道同时运行。单个: "feishu",多个: "feishu, dingtalk" 或 ["feishu", "dingtalk"]。可选值: web,feishu,dingtalk,wecom_bot,weixin,wechatmp,wechatmp_service,wechatcom_app
|
||||
"web_console": True, # 是否自动启动Web控制台(默认启动)。设为False可禁用
|
||||
"subscribe_msg": "", # 订阅消息, 支持: wechatmp, wechatmp_service, wechatcom_app
|
||||
"debug": False, # 是否开启debug模式,开启后会打印更多日志
|
||||
@@ -382,7 +387,8 @@ def load_config():
|
||||
"wechatcomapp_agent_id": "WECHATCOMAPP_AGENT_ID",
|
||||
"wechatcomapp_secret": "WECHATCOMAPP_SECRET",
|
||||
"qq_app_id": "QQ_APP_ID",
|
||||
"qq_app_secret": "QQ_APP_SECRET"
|
||||
"qq_app_secret": "QQ_APP_SECRET",
|
||||
"weixin_token": "WEIXIN_TOKEN",
|
||||
}
|
||||
injected = 0
|
||||
for conf_key, env_key in _CONFIG_TO_ENV.items():
|
||||
|
||||
72
docs/channels/weixin.mdx
Normal file
72
docs/channels/weixin.mdx
Normal file
@@ -0,0 +1,72 @@
|
||||
---
|
||||
title: 微信
|
||||
description: 将 CowAgent 接入个人微信
|
||||
---
|
||||
|
||||
> 接入个人微信,扫码登录即可使用,无需公网 IP,支持文本、图片、语音、文件、视频等消息的收发。
|
||||
|
||||
## 一、配置和运行
|
||||
|
||||
### 方式一:Web 控制台接入
|
||||
|
||||
启动 Cow 项目后打开 Web 控制台 (本地链接为: http://127.0.0.1:9899/ ),选择 **通道** 菜单,点击 **接入通道**,选择 **微信**,点击接入后按照提示扫码登录。
|
||||
|
||||
### 方式二:配置文件接入
|
||||
|
||||
在 `config.json` 中设置 `channel_type` 为 `weixin`:
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_type": "weixin"
|
||||
}
|
||||
```
|
||||
|
||||
启动程序后,终端会显示二维码,使用微信扫码授权即可完成登录。
|
||||
|
||||
<Note>
|
||||
兼容历史配置:`channel_type` 设为 `wx` 同样可以启用微信通道。
|
||||
</Note>
|
||||
|
||||
## 二、参数说明
|
||||
|
||||
| 参数 | 说明 | 默认值 |
|
||||
| --- | --- | --- |
|
||||
| `channel_type` | 设为 `weixin` 或 `wx` | — |
|
||||
|
||||
登录凭证会自动保存至 `~/.weixin_cow_credentials.json`,如需重新登录删除该文件后重启即可。
|
||||
|
||||
## 三、登录说明
|
||||
|
||||
### 扫码登录
|
||||
|
||||
首次启动时,终端会显示一个二维码(有效期约 2 分钟)。使用微信扫描二维码并在手机上确认后即可完成登录。
|
||||
|
||||
- 二维码过期后会自动刷新并重新显示
|
||||
- 安装 `qrcode` Python 包可在终端直接渲染二维码图案(`pip3 install qrcode`)
|
||||
|
||||
### 凭证保存
|
||||
|
||||
登录成功后,凭证会自动保存至 `~/.weixin_cow_credentials.json`,下次启动时无需重新扫码。
|
||||
|
||||
如需重新登录,删除该凭证文件后重启程序即可。
|
||||
|
||||
### Session 过期
|
||||
|
||||
当微信 session 过期时(errcode -14),程序会自动清除旧凭证并重新发起扫码登录,无需手动干预。
|
||||
|
||||
## 四、功能说明
|
||||
|
||||
| 功能 | 支持情况 |
|
||||
| --- | --- |
|
||||
| 单聊 | ✅ |
|
||||
| 文本消息 | ✅ 收发 |
|
||||
| 图片消息 | ✅ 收发 |
|
||||
| 文件消息 | ✅ 收发 |
|
||||
| 视频消息 | ✅ 收发 |
|
||||
| 语音消息 | ✅ 接收 |
|
||||
|
||||
## 五、注意事项
|
||||
|
||||
1. 需确保网络可以访问 `ilinkai.weixin.qq.com`。
|
||||
2. 媒体文件(图片、文件、视频)通过 CDN 传输,使用 AES-128-ECB 加密,上传和下载由程序自动完成。
|
||||
3. 建议在稳定的网络环境下运行,避免频繁断线导致需要重新扫码。
|
||||
@@ -155,6 +155,7 @@
|
||||
{
|
||||
"group": "接入渠道",
|
||||
"pages": [
|
||||
"channels/weixin",
|
||||
"channels/web",
|
||||
"channels/feishu",
|
||||
"channels/dingtalk",
|
||||
@@ -301,6 +302,7 @@
|
||||
{
|
||||
"group": "Platforms",
|
||||
"pages": [
|
||||
"en/channels/weixin",
|
||||
"en/channels/web",
|
||||
"en/channels/feishu",
|
||||
"en/channels/dingtalk",
|
||||
@@ -448,6 +450,7 @@
|
||||
{
|
||||
"group": "プラットフォーム",
|
||||
"pages": [
|
||||
"ja/channels/weixin",
|
||||
"ja/channels/web",
|
||||
"ja/channels/feishu",
|
||||
"ja/channels/dingtalk",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
[<a href="https://github.com/zhayujie/chatgpt-on-wechat/blob/master/README.md">中文</a>] | [English] | [<a href="https://github.com/zhayujie/chatgpt-on-wechat/blob/master/docs/ja/README.md">日本語</a>]
|
||||
</p>
|
||||
|
||||
**CowAgent** is an AI super assistant powered by LLMs, capable of autonomous task planning, operating computers and external resources, creating and executing Skills, and continuously growing with long-term memory. It supports flexible model switching, handles text, voice, images, and files, and can be integrated into Web, Feishu, DingTalk, WeCom Bot, WeCom App, and WeChat Official Account — running 7×24 hours on your personal computer or server.
|
||||
**CowAgent** is an AI super assistant powered by LLMs, capable of autonomous task planning, operating computers and external resources, creating and executing Skills, and continuously growing with long-term memory. It supports flexible model switching, handles text, voice, images, and files, and can be integrated into WeChat, Web, Feishu, DingTalk, WeCom Bot, WeCom App, and WeChat Official Account — running 7×24 hours on your personal computer or server.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://cowagent.ai/">🌐 Website</a> ·
|
||||
@@ -25,7 +25,7 @@
|
||||
- ✅ **Skills System**: Implements a Skills creation and execution engine with multiple built-in skills, and supports custom Skills development through natural language conversation.
|
||||
- ✅ **Multimodal Messages**: Supports parsing, processing, generating, and sending text, images, voice, files, and other message types.
|
||||
- ✅ **Multiple Model Support**: Supports OpenAI, Claude, Gemini, DeepSeek, MiniMax, GLM, Qwen, Kimi, Doubao, and other mainstream model providers.
|
||||
- ✅ **Multi-platform Deployment**: Runs on local computers or servers, integrable into Web, Feishu, DingTalk, WeChat Official Account, and WeCom applications.
|
||||
- ✅ **Multi-platform Deployment**: Runs on local computers or servers, integrable into WeChat, Web, Feishu, DingTalk, WeChat Official Account, and WeCom applications.
|
||||
- ✅ **Knowledge Base**: Integrates enterprise knowledge base capabilities via the [LinkAI](https://link-ai.tech) platform.
|
||||
|
||||
## Disclaimer
|
||||
@@ -163,6 +163,7 @@ Supports multiple platforms. Set `channel_type` in `config.json` to switch:
|
||||
|
||||
| Channel | `channel_type` | Docs |
|
||||
| --- | --- | --- |
|
||||
| WeChat | `weixin` | [WeChat Setup](https://docs.cowagent.ai/en/channels/weixin) |
|
||||
| Web (default) | `web` | [Web Channel](https://docs.cowagent.ai/en/channels/web) |
|
||||
| Feishu | `feishu` | [Feishu Setup](https://docs.cowagent.ai/en/channels/feishu) |
|
||||
| DingTalk | `dingtalk` | [DingTalk Setup](https://docs.cowagent.ai/en/channels/dingtalk) |
|
||||
|
||||
72
docs/en/channels/weixin.mdx
Normal file
72
docs/en/channels/weixin.mdx
Normal file
@@ -0,0 +1,72 @@
|
||||
---
|
||||
title: WeChat
|
||||
description: Connect CowAgent to personal WeChat
|
||||
---
|
||||
|
||||
> Connect CowAgent to your personal WeChat. Simply scan a QR code to log in — no public IP required. Supports text, image, voice, file, and video messages.
|
||||
|
||||
## 1. Configuration
|
||||
|
||||
### Option A: Web Console
|
||||
|
||||
Start the program and open the Web console (local access: http://127.0.0.1:9899). Go to the **Channels** tab, click **Connect Channel**, select **WeChat**, and follow the prompts to scan the QR code.
|
||||
|
||||
### Option B: Config File
|
||||
|
||||
Set `channel_type` to `weixin` in your `config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_type": "weixin"
|
||||
}
|
||||
```
|
||||
|
||||
After starting the program, a QR code will be displayed in the terminal. Scan it with WeChat and confirm on your phone to complete login.
|
||||
|
||||
<Note>
|
||||
For backward compatibility, setting `channel_type` to `wx` also activates the WeChat channel.
|
||||
</Note>
|
||||
|
||||
## 2. Parameters
|
||||
|
||||
| Parameter | Description | Default |
|
||||
| --- | --- | --- |
|
||||
| `channel_type` | Set to `weixin` or `wx` | — |
|
||||
|
||||
Login credentials are automatically saved to `~/.weixin_cow_credentials.json`. To force a re-login, delete this file and restart.
|
||||
|
||||
## 3. Login
|
||||
|
||||
### QR Code Login
|
||||
|
||||
On first startup, a QR code is displayed in the terminal (valid for approximately 2 minutes). Scan it with WeChat and confirm on your phone.
|
||||
|
||||
- The QR code automatically refreshes when it expires
|
||||
- Install the `qrcode` Python package to render the QR code directly in the terminal: `pip3 install qrcode`
|
||||
|
||||
### Credential Persistence
|
||||
|
||||
After successful login, credentials are saved to `~/.weixin_cow_credentials.json`. Subsequent startups will reuse the saved credentials without requiring a new scan.
|
||||
|
||||
To force a re-login, delete the credentials file and restart the program.
|
||||
|
||||
### Session Expiry
|
||||
|
||||
When the WeChat session expires (errcode -14), the program automatically clears old credentials and initiates a new QR login — no manual intervention required.
|
||||
|
||||
## 4. Supported Features
|
||||
|
||||
| Feature | Status |
|
||||
| --- | --- |
|
||||
| Direct Messages | ✅ |
|
||||
| Text Messages | ✅ Send & Receive |
|
||||
| Image Messages | ✅ Send & Receive |
|
||||
| File Messages | ✅ Send & Receive |
|
||||
| Video Messages | ✅ Send & Receive |
|
||||
| Voice Messages | ✅ Receive |
|
||||
|
||||
## 5. Notes
|
||||
|
||||
1. Ensure network access to `ilinkai.weixin.qq.com`.
|
||||
2. Media files (images, files, videos) are transferred via CDN with AES-128-ECB encryption, handled automatically by the program.
|
||||
3. A stable network connection is recommended to avoid frequent disconnections that would require re-scanning.
|
||||
@@ -7,7 +7,7 @@ description: CowAgent - AI Super Assistant powered by LLMs
|
||||
|
||||
**CowAgent** is an AI super assistant powered by LLMs with autonomous task planning, long-term memory, skills system, multimodal messages, multiple model support, and multi-platform deployment.
|
||||
|
||||
CowAgent can proactively think and plan tasks, operate computers and external resources, create and execute Skills, and continuously grow with long-term memory. It supports flexible switching between multiple models, handles text, voice, images, files and other multimodal messages, and can be integrated into web, Feishu, DingTalk, WeCom, and WeChat Official Account. It runs 7x24 hours on your personal computer or server.
|
||||
CowAgent can proactively think and plan tasks, operate computers and external resources, create and execute Skills, and continuously grow with long-term memory. It supports flexible switching between multiple models, handles text, voice, images, files and other multimodal messages, and can be integrated into WeChat, web, Feishu, DingTalk, WeCom, and WeChat Official Account. It runs 7x24 hours on your personal computer or server.
|
||||
|
||||
<Card title="GitHub" icon="github" href="https://github.com/zhayujie/chatgpt-on-wechat">
|
||||
github.com/zhayujie/chatgpt-on-wechat
|
||||
@@ -31,8 +31,8 @@ CowAgent can proactively think and plan tasks, operate computers and external re
|
||||
<Card title="Multiple Model Support" icon="microchip" href="/en/models/index">
|
||||
Supports mainstream model providers including OpenAI, Claude, Gemini, DeepSeek, MiniMax, GLM, Qwen, Kimi, Doubao, and more.
|
||||
</Card>
|
||||
<Card title="Multi-platform Deployment" icon="server" href="/en/channels/web">
|
||||
Runs on local computers or servers, integrable into web, Feishu, DingTalk, WeChat Official Account, and WeCom applications.
|
||||
<Card title="Multi-platform Deployment" icon="server" href="/en/channels/weixin">
|
||||
Runs on local computers or servers, integrable into WeChat, web, Feishu, DingTalk, WeChat Official Account, and WeCom applications.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ description: CowAgent - 基于大模型的超级AI助理
|
||||
|
||||
**CowAgent** 是基于大模型的超级AI助理,能够主动思考和任务规划、操作计算机和外部资源、创造和执行Skills、拥有长期记忆并不断成长。
|
||||
|
||||
CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、文件等多模态消息,可接入网页、飞书、钉钉、企业微信应用、微信公众号中使用,7×24小时运行于你的个人电脑或服务器中。
|
||||
CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、文件等多模态消息,可接入微信、飞书、钉钉、企业微信应用、微信公众号、网页中使用,7×24小时运行于你的个人电脑或服务器中。
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="GitHub" icon="github" href="https://github.com/zhayujie/chatgpt-on-wechat">
|
||||
@@ -36,8 +36,8 @@ CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、
|
||||
<Card title="多模型接入" icon="microchip" href="/models/index">
|
||||
支持 OpenAI, Claude, Gemini, DeepSeek, MiniMax, GLM, Qwen, Kimi, Doubao 等国内外主流模型厂商。
|
||||
</Card>
|
||||
<Card title="多端部署" icon="server" href="/channels/web">
|
||||
支持运行在本地计算机或服务器,可集成到网页、飞书、钉钉、微信公众号、企业微信应用中使用。
|
||||
<Card title="多端部署" icon="server" href="/channels/weixin">
|
||||
支持运行在本地计算机或服务器,可集成到微信、网页、飞书、钉钉、微信公众号、企业微信应用中使用。
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -49,7 +49,7 @@ CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、
|
||||
bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh)
|
||||
```
|
||||
|
||||
运行后默认会启动 Web 服务,通过访问 `http://localhost:9899/chat` 在网页端对话。
|
||||
运行后默认会启动 Web 控制台,通过访问 `http://localhost:9899` 可以在网页端进行对话、配置、应用通道接入等操作。
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="快速开始" icon="rocket" href="/guide/quick-start">
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
[<a href="https://github.com/zhayujie/chatgpt-on-wechat/blob/master/README.md">中文</a>] | [<a href="https://github.com/zhayujie/chatgpt-on-wechat/blob/master/docs/en/README.md">English</a>] | [日本語]
|
||||
</p>
|
||||
|
||||
**CowAgent** はLLMを搭載したAIスーパーアシスタントです。自律的なタスク計画、コンピュータや外部リソースの操作、Skillの作成・実行、長期記憶による継続的な成長が可能です。柔軟なモデル切り替えに対応し、テキスト・音声・画像・ファイルを処理でき、Web、Feishu(飛書)、DingTalk(釘釘)、WeCom Bot(企業微信ボット)、WeComアプリ、WeChat公式アカウントに統合可能で、個人のPCやサーバー上で24時間365日稼働できます。
|
||||
**CowAgent** はLLMを搭載したAIスーパーアシスタントです。自律的なタスク計画、コンピュータや外部リソースの操作、Skillの作成・実行、長期記憶による継続的な成長が可能です。柔軟なモデル切り替えに対応し、テキスト・音声・画像・ファイルを処理でき、WeChat、Web、Feishu(飛書)、DingTalk(釘釘)、WeCom Bot(企業微信ボット)、WeComアプリ、WeChat公式アカウントに統合可能で、個人のPCやサーバー上で24時間365日稼働できます。
|
||||
|
||||
<p align="center">
|
||||
<a href="https://cowagent.ai/">🌐 ウェブサイト</a> ·
|
||||
@@ -25,7 +25,7 @@
|
||||
- ✅ **Skillシステム**: Skillの作成・実行エンジンを実装しており、複数の組み込みSkillを備え、自然言語での会話を通じたカスタムSkillの開発もサポートしています。
|
||||
- ✅ **マルチモーダルメッセージ**: テキスト、画像、音声、ファイルなど、さまざまなメッセージタイプの解析・処理・生成・送信に対応しています。
|
||||
- ✅ **複数モデル対応**: OpenAI、Claude、Gemini、DeepSeek、MiniMax、GLM、Qwen、Kimi、Doubaoなど、主要なモデルプロバイダーに対応しています。
|
||||
- ✅ **マルチプラットフォームデプロイ**: ローカルPCやサーバー上で実行でき、Web、Feishu、DingTalk、WeChat公式アカウント、WeComアプリケーションに統合可能です。
|
||||
- ✅ **マルチプラットフォームデプロイ**: ローカルPCやサーバー上で実行でき、WeChat、Web、Feishu、DingTalk、WeChat公式アカウント、WeComアプリケーションに統合可能です。
|
||||
- ✅ **ナレッジベース**: [LinkAI](https://link-ai.tech) プラットフォームを通じて、企業向けナレッジベース機能を統合できます。
|
||||
|
||||
## 免責事項
|
||||
@@ -163,6 +163,7 @@ Coding Planは各プロバイダーが提供する月額サブスクリプショ
|
||||
|
||||
| チャネル | `channel_type` | ドキュメント |
|
||||
| --- | --- | --- |
|
||||
| WeChat | `weixin` | [WeChat設定](https://docs.cowagent.ai/ja/channels/weixin) |
|
||||
| Web(デフォルト) | `web` | [Webチャネル](https://docs.cowagent.ai/en/channels/web) |
|
||||
| Feishu(飛書) | `feishu` | [Feishu設定](https://docs.cowagent.ai/en/channels/feishu) |
|
||||
| DingTalk(釘釘) | `dingtalk` | [DingTalk設定](https://docs.cowagent.ai/en/channels/dingtalk) |
|
||||
|
||||
72
docs/ja/channels/weixin.mdx
Normal file
72
docs/ja/channels/weixin.mdx
Normal file
@@ -0,0 +1,72 @@
|
||||
---
|
||||
title: WeChat
|
||||
description: CowAgent を個人の WeChat に接続する
|
||||
---
|
||||
|
||||
> 個人の WeChat に接続します。QR コードをスキャンするだけでログインでき、パブリック IP は不要です。テキスト、画像、音声、ファイル、動画メッセージの送受信に対応しています。
|
||||
|
||||
## 1. 設定
|
||||
|
||||
### 方法 A: Web コンソール
|
||||
|
||||
プログラムを起動し、Web コンソール(ローカルアクセス: http://127.0.0.1:9899)を開きます。**チャネル**タブに移動し、**チャネルを接続**をクリックして **WeChat** を選択し、プロンプトに従って QR コードをスキャンしてください。
|
||||
|
||||
### 方法 B: 設定ファイル
|
||||
|
||||
`config.json` で `channel_type` を `weixin` に設定します:
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_type": "weixin"
|
||||
}
|
||||
```
|
||||
|
||||
プログラム起動後、ターミナルに QR コードが表示されます。WeChat でスキャンし、スマートフォンで確認してログインを完了してください。
|
||||
|
||||
<Note>
|
||||
後方互換性のため、`channel_type` を `wx` に設定しても WeChat チャネルが有効になります。
|
||||
</Note>
|
||||
|
||||
## 2. パラメータ
|
||||
|
||||
| パラメータ | 説明 | デフォルト |
|
||||
| --- | --- | --- |
|
||||
| `channel_type` | `weixin` または `wx` を指定 | — |
|
||||
|
||||
ログイン認証情報は `~/.weixin_cow_credentials.json` に自動保存されます。再ログインするには、このファイルを削除してプログラムを再起動してください。
|
||||
|
||||
## 3. ログイン
|
||||
|
||||
### QR コードログイン
|
||||
|
||||
初回起動時に、ターミナルに QR コードが表示されます(有効期限は約 2 分)。WeChat でスキャンし、スマートフォンで確認してください。
|
||||
|
||||
- QR コードが期限切れになると自動的に更新・再表示されます
|
||||
- `qrcode` Python パッケージをインストールすると、ターミナルに直接 QR コードを表示できます:`pip3 install qrcode`
|
||||
|
||||
### 認証情報の永続化
|
||||
|
||||
ログイン成功後、認証情報は `~/.weixin_cow_credentials.json` に保存されます。次回起動時は保存された認証情報が再利用され、再スキャンは不要です。
|
||||
|
||||
再ログインするには、認証情報ファイルを削除してプログラムを再起動してください。
|
||||
|
||||
### セッションの期限切れ
|
||||
|
||||
WeChat セッションが期限切れになった場合(errcode -14)、プログラムは自動的に古い認証情報をクリアし、新しい QR ログインを開始します。手動での操作は不要です。
|
||||
|
||||
## 4. 対応機能
|
||||
|
||||
| 機能 | 状態 |
|
||||
| --- | --- |
|
||||
| ダイレクトメッセージ | ✅ |
|
||||
| テキストメッセージ | ✅ 送受信 |
|
||||
| 画像メッセージ | ✅ 送受信 |
|
||||
| ファイルメッセージ | ✅ 送受信 |
|
||||
| 動画メッセージ | ✅ 送受信 |
|
||||
| 音声メッセージ | ✅ 受信 |
|
||||
|
||||
## 5. 注意事項
|
||||
|
||||
1. `ilinkai.weixin.qq.com` へのネットワークアクセスが必要です。
|
||||
2. メディアファイル(画像、ファイル、動画)は CDN 経由で AES-128-ECB 暗号化を使用して転送され、プログラムが自動的に処理します。
|
||||
3. 頻繁な切断による再スキャンを避けるため、安定したネットワーク環境での実行を推奨します。
|
||||
@@ -7,7 +7,7 @@ description: CowAgent - LLM を活用した AI スーパーアシスタント
|
||||
|
||||
**CowAgent** は、自律的なタスク計画、長期記憶、Skill システム、マルチモーダルメッセージ、複数モデル対応、マルチプラットフォームデプロイを備えた、LLM を活用した AI スーパーアシスタントです。
|
||||
|
||||
CowAgent は自ら思考しタスクを計画し、コンピュータや外部リソースを操作し、Skill を作成・実行し、長期記憶により継続的に成長します。複数モデルの柔軟な切り替えをサポートし、テキスト、音声、画像、ファイルなどのマルチモーダルメッセージを処理でき、Web、Feishu(飛書)、DingTalk(釘釘)、WeCom(企業微信)、WeChat公式アカウントに統合できます。お使いのパソコンやサーバー上で24時間365日稼働します。
|
||||
CowAgent は自ら思考しタスクを計画し、コンピュータや外部リソースを操作し、Skill を作成・実行し、長期記憶により継続的に成長します。複数モデルの柔軟な切り替えをサポートし、テキスト、音声、画像、ファイルなどのマルチモーダルメッセージを処理でき、WeChat、Web、Feishu(飛書)、DingTalk(釘釘)、WeCom(企業微信)、WeChat公式アカウントに統合できます。お使いのパソコンやサーバー上で24時間365日稼働します。
|
||||
|
||||
<Card title="GitHub" icon="github" href="https://github.com/zhayujie/chatgpt-on-wechat">
|
||||
github.com/zhayujie/chatgpt-on-wechat
|
||||
@@ -31,8 +31,8 @@ CowAgent は自ら思考しタスクを計画し、コンピュータや外部
|
||||
<Card title="複数モデル対応" icon="microchip" href="/ja/models/index">
|
||||
OpenAI、Claude、Gemini、DeepSeek、MiniMax、GLM、Qwen、Kimi、Doubao など、主要なモデルプロバイダーをサポートしています。
|
||||
</Card>
|
||||
<Card title="マルチプラットフォームデプロイ" icon="server" href="/ja/channels/web">
|
||||
ローカルコンピュータやサーバー上で動作し、Web、Feishu(飛書)、DingTalk(釘釘)、WeChat公式アカウント、WeCom(企業微信)アプリケーションに統合できます。
|
||||
<Card title="マルチプラットフォームデプロイ" icon="server" href="/ja/channels/weixin">
|
||||
ローカルコンピュータやサーバー上で動作し、WeChat、Web、Feishu(飛書)、DingTalk(釘釘)、WeChat公式アカウント、WeCom(企業微信)アプリケーションに統合できます。
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ agentmesh-sdk>=0.1.3
|
||||
python-dotenv>=1.0.0
|
||||
PyYAML>=6.0
|
||||
croniter>=2.0.0
|
||||
qrcode
|
||||
|
||||
# wechatcom & wechatmp
|
||||
wechatpy
|
||||
|
||||
Reference in New Issue
Block a user