From f93fc7f8516a11d43b173d87c44823deb0004bc5 Mon Sep 17 00:00:00 2001 From: CaoMeiYouRen <996881204@qq.com> Date: Mon, 10 Feb 2025 17:12:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E9=A3=9E=E4=B9=A6?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E5=8F=91=E9=80=81=E5=8A=9F=E8=83=BD=E5=8F=8A?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix #285 --- src/index.ts | 6 +- src/one.ts | 3 +- src/push/feishu.ts | 252 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 258 insertions(+), 3 deletions(-) create mode 100644 src/push/feishu.ts diff --git a/src/index.ts b/src/index.ts index bff9252..e6c1f4a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ export * from './push/custom-email' export * from './push/dingtalk' export * from './push/discord' +export * from './push/feishu' export * from './push/i-got' export * from './push/one-bot' export * from './push/push-deer' @@ -13,7 +14,8 @@ export * from './push/wechat-app' export * from './push/wechat-robot' export * from './push/xi-zhi' -export * from './one' export * from './interfaces/response' -export * from './interfaces/send' export * from './interfaces/schema' +export * from './interfaces/send' +export * from './one' + diff --git a/src/one.ts b/src/one.ts index c9f5ecb..7a8f32e 100644 --- a/src/one.ts +++ b/src/one.ts @@ -1,10 +1,11 @@ -import { CustomEmail, Dingtalk, Discord, IGot, OneBot, PushDeer, PushPlus, Qmsg, ServerChanTurbo, ServerChanV3, Telegram, WechatApp, WechatRobot, XiZhi } from './index' +import { CustomEmail, Dingtalk, Discord, IGot, OneBot, PushDeer, PushPlus, Qmsg, ServerChanTurbo, ServerChanV3, Telegram, WechatApp, WechatRobot, XiZhi, Feishu } from './index' import { SendResponse } from '@/interfaces/response' export const PushAllInOne = { CustomEmail, Dingtalk, Discord, + Feishu, IGot, OneBot, PushDeer, diff --git a/src/push/feishu.ts b/src/push/feishu.ts new file mode 100644 index 0000000..7778bd0 --- /dev/null +++ b/src/push/feishu.ts @@ -0,0 +1,252 @@ +import debug from 'debug' +import { Send } from '@/interfaces/send' +import { ajax } from '@/utils/ajax' +import { SendResponse } from '@/interfaces/response' +import { ConfigSchema, OptionSchema } from '@/interfaces/schema' +import { validate } from '@/utils/validate' + +const Debugger = debug('push:feishu') + +export interface FeishuConfig { + /** + * 飞书应用 ID。官方文档:https://open.feishu.cn/document/server-docs/api-call-guide/terminology#b047be0c + */ + FEISHU_APP_ID: string + /** + * 飞书应用密钥。官方文档:https://open.feishu.cn/document/server-docs/api-call-guide/terminology#1b5fb6cd + */ + FEISHU_APP_SECRET: string +} + +export type FeishuConfigSchema = ConfigSchema + +export const feishuConfigSchema: FeishuConfigSchema = { + FEISHU_APP_ID: { + type: 'string', + title: '飞书应用 ID', + description: '飞书应用 ID', + required: true, + default: '', + }, + FEISHU_APP_SECRET: { + type: 'string', + title: '飞书应用密钥', + description: '飞书应用密钥', + required: true, + default: '', + }, +} + +export type FeishuOption = { + // 用户 ID 类型 + receive_id_type: 'open_id' | 'union_id' | 'user_id' | 'email' | 'chat_id' + // 消息接收者的 ID,ID 类型与查询参数 receive_id_type 的取值一致。 + receive_id: string + // 消息类型。 + msg_type: 'text' | 'post' | 'image' | 'file' | 'audio' | 'media' | 'sticker' | 'interactive' | 'share_chat' | 'share_user' | 'system' + // 消息内容,JSON 结构序列化后的字符串。该参数的取值与 msg_type 对应,例如 msg_type 取值为 text,则该参数需要传入文本类型的内容。 + content?: string + // 自定义设置的唯一字符串序列,用于在发送消息时请求去重。持有相同 uuid 的请求,在 1 小时内至多成功发送一条消息。 + uuid?: string +} + +export type FeishuOptionSchema = OptionSchema + +export const feishuOptionSchema: FeishuOptionSchema = { + receive_id_type: { + type: 'select', + title: '用户 ID 类型', + description: '用户 ID 类型', + required: true, + options: [ + { + label: 'open_id', + value: 'open_id', + }, + { + label: 'union_id', + value: 'union_id', + }, + { + label: 'user_id', + value: 'user_id', + }, + { + label: 'email', + value: 'email', + }, + { + label: 'chat_id', + value: 'chat_id', + }, + ], + }, + receive_id: { + type: 'string', + title: '消息接收者的 ID', + description: '消息接收者的 ID,ID 类型与查询参数 receive_id_type 的取值一致。', + required: true, + }, + msg_type: { + type: 'select', + title: '消息类型', + description: '消息类型', + required: true, + options: [ + { + label: '文本', + value: 'text', + }, + { + label: '富文本', + value: 'post', + }, + { + label: '图片', + value: 'image', + }, + { + label: '文件', + value: 'file', + }, + { + label: '语音', + value: 'audio', + }, + { + label: '视频', + value: 'media', + }, + { + label: '表情包', + value: 'sticker', + }, + { + label: '卡片', + value: 'interactive', + }, + { + label: '分享群名片', + value: 'share_chat', + }, + { + label: '分享个人名片', + value: 'share_user', + }, + { + label: '系统消息', + value: 'system', + }, + ], + }, + content: { + type: 'string', + title: '消息内容', + description: '消息内容,JSON 结构序列化后的字符串。该参数的取值与 msg_type 对应,例如 msg_type 取值为 text,则该参数需要传入文本类型的内容。', + required: false, + }, + uuid: { + type: 'string', + title: '自定义设置的唯一字符串序列', + description: '自定义设置的唯一字符串序列,用于在发送消息时请求去重。持有相同 uuid 的请求,在 1 小时内至多成功发送一条消息。', + required: false, + }, +} + +export class Feishu implements Send { + + static readonly namespace = '飞书' + + static readonly configSchema = feishuConfigSchema + + static readonly optionSchema = feishuOptionSchema + + private readonly config: FeishuConfig + + /** + * accessToken 的过期时间(时间戳) + */ + private expiresTime: number + + private accessToken: string + + constructor(config: FeishuConfig) { + this.config = config + // 根据 configSchema 验证 config + validate(config, Feishu.configSchema) + } + + private async getAccessToken() { + const { FEISHU_APP_ID, FEISHU_APP_SECRET } = this.config + const url = 'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal' + const data = { + app_id: FEISHU_APP_ID, + app_secret: FEISHU_APP_SECRET, + } + const result = await ajax({ + url, + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=utf-8', + }, + data, + }) + const { code, msg, tenant_access_token, expire } = result.data + if (code !== 0) { // 出错返回码,为0表示成功,非0表示调用失败 + throw new Error(msg || '获取 tenant_access_token 失败!') + } + this.expiresTime = Date.now() + expire * 1000 + Debugger('获取 tenant_access_token 成功: %s', tenant_access_token) + return tenant_access_token as string + } + + async send(title: string, desp?: string, option?: FeishuOption): Promise { + Debugger('title: "%s", desp: "%s", option: %O', title, desp, option) + if (!this.accessToken || Date.now() >= this.expiresTime) { + this.accessToken = await this.getAccessToken() + } + const { receive_id_type = 'open_id', receive_id, msg_type = 'text', content, uuid } = option + const data = { receive_id, msg_type, content, uuid } + if (!data.content) { + switch (msg_type) { + case 'text': + data.content = JSON.stringify({ + text: `${title}${desp ? `\n${desp}` : ''}`, + }) + break + case 'post': + data.content = JSON.stringify({ + post: { + zh_cn: { + title, + content: [ + [ + { + tag: 'text', + text: desp, + }, + ], + ], + }, + }, + }) + break + default: + throw new Error('msg_type is required!') + } + } + const result = await ajax({ + url: 'https://open.feishu.cn/open-apis/im/v1/messages', + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=utf-8', + Authorization: `Bearer ${this.accessToken}`, + }, + data, + query: { + receive_id_type: receive_id_type || 'open_id', + }, + }) + return result + } +}