This commit is contained in:
2025-11-30 04:48:09 +08:00
parent 53df135696
commit b7f1095e4a
14 changed files with 552 additions and 21 deletions

View File

View File

@@ -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 <xiongxiao@xiongxiao.me>",
"license": "MIT",
"type": "module"
}

47
pnpm-lock.yaml generated
View File

@@ -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: {}

View File

@@ -1,2 +1,2 @@
packages:
- 'submodules/*'
- 'wxmsg/*'

View File

@@ -16,11 +16,15 @@ export { manager };
// console.log('app', app, );
// console.log('app2 context', global.context);
// console.log('app equal', app === ManagerApp);
if (isExist) {
const delayRun = () => {
if (isExist) {
loadManager({ runtime: 'server', configFilename: 'assistant-apps-config.json' });
} else {
} else {
loadManager({ runtime: 'server' });
}
}
setTimeout(delayRun, 100);
// middleware: ['auth-admin']
/*

20
wxmsg/bun.config.mjs Normal file
View File

@@ -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_*',
});

40
wxmsg/package.json Normal file
View File

@@ -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 <xiongxiao@xiongxiao.me> (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"
}
}

103
wxmsg/src/index.ts Normal file
View File

@@ -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<any> => {
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);
}
});

View File

@@ -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<Config>();

View File

@@ -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);
}

78
wxmsg/src/wx/index.ts Normal file
View File

@@ -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<string> {
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());
}
}

16
wxmsg/src/wx/send-user.ts Normal file
View File

@@ -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);
};

View File

@@ -0,0 +1,94 @@
export type WxMsgBase<T = any> = {
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; // 语音格式如amrspeex等
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;
}

91
wxmsg/src/wx/type/send.ts Normal file
View File

@@ -0,0 +1,91 @@
export type Send<T = any> = {
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;