import * as $OpenApi from "@alicloud/openapi-client";
import * as $Util from "@alicloud/tea-util";
import dingtalk from "@alicloud/dingtalk";
import type { ResolvedDingTalkAccount, WebhookResponse, MarkdownReplyBody } from "./types.js";
import { logger } from "./logger.js";
const { oauth2_1_0, robot_1_0 } = dingtalk;
// SDK 客户端类型
type OAuth2Client = InstanceType<typeof oauth2_1_0.default>;
type RobotClient = InstanceType<typeof robot_1_0.default>;
// ======================= Access Token 缓存 =======================
interface TokenCache {
token: string;
expireTime: number;
}
const tokenCacheMap = new Map<string, TokenCache>();
/**
* 创建 OAuth2 客户端
*/
function createOAuth2Client(): OAuth2Client {
const config = new $OpenApi.Config({});
config.protocol = "https";
config.regionId = "central";
return new oauth2_1_0.default(config);
}
/**
* 创建 Robot 客户端
*/
function createRobotClient(): RobotClient {
const config = new $OpenApi.Config({});
config.protocol = "https";
config.regionId = "central";
return new robot_1_0.default(config);
}
/**
* 获取钉钉 access_token
*/
export async function getAccessToken(account: ResolvedDingTalkAccount): Promise<string> {
const cacheKey = `${account.clientId}`;
const cached = tokenCacheMap.get(cacheKey);
// 检查缓存的 token 是否有效(提前5分钟过期)
if (cached && Date.now() < cached.expireTime - 5 * 60 * 1000) {
return cached.token;
}
const oauth2Client = createOAuth2Client();
const request = new oauth2_1_0.GetAccessTokenRequest({
appKey: account.clientId,
appSecret: account.clientSecret,
});
const response = await oauth2Client.getAccessToken(request);
if (response.body?.accessToken) {
const token = response.body.accessToken;
const expireTime = Date.now() + (response.body.expireIn ?? 7200) * 1000;
tokenCacheMap.set(cacheKey, { token, expireTime });
return token;
}
throw new Error("获取 access_token 失败: 返回结果为空");
}
// ======================= 发送消息 =======================
export interface SendMessageOptions {
account: ResolvedDingTalkAccount;
verbose?: boolean;
}
export interface SendMessageResult {
messageId: string;
chatId: string;
}
/**
* 通过 sessionWebhook 回复消息(markdown 格式)
*/
export async function replyViaWebhook(
webhook: string,
content: string,
options?: {
atUserIds?: string[];
isAtAll?: boolean;
}
): Promise<WebhookResponse> {
const contentPreview = content.slice(0, 50).replace(/\n/g, " ");
logger.log(`[回复消息] via Webhook | ${contentPreview}${content.length > 50 ? "..." : ""}`);
const title = content.slice(0, 10).replace(/\n/g, " ");
const body: MarkdownReplyBody = {
msgtype: "markdown",
markdown: {
title,
text: content,
},
at: {
atUserIds: options?.atUserIds ?? [],
isAtAll: options?.isAtAll ?? false,
},
};
const response = await fetch(webhook, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
const result = (await response.json()) as WebhookResponse;
if (result.errcode === 0) {
logger.log(`[回复消息] 发送成功`);
} else {
logger.error(`[回复消息] 发送失败: ${result.errmsg ?? JSON.stringify(result)}`);
}
return result;
}
// ======================= 主动发送消息(BatchSendOTO / OrgGroupSend) =======================
/**
* 钉钉机器人消息类型(msgKey)
* @see https://open.dingtalk.com/document/orgapp/types-of-messages-sent-by-enterprise-robots
*/
export type DingTalkMsgKey =
| "sampleText" // 文本
| "sampleMarkdown" // Markdown
| "sampleImageMsg" // 图片
| "sampleLink" // 链接
| "sampleAudio" // 语音
| "sampleVideo" // 视频
| "sampleFile" // 文件
| "sampleActionCard" // 卡片
| "sampleActionCard2" // 卡片(独立跳转)
| "sampleActionCard3" // 卡片(竖向按钮)
| "sampleActionCard4" // 卡片(横向按钮)
| "sampleActionCard5" // 卡片(横向2按钮)
| "sampleActionCard6"; // 卡片(横向3按钮)
/**
* 底层通用方法:主动发送单聊消息(BatchSendOTO)
* 所有 sendXxxMessage 方法都基于此方法实现
*/
async function sendOTOMessage(
userId: string,
msgKey: DingTalkMsgKey,
msgParam: Record<string, unknown>,
options: SendMessageOptions
): Promise<SendMessageResult> {
const accessToken = await getAccessToken(options.account);
const robotClient = createRobotClient();
const headers = new robot_1_0.BatchSendOTOHeaders({
xAcsDingtalkAccessToken: accessToken,
});
const request = new robot_1_0.BatchSendOTORequest({
robotCode: options.account.clientId,
userIds: [userId],
msgKey,
msgParam: JSON.stringify(msgParam),
});
const response = await robotClient.batchSendOTOWithOptions(
request,
headers,
new $Util.RuntimeOptions({})
);
const processQueryKey = response.body?.processQueryKey ?? `dingtalk-${Date.now()}`;
return {
messageId: processQueryKey,
chatId: userId,
};
}
/**
* 底层通用方法:主动发送群聊消息(OrgGroupSend)
*/
async function sendGroupMessage(
openConversationId: string,
msgKey: DingTalkMsgKey,
msgParam: Record<string, unknown>,
options: SendMessageOptions
): Promise<SendMessageResult> {
const accessToken = await getAccessToken(options.account);
const robotClient = createRobotClient();
const headers = new robot_1_0.OrgGroupSendHeaders({
xAcsDingtalkAccessToken: accessToken,
});
const request = new robot_1_0.OrgGroupSendRequest({
robotCode: options.account.clientId,
openConversationId,
msgKey,
msgParam: JSON.stringify(msgParam),
});
const response = await robotClient.orgGroupSendWithOptions(
request,
headers,
new $Util.RuntimeOptions({})
);
const processQueryKey = response.body?.processQueryKey ?? `dingtalk-group-${Date.now()}`;
return {
messageId: processQueryKey,
chatId: openConversationId,
};
}
// ======================= 统一目标路由 =======================
/**
* 判断目标是否为群聊
* 群聊目标格式:chat:<openConversationId>
* 单聊目标格式:user:<userId> 或直接 <userId>
*/
export function isGroupTarget(to: string): boolean {
return to.startsWith("chat:");
}
/** 从 to 中提取实际 ID(去除 chat: / user: 前缀) */
export function extractTargetId(to: string): string {
if (to.startsWith("chat:")) return to.slice(5);
if (to.startsWith("user:")) return to.slice(5);
return to;
}
/**
* 统一发送消息(自动根据 to 格式路由到单聊或群聊)
*/
async function sendMessage(
to: string,
msgKey: DingTalkMsgKey,
msgParam: Record<string, unknown>,
options: SendMessageOptions
): Promise<SendMessageResult> {
const targetId = extractTargetId(to);
if (isGroupTarget(to)) {
return sendGroupMessage(targetId, msgKey, msgParam, options);
}
return sendOTOMessage(targetId, msgKey, msgParam, options);
}
/**
* 发送文本消息(markdown 格式,自动路由群聊/单聊)
*/
export async function sendTextMessage(
to: string,
content: string,
options: SendMessageOptions
): Promise<SendMessageResult> {
const contentPreview = content.slice(0, 50).replace(/\n/g, " ");
const isGroup = isGroupTarget(to);
logger.log(`[主动发送] 文本消息 | ${isGroup ? "群聊" : "单聊"} | to: ${to} | ${contentPreview}${content.length > 50 ? "..." : ""}`);
const title = content.slice(0, 10).replace(/\n/g, " ");
const result = await sendMessage(to, "sampleMarkdown", { title, text: content }, options);
logger.log(`[主动发送] 文本消息发送成功 | messageId: ${result.messageId}`);
return result;
}
/**
* 发送图片消息(自动路由群聊/单聊)
* @param photoURL - 图片的公网可访问 URL
*/
export async function sendImageMessage(
to: string,
photoURL: string,
options: SendMessageOptions
): Promise<SendMessageResult> {
const isGroup = isGroupTarget(to);
logger.log(`[主动发送] 图片消息 | ${isGroup ? "群聊" : "单聊"} | to: ${to} | photoURL: ${photoURL.slice(0, 80)}...`);
const result = await sendMessage(to, "sampleImageMsg", { photoURL }, options);
logger.log(`[主动发送] 图片消息发送成功 | messageId: ${result.messageId}`);
return result;
}
/**
* 发送语音消息(自动路由群聊/单聊)
* @param mediaId - 语音文件的 mediaId(通过 uploadMedia 获取)
* @param duration - 语音时长(秒),可选
*/
export async function sendAudioMessage(
to: string,
mediaId: string,
options: SendMessageOptions & {
duration?: string;
}
): Promise<SendMessageResult> {
logger.log(`[主动发送] 语音消息 | to: ${to} | mediaId: ${mediaId} | duration: ${options.duration ?? "未知"}`);
const msgParam: Record<string, string> = { mediaId };
if (options.duration) {
msgParam.duration = options.duration;
}
const result = await sendMessage(to, "sampleAudio", msgParam, options);
logger.log(`[主动发送] 语音消息发送成功 | messageId: ${result.messageId}`);
return result;
}
/**
* 发送视频消息(自动路由群聊/单聊)
*/
export async function sendVideoMessage(
to: string,
videoMediaId: string,
options: SendMessageOptions & {
duration?: string;
picMediaId?: string;
width?: string;
height?: string;
}
): Promise<SendMessageResult> {
logger.log(`[主动发送] 视频消息 | to: ${to} | videoMediaId: ${videoMediaId}`);
const msgParam: Record<string, string> = {
videoMediaId,
videoType: "mp4",
};
if (options.duration) {
msgParam.duration = options.duration;
}
if (options.picMediaId) {
msgParam.picMediaId = options.picMediaId;
}
if (options.width) {
msgParam.width = options.width;
}
if (options.height) {
msgParam.height = options.height;
}
const result = await sendMessage(to, "sampleVideo", msgParam, options);
logger.log(`[主动发送] 视频消息发送成功 | messageId: ${result.messageId}`);
return result;
}
/**
* 发送文件消息(自动路由群聊/单聊)
* @param mediaId - 文件的 mediaId(通过 uploadMedia 获取)
* @param fileName - 文件名
* @param fileType - 文件扩展名(如 pdf、doc 等)
*/
export async function sendFileMessage(
to: string,
mediaId: string,
fileName: string,
fileType: string,
options: SendMessageOptions
): Promise<SendMessageResult> {
logger.log(`[主动发送] 文件消息 | to: ${to} | fileName: ${fileName} | fileType: ${fileType}`);
const result = await sendMessage(to, "sampleFile", { mediaId, fileName, fileType }, options);
logger.log(`[主动发送] 文件消息发送成功 | messageId: ${result.messageId}`);
return result;
}
/**
* 发送链接消息(自动路由群聊/单聊)
*/
export async function sendLinkMessage(
to: string,
options: SendMessageOptions & {
title: string;
text: string;
messageUrl: string;
picUrl?: string;
}
): Promise<SendMessageResult> {
logger.log(`[主动发送] 链接消息 | to: ${to} | title: ${options.title}`);
const result = await sendMessage(
to,
"sampleLink",
{
title: options.title,
text: options.text,
messageUrl: options.messageUrl,
picUrl: options.picUrl ?? "",
},
options
);
logger.log(`[主动发送] 链接消息发送成功 | messageId: ${result.messageId}`);
return result;
}
// ======================= 探测 Bot =======================
export interface DingTalkProbeResult {
ok: boolean;
bot?: {
name?: string;
robotCode?: string;
};
error?: string;
}
/**
* 探测钉钉机器人状态
*/
export async function probeDingTalkBot(
account: ResolvedDingTalkAccount,
_timeoutMs?: number
): Promise<DingTalkProbeResult> {
try {
// 尝试获取 access_token 来验证凭据是否有效
await getAccessToken(account);
return {
ok: true,
bot: {
robotCode: account.clientId,
name: account.name,
},
};
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
// ======================= 图片处理 =======================
/**
* 获取钉钉文件下载链接
* @param downloadCode - 文件下载码
* @param account - 钉钉账户配置
* @returns 下载链接
*/
export async function getFileDownloadUrl(
downloadCode: string,
account: ResolvedDingTalkAccount
): Promise<string> {
const accessToken = await getAccessToken(account);
const robotClient = createRobotClient();
const headers = new robot_1_0.RobotMessageFileDownloadHeaders({
xAcsDingtalkAccessToken: accessToken,
});
const request = new robot_1_0.RobotMessageFileDownloadRequest({
downloadCode,
robotCode: account.clientId,
});
const response = await robotClient.robotMessageFileDownloadWithOptions(
request,
headers,
new $Util.RuntimeOptions({})
);
if (response.body?.downloadUrl) {
return response.body.downloadUrl;
}
throw new Error("获取下载链接失败: 返回结果为空");
}
/**
* 从 URL 下载文件
* @param url - 下载链接
* @returns 文件内容 Buffer
*/
export async function downloadFromUrl(url: string): Promise<Buffer> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`下载文件失败: ${response.status} ${response.statusText}`);
}
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
}
// ======================= 媒体文件上传 =======================
/**
* 钉钉支持的媒体类型(media/upload 接口)
* - image: 图片,最大 20MB,支持 jpg/gif/png/bmp
* - voice: 语音,最大 2MB,支持 amr/mp3/wav
* - video: 视频,最大 20MB,支持 mp4
* - file: 普通文件,最大 20MB,支持 doc/docx/xls/xlsx/ppt/pptx/zip/pdf/rar
*/
export type DingTalkMediaType = "image" | "voice" | "video" | "file";
export interface UploadMediaResult {
mediaId: string;
/** 图片类型返回公网可访问 URL,其他类型返回空字符串 */
url: string;
/** 媒体类型 */
type: DingTalkMediaType;
}
/**
* 根据 MIME 类型推断钉钉媒体类型
*/
export function inferMediaType(mimeType: string): DingTalkMediaType {
if (mimeType.startsWith("image/")) {
return "image";
}
if (mimeType.startsWith("audio/")) {
return "voice";
}
if (mimeType.startsWith("video/")) {
return "video";
}
return "file";
}
/**
* 根据媒体类型获取对应的 Content-Type
*/
function getContentType(type: DingTalkMediaType, mimeType?: string): string {
if (mimeType) {
return mimeType;
}
switch (type) {
case "image":
return "image/png";
case "voice":
return "audio/amr";
case "video":
return "video/mp4";
case "file":
default:
return "application/octet-stream";
}
}
/**
* 上传媒体文件到钉钉(使用旧版 oapi 接口)
* @param fileBuffer - 文件 Buffer
* @param fileName - 文件名
* @param account - 钉钉账户配置
* @param options - 上传选项
* @returns 包含 media_id 和公网可访问 URL 的对象
*/
export async function uploadMedia(
fileBuffer: Buffer,
fileName: string,
account: ResolvedDingTalkAccount,
options?: {
/** 媒体类型,不传则根据 mimeType 自动推断 */
type?: DingTalkMediaType;
/** MIME 类型,用于推断媒体类型和设置 Content-Type */
mimeType?: string;
}
): Promise<UploadMediaResult> {
const mimeType = options?.mimeType;
const type = options?.type ?? (mimeType ? inferMediaType(mimeType) : "image");
const contentType = getContentType(type, mimeType);
logger.log(`[上传媒体] type: ${type} | fileName: ${fileName} | size: ${fileBuffer.length} bytes`);
const accessToken = await getAccessToken(account);
// 使用 FormData 上传
const formData = new FormData();
const uint8Array = new Uint8Array(fileBuffer);
const blob = new Blob([uint8Array], { type: contentType });
formData.append("media", blob, fileName);
formData.append("type", type);
const response = await fetch(
`https://oapi.dingtalk.com/media/upload?access_token=${accessToken}`,
{
method: "POST",
body: formData,
}
);
const result = (await response.json()) as {
errcode?: number;
errmsg?: string;
media_id?: string;
};
if (result.errcode === 0 && result.media_id) {
logger.log(`[上传媒体] 上传成功 | mediaId: ${result.media_id}`);
// 只有图片类型才构造公网可访问的 URL
const url = type === "image"
? `https://oapi.dingtalk.com/media/downloadFile?access_token=${accessToken}&media_id=${result.media_id}`
: "";
return {
mediaId: result.media_id,
url,
type,
};
}
logger.error(`[上传媒体] 上传失败: ${result.errmsg ?? JSON.stringify(result)}`);
throw new Error(`上传媒体文件失败: ${result.errmsg ?? JSON.stringify(result)}`);
}