mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 04:13:17 +08:00
The Feishu bot now calls the app_slash_commands OpenAPI at startup to register its common commands (menu, new, help, status, compact, sessionlist, workspacelist, watch, unwatch, watchlist, archived) as native Slash Commands, so typing / in a Feishu direct-message input box pops the command panel and tapping a command triggers it. The command list is owned and pushed by dsh-im; it does not depend on the dsh/Harness backend. Registration is best-effort and runs asynchronously, so it never blocks the long-connection startup or message delivery when the app lacks the application:app_slash_command:read/write scopes. Adds src/channels/feishu/slash-command-registry.mjs (manifest, list/create/delete, idempotent sync), wires it into feishu-runtime.mjs (slashCommands config flag + status fields), and covers it with tests.
703 lines
25 KiB
JavaScript
703 lines
25 KiB
JavaScript
import { randomUUID } from 'node:crypto';
|
||
import { FeishuHarnessBridge } from './bridge.mjs';
|
||
import { cardActionProbeCard } from './feishu-cards.mjs';
|
||
import { VerifiedFeishuChannel } from './feishu-channel.mjs';
|
||
import { normalizeFeishuGroupResponseMode } from './group-response-mode.mjs';
|
||
import {
|
||
registerSlashCommands,
|
||
SLASH_COMMAND_MANIFEST,
|
||
} from './slash-command-registry.mjs';
|
||
import {
|
||
connectionTestTargetUnavailable,
|
||
sendRememberedConnectionTest,
|
||
} from '../shared/connection-test.mjs';
|
||
import { t } from '../shared/i18n.mjs';
|
||
|
||
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
||
const CALLBACK_PROBE_SUCCESS_NOTICE = '✅ 修复完成:已实测收到 card.action.trigger,菜单按钮现在可用。';
|
||
const CALLBACK_PROBE_TIMEOUT_NOTICE = '⚠️ 修复验证超时:未收到测试卡按钮的 card.action.trigger,不能确认按钮已修复。请不要重复授权;先检查飞书开放平台的卡片回调配置,确认后再发送 /repair。';
|
||
const CALLBACK_PROBE_SEND_FAILURE_NOTICE = '⚠️ 修复验证失败:无法发送专用测试卡,不能确认 card.action.trigger 已恢复。请不要重复授权;先检查机器人消息权限和连接状态。';
|
||
const CALLBACK_PROBE_ABORT_NOTICE = '⚠️ 修复验证中断:Runtime 已停止,未完成 card.action.trigger 实测,不能确认修复成功。请不要重复授权;先等待机器人恢复连接。';
|
||
const REUSABLE_WS_STATES = new Set(['connected', 'connecting', 'reconnecting']);
|
||
|
||
function nonEmptyString(value) {
|
||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||
}
|
||
|
||
function strictCardOperatorOpenId(event) {
|
||
return nonEmptyString(event?.operator?.open_id)
|
||
?? nonEmptyString(event?.operator?.operator_id?.open_id);
|
||
}
|
||
|
||
function websocketState(wsClient, fallback) {
|
||
try {
|
||
const state = wsClient?.getConnectionStatus?.()?.state;
|
||
return typeof state === 'string' && state ? state : fallback;
|
||
} catch {
|
||
return fallback;
|
||
}
|
||
}
|
||
|
||
function probeError(code, message) {
|
||
const error = new Error(message);
|
||
error.code = code;
|
||
return error;
|
||
}
|
||
|
||
function httpInstanceWithTimeout(httpInstance, timeoutMs) {
|
||
if (!httpInstance || typeof httpInstance.request !== 'function') return undefined;
|
||
const optionsWithTimeout = (options) => ({
|
||
...(options ?? {}),
|
||
timeout: options?.timeout ?? timeoutMs,
|
||
});
|
||
return {
|
||
request: (options) => httpInstance.request(optionsWithTimeout(options)),
|
||
get: (url, options) => httpInstance.get(url, optionsWithTimeout(options)),
|
||
delete: (url, options) => httpInstance.delete(url, optionsWithTimeout(options)),
|
||
head: (url, options) => httpInstance.head(url, optionsWithTimeout(options)),
|
||
options: (url, options) => httpInstance.options(url, optionsWithTimeout(options)),
|
||
post: (url, data, options) => httpInstance.post(url, data, optionsWithTimeout(options)),
|
||
put: (url, data, options) => httpInstance.put(url, data, optionsWithTimeout(options)),
|
||
patch: (url, data, options) => httpInstance.patch(url, data, optionsWithTimeout(options)),
|
||
};
|
||
}
|
||
|
||
export function createBridgeStatus({ allowedSenderCount = 1 } = {}) {
|
||
return {
|
||
startedAt: null,
|
||
ready: false,
|
||
feishuLongConnectionState: 'idle',
|
||
harnessReachable: false,
|
||
messagesReceived: 0,
|
||
messagesReplied: 0,
|
||
messagesRejected: 0,
|
||
reactionsAdded: 0,
|
||
reactionsRemoved: 0,
|
||
reactionErrors: 0,
|
||
streamResponses: 0,
|
||
streamUpdates: 0,
|
||
streamFallbacks: 0,
|
||
streamErrors: 0,
|
||
cardActionsReceived: 0,
|
||
cardActionProbesVerified: 0,
|
||
lastMessageAt: null,
|
||
lastReplyAt: null,
|
||
lastRejectedAt: null,
|
||
lastCardActionAt: null,
|
||
lastError: null,
|
||
agentPreset: 'standard',
|
||
authorizationMode: 'sender-open-id-allowlist',
|
||
allowedSenderCount,
|
||
slashCommandRegistration: 'idle',
|
||
slashCommandsRegistered: 0,
|
||
slashCommandsExisting: 0,
|
||
slashCommandsFailed: 0,
|
||
slashCommandsError: null,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Owns one live Feishu long connection and the already-tested bridge stack.
|
||
* The class intentionally receives the SDK and Harness dependencies so the
|
||
* plugin can run it in-process while tests exercise the lifecycle without a
|
||
* real Feishu tenant.
|
||
*/
|
||
export class FeishuRuntime {
|
||
#lark;
|
||
#botId;
|
||
#appId;
|
||
#appSecret;
|
||
#domain;
|
||
#botOpenId;
|
||
#groupResponseMode;
|
||
#ownerOpenIds;
|
||
#harness;
|
||
#state;
|
||
#contextEnhancement;
|
||
#replyTimeoutMs;
|
||
#connectTimeoutMs;
|
||
#requestTimeoutMs;
|
||
#wsAgent;
|
||
#logger;
|
||
#repair;
|
||
#client = null;
|
||
#bridge = null;
|
||
#wsClient = null;
|
||
#starting = null;
|
||
#stopping = null;
|
||
#abortController = null;
|
||
#pendingCardActionProbes = new Map();
|
||
#status;
|
||
#slashCommands = true;
|
||
|
||
constructor({
|
||
lark,
|
||
botId,
|
||
appId,
|
||
appSecret,
|
||
domain = 'feishu',
|
||
botOpenId,
|
||
groupResponseMode,
|
||
ownerOpenId,
|
||
ownerOpenIds,
|
||
harness,
|
||
state,
|
||
contextEnhancement,
|
||
repair,
|
||
replyTimeoutMs = 600000,
|
||
connectTimeoutMs = 15000,
|
||
requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
|
||
slashCommands = true,
|
||
wsAgent,
|
||
logger = console,
|
||
}) {
|
||
if (!lark) throw new Error('FeishuRuntime requires the Feishu SDK');
|
||
if (!appId || !appSecret) throw new Error('FeishuRuntime requires app credentials');
|
||
const allowedOwners = Array.isArray(ownerOpenIds) ? ownerOpenIds : [ownerOpenId];
|
||
const normalizedOwners = [...new Set(allowedOwners.filter((value) => typeof value === 'string' && value))];
|
||
if (normalizedOwners.length === 0) throw new Error('FeishuRuntime requires at least one owner open_id');
|
||
if (!harness) throw new Error('FeishuRuntime requires a Harness client');
|
||
if (!state) throw new Error('FeishuRuntime requires a state store');
|
||
if (repair !== undefined && repair !== null && !nonEmptyString(botId)) {
|
||
throw new TypeError('FeishuRuntime repair capability requires a botId');
|
||
}
|
||
if (!Number.isFinite(requestTimeoutMs) || requestTimeoutMs <= 0) {
|
||
throw new TypeError('FeishuRuntime requestTimeoutMs must be a positive number');
|
||
}
|
||
|
||
this.#lark = lark;
|
||
this.#botId = nonEmptyString(botId);
|
||
this.#appId = appId;
|
||
this.#appSecret = appSecret;
|
||
this.#domain = domain;
|
||
this.#botOpenId = nonEmptyString(botOpenId);
|
||
this.#groupResponseMode = normalizeFeishuGroupResponseMode(groupResponseMode);
|
||
this.#ownerOpenIds = normalizedOwners;
|
||
this.#harness = harness;
|
||
this.#state = state;
|
||
this.#contextEnhancement = contextEnhancement;
|
||
this.#repair = repair ?? null;
|
||
this.#replyTimeoutMs = replyTimeoutMs;
|
||
this.#connectTimeoutMs = connectTimeoutMs;
|
||
this.#requestTimeoutMs = requestTimeoutMs;
|
||
this.#slashCommands = Boolean(slashCommands);
|
||
this.#wsAgent = wsAgent;
|
||
this.#logger = logger;
|
||
this.#status = createBridgeStatus({ allowedSenderCount: normalizedOwners.length });
|
||
}
|
||
|
||
get status() {
|
||
return structuredClone(this.#status);
|
||
}
|
||
|
||
setGroupResponseMode(value) {
|
||
this.#groupResponseMode = normalizeFeishuGroupResponseMode(value);
|
||
this.#bridge?.setGroupResponseMode(this.#groupResponseMode);
|
||
}
|
||
|
||
async start() {
|
||
while (true) {
|
||
while (this.#stopping) await this.#stopping;
|
||
if (this.#starting) return this.#starting;
|
||
|
||
const wsClient = this.#wsClient;
|
||
if (wsClient) {
|
||
const state = websocketState(wsClient, this.#status.feishuLongConnectionState);
|
||
if (REUSABLE_WS_STATES.has(state)) return this.status;
|
||
|
||
await this.stop({ preserveError: state === 'failed' });
|
||
continue;
|
||
}
|
||
|
||
// A partial/failed attempt may have created resources before its
|
||
// WSClient became observable. Drain them before assigning a new attempt.
|
||
if (this.#client || this.#bridge || this.#abortController) {
|
||
await this.stop({
|
||
preserveError: this.#status.feishuLongConnectionState === 'failed',
|
||
});
|
||
continue;
|
||
}
|
||
|
||
break;
|
||
}
|
||
|
||
let starting;
|
||
starting = this.#start().finally(() => {
|
||
if (this.#starting === starting) this.#starting = null;
|
||
});
|
||
this.#starting = starting;
|
||
return starting;
|
||
}
|
||
|
||
async #start() {
|
||
const abortController = new AbortController();
|
||
this.#abortController = abortController;
|
||
const { signal } = abortController;
|
||
const abortError = () => (
|
||
signal.reason ?? new DOMException('Feishu runtime stopped', 'AbortError')
|
||
);
|
||
const isCurrentStart = () => (
|
||
!signal.aborted && this.#abortController === abortController
|
||
);
|
||
const assertCurrentStart = () => {
|
||
if (!isCurrentStart()) throw abortError();
|
||
};
|
||
this.#status.startedAt = new Date().toISOString();
|
||
this.#status.feishuLongConnectionState = 'connecting';
|
||
this.#status.lastError = null;
|
||
|
||
try {
|
||
await this.#harness.ensureRunning({ signal });
|
||
assertCurrentStart();
|
||
this.#status.harnessReachable = true;
|
||
|
||
const sdkDomain = this.#domain === 'lark'
|
||
? this.#lark.Domain.Lark
|
||
: this.#lark.Domain.Feishu;
|
||
const larkConfig = {
|
||
appId: this.#appId,
|
||
appSecret: this.#appSecret,
|
||
domain: sdkDomain,
|
||
};
|
||
const httpInstance = httpInstanceWithTimeout(
|
||
this.#lark.defaultHttpInstance,
|
||
this.#requestTimeoutMs,
|
||
);
|
||
if (httpInstance) larkConfig.httpInstance = httpInstance;
|
||
const client = new this.#lark.Client(larkConfig);
|
||
this.#client = client;
|
||
const channel = new VerifiedFeishuChannel({
|
||
client,
|
||
initialText: t('已连接 DeepSeek Harness,正在思考…'),
|
||
});
|
||
const bridge = new FeishuHarnessBridge({
|
||
client,
|
||
channel,
|
||
harness: this.#harness,
|
||
state: this.#state,
|
||
contextEnhancement: this.#contextEnhancement,
|
||
status: this.#status,
|
||
allowedSenderOpenIds: new Set(this.#ownerOpenIds),
|
||
botId: this.#botId,
|
||
appId: this.#appId,
|
||
botOpenId: this.#botOpenId,
|
||
groupResponseMode: this.#groupResponseMode,
|
||
repair: this.#repair,
|
||
replyTimeoutMs: this.#replyTimeoutMs,
|
||
signal,
|
||
logger: this.#logger,
|
||
});
|
||
this.#bridge = bridge;
|
||
|
||
const dispatcher = new this.#lark.EventDispatcher({}).register({
|
||
'im.message.receive_v1': (event) => {
|
||
if (isCurrentStart()) void bridge.accept(event);
|
||
},
|
||
'im.message.reaction.created_v1': () => undefined,
|
||
'im.message.reaction.deleted_v1': () => undefined,
|
||
// Interactive-card button callbacks (only delivered when the app
|
||
// subscribes card.action.trigger; the number-reply fallback covers
|
||
// apps that do not).
|
||
'card.action.trigger': (event) => {
|
||
if (!isCurrentStart()) return;
|
||
this.#status.cardActionsReceived += 1;
|
||
this.#status.lastCardActionAt = new Date().toISOString();
|
||
if (!this.#consumeCardActionProbe(event)) void bridge.onCardAction(event);
|
||
},
|
||
});
|
||
|
||
let settleReady;
|
||
let settleError;
|
||
const ready = new Promise((resolve, reject) => {
|
||
let settled = false;
|
||
const onAbort = () => {
|
||
settleError(abortError());
|
||
};
|
||
const settle = (callback, value) => {
|
||
if (settled) return;
|
||
settled = true;
|
||
clearTimeout(timer);
|
||
signal.removeEventListener('abort', onAbort);
|
||
callback(value);
|
||
};
|
||
const timer = setTimeout(() => {
|
||
settle(
|
||
reject,
|
||
new Error(`Feishu WebSocket handshake timed out after ${this.#connectTimeoutMs}ms`),
|
||
);
|
||
}, this.#connectTimeoutMs);
|
||
settleReady = () => {
|
||
settle(resolve);
|
||
};
|
||
settleError = (error) => {
|
||
settle(reject, error);
|
||
};
|
||
signal.addEventListener('abort', onAbort, { once: true });
|
||
if (signal.aborted) onAbort();
|
||
});
|
||
// The SDK constructor can throw before Promise.all attaches below.
|
||
// Keep the abort-driven rejection observed in that path as well.
|
||
void ready.catch(() => undefined);
|
||
|
||
const wsClient = new this.#lark.WSClient({
|
||
...larkConfig,
|
||
...(this.#wsAgent ? { agent: this.#wsAgent } : {}),
|
||
loggerLevel: this.#lark.LoggerLevel.info,
|
||
handshakeTimeoutMs: this.#connectTimeoutMs,
|
||
onReady: () => {
|
||
if (!isCurrentStart()) return;
|
||
this.#status.feishuLongConnectionState = 'connected';
|
||
this.#status.ready = true;
|
||
this.#status.lastError = null;
|
||
settleReady();
|
||
},
|
||
onError: (error) => {
|
||
if (!isCurrentStart()) return;
|
||
this.#status.feishuLongConnectionState = 'failed';
|
||
this.#status.ready = false;
|
||
this.#status.lastError = error?.message ?? String(error);
|
||
this.#logger.error('[dsh-feishu] Feishu long connection failed:', this.#status.lastError);
|
||
settleError(error);
|
||
},
|
||
onReconnecting: () => {
|
||
if (!isCurrentStart()) return;
|
||
this.#status.feishuLongConnectionState = 'reconnecting';
|
||
this.#status.ready = false;
|
||
},
|
||
onReconnected: () => {
|
||
if (!isCurrentStart()) return;
|
||
this.#status.feishuLongConnectionState = 'connected';
|
||
this.#status.ready = true;
|
||
this.#status.lastError = null;
|
||
},
|
||
});
|
||
this.#wsClient = wsClient;
|
||
const wsStarted = Promise.resolve()
|
||
.then(() => wsClient.start({ eventDispatcher: dispatcher }))
|
||
.catch((error) => {
|
||
settleError(error);
|
||
throw error;
|
||
});
|
||
await Promise.all([wsStarted, ready]);
|
||
assertCurrentStart();
|
||
// Register the native Slash Command panel best-effort and asynchronously
|
||
// so it never blocks the long-connection startup. The panel is only a
|
||
// client-side convenience; failure here must not take the bot down.
|
||
if (this.#slashCommands && httpInstance) {
|
||
void this.#registerSlashCommands(httpInstance, isCurrentStart);
|
||
}
|
||
return this.status;
|
||
} catch (error) {
|
||
// stop() owns the terminal idle state for an explicitly aborted start.
|
||
// In particular, do not let the rejected handshake waiter overwrite it.
|
||
if (signal.aborted) throw error;
|
||
this.#status.ready = false;
|
||
this.#status.feishuLongConnectionState = 'failed';
|
||
this.#status.lastError = error?.message ?? String(error);
|
||
await this.#cleanup({ preserveError: true, abortController });
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Send a one-shot callback card and resolve only after Feishu delivers the
|
||
* exact message/nonce/operator tuple over card.action.trigger. The controller
|
||
* uses this as the final proof for both browser- and chat-initiated repairs.
|
||
*/
|
||
async beginCardActionProbe({ expectedOperatorOpenId, timeoutMs = 90_000 } = {}) {
|
||
if (!this.#status.ready || !this.#client) {
|
||
throw probeError('card_action_probe_unavailable', '飞书机器人尚未连接');
|
||
}
|
||
const operatorOpenId = nonEmptyString(expectedOperatorOpenId);
|
||
if (!operatorOpenId || operatorOpenId === '*') {
|
||
throw new TypeError('A precise Feishu operator open_id is required');
|
||
}
|
||
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 10 * 60_000) {
|
||
throw new TypeError('Card-action probe timeout must be between 1 and 600000ms');
|
||
}
|
||
|
||
const nonce = randomUUID().replaceAll('-', '');
|
||
let response;
|
||
try {
|
||
response = await this.#client.im.v1.message.create({
|
||
params: { receive_id_type: 'open_id' },
|
||
data: {
|
||
receive_id: operatorOpenId,
|
||
msg_type: 'interactive',
|
||
content: cardActionProbeCard(nonce),
|
||
},
|
||
});
|
||
} catch {
|
||
void this.#sendCardActionProbeNotice(
|
||
operatorOpenId,
|
||
t(CALLBACK_PROBE_SEND_FAILURE_NOTICE),
|
||
'failure',
|
||
);
|
||
throw probeError('card_action_probe_send_failed', '无法发送飞书卡片回调测试');
|
||
}
|
||
if (response?.code && response.code !== 0) {
|
||
void this.#sendCardActionProbeNotice(
|
||
operatorOpenId,
|
||
t(CALLBACK_PROBE_SEND_FAILURE_NOTICE),
|
||
'failure',
|
||
);
|
||
throw probeError('card_action_probe_send_failed', '无法发送飞书卡片回调测试');
|
||
}
|
||
const messageId = nonEmptyString(response?.data?.message_id)
|
||
?? nonEmptyString(response?.message_id);
|
||
if (!messageId) {
|
||
void this.#sendCardActionProbeNotice(
|
||
operatorOpenId,
|
||
t(CALLBACK_PROBE_SEND_FAILURE_NOTICE),
|
||
'failure',
|
||
);
|
||
throw probeError('card_action_probe_send_failed', '飞书未返回测试卡片的消息 ID');
|
||
}
|
||
|
||
return new Promise((resolve, reject) => {
|
||
const timeout = setTimeout(() => {
|
||
const current = this.#pendingCardActionProbes.get(messageId);
|
||
if (!current || current.nonce !== nonce) return;
|
||
this.#pendingCardActionProbes.delete(messageId);
|
||
void this.#sendCardActionProbeNotice(
|
||
operatorOpenId,
|
||
t(CALLBACK_PROBE_TIMEOUT_NOTICE),
|
||
'timeout',
|
||
);
|
||
reject(probeError(
|
||
'card_action_probe_timeout',
|
||
'在规定时间内未收到飞书卡片按钮回调',
|
||
));
|
||
}, timeoutMs);
|
||
timeout.unref?.();
|
||
this.#pendingCardActionProbes.set(messageId, {
|
||
messageId,
|
||
nonce,
|
||
expectedOperatorOpenId: operatorOpenId,
|
||
timeout,
|
||
resolve,
|
||
reject,
|
||
});
|
||
});
|
||
}
|
||
|
||
#consumeCardActionProbe(event) {
|
||
const messageId = nonEmptyString(event?.context?.open_message_id);
|
||
if (!messageId) return false;
|
||
const probe = this.#pendingCardActionProbes.get(messageId);
|
||
if (!probe) return false;
|
||
const value = event?.action?.value;
|
||
const operatorOpenId = strictCardOperatorOpenId(event);
|
||
if (value?.action !== 'repair_verify'
|
||
|| value?.nonce !== probe.nonce
|
||
|| operatorOpenId !== probe.expectedOperatorOpenId) {
|
||
return false;
|
||
}
|
||
clearTimeout(probe.timeout);
|
||
this.#pendingCardActionProbes.delete(messageId);
|
||
this.#status.cardActionProbesVerified += 1;
|
||
// Start the terminal notification before resolving the controller-facing
|
||
// probe. A repair may rotate the App Secret and immediately replace this
|
||
// runtime after resolution; initiating the send here keeps chat and web
|
||
// repair flows equally observable. Notification failure never invalidates
|
||
// the callback proof itself.
|
||
void this.#sendCardActionProbeNotice(
|
||
operatorOpenId,
|
||
t(CALLBACK_PROBE_SUCCESS_NOTICE),
|
||
'success',
|
||
).finally(() => {
|
||
probe.resolve({
|
||
verified: true,
|
||
messageId,
|
||
operatorOpenId,
|
||
});
|
||
});
|
||
return true;
|
||
}
|
||
|
||
#sendCardActionProbeNotice(operatorOpenId, text, outcome) {
|
||
const client = this.#client;
|
||
if (!client) {
|
||
this.#logger.warn?.(`[dsh-feishu] unable to send the callback repair ${outcome} notice`);
|
||
return Promise.resolve(false);
|
||
}
|
||
return Promise.resolve().then(async () => {
|
||
const response = await client.im.v1.message.create({
|
||
params: { receive_id_type: 'open_id' },
|
||
data: {
|
||
receive_id: operatorOpenId,
|
||
msg_type: 'text',
|
||
content: JSON.stringify({ text }),
|
||
},
|
||
});
|
||
if (response?.code && response.code !== 0) {
|
||
throw new Error('Feishu callback repair notice failed');
|
||
}
|
||
return true;
|
||
}).catch(() => {
|
||
this.#logger.warn?.(`[dsh-feishu] unable to send the callback repair ${outcome} notice`);
|
||
return false;
|
||
});
|
||
}
|
||
|
||
async sendConnectionTest(text) {
|
||
if (!this.#status.ready || !this.#client) {
|
||
const error = new Error('飞书机器人尚未连接');
|
||
error.code = 'test-target-unavailable';
|
||
throw error;
|
||
}
|
||
if (typeof text !== 'string' || !text.trim()) {
|
||
throw new TypeError('Feishu connection test text is required');
|
||
}
|
||
const send = async (receiveIdType, receiveId, content) => {
|
||
const response = await this.#client.im.v1.message.create({
|
||
params: { receive_id_type: receiveIdType },
|
||
data: {
|
||
receive_id: receiveId,
|
||
msg_type: 'text',
|
||
content: JSON.stringify({ text: content }),
|
||
},
|
||
});
|
||
if (response?.code && response.code !== 0) {
|
||
throw new Error(`Feishu connection test failed: ${response.msg || response.code}`);
|
||
}
|
||
};
|
||
|
||
const ownerOpenId = this.#ownerOpenIds.find((value) => value !== '*');
|
||
if (ownerOpenId) {
|
||
await send('open_id', ownerOpenId, text);
|
||
return { sent: true };
|
||
}
|
||
|
||
return sendRememberedConnectionTest({
|
||
state: this.#state,
|
||
text,
|
||
channelLabel: t('飞书机器人'),
|
||
send: async (target, content) => {
|
||
const chatId = typeof target?.chatId === 'string' ? target.chatId.trim() : '';
|
||
if (!chatId) throw connectionTestTargetUnavailable(t('飞书机器人'));
|
||
await send('chat_id', chatId, content);
|
||
},
|
||
});
|
||
}
|
||
|
||
async sendProactiveText(target, text, { signal } = {}) {
|
||
if (!this.#status.ready || !this.#client) {
|
||
const error = new Error('飞书机器人尚未连接');
|
||
error.code = 'bot-not-connected';
|
||
throw error;
|
||
}
|
||
const receiveIdType = target?.kind === 'user' ? 'open_id'
|
||
: target?.kind === 'group' ? 'chat_id' : null;
|
||
const receiveId = target?.kind === 'user'
|
||
? nonEmptyString(target?.route?.openId)
|
||
: target?.kind === 'group'
|
||
? nonEmptyString(target?.route?.chatId)
|
||
: null;
|
||
if (!receiveIdType || !receiveId) {
|
||
const error = new TypeError('Invalid Feishu proactive delivery target');
|
||
error.code = 'invalid-target';
|
||
throw error;
|
||
}
|
||
signal?.throwIfAborted();
|
||
const response = await this.#client.im.v1.message.create({
|
||
params: { receive_id_type: receiveIdType },
|
||
data: {
|
||
receive_id: receiveId,
|
||
msg_type: 'text',
|
||
content: JSON.stringify({ text }),
|
||
},
|
||
});
|
||
if (response?.code && response.code !== 0) {
|
||
const error = new Error(`Feishu proactive delivery failed: ${response.msg || response.code}`);
|
||
error.code = 'target-rejected';
|
||
throw error;
|
||
}
|
||
return { sent: true };
|
||
}
|
||
|
||
async #registerSlashCommands(httpInstance, isCurrentStart) {
|
||
this.#status.slashCommandRegistration = 'registering';
|
||
this.#status.slashCommandsError = null;
|
||
try {
|
||
const result = await registerSlashCommands({
|
||
appId: this.#appId,
|
||
appSecret: this.#appSecret,
|
||
domain: this.#domain,
|
||
httpInstance,
|
||
manifest: SLASH_COMMAND_MANIFEST,
|
||
});
|
||
if (!isCurrentStart()) return;
|
||
this.#status.slashCommandRegistration = 'done';
|
||
this.#status.slashCommandsRegistered = result.created.length;
|
||
this.#status.slashCommandsExisting = result.existing.length;
|
||
this.#status.slashCommandsFailed = result.failed.length;
|
||
this.#status.slashCommandsError = result.failed.length > 0
|
||
? result.failed.map((f) => `/${f.command}: ${f.error?.message ?? String(f.error)}`).join('; ')
|
||
: null;
|
||
if (result.created.length > 0) {
|
||
this.#logger.info?.(`[dsh-feishu] registered ${result.created.length} slash command(s)`);
|
||
}
|
||
if (result.failed.length > 0) {
|
||
this.#logger.warn?.(
|
||
`[dsh-feishu] ${result.failed.length} slash command(s) failed to register: ${this.#status.slashCommandsError}`,
|
||
);
|
||
}
|
||
} catch (error) {
|
||
if (!isCurrentStart()) return;
|
||
this.#status.slashCommandRegistration = 'failed';
|
||
this.#status.slashCommandsError = error?.message ?? String(error);
|
||
this.#logger.warn?.(
|
||
`[dsh-feishu] slash command registration skipped: ${this.#status.slashCommandsError}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
stop(options = {}) {
|
||
if (this.#stopping) return this.#stopping;
|
||
|
||
let stopping;
|
||
stopping = this.#stop(options).finally(() => {
|
||
if (this.#stopping === stopping) this.#stopping = null;
|
||
});
|
||
this.#stopping = stopping;
|
||
return stopping;
|
||
}
|
||
|
||
async #stop({ preserveError = false } = {}) {
|
||
const abortController = this.#abortController;
|
||
if (this.#abortController === abortController) this.#abortController = null;
|
||
abortController?.abort(new DOMException('Feishu runtime stopped', 'AbortError'));
|
||
|
||
const starting = this.#starting;
|
||
if (starting) await starting.catch(() => undefined);
|
||
return this.#cleanup({ preserveError, abortController });
|
||
}
|
||
|
||
async #cleanup({ preserveError = false, abortController } = {}) {
|
||
const error = preserveError ? this.#status.lastError : null;
|
||
if (this.#abortController === abortController) this.#abortController = null;
|
||
abortController?.abort(new DOMException('Feishu runtime stopped', 'AbortError'));
|
||
for (const probe of this.#pendingCardActionProbes.values()) {
|
||
clearTimeout(probe.timeout);
|
||
void this.#sendCardActionProbeNotice(
|
||
probe.expectedOperatorOpenId,
|
||
t(CALLBACK_PROBE_ABORT_NOTICE),
|
||
'abort',
|
||
);
|
||
probe.reject(probeError('abort', '飞书运行时已停止'));
|
||
}
|
||
this.#pendingCardActionProbes.clear();
|
||
this.#status.ready = false;
|
||
const wsClient = this.#wsClient;
|
||
this.#wsClient = null;
|
||
wsClient?.close({ force: true });
|
||
const bridge = this.#bridge;
|
||
this.#bridge = null;
|
||
if (bridge) await bridge.waitForIdle();
|
||
this.#client = null;
|
||
this.#status.feishuLongConnectionState = preserveError ? 'failed' : 'idle';
|
||
this.#status.lastError = error;
|
||
return this.status;
|
||
}
|
||
}
|