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

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