From b7f1095e4a6dd9b7648d19fb68cfc36353fd09a5 Mon Sep 17 00:00:00 2001 From: abearxiong Date: Sun, 30 Nov 2025 04:48:09 +0800 Subject: [PATCH] update --- docker/.env.example | 0 docker/package.json | 15 ---- pnpm-lock.yaml | 47 ++++++++++++- pnpm-workspace.yaml | 2 +- src/routes/micro-app/manager-app.ts | 12 ++-- wxmsg/bun.config.mjs | 20 ++++++ wxmsg/package.json | 40 +++++++++++ wxmsg/src/index.ts | 103 ++++++++++++++++++++++++++++ wxmsg/src/modules/config.ts | 7 ++ wxmsg/src/wx/access-token.ts | 48 +++++++++++++ wxmsg/src/wx/index.ts | 78 +++++++++++++++++++++ wxmsg/src/wx/send-user.ts | 16 +++++ wxmsg/src/wx/type/custom-service.ts | 94 +++++++++++++++++++++++++ wxmsg/src/wx/type/send.ts | 91 ++++++++++++++++++++++++ 14 files changed, 552 insertions(+), 21 deletions(-) delete mode 100644 docker/.env.example delete mode 100644 docker/package.json create mode 100644 wxmsg/bun.config.mjs create mode 100644 wxmsg/package.json create mode 100644 wxmsg/src/index.ts create mode 100644 wxmsg/src/modules/config.ts create mode 100644 wxmsg/src/wx/access-token.ts create mode 100644 wxmsg/src/wx/index.ts create mode 100644 wxmsg/src/wx/send-user.ts create mode 100644 wxmsg/src/wx/type/custom-service.ts create mode 100644 wxmsg/src/wx/type/send.ts diff --git a/docker/.env.example b/docker/.env.example deleted file mode 100644 index e69de29..0000000 diff --git a/docker/package.json b/docker/package.json deleted file mode 100644 index 0090abd..0000000 --- a/docker/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "docker", - "version": "0.0.1", - "description": "", - "main": "index.js", - "scripts": { - "docker:build": "docker build -t docker.xiongxiao.me/code-flow:v0.0.2 .", - "docker:push": "docker push docker.xiongxiao.me/code-flow:v0.0.2", - "docker:run": "docker run -it --name code-flow -p 4000:4000 docker.xiongxiao.me/code-flow:v0.0.2" - }, - "keywords": [], - "author": "abearxiong ", - "license": "MIT", - "type": "module" -} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 453f14c..5420017 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -152,6 +152,31 @@ importers: specifier: ^4.1.13 version: 4.1.13 + wxmsg: + dependencies: + '@kevisual/context': + specifier: ^0.0.4 + version: 0.0.4 + '@kevisual/router': + specifier: 0.0.33 + version: 0.0.33 + '@types/node': + specifier: ^24.10.1 + version: 24.10.1 + crypto-js: + specifier: ^4.2.0 + version: 4.2.0 + xml2js: + specifier: ^0.6.2 + version: 0.6.2 + devDependencies: + '@types/crypto-js': + specifier: ^4.2.2 + version: 4.2.2 + '@types/xml2js': + specifier: ^0.4.14 + version: 0.4.14 + packages: '@ioredis/commands@1.4.0': @@ -309,6 +334,9 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@types/xml2js@0.4.14': + resolution: {integrity: sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==} + '@zxing/text-encoding@0.9.0': resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==} @@ -1645,6 +1673,10 @@ packages: resolution: {integrity: sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==} engines: {node: '>=4.0.0'} + xml2js@0.6.2: + resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} + engines: {node: '>=4.0.0'} + xmlbuilder@11.0.1: resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} engines: {node: '>=4.0'} @@ -1946,6 +1978,10 @@ snapshots: dependencies: '@types/node': 24.10.1 + '@types/xml2js@0.4.14': + dependencies: + '@types/node': 24.10.1 + '@zxing/text-encoding@0.9.0': optional: true @@ -2183,6 +2219,10 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.4.3: + dependencies: + ms: 2.1.3 + debug@4.4.3(supports-color@5.5.0): dependencies: ms: 2.1.3 @@ -2987,7 +3027,7 @@ snapshots: send@1.2.0: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -3299,6 +3339,11 @@ snapshots: sax: 1.4.1 xmlbuilder: 11.0.1 + xml2js@0.6.2: + dependencies: + sax: 1.4.1 + xmlbuilder: 11.0.1 + xmlbuilder@11.0.1: {} xtend@4.0.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d7cf5d1..7403a61 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,2 @@ packages: - - 'submodules/*' + - 'wxmsg/*' diff --git a/src/routes/micro-app/manager-app.ts b/src/routes/micro-app/manager-app.ts index 21c3976..4bcb288 100644 --- a/src/routes/micro-app/manager-app.ts +++ b/src/routes/micro-app/manager-app.ts @@ -16,11 +16,15 @@ export { manager }; // console.log('app', app, ); // console.log('app2 context', global.context); // console.log('app equal', app === ManagerApp); -if (isExist) { - loadManager({ runtime: 'server', configFilename: 'assistant-apps-config.json' }); -} else { - loadManager({ runtime: 'server' }); + +const delayRun = () => { + if (isExist) { + loadManager({ runtime: 'server', configFilename: 'assistant-apps-config.json' }); + } else { + loadManager({ runtime: 'server' }); + } } +setTimeout(delayRun, 100); // middleware: ['auth-admin'] /* diff --git a/wxmsg/bun.config.mjs b/wxmsg/bun.config.mjs new file mode 100644 index 0000000..1f7665d --- /dev/null +++ b/wxmsg/bun.config.mjs @@ -0,0 +1,20 @@ +import { resolvePath } from '@kevisual/use-config'; +import { execSync } from 'node:child_process'; + +const entry = 'src/index.ts'; +const naming = 'app'; +const external = []; +/** + * @type {import('bun').BuildConfig} + */ +await Bun.build({ + target: 'node', + format: 'esm', + entrypoints: [resolvePath(entry, { meta: import.meta })], + outdir: resolvePath('./dist', { meta: import.meta }), + naming: { + entry: `${naming}.js`, + }, + external, + env: 'KEVISUAL_*', +}); \ No newline at end of file diff --git a/wxmsg/package.json b/wxmsg/package.json new file mode 100644 index 0000000..fc7c671 --- /dev/null +++ b/wxmsg/package.json @@ -0,0 +1,40 @@ +{ + "name": "@kevisual/wxmsg", + "version": "0.0.1", + "description": "", + "main": "src/index.ts", + "basename": "/root/wxmsg", + "app": { + "type": "system-app", + "key": "root/wxmsg", + "entry": "./app.js", + "runtime": [ + "server" + ] + }, + "scripts": { + "dev": "pnpm dev src/index.ts", + "build": "bun run bun.config.mjs", + "prepub": "rimraf dist && rimraf pack-dist && pnpm build", + "pub": "envision pack -p -u -c" + }, + "keywords": [], + "files": [ + "dist" + ], + "author": "abearxiong (https://www.xiongxiao.me)", + "license": "MIT", + "packageManager": "pnpm@10.24.0", + "type": "module", + "dependencies": { + "@kevisual/context": "^0.0.4", + "@kevisual/router": "0.0.33", + "@types/node": "^24.10.1", + "crypto-js": "^4.2.0", + "xml2js": "^0.6.2" + }, + "devDependencies": { + "@types/crypto-js": "^4.2.2", + "@types/xml2js": "^0.4.14" + } +} \ No newline at end of file diff --git a/wxmsg/src/index.ts b/wxmsg/src/index.ts new file mode 100644 index 0000000..33f0c3c --- /dev/null +++ b/wxmsg/src/index.ts @@ -0,0 +1,103 @@ +import { SimpleRouter } from '@kevisual/router/simple'; +import CryptoJS from 'crypto-js'; +import xml2js from 'xml2js'; +import { useContextKey } from '@kevisual/context'; +import { Redis } from 'ioredis'; +import http from 'node:http'; +import { Wx, parseWxMessage } from './wx/index.ts'; +import { config } from './modules/config.ts'; +export const simpleRouter: SimpleRouter = await useContextKey('router'); +export const redis: Redis = await useContextKey('redis'); + +simpleRouter.get('/api/wxmsg', async (req: http.IncomingMessage, res: http.ServerResponse) => { + console.log('微信检测服务是否可用'); + const query = simpleRouter.getSearch(req); + const body = await simpleRouter.getBody(req); + const { + signature, // 微信加密签名,signature结合了开发者填写的token参数和请求中的timestamp参数、nonce参数。 + timestamp, // 时间戳 + nonce, // 随机数 + echostr, // 随机字符串 + } = query; + const token = 'xiongabc123'; + let str = [token, timestamp, nonce].sort().join(''); + let strSha1 = CryptoJS.SHA1(str).toString(); + // 签名对比,相同则按照微信要求返回echostr参数值 + if (signature == strSha1) { + res.end(echostr); + } else { + res.end('send fail'); + } +}); + +export const getJsonFromXml = async (req: http.IncomingMessage): Promise => { + return await new Promise((resolve) => { + // 读取请求数据 + let data = ''; + req.setEncoding('utf8'); + // 监听data事件,接收数据片段 + req.on('data', (chunk: string) => { + data += chunk; + }); + // 当请求结束时处理数据 + req.on('end', () => { + try { + // 使用xml2js解析XML + xml2js.parseString(data, function (err, result) { + if (err) { + console.error('XML解析错误:', err); + resolve(null); + } else { + resolve(result); + } + }); + } catch (error) { + console.error('处理请求时出错:', error); + resolve(null); + } + }); + }); +}; + + + +simpleRouter.post('/api/wxmsg', async (req: http.IncomingMessage, res: http.ServerResponse) => { + try { + const xml = await getJsonFromXml(req); + const msgOrigin = xml?.xml; + if (!msgOrigin) { + console.error('No message received'); + res.end(''); + return + } + const msg = parseWxMessage(msgOrigin); + const { fromusername, msgtype } = msg; + res.end('') + if (msgtype === 'event') { + console.log('Received event message'); + return + } + if (fromusername) { + // 代理分析返回 + const wx = new Wx({ appId: config.WX_MP_APP_ID, appSecret: config.WX_MP_APP_SECRET, redis }); + wx.analyzeUserMsg(msg); + } + return + + + // 直接返回 + // const builder = new xml2js.Builder(); + // const result = builder.buildObject({ + // xml: { + // ToUserName: fromusername, + // FromUserName: tousername, + // CreateTime: Date.now(), + // MsgType: msgtype, + // Content: 'Hello ' + content, + // }, + // }); + // res.end(result); + } catch (e) { + console.error('Error processing message:', e); + } +}); diff --git a/wxmsg/src/modules/config.ts b/wxmsg/src/modules/config.ts new file mode 100644 index 0000000..7b93aa8 --- /dev/null +++ b/wxmsg/src/modules/config.ts @@ -0,0 +1,7 @@ +import { useConfig } from "@kevisual/context"; + +type Config = { + WX_MP_APP_ID: string; + WX_MP_APP_SECRET: string; +} +export const config = useConfig(); diff --git a/wxmsg/src/wx/access-token.ts b/wxmsg/src/wx/access-token.ts new file mode 100644 index 0000000..79d40fe --- /dev/null +++ b/wxmsg/src/wx/access-token.ts @@ -0,0 +1,48 @@ +// const accessURL = 'https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=APPID&secret=APPSECRET' +const accessURL = 'https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential'; + +type AccessData = { + "access_token": string; + "expires_in": number; // 7200, 单位秒 2小时 + "accessToken": string; + "expiredAt": number; // 到期时间戳,单位毫秒 +} + +type ErrorData = { + errcode: number; + errmsg: string; +} +export const getAccessURL = (appId: string, appSecret: string): string => { + return `${accessURL}&appid=${appId}&secret=${appSecret}`; +}; + +export const getAccessToken = async (appId: string, appSecret: string): Promise<{ + code: number; + data?: AccessData; + message?: string; +}> => { + const url = getAccessURL(appId, appSecret); + const response = await fetch(url); + const data = await response.json(); + if ((data as ErrorData).errcode) { + return { + code: 500, + message: (data as ErrorData).errmsg, + } + } + console.log('access token data', data); + data.accessToken = data.access_token; + data.expiredAt = Date.now() + data.expires_in * 1000; + return { + code: 200, + data + } +} + +if(require.main === module) { + // 测试代码 + const appId = 'wxff97d569b1db16b6'; + const appSecret = '012d84d0d2b914de95f4e9ca84923aed'; + const res = await getAccessToken(appId, appSecret); + console.log('getAccessToken res', res); +} diff --git a/wxmsg/src/wx/index.ts b/wxmsg/src/wx/index.ts new file mode 100644 index 0000000..b60f0da --- /dev/null +++ b/wxmsg/src/wx/index.ts @@ -0,0 +1,78 @@ +import { getAccessToken } from './access-token'; +import { Redis } from 'ioredis'; +import { WxCustomServiceMsg, WxMsgText } from './type/custom-service.ts'; + +export * from './type/custom-service.ts'; +export * from './type/send.ts'; + +/** + * 从 + * @param str + * @returns + */ +export const changeToLowerCase = (str: string): string => { + return str.toLowerCase(); +} + + +export class Wx { + private appId: string; + private appSecret: string; + public redis: Redis | null = null; + constructor({ appId, appSecret, redis }: { appId: string; appSecret: string; redis?: Redis }) { + this.appId = appId; + this.appSecret = appSecret; + this.redis = redis! || null; + } + + public async getAccessToken(): Promise { + const _accessToken = await this.redis?.get(`wx:access_token:${this.appId}`); + if (_accessToken) { + return _accessToken; + } + const res = await getAccessToken(this.appId, this.appSecret); + if (res.code !== 200 || !res.data) { + throw new Error(`Failed to get access token: ${res.message || 'unknown error'}`); + } + const { accessToken, expires_in } = res.data; + await this.redis?.set(`wx:access_token:${this.appId}`, accessToken, 'EX', expires_in - 200); + return accessToken; + } + public async analyzeUserMsg(msg: WxCustomServiceMsg) { + const touser = msg.fromusername; + const msgType = msg.msgtype; + if (msgType !== 'text') { + console.log('Only text messages are supported for auto-reply.'); + console.log('Analyzed message:', { touser, msgType }); + return; + } + const txtMsg = msg as WxMsgText; + const content = txtMsg.content; + + console.log('Analyzing user message:', { touser, msgType, content }); + const sendData = { + touser, + msgtype: 'text', + text: { + content: 'Hello World', + }, + } + this.sendUserMessage(sendData); + } + public async sendUserMessage(data: any) { + const accessToken = await this.getAccessToken(); + const url = `https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=${accessToken}` + + const res = await this.post(url, data); + if (res.errcode !== 0) { + console.error('Failed to send user message:', res); + } + return res; + } + public async post(url: string, data: any) { + return fetch(url, { + method: 'POST', + body: JSON.stringify(data), + }).then((res) => res.json()); + } +} \ No newline at end of file diff --git a/wxmsg/src/wx/send-user.ts b/wxmsg/src/wx/send-user.ts new file mode 100644 index 0000000..3591633 --- /dev/null +++ b/wxmsg/src/wx/send-user.ts @@ -0,0 +1,16 @@ +export const sendUser = async (accessToken: string) => { + const data = { + touser: 'omcvy7AHC6bAA0QM4x9_bE0fGD1g', + msgtype: 'text', + text: { + content: 'Hello World', + }, + }; + const url = 'https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=ACCESS_TOKEN'; + const link = url.replace('ACCESS_TOKEN', accessToken); + const res = await fetch(link, { + method: 'POST', + body: JSON.stringify(data), + }).then((res) => res.json()); + console.log('res', res); +}; diff --git a/wxmsg/src/wx/type/custom-service.ts b/wxmsg/src/wx/type/custom-service.ts new file mode 100644 index 0000000..d33d152 --- /dev/null +++ b/wxmsg/src/wx/type/custom-service.ts @@ -0,0 +1,94 @@ +export type WxMsgBase = { + tousername: string; + fromusername: string; + createtime: string; + msgtype: T extends { msgtype: infer U } ? U : string; + msgid: string; +} & T; +export type WxMsgText = WxMsgBase<{ + msgtype: 'text'; + content: string; +}>; +export type WxMsgEvent = WxMsgBase<{ + msgtype: 'event'; + event: 'subscribe' | 'unsubscribe' | 'click' | 'location' | 'scan'; + eventkey: string; + /** + * + * 用户同意上报地理位置后,每次进入服务号会话时,都会在进入时上报地理位置, + * 或在进入会话后每5秒上报一次地理位置,服务号可以在公众平台网站中修改以上设置。 + * 上报地理位置时,微信会将上报地理位置事件推送到开发者填写的URL。 + */ + longitude?: string; // 经度 + latitude?: string; // 纬度 + precision?: string; // 精度 + + /** + * 用户未关注时,进行关注后的扫描带参数二维码事件推送 + * eventkey为 qrscene_为前缀,后面为二维码的场景值ID + */ + ticket?: string; +}>; +export type WxMsgVoice = WxMsgBase<{ + mediaid: string; + format: string; // 语音格式,如amr,speex等 + recognition?: string; // 语音识别结果,UTF8编码 + mediaid16k?: string; // 语音播放时间长度,单位为秒 +}>; + +export type WxMsgImage = WxMsgBase<{ + msgtype: 'image'; + picurl: string; + mediaid: string; +}>; + +export type WxMsgLocation = WxMsgBase<{ + msgtype: 'location'; + location_x: string; // 地理位置纬度 + location_y: string; // 地理位置经度 + scale: string; // 地图缩放大小 + label: string; // 地理位置信息 +}>; +export type WxMsgVideo = WxMsgBase<{ + msgtype: 'video'; + mediaid: string; + thumbmediaid: string; +}>; + +export type WxMsgLink = WxMsgBase<{ + msgtype: 'link'; + title: string; + description: string; + url: string; +}>; + +export type WxMsgFile = WxMsgBase<{ + msgtype: 'file'; + title: string; + description: string; + filekey: string; + filemd5: string; + filetotallen: string; +}>; + +export type WxCustomServiceMsg = WxMsgText | WxMsgEvent | WxMsgVoice | WxMsgImage | WxMsgLocation | WxMsgVideo | WxMsgLink | WxMsgFile; + +export const parseWxMessage = (msg: any): WxCustomServiceMsg => { + const keys = Object.keys(msg); + let values = keys.map((key) => { + const value = msg[key]; + if (Array.isArray(value) && value.length > 0) { + return value[0]; + } + return value; + }).reduce((acc, curr, index) => { + const newKey = keys[index].toLowerCase(); + if (newKey === 'event') { + curr = curr.toLowerCase(); + } + acc[newKey] = curr; + return acc; + }, {} as any); + return values as WxCustomServiceMsg; + +} \ No newline at end of file diff --git a/wxmsg/src/wx/type/send.ts b/wxmsg/src/wx/type/send.ts new file mode 100644 index 0000000..2a8af18 --- /dev/null +++ b/wxmsg/src/wx/type/send.ts @@ -0,0 +1,91 @@ +export type Send = { + touser: string; + msgtype: 'text' | 'image' | 'voice' | 'video' | 'music' | 'news' | 'mpnews' | 'mpnewsarticle' | 'msgmenu' | 'wxcard' | 'miniprogrampage' | 'customservice' | 'aimsgcontext'; +} & T; + +type SendText = Send<{ + text: { + content: string; + } +}>; + +type SendImage = Send<{ + image: { + media_id: string; + } +}>; +type SendVoice = Send<{ + voice: { + media_id: string; + } +}> +type SendVideo = Send<{ + video: { + media_id: string; + thumb_media_id: string; + title?: string; + description?: string; + } +}> +type SendMusic = Send<{ + music: { + title?: string; + description?: string; + musicurl?: string; + hqmusicurl?: string; + thumb_media_id: string; + } +}> +type SendNews = Send<{ + news: { + articles: Array<{ + title: string; + description?: string; + url: string; + picurl?: string; + }> + } +}> +type SendMpnews = Send<{ + mpnews: { + media_id: string; + } +}> +type SendMsgmenu = Send<{ + msgmenu: { + head_content: string; + tail_content: string; + list: Array<{ + id: string; + content: string; + }> + } +}> +type SendWxcard = Send<{ + wxcard: { + card_id: string; + } +}> +type SendMiniprogrampage = Send<{ + miniprogrampage: { + title: string; + appid: string; + pagepath: string; + thumb_media_id: string; + } +}> +type SendCustomservice = Send<{ + customservice: { + kf_account: string; + } +}> +type SendAimsgcontext = Send<{ + aimsgcontext: { + /** + * 消息下方增加灰色 wording “内容由第三方AI生成” 0 不增加 1 增加 + */ + is_ai_msg: number; + } +}> + +export type WxSendMsgType = SendText | SendImage | SendVoice | SendVideo | SendMusic | SendNews | SendMpnews | SendMsgmenu | SendWxcard | SendMiniprogrampage | SendCustomservice | SendAimsgcontext; \ No newline at end of file