commit 6aebc60bfdc1af9b7751635812207f5d7cdaeafc Author: cirry <812852553@qq.com> Date: Tue Jun 9 14:50:53 2026 +0800 first commit diff --git a/.data/wechat/messages.jsonl b/.data/wechat/messages.jsonl new file mode 100644 index 0000000..5d56257 --- /dev/null +++ b/.data/wechat/messages.jsonl @@ -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} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bb6de05 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ + +node_modules/ \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ebab9e7 --- /dev/null +++ b/.env.example @@ -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' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d4ee56c --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules +WechatEveryDay.memory-card.json +.env +test.js +package-lock.json +yarn.lock +Chromium.app + +.DS_Store +.idea \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..d0a7784 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npx lint-staged \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..bf8922a --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +# puppeteer_download_host=https://registry.npmmirror.com/-/binary/ \ No newline at end of file diff --git a/.prettierrc.cjs b/.prettierrc.cjs new file mode 100644 index 0000000..9a21c15 --- /dev/null +++ b/.prettierrc.cjs @@ -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', +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..978009b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "cSpell.words": ["kimi", "xunfei"] +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ea50c45 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Dockerfile.alpine b/Dockerfile.alpine new file mode 100644 index 0000000..4288be4 --- /dev/null +++ b/Dockerfile.alpine @@ -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"] diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..f92b339 --- /dev/null +++ b/LICENSE.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..24bfaf1 --- /dev/null +++ b/README.md @@ -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 协议存在风控和封号风险。请只在你明确接受风险的账号和场景中使用,优先控制白名单和使用范围。 + +
+ +## 贡献者们 + + +
|
+| --- | --- |
+
+### 运行报错等问题
+
+首先你需要做到以下几点:
+
+- 拉取最新代码,重新安装依赖(删除 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).
diff --git a/RECORD.md b/RECORD.md
new file mode 100644
index 0000000..738d58d
--- /dev/null
+++ b/RECORD.md
@@ -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/
diff --git a/cli.js b/cli.js
new file mode 100644
index 0000000..f1bf3f8
--- /dev/null
+++ b/cli.js
@@ -0,0 +1,4 @@
+#! /usr/bin/env node
+
+'use strict'
+import('./src/index.js')
diff --git a/docs/pi-im-agent.md b/docs/pi-im-agent.md
new file mode 100644
index 0000000..797ba9a
--- /dev/null
+++ b/docs/pi-im-agent.md
@@ -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 -- ', '继续完成上一次 --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 ', 'user 或 bot', 'user')
+ .option('--chat-id ', '群聊 ID,oc_xxx')
+ .option('--user-id ', '用户 open_id,ou_xxx')
+ .requiredOption('--text ', '文本内容')
+ .action(async (options) => {
+ await larkSendText(options)
+ })
+
+lark
+ .command('messages')
+ .description('读取某个飞书群聊或 P2P 会话消息')
+ .option('--as ', 'user 或 bot', 'user')
+ .option('--chat-id ', '群聊 ID,oc_xxx')
+ .option('--user-id ', '用户 open_id,ou_xxx')
+ .option('--start ', '开始时间 ISO 8601')
+ .option('--end ', '结束时间 ISO 8601')
+ .option('--page-size ', '分页大小', '50')
+ .option('--format ', 'json | pretty | table | ndjson | csv', 'pretty')
+ .action(async (options) => {
+ await larkListMessages(options)
+ })
+
+lark
+ .command('search')
+ .description('搜索飞书 IM 消息')
+ .option('--query ', '搜索关键词')
+ .option('--chat-id ', '限制群聊 ID')
+ .option('--chat-type ', 'group 或 p2p')
+ .option('--start ', '开始时间 ISO 8601')
+ .option('--end ', '结束时间 ISO 8601')
+ .option('--page-all', '自动翻页')
+ .option('--page-limit ', '最多翻页数', '20')
+ .option('--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
+})
diff --git a/src/kimi/__test__.js b/src/kimi/__test__.js
new file mode 100644
index 0000000..2920a69
--- /dev/null
+++ b/src/kimi/__test__.js
@@ -0,0 +1,8 @@
+import { getKimiReply } from './index.js'
+
+// 测试 open ai api
+async function test() {
+ const message = await getKimiReply('你好!')
+ console.log('🌸🌸🌸 / message: ', message)
+}
+test()
diff --git a/src/kimi/index.js b/src/kimi/index.js
new file mode 100644
index 0000000..2a238bb
--- /dev/null
+++ b/src/kimi/index.js
@@ -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)
+ }
+}
diff --git a/src/ollama/__test__.js b/src/ollama/__test__.js
new file mode 100644
index 0000000..e8047b1
--- /dev/null
+++ b/src/ollama/__test__.js
@@ -0,0 +1,9 @@
+import { getDifyReply } from './index.js'
+
+// 测试 dify api
+async function testMessage() {
+ const message = await getDifyReply('hello')
+ console.log('🌸🌸🌸 / message: ', message)
+}
+
+testMessage()
diff --git a/src/ollama/index.js b/src/ollama/index.js
new file mode 100644
index 0000000..a3c2ccc
--- /dev/null
+++ b/src/ollama/index.js
@@ -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)
+ }
+}
diff --git a/src/openai/__test__.js b/src/openai/__test__.js
new file mode 100644
index 0000000..becb4bc
--- /dev/null
+++ b/src/openai/__test__.js
@@ -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()
diff --git a/src/openai/index.js b/src/openai/index.js
new file mode 100644
index 0000000..052d4a2
--- /dev/null
+++ b/src/openai/index.js
@@ -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}`
+}
diff --git a/src/pi/index.js b/src/pi/index.js
new file mode 100644
index 0000000..228f1f5
--- /dev/null
+++ b/src/pi/index.js
@@ -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)
+}
diff --git a/src/platforms/wechat/bot.js b/src/platforms/wechat/bot.js
new file mode 100644
index 0000000..b628d87
--- /dev/null
+++ b/src/platforms/wechat/bot.js
@@ -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
+}
diff --git a/src/platforms/wechat/commandRouter.js b/src/platforms/wechat/commandRouter.js
new file mode 100644
index 0000000..b76365d
--- /dev/null
+++ b/src/platforms/wechat/commandRouter.js
@@ -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 }
+}
diff --git a/src/platforms/wechat/messageStore.js b/src/platforms/wechat/messageStore.js
new file mode 100644
index 0000000..9eb518f
--- /dev/null
+++ b/src/platforms/wechat/messageStore.js
@@ -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
+ })
+}
diff --git a/src/tongyi/index.js b/src/tongyi/index.js
new file mode 100644
index 0000000..1076bc8
--- /dev/null
+++ b/src/tongyi/index.js
@@ -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}`
+}
diff --git a/src/utils/process.js b/src/utils/process.js
new file mode 100644
index 0000000..ea5bb86
--- /dev/null
+++ b/src/utils/process.js
@@ -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
+}
diff --git a/src/wechaty/sendMessage.js b/src/wechaty/sendMessage.js
new file mode 100644
index 0000000..a86f50e
--- /dev/null
+++ b/src/wechaty/sendMessage.js
@@ -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}
+ */
+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}
+ */
+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}
+ */
+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
+}
diff --git a/src/wechaty/serve.js b/src/wechaty/serve.js
new file mode 100644
index 0000000..281ea07
--- /dev/null
+++ b/src/wechaty/serve.js
@@ -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')
+ }
+}
diff --git a/src/wechaty/testMessage.js b/src/wechaty/testMessage.js
new file mode 100644
index 0000000..498faf7
--- /dev/null
+++ b/src/wechaty/testMessage.js
@@ -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()
diff --git a/src/xunfei/__test__.js b/src/xunfei/__test__.js
new file mode 100644
index 0000000..40c9e8b
--- /dev/null
+++ b/src/xunfei/__test__.js
@@ -0,0 +1,9 @@
+import { getXunfeiReply } from './index.js'
+
+// 测试 科大讯飞 api
+async function testMessage() {
+ const message = await getXunfeiReply('秦始皇的儿子是谁?')
+ console.log('🌸🌸🌸 / message: ', message)
+}
+
+testMessage()
diff --git a/src/xunfei/index.js b/src/xunfei/index.js
new file mode 100644
index 0000000..7f71053
--- /dev/null
+++ b/src/xunfei/index.js
@@ -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}`
+}
diff --git a/src/xunfei/xunfei.js b/src/xunfei/xunfei.js
new file mode 100644
index 0000000..41ea228
--- /dev/null
+++ b/src/xunfei/xunfei.js
@@ -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;
+}