first commit
This commit is contained in:
16
.data/wechat/messages.jsonl
Normal file
16
.data/wechat/messages.jsonl
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{"id":"8176687339562140017","timestamp":"2026-06-09T06:25:33.164Z","type":0,"typeName":"Unknown","isText":false,"isRoom":false,"roomName":"","talkerName":"菜小猪","talkerAlias":"","receiverName":"菜小猪","text":"","self":true}
|
||||||
|
{"id":"6001436733549628695","timestamp":"2026-06-09T06:25:33.779Z","type":0,"typeName":"Unknown","isText":false,"isRoom":false,"roomName":"","talkerName":"菜小猪","talkerAlias":"","receiverName":"菜小猪","text":"","self":true}
|
||||||
|
{"id":"5974728366622606792","timestamp":"2026-06-09T06:25:33.780Z","type":0,"typeName":"Unknown","isText":false,"isRoom":false,"roomName":"","talkerName":"菜小猪","talkerAlias":"","receiverName":"菜小猪","text":"","self":true}
|
||||||
|
{"id":"4991830149134227925","timestamp":"2026-06-09T06:25:46.748Z","type":7,"typeName":"Text","isText":true,"isRoom":false,"roomName":"","talkerName":"AnShooter","talkerAlias":"","receiverName":"菜小猪","text":"你好啊","self":false}
|
||||||
|
{"id":"4480028056455094172","timestamp":"2026-06-09T06:25:59.073Z","type":7,"typeName":"Text","isText":true,"isRoom":false,"roomName":"","talkerName":"AnShooter","talkerAlias":"","receiverName":"菜小猪","text":"你会说话吗","self":false}
|
||||||
|
{"id":"1771518838726007766","timestamp":"2026-06-09T06:29:19.414Z","type":7,"typeName":"Text","isText":true,"isRoom":false,"roomName":"","talkerName":"AnShooter","talkerAlias":"","receiverName":"菜小猪","text":"你好啊","self":false}
|
||||||
|
{"id":"3554307033158000287","timestamp":"2026-06-09T06:29:32.316Z","type":7,"typeName":"Text","isText":true,"isRoom":false,"roomName":"","talkerName":"AnShooter","talkerAlias":"","receiverName":"菜小猪","text":"???","self":false}
|
||||||
|
{"id":"5224657246230665616","timestamp":"2026-06-09T06:29:50.240Z","type":0,"typeName":"Unknown","isText":false,"isRoom":false,"roomName":"","talkerName":"菜小猪","talkerAlias":"","receiverName":"菜小猪","text":"","self":true}
|
||||||
|
{"id":"2477581012569855746","timestamp":"2026-06-09T06:30:06.335Z","type":7,"typeName":"Text","isText":true,"isRoom":false,"roomName":"","talkerName":"AnShooter","talkerAlias":"","receiverName":"菜小猪","text":"!?","self":false}
|
||||||
|
{"id":"6196330599325256651","timestamp":"2026-06-09T06:36:53.739Z","type":0,"typeName":"Unknown","isText":false,"isRoom":false,"roomName":"","talkerName":"菜小猪","talkerAlias":"","receiverName":"菜小猪","text":"","self":true}
|
||||||
|
{"id":"5837753676322003487","timestamp":"2026-06-09T06:36:53.740Z","type":0,"typeName":"Unknown","isText":false,"isRoom":false,"roomName":"","talkerName":"菜小猪","talkerAlias":"","receiverName":"菜小猪","text":"","self":true}
|
||||||
|
{"id":"3492185411778314662","timestamp":"2026-06-09T06:36:59.966Z","type":7,"typeName":"Text","isText":true,"isRoom":false,"roomName":"","talkerName":"AnShooter","talkerAlias":"","receiverName":"菜小猪","text":"你好","self":false}
|
||||||
|
{"id":"8895573730091772400","timestamp":"2026-06-09T06:39:01.591Z","type":7,"typeName":"Text","isText":true,"isRoom":false,"roomName":"","talkerName":"AnShooter","talkerAlias":"","receiverName":"菜小猪","text":"啊","self":false}
|
||||||
|
{"id":"3571933861420079183","timestamp":"2026-06-09T06:39:02.246Z","type":0,"typeName":"Unknown","isText":false,"isRoom":false,"roomName":"","talkerName":"菜小猪","talkerAlias":"","receiverName":"菜小猪","text":"","self":true}
|
||||||
|
{"id":"8010648486659081543","timestamp":"2026-06-09T06:39:02.246Z","type":0,"typeName":"Unknown","isText":false,"isRoom":false,"roomName":"","talkerName":"菜小猪","talkerAlias":"","receiverName":"菜小猪","text":"","self":true}
|
||||||
|
{"id":"6422080270206561680","timestamp":"2026-06-09T06:43:24.642Z","type":7,"typeName":"Text","isText":true,"isRoom":false,"roomName":"","talkerName":"AnShooter","talkerAlias":"","receiverName":"菜小猪","text":"啊","self":false}
|
||||||
2
.dockerignore
Normal file
2
.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
node_modules/
|
||||||
130
.env.example
Normal file
130
.env.example
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
# .env
|
||||||
|
|
||||||
|
# OpenAi 的api key, 去 https://beta.openai.com/account/api-keys 中生成一个即可,OPENAI_MODEL不填则默认gpt-4o,OPENAI_SYSTEM_MESSAGE为默认角色设定
|
||||||
|
OPENAI_API_KEY=''
|
||||||
|
OPENAI_PROXY_URL='https://openai.xxxx.com/v1/'
|
||||||
|
OPENAI_MODEL=''
|
||||||
|
OPENAI_SYSTEM_MESSAGE='You are a personal assistant.'
|
||||||
|
|
||||||
|
# doubao, model和api-key, 去 https://console.volcengine.com/ark/apiKey
|
||||||
|
DOUBAO_API_KEY=''
|
||||||
|
DOUBAO_URL="https://ark.cn-beijing.volces.com/api/v3"
|
||||||
|
DOUBAO_MODEL='doubao-seed-1-6-thinking-250615'
|
||||||
|
|
||||||
|
# deepseek, model和api-key, 去 https://platform.deepseek.com/usage 或者 https://cloud.siliconflow.cn/models (deepseek官方api暂时停止使用,可以用siliconflow的api)
|
||||||
|
DEEPSEEK_API_KEY=''
|
||||||
|
DEEPSEEK_URL="https://api.siliconflow.cn/v1"
|
||||||
|
DEEPSEEK_MODEL='deepseek-ai/DeepSeek-R1'
|
||||||
|
DEEPSEEK_SYSTEM_MESSAGE='# 角色定义
|
||||||
|
role: "AI Assistant (DeepSeek-R1-Enhanced)"
|
||||||
|
author: "DeepSeek"
|
||||||
|
description: >
|
||||||
|
通用型智能助手,通过结构化思考流程提供可靠服务,
|
||||||
|
知识截止2023年12月,不处理实时信息。
|
||||||
|
# 交互协议
|
||||||
|
interaction_rules:
|
||||||
|
thinking_flow: # 新增思考流程规范
|
||||||
|
- 步骤1: 问题语义解析(意图/实体/上下文)
|
||||||
|
- 步骤2: 知识库匹配(学科分类/可信度评估)
|
||||||
|
- 步骤3: 逻辑验证(矛盾检测/边界检查)
|
||||||
|
- 步骤4: 响应结构设计(分点/示例/注意事项)
|
||||||
|
safety_layer:
|
||||||
|
- 自动激活场景: [政治, 医疗建议, 隐私相关]
|
||||||
|
- 响应模板: "该问题涉及[领域],建议咨询专业机构"
|
||||||
|
# 输出规范
|
||||||
|
output_schema:
|
||||||
|
thinking_section: # 强制思考段落
|
||||||
|
required: true
|
||||||
|
tags: "思考内容:{content}
|
||||||
|
"
|
||||||
|
content_rules:
|
||||||
|
- 使用Markdown列表格式
|
||||||
|
- 包含至少2个验证步骤
|
||||||
|
- 标注潜在不确定性
|
||||||
|
- 复杂概念使用类比解释'
|
||||||
|
|
||||||
|
# Kimi 的api key, 去 https://platform.moonshot.cn/console/api-keys
|
||||||
|
KIMI_API_KEY=''
|
||||||
|
|
||||||
|
# 科大讯飞, 去 https://console.xfyun.cn/services
|
||||||
|
XUNFEI_APP_ID=''
|
||||||
|
XUNFEI_API_KEY=''
|
||||||
|
XUNFEI_API_SECRET=''
|
||||||
|
# 使用的模型版本,默认填写 v4.0 或需要的版本号(如: v3.5, max-32k, pro-128k),参考src/xunfei.js中modelVersionMap
|
||||||
|
XUNFEI_MODEL_VERSION='v4.0'
|
||||||
|
# 系统角色描述,支持个性化定制
|
||||||
|
XUNFEI_PROMPT='你是一个专业的智能助手,能够回答用户提出的各种问题。'
|
||||||
|
|
||||||
|
# deepseek-free, model必须为deepseek-chat或deepseek-coder,去 https://platform.deepseek.com/usage或者https://github.com/LLM-Red-Team/deepseek-free-api
|
||||||
|
# 在DEEPSEEK_SYSTEM_MESSAGE中设置系统提示词
|
||||||
|
DEEPSEEK_FREE_URL='https://api.deepseek.com/chat/completions'
|
||||||
|
DEEPSEEK_FREE_TOKEN=''
|
||||||
|
DEEPSEEK_FREE_MODEL='deepseek-chat'
|
||||||
|
DEEPSEEK_FREE_SYSTEM_MESSAGE='You are a personal assistant.'
|
||||||
|
|
||||||
|
# 302AI
|
||||||
|
_302AI_API_KEY = ''
|
||||||
|
_302AI_MODEL= 'gpt-4o-mini'
|
||||||
|
|
||||||
|
# dify, URL不包含uri路径
|
||||||
|
DIFY_API_KEY = ''
|
||||||
|
DIFY_URL = 'https://api.dify.ai'
|
||||||
|
|
||||||
|
# 通义千问, URL 包含 uri 路径
|
||||||
|
TONGYI_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||||
|
|
||||||
|
# 通义千问的 API_KEY
|
||||||
|
TONGYI_API_KEY = ''
|
||||||
|
|
||||||
|
# 通义千问使用的模型
|
||||||
|
TONGYI_MODEL='qwen-plus'
|
||||||
|
|
||||||
|
# claude
|
||||||
|
CLAUDE_API_VERSION = '2023-06-01'
|
||||||
|
CLAUDE_API_KEY = ''
|
||||||
|
CLAUDE_MODEL = 'claude-sonnet-4-5-20250929'
|
||||||
|
CLAUDE_BASE_URL = 'https://api.anthropic.com/v1/messages'
|
||||||
|
# 系统人设
|
||||||
|
CLAUDE_SYSTEM = ''
|
||||||
|
|
||||||
|
# ollama
|
||||||
|
OLLAMA_URL='http://127.0.0.1:11434/api/chat'
|
||||||
|
OLLAMA_MODEL=''
|
||||||
|
OLLAMA_SYSTEM_MESSAGE='You are a personal assistant.'
|
||||||
|
|
||||||
|
# 白名单配置
|
||||||
|
#定义机器人的名称,这里是为了防止群聊消息太多,所以只有艾特机器人才会回复,
|
||||||
|
#这里不要把@去掉,在@后面加上你启动机器人账号的微信名称
|
||||||
|
BOT_NAME='@可乐'
|
||||||
|
#联系人白名单
|
||||||
|
ALIAS_WHITELIST='微信名1,备注名2'
|
||||||
|
#群聊白名单
|
||||||
|
ROOM_WHITELIST='XX群1,群2'
|
||||||
|
#自动回复前缀匹配,文本消息匹配到指定前缀时,才会触发自动回复,不配或配空串情况下该配置不生效(适用于用大号,不期望每次被@或者私聊时都触发自动回复的人群)
|
||||||
|
#匹配规则:群聊消息去掉${BOT_NAME}并trim后进行前缀匹配,私聊消息trim后直接进行前缀匹配
|
||||||
|
AUTO_REPLY_PREFIX=''
|
||||||
|
|
||||||
|
# 默认服务 302AI,ChatGPT、Kimi、Xunfei、deepseek-free, ollama, dify, tongyi 八选一,不填则键盘交互
|
||||||
|
SERVICE_TYPE=''
|
||||||
|
|
||||||
|
# 本地微信消息捕获与命令
|
||||||
|
# 默认只记录扫码登录后收到的消息,用于后续本地统计/分析;设为 false 可关闭记录
|
||||||
|
WECHAT_DATA_DIR='.data/wechat'
|
||||||
|
WECHAT_STORE_MESSAGES='true'
|
||||||
|
BOT_COMMAND_PREFIX='/'
|
||||||
|
# 出于安全考虑,微信聊天中远程执行 OpenCLI 默认关闭;仅在你确认需要时开启
|
||||||
|
ENABLE_REMOTE_OPENCLI='false'
|
||||||
|
|
||||||
|
# 飞书 IM 通过 lark-cli 接入。首次使用可执行:npm run lark:login
|
||||||
|
LARK_CLI_BIN='lark-cli'
|
||||||
|
LARK_DEFAULT_IDENTITY='user'
|
||||||
|
|
||||||
|
# OpenCLI 透传。留空时会使用 npx --yes @jackwener/opencli
|
||||||
|
OPENCLI_BIN=''
|
||||||
|
OPENCLI_NPM_PACKAGE='@jackwener/opencli'
|
||||||
|
|
||||||
|
# Pi coding agent 透传。留空时会使用 npx --yes @earendil-works/pi-coding-agent
|
||||||
|
PI_BIN=''
|
||||||
|
PI_NPM_PACKAGE='@earendil-works/pi-coding-agent'
|
||||||
|
# Pi 作为 IM 回复 agent 时使用的参数。默认非交互、单轮回复。
|
||||||
|
PI_AGENT_ARGS='--print --no-session'
|
||||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
WechatEveryDay.memory-card.json
|
||||||
|
.env
|
||||||
|
test.js
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
Chromium.app
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
.idea
|
||||||
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
|||||||
|
npx lint-staged
|
||||||
1
.npmrc
Normal file
1
.npmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# puppeteer_download_host=https://registry.npmmirror.com/-/binary/
|
||||||
23
.prettierrc.cjs
Normal file
23
.prettierrc.cjs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* 参考 https://prettier.io/docs/en/options.html
|
||||||
|
*/
|
||||||
|
module.exports = {
|
||||||
|
tabWidth: 2, // 空格数
|
||||||
|
useTabs: false, // 是否开启tab
|
||||||
|
printWidth: 150, // 换行的宽度
|
||||||
|
semi: false, // 是否在语句末尾打印分号
|
||||||
|
singleQuote: true, // 是否使用单引号
|
||||||
|
quoteProps: 'as-needed', // 对象的key仅在需要时用引号 as-needed|consistent|preserve
|
||||||
|
trailingComma: 'all', // 多行时尽可能打印尾随逗号 |all|es5|none
|
||||||
|
rangeStart: 0, // 每个文件格式化的范围是文件的全部内容
|
||||||
|
bracketSpacing: true, // 对象文字中的括号之间打印空格
|
||||||
|
jsxSingleQuote: true, // 在JSX中是否使用单引号
|
||||||
|
bracketSameLine: false, // 将HTML元素的闭括号放在最后一行的末尾(不适用于自闭合元素)。
|
||||||
|
arrowParens: 'always', // 箭头函数,只有一个参数的时候,也需要括号 always|avoid
|
||||||
|
htmlWhitespaceSensitivity: 'ignore', // html中换行规则 css|strict|ignore,strict会强制在标签周围添加空格
|
||||||
|
vueIndentScriptAndStyle: false, // vue中script与style里的第一条语句是否空格
|
||||||
|
singleAttributePerLine: false, // 每行强制单个属性
|
||||||
|
endOfLine: 'lf', // 换行符
|
||||||
|
proseWrap: 'never', // 当超出print width时就折行 always|never|preserve .md文件?
|
||||||
|
embeddedLanguageFormatting: 'auto',
|
||||||
|
}
|
||||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"cSpell.words": ["kimi", "xunfei"]
|
||||||
|
}
|
||||||
58
Dockerfile
Normal file
58
Dockerfile
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
ARG APT_SOURCE="default"
|
||||||
|
|
||||||
|
FROM node:19 as builder-default
|
||||||
|
ENV NPM_REGISTRY="https://registry.npmjs.org"
|
||||||
|
|
||||||
|
FROM node:19 as builder-aliyun
|
||||||
|
|
||||||
|
ENV NPM_REGISTRY="https://registry.npmmirror.com"
|
||||||
|
RUN sed -i s/deb.debian.org/mirrors.aliyun.com/g /etc/apt/sources.list \
|
||||||
|
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && echo 'Asia/Shanghai' >/etc/timezone
|
||||||
|
|
||||||
|
FROM builder-${APT_SOURCE} AS builder
|
||||||
|
# Instal the 'apt-utils' package to solve the error 'debconf: delaying package configuration, since apt-utils is not installed'
|
||||||
|
# https://peteris.rocks/blog/quiet-and-unattended-installation-with-apt-get/
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
apt-utils \
|
||||||
|
autoconf \
|
||||||
|
automake \
|
||||||
|
bash \
|
||||||
|
build-essential \
|
||||||
|
ca-certificates \
|
||||||
|
chromium \
|
||||||
|
coreutils \
|
||||||
|
curl \
|
||||||
|
ffmpeg \
|
||||||
|
figlet \
|
||||||
|
git \
|
||||||
|
gnupg2 \
|
||||||
|
jq \
|
||||||
|
libgconf-2-4 \
|
||||||
|
libtool \
|
||||||
|
libxtst6 \
|
||||||
|
moreutils \
|
||||||
|
python-dev \
|
||||||
|
shellcheck \
|
||||||
|
sudo \
|
||||||
|
tzdata \
|
||||||
|
vim \
|
||||||
|
wget \
|
||||||
|
&& apt-get purge --auto-remove \
|
||||||
|
&& rm -rf /tmp/* /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
FROM builder
|
||||||
|
|
||||||
|
ENV CHROME_BIN="/usr/bin/chromium" \
|
||||||
|
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD="true"
|
||||||
|
|
||||||
|
RUN mkdir -p /app
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm config set registry ${NPM_REGISTRY} && npm i
|
||||||
|
|
||||||
|
COPY *.js ./
|
||||||
|
COPY src/ ./src/
|
||||||
|
|
||||||
|
CMD ["npm", "run", "dev"]
|
||||||
45
Dockerfile.alpine
Normal file
45
Dockerfile.alpine
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
ARG APT_SOURCE="default"
|
||||||
|
|
||||||
|
FROM node:19-alpine as base
|
||||||
|
RUN apk update && \
|
||||||
|
apk upgrade && \
|
||||||
|
apk add --no-cache bash \
|
||||||
|
ca-certificates \
|
||||||
|
chromium-chromedriver \
|
||||||
|
chromium \
|
||||||
|
coreutils \
|
||||||
|
curl \
|
||||||
|
ffmpeg \
|
||||||
|
figlet \
|
||||||
|
jq \
|
||||||
|
moreutils \
|
||||||
|
ttf-freefont \
|
||||||
|
udev \
|
||||||
|
vim \
|
||||||
|
xauth \
|
||||||
|
xvfb \
|
||||||
|
&& rm -rf /tmp/* /var/cache/apk/*
|
||||||
|
|
||||||
|
|
||||||
|
FROM base as builder-default
|
||||||
|
ENV NPM_REGISTRY="https://registry.npmjs.org"
|
||||||
|
|
||||||
|
FROM base as builder-aliyun
|
||||||
|
ENV NPM_REGISTRY="https://registry.npmmirror.com"
|
||||||
|
|
||||||
|
|
||||||
|
FROM builder-${APT_SOURCE}
|
||||||
|
|
||||||
|
ENV CHROME_BIN="/usr/bin/chromium-browser" \
|
||||||
|
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD="true"
|
||||||
|
|
||||||
|
RUN mkdir -p /app
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm config set registry ${NPM_REGISTRY} && npm i
|
||||||
|
|
||||||
|
COPY *.js ./
|
||||||
|
COPY src/ ./src/
|
||||||
|
|
||||||
|
CMD ["npm", "run", "dev"]
|
||||||
9
LICENSE.md
Normal file
9
LICENSE.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2020-present, 荣顶 and wechat-bot contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
526
README.md
Normal file
526
README.md
Normal file
@@ -0,0 +1,526 @@
|
|||||||
|
# WeChat Bot
|
||||||
|
|
||||||
|
一个基于 `Wechaty` 的微信 / IM agent 项目。
|
||||||
|
|
||||||
|
它可以把微信扫码登录后的 IM 消息交给 ChatGPT、DeepSeek、Ollama、Claude、Pi 等服务处理;也可以通过 OpenCLI 的 `wx-cli` 访问本机微信聊天、联系人、群成员、收藏、朋友圈缓存,并对群聊或某个好友做统计和分析。飞书 IM 目前提供登录、读消息、搜消息和发消息的 CLI 通道。
|
||||||
|
|
||||||
|
如果你希望把 Pi 作为本项目的 agent,用微信作为外部通信渠道,直接看:[Pi Agent + IM 使用说明](./docs/pi-im-agent.md)。
|
||||||
|
|
||||||
|
## 能力概览
|
||||||
|
|
||||||
|
| 能力 | 命令入口 | 当前状态 |
|
||||||
|
| ------------------------------ | ------------------------------------------------------------ | -------------------------------------------- |
|
||||||
|
| 微信扫码 IM | `wb agent --im wechat --agent pi` / `wb start --serve pi` | 已接入,可扫码登录并回复白名单消息 |
|
||||||
|
| Pi 作为项目 agent | `wb agent --im wechat --agent pi` | 已接入,默认单轮非交互回复 |
|
||||||
|
| 本地微信聊天 / 联系人 / 群成员 | `wb wx sessions`、`wb wx history`、`wb wx members` | 通过 OpenCLI `wx-cli` 接入 |
|
||||||
|
| 本地朋友圈缓存 | `wb wx sns-feed`、`wb wx sns-search` | 通过 OpenCLI `wx-cli` 接入 |
|
||||||
|
| 群 / 好友分析 | `wb analyze --room "群名"`、`wb analyze --friend "好友备注"` | 支持本地统计和 AI 深度分析 |
|
||||||
|
| 飞书 IM | `wb lark login`、`wb lark messages`、`wb lark send` | 支持登录、读、搜、发;暂未做实时事件自动回复 |
|
||||||
|
| 多模型回复 | `--serve ChatGPT/deepseek/ollama/pi/...` | 复用现有 provider 机制 |
|
||||||
|
|
||||||
|
## 快速开始:Pi + 微信 IM
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm i
|
||||||
|
cp .env.example .env
|
||||||
|
npm link
|
||||||
|
```
|
||||||
|
|
||||||
|
在 `.env` 中至少配置:
|
||||||
|
|
||||||
|
```env
|
||||||
|
BOT_NAME='@你的微信昵称'
|
||||||
|
ALIAS_WHITELIST='允许私聊你的好友备注'
|
||||||
|
ROOM_WHITELIST='允许接入的群名'
|
||||||
|
|
||||||
|
PI_BIN='pi'
|
||||||
|
PI_AGENT_ARGS='--print --no-session'
|
||||||
|
WECHAT_STORE_MESSAGES='true'
|
||||||
|
```
|
||||||
|
|
||||||
|
启动:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
wb agent --im wechat --agent pi
|
||||||
|
```
|
||||||
|
|
||||||
|
终端出现二维码后,用微信扫码。消息链路是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
微信扫码登录 -> Wechaty 收消息 -> 本地 JSONL 捕获 -> Pi agent 回复 -> 微信 IM 发回
|
||||||
|
```
|
||||||
|
|
||||||
|
触发规则:
|
||||||
|
|
||||||
|
- 私聊:好友备注或昵称需要在 `ALIAS_WHITELIST`。
|
||||||
|
- 群聊:群名需要在 `ROOM_WHITELIST`,并且消息里需要 `@BOT_NAME`。
|
||||||
|
- 非文本消息不会自动进入回复链路。
|
||||||
|
|
||||||
|
> 注意:微信 Web 协议存在风控和封号风险。请只在你明确接受风险的账号和场景中使用,优先控制白名单和使用范围。
|
||||||
|
|
||||||
|
<div align='center'>
|
||||||
|
<a href="https://trendshift.io/repositories/11077" target="_blank"><img src="https://trendshift.io/api/badge/repositories/11077" alt="wangrongding%2Fwechat-bot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## 贡献者们
|
||||||
|
|
||||||
|
<a href="https://github.com/wangrongding/wechat-bot/graphs/contributors">
|
||||||
|
<img src="https://contrib.rocks/image?repo=wangrongding/wechat-bot&columns=20" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
欢迎大家提交 PR 接入更多的 ai 服务(比如扣子等...),积极贡献更好的功能实现,让 wechat-bot 变得更强!
|
||||||
|
|
||||||
|
## 注意:最近微信对此审查变得非常严格,使用默认的协议有微信警告或者封号的风险,请大家谨慎使用,关于 padlocal ,这个协议的作者没有继续维护,大家可以自行切换更稳定的协议。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 支持的回复 / Agent 服务
|
||||||
|
|
||||||
|
如果只使用 `wb wx ...` 访问本地微信数据,或只使用 `wb lark ...` 操作飞书 IM,可以不配置大模型。
|
||||||
|
|
||||||
|
如果要让微信消息自动回复,或执行 `wb analyze` 深度分析,需要选择一个 `--serve` 服务。当前可选:`ChatGPT`、`doubao`、`deepseek`、`Kimi`、`Xunfei`、`deepseek-free`、`302AI`、`dify`、`ollama`、`tongyi`、`claude`、`pi`。
|
||||||
|
|
||||||
|
- pi
|
||||||
|
|
||||||
|
Pi 适合作为项目 agent 使用,可通过微信 IM 对外通信:
|
||||||
|
|
||||||
|
```env
|
||||||
|
PI_BIN='pi'
|
||||||
|
PI_NPM_PACKAGE='@earendil-works/pi-coding-agent'
|
||||||
|
PI_AGENT_ARGS='--print --no-session'
|
||||||
|
```
|
||||||
|
|
||||||
|
如果本机没有全局 `pi` 命令,可以先把 `PI_BIN` 留空,项目会通过 `npx --yes @earendil-works/pi-coding-agent` 调起 Pi。
|
||||||
|
|
||||||
|
- deepseek
|
||||||
|
|
||||||
|
获取自己的 `api key`,地址戳这里 👉🏻 :[deepseek 开放平台](https://platform.deepseek.com/usage)
|
||||||
|
将获取到的`api key`填入 `.evn` 文件中的 `DEEPSEEK_FREE_TOKEN` 中。
|
||||||
|
|
||||||
|
- ChatGPT
|
||||||
|
|
||||||
|
先获取自己的 `api key`,地址戳这里 👉🏻 :[创建你的 api key](https://platform.openai.com/settings/organization/api-keys)
|
||||||
|
|
||||||
|
**注意:这个是需要去付费购买的,很多人过来问为什么请求不通,请确保终端走了代理,并且付费购买了它的服务**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 执行下面命令,拷贝一份 .env.example 文件为 .env
|
||||||
|
cp .env.example .env
|
||||||
|
# 填写完善 .env 文件中的内容
|
||||||
|
OPENAI_API_KEY='你的key'
|
||||||
|
```
|
||||||
|
|
||||||
|
- 豆包
|
||||||
|
|
||||||
|
豆包最新的Doubao-Seed-1.6模型,支持输入图片和深度思考,而且每个模型都有 50 万的免费tokens。在火山引擎注册登录账号,可以选择最新的Doubao-Seed-1.6-thinking模型,选择“API接入” -> “获取 API Key”。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 拷贝 .env.example 文件为 .env
|
||||||
|
cp .env.example .env
|
||||||
|
# 修改 .env 文件中的内容
|
||||||
|
DOUBAO_API_KEY='你的key'
|
||||||
|
# 简单测试API是否可用
|
||||||
|
node src/doubao/__test__.js
|
||||||
|
```
|
||||||
|
|
||||||
|
- 通义千问
|
||||||
|
|
||||||
|
通义千问是阿里云提供的 AI 服务,获取到你的 api key 之后, 填写到 .env 文件中即可
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 执行下面命令,拷贝一份 .env.example 文件为 .env
|
||||||
|
cp .env.example .env
|
||||||
|
# 填写完善 .env 文件中的内容
|
||||||
|
# 通义千问, URL 包含 uri 路径
|
||||||
|
TONGYI_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||||
|
# 通义千问的 API_KEY
|
||||||
|
TONGYI_API_KEY = ''
|
||||||
|
# 通义千问使用的模型
|
||||||
|
TONGYI_MODEL='qwen-plus'
|
||||||
|
```
|
||||||
|
|
||||||
|
- 科大讯飞
|
||||||
|
|
||||||
|
新增科大讯飞,去这里申请一个 key:[科大讯飞](https://console.xfyun.cn/services/bm35),每个模型都有 200 万的免费 token ,感觉很难用完。
|
||||||
|
注意: 讯飞的配置文件几个 key,别填反了,很多人找到我说为什么不回复,都是填反了。
|
||||||
|
而且还有一个好处就是,接口不会像 Kimi 一样限制请求频次,相对来说稳定很多。
|
||||||
|
服务出错可参考: [issues/170](https://github.com/wangrongding/wechat-bot/issues/170), [issues/180](https://github.com/wangrongding/wechat-bot/issues/180)
|
||||||
|
|
||||||
|
- Kimi (请求限制较严重)
|
||||||
|
|
||||||
|
可以去 : [kimi apikey](https://platform.moonshot.cn/console/api-keys) 获取你的 key
|
||||||
|
最近比较忙,大家感兴趣可以提交 PR,我会尽快合并。目前 Kimi 刚刚集成,还可以实现上传文件等功能,然后有其它较好的服务也可以提交 PR 。
|
||||||
|
|
||||||
|
- dify
|
||||||
|
|
||||||
|
地址:[dify](https://dify.ai/), 创建你的应用之后, 获取到你的 api key 之后, 填写到 .env 文件中即可, 也支持私有化部署dify版本
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 执行下面命令,拷贝一份 .env.example 文件为 .env
|
||||||
|
cp .env.example .env
|
||||||
|
# 填写完善 .env 文件中的内容
|
||||||
|
DIFY_API_KEY='你的key'
|
||||||
|
# 如果需要私有化部署,请修改.env中下面的配置
|
||||||
|
# DIFY_URL='https://[你的私有化部署地址]'
|
||||||
|
```
|
||||||
|
|
||||||
|
- ollama
|
||||||
|
|
||||||
|
Ollama 是一个本地化的 AI 服务,它的 API 与 OpenAI 非常接近。配置 Ollama 的过程与各种在线服务略有不同
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 执行下面命令,拷贝一份 .env.example 文件为 .env
|
||||||
|
cp .env.example .env
|
||||||
|
# 填写完善 .env 文件中的内容
|
||||||
|
OLLAMA_URL='http://127.0.0.1:11434/api/chat'
|
||||||
|
OLLAMA_MODEL='qwen2.5:7b'
|
||||||
|
OLLAMA_SYSTEM_MESSAGE='You are a personal assistant.'
|
||||||
|
```
|
||||||
|
|
||||||
|
- 302.AI
|
||||||
|
|
||||||
|
AI聚合平台,有套壳GPT的API,也有其他模型,点这里可以[添加API](https://dash.302.ai/apis/list),添加之后把API KEY配置到.env里,如下,MODEL可以自行选择配置
|
||||||
|
|
||||||
|
```
|
||||||
|
_302AI_API_KEY = 'xxxx'
|
||||||
|
_302AI_MODEL= 'gpt-4o-mini'
|
||||||
|
```
|
||||||
|
|
||||||
|
由于openai充值需要国外信用卡,流程比较繁琐,大多需要搞国外虚拟卡,手续费也都不少,该平台可以直接支付宝,算是比较省事的,注册填问卷可领1刀额度,后续充值也有手续费,用户可自行酌情选择。
|
||||||
|
|
||||||
|
- claude
|
||||||
|
|
||||||
|
前往 [官网](https://console.anthropic.com) 注册并获取API KEY后进行配置即可
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 执行下面命令,拷贝一份 .env.example 文件为 .env,如果已存在则忽略此步
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# 编辑.env文件并添加claude相关配置
|
||||||
|
|
||||||
|
CLAUDE_API_VERSION = '2023-06-01'
|
||||||
|
CLAUDE_API_KEY = '你的API KEY'
|
||||||
|
CLAUDE_MODEL = 'claude-3-5-sonnet-latest'
|
||||||
|
# 系统人设
|
||||||
|
CLAUDE_SYSTEM = ''
|
||||||
|
```
|
||||||
|
|
||||||
|
- 其他
|
||||||
|
(待实践)理论上使用 openAI 格式的 api,都可以使用,在 env 文件中修改对应的 api_key、model、proxy_url 即可。
|
||||||
|
|
||||||
|
## API资源/平台收录
|
||||||
|
|
||||||
|
- [gpt4free](https://github.com/xtekky/gpt4free)
|
||||||
|
- [chatanywhere](https://github.com/chatanywhere/GPT_API_free)
|
||||||
|
|
||||||
|
## 赞助商
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<table>
|
||||||
|
<!-- Header -->
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://api.shenfengwl.fun/" target="_blank">
|
||||||
|
<img src="./sponsors/shenfengwl.png" alt="深风网络" width="500px"/>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Description -->
|
||||||
|
<tr>
|
||||||
|
<td align="left">
|
||||||
|
主营海外主流大模型中转聚合API平台,高效稳定,高并发,价格超低
|
||||||
|
<a href="https://api.shenfengwl.fun/" target="_blank">产品链接</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
目前该项目流量较大,已经上过 27 次 [Github Trending 榜](https://github.com/trending),如果您的公司或者产品需要推广,可以在下方二维码处联系我,我会在项目中加入您的广告,帮助您的产品获得更多的曝光。
|
||||||
|
|
||||||
|
## 开发/使用
|
||||||
|
|
||||||
|
检查好自己的开发环境,确保已经安装了 `nodejs` , 版本需要满足 Node.js >= v18.0 ,版本太低会导致运行报错,最好使用 LTS 版本。
|
||||||
|
|
||||||
|
### 1. 安装依赖
|
||||||
|
|
||||||
|
> 安装依赖时,大陆的朋友推荐切到 taobao 镜像源后再安装,命令:
|
||||||
|
> `npm config set registry https://registry.npmmirror.com`
|
||||||
|
> 想要灵活切换,推荐使用我的工具 👉🏻 [prm-cli](https://github.com/wangrongding/prm-cli) 快速切换。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm i
|
||||||
|
|
||||||
|
# 可选:把 wb 注册成本机命令
|
||||||
|
npm link
|
||||||
|
```
|
||||||
|
|
||||||
|
如果不想执行 `npm link`,下文所有 `wb ...` 都可以替换为:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run start -- ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 配置 `.env`
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
最小可用配置:
|
||||||
|
|
||||||
|
```env
|
||||||
|
BOT_NAME='@你的微信昵称'
|
||||||
|
ALIAS_WHITELIST='好友备注1,好友昵称2'
|
||||||
|
ROOM_WHITELIST='群名1,群名2'
|
||||||
|
AUTO_REPLY_PREFIX=''
|
||||||
|
|
||||||
|
WECHAT_DATA_DIR='.data/wechat'
|
||||||
|
WECHAT_STORE_MESSAGES='true'
|
||||||
|
|
||||||
|
PI_BIN='pi'
|
||||||
|
PI_AGENT_ARGS='--print --no-session'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 启动微信 IM
|
||||||
|
|
||||||
|
Pi agent 模式:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
wb agent --im wechat --agent pi
|
||||||
|
```
|
||||||
|
|
||||||
|
等价写法:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
wb start --serve pi
|
||||||
|
npm run agent
|
||||||
|
npm run start -- start --serve pi
|
||||||
|
```
|
||||||
|
|
||||||
|
传统模型回复模式:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
wb start --serve ollama
|
||||||
|
wb start --serve ChatGPT
|
||||||
|
wb start --serve deepseek
|
||||||
|
```
|
||||||
|
|
||||||
|
启动后终端会展示二维码,扫码即可登录微信。登录后,收到的微信消息会追加写入:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.data/wechat/messages.jsonl
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 本地微信数据和朋友圈
|
||||||
|
|
||||||
|
OpenCLI 的 `wx-cli` 会被 `wb wx ...` 透传调用,用于访问本机微信缓存:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
wb wx init
|
||||||
|
wb wx sessions
|
||||||
|
wb wx history
|
||||||
|
wb wx search
|
||||||
|
wb wx contacts
|
||||||
|
wb wx members
|
||||||
|
wb wx stats
|
||||||
|
wb wx favorites
|
||||||
|
wb wx sns-feed
|
||||||
|
wb wx sns-search
|
||||||
|
wb wx sns-notifications
|
||||||
|
wb wx help
|
||||||
|
```
|
||||||
|
|
||||||
|
常用场景:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 初始化本地微信数据访问
|
||||||
|
wb wx init
|
||||||
|
|
||||||
|
# 查看最近会话和聊天记录
|
||||||
|
wb wx sessions
|
||||||
|
wb wx history
|
||||||
|
|
||||||
|
# 查看群成员和聊天统计
|
||||||
|
wb wx members
|
||||||
|
wb wx stats
|
||||||
|
|
||||||
|
# 查看朋友圈缓存和朋友圈全文搜索
|
||||||
|
wb wx sns-feed
|
||||||
|
wb wx sns-search
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 群聊 / 好友分析
|
||||||
|
|
||||||
|
命令行分析:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 只做本地统计,不调用 AI
|
||||||
|
wb analyze --room "群名" --stats-only
|
||||||
|
wb analyze --friend "好友备注" --stats-only
|
||||||
|
|
||||||
|
# 调用指定服务做深度分析
|
||||||
|
wb analyze --room "群名" --serve pi
|
||||||
|
wb analyze --friend "好友备注" --serve ollama
|
||||||
|
```
|
||||||
|
|
||||||
|
微信聊天中的内置命令默认只对联系人白名单或群聊白名单生效:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/统计 群 XX群1
|
||||||
|
/分析 好友 好友备注
|
||||||
|
```
|
||||||
|
|
||||||
|
`/统计` 只读本地 JSONL,不调用 AI;`/分析` 会把最近消息样本交给当前 `serve` 服务或 agent。处理隐私聊天时,建议优先使用本地模型或本地 Pi 配置。
|
||||||
|
|
||||||
|
### 6. 飞书 IM
|
||||||
|
|
||||||
|
飞书 IM 通过 `lark-cli` 接入:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 生成 device-flow 授权链接/扫码信息
|
||||||
|
wb lark login --no-wait
|
||||||
|
|
||||||
|
# 查看授权状态
|
||||||
|
wb lark status
|
||||||
|
|
||||||
|
# 读取 / 搜索 / 发送消息
|
||||||
|
wb lark messages --chat-id oc_xxx
|
||||||
|
wb lark search --query "关键词"
|
||||||
|
wb lark send --chat-id oc_xxx --text "hello"
|
||||||
|
```
|
||||||
|
|
||||||
|
当前飞书是 CLI 控制通道,支持登录、读消息、搜消息、发消息;还不是实时事件通道,因此飞书消息暂不会自动推给 Pi 回复。
|
||||||
|
|
||||||
|
### 7. Pi / OpenCLI 透传
|
||||||
|
|
||||||
|
```sh
|
||||||
|
wb pi -- --help
|
||||||
|
wb pi -- --print "分析当前项目结构"
|
||||||
|
|
||||||
|
wb opencli -- --help
|
||||||
|
wb opencli -- wx-cli help
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. 测试
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run test:analysis
|
||||||
|
node ./cli.js --help
|
||||||
|
node ./cli.js wx help
|
||||||
|
node ./cli.js pi -- --help
|
||||||
|
```
|
||||||
|
|
||||||
|
如果使用 OpenAI、Claude、Kimi 等云端服务,请确保对应 API Key、余额和网络代理可用。
|
||||||
|
|
||||||
|
## 你要修改的
|
||||||
|
|
||||||
|
很多人说运行后不会自动收发信息,不是的哈,为了防止给每一条收到的消息都自动回复(太恐怖了),所以加了限制条件。
|
||||||
|
|
||||||
|
你要把下面提到的地方自定义修改下:
|
||||||
|
|
||||||
|
- `BOT_NAME`:改成你启动机器人账号的微信昵称,格式类似 `@可乐`。
|
||||||
|
- `ALIAS_WHITELIST`:允许自动回复的好友备注或昵称。
|
||||||
|
- `ROOM_WHITELIST`:允许自动回复的群聊名称。
|
||||||
|
- `AUTO_REPLY_PREFIX`:可选,只有匹配指定前缀才自动回复。
|
||||||
|
- `PI_AGENT_ARGS`:Pi 作为 IM agent 时的参数,默认是 `--print --no-session`。
|
||||||
|
- 更深入的业务逻辑可以看 `src/wechaty/sendMessage.js` 和 `src/platforms/wechat/commandRouter.js`。
|
||||||
|
|
||||||
|
在.env 文件中修改你的配置即可,示例如下
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 白名单配置
|
||||||
|
#定义机器人的名称,这里是为了防止群聊消息太多,所以只有艾特机器人才会回复,
|
||||||
|
#这里不要把@去掉,在@后面加上你启动机器人账号的微信名称
|
||||||
|
BOT_NAME=@可乐
|
||||||
|
#联系人白名单
|
||||||
|
ALIAS_WHITELIST=微信名1,备注名2
|
||||||
|
#群聊白名单
|
||||||
|
ROOM_WHITELIST=XX群1,群2
|
||||||
|
#自动回复前缀匹配,文本消息匹配到指定前缀时,才会触发自动回复,不配或配空串情况下该配置不生效(适用于用大号,不期望每次被@或者私聊时都触发自动回复的人群)
|
||||||
|
#匹配规则:群聊消息去掉${BOT_NAME}并trim后进行前缀匹配,私聊消息trim后直接进行前缀匹配
|
||||||
|
AUTO_REPLY_PREFIX=''
|
||||||
|
|
||||||
|
# Pi agent
|
||||||
|
PI_BIN='pi'
|
||||||
|
PI_AGENT_ARGS='--print --no-session'
|
||||||
|
```
|
||||||
|
|
||||||
|
自动回复不再只限于 `chatgpt`,可以通过 `--serve` 选择不同服务,例如 `pi`、`ollama`、`deepseek`、`claude`、`ChatGPT`。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 注意项
|
||||||
|
|
||||||
|
近期微信审查很严格,大量用户反映弹出外挂警告,由于项目内默认使用的是免费版的 web 协议,所以目前来说很容易会被微信检测到,建议使用 pad 协议,或者自行购买企业版协议,避免被封号。
|
||||||
|
|
||||||
|
修改可参考: https://github.com/wangrongding/wechat-bot/pull/263/files
|
||||||
|
自行购买 pad 协议渠道(wechaty 出的,购买仍需谨慎):http://pad-local.com
|
||||||
|
由于底层依赖的 wechaty 本身不怎么维护了,听说是被腾讯告了,所以大家购买也要谨慎,群友分享目前 pad 协议可正常使用(但频繁登录登出也会收到警告),最好别一次性买太久的。
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
以下是我的微信和群二维码,添加的时候记得备注清楚来意。
|
||||||
|
希望可以一起交流探讨相关问题和解决方案。
|
||||||
|
|
||||||
|
| <img src="https://github.com/user-attachments/assets/902b1a20-0ea0-4348-9ac1-b9eb6645223c" width="180px"> | <img src="https://raw.githubusercontent.com/wangrongding/image-house/master/WechatIMG173.jpg" width="180px"> |
|
||||||
|
| --- | --- |
|
||||||
|
|
||||||
|
### 运行报错等问题
|
||||||
|
|
||||||
|
首先你需要做到以下几点:
|
||||||
|
|
||||||
|
- 拉取最新代码,重新安装依赖(删除 lock 文件,删除 node_modules)
|
||||||
|
- 安装依赖时最好不要设置 npm 镜像
|
||||||
|
- 遇到 puppeteer 安装失败设置环境变量:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Mac
|
||||||
|
export PUPPETEER_SKIP_DOWNLOAD='true'
|
||||||
|
|
||||||
|
# Windows
|
||||||
|
SET PUPPETEER_SKIP_DOWNLOAD='true'
|
||||||
|
```
|
||||||
|
|
||||||
|
- 如果使用云端模型,确保终端网络可以访问对应模型服务(开全局代理,或者手动设置终端代理)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 设置代理
|
||||||
|
export https_proxy=http://127.0.0.1:你的代理服务端口号;export http_proxy=http://127.0.0.1:你的代理服务端口号;export all_proxy=socks5://127.0.0.1:你的代理服务端口号
|
||||||
|
# 然后执行对应服务测试,或先查看 CLI 是否正常
|
||||||
|
node ./cli.js --help
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- 如果使用 OpenAI / Claude / Kimi 等云端模型,确认 API Key、余额、模型名和代理配置正确
|
||||||
|
- 配置好 `.env` 文件,尤其是 `BOT_NAME`、白名单和当前 `--serve` 服务所需参数
|
||||||
|
- 执行 `npm run test:analysis` 验证本地分析模块,执行 `node ./cli.js --help` 验证 CLI
|
||||||
|
- 执行 `wb agent --im wechat --agent pi` 或 `wb start --serve <服务名>` 启动微信扫码
|
||||||
|
|
||||||
|
也可以参考这条 [issue](https://github.com/wangrongding/wechat-bot/issues/54#issuecomment-1347880291)
|
||||||
|
|
||||||
|
- 怎么玩? 完成自定义修改后,群聊时,在白名单中的群,有人 @你 时会触发自动回复,私聊中,联系人白名单中的人发消息给你时会触发自动回复。
|
||||||
|
- 运行报错?检查 node 版本是否符合,如果不符合,升级 node 版本即可,检查依赖是否安装完整,如果不完整,大陆推荐切换下 npm 镜像源,然后重新安装依赖即可。(可以用我的 [prm-cli](https://github.com/wangrongding/prm-cli) 工具快速切换)
|
||||||
|
- 调整对话模式?优先通过 `--serve` 切换服务;需要定制业务逻辑时看 [sendMessage.js](./src/wechaty/sendMessage.js)、[commandRouter.js](./src/platforms/wechat/commandRouter.js) 和对应 provider 实现。
|
||||||
|
|
||||||
|
## 使用 Docker 部署
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ docker build . -t wechat-bot
|
||||||
|
|
||||||
|
$ docker run -d --rm --name wechat-bot -v $(pwd)/.env:/app/.env wechat-bot
|
||||||
|
```
|
||||||
|
|
||||||
|
- 如果docker build过程中node反复下载超时,可先下载nodejs镜像到本地镜像库,并将DockerFile中的'node:19'修改为本地nodejs镜像版本
|
||||||
|
|
||||||
|
## Star History Chart
|
||||||
|
|
||||||
|
该项目于 2023/2/13 日成为 Github Trending 榜首。
|
||||||
|
|
||||||
|
[](https://star-history.com/#wangrongding/wechat-bot&Date)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT](./LICENSE).
|
||||||
30
RECORD.md
Normal file
30
RECORD.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# 使用最近很火的 OpenAi ChatGPT 配合 Wechaty 实现一个 微信聊天機器人
|
||||||
|
|
||||||
|
## 前言
|
||||||
|
|
||||||
|
使用 OpenAi ChatGPT 和 Wechaty 可以实现一个微信聊天机器人。OpenAi ChatGPT 是一个大型语言模型,能够模拟人类聊天对话,并回答用户的问题。Wechaty 是一个 Node.js 库,可以让开发者轻松地接入微信并实现聊天机器人功能。
|
||||||
|
|
||||||
|
首先,需要安装 Node.js 和 npm(Node.js 包管理器)。安装完成后,使用 npm 安装 Wechaty 和 OpenAi ChatGPT。
|
||||||
|
|
||||||
|
## 什么是 OpenAi ChatGPT
|
||||||
|
|
||||||
|
OpenAI ChatGPT 是一个语言模型,由 OpenAI 开发。它是基于 GPT-3(Generative Pretrained Transformer-3)架构的,用于对话和聊天的自然语言处理(NLP)任务。它的目的是通过模仿人类的语言方式来生成文本。
|
||||||
|
|
||||||
|
与其他语言模型不同,OpenAI ChatGPT 可以记忆之前的对话内容,并根据上下文和预先学习的知识来生成自然的回复。它可以用于支持机器人,聊天机器人和其他应用程序,以提供更加人性化和自然的对话体验。
|
||||||
|
|
||||||
|
OpenAI ChatGPT 模型使用了大量的语料数据进行预训练,并通过机器学习算法来优化其生成文本的能力。它具有出色的自然语言理解能力,可以模拟人类的语言特征,如句子结构、语法和修辞手法。
|
||||||
|
|
||||||
|
总的来说,OpenAI ChatGPT 是一个非常强大的语言模型,可以用于实现多种 NLP 应用程序,以提高对话和聊天的自然语言处理能力。
|
||||||
|
|
||||||
|
## 什么是 Wechaty
|
||||||
|
|
||||||
|
## 一些相关链接
|
||||||
|
|
||||||
|
- [OpenAI ChatGPT](https://openai.com/blog/chatting/)
|
||||||
|
- [Wechaty](https://wechaty.js.org/)
|
||||||
|
- [Wechaty Chatbot](https://wechaty.js.org/docs/examples/chatbot/)
|
||||||
|
- [Wechaty Chatbot Tutorial](https://wechaty.js.org/docs/tutorials/chatbot-tutorial/)
|
||||||
|
|
||||||
|
- https://openai.com/blog/chatgpt/
|
||||||
|
- https://download-chromium.appspot.com/?platform=Mac_Arm&type=snapshots
|
||||||
|
- https://registry.npmmirror.com/binary.html?path=chromium-browser-snapshots/Mac_Arm/
|
||||||
4
cli.js
Normal file
4
cli.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
#! /usr/bin/env node
|
||||||
|
|
||||||
|
'use strict'
|
||||||
|
import('./src/index.js')
|
||||||
219
docs/pi-im-agent.md
Normal file
219
docs/pi-im-agent.md
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
# Pi Agent + IM 使用说明
|
||||||
|
|
||||||
|
这份文档说明如何把当前项目作为 Pi agent 的运行壳,用 IM 作为外部通信渠道。
|
||||||
|
|
||||||
|
## 目标链路
|
||||||
|
|
||||||
|
```text
|
||||||
|
外部 IM 消息 -> wechat-bot -> Pi agent -> IM 回复
|
||||||
|
```
|
||||||
|
|
||||||
|
当前已实现:
|
||||||
|
|
||||||
|
- 微信 IM:扫码登录后接收/回复消息。
|
||||||
|
- Pi agent:作为 `serve` 类型处理微信消息。
|
||||||
|
- 本地微信数据:通过 OpenCLI `wx-cli` 访问聊天、群成员、统计和朋友圈缓存。
|
||||||
|
- 飞书 IM:通过 `lark-cli` 登录、发消息、读消息、搜索消息。
|
||||||
|
|
||||||
|
## 安装命令
|
||||||
|
|
||||||
|
如果希望直接使用 `wb` 命令,在项目根目录执行:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm link
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以不用 `wb`,直接使用:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run start -- <command>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 环境配置
|
||||||
|
|
||||||
|
复制并编辑 `.env`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
微信 + Pi 推荐配置:
|
||||||
|
|
||||||
|
```env
|
||||||
|
BOT_NAME='@你的微信昵称'
|
||||||
|
ALIAS_WHITELIST='允许私聊你的好友备注'
|
||||||
|
ROOM_WHITELIST='允许接入的群名'
|
||||||
|
AUTO_REPLY_PREFIX=''
|
||||||
|
|
||||||
|
WECHAT_DATA_DIR='.data/wechat'
|
||||||
|
WECHAT_STORE_MESSAGES='true'
|
||||||
|
|
||||||
|
PI_BIN='pi'
|
||||||
|
PI_NPM_PACKAGE='@earendil-works/pi-coding-agent'
|
||||||
|
PI_AGENT_ARGS='--print --no-session'
|
||||||
|
```
|
||||||
|
|
||||||
|
如果本机没有全局 `pi` 命令,可以先留空:
|
||||||
|
|
||||||
|
```env
|
||||||
|
PI_BIN=''
|
||||||
|
```
|
||||||
|
|
||||||
|
项目会通过 `npx --yes @earendil-works/pi-coding-agent` 调起 Pi,但每次冷启动会慢一些。
|
||||||
|
|
||||||
|
## 微信扫码接入 Pi
|
||||||
|
|
||||||
|
推荐命令:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
wb agent --im wechat --agent pi
|
||||||
|
```
|
||||||
|
|
||||||
|
等价命令:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
wb start --serve pi
|
||||||
|
```
|
||||||
|
|
||||||
|
或使用 npm:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run agent
|
||||||
|
npm run start -- start --serve pi
|
||||||
|
```
|
||||||
|
|
||||||
|
启动后终端会展示微信二维码。扫码登录成功后,链路如下:
|
||||||
|
|
||||||
|
```text
|
||||||
|
微信扫码登录 -> Wechaty 收消息 -> 本地 JSONL 捕获 -> Pi 单轮 agent 回复 -> 微信 IM 发回
|
||||||
|
```
|
||||||
|
|
||||||
|
触发规则:
|
||||||
|
|
||||||
|
- 私聊:发消息人必须在 `ALIAS_WHITELIST` 中。
|
||||||
|
- 群聊:群名必须在 `ROOM_WHITELIST` 中,并且消息里需要 `@机器人昵称`。
|
||||||
|
- 非文本消息不会进入 Pi 回复链路。
|
||||||
|
|
||||||
|
## 微信内置分析命令
|
||||||
|
|
||||||
|
微信聊天中可以直接发命令:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/统计 群 XX群1
|
||||||
|
/分析 群 XX群1
|
||||||
|
/统计 好友 好友备注
|
||||||
|
/分析 好友 好友备注
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- `/统计` 只读取本地 JSONL,不调用 AI。
|
||||||
|
- `/分析` 会调用当前 agent 或 AI 服务,并把最近消息样本发给模型。
|
||||||
|
- 隐私聊天建议优先使用本地模型或本地 Pi 配置。
|
||||||
|
|
||||||
|
## 本地微信数据与朋友圈
|
||||||
|
|
||||||
|
OpenCLI 的 `wx-cli` 可访问本机微信缓存数据:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
wb wx init
|
||||||
|
wb wx sessions
|
||||||
|
wb wx history
|
||||||
|
wb wx search
|
||||||
|
wb wx contacts
|
||||||
|
wb wx members
|
||||||
|
wb wx stats
|
||||||
|
wb wx favorites
|
||||||
|
wb wx sns-feed
|
||||||
|
wb wx sns-search
|
||||||
|
wb wx sns-notifications
|
||||||
|
```
|
||||||
|
|
||||||
|
首次使用先执行:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
wb wx init
|
||||||
|
```
|
||||||
|
|
||||||
|
查看 `wx-cli` 支持的完整命令:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
wb wx help
|
||||||
|
```
|
||||||
|
|
||||||
|
## 飞书 IM
|
||||||
|
|
||||||
|
飞书当前是 CLI 控制通道,可登录、读写和搜索消息:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
wb lark login --no-wait
|
||||||
|
wb lark status
|
||||||
|
wb lark messages --chat-id oc_xxx
|
||||||
|
wb lark search --query "关键词"
|
||||||
|
wb lark send --chat-id oc_xxx --text "hello"
|
||||||
|
```
|
||||||
|
|
||||||
|
`--no-wait` 会返回 device-flow 授权链接/扫码信息。你完成授权后,再运行读写命令。
|
||||||
|
|
||||||
|
当前飞书还不是实时事件通道;也就是说,飞书消息不会自动推给 Pi 回复。后续要实现飞书实时 agent,需要接入 Lark event consume,再把收到的消息转给 Pi。
|
||||||
|
|
||||||
|
## Pi 透传命令
|
||||||
|
|
||||||
|
直接调用 Pi:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
wb pi -- --help
|
||||||
|
wb pi -- --print "分析当前项目结构"
|
||||||
|
```
|
||||||
|
|
||||||
|
`PI_AGENT_ARGS` 控制 Pi 作为 IM 回复 agent 时的参数。默认:
|
||||||
|
|
||||||
|
```env
|
||||||
|
PI_AGENT_ARGS='--print --no-session'
|
||||||
|
```
|
||||||
|
|
||||||
|
这表示每条 IM 消息都是单轮非交互回复。如果希望沿用会话,可以去掉 `--no-session`,但要注意上下文和隐私数据会被 Pi session 保存。
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### 扫码后没有回复
|
||||||
|
|
||||||
|
检查:
|
||||||
|
|
||||||
|
- 私聊好友备注是否在 `ALIAS_WHITELIST`。
|
||||||
|
- 群名是否在 `ROOM_WHITELIST`。
|
||||||
|
- 群聊是否真的 `@` 了 `BOT_NAME`。
|
||||||
|
- `.env` 中 `BOT_NAME` 是否形如 `@你的微信昵称`。
|
||||||
|
- 当前消息是否为文本消息。
|
||||||
|
|
||||||
|
### Pi 回复慢
|
||||||
|
|
||||||
|
建议配置本机 Pi:
|
||||||
|
|
||||||
|
```env
|
||||||
|
PI_BIN='pi'
|
||||||
|
```
|
||||||
|
|
||||||
|
如果留空,项目会通过 `npx` 调起 Pi,首次执行和冷启动都会更慢。
|
||||||
|
|
||||||
|
### 如何只分析,不自动回复
|
||||||
|
|
||||||
|
使用命令行:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
wb analyze --room "群名" --stats-only
|
||||||
|
wb analyze --friend "好友备注" --stats-only
|
||||||
|
```
|
||||||
|
|
||||||
|
或调用 AI 深度分析:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
wb analyze --room "群名" --serve pi
|
||||||
|
```
|
||||||
|
|
||||||
|
### 安全边界
|
||||||
|
|
||||||
|
- 项目只处理本机已登录账号可见的数据。
|
||||||
|
- 微信自动回复受白名单控制。
|
||||||
|
- OpenCLI 远程执行默认关闭。
|
||||||
|
- `/分析` 会把消息样本交给当前模型或 agent,处理隐私数据前请确认模型运行位置和配置。
|
||||||
75
package.json
Normal file
75
package.json
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"name": "wechat-bot",
|
||||||
|
"version": "1.0.2",
|
||||||
|
"description": "wechat-bot",
|
||||||
|
"main": "index.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node ./cli.js",
|
||||||
|
"format": "prettier --write ./src",
|
||||||
|
"start": "node ./cli.js",
|
||||||
|
"test": "node ./src/wechaty/testMessage.js",
|
||||||
|
"test:analysis": "node ./src/analysis/__test__.js",
|
||||||
|
"lark:login": "node ./cli.js lark login",
|
||||||
|
"wechat:analyze": "node ./cli.js analyze --stats-only",
|
||||||
|
"agent": "node ./cli.js agent --im wechat --agent pi",
|
||||||
|
"opencli": "node ./cli.js opencli",
|
||||||
|
"wx": "node ./cli.js wx",
|
||||||
|
"pi": "node ./cli.js pi",
|
||||||
|
"test-openai": "node ./src/openai/__test__.js",
|
||||||
|
"test-xunfei": "node ./src/xunfei/__test__.js",
|
||||||
|
"test-kimi": "node ./src/kimi/__test__.js",
|
||||||
|
"test-dify": "node ./src/dify/__test__.js",
|
||||||
|
"prepare": "npx husky"
|
||||||
|
},
|
||||||
|
"lint-staged": {
|
||||||
|
"*.{js,ts,md}": [
|
||||||
|
"prettier --write"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"src",
|
||||||
|
"./.env.example",
|
||||||
|
"./cli.js",
|
||||||
|
"./Dockerfile",
|
||||||
|
"./Dockerfile.alpine",
|
||||||
|
"./LICENSE.md",
|
||||||
|
"./README.md",
|
||||||
|
"./RECORD.md"
|
||||||
|
],
|
||||||
|
"bin": {
|
||||||
|
"wb": "./cli.js",
|
||||||
|
"wechat-bot": "./cli.js"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/wangrongding/wechat-bot/",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/wangrongding/wechat-bot.git"
|
||||||
|
},
|
||||||
|
"author": "荣顶",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.6.8",
|
||||||
|
"chatgpt": "^2.5.2",
|
||||||
|
"commander": "^12.0.0",
|
||||||
|
"crypto-js": "^4.2.0",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"inquirer": "^9.2.16",
|
||||||
|
"openai": "^4.52.0",
|
||||||
|
"p-timeout": "^6.0.0",
|
||||||
|
"qrcode-terminal": "^0.12.0",
|
||||||
|
"remark": "^14.0.2",
|
||||||
|
"strip-markdown": "^5.0.0",
|
||||||
|
"wechaty": "^1.20.2",
|
||||||
|
"wechaty-puppet-wechat": "^1.18.4",
|
||||||
|
"wechaty-puppet-wechat4u": "^1.14.14",
|
||||||
|
"ws": "^8.16.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"husky": "^9.0.11",
|
||||||
|
"lint-staged": "^15.2.7",
|
||||||
|
"prettier": "^3.3.3",
|
||||||
|
"puppeteer": "13.5.1",
|
||||||
|
"puppeteer-core": "13.5.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
sponsors/91api.jpg
Normal file
BIN
sponsors/91api.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
BIN
sponsors/shenfengwl.png
Normal file
BIN
sponsors/shenfengwl.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 MiB |
9
src/302ai/__test__.js
Normal file
9
src/302ai/__test__.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { get302AiReply } from './index.js'
|
||||||
|
|
||||||
|
// 测试 302 ai api
|
||||||
|
async function testMessage() {
|
||||||
|
const message = await get302AiReply('hello')
|
||||||
|
console.log('🌸🌸🌸 / message: ', message)
|
||||||
|
}
|
||||||
|
|
||||||
|
testMessage()
|
||||||
41
src/302ai/index.js
Normal file
41
src/302ai/index.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import dotenv from 'dotenv'
|
||||||
|
|
||||||
|
dotenv.config()
|
||||||
|
const env = dotenv.config().parsed
|
||||||
|
const key = env._302AI_API_KEY
|
||||||
|
const model = env._302AI_MODEL ? env._302AI_MODEL : 'gpt-4o-mini'
|
||||||
|
|
||||||
|
function setConfig(prompt) {
|
||||||
|
return {
|
||||||
|
method: 'post',
|
||||||
|
url: 'https://api.302.ai/v1/chat/completions',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
Authorization: `Bearer ${key}`,
|
||||||
|
'User-Agent': 'Apifox/1.0.0 (https://apifox.com)',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
data: JSON.stringify({
|
||||||
|
model: model,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: prompt,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get302AiReply(prompt) {
|
||||||
|
try {
|
||||||
|
const config = setConfig(prompt)
|
||||||
|
const response = await axios(config)
|
||||||
|
const { choices } = response.data
|
||||||
|
return choices[0].message.content
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.code)
|
||||||
|
console.error(error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/adapters/lark.js
Normal file
79
src/adapters/lark.js
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { getLarkRuntimeConfig } from '../config/env.js'
|
||||||
|
import { runCommand, streamCommand } from '../utils/process.js'
|
||||||
|
|
||||||
|
function getLarkBin() {
|
||||||
|
return getLarkRuntimeConfig().bin
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function larkLogin(options = {}) {
|
||||||
|
const args = ['auth', 'login']
|
||||||
|
|
||||||
|
if (options.deviceCode) {
|
||||||
|
args.push('--device-code', options.deviceCode)
|
||||||
|
} else if (options.scope) {
|
||||||
|
args.push('--scope', options.scope)
|
||||||
|
} else {
|
||||||
|
args.push('--domain', options.domain || 'im')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.noWait || options.wait === false) {
|
||||||
|
args.push('--no-wait', '--json')
|
||||||
|
}
|
||||||
|
|
||||||
|
return streamCommand(getLarkBin(), args)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function larkStatus() {
|
||||||
|
return streamCommand(getLarkBin(), ['auth', 'status'])
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function larkSendText(options = {}) {
|
||||||
|
const identity = options.as || getLarkRuntimeConfig().defaultIdentity
|
||||||
|
const args = ['im', '+messages-send', '--as', identity, '--text', options.text || '']
|
||||||
|
|
||||||
|
if (options.chatId) {
|
||||||
|
args.push('--chat-id', options.chatId)
|
||||||
|
} else if (options.userId) {
|
||||||
|
args.push('--user-id', options.userId)
|
||||||
|
} else {
|
||||||
|
throw new Error('larkSendText requires chatId or userId')
|
||||||
|
}
|
||||||
|
|
||||||
|
return streamCommand(getLarkBin(), args)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function larkListMessages(options = {}) {
|
||||||
|
const identity = options.as || getLarkRuntimeConfig().defaultIdentity
|
||||||
|
const args = ['im', '+chat-messages-list', '--as', identity, '--format', options.format || 'pretty']
|
||||||
|
|
||||||
|
if (options.chatId) {
|
||||||
|
args.push('--chat-id', options.chatId)
|
||||||
|
} else if (options.userId) {
|
||||||
|
args.push('--user-id', options.userId)
|
||||||
|
} else {
|
||||||
|
throw new Error('larkListMessages requires chatId or userId')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.start) args.push('--start', options.start)
|
||||||
|
if (options.end) args.push('--end', options.end)
|
||||||
|
if (options.pageSize) args.push('--page-size', String(options.pageSize))
|
||||||
|
|
||||||
|
return streamCommand(getLarkBin(), args)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function larkSearchMessages(options = {}) {
|
||||||
|
const args = ['im', '+messages-search', '--as', 'user', '--format', options.format || 'pretty']
|
||||||
|
if (options.query) args.push('--query', options.query)
|
||||||
|
if (options.chatId) args.push('--chat-id', options.chatId)
|
||||||
|
if (options.chatType) args.push('--chat-type', options.chatType)
|
||||||
|
if (options.start) args.push('--start', options.start)
|
||||||
|
if (options.end) args.push('--end', options.end)
|
||||||
|
if (options.pageAll) args.push('--page-all')
|
||||||
|
if (options.pageLimit) args.push('--page-limit', String(options.pageLimit))
|
||||||
|
|
||||||
|
return streamCommand(getLarkBin(), args)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function larkCheckImAuth() {
|
||||||
|
return runCommand(getLarkBin(), ['auth', 'check', '--domain', 'im'], { echo: true })
|
||||||
|
}
|
||||||
27
src/adapters/opencli.js
Normal file
27
src/adapters/opencli.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { getOpenCliRuntimeConfig } from '../config/env.js'
|
||||||
|
import { splitCommand, streamCommand } from '../utils/process.js'
|
||||||
|
|
||||||
|
export function resolveOpenCliCommand() {
|
||||||
|
const config = getOpenCliRuntimeConfig()
|
||||||
|
const configured = splitCommand(config.bin)
|
||||||
|
if (configured.length) {
|
||||||
|
return {
|
||||||
|
command: configured[0],
|
||||||
|
args: configured.slice(1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: 'npx',
|
||||||
|
args: ['--yes', config.npmPackage],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runOpenCli(args = []) {
|
||||||
|
const base = resolveOpenCliCommand()
|
||||||
|
return streamCommand(base.command, [...base.args, ...args])
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runWxCli(args = []) {
|
||||||
|
return runOpenCli(['wx-cli', ...args])
|
||||||
|
}
|
||||||
39
src/adapters/pi.js
Normal file
39
src/adapters/pi.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { getPiRuntimeConfig } from '../config/env.js'
|
||||||
|
import { splitCommand, runCommand, streamCommand } from '../utils/process.js'
|
||||||
|
|
||||||
|
export function resolvePiCommand() {
|
||||||
|
const config = getPiRuntimeConfig()
|
||||||
|
const configured = splitCommand(config.bin)
|
||||||
|
if (configured.length) {
|
||||||
|
return {
|
||||||
|
command: configured[0],
|
||||||
|
args: configured.slice(1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: 'npx',
|
||||||
|
args: ['--yes', config.npmPackage],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runPi(args = []) {
|
||||||
|
const base = resolvePiCommand()
|
||||||
|
return streamCommand(base.command, [...base.args, ...args])
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function askPi(prompt, options = {}) {
|
||||||
|
const base = resolvePiCommand()
|
||||||
|
const config = getPiRuntimeConfig()
|
||||||
|
const agentArgs = splitCommand(options.agentArgs || config.agentArgs)
|
||||||
|
const result = await runCommand(base.command, [...base.args, ...agentArgs, prompt], {
|
||||||
|
cwd: options.cwd || process.cwd(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.code !== 0) {
|
||||||
|
const detail = result.stderr || result.stdout || `exit code ${result.code}`
|
||||||
|
throw new Error(`pi failed: ${detail}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (result.stdout || result.stderr || '').trim()
|
||||||
|
}
|
||||||
56
src/analysis/__test__.js
Normal file
56
src/analysis/__test__.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import assert from 'assert'
|
||||||
|
import fs from 'fs'
|
||||||
|
import os from 'os'
|
||||||
|
import path from 'path'
|
||||||
|
import { analyzeWechatMessages, buildWechatStats } from './wechatAnalyzer.js'
|
||||||
|
|
||||||
|
const records = [
|
||||||
|
{
|
||||||
|
timestamp: '2026-05-12T08:00:00.000Z',
|
||||||
|
roomName: '研发群',
|
||||||
|
talkerName: 'Alice',
|
||||||
|
talkerAlias: 'Alice',
|
||||||
|
receiverName: '',
|
||||||
|
text: '今天排查登录问题',
|
||||||
|
typeName: 'Text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamp: '2026-05-12T09:00:00.000Z',
|
||||||
|
roomName: '研发群',
|
||||||
|
talkerName: 'Bob',
|
||||||
|
talkerAlias: 'Bob',
|
||||||
|
receiverName: '',
|
||||||
|
text: '我来补日志',
|
||||||
|
typeName: 'Text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamp: '2026-05-12T10:00:00.000Z',
|
||||||
|
roomName: '',
|
||||||
|
talkerName: 'Carol',
|
||||||
|
talkerAlias: 'Carol',
|
||||||
|
receiverName: 'me',
|
||||||
|
text: '周会改到下午',
|
||||||
|
typeName: 'Text',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const stats = buildWechatStats(records)
|
||||||
|
assert.equal(stats.totalMessages, 3)
|
||||||
|
assert.equal(stats.textMessages, 3)
|
||||||
|
assert.equal(stats.topSpeakers[0].name, 'Alice')
|
||||||
|
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-bot-analysis-'))
|
||||||
|
fs.mkdirSync(tmpDir, { recursive: true })
|
||||||
|
fs.writeFileSync(path.join(tmpDir, 'messages.jsonl'), records.map((record) => JSON.stringify(record)).join('\n'), 'utf8')
|
||||||
|
|
||||||
|
const result = await analyzeWechatMessages({
|
||||||
|
dataDir: tmpDir,
|
||||||
|
room: '研发群',
|
||||||
|
statsOnly: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(result.target, '群聊「研发群」')
|
||||||
|
assert.equal(result.stats.totalMessages, 2)
|
||||||
|
assert.equal(result.analysis, '')
|
||||||
|
|
||||||
|
console.log('analysis tests passed')
|
||||||
95
src/analysis/wechatAnalyzer.js
Normal file
95
src/analysis/wechatAnalyzer.js
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { getServe } from '../wechaty/serve.js'
|
||||||
|
import { filterWechatMessages, loadWechatMessages } from '../platforms/wechat/messageStore.js'
|
||||||
|
|
||||||
|
function increment(map, key, step = 1) {
|
||||||
|
if (!key) return
|
||||||
|
map.set(key, (map.get(key) || 0) + step)
|
||||||
|
}
|
||||||
|
|
||||||
|
function topEntries(map, limit = 10) {
|
||||||
|
return [...map.entries()]
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, limit)
|
||||||
|
.map(([name, count]) => ({ name, count }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWechatStats(records) {
|
||||||
|
const speakers = new Map()
|
||||||
|
const rooms = new Map()
|
||||||
|
const hourly = new Map()
|
||||||
|
let textMessages = 0
|
||||||
|
let totalTextLength = 0
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
increment(speakers, record.talkerAlias || record.talkerName || 'unknown')
|
||||||
|
increment(rooms, record.roomName || 'private')
|
||||||
|
if (record.timestamp) {
|
||||||
|
increment(hourly, new Date(record.timestamp).getHours().toString().padStart(2, '0'))
|
||||||
|
}
|
||||||
|
if (record.text) {
|
||||||
|
textMessages += 1
|
||||||
|
totalTextLength += record.text.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalMessages: records.length,
|
||||||
|
textMessages,
|
||||||
|
averageTextLength: textMessages ? Number((totalTextLength / textMessages).toFixed(1)) : 0,
|
||||||
|
topSpeakers: topEntries(speakers),
|
||||||
|
topRooms: topEntries(rooms),
|
||||||
|
hourly: topEntries(hourly, 24).sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWechatAnalysisPrompt({ records, stats, target }) {
|
||||||
|
const recentMessages = records
|
||||||
|
.slice(-120)
|
||||||
|
.map((record) => {
|
||||||
|
const speaker = record.talkerAlias || record.talkerName || 'unknown'
|
||||||
|
return `[${record.timestamp}] ${speaker}: ${record.text || `[${record.typeName}]`}`
|
||||||
|
})
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
return [
|
||||||
|
'你是一个严谨的中文聊天数据分析助手。',
|
||||||
|
'请基于用户显式提供的本地微信聊天记录做分析,不要编造记录之外的事实。',
|
||||||
|
'输出结构:1. 关键统计;2. 主要话题;3. 互动模式;4. 风险或误读提醒;5. 可执行建议。',
|
||||||
|
`分析对象:${target}`,
|
||||||
|
`基础统计:${JSON.stringify(stats, null, 2)}`,
|
||||||
|
'最近消息样本:',
|
||||||
|
recentMessages || '无文本消息样本。',
|
||||||
|
].join('\n\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function analyzeWechatMessages(options = {}) {
|
||||||
|
const allRecords = loadWechatMessages({
|
||||||
|
dataDir: options.dataDir,
|
||||||
|
limit: options.limit || 5000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const records = filterWechatMessages(allRecords, {
|
||||||
|
room: options.room,
|
||||||
|
friend: options.friend,
|
||||||
|
query: options.query,
|
||||||
|
start: options.start,
|
||||||
|
end: options.end,
|
||||||
|
})
|
||||||
|
|
||||||
|
const stats = buildWechatStats(records)
|
||||||
|
const target = options.room ? `群聊「${options.room}」` : options.friend ? `好友「${options.friend}」` : '全部本地记录'
|
||||||
|
|
||||||
|
if (options.statsOnly || !records.length) {
|
||||||
|
return {
|
||||||
|
target,
|
||||||
|
stats,
|
||||||
|
analysis: records.length ? '' : '没有匹配到可分析的本地微信消息。',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getReply = getServe(options.serviceType || 'ChatGPT')
|
||||||
|
const prompt = buildWechatAnalysisPrompt({ records, stats, target })
|
||||||
|
const analysis = await getReply(prompt)
|
||||||
|
|
||||||
|
return { target, stats, analysis }
|
||||||
|
}
|
||||||
35
src/chatgpt/index.js
Normal file
35
src/chatgpt/index.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { ChatGPTAPI } from 'chatgpt'
|
||||||
|
import dotenv from 'dotenv'
|
||||||
|
|
||||||
|
const env = dotenv.config().parsed // 环境参数
|
||||||
|
|
||||||
|
// 定义ChatGPT的配置
|
||||||
|
const config = {
|
||||||
|
markdown: true, // 返回的内容是否需要markdown格式
|
||||||
|
AutoReply: true, // 是否自动回复
|
||||||
|
clearanceToken: env.CHATGPT_CLEARANCE, // ChatGPT的clearance,从cookie取值
|
||||||
|
sessionToken: env.CHATGPT_SESSION_TOKEN, // ChatGPT的sessionToken, 从cookie取值
|
||||||
|
userAgent: env.CHATGPT_USER_AGENT, // ChatGPT的user-agent,从浏览器取值,或者替换为与你的真实浏览器的User-Agent相匹配的值
|
||||||
|
accessToken: env.CHATGPT_ACCESS_TOKEN, // 在用户授权情况下,访问https://chat.openai.com/api/auth/session,获取accesstoken
|
||||||
|
}
|
||||||
|
const api = new ChatGPTAPI(config)
|
||||||
|
|
||||||
|
// 获取 chatGPT 的回复
|
||||||
|
export async function getChatGPTReply(content) {
|
||||||
|
await api.ensureAuth()
|
||||||
|
console.log('🚀🚀🚀 / content', content)
|
||||||
|
// 调用ChatGPT的接口
|
||||||
|
const reply = await api.sendMessage(content, {
|
||||||
|
// "ChatGPT 请求超时!最好开下全局代理。"
|
||||||
|
timeoutMs: 2 * 60 * 1000,
|
||||||
|
})
|
||||||
|
console.log('🚀🚀🚀 / reply', reply)
|
||||||
|
return reply
|
||||||
|
|
||||||
|
// // 如果你想要连续语境对话,可以使用下面的代码
|
||||||
|
// const conversation = api.getConversation();
|
||||||
|
// return await conversation.sendMessage(content, {
|
||||||
|
// // "ChatGPT 请求超时!最好开下全局代理。"
|
||||||
|
// timeoutMs: 2 * 60 * 1000,
|
||||||
|
// });
|
||||||
|
}
|
||||||
72
src/claude/index.js
Normal file
72
src/claude/index.js
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import dotenv from 'dotenv'
|
||||||
|
|
||||||
|
dotenv.config()
|
||||||
|
const env = dotenv.config().parsed
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用于规整env文件中配置的url到标准格式
|
||||||
|
*/
|
||||||
|
function normalizeClaudeBaseUrl(url) {
|
||||||
|
if (!url) {
|
||||||
|
return 'https://api.anthropic.com/v1/messages'
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalizedUrl = url.replace(/\/$/, '')
|
||||||
|
|
||||||
|
if (normalizedUrl.includes('/v1/messages')) {
|
||||||
|
return normalizedUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedUrl.includes('/v1') && !normalizedUrl.includes('/messages')) {
|
||||||
|
return `${normalizedUrl}/messages`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedUrl.includes('/messages') && !normalizedUrl.includes('/v1')) {
|
||||||
|
return normalizedUrl.replace('/messages', '/v1/messages')
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${normalizedUrl}/v1/messages`
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = env.CLAUDE_API_KEY || ''
|
||||||
|
const model = env.CLAUDE_MODEL ? env.CLAUDE_MODEL : 'claude-3-5-sonnet-latest'
|
||||||
|
const baseUrl = normalizeClaudeBaseUrl(env.CLAUDE_BASE_URL)
|
||||||
|
const system = env.CLAUDE_SYSTEM || ''
|
||||||
|
const apiVersion = env.CLAUDE_API_VERSION || '2023-06-01'
|
||||||
|
|
||||||
|
function claudeConfig(prompt) {
|
||||||
|
const body = {
|
||||||
|
model: model,
|
||||||
|
temperature: 0.4,
|
||||||
|
max_tokens: 1024,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: prompt,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if (system !== '') {
|
||||||
|
body.system = system
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
method: 'post',
|
||||||
|
url: baseUrl,
|
||||||
|
headers: {
|
||||||
|
'x-api-key': key,
|
||||||
|
'anthropic-version': apiVersion,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
data: body,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export async function getClaudeReply(prompt) {
|
||||||
|
try {
|
||||||
|
const claude = claudeConfig(prompt)
|
||||||
|
const reply = await axios(claude)
|
||||||
|
return reply.data.content[0].text || ''
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/config/env.js
Normal file
50
src/config/env.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import dotenv from 'dotenv'
|
||||||
|
|
||||||
|
const dotenvResult = dotenv.config()
|
||||||
|
|
||||||
|
export const env = {
|
||||||
|
...(dotenvResult.parsed || {}),
|
||||||
|
...process.env,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readCsvEnv(key) {
|
||||||
|
return (env[key] || '')
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWechatRuntimeConfig() {
|
||||||
|
return {
|
||||||
|
botName: env.BOT_NAME || '',
|
||||||
|
autoReplyPrefix: env.AUTO_REPLY_PREFIX || '',
|
||||||
|
aliasWhiteList: readCsvEnv('ALIAS_WHITELIST'),
|
||||||
|
roomWhiteList: readCsvEnv('ROOM_WHITELIST'),
|
||||||
|
dataDir: env.WECHAT_DATA_DIR || '.data/wechat',
|
||||||
|
storeMessages: env.WECHAT_STORE_MESSAGES !== 'false',
|
||||||
|
commandPrefix: env.BOT_COMMAND_PREFIX || '/',
|
||||||
|
enableRemoteOpenCli: env.ENABLE_REMOTE_OPENCLI === 'true',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLarkRuntimeConfig() {
|
||||||
|
return {
|
||||||
|
bin: env.LARK_CLI_BIN || 'lark-cli',
|
||||||
|
defaultIdentity: env.LARK_DEFAULT_IDENTITY || 'user',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOpenCliRuntimeConfig() {
|
||||||
|
return {
|
||||||
|
bin: env.OPENCLI_BIN || '',
|
||||||
|
npmPackage: env.OPENCLI_NPM_PACKAGE || '@jackwener/opencli',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPiRuntimeConfig() {
|
||||||
|
return {
|
||||||
|
bin: env.PI_BIN || '',
|
||||||
|
npmPackage: env.PI_NPM_PACKAGE || '@earendil-works/pi-coding-agent',
|
||||||
|
agentArgs: env.PI_AGENT_ARGS || '--print --no-session',
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/deepseek-free/__test__.js
Normal file
9
src/deepseek-free/__test__.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { getDeepSeekFreeReply } from './index.js'
|
||||||
|
|
||||||
|
// 测试 open ai api
|
||||||
|
async function testMessage() {
|
||||||
|
const message = await getDeepSeekFreeReply('hello')
|
||||||
|
console.log('🌸🌸🌸 / message: ', message)
|
||||||
|
}
|
||||||
|
|
||||||
|
testMessage()
|
||||||
49
src/deepseek-free/index.js
Normal file
49
src/deepseek-free/index.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import dotenv from 'dotenv'
|
||||||
|
// 加载环境变量
|
||||||
|
dotenv.config()
|
||||||
|
const env = dotenv.config().parsed // 环境参数
|
||||||
|
const token = env.DEEPSEEK_FREE_TOKEN
|
||||||
|
const model = env.DEEPSEEK_FREE_MODEL
|
||||||
|
const url = env.DEEPSEEK_FREE_URL
|
||||||
|
const syscontent = env.DEEPSEEK_FREE_SYSTEM_MESSAGE
|
||||||
|
|
||||||
|
function setConfig(prompt) {
|
||||||
|
return {
|
||||||
|
method: 'post',
|
||||||
|
maxBodyLength: Infinity,
|
||||||
|
// url: 'https://api.deepseek.com/chat/completions',
|
||||||
|
url: url,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
data: JSON.stringify({
|
||||||
|
model: model,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content: syscontent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: prompt,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
stream: false,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDeepSeekFreeReply(prompt) {
|
||||||
|
try {
|
||||||
|
const config = setConfig(prompt)
|
||||||
|
const response = await axios(config)
|
||||||
|
const { choices } = response.data
|
||||||
|
return choices[0].message.content
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.code)
|
||||||
|
console.error(error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/deepseek/__test__.js
Normal file
9
src/deepseek/__test__.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { getDoubaoReply } from './index.js'
|
||||||
|
|
||||||
|
// 测试 open ai api
|
||||||
|
async function testMessage() {
|
||||||
|
const message = await getDoubaoReply('猪可以吃钛合金吗')
|
||||||
|
console.log('🌸🌸🌸 / message: ', message)
|
||||||
|
}
|
||||||
|
|
||||||
|
testMessage()
|
||||||
36
src/deepseek/index.js
Normal file
36
src/deepseek/index.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { remark } from 'remark'
|
||||||
|
import stripMarkdown from 'strip-markdown'
|
||||||
|
import OpenAI from 'openai'
|
||||||
|
import dotenv from 'dotenv'
|
||||||
|
const env = dotenv.config().parsed // 环境参数
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
const __dirname = path.resolve()
|
||||||
|
// 判断是否有 .env 文件, 没有则报错
|
||||||
|
const envPath = path.join(__dirname, '.env')
|
||||||
|
if (!fs.existsSync(envPath)) {
|
||||||
|
console.log('❌ 请先根据文档,创建并配置.env文件!')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = {
|
||||||
|
apiKey: env.DEEPSEEK_API_KEY,
|
||||||
|
}
|
||||||
|
if (env.DEEPSEEK_URL) {
|
||||||
|
config.baseURL = env.DEEPSEEK_URL
|
||||||
|
}
|
||||||
|
const openai = new OpenAI(config)
|
||||||
|
const chosen_model = env.DEEPSEEK_MODEL
|
||||||
|
export async function getDeepseekReply(prompt) {
|
||||||
|
console.log('🚀🚀🚀 / prompt', prompt)
|
||||||
|
const response = await openai.chat.completions.create({
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: env.DEEPSEEK_SYSTEM_MESSAGE },
|
||||||
|
{ role: 'user', content: prompt },
|
||||||
|
],
|
||||||
|
model: chosen_model,
|
||||||
|
})
|
||||||
|
console.log('🚀🚀🚀 / reply', response.choices[0].message.content)
|
||||||
|
return `${response.choices[0].message.content}`
|
||||||
|
}
|
||||||
9
src/dify/__test__.js
Normal file
9
src/dify/__test__.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { getDifyReply } from './index.js'
|
||||||
|
|
||||||
|
// 测试 dify api
|
||||||
|
async function testMessage() {
|
||||||
|
const message = await getDifyReply('hello')
|
||||||
|
console.log('🌸🌸🌸 / message: ', message)
|
||||||
|
}
|
||||||
|
|
||||||
|
testMessage()
|
||||||
39
src/dify/index.js
Normal file
39
src/dify/index.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import dotenv from 'dotenv'
|
||||||
|
// 加载环境变量
|
||||||
|
dotenv.config()
|
||||||
|
const env = dotenv.config().parsed // 环境参数
|
||||||
|
const token = env.DIFY_API_KEY
|
||||||
|
const url = env.DIFY_URL
|
||||||
|
const bot_name = env.BOT_NAME
|
||||||
|
function setConfig(prompt) {
|
||||||
|
return {
|
||||||
|
method: 'post',
|
||||||
|
url: `${url}/chat-messages`,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
data: JSON.stringify({
|
||||||
|
inputs: {},
|
||||||
|
query: prompt,
|
||||||
|
response_mode: 'blocking',
|
||||||
|
user: bot_name,
|
||||||
|
files: [],
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDifyReply(prompt) {
|
||||||
|
try {
|
||||||
|
const config = setConfig(prompt)
|
||||||
|
console.log('🌸🌸🌸 / config: ', config)
|
||||||
|
const response = await axios(config)
|
||||||
|
console.log('🌸🌸🌸 / response: ', response)
|
||||||
|
return response.data.answer
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.code)
|
||||||
|
console.error(error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/doubao/__test__.js
Normal file
12
src/doubao/__test__.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { getDoubaoReply } from './index.js'
|
||||||
|
|
||||||
|
// 测试 open ai api
|
||||||
|
async function testMessage() {
|
||||||
|
let message
|
||||||
|
message = await getDoubaoReply('猪可以吃钛合金吗')
|
||||||
|
console.log('🌸🌸🌸 / message: ', message)
|
||||||
|
message = await getDoubaoReply('这是哪里?', 'https://ark-project.tos-cn-beijing.ivolces.com/images/view.jpeg')
|
||||||
|
console.log('🌸🌸🌸 / message: ', message)
|
||||||
|
}
|
||||||
|
|
||||||
|
testMessage()
|
||||||
53
src/doubao/index.js
Normal file
53
src/doubao/index.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { remark } from 'remark'
|
||||||
|
import stripMarkdown from 'strip-markdown'
|
||||||
|
import OpenAI from 'openai'
|
||||||
|
import dotenv from 'dotenv'
|
||||||
|
const env = dotenv.config().parsed // 环境参数
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
const __dirname = path.resolve()
|
||||||
|
// 判断是否有 .env 文件, 没有则报错
|
||||||
|
const envPath = path.join(__dirname, '.env')
|
||||||
|
if (!fs.existsSync(envPath)) {
|
||||||
|
console.log('❌ 请先根据文档,创建并配置.env文件!')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = {
|
||||||
|
apiKey: env.DOUBAO_API_KEY,
|
||||||
|
baseURL: env.DOUBAO_URL,
|
||||||
|
}
|
||||||
|
const openai = new OpenAI(config)
|
||||||
|
const chosen_model = env.DOUBAO_MODEL
|
||||||
|
export async function getDoubaoReply(prompt, img_url = '') {
|
||||||
|
const only_text = img_url == ''
|
||||||
|
console.log('🚀🚀🚀 / prompt', prompt)
|
||||||
|
let response
|
||||||
|
if (only_text) {
|
||||||
|
response = await openai.chat.completions.create({
|
||||||
|
messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
|
||||||
|
model: chosen_model,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
response = await openai.chat.completions.create({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'image_url',
|
||||||
|
image_url: {
|
||||||
|
url: img_url,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: 'text', text: prompt },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
model: chosen_model,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
console.log('🚀🚀🚀 / reply', response.choices[0].message.content)
|
||||||
|
return `${response.choices[0].message.content}`
|
||||||
|
}
|
||||||
259
src/index.js
Normal file
259
src/index.js
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import { Command } from 'commander'
|
||||||
|
import inquirer from 'inquirer'
|
||||||
|
import fs from 'fs'
|
||||||
|
import path, { dirname } from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
import { env, getWechatRuntimeConfig } from './config/env.js'
|
||||||
|
import { analyzeWechatMessages } from './analysis/wechatAnalyzer.js'
|
||||||
|
import { larkListMessages, larkLogin, larkSearchMessages, larkSendText, larkStatus } from './adapters/lark.js'
|
||||||
|
import { runOpenCli, runWxCli } from './adapters/opencli.js'
|
||||||
|
import { runPi } from './adapters/pi.js'
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = dirname(__filename)
|
||||||
|
const { version, name } = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../package.json'), 'utf8'))
|
||||||
|
|
||||||
|
export const serveList = [
|
||||||
|
{ name: 'ChatGPT', value: 'ChatGPT' },
|
||||||
|
{ name: 'doubao', value: 'doubao' },
|
||||||
|
{ name: 'deepseek', value: 'deepseek' },
|
||||||
|
{ name: 'Kimi', value: 'Kimi' },
|
||||||
|
{ name: 'Xunfei', value: 'Xunfei' },
|
||||||
|
{ name: 'deepseek-free', value: 'deepseek-free' },
|
||||||
|
{ name: '302AI', value: '302AI' },
|
||||||
|
{ name: 'dify', value: 'dify' },
|
||||||
|
{ name: 'ollama', value: 'ollama' },
|
||||||
|
{ name: 'tongyi', value: 'tongyi' },
|
||||||
|
{ name: 'claude', value: 'claude' },
|
||||||
|
{ name: 'pi', value: 'pi' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function getMissingConfig(type) {
|
||||||
|
switch (type) {
|
||||||
|
case 'ChatGPT':
|
||||||
|
return env.OPENAI_API_KEY ? [] : ['OPENAI_API_KEY']
|
||||||
|
case 'doubao':
|
||||||
|
return env.DOUBAO_API_KEY ? [] : ['DOUBAO_API_KEY']
|
||||||
|
case 'deepseek':
|
||||||
|
return env.DEEPSEEK_API_KEY ? [] : ['DEEPSEEK_API_KEY']
|
||||||
|
case 'Kimi':
|
||||||
|
return env.KIMI_API_KEY ? [] : ['KIMI_API_KEY']
|
||||||
|
case 'Xunfei':
|
||||||
|
return env.XUNFEI_APP_ID && env.XUNFEI_API_KEY && env.XUNFEI_API_SECRET ? [] : ['XUNFEI_APP_ID', 'XUNFEI_API_KEY', 'XUNFEI_API_SECRET']
|
||||||
|
case 'deepseek-free':
|
||||||
|
return env.DEEPSEEK_FREE_URL && env.DEEPSEEK_FREE_TOKEN && env.DEEPSEEK_FREE_MODEL
|
||||||
|
? []
|
||||||
|
: ['DEEPSEEK_FREE_URL', 'DEEPSEEK_FREE_TOKEN', 'DEEPSEEK_FREE_MODEL']
|
||||||
|
case '302AI':
|
||||||
|
return env._302AI_API_KEY ? [] : ['_302AI_API_KEY']
|
||||||
|
case 'dify':
|
||||||
|
return env.DIFY_API_KEY && env.DIFY_URL ? [] : ['DIFY_API_KEY', 'DIFY_URL']
|
||||||
|
case 'ollama':
|
||||||
|
return env.OLLAMA_URL && env.OLLAMA_MODEL ? [] : ['OLLAMA_URL', 'OLLAMA_MODEL']
|
||||||
|
case 'tongyi':
|
||||||
|
return env.TONGYI_URL && env.TONGYI_MODEL ? [] : ['TONGYI_URL', 'TONGYI_MODEL']
|
||||||
|
case 'claude':
|
||||||
|
return env.CLAUDE_API_KEY && env.CLAUDE_MODEL ? [] : ['CLAUDE_API_KEY', 'CLAUDE_MODEL']
|
||||||
|
case 'pi':
|
||||||
|
return []
|
||||||
|
default:
|
||||||
|
return ['SERVICE_TYPE']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startWechat(type) {
|
||||||
|
const serviceType = type || env.SERVICE_TYPE
|
||||||
|
if (!serveList.find((item) => item.value === serviceType)) {
|
||||||
|
console.log('服务类型错误,目前支持:' + serveList.map((item) => item.value).join(' | '))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const missing = getMissingConfig(serviceType)
|
||||||
|
if (missing.length) {
|
||||||
|
console.log(`请先配置 .env 文件中的 ${missing.join(',')}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('service type:', serviceType)
|
||||||
|
const { startWechatBot } = await import('./platforms/wechat/bot.js')
|
||||||
|
startWechatBot({ serviceType })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptAndStart() {
|
||||||
|
if (env.SERVICE_TYPE) {
|
||||||
|
await startWechat(env.SERVICE_TYPE)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const answer = await inquirer.prompt([
|
||||||
|
{
|
||||||
|
type: 'list',
|
||||||
|
name: 'serviceType',
|
||||||
|
message: '请先选择服务类型',
|
||||||
|
choices: serveList,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
await startWechat(answer.serviceType)
|
||||||
|
}
|
||||||
|
|
||||||
|
function printAnalysisResult(result) {
|
||||||
|
console.log(`分析对象:${result.target}`)
|
||||||
|
console.log(JSON.stringify(result.stats, null, 2))
|
||||||
|
if (result.analysis) {
|
||||||
|
console.log('\n分析结果:')
|
||||||
|
console.log(result.analysis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const program = new Command(name)
|
||||||
|
program.alias('we').description('一个基于 WeChaty 结合 AI 服务实现的微信机器人。').version(version, '-v, --version, -V')
|
||||||
|
|
||||||
|
program.option('-s, --serve <type>', '跳过交互,直接设置启动的服务类型').action(async () => {
|
||||||
|
const { serve } = program.opts()
|
||||||
|
if (serve) {
|
||||||
|
await startWechat(serve)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await promptAndStart()
|
||||||
|
})
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('start')
|
||||||
|
.description('启动微信 IM,终端展示二维码扫码登录')
|
||||||
|
.option('-s, --serve <type>', '跳过交互,直接设置启动的服务类型')
|
||||||
|
.action(async (options) => {
|
||||||
|
if (options.serve) {
|
||||||
|
await startWechat(options.serve)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await promptAndStart()
|
||||||
|
})
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('agent')
|
||||||
|
.description('启动外部 IM 通道,并使用指定 agent 处理消息')
|
||||||
|
.option('--im <channel>', '外部通信渠道:wechat', 'wechat')
|
||||||
|
.option('--agent <agent>', '消息处理 agent:pi 或其他 serve 类型', 'pi')
|
||||||
|
.action(async (options) => {
|
||||||
|
if (options.im !== 'wechat') {
|
||||||
|
console.log('当前 agent 命令只支持 --im wechat。飞书可先使用 wb lark login/send/messages/search。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await startWechat(options.agent)
|
||||||
|
})
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('analyze')
|
||||||
|
.description('分析本地捕获的微信聊天记录')
|
||||||
|
.option('--room <name>', '按群聊名称分析')
|
||||||
|
.option('--friend <name>', '按好友昵称或备注分析')
|
||||||
|
.option('--query <keyword>', '只分析包含关键词的消息')
|
||||||
|
.option('--start <iso>', '开始时间 ISO 8601')
|
||||||
|
.option('--end <iso>', '结束时间 ISO 8601')
|
||||||
|
.option('--limit <number>', '最多读取最近 N 条本地消息', '5000')
|
||||||
|
.option('-s, --serve <type>', '用于生成深度分析的 AI 服务', env.SERVICE_TYPE || 'ChatGPT')
|
||||||
|
.option('--stats-only', '只输出统计,不调用 AI 服务')
|
||||||
|
.action(async (options) => {
|
||||||
|
const config = getWechatRuntimeConfig()
|
||||||
|
const result = await analyzeWechatMessages({
|
||||||
|
...options,
|
||||||
|
serviceType: options.serve,
|
||||||
|
dataDir: config.dataDir,
|
||||||
|
limit: Number(options.limit),
|
||||||
|
})
|
||||||
|
printAnalysisResult(result)
|
||||||
|
})
|
||||||
|
|
||||||
|
const lark = program.command('lark').description('飞书 IM 登录、发消息和读取消息')
|
||||||
|
|
||||||
|
lark
|
||||||
|
.command('login')
|
||||||
|
.description('使用 lark-cli device flow 登录飞书 IM')
|
||||||
|
.option('--scope <scope>', '指定 scope,例:im:message:readonly')
|
||||||
|
.option('--domain <domain>', '按 domain 授权', 'im')
|
||||||
|
.option('--no-wait', '只生成授权链接/扫码信息,不阻塞等待授权完成')
|
||||||
|
.option('--device-code <code>', '继续完成上一次 --no-wait 返回的 device_code')
|
||||||
|
.action(async (options) => {
|
||||||
|
await larkLogin(options)
|
||||||
|
})
|
||||||
|
|
||||||
|
lark
|
||||||
|
.command('status')
|
||||||
|
.description('查看当前飞书授权状态')
|
||||||
|
.action(async () => {
|
||||||
|
await larkStatus()
|
||||||
|
})
|
||||||
|
|
||||||
|
lark
|
||||||
|
.command('send')
|
||||||
|
.description('发送飞书 IM 文本消息')
|
||||||
|
.option('--as <identity>', 'user 或 bot', 'user')
|
||||||
|
.option('--chat-id <chatId>', '群聊 ID,oc_xxx')
|
||||||
|
.option('--user-id <userId>', '用户 open_id,ou_xxx')
|
||||||
|
.requiredOption('--text <text>', '文本内容')
|
||||||
|
.action(async (options) => {
|
||||||
|
await larkSendText(options)
|
||||||
|
})
|
||||||
|
|
||||||
|
lark
|
||||||
|
.command('messages')
|
||||||
|
.description('读取某个飞书群聊或 P2P 会话消息')
|
||||||
|
.option('--as <identity>', 'user 或 bot', 'user')
|
||||||
|
.option('--chat-id <chatId>', '群聊 ID,oc_xxx')
|
||||||
|
.option('--user-id <userId>', '用户 open_id,ou_xxx')
|
||||||
|
.option('--start <iso>', '开始时间 ISO 8601')
|
||||||
|
.option('--end <iso>', '结束时间 ISO 8601')
|
||||||
|
.option('--page-size <number>', '分页大小', '50')
|
||||||
|
.option('--format <format>', 'json | pretty | table | ndjson | csv', 'pretty')
|
||||||
|
.action(async (options) => {
|
||||||
|
await larkListMessages(options)
|
||||||
|
})
|
||||||
|
|
||||||
|
lark
|
||||||
|
.command('search')
|
||||||
|
.description('搜索飞书 IM 消息')
|
||||||
|
.option('--query <keyword>', '搜索关键词')
|
||||||
|
.option('--chat-id <chatId>', '限制群聊 ID')
|
||||||
|
.option('--chat-type <type>', 'group 或 p2p')
|
||||||
|
.option('--start <iso>', '开始时间 ISO 8601')
|
||||||
|
.option('--end <iso>', '结束时间 ISO 8601')
|
||||||
|
.option('--page-all', '自动翻页')
|
||||||
|
.option('--page-limit <number>', '最多翻页数', '20')
|
||||||
|
.option('--format <format>', 'json | pretty | table | ndjson | csv', 'pretty')
|
||||||
|
.action(async (options) => {
|
||||||
|
await larkSearchMessages(options)
|
||||||
|
})
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('opencli')
|
||||||
|
.description('透传调用 OpenCLI,用于本地微信、朋友圈或其他本机工具')
|
||||||
|
.allowUnknownOption(true)
|
||||||
|
.argument('[args...]')
|
||||||
|
.action(async (args) => {
|
||||||
|
await runOpenCli(args)
|
||||||
|
})
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('wx')
|
||||||
|
.description('通过 OpenCLI wx-cli 访问本地微信聊天、联系人、群成员和朋友圈缓存')
|
||||||
|
.allowUnknownOption(true)
|
||||||
|
.argument('[args...]')
|
||||||
|
.action(async (args) => {
|
||||||
|
await runWxCli(args)
|
||||||
|
})
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('pi')
|
||||||
|
.description('透传调用 Pi coding agent')
|
||||||
|
.allowUnknownOption(true)
|
||||||
|
.argument('[args...]')
|
||||||
|
.action(async (args) => {
|
||||||
|
await runPi(args)
|
||||||
|
})
|
||||||
|
|
||||||
|
program.parseAsync().catch((error) => {
|
||||||
|
console.error(error.message)
|
||||||
|
process.exitCode = 1
|
||||||
|
})
|
||||||
8
src/kimi/__test__.js
Normal file
8
src/kimi/__test__.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { getKimiReply } from './index.js'
|
||||||
|
|
||||||
|
// 测试 open ai api
|
||||||
|
async function test() {
|
||||||
|
const message = await getKimiReply('你好!')
|
||||||
|
console.log('🌸🌸🌸 / message: ', message)
|
||||||
|
}
|
||||||
|
test()
|
||||||
94
src/kimi/index.js
Normal file
94
src/kimi/index.js
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import dotenv from 'dotenv'
|
||||||
|
const env = dotenv.config().parsed // 环境参数
|
||||||
|
|
||||||
|
const domain = 'https://api.moonshot.cn'
|
||||||
|
const server = {
|
||||||
|
chat: `${domain}/v1/chat/completions`,
|
||||||
|
models: `${domain}/v1/models`,
|
||||||
|
files: `${domain}/v1/files`,
|
||||||
|
token: `${domain}/v1/tokenizers/estimate-token-count`,
|
||||||
|
// 这块还可以实现上传文件让 kimi 读取并交互等操作
|
||||||
|
// 具体参考文档: https://platform.moonshot.cn/docs/api-reference#api-%E8%AF%B4%E6%98%8E
|
||||||
|
// 由于我近期非常忙碌,这块欢迎感兴趣的同学提 PR ,我会很快合并
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuration = {
|
||||||
|
// 参数详情请参考 https://platform.moonshot.cn/docs/api-reference#%E5%AD%97%E6%AE%B5%E8%AF%B4%E6%98%8E
|
||||||
|
/*
|
||||||
|
Model ID, 可以通过 List Models 获取
|
||||||
|
目前可选 moonshot-v1-8k | moonshot-v1-32k | moonshot-v1-128k
|
||||||
|
*/
|
||||||
|
model: 'moonshot-v1-8k',
|
||||||
|
/*
|
||||||
|
使用什么采样温度,介于 0 和 1 之间。较高的值(如 0.7)将使输出更加随机,而较低的值(如 0.2)将使其更加集中和确定性。
|
||||||
|
如果设置,值域须为 [0, 1] 我们推荐 0.3,以达到较合适的效果。
|
||||||
|
*/
|
||||||
|
temperature: 0.3,
|
||||||
|
/*
|
||||||
|
聊天完成时生成的最大 token 数。如果到生成了最大 token 数个结果仍然没有结束,finish reason 会是 "length", 否则会是 "stop"
|
||||||
|
这个值建议按需给个合理的值,如果不给的话,我们会给一个不错的整数比如 1024。特别要注意的是,这个 max_tokens 是指您期待我们返回的 token 长度,而不是输入 + 输出的总长度。
|
||||||
|
比如对一个 moonshot-v1-8k 模型,它的最大输入 + 输出总长度是 8192,当输入 messages 总长度为 4096 的时候,您最多只能设置为 4096,
|
||||||
|
否则我们服务会返回不合法的输入参数( invalid_request_error ),并拒绝回答。如果您希望获得“输入的精确 token 数”,可以使用下面的“计算 Token” API 使用我们的计算器获得计数。
|
||||||
|
*/
|
||||||
|
max_tokens: 5000,
|
||||||
|
/*
|
||||||
|
是否流式返回, 默认 false, 可选 true
|
||||||
|
*/
|
||||||
|
stream: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getKimiReply(prompt) {
|
||||||
|
try {
|
||||||
|
const res = await axios.post(
|
||||||
|
server.chat,
|
||||||
|
Object.assign(configuration, {
|
||||||
|
/*
|
||||||
|
包含迄今为止对话的消息列表。
|
||||||
|
要保持对话的上下文,需要将之前的对话历史并入到该数组
|
||||||
|
这是一个结构体的列表,每个元素类似如下:{"role": "user", "content": "你好"} role 只支持 system,user,assistant 其一,content 不得为空
|
||||||
|
*/
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: prompt,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
model: 'moonshot-v1-128k',
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
timeout: 120000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${env.KIMI_API_KEY}`,
|
||||||
|
},
|
||||||
|
// pass a http proxy agent
|
||||||
|
// proxy: {
|
||||||
|
// host: 'localhost',
|
||||||
|
// port: 7890,
|
||||||
|
// }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (!configuration.stream) return res.data.choices[0].message.content
|
||||||
|
|
||||||
|
let result = ''
|
||||||
|
const lines = res.data.split('\n').filter((line) => line.trim() !== '')
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
const messageObj = line.substring(6)
|
||||||
|
if (messageObj === '[DONE]') break
|
||||||
|
const message = JSON.parse(messageObj)
|
||||||
|
if (message.choices && message.choices[0].delta && message.choices[0].delta.content) {
|
||||||
|
result += message.choices[0].delta.content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Kimi 错误对应详情可参考官网: https://platform.moonshot.cn/docs/api-reference#%E9%94%99%E8%AF%AF%E8%AF%B4%E6%98%8E')
|
||||||
|
console.log('常见的 401 一般意味着你鉴权失败, 请检查你的 API_KEY 是否正确。')
|
||||||
|
console.log('常见的 429 一般意味着你被限制了请求频次,请求频率过高,或 kimi 服务器过载,可以适当调整请求频率,或者等待一段时间再试。')
|
||||||
|
console.error(error.code)
|
||||||
|
console.error(error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/ollama/__test__.js
Normal file
9
src/ollama/__test__.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { getDifyReply } from './index.js'
|
||||||
|
|
||||||
|
// 测试 dify api
|
||||||
|
async function testMessage() {
|
||||||
|
const message = await getDifyReply('hello')
|
||||||
|
console.log('🌸🌸🌸 / message: ', message)
|
||||||
|
}
|
||||||
|
|
||||||
|
testMessage()
|
||||||
45
src/ollama/index.js
Normal file
45
src/ollama/index.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import dotenv from 'dotenv'
|
||||||
|
// 加载环境变量
|
||||||
|
dotenv.config()
|
||||||
|
const env = dotenv.config().parsed // 环境参数
|
||||||
|
const url = env.OLLAMA_URL
|
||||||
|
const bot_name = env.BOT_NAME
|
||||||
|
const model_name = env.OLLAMA_MODEL
|
||||||
|
function createRequest(prompt) {
|
||||||
|
return {
|
||||||
|
method: 'post',
|
||||||
|
url: url,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
data: JSON.stringify({
|
||||||
|
model: model_name,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content: env.OLLAMA_SYSTEM_MESSAGE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: prompt,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
stream: false,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOllamaReply(prompt) {
|
||||||
|
try {
|
||||||
|
console.log('=============== ollama request start ======================')
|
||||||
|
const request = createRequest(prompt)
|
||||||
|
const res = await axios(request)
|
||||||
|
console.log('=============== ollama request finished ======================')
|
||||||
|
return res.data.message.content
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.code)
|
||||||
|
console.error(error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/openai/__test__.js
Normal file
9
src/openai/__test__.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { getGptReply } from './index.js'
|
||||||
|
|
||||||
|
// 测试 open ai api
|
||||||
|
async function testMessage() {
|
||||||
|
const message = await getGptReply('hello')
|
||||||
|
console.log('🌸🌸🌸 / message: ', message)
|
||||||
|
}
|
||||||
|
|
||||||
|
testMessage()
|
||||||
37
src/openai/index.js
Normal file
37
src/openai/index.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { remark } from 'remark'
|
||||||
|
import stripMarkdown from 'strip-markdown'
|
||||||
|
import OpenAIApi from 'openai'
|
||||||
|
import dotenv from 'dotenv'
|
||||||
|
const env = dotenv.config().parsed // 环境参数
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
const __dirname = path.resolve()
|
||||||
|
// 判断是否有 .env 文件, 没有则报错
|
||||||
|
const envPath = path.join(__dirname, '.env')
|
||||||
|
if (!fs.existsSync(envPath)) {
|
||||||
|
console.log('❌ 请先根据文档,创建并配置.env文件!')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = {
|
||||||
|
apiKey: env.OPENAI_API_KEY,
|
||||||
|
organization: '',
|
||||||
|
}
|
||||||
|
if (env.OPENAI_PROXY_URL) {
|
||||||
|
config.baseURL = env.OPENAI_PROXY_URL
|
||||||
|
}
|
||||||
|
const openai = new OpenAIApi(config)
|
||||||
|
const chosen_model = env.OPENAI_MODEL || 'gpt-4o'
|
||||||
|
export async function getGptReply(prompt) {
|
||||||
|
console.log('🚀🚀🚀 / prompt', prompt)
|
||||||
|
const response = await openai.chat.completions.create({
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: env.OPENAI_SYSTEM_MESSAGE },
|
||||||
|
{ role: 'user', content: prompt },
|
||||||
|
],
|
||||||
|
model: chosen_model,
|
||||||
|
})
|
||||||
|
console.log('🚀🚀🚀 / reply', response.choices[0].message.content)
|
||||||
|
return `${response.choices[0].message.content}`
|
||||||
|
}
|
||||||
13
src/pi/index.js
Normal file
13
src/pi/index.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { askPi } from '../adapters/pi.js'
|
||||||
|
|
||||||
|
export async function getPiReply(prompt) {
|
||||||
|
const agentPrompt = [
|
||||||
|
'你是当前 wechat-bot 项目的 Pi agent,通过 IM 渠道和外部用户沟通。',
|
||||||
|
'请直接回答用户问题;如果需要访问本地微信聊天、朋友圈、群成员或统计数据,优先建议或使用项目中的 wb wx / wb analyze 能力。',
|
||||||
|
'不要编造本地数据;没有读取到数据时要明确说明。',
|
||||||
|
'',
|
||||||
|
`用户消息:${prompt}`,
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
return askPi(agentPrompt)
|
||||||
|
}
|
||||||
75
src/platforms/wechat/bot.js
Normal file
75
src/platforms/wechat/bot.js
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { WechatyBuilder, ScanStatus, log } from 'wechaty'
|
||||||
|
import qrTerminal from 'qrcode-terminal'
|
||||||
|
import { defaultMessage } from '../../wechaty/sendMessage.js'
|
||||||
|
import { captureWechatMessage } from './messageStore.js'
|
||||||
|
import { getWechatRuntimeConfig } from '../../config/env.js'
|
||||||
|
|
||||||
|
function onScan(qrcode, status) {
|
||||||
|
if (status === ScanStatus.Waiting || status === ScanStatus.Timeout) {
|
||||||
|
qrTerminal.generate(qrcode, { small: true })
|
||||||
|
const qrcodeImageUrl = ['https://api.qrserver.com/v1/create-qr-code/?data=', encodeURIComponent(qrcode)].join('')
|
||||||
|
console.log('onScan:', qrcodeImageUrl, ScanStatus[status], status)
|
||||||
|
} else {
|
||||||
|
log.info('onScan: %s(%s)', ScanStatus[status], status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLogin(user) {
|
||||||
|
console.log(`${user} has logged in`)
|
||||||
|
const date = new Date()
|
||||||
|
console.log(`Current time:${date}`)
|
||||||
|
console.log('Automatic robot chat mode has been activated')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLogout(user) {
|
||||||
|
console.log(`${user} has logged out`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onFriendShip(friendship) {
|
||||||
|
const friendShipRe = /chatgpt|chat/
|
||||||
|
if (friendship.type() === 2 && friendShipRe.test(friendship.hello())) {
|
||||||
|
await friendship.accept()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWechatBot(options = {}) {
|
||||||
|
const config = getWechatRuntimeConfig()
|
||||||
|
const chromeBin = process.env.CHROME_BIN ? { endpoint: process.env.CHROME_BIN } : {}
|
||||||
|
const serviceType = options.serviceType || ''
|
||||||
|
|
||||||
|
const bot = WechatyBuilder.build({
|
||||||
|
name: 'WechatEveryDay',
|
||||||
|
puppet: 'wechaty-puppet-wechat4u',
|
||||||
|
puppetOptions: {
|
||||||
|
uos: true,
|
||||||
|
...chromeBin,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
bot.on('scan', onScan)
|
||||||
|
bot.on('login', onLogin)
|
||||||
|
bot.on('logout', onLogout)
|
||||||
|
bot.on('friendship', onFriendShip)
|
||||||
|
bot.on('message', async (message) => {
|
||||||
|
await captureWechatMessage(message, bot, {
|
||||||
|
dataDir: config.dataDir,
|
||||||
|
storeMessages: config.storeMessages,
|
||||||
|
})
|
||||||
|
await defaultMessage(message, bot, serviceType)
|
||||||
|
})
|
||||||
|
bot.on('error', (error) => {
|
||||||
|
console.error('bot error handle: ', error)
|
||||||
|
})
|
||||||
|
|
||||||
|
return bot
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startWechatBot(options = {}) {
|
||||||
|
const bot = createWechatBot(options)
|
||||||
|
bot
|
||||||
|
.start()
|
||||||
|
.then(() => console.log('Start to log in wechat...'))
|
||||||
|
.catch((error) => console.error('botStart error: ', error))
|
||||||
|
|
||||||
|
return bot
|
||||||
|
}
|
||||||
81
src/platforms/wechat/commandRouter.js
Normal file
81
src/platforms/wechat/commandRouter.js
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { analyzeWechatMessages } from '../../analysis/wechatAnalyzer.js'
|
||||||
|
import { getWechatRuntimeConfig } from '../../config/env.js'
|
||||||
|
import { runOpenCli } from '../../adapters/opencli.js'
|
||||||
|
|
||||||
|
function stripMention(content, botName) {
|
||||||
|
return content.replace(botName, '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTarget(tokens) {
|
||||||
|
const type = tokens[1]
|
||||||
|
const value = tokens.slice(2).join(' ').trim()
|
||||||
|
|
||||||
|
if (['群', '群聊', 'room', 'group'].includes(type)) {
|
||||||
|
return { room: value }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['好友', 'friend', 'contact'].includes(type)) {
|
||||||
|
return { friend: value }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleWechatCommand(content, context = {}) {
|
||||||
|
const config = getWechatRuntimeConfig()
|
||||||
|
const normalized = stripMention(content, config.botName)
|
||||||
|
|
||||||
|
if (!normalized.startsWith(config.commandPrefix)) {
|
||||||
|
return { handled: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const commandLine = normalized.slice(config.commandPrefix.length).trim()
|
||||||
|
const tokens = commandLine.split(/\s+/).filter(Boolean)
|
||||||
|
const command = tokens[0]
|
||||||
|
|
||||||
|
if (['分析', 'analyze', '统计', 'stats'].includes(command)) {
|
||||||
|
const statsOnly = ['统计', 'stats'].includes(command)
|
||||||
|
const target = parseTarget(tokens)
|
||||||
|
const result = await analyzeWechatMessages({
|
||||||
|
...target,
|
||||||
|
serviceType: context.serviceType,
|
||||||
|
dataDir: config.dataDir,
|
||||||
|
statsOnly,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (statsOnly || !result.analysis) {
|
||||||
|
return {
|
||||||
|
handled: true,
|
||||||
|
reply: [
|
||||||
|
`${result.target}`,
|
||||||
|
`消息数:${result.stats.totalMessages}`,
|
||||||
|
`文本消息:${result.stats.textMessages}`,
|
||||||
|
`平均长度:${result.stats.averageTextLength}`,
|
||||||
|
`高频发言:${result.stats.topSpeakers.map((item) => `${item.name}(${item.count})`).join(',') || '无'}`,
|
||||||
|
].join('\n'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
handled: true,
|
||||||
|
reply: result.analysis,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === 'opencli') {
|
||||||
|
if (!config.enableRemoteOpenCli) {
|
||||||
|
return {
|
||||||
|
handled: true,
|
||||||
|
reply: '远程 OpenCLI 执行未开启。需要在 .env 中显式设置 ENABLE_REMOTE_OPENCLI=true。',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await runOpenCli(tokens.slice(1))
|
||||||
|
return {
|
||||||
|
handled: true,
|
||||||
|
reply: 'OpenCLI 命令已执行,结果请看本机控制台。',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { handled: false }
|
||||||
|
}
|
||||||
96
src/platforms/wechat/messageStore.js
Normal file
96
src/platforms/wechat/messageStore.js
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
const MESSAGE_FILE = 'messages.jsonl'
|
||||||
|
|
||||||
|
function ensureDir(dir) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMessageStorePath(dataDir = '.data/wechat') {
|
||||||
|
return path.resolve(process.cwd(), dataDir, MESSAGE_FILE)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function captureWechatMessage(message, bot, options = {}) {
|
||||||
|
const dataDir = options.dataDir || '.data/wechat'
|
||||||
|
const storeMessages = options.storeMessages !== false
|
||||||
|
if (!storeMessages) return null
|
||||||
|
|
||||||
|
const talker = message.talker()
|
||||||
|
const receiver = message.to()
|
||||||
|
const room = message.room()
|
||||||
|
const isText = message.type() === bot.Message.Type.Text
|
||||||
|
const roomName = room ? await room.topic() : ''
|
||||||
|
const talkerAlias = talker ? await talker.alias() : ''
|
||||||
|
const talkerName = talker ? await talker.name() : ''
|
||||||
|
const receiverName = receiver ? await receiver.name() : ''
|
||||||
|
|
||||||
|
const record = {
|
||||||
|
id: message.id,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
type: message.type(),
|
||||||
|
typeName: bot.Message.Type[message.type()] || String(message.type()),
|
||||||
|
isText,
|
||||||
|
isRoom: Boolean(room),
|
||||||
|
roomName,
|
||||||
|
talkerName,
|
||||||
|
talkerAlias,
|
||||||
|
receiverName,
|
||||||
|
text: isText ? message.text() : '',
|
||||||
|
self: Boolean(talker?.self?.()),
|
||||||
|
}
|
||||||
|
|
||||||
|
const storePath = getMessageStorePath(dataDir)
|
||||||
|
ensureDir(path.dirname(storePath))
|
||||||
|
fs.appendFileSync(storePath, `${JSON.stringify(record)}\n`, 'utf8')
|
||||||
|
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadWechatMessages(options = {}) {
|
||||||
|
const storePath = getMessageStorePath(options.dataDir)
|
||||||
|
if (!fs.existsSync(storePath)) return []
|
||||||
|
|
||||||
|
const lines = fs
|
||||||
|
.readFileSync(storePath, 'utf8')
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
const limit = Number(options.limit || 0)
|
||||||
|
const selectedLines = limit > 0 ? lines.slice(-limit) : lines
|
||||||
|
|
||||||
|
return selectedLines
|
||||||
|
.map((line) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(line)
|
||||||
|
} catch (error) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterWechatMessages(records, filters = {}) {
|
||||||
|
const startTime = filters.start ? new Date(filters.start).getTime() : null
|
||||||
|
const endTime = filters.end ? new Date(filters.end).getTime() : null
|
||||||
|
const query = filters.query ? filters.query.toLowerCase() : ''
|
||||||
|
|
||||||
|
return records.filter((record) => {
|
||||||
|
if (filters.room && record.roomName !== filters.room) return false
|
||||||
|
if (filters.friend) {
|
||||||
|
const names = [record.talkerName, record.talkerAlias, record.receiverName].filter(Boolean)
|
||||||
|
if (!names.includes(filters.friend)) return false
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
query &&
|
||||||
|
!String(record.text || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(query)
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
if (startTime && new Date(record.timestamp).getTime() < startTime) return false
|
||||||
|
if (endTime && new Date(record.timestamp).getTime() > endTime) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
42
src/tongyi/index.js
Normal file
42
src/tongyi/index.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import dotenv from 'dotenv'
|
||||||
|
import OpenAI from 'openai'
|
||||||
|
|
||||||
|
const env = dotenv.config().parsed // 环境参数
|
||||||
|
// 加载环境变量
|
||||||
|
dotenv.config()
|
||||||
|
const url = env.TONGYI_URL
|
||||||
|
const api_key = env.TONGYI_API_KEY
|
||||||
|
const model_name = env.TONGYI_MODEL || 'qwen-plus'
|
||||||
|
|
||||||
|
const openai = new OpenAI({
|
||||||
|
apiKey: api_key,
|
||||||
|
baseURL: url,
|
||||||
|
temperature: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const __dirname = path.resolve()
|
||||||
|
// 判断是否有 .env 文件, 没有则报错
|
||||||
|
const envPath = path.join(__dirname, '.env')
|
||||||
|
if (!fs.existsSync(envPath)) {
|
||||||
|
console.log('❌ 请先根据文档,创建并配置 .env 文件!')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTongyiReply(prompt) {
|
||||||
|
const completion = await openai.chat.completions.create({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: prompt + ' ,用中文回答',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
model: model_name,
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('🚀🚀🚀 / prompt', prompt)
|
||||||
|
const Content = await completion.choices[0].message.content
|
||||||
|
console.log('🚀🚀🚀 / reply', Content)
|
||||||
|
return `${Content}`
|
||||||
|
}
|
||||||
53
src/utils/process.js
Normal file
53
src/utils/process.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { spawn } from 'child_process'
|
||||||
|
|
||||||
|
export function splitCommand(command) {
|
||||||
|
if (!command) return []
|
||||||
|
return command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g)?.map((part) => part.replace(/^["']|["']$/g, '')) || []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runCommand(command, args = [], options = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(command, args, {
|
||||||
|
cwd: options.cwd || process.cwd(),
|
||||||
|
env: options.env || process.env,
|
||||||
|
stdio: options.stdio || ['ignore', 'pipe', 'pipe'],
|
||||||
|
})
|
||||||
|
|
||||||
|
let stdout = ''
|
||||||
|
let stderr = ''
|
||||||
|
|
||||||
|
if (child.stdout) {
|
||||||
|
child.stdout.on('data', (chunk) => {
|
||||||
|
stdout += chunk.toString()
|
||||||
|
if (options.echo) process.stdout.write(chunk)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (child.stderr) {
|
||||||
|
child.stderr.on('data', (chunk) => {
|
||||||
|
stderr += chunk.toString()
|
||||||
|
if (options.echo) process.stderr.write(chunk)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
child.on('error', reject)
|
||||||
|
child.on('close', (code) => {
|
||||||
|
resolve({ code, stdout, stderr })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function streamCommand(command, args = [], options = {}) {
|
||||||
|
const result = await runCommand(command, args, {
|
||||||
|
...options,
|
||||||
|
stdio: options.stdio || 'inherit',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.code !== 0) {
|
||||||
|
const error = new Error(`${command} exited with code ${result.code}`)
|
||||||
|
error.result = result
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
136
src/wechaty/sendMessage.js
Normal file
136
src/wechaty/sendMessage.js
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { getServe } from './serve.js'
|
||||||
|
import { getWechatRuntimeConfig } from '../config/env.js'
|
||||||
|
import { handleWechatCommand } from '../platforms/wechat/commandRouter.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认消息发送
|
||||||
|
* @param msg
|
||||||
|
* @param bot
|
||||||
|
* @param ServiceType 服务类型 'GPT' | 'Kimi'
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export async function defaultMessage(msg, bot, ServiceType = 'GPT') {
|
||||||
|
const { botName, autoReplyPrefix, aliasWhiteList, roomWhiteList, commandPrefix } = getWechatRuntimeConfig()
|
||||||
|
console.log({ botName, autoReplyPrefix, aliasWhiteList, roomWhiteList, commandPrefix })
|
||||||
|
const getReply = getServe(ServiceType)
|
||||||
|
const contact = msg.talker() // 发消息人
|
||||||
|
const receiver = msg.to() // 消息接收人
|
||||||
|
const content = msg.text() // 消息内容
|
||||||
|
const room = msg.room() // 是否是群消息
|
||||||
|
const roomName = (await room?.topic()) || null // 群名称
|
||||||
|
const alias = (await contact.alias()) || (await contact.name()) // 发消息人昵称
|
||||||
|
const remarkName = await contact.alias() // 备注名称
|
||||||
|
const name = await contact.name() // 微信名称
|
||||||
|
const isText = msg.type() === bot.Message.Type.Text // 消息类型是否为文本
|
||||||
|
const isRoom = roomWhiteList.includes(roomName) && content.includes(`${botName}`) // 是否在群聊白名单内并且艾特了机器人
|
||||||
|
const isAlias = aliasWhiteList.includes(remarkName) || aliasWhiteList.includes(name) // 发消息的人是否在联系人白名单内
|
||||||
|
const isBotSelf = botName === `@${remarkName}` || botName === `@${name}` // 是否是机器人自己
|
||||||
|
const isBotSelfDebug = content.trimStart().startsWith('你是谁') // 是否是机器人自己的调试消息
|
||||||
|
const isAuthorizedCommand = (room && isRoom) || (!room && isAlias)
|
||||||
|
// TODO 你们可以根据自己的需求修改这里的逻辑
|
||||||
|
if ((isBotSelf && !isBotSelfDebug) || !isText) return // 如果是机器人自己发送的消息或者消息类型不是文本则不处理
|
||||||
|
try {
|
||||||
|
if (content.replace(`${botName}`, '').trimStart().startsWith(commandPrefix)) {
|
||||||
|
if (!isAuthorizedCommand) return
|
||||||
|
const commandResult = await handleWechatCommand(content, {
|
||||||
|
serviceType: ServiceType,
|
||||||
|
roomName,
|
||||||
|
alias,
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
if (commandResult.handled) {
|
||||||
|
if (commandResult.reply) {
|
||||||
|
await (room || contact).say(commandResult.reply)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 区分群聊和私聊
|
||||||
|
// 群聊消息去掉艾特主体后,匹配自动回复前缀
|
||||||
|
if (isRoom && room && content.replace(`${botName}`, '').trimStart().startsWith(`${autoReplyPrefix}`)) {
|
||||||
|
const question = (await msg.mentionText()) || content.replace(`${botName}`, '').replace(`${autoReplyPrefix}`, '') // 去掉艾特的消息主体
|
||||||
|
console.log('🌸🌸🌸 / question: ', question)
|
||||||
|
const response = await getReply(question)
|
||||||
|
await room.say(response)
|
||||||
|
}
|
||||||
|
// 私人聊天,白名单内的直接发送
|
||||||
|
// 私人聊天直接匹配自动回复前缀
|
||||||
|
if (isAlias && !room && content.trimStart().startsWith(`${autoReplyPrefix}`)) {
|
||||||
|
const question = content.replace(`${autoReplyPrefix}`, '')
|
||||||
|
console.log('🌸🌸🌸 / content: ', question)
|
||||||
|
const response = await getReply(question)
|
||||||
|
await contact.say(response)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分片消息发送
|
||||||
|
* @param message
|
||||||
|
* @param bot
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export async function shardingMessage(message, bot) {
|
||||||
|
const talker = message.talker()
|
||||||
|
const isText = message.type() === bot.Message.Type.Text // 消息类型是否为文本
|
||||||
|
if (talker.self() || message.type() > 10 || (talker.name() === '微信团队' && isText)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const text = message.text()
|
||||||
|
const room = message.room()
|
||||||
|
if (!room) {
|
||||||
|
console.log(`Chat GPT Enabled User: ${talker.name()}`)
|
||||||
|
const response = await getChatGPTReply(text)
|
||||||
|
await trySay(talker, response)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let realText = splitMessage(text)
|
||||||
|
// 如果是群聊但不是指定艾特人那么就不进行发送消息
|
||||||
|
if (text.indexOf(`${botName}`) === -1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
realText = text.replace(`${botName}`, '')
|
||||||
|
const topic = await room.topic()
|
||||||
|
const response = await getChatGPTReply(realText)
|
||||||
|
const result = `${realText}\n ---------------- \n ${response}`
|
||||||
|
await trySay(room, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分片长度
|
||||||
|
const SINGLE_MESSAGE_MAX_SIZE = 500
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送
|
||||||
|
* @param talker 发送哪个 room为群聊类 text为单人
|
||||||
|
* @param msg
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async function trySay(talker, msg) {
|
||||||
|
const messages = []
|
||||||
|
let message = msg
|
||||||
|
while (message.length > SINGLE_MESSAGE_MAX_SIZE) {
|
||||||
|
messages.push(message.slice(0, SINGLE_MESSAGE_MAX_SIZE))
|
||||||
|
message = message.slice(SINGLE_MESSAGE_MAX_SIZE)
|
||||||
|
}
|
||||||
|
messages.push(message)
|
||||||
|
for (const msg of messages) {
|
||||||
|
await talker.say(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分组消息
|
||||||
|
* @param text
|
||||||
|
* @returns {Promise<*>}
|
||||||
|
*/
|
||||||
|
async function splitMessage(text) {
|
||||||
|
let realText = text
|
||||||
|
const item = text.split('- - - - - - - - - - - - - - -')
|
||||||
|
if (item.length > 1) {
|
||||||
|
realText = item[item.length - 1]
|
||||||
|
}
|
||||||
|
return realText
|
||||||
|
}
|
||||||
42
src/wechaty/serve.js
Normal file
42
src/wechaty/serve.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
function lazyServe(loader, exportName) {
|
||||||
|
return async (...args) => {
|
||||||
|
const module = await loader()
|
||||||
|
return module[exportName](...args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 AI 服务
|
||||||
|
* @param serviceType 服务类型
|
||||||
|
* @returns {Function}
|
||||||
|
*/
|
||||||
|
export function getServe(serviceType) {
|
||||||
|
switch (serviceType) {
|
||||||
|
case 'ChatGPT':
|
||||||
|
return lazyServe(() => import('../openai/index.js'), 'getGptReply')
|
||||||
|
case 'doubao':
|
||||||
|
return lazyServe(() => import('../doubao/index.js'), 'getDoubaoReply')
|
||||||
|
case 'deepseek':
|
||||||
|
return lazyServe(() => import('../deepseek/index.js'), 'getDeepseekReply')
|
||||||
|
case 'Kimi':
|
||||||
|
return lazyServe(() => import('../kimi/index.js'), 'getKimiReply')
|
||||||
|
case 'Xunfei':
|
||||||
|
return lazyServe(() => import('../xunfei/index.js'), 'getXunfeiReply')
|
||||||
|
case 'deepseek-free':
|
||||||
|
return lazyServe(() => import('../deepseek-free/index.js'), 'getDeepSeekFreeReply')
|
||||||
|
case '302AI':
|
||||||
|
return lazyServe(() => import('../302ai/index.js'), 'get302AiReply')
|
||||||
|
case 'dify':
|
||||||
|
return lazyServe(() => import('../dify/index.js'), 'getDifyReply')
|
||||||
|
case 'ollama':
|
||||||
|
return lazyServe(() => import('../ollama/index.js'), 'getOllamaReply')
|
||||||
|
case 'tongyi':
|
||||||
|
return lazyServe(() => import('../tongyi/index.js'), 'getTongyiReply')
|
||||||
|
case 'claude':
|
||||||
|
return lazyServe(() => import('../claude/index.js'), 'getClaudeReply')
|
||||||
|
case 'pi':
|
||||||
|
return lazyServe(() => import('../pi/index.js'), 'getPiReply')
|
||||||
|
default:
|
||||||
|
return lazyServe(() => import('../openai/index.js'), 'getGptReply')
|
||||||
|
}
|
||||||
|
}
|
||||||
105
src/wechaty/testMessage.js
Normal file
105
src/wechaty/testMessage.js
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { getGptReply } from '../openai/index.js'
|
||||||
|
import { getKimiReply } from '../kimi/index.js'
|
||||||
|
import { getXunfeiReply } from '../xunfei/index.js'
|
||||||
|
import dotenv from 'dotenv'
|
||||||
|
import inquirer from 'inquirer'
|
||||||
|
import { getDeepSeekFreeReply } from '../deepseek-free/index.js'
|
||||||
|
import { get302AiReply } from '../302ai/index.js'
|
||||||
|
import { getDifyReply } from '../dify/index.js'
|
||||||
|
import { getOllamaReply } from '../ollama/index.js'
|
||||||
|
const env = dotenv.config().parsed // 环境参数
|
||||||
|
|
||||||
|
// 控制启动
|
||||||
|
async function handleRequest(type) {
|
||||||
|
console.log('type: ', type)
|
||||||
|
switch (type) {
|
||||||
|
case 'ChatGPT':
|
||||||
|
if (env.OPENAI_API_KEY) {
|
||||||
|
const message = await getGptReply('hello')
|
||||||
|
console.log('🌸🌸🌸 / reply: ', message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log('❌ 请先配置.env文件中的 OPENAI_API_KEY')
|
||||||
|
break
|
||||||
|
case 'Kimi':
|
||||||
|
if (env.KIMI_API_KEY) {
|
||||||
|
const message = await getKimiReply('你好!')
|
||||||
|
console.log('🌸🌸🌸 / reply: ', message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log('❌ 请先配置.env文件中的 KIMI_API_KEY')
|
||||||
|
break
|
||||||
|
case 'Xunfei':
|
||||||
|
if (env.XUNFEI_APP_ID && env.XUNFEI_API_KEY && env.XUNFEI_API_SECRET) {
|
||||||
|
const message = await getXunfeiReply('你好!')
|
||||||
|
console.log('🌸🌸🌸 / reply: ', message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log('❌ 请先配置.env文件中的 XUNFEI_APP_ID,XUNFEI_API_KEY,XUNFEI_API_SECRET')
|
||||||
|
break
|
||||||
|
case 'deepseek-free':
|
||||||
|
if (env.DEEPSEEK_FREE_URL && env.DEEPSEEK_FREE_TOKEN && env.DEEPSEEK_FREE_MODEL) {
|
||||||
|
const message = await getDeepSeekFreeReply('你好!')
|
||||||
|
console.log('🌸🌸🌸 / reply: ', message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log('❌ 请先配置.env文件中的 DEEPSEEK_FREE_URL,DEEPSEEK_FREE_TOKEN,DEEPSEEK_FREE_MODEL')
|
||||||
|
break
|
||||||
|
case 'dify':
|
||||||
|
if (env.DIFY_API_KEY) {
|
||||||
|
const message = await getDifyReply('hello')
|
||||||
|
console.log('🌸🌸🌸 / reply: ', message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log('❌ 请先配置.env文件中的 DIFY_API_KEY, DIFY_URL')
|
||||||
|
break
|
||||||
|
case '302AI':
|
||||||
|
if (env._302AI_API_KEY) {
|
||||||
|
const message = await get302AiReply('hello')
|
||||||
|
console.log('🌸🌸🌸 / reply: ', message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log('❌ 请先配置.env文件中的 _302AI_API_KEY')
|
||||||
|
break
|
||||||
|
case 'ollama':
|
||||||
|
if (env.OLLAMA_URL) {
|
||||||
|
const message = await getOllamaReply('hello')
|
||||||
|
console.log('🌸🌸🌸 / reply: ', message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log('❌ 请先配置.env文件中的 OLLAMA_URL')
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
console.log('🚀服务类型错误')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const serveList = [
|
||||||
|
{ name: 'ChatGPT', value: 'ChatGPT' },
|
||||||
|
{ name: 'Kimi', value: 'Kimi' },
|
||||||
|
{ name: 'Xunfei', value: 'Xunfei' },
|
||||||
|
{ name: 'deepseek-free', value: 'deepseek-free' },
|
||||||
|
{ name: '302AI', value: '302AI' },
|
||||||
|
{ name: 'dify', value: 'dify' },
|
||||||
|
// ... 欢迎大家接入更多的服务
|
||||||
|
{ name: 'ollama', value: 'ollama' },
|
||||||
|
]
|
||||||
|
const questions = [
|
||||||
|
{
|
||||||
|
type: 'list',
|
||||||
|
name: 'serviceType', //存储当前问题回答的变量key,
|
||||||
|
message: '请先选择服务类型',
|
||||||
|
choices: serveList,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
function init() {
|
||||||
|
inquirer
|
||||||
|
.prompt(questions)
|
||||||
|
.then((res) => {
|
||||||
|
handleRequest(res.serviceType)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.log('🚀error:', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
init()
|
||||||
9
src/xunfei/__test__.js
Normal file
9
src/xunfei/__test__.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { getXunfeiReply } from './index.js'
|
||||||
|
|
||||||
|
// 测试 科大讯飞 api
|
||||||
|
async function testMessage() {
|
||||||
|
const message = await getXunfeiReply('秦始皇的儿子是谁?')
|
||||||
|
console.log('🌸🌸🌸 / message: ', message)
|
||||||
|
}
|
||||||
|
|
||||||
|
testMessage()
|
||||||
9
src/xunfei/index.js
Normal file
9
src/xunfei/index.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { xunfeiSendMsg } from './xunfei.js'
|
||||||
|
|
||||||
|
export async function getXunfeiReply(prompt, name) {
|
||||||
|
console.log('🚀🚀🚀 / prompt', prompt)
|
||||||
|
let reply = await xunfeiSendMsg(prompt)
|
||||||
|
|
||||||
|
if (typeof name != 'undefined') reply = `@${name}\n ${reply}`
|
||||||
|
return `${reply}`
|
||||||
|
}
|
||||||
133
src/xunfei/xunfei.js
Normal file
133
src/xunfei/xunfei.js
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import CryptoJS from "crypto-js";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
import WebSocket from "ws";
|
||||||
|
|
||||||
|
const env = dotenv.config().parsed; // 环境参数
|
||||||
|
|
||||||
|
// APPID,APISecret,APIKey在 https://console.xfyun.cn/services/cbm 获取
|
||||||
|
const appID = env.XUNFEI_APP_ID;
|
||||||
|
const apiKey = env.XUNFEI_API_KEY;
|
||||||
|
const apiSecret = env.XUNFEI_API_SECRET;
|
||||||
|
|
||||||
|
// 地址必须填写,代表着大模型的版本号
|
||||||
|
const modelVersion = env.XUNFEI_MODEL_VERSION || "v4.0"; // 默认值 "v4.0"
|
||||||
|
const httpUrl = new URL(`https://spark-api.xf-yun.com/${modelVersion}/chat`);
|
||||||
|
|
||||||
|
// 判断 prompt 是否存在,如果不存在则使用默认值
|
||||||
|
const prompt = env.XUNFEI_PROMPT || "你是一个专业的智能助手";
|
||||||
|
|
||||||
|
// 动态映射模型版本到 domain 的逻辑
|
||||||
|
const modelVersionMap = {
|
||||||
|
"v1.1": "general",
|
||||||
|
"v2.1": "generalv2",
|
||||||
|
"v3.1": "generalv3",
|
||||||
|
"v3.5": "generalv3.5",
|
||||||
|
"pro-128k": "pro-128k",
|
||||||
|
"max-32k": "max-32k",
|
||||||
|
"v4.0": "4.0Ultra",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取模型域名
|
||||||
|
function getModelDomain(httpUrl) {
|
||||||
|
try {
|
||||||
|
const modelPath = httpUrl.pathname.split("/")[1]; // 提取版本号或模型路径
|
||||||
|
return modelVersionMap[modelPath] || "unknown"; // 如果没有匹配,返回 "unknown"
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取模型域名失败:", error);
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let modelDomain = getModelDomain(httpUrl);
|
||||||
|
|
||||||
|
// 签名生成逻辑(可复用)
|
||||||
|
function generateSignature(httpUrl, apiKey, apiSecret) {
|
||||||
|
const host = "localhost:8080";
|
||||||
|
const date = new Date().toGMTString();
|
||||||
|
const algorithm = "hmac-sha256";
|
||||||
|
const headers = "host date request-line";
|
||||||
|
|
||||||
|
const signatureOrigin = `host: ${host}\ndate: ${date}\nGET ${httpUrl.pathname} HTTP/1.1`;
|
||||||
|
const signatureSha = CryptoJS.HmacSHA256(signatureOrigin, apiSecret);
|
||||||
|
const signature = CryptoJS.enc.Base64.stringify(signatureSha);
|
||||||
|
|
||||||
|
const authorizationOrigin = `api_key="${apiKey}", algorithm="${algorithm}", headers="${headers}", signature="${signature}"`;
|
||||||
|
const authorization = btoa(authorizationOrigin);
|
||||||
|
|
||||||
|
const url = `wss://${httpUrl.host}${httpUrl.pathname}?authorization=${authorization}&date=${date}&host=${host}`;
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 WebSocket 地址
|
||||||
|
function authenticate() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
const url = generateSignature(httpUrl, apiKey, apiSecret);
|
||||||
|
resolve(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("认证失败:", error);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送消息并处理 WebSocket 逻辑
|
||||||
|
export async function xunfeiSendMsg(inputVal) {
|
||||||
|
// 获取请求地址
|
||||||
|
let myUrl = await authenticate();
|
||||||
|
let socket = new WebSocket(String(myUrl));
|
||||||
|
let total_res = ""; // 清空回答历史
|
||||||
|
|
||||||
|
// 创建一个Promise
|
||||||
|
let messagePromise = new Promise((resolve, reject) => {
|
||||||
|
socket.addEventListener("open", () => {
|
||||||
|
const params = {
|
||||||
|
header: {
|
||||||
|
app_id: appID,
|
||||||
|
uid: "fd3f47e4-d",
|
||||||
|
},
|
||||||
|
parameter: {
|
||||||
|
chat: {
|
||||||
|
domain: modelDomain,
|
||||||
|
temperature: 0.8,
|
||||||
|
max_tokens: 1024,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
message: {
|
||||||
|
text: [
|
||||||
|
{ role: "system", content: prompt },
|
||||||
|
{ role: "user", content: inputVal }, // 最新的问题
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
socket.send(JSON.stringify(params));
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener("message", (event) => {
|
||||||
|
const data = JSON.parse(String(event.data));
|
||||||
|
if (data.header.code !== 0) {
|
||||||
|
console.error("Socket 出错:", data.header.code, data.header.message);
|
||||||
|
socket.close();
|
||||||
|
reject("");
|
||||||
|
} else if (data.payload.choices.text && data.header.status === 2) {
|
||||||
|
total_res += data.payload.choices.text[0].content;
|
||||||
|
setTimeout(() => {
|
||||||
|
socket.close();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener("close", () => {
|
||||||
|
resolve(total_res);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener("error", (event) => {
|
||||||
|
console.error("Socket 连接错误:", event);
|
||||||
|
reject("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return await messagePromise;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user