diff --git a/.github/workflows/publishNPM.yml b/.github/workflows/publishNPM.yml index 28aad33..22b835b 100644 --- a/.github/workflows/publishNPM.yml +++ b/.github/workflows/publishNPM.yml @@ -21,6 +21,10 @@ jobs: # 安装 pnpm - name: Install pnpm run: npm install -g pnpm + + # 构建包必要文件 + - name: Build package files + run: pnpm run build:cli # 安装依赖 - name: Install Dependencies diff --git a/README.md b/README.md index 36e57b7..0bfc599 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [🚢 Docker 镜像](https://hub.docker.com/repository/docker/dannicool/docker-wechatbot-webhook/general) | [📦 NPM包](https://www.npmjs.com/package/wechatbot-webhook)|[🔍 FAQ](https://github.com/danni-cool/wechatbot-webhook/issues/72) -开箱即用的微信webhook机器人,通过 http 接口调用即可实现微信消息的发送和接收,作为基于 wechaty 的消息机器人服务在稳定性上做了较多优化。 +一个小小的微信机器人webhook,帮你抹平了很多自己开发的障碍,基于 http 请求 ## ✨ Features @@ -34,7 +34,8 @@ | 接收文件 | ✅ | | | 接收公众号推文链接 | ✅ | | | 接收系统通知 | ✅ 上线通知 / 掉线通知 / 异常通知 | | -| [快捷回复](https://github.com/danni-cool/wechatbot-webhook?tab=readme-ov-file#2-%E6%94%B6%E6%B6%88%E6%81%AF-api) | ✅ | ✅ | +| [头像获取](#33-获取静态资源接口) | ✅ | | +| [快捷回复](#2-%E6%94%B6%E6%B6%88%E6%81%AF-api) | ✅ | ✅ | | **<群管理>** | | | | **<好友管理>** | | | | 接收好友申请 | ✅ | | @@ -410,12 +411,11 @@ curl --location 'https://your.recvdapi.com' \ #### token 配置说明 > 除了在 docker 启动时配置token,在默认缺省 token 的情况,会默认生成一个写入 `.env` 文件中 -#### `/login?token=[YOUR_PERSONAL_TOKEN]` - -- **描述**:获取登录二维码接口。 +#### 3.1 获取登录二维码接口 +- **地址**:`/login` - **methods**: `GET` - **query**: token - +- **example**: http://localhost:3001/login?token=[YOUR_PERSONAL_TOKEN] **status**: `200` ##### 登录成功 @@ -430,15 +430,55 @@ curl --location 'https://your.recvdapi.com' \ 展示微信登录扫码页面 -#### `/healthz?token=[YOUR_PERSONAL_TOKEN]` +#### 3.2 健康检测接口 + +可以主动轮询该接口,检查服务是否正常运行 -- **描述**:健康检测接口。 +- **地址**:`/healthz` - **methods**: `GET` - **query**: token - **status**: `200` +- **example**: http://localhost:3001/healthz?token=[YOUR_PERSONAL_TOKEN] 微信已登录, 返回纯文本 `healthy`,否则返回 `unHealthy` +#### 3.3 获取静态资源接口 + +从 2.8.0 版本开始,可以通过本接口访问到头像等静态资源,具体见 [recvd_api 数据结构示例的 avatar 字段](/docs/recvdApi.example.md#2-formdatasource-string) + +注意所有上报 recvd_api 的静态资源地址不会默认带上 token, 需要自己拼接,否则会返回 401 错误, 请确保自己微信已登录,需要通过登录态去获取资源 + +- **地址**:`/resouces` +- **methods**: `GET` +- **query**: + - token: 登录token + - media: encode过的相对路径,比如 `/avatar/1234567890.jpg` encode为 `avatar%2F1234567890.jpg` +- **status**: `200` `404` `401` + +- **example**:http://localhost:3001/resouces?media=%2Fcgi-bin%2Fmmwebwx-bin%2Fwebwxgetheadimg%3Fseq%3D83460%26username%3D%40%4086815a%26skey%3D&token=[YOUR_PERSONAL_TOKEN] + +##### status: `200` + +成功获取资源, 返回静态资源文件 + +##### status: `404` + +获取资源失败 + +##### status: `401` 未携带登录token + +```json +{"success":false, "message":"Unauthorized: Access is denied due to invalid credentials."} +``` + +##### status: `401` 微信登录态已过期 + +```json +{ + "success": false, "message": "you must login first" +} +``` + ## 🌟 Star History @@ -446,7 +486,9 @@ curl --location 'https://your.recvdapi.com' \ ## Contributors -![](https://contrib.rocks/image?repo=danni-cool/wechatbot-webhook) +Thanks to all our contributors! + +![](https://contrib.rocks/image?repo=danni-cool/wechatbot-webhook) ## ⏫ 更新日志 diff --git a/docs/recvdApi.example.md b/docs/recvdApi.example.md index 64ff03e..40869ca 100644 --- a/docs/recvdApi.example.md +++ b/docs/recvdApi.example.md @@ -73,7 +73,7 @@ "id": "@xxxasdfsf", "payload": { "alias": "", - "avatar": "", + "avatar": "http://localhost:3001/resouces?media=%2Fcgi-bin%2Fmmwebwx-bixxx", //请配合 token=[YOUR_PERSONAL_TOKEN] 解密 "friend": false, "gender": 1, "id": "@xxx", @@ -98,7 +98,7 @@ "id": "@xxxasdfsf", "payload": { "alias": "", - "avatar": "", + "avatar": "http://localhost:3001/resouces?media=%2Fcgi-bin%2Fmmwebwx-bixxx", //请配合 token=[YOUR_PERSONAL_TOKEN] 解密 "friend": false, "gender": 1, "id": "@xxx", @@ -125,7 +125,7 @@ "id": "@xxxasdfsf", "payload": { "alias": "", - "avatar": "", + "avatar": "http://localhost:3001/resouces?media=%2Fcgi-bin%2Fmmwebwx-bixxx", //请配合 token=[YOUR_PERSONAL_TOKEN] 解密 "friend": false, "gender": 1, "id": "@xxx", @@ -166,7 +166,11 @@ "adminIdList": [], "avatar": "xxxx", // 相对路径,应该要配合解密 "memberList": [ - {id: '@xxxx', name:'昵称', alias: '备注名'/** 个人备注名,非群备注名 */ } + { + id: '@xxxx', + avatar: "http://localhost:3001/resouces?media=%2Fcgi-bin%2Fmmwebwx-bixxx", //请配合 token=[YOUR_PERSONAL_TOKEN] 解密 + name:'昵称', + alias: '备注名'/** 个人备注名,非群备注名 */ } ] }, //以下暂不清楚什么用途,如有兴趣,请查阅 wechaty 官网文档 @@ -181,7 +185,7 @@ "payload": { "alias": "", //备注名 - "avatar": "xxx", + "avatar": "http://localhost:3001/resouces?media=%2Fcgi-bin%2Fmmwebwx-bixxx", //请配合 token=[YOUR_PERSONAL_TOKEN] 解密 "friend": false, "gender": 1, "id": "@xxx", @@ -202,7 +206,7 @@ "payload": { "alias": "", - "avatar": "xxx", + "avatar": "http://localhost:3001/resouces?media=%2Fcgi-bin%2Fmmwebwx-bixxx", //请配合 token=[YOUR_PERSONAL_TOKEN] 解密 "city": "北京", "friend": true, "gender": 1, diff --git a/package.json b/package.json index 8a32393..ceb8b6b 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "form-data": "^4.0.0", "gerror": "^1.0.16", "hono": "^3.11.11", + "lodash.clonedeep": "^4.5.0", "log4js": "^6.9.1", "mime": "^3.0.0", "node-fetch-commonjs": "^3.3.2", @@ -64,6 +65,8 @@ "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.2", "husky": "^8.0.3", + "i": "^0.3.7", + "npm": "^10.5.0", "prettier": "^3.1.1", "tsc-files": "^1.1.4", "typescript": "^5.3.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9538783..4d5c3b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,6 +40,9 @@ importers: hono: specifier: ^3.11.11 version: 3.11.11 + lodash.clonedeep: + specifier: ^4.5.0 + version: 4.5.0 log4js: specifier: ^6.9.1 version: 6.9.1 @@ -86,6 +89,12 @@ importers: husky: specifier: ^8.0.3 version: 8.0.3 + i: + specifier: ^0.3.7 + version: 0.3.7 + npm: + specifier: ^10.5.0 + version: 10.5.0 prettier: specifier: ^3.1.1 version: 3.1.1 @@ -2147,6 +2156,11 @@ packages: hasBin: true dev: true + /i@0.3.7: + resolution: {integrity: sha512-FYz4wlXgkQwIPqhzC5TdNMLSE5+GS1IIDJZY/1ZiEPCT2S3COUVZeT5OW4BmW4r5LHLQuOosSwsvnroG9GR59Q==} + engines: {node: '>=0.4'} + dev: true + /ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -2452,6 +2466,10 @@ packages: resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} dev: false + /lodash.clonedeep@4.5.0: + resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + dev: false + /lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} dev: false @@ -2631,6 +2649,83 @@ packages: resolution: {integrity: sha512-XdkOuXGx0DTwlqb0DWTcDqelgU/F3YyZ+PTRaecpDVpkYskcnh3OeUYKfvjcRQ2D1diTIGxi/a3eHVjW5yPupQ==} dev: false + /npm@10.5.0: + resolution: {integrity: sha512-Ejxwvfh9YnWVU2yA5FzoYLTW52vxHCz+MHrOFg9Cc8IFgF/6f5AGPAvb5WTay5DIUP1NIfN3VBZ0cLlGO0Ys+A==} + engines: {node: ^18.17.0 || >=20.5.0} + hasBin: true + dev: true + bundledDependencies: + - '@isaacs/string-locale-compare' + - '@npmcli/arborist' + - '@npmcli/config' + - '@npmcli/fs' + - '@npmcli/map-workspaces' + - '@npmcli/package-json' + - '@npmcli/promise-spawn' + - '@npmcli/run-script' + - '@sigstore/tuf' + - abbrev + - archy + - cacache + - chalk + - ci-info + - cli-columns + - cli-table3 + - columnify + - fastest-levenshtein + - fs-minipass + - glob + - graceful-fs + - hosted-git-info + - ini + - init-package-json + - is-cidr + - json-parse-even-better-errors + - libnpmaccess + - libnpmdiff + - libnpmexec + - libnpmfund + - libnpmhook + - libnpmorg + - libnpmpack + - libnpmpublish + - libnpmsearch + - libnpmteam + - libnpmversion + - make-fetch-happen + - minimatch + - minipass + - minipass-pipeline + - ms + - node-gyp + - nopt + - normalize-package-data + - npm-audit-report + - npm-install-checks + - npm-package-arg + - npm-pick-manifest + - npm-profile + - npm-registry-fetch + - npm-user-validate + - npmlog + - p-map + - pacote + - parse-conflict-json + - proc-log + - qrcode-terminal + - read + - semver + - spdx-expression-parse + - ssri + - supports-color + - tar + - text-table + - tiny-relative-date + - treeverse + - validate-npm-package-name + - which + - write-file-atomic + /nth-check@1.0.2: resolution: {integrity: sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg==} dependencies: diff --git a/src/config/const.js b/src/config/const.js index e0ec497..d43cd72 100644 --- a/src/config/const.js +++ b/src/config/const.js @@ -1,11 +1,14 @@ const path = require('path') +const { PORT } = process.env const config = { /** * 上报消息的api群成员缓存多久(单位:ms) * @type {number} */ - roomCachedTime: 1000 * 60 * 5 + roomCachedTime: 1000 * 60 * 5, + /** 服务启动地址 */ + localUrl: `http://localhost:${PORT}` } const { homeEnvCfg, homeMemoryCardPath } = process.env diff --git a/src/middleware/loginCheck.js b/src/middleware/loginCheck.js index d85aec3..f812d9b 100644 --- a/src/middleware/loginCheck.js +++ b/src/middleware/loginCheck.js @@ -8,7 +8,7 @@ module.exports.loginCheck = async (c, next) => { c.status(401) return c.json({ success: false, - message: 'you must login first before sending messages' + message: 'you must login first' }) } diff --git a/src/route/index.js b/src/route/index.js index b920fd7..5efd50d 100644 --- a/src/route/index.js +++ b/src/route/index.js @@ -21,4 +21,5 @@ module.exports = function registerRoute({ app, bot }) { require('./msg')({ app, bot }) require('./login')({ app, bot }) + require('./resouces')({ app, bot }) } diff --git a/src/route/resouces.js b/src/route/resouces.js new file mode 100644 index 0000000..511dc30 --- /dev/null +++ b/src/route/resouces.js @@ -0,0 +1,39 @@ +const { downloadFile } = require('../utils/index') +const middleware = require('../middleware') +/** + * 通过该接口代理获取微信静态资源 + * @param {Object} param + * @param {import('hono').Hono} param.app + * @param {import('wechaty').Wechaty} param.bot + */ +module.exports = function registerResourceAgentRoute({ app, bot }) { + app.get( + '/resouces', + middleware.loginCheck, + /** @param {import('hono').Context} c */ + async (c) => { + // 暂时不考虑其他puppet的情况 + const cookie = + // @ts-ignore 私有变量 + bot.__puppet._memory.payload['\rpuppet\nPUPPET-WECHAT4U'].COOKIE + const mediaUrl = c.req.query('media') + const fullResouceUrl = `https://wx2.qq.com${decodeURIComponent( + mediaUrl || '' + )}` + + const { buffer, contentType } = await downloadFile(fullResouceUrl, { + Cookie: Object.entries(cookie).reduce( + (pre, next) => (pre += `${next[0]}=${next[1]};`), + '' + ) + }) + if (buffer) { + contentType && c.header('Content-Type', contentType) + return c.body(buffer) + } else { + c.status(404) + return c.json({ success: false, message: '获取资源失败' }) + } + } + ) +} diff --git a/src/service/msgUploader.js b/src/service/msgUploader.js index 41bf9a7..f71d431 100644 --- a/src/service/msgUploader.js +++ b/src/service/msgUploader.js @@ -5,7 +5,7 @@ const FormData = require('form-data') const { LOCAL_RECVD_MSG_API, RECVD_MSG_API } = process.env const { MSG_TYPE_ENUM } = require('../config/const') const cacheTool = require('../service/cache') - +const cloneDeep = require('lodash.clonedeep') /** * 收到消息上报接受url * @typedef {{type:'text'|'fileUrl'}} baseMsgInterface @@ -39,7 +39,7 @@ async function sendMsg2RecvdApi(msg) { // 有webhookurl才发送 if (!webhookUrl) return - /** @type {import('wechaty/impls').RoomInterface & {payload: { memberList: {id:string,name:string,alias:string|undefined}[]}}} */ + /** @type {roomInfoForUpload} */ //@ts-expect-errors 强制as配合 ts-expect-errors 实用更佳 const roomInfo = msg.room() @@ -59,6 +59,8 @@ async function sendMsg2RecvdApi(msg) { }) } roomInfo.payload.memberList = roomMemberInfo.map((item) => ({ + // @ts-expect-error wechaty定义问题,数据在payload里 + avatar: Utils.getAssetsAgentUrl(item.payload.avatar), // @ts-expect-error wechaty定义问题,数据在payload里 id: item.payload.id, // @ts-expect-error wechaty定义问题,数据在payload里 @@ -74,11 +76,32 @@ async function sendMsg2RecvdApi(msg) { } const source = { - /** room的话解析群成员信息,原始信息不会带 */ - room: roomInfo ?? '', - to: msg.to() ?? '', + room: cloneDeep(roomInfo || {}), + /** @type { import('wechaty').Message['to'] } */ + // @ts-ignore + to: cloneDeep(msg.to() || {}), + from: cloneDeep(msg.talker() || {}) + } + + // @ts-ignore + if (source.to && source.to.payload?.avatar) { + // @ts-ignore + source.to.payload.avatar = Utils.getAssetsAgentUrl(source.to.payload.avatar) + } + + // @ts-ignore + if (source.from.payload?.avatar) { // @ts-ignore - from: msg.talker() ?? '' + source.from.payload.avatar = Utils.getAssetsAgentUrl( + // @ts-ignore + source.from.payload.avatar + ) + } + + if (source.room.payload?.avatar) { + source.room.payload.avatar = Utils.getAssetsAgentUrl( + source.room.payload.avatar + ) } // let passed = true diff --git a/src/utils/index.js b/src/utils/index.js index 826afbc..9f4e19f 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -2,31 +2,34 @@ const { FileBox } = require('file-box') const MIME = require('mime') const { logger } = require('./log') const { URL } = require('url') + /** * 下载媒体文件转化为Buffer * @param {string} fileUrl - * @returns {Promise<{buffer?: Buffer, fileName?: string, fileNameAlias?: string}>} + * @returns {Promise<{buffer?: Buffer, fileName?: string, fileNameAlias?: string, contentType?: null | string}>} */ -const downloadFile = async (fileUrl) => { +const downloadFile = async (fileUrl, headers = {}) => { try { - const response = await fetch(fileUrl) + const response = await fetch(fileUrl, { headers }) if (response.ok) { const buffer = Buffer.from(await response.arrayBuffer()) // 使用自定义文件名,解决URL无文件后缀名时,文件被微信解析成不正确的后缀问题 let { fileName, query } = getFileInfoFromUrl(fileUrl) + let contentType = response.headers.get('content-type') // deal with unValid Url format like https://pangji-home.com/Fi5DimeGHBLQ3KcELn3DolvENjVU if (fileName === '') { // 有些资源文件链接是不会返回文件后缀的 例如 https://pangji-home.com/Fi5DimeGHBLQ3KcELn3DolvENjVU 其实是一张图片 //@ts-expect-errors 不考虑无content-type的情况 - const extName = MIME.getExtension(response.headers.get('content-type')) + const extName = MIME.getExtension(contentType) fileName = `${Date.now()}.${extName}` } return { buffer, fileName, + contentType, fileNameAlias: query?.$alias } } @@ -194,6 +197,8 @@ module.exports = { ...require('./nextTick.js'), ...require('./paramsValid.js'), ...require('./log.js'), + ...require('./res'), + downloadFile, getMediaFromUrl, getBufferFile, generateToken, diff --git a/src/utils/msg.js b/src/utils/msg.js index ad54b0f..3203427 100644 --- a/src/utils/msg.js +++ b/src/utils/msg.js @@ -1,4 +1,6 @@ const { MSG_TYPE_ENUM, legacySystemMsgStrMap } = require('../config/const') +const { getAssetsAgentUrl } = require('./res') +const cloneDeep = require('lodash.clonedeep') class CommonMsg { /** * @param {commonMsgPayload} payload @@ -108,8 +110,15 @@ class SystemEvent extends CommonMsg { * @param {systemEventPayload} payload */ constructor(payload) { + const payloadClone = cloneDeep(payload) + if (payloadClone.user?.payload) { + payloadClone.user.payload.avatar = getAssetsAgentUrl( + payloadClone.user.payload.avatar + ) + } + super({ - text: JSON.stringify(payload), + text: JSON.stringify(payloadClone), type: legacySystemMsgStrMap[payload.event], isSystemEvent: true }) diff --git a/src/utils/res.js b/src/utils/res.js new file mode 100644 index 0000000..fff957f --- /dev/null +++ b/src/utils/res.js @@ -0,0 +1,14 @@ +const { + config: { localUrl } +} = require('../config/const') + +/** + * 将相对资源路径转为代理获取资源路径 + * @param {string} relativePath + * @returns {string} + */ +module.exports.getAssetsAgentUrl = (relativePath) => { + if (!relativePath) return '' + + return `${localUrl}/resouces?media=${encodeURIComponent(relativePath)}` +} diff --git a/src/wechaty/init.js b/src/wechaty/init.js index 1d090dc..504673e 100644 --- a/src/wechaty/init.js +++ b/src/wechaty/init.js @@ -4,8 +4,11 @@ const { SystemEvent } = require('../utils/msg.js') const Service = require('../service') const Utils = require('../utils/index') const chalk = require('chalk') -const { PORT } = process.env -const { memoryCardName, logOutUnofficialCodeList } = require('../config/const') +const { + memoryCardName, + logOutUnofficialCodeList, + config: { localUrl } +} = require('../config/const') const token = Service.initLoginApiToken() const cacheTool = require('../service/cache') const bot = @@ -31,7 +34,7 @@ module.exports = function init() { Utils.logger.info( [ 'Or Access the URL to login: ' + - chalk.cyan(`http://localhost:${PORT}/login?token=${token}`) + chalk.cyan(`${localUrl}/login?token=${token}`) ].join('\n') ) }) @@ -42,7 +45,7 @@ module.exports = function init() { Utils.logger.info( '💬 ' + `你的推消息 api 为:${chalk.cyan( - `http://localhost:${PORT}/webhook/msg/v2?token=${token}` + `${localUrl}/webhook/msg/v2?token=${token}` )}` ) Utils.logger.info( diff --git a/typings/global.d.ts b/typings/global.d.ts index 96bc4be..402e37f 100644 --- a/typings/global.d.ts +++ b/typings/global.d.ts @@ -150,3 +150,14 @@ type commonMsgPayload = { room?: import('wechaty').Room | '' file?: string | (import('file-box').FileBoxInterface & { _name: string }) } + +type roomInfoForUpload = import('wechaty/impls').RoomInterface & { + payload: { + memberList: { + id: string + name: string + alias: string | undefined + avatar: string + }[] + } +} diff --git a/typings/extend.d.ts b/typings/hono.d.ts similarity index 100% rename from typings/extend.d.ts rename to typings/hono.d.ts diff --git a/typings/lodash.d.ts b/typings/lodash.d.ts new file mode 100644 index 0000000..d405925 --- /dev/null +++ b/typings/lodash.d.ts @@ -0,0 +1,6 @@ +declare module 'lodash.clonedeep' { + // 定义 cloneDeep 函数,接受任意类型的参数,并返回相同的类型 + function cloneDeep(value: T): T + // commonjs 导出 + export = cloneDeep +}