blob: 7b2b0e08bf700738f1ab55b62dc6350c3314e520 [file]
/**
* PR-BOT-DINGTALK-OPERATIONAL-0 (external bot research: DingTalk Stream):
* full DingTalk (钉钉) bot lifecycle — access_token cache, Stream
* subscription open, WebSocket connect, frame dispatch + ack, bot
* message receive, REST send via the open-platform messaging API,
* reconnect with backoff.
*
* Stream is DingTalk's outbound-connection alternative to the legacy
* callback URL. The bot opens a WebSocket to DingTalk; events arrive
* inbound. No need to expose a public HTTP port — Maka can run as a
* desktop app without a tunnel.
*
* Storage semantics (matches the credential-test PR):
* - `appId` = appKey (the self-built app's identifier)
* - `appSecret` = appsecret
* Outbound replies require the open-platform `robotCode`; we derive it
* from `appKey` (DingTalk's chatbot SDK uses appKey as robotCode).
*/
import { WebSocket } from 'undici';
import type { BotChannelSettings } from '@maka/core';
import { BaseBotAdapter, botReadinessFromSettings } from './base-adapter.js';
import { proxiedFetch } from './proxied-fetch.js';
import type { BotPlatform, BotSendOptions, BotStatus, SendCapable } from './types.js';
const DINGTALK_API = 'https://api.dingtalk.com';
const DINGTALK_OAPI = 'https://oapi.dingtalk.com';
const TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1_000; // refresh 5 min before expiry
const RECONNECT_DELAY_MIN_MS = 1_000;
const RECONNECT_DELAY_MAX_MS = 30_000;
const SEND_RETRY_DELAY_MIN_MS = 1_000;
const SEND_RETRY_DELAY_MAX_MS = 30_000;
const DINGTALK_TOPIC_BOT_MESSAGES = '/v1.0/im/bot/messages/get';
interface DingTalkConnectionOpenResponse {
endpoint: string;
ticket: string;
}
interface DingTalkStreamFrame {
specVersion?: string;
type?: 'SYSTEM' | 'EVENT' | 'CALLBACK';
headers?: { messageId?: string; topic?: string; contentType?: string };
data?: string;
}
interface DingTalkBotMessagePayload {
senderId?: string;
senderNick?: string;
conversationId?: string;
conversationType?: '1' | '2'; // 1 = single chat, 2 = group
text?: { content?: string };
robotCode?: string;
chatbotUserId?: string;
}
/**
* Pure decision: given the gateway close code and whether the bridge
* was explicitly stopped, decide what to do next. Extracted so the
* branching is unit-testable without a live WebSocket.
*/
export type DingTalkCloseDecision = { kind: 'stopped' } | { kind: 'reconnect' };
export function decideDingTalkClose(
_code: number,
explicitlyStopped: boolean,
): DingTalkCloseDecision {
if (explicitlyStopped) return { kind: 'stopped' };
return { kind: 'reconnect' };
}
/**
* Pure helper: exponential backoff for stream reconnect.
*/
export function dingTalkReconnectBackoffMs(attempts: number): number {
const exp = Math.min(2 ** attempts, RECONNECT_DELAY_MAX_MS / RECONNECT_DELAY_MIN_MS);
return Math.min(RECONNECT_DELAY_MIN_MS * exp, RECONNECT_DELAY_MAX_MS);
}
/**
* Pure helper: build the open-platform bot reply request body.
* For group chats we POST to `/v1.0/robot/groupMessages/send` and the
* body needs `openConversationId` + `robotCode`. For single chats
* we use `/v1.0/robot/oToMessages/batchSend` with `userIds`. The
* caller decides which endpoint based on conversation context; this
* helper just shape-checks the body.
*/
export function buildDingTalkGroupSendBody(
openConversationId: string,
robotCode: string,
text: string,
): Record<string, unknown> {
return {
robotCode,
openConversationId,
msgKey: 'sampleText',
msgParam: JSON.stringify({ content: text }),
};
}
export function buildDingTalkSingleSendBody(
userId: string,
robotCode: string,
text: string,
): Record<string, unknown> {
return {
robotCode,
userIds: [userId],
msgKey: 'sampleText',
msgParam: JSON.stringify({ content: text }),
};
}
export function pickDingTalkSendRoute(
chatId: string,
robotCode: string,
text: string,
): {
path: string;
body: Record<string, unknown>;
} | null {
const targetId = chatId.trim();
if (!targetId) return null;
const isGroup = targetId.startsWith('cid');
return {
path: isGroup ? '/v1.0/robot/groupMessages/send' : '/v1.0/robot/oToMessages/batchSend',
body: isGroup
? buildDingTalkGroupSendBody(targetId, robotCode, text)
: buildDingTalkSingleSendBody(targetId, robotCode, text),
};
}
/**
* Pure helper: classify DingTalk's HTTP send response so we can route
* between done / retry / fatal. DingTalk uses `errcode` style (0 = ok)
* with HTTP wrapping. 429 is the retry signal.
*/
export type DingTalkSendClassification =
| { kind: 'ok'; messageId: string | null }
| { kind: 'retry'; delayMs: number }
| { kind: 'fatal'; description: string };
export function classifyDingTalkSendResponse(
status: number,
bodyJson: unknown,
): DingTalkSendClassification {
if (status >= 200 && status < 300) {
const body = bodyJson as {
errcode?: number;
errmsg?: string;
processQueryKey?: unknown;
} | null;
if (body && typeof body.errcode === 'number' && body.errcode !== 0) {
return { kind: 'fatal', description: body.errmsg ?? `errcode ${body.errcode}` };
}
const id = body?.processQueryKey;
return {
kind: 'ok',
messageId: typeof id === 'string' || typeof id === 'number' ? String(id) : null,
};
}
if (status === 429) {
return {
kind: 'retry',
delayMs: Math.min(Math.max(SEND_RETRY_DELAY_MIN_MS, 1_000), SEND_RETRY_DELAY_MAX_MS),
};
}
const message = bodyJson as { errmsg?: unknown; message?: unknown } | null;
const description =
(typeof message?.errmsg === 'string' && message.errmsg) ||
(typeof message?.message === 'string' && message.message) ||
`HTTP ${status}`;
return { kind: 'fatal', description };
}
/**
* Pure helper: map a DingTalk Stream `/v1.0/im/bot/messages/get`
* callback payload to BotMessageEvent. Returns `null` for payloads
* that aren't text messages we can act on.
*/
export function dingTalkPayloadToEvent(
payload: DingTalkBotMessagePayload,
receivedAt: number,
): {
platform: 'dingtalk';
userId: string;
userName: string;
chatId: string;
isGroup: boolean;
text: string;
sourceMessageId: string;
receivedAt: number;
} | null {
if (!payload || typeof payload !== 'object') return null;
const content = payload.text?.content;
if (typeof content !== 'string' || content.length === 0) return null;
const chatId = payload.conversationId;
const userId = payload.senderId;
if (typeof chatId !== 'string' || chatId.length === 0) return null;
if (typeof userId !== 'string' || userId.length === 0) return null;
return {
platform: 'dingtalk',
userId,
userName: payload.senderNick ?? userId,
chatId,
isGroup: payload.conversationType === '2',
text: content,
// DingTalk Stream callbacks do not carry the original message id;
// use a synthetic key so downstream contracts that key off
// `sourceMessageId` still get a unique value.
sourceMessageId: `${chatId}:${receivedAt}`,
receivedAt,
};
}
/**
* Pure helper: shape the ack frame DingTalk Stream expects after every
* CALLBACK delivery. Missing the ack causes the gateway to retransmit.
*/
export function buildDingTalkAckFrame(
messageId: string,
data: Record<string, unknown> = {},
): {
code: number;
headers: { contentType: string; messageId: string };
data: string;
} {
return {
code: 200,
headers: { contentType: 'application/json', messageId },
data: JSON.stringify(data),
};
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
interface CachedToken {
value: string;
expiresAt: number;
}
export class DingTalkBotBridge extends BaseBotAdapter implements SendCapable {
private ws: WebSocket | null = null;
private token: CachedToken | null = null;
private explicitlyStopped = false;
private reconnectAttempts = 0;
private reconnectTimer: NodeJS.Timeout | null = null;
constructor(platform: BotPlatform, settings: BotChannelSettings) {
super(platform, settings);
}
async start(): Promise<void> {
if (this.running) return;
if (!this.settings.enabled) {
this.reason = 'disabled';
this.readiness = 'scaffolded';
return;
}
if (!this.settings.appId?.trim() || !this.settings.appSecret?.trim()) {
this.reason = 'no-credentials';
this.readiness = 'scaffolded';
return;
}
this.explicitlyStopped = false;
await this.startStream();
}
async stop(): Promise<void> {
this.explicitlyStopped = true;
this.running = false;
this.clearReconnect();
if (this.ws) {
try {
this.ws.close(1000);
} catch {
/* swallow */
}
this.ws = null;
}
this.reason = 'stopped';
this.readiness = botReadinessFromSettings(this.settings);
this.emitStatusChange();
}
/**
* DingTalk REST send. We treat any chatId with a `cidp` prefix as a
* group conversation; pure-numeric or other prefixes route to the
* single-user batch API. The caller (main.ts) already knows whether
* the bot conversation is a group via the BotMessageEvent.isGroup
* flag, but the bridge's `sendMessage` only sees the chatId — so we
* make a conservative split based on the `conversationType` hint
* baked into the chatId structure: group conversation IDs start with
* `cid` per DingTalk's open platform docs.
*/
async sendMessage(
chatId: string,
text: string,
_options?: BotSendOptions,
): Promise<string | null> {
if (this.platform !== 'dingtalk' || !this.running) return null;
const robotCode = this.settings.appId?.trim() ?? '';
const route = pickDingTalkSendRoute(chatId, robotCode, text);
if (!route) return null;
const token = await this.refreshTokenIfNeeded();
if (!token) return null;
const first = await this.performSend(route.path, route.body, token);
let classification = first;
if (first.kind === 'retry') {
await sleep(first.delayMs);
classification = await this.performSend(route.path, route.body, token);
}
if (classification.kind !== 'ok') {
this.readiness = this.readiness === 'operational' ? 'degraded' : 'credentials_valid';
this.reason = classification.kind === 'retry' ? 'rate-limited' : classification.description;
this.emitStatusChange();
return null;
}
this.readiness = 'operational';
this.reason = undefined;
this.lastEventAt = Date.now();
this.emitStatusChange();
return classification.messageId;
}
protected override connectionKind(): BotStatus['connection'] {
return 'gateway';
}
private async performSend(
path: string,
body: Record<string, unknown>,
token: string,
): Promise<DingTalkSendClassification> {
try {
const response = await proxiedFetch(`${DINGTALK_API}${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-acs-dingtalk-access-token': token,
},
body: JSON.stringify(body),
timeoutMs: 10_000,
});
const json = await response.json().catch(() => null);
return classifyDingTalkSendResponse(response.status, json);
} catch (error) {
return { kind: 'fatal', description: error instanceof Error ? error.message : String(error) };
}
}
private async refreshTokenIfNeeded(): Promise<string | null> {
const now = Date.now();
if (this.token && this.token.expiresAt - TOKEN_REFRESH_SKEW_MS > now) {
return this.token.value;
}
const appkey = this.settings.appId?.trim() ?? '';
const appsecret = this.settings.appSecret?.trim() ?? '';
if (!appkey || !appsecret) return null;
try {
const url =
`${DINGTALK_OAPI}/gettoken?appkey=` +
encodeURIComponent(appkey) +
'&appsecret=' +
encodeURIComponent(appsecret);
const response = await proxiedFetch(url, { method: 'GET', timeoutMs: 10_000 });
const json = (await response.json().catch(() => null)) as {
access_token?: unknown;
expires_in?: unknown;
errcode?: number;
errmsg?: string;
} | null;
if (!json || (json.errcode !== undefined && json.errcode !== 0)) {
this.reason = json?.errmsg ?? 'gettoken failed';
return null;
}
if (typeof json.access_token !== 'string') return null;
const expiresInSec = typeof json.expires_in === 'number' ? json.expires_in : 7200;
this.token = {
value: json.access_token,
expiresAt: now + expiresInSec * 1_000,
};
return this.token.value;
} catch (error) {
this.reason = error instanceof Error ? error.message : String(error);
return null;
}
}
private async startStream(): Promise<void> {
const token = await this.refreshTokenIfNeeded();
if (!token) {
this.readiness = 'configured';
this.emitStatusChange();
this.scheduleReconnect();
return;
}
try {
const response = await proxiedFetch(`${DINGTALK_API}/v1.0/gateway/connections/open`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-acs-dingtalk-access-token': token,
},
body: JSON.stringify({
clientId: this.settings.appId?.trim(),
clientSecret: this.settings.appSecret?.trim(),
subscriptions: [
{ type: 'EVENT', topic: '*' },
{ type: 'CALLBACK', topic: DINGTALK_TOPIC_BOT_MESSAGES },
],
ua: 'Maka/0.1',
localIp: '127.0.0.1',
}),
timeoutMs: 10_000,
});
const json = (await response
.json()
.catch(() => null)) as DingTalkConnectionOpenResponse | null;
if (
!response.ok ||
!json ||
typeof json.endpoint !== 'string' ||
typeof json.ticket !== 'string'
) {
this.reason = `connections-open-${response.status}`;
this.readiness = 'configured';
this.emitStatusChange();
this.scheduleReconnect();
return;
}
this.connect(`${json.endpoint}?ticket=${encodeURIComponent(json.ticket)}`);
} catch (error) {
this.reason = error instanceof Error ? error.message : String(error);
this.readiness = 'configured';
this.emitStatusChange();
this.scheduleReconnect();
}
}
private connect(url: string): void {
let ws: WebSocket;
try {
ws = new WebSocket(url);
} catch (error) {
this.reason = error instanceof Error ? error.message : String(error);
this.readiness = 'configured';
this.emitStatusChange();
this.scheduleReconnect();
return;
}
this.ws = ws;
ws.addEventListener('open', () => {
this.running = true;
this.startedAt = Date.now();
this.readiness = 'operational';
this.reason = undefined;
this.reconnectAttempts = 0;
this.emitStatusChange();
});
ws.addEventListener('message', (event: { data: unknown }) => {
const data = event.data;
this.handlePayload(typeof data === 'string' ? data : String(data));
});
ws.addEventListener('close', (event: { code: number; reason: string }) => {
this.handleClose(event.code, event.reason);
});
ws.addEventListener('error', () => {
// The close event fires immediately after; no separate handling.
});
}
private handlePayload(raw: string): void {
let frame: DingTalkStreamFrame;
try {
frame = JSON.parse(raw) as DingTalkStreamFrame;
} catch {
return;
}
const messageId = frame.headers?.messageId;
if (!messageId) return;
if (frame.type === 'CALLBACK' && frame.headers?.topic === DINGTALK_TOPIC_BOT_MESSAGES) {
let payload: DingTalkBotMessagePayload | null = null;
try {
payload =
typeof frame.data === 'string'
? (JSON.parse(frame.data) as DingTalkBotMessagePayload)
: null;
} catch {
payload = null;
}
if (payload) {
const event = dingTalkPayloadToEvent(payload, Date.now());
if (event) {
this.lastEventAt = event.receivedAt;
this.emitIncomingMessage(event);
this.emitStatusChange();
}
}
this.sendAck(messageId);
return;
}
// System / unrelated event types — still ack so the gateway does
// not redeliver, but do not emit a message event.
this.sendAck(messageId);
}
private sendAck(messageId: string): void {
if (!this.ws || this.ws.readyState !== 1) return;
try {
this.ws.send(JSON.stringify(buildDingTalkAckFrame(messageId)));
} catch {
// Swallow — close handler will fire if the socket died.
}
}
private clearReconnect(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
private handleClose(code: number, reason: string): void {
this.ws = null;
this.running = false;
const decision = decideDingTalkClose(code, this.explicitlyStopped);
if (decision.kind === 'stopped') return;
this.readiness = 'degraded';
this.reason = reason || `stream-closed-${code}`;
this.emitStatusChange();
this.scheduleReconnect();
}
private scheduleReconnect(): void {
if (this.explicitlyStopped) return;
this.clearReconnect();
const delay = dingTalkReconnectBackoffMs(this.reconnectAttempts);
this.reconnectAttempts += 1;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
void this.startStream();
}, delay);
this.reconnectTimer.unref?.();
}
}
export const __TEST__ = {
decideDingTalkClose,
dingTalkReconnectBackoffMs,
buildDingTalkGroupSendBody,
buildDingTalkSingleSendBody,
pickDingTalkSendRoute,
classifyDingTalkSendResponse,
dingTalkPayloadToEvent,
buildDingTalkAckFrame,
};