This commit is contained in:
2026-01-12 18:01:04 +08:00
commit 94784ee1ec
39 changed files with 2847 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
/**
* API错误
*/
export class WeChatError extends Error {
constructor(
public errcode: number,
public errmsg: string
) {
super(`企业微信API错误 [${errcode}]: ${errmsg}`);
}
}
+13
View File
@@ -0,0 +1,13 @@
import { handleServer } from './server.js';
/**
* 主函数:演示如何使用企业微信客户联系API
*/
async function main() {
await handleServer();
}
main().catch(console.error);
+134
View File
@@ -0,0 +1,134 @@
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { getSignature, decrypt, encrypt } from '@wecom/crypto';
import { parseStringPromise } from 'xml2js';
function validateSignature(msgSignature: string, timestamp: string, nonce: string, encrypt: string) {
const {
WEIXIN_EVENT_TOKEN,
} = process.env;
if (!WEIXIN_EVENT_TOKEN) throw new Error('static param error');
const devMsgSignature = getSignature(WEIXIN_EVENT_TOKEN, timestamp, nonce, encrypt);
return devMsgSignature === msgSignature;
}
export async function handleServer() {
const app = new Hono();
app.get('/we-comp/event', ({ req, ...args }) => {
try {
const { 'msg_signature': msgSignature, timestamp, nonce, echostr } = req.query();
const {
WEIXIN_EVENT_TOKEN,
WEIXIN_EVENT_ENCODING_AES_KEY,
} = process.env;
if (
!WEIXIN_EVENT_TOKEN
|| !WEIXIN_EVENT_ENCODING_AES_KEY
|| !msgSignature
|| !timestamp
|| !nonce
|| !echostr
) {
throw new Error('static param error');
}
if (!validateSignature(msgSignature, timestamp, nonce, echostr)) {
throw new Error('validate error');
}
const { message, id } = decrypt(WEIXIN_EVENT_ENCODING_AES_KEY, echostr);
console.log('URL验证成功,解密后的消息:', message);
console.log('接收者ID:', id);
} catch (e) {
console.error(e);
}
return args.text("");
});
app.post('/we-comp/event', async ({ req, ...args }) => {
const {
'msg_signature': msgSignature,
timestamp,
nonce
} = req.query();
try {
const result = await parseStringPromise(await req.text());
const encrypt = result.xml.Encrypt.at(0);
const {
WEIXIN_EVENT_TOKEN,
WEIXIN_EVENT_ENCODING_AES_KEY,
} = process.env;
if (
!WEIXIN_EVENT_TOKEN
|| !WEIXIN_EVENT_ENCODING_AES_KEY
|| !msgSignature
|| !timestamp
|| !nonce
) {
throw new Error('static param error');
}
// 2. 验证签名
if (!validateSignature(msgSignature, timestamp, nonce, encrypt)) {
throw new Error('签名验证失败');
}
const { message, id } = decrypt(WEIXIN_EVENT_ENCODING_AES_KEY, encrypt);
console.log('解密后的消息:', message);
console.log('接收者ID:', id);
// 5. 解析明文XML
const decryptedXml = await parseStringPromise(message);
const msgData = decryptedXml.xml;
const msgType = msgData.MsgType?.[0];
const fromUserName = msgData.FromUserName?.[0];
const toUserName = msgData.ToUserName?.[0];
const createTime = msgData.CreateTime?.[0];
console.log('========== 消息详情 ==========');
console.log('消息类型:', msgType);
console.log('发送者:', fromUserName);
console.log('接收者:', toUserName);
console.log('创建时间:', createTime);
console.log('============================');
} catch (e) {
console.error(e);
}
return args.text('');
})
const server = serve({
fetch: app.fetch,
port: 3080,
});
process.on('SIGINT', () => {
server.close()
process.exit(0)
})
process.on('SIGTERM', () => {
server.close((err) => {
if (err) {
console.error(err)
process.exit(1)
}
process.exit(0)
})
})
}
+355
View File
@@ -0,0 +1,355 @@
/**
* 企业微信客户联系 API 类型定义
*/
/**
* 外部联系人详情
*/
export interface ExternalContact {
/** 外部联系人userid */
external_userid: string;
/** 名称(微信用户返回昵称,企业微信用户返回对外展示名称) */
name: string;
/** 头像URL(部分应用不可获取) */
avatar?: string;
/** 1-微信用户,2-企业微信用户 */
type: 1 | 2;
/** 0-未知,1-男性,2-女性 */
gender: 0 | 1 | 2;
/** 微信unionid(需绑定微信开发者ID) */
unionid?: string;
/** 职位(仅企业微信用户) */
position?: string;
/** 企业简称(仅企业微信用户) */
corp_name?: string;
/** 企业主体名称(仅企业微信用户) */
corp_full_name?: string;
/** 自定义展示信息(仅企业微信用户) */
external_profile?: ExternalProfile;
}
/**
* 外部联系人自定义展示信息
*/
export interface ExternalProfile {
/** 自定义展示字段列表 */
external_attr: ExternalAttr[];
}
/**
* 自定义展示字段
*/
export interface ExternalAttr {
/** 0-文本,1-网页,2-小程序 */
type: number;
/** 属性名称 */
name: string;
/** 文本内容(type=0时) */
text?: { value: string };
/** 网页内容(type=1时) */
web?: { url: string; title: string };
/** 小程序内容(type=2时) */
miniprogram?: { appid: string; pagepath: string; title: string };
}
/**
* 标签信息
*/
export interface Tag {
/** 标签分组名称 */
group_name: string;
/** 标签名称 */
tag_name: string;
/** 企业标签ID(用户自定义标签不返回) */
tag_id?: string;
/** 1-企业设置,2-用户自定义,3-规则组标签 */
type: 1 | 2 | 3;
}
/**
* 视频号信息
*/
export interface WechatChannels {
/** 视频号名称 */
nickname: string;
/** 0-未知,1-主页,2-直播间,3-留资服务 */
source: 0 | 1 | 2 | 3;
}
/**
* 跟进用户信息
*/
export interface FollowUser {
/** 成员userid */
userid: string;
/** 该成员对客户的备注 */
remark: string;
/** 该成员对客户的描述 */
description: string;
/** 添加时间(Unix时间戳) */
createtime: number;
/** 标签列表 */
tags: Tag[];
/** 备注的企业名称 */
remark_corp_name: string;
/** 备注的手机号(部分应用不可获取) */
remark_mobiles?: string[];
/** 添加来源 */
add_way: number;
/** 视频号信息(add_way=10时有此字段) */
wechat_channels?: WechatChannels;
/** 发起添加的userid */
oper_userid: string;
/** 自定义state参数(联系我/获客链接) */
state?: string;
}
/**
* 获取客户详情API响应
*/
export interface GetExternalContactResponse {
/** 返回码 */
errcode: number;
/** 返回信息 */
errmsg: string;
/** 外部联系人详情 */
external_contact: ExternalContact;
/** 添加了此外部联系人的企业成员列表 */
follow_user: FollowUser[];
/** 分页cursor(跟进人>500时返回) */
next_cursor?: string;
}
/**
* 获取access_token API响应
*/
export interface AccessTokenResponse {
/** 返回码 */
errcode: number;
/** 返回信息 */
errmsg: string;
/** 获取到的凭证 */
access_token?: string;
/** 凭证有效期(秒) */
expires_in?: number;
}
/**
* 获取配置了客户联系功能的成员列表API响应
*/
export interface GetFollowUserListResponse {
/** 返回码 */
errcode: number;
/** 返回信息 */
errmsg: string;
/** 配置了客户联系功能的成员userid列表 */
follow_user?: string[];
}
/**
* 获取客户列表API响应
*/
export interface GetExternalContactListResponse {
/** 返回码 */
errcode: number;
/** 返回信息 */
errmsg: string;
/** 外部联系人的userid列表 */
external_userid?: string[];
}
/**
* 批量获取客户详情API响应
*/
export interface BatchGetExternalContactResponse {
/** 返回码 */
errcode: number;
/** 返回信息 */
errmsg: string;
/** 外部联系人详情列表 */
external_contact_list?: ExternalContact[];
/** 下一次分页的起始位置 */
next_cursor?: string;
}
/**
* 结束语文本内容
*/
export interface ConclusionText {
/** 文本内容 */
content: string;
}
/**
* 结束语
*/
export interface Conclusion {
/** 文本结束语 */
text?: ConclusionText;
}
/**
* 添加「联系我」方式请求参数
*/
export interface AddContactWayRequest {
/** 联系方式类型,1-单人,2-多人 */
type: 1 | 2;
/** 场景,1-在小程序中联系,2-通过二维码联系 */
scene: 1 | 2;
/** 在小程序中联系时使用的控件样式 */
style?: number;
/** 联系方式的备注信息,用于助记,不超过30个字符 */
remark?: string;
/** 外部客户添加时是否无需验证,默认为true */
skip_verify?: boolean;
/** 企业自定义的state参数,用于区分不同的添加渠道 */
state?: string;
/** 使用该联系方式的用户userID列表,在type为1时为必填,且只能有一个 */
user?: string[];
/** 使用该联系方式的部门id列表,只在type为2时有效 */
party?: number[];
/** 是否临时会话模式 */
is_temp?: boolean;
/** 临时会话二维码有效期,以秒为单位 */
expires_in?: number;
/** 临时会话有效期,以秒为单位 */
chat_expires_in?: number;
/** 可进行临时会话的客户unionid */
unionid?: string;
/** 是否开启同一外部企业客户只能添加同一个员工 */
is_exclusive?: boolean;
/** 是否标记客户添加来源 */
mark_source?: boolean;
/** 结束语 */
conclusions?: Conclusion;
}
/**
* 添加「联系我」方式响应
*/
export interface AddContactWayResponse {
/** 返回码 */
errcode: number;
/** 返回信息 */
errmsg: string;
/** 联系方式的配置id */
config_id?: string;
/** 联系方式二维码的URL,开发者需要自行将此二维码转换为图片 */
qr_code?: string;
}
/**
* 获取「联系我」方式请求参数
*/
export interface GetContactWayRequest {
/** 联系方式的配置id */
config_id: string;
}
/**
* 获取「联系我」方式响应
*/
export interface GetContactWayResponse {
/** 返回码 */
errcode: number;
/** 返回信息 */
errmsg: string;
/** 联系方式配置信息 */
contact_way?: ContactWayInfo;
}
/**
* 联系方式配置信息
*/
export interface ContactWayInfo {
/** 联系方式配置id */
config_id: string;
/** 联系方式类型 */
type: 1 | 2;
/** 场景 */
scene: 1 | 2;
/** 在小程序中联系时使用的控件样式 */
style?: number;
/** 联系方式的备注信息 */
remark?: string;
/** 是否无需验证 */
skip_verify: boolean;
/** state参数 */
state?: string;
/** 使用该联系方式的用户userID列表 */
user?: string[];
/** 使用该联系方式的部门id列表 */
party?: number[];
/** 是否临时会话模式 */
is_temp: boolean;
/** 临时会话二维码有效期 */
expires_in?: number;
/** 临时会话有效期 */
chat_expires_in?: number;
/** 临时会话指定的客户unionid */
unionid?: string;
/** 是否开启同一外部企业客户只能添加同一个员工 */
is_exclusive: boolean;
/** 结束语 */
conclusions?: Conclusion;
/** 二维码的URL */
qr_code?: string;
}
/**
* 更新「联系我」方式请求参数
*/
export interface UpdateContactWayRequest {
/** 联系方式的配置id */
config_id: string;
/** 在小程序中联系时使用的控件样式 */
style?: number;
/** 联系方式的备注信息 */
remark?: string;
/** 是否无需验证 */
skip_verify?: boolean;
/** 使用该联系方式的用户userID列表 */
user?: string[];
/** 使用该联系方式的部门id列表 */
party?: number[];
/** 是否临时会话模式 */
is_temp?: boolean;
/** 临时会话二维码有效期 */
expires_in?: number;
/** 临时会话有效期 */
chat_expires_in?: number;
/** 临时会话指定的客户unionid */
unionid?: string;
/** 是否开启同一外部企业客户只能添加同一个员工 */
is_exclusive?: boolean;
/** 结束语 */
conclusions?: Conclusion;
}
/**
* 更新「联系我」方式响应
*/
export interface UpdateContactWayResponse {
/** 返回码 */
errcode: number;
/** 返回信息 */
errmsg: string;
}
/**
* 删除「联系我」方式请求参数
*/
export interface DeleteContactWayRequest {
/** 联系方式的配置id */
config_id: string;
}
/**
* 删除「联系我」方式响应
*/
export interface DeleteContactWayResponse {
/** 返回码 */
errcode: number;
/** 返回信息 */
errmsg: string;
}
+395
View File
@@ -0,0 +1,395 @@
/**
* 企业微信 API 客户端
*/
import { WeChatError } from './common/wechat-error.js';
import type {
AccessTokenResponse,
GetExternalContactResponse,
GetExternalContactListResponse,
GetFollowUserListResponse,
BatchGetExternalContactResponse,
ExternalContact,
AddContactWayRequest,
AddContactWayResponse,
GetContactWayRequest,
GetContactWayResponse,
UpdateContactWayRequest,
UpdateContactWayResponse,
DeleteContactWayRequest,
DeleteContactWayResponse,
} from './types/wechat.js';
/**
* 企业微信API客户端配置
*/
export interface WeChatClientConfig {
/** 企业ID */
corpId: string;
/** 应用的secret */
corpSecret: string;
/** API基础URL */
baseUrl?: string;
}
/**
* 企业微信API客户端
*/
export class WeChatClient {
private config: Required<WeChatClientConfig>;
private accessToken: string | undefined;
private tokenExpireTime: number | undefined;
constructor(config: WeChatClientConfig) {
this.config = {
baseUrl: process.env.WEIXIN_CORP_URL ?? 'https://qyapi.weixin.qq.com',
...config,
};
}
/**
* 获取access_token
* @returns access_token
*/
async getAccessToken(): Promise<string> {
// 检查token是否有效
if (this.accessToken && this.tokenExpireTime && Date.now() < this.tokenExpireTime) {
return this.accessToken as string;
}
const url = new URL('/cgi-bin/gettoken', this.config.baseUrl);
url.searchParams.set('corpid', this.config.corpId);
url.searchParams.set('corpsecret', this.config.corpSecret);
const response = await fetch(url);
const data = await response.json() as AccessTokenResponse;
if (data.errcode !== 0 || !data.access_token) {
throw new Error(`获取access_token失败: ${data.errmsg}`);
}
this.accessToken = data.access_token;
// 提前5分钟过期,避免临界点问题
this.tokenExpireTime = Date.now() + (data.expires_in || 7200) * 1000 - 5 * 60 * 1000;
return this.accessToken;
}
/**
* 获取客户详情
* @param externalUserId 外部联系人的userid
* @param cursor 上次请求返回的next_cursor,用于分页
* @returns 客户详情
*/
async getExternalContact(
externalUserId: string,
cursor?: string
): Promise<GetExternalContactResponse> {
const token = await this.getAccessToken();
const url = new URL('/cgi-bin/externalcontact/get', this.config.baseUrl);
url.searchParams.set('access_token', token);
url.searchParams.set('external_userid', externalUserId);
if (cursor) {
url.searchParams.set('cursor', cursor);
}
const response = await fetch(url);
const data = await response.json() as GetExternalContactResponse;
// 检查API错误
if (data.errcode !== 0) {
const error = new WeChatError(data.errcode, data.errmsg);
error.errcode = data.errcode;
error.errmsg = data.errmsg;
throw error;
}
return data;
}
/**
* 获取客户详情(支持分页,自动获取所有跟进人)
* @param externalUserId 外部联系人的userid
* @returns 客户详情(包含所有跟进人)
*/
async getExternalContactFull(
externalUserId: string
): Promise<GetExternalContactResponse> {
let result: GetExternalContactResponse | null = null;
let cursor: string | undefined;
do {
const data = await this.getExternalContact(externalUserId, cursor);
if (!result) {
// 第一次请求,直接使用返回的数据
result = data;
} else {
// 后续请求,合并follow_user列表
result.follow_user.push(...data.follow_user);
// 更新next_cursor
if (data.next_cursor !== undefined) {
result.next_cursor = data.next_cursor;
}
}
cursor = data.next_cursor;
} while (cursor);
return result!;
}
/**
* 获取配置了客户联系功能的成员列表
* @returns 配置了客户联系功能的成员userid列表
*/
async getFollowUserList(): Promise<GetFollowUserListResponse> {
const token = await this.getAccessToken();
const url = new URL('/cgi-bin/externalcontact/get_follow_user_list', this.config.baseUrl);
url.searchParams.set('access_token', token);
const response = await fetch(url);
const data = await response.json() as GetFollowUserListResponse;
// 检查API错误
if (data.errcode !== 0) {
const error = new WeChatError(data.errcode, data.errmsg);
error.errcode = data.errcode;
error.errmsg = data.errmsg;
throw error;
}
return data;
}
/**
* 获取客户列表
* @param userId 企业成员的userid
* @returns 外部联系人的userid列表
*/
async getExternalContactList(
userId: string
): Promise<GetExternalContactListResponse> {
const token = await this.getAccessToken();
const url = new URL('/cgi-bin/externalcontact/list', this.config.baseUrl);
url.searchParams.set('access_token', token);
url.searchParams.set('userid', userId);
const response = await fetch(url);
const data = await response.json() as GetExternalContactListResponse;
// 检查API错误
if (data.errcode !== 0) {
const error = new WeChatError(data.errcode, data.errmsg);
error.errcode = data.errcode;
error.errmsg = data.errmsg;
throw error;
}
return data;
}
/**
* 批量获取客户详情
* @param userId 企业成员的userid
* @param cursor 上次请求返回的next_cursor,用于分页
* @param limit 返回的最大记录数,整型,最大值1000,默认值为100
* @returns 客户详情列表
*/
async batchGetExternalContact(
userId: string,
cursor?: string,
limit: number = 100
): Promise<BatchGetExternalContactResponse> {
const token = await this.getAccessToken();
const url = new URL('/cgi-bin/externalcontact/batch/get_by_user', this.config.baseUrl);
url.searchParams.set('access_token', token);
url.searchParams.set('userid', userId);
if (cursor) {
url.searchParams.set('cursor', cursor);
}
url.searchParams.set('limit', limit.toString());
const response = await fetch(url);
const data = await response.json() as BatchGetExternalContactResponse;
// 检查API错误
if (data.errcode !== 0) {
const error = new WeChatError(data.errcode, data.errmsg);
error.errcode = data.errcode;
error.errmsg = data.errmsg;
throw error;
}
return data;
}
/**
* 批量获取客户详情(自动分页,获取所有客户)
* @param userId 企业成员的userid
* @returns 所有客户详情列表
*/
async batchGetExternalContactFull(
userId: string
): Promise<ExternalContact[]> {
const allContacts: ExternalContact[] = [];
let cursor: string | undefined;
do {
const data = await this.batchGetExternalContact(userId, cursor);
if (data.external_contact_list) {
allContacts.push(...data.external_contact_list);
}
cursor = data.next_cursor;
} while (cursor);
return allContacts;
}
/**
* 添加「联系我」方式
* @param params 联系方式配置参数
* @returns 联系方式配置信息(包含config_id和qr_code)
*/
async addContactWay(
params: AddContactWayRequest
): Promise<AddContactWayResponse> {
const token = await this.getAccessToken();
const url = new URL('/cgi-bin/externalcontact/add_contact_way', this.config.baseUrl);
url.searchParams.set('access_token', token);
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(params),
});
const data = await response.json() as AddContactWayResponse;
// 检查API错误
if (data.errcode !== 0) {
const error = new WeChatError(data.errcode, data.errmsg);
error.errcode = data.errcode;
error.errmsg = data.errmsg;
throw error;
}
return data;
}
/**
* 获取「联系我」方式
* @param params 查询参数(包含config_id)
* @returns 联系方式配置信息
*/
async getContactWay(
params: GetContactWayRequest
): Promise<GetContactWayResponse> {
const token = await this.getAccessToken();
const url = new URL('/cgi-bin/externalcontact/get_contact_way', this.config.baseUrl);
url.searchParams.set('access_token', token);
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(params),
});
const data = await response.json() as GetContactWayResponse;
// 检查API错误
if (data.errcode !== 0) {
const error = new WeChatError(data.errcode, data.errmsg);
error.errcode = data.errcode;
error.errmsg = data.errmsg;
throw error;
}
return data;
}
/**
* 更新「联系我」方式
* @param params 更新参数(包含config_id和要更新的字段)
* @returns 更新结果
*/
async updateContactWay(
params: UpdateContactWayRequest
): Promise<UpdateContactWayResponse> {
const token = await this.getAccessToken();
const url = new URL('/cgi-bin/externalcontact/update_contact_way', this.config.baseUrl);
url.searchParams.set('access_token', token);
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(params),
});
const data = await response.json() as UpdateContactWayResponse;
// 检查API错误
if (data.errcode !== 0) {
const error = new WeChatError(data.errcode, data.errmsg);
error.errcode = data.errcode;
error.errmsg = data.errmsg;
throw error;
}
return data;
}
/**
* 删除「联系我」方式
* @param params 删除参数(包含config_id)
* @returns 删除结果
*/
async deleteContactWay(
params: DeleteContactWayRequest
): Promise<DeleteContactWayResponse> {
const token = await this.getAccessToken();
const url = new URL('/cgi-bin/externalcontact/del_contact_way', this.config.baseUrl);
url.searchParams.set('access_token', token);
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(params),
});
const data = await response.json() as DeleteContactWayResponse;
// 检查API错误
if (data.errcode !== 0) {
const error = new WeChatError(data.errcode, data.errmsg);
error.errcode = data.errcode;
error.errmsg = data.errmsg;
throw error;
}
return data;
}
/**
* 重置access_token(强制重新获取)
*/
resetAccessToken(): void {
this.accessToken = undefined as string | undefined;
this.tokenExpireTime = undefined as number | undefined;
}
}
+62
View File
@@ -0,0 +1,62 @@
import { WeChatClient } from "./wechat-client.js";
export async function handleWecomp() {
// 检查环境变量
if (process.env.WEIXIN_CORP_ID == null ||
process.env.WEIXIN_CORP_SECRET == null
) {
throw new Error('环境变量错误:请设置 WEIXIN_CORP_ID 和 WEIXIN_CORP_SECRET');
}
// 创建企业微信客户端
const client = new WeChatClient({
corpId: process.env.WEIXIN_CORP_ID,
corpSecret: process.env.WEIXIN_CORP_SECRET,
baseUrl: process.env.WEIXIN_CORP_URL || 'https://qyapi.weixin.qq.com',
});
try {
// 示例:获取配置了客户联系功能的成员列表
console.log('\n=== 获取配置了客户联系功能的成员列表 ===');
const followUserList = await client.getFollowUserList();
console.log('\n--- 成员列表 ---');
console.log('成员数量:', followUserList.follow_user?.length || 0);
if (!followUserList.follow_user || followUserList.follow_user.length === 0) {
console.log('没有配置了客户联系功能的成员');
return;
}
console.log('成员ID列表:');
followUserList.follow_user.forEach((id, index) => {
console.log(` ${index + 1}. ${id}`);
});
let userId = 'YeDeLiang';
if (!userId) {
console.log('错误:无法获取有效的成员userid');
return;
}
const res = await client.getContactWay({
config_id: 'd5990127d833c10d9db8ba7f192e7b19'
});
console.log(res);
} catch (error) {
if (error instanceof Error) {
console.error('发生错误:', error.message);
const err = error as unknown as { errcode?: number; errmsg?: string };
if (err.errcode !== undefined) {
console.error('错误码:', err.errcode);
}
if (err.errmsg !== undefined) {
console.error('错误信息:', err.errmsg);
}
} else {
throw error;
}
}
}