feat(feishu): register native Slash Command panel on startup

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.
This commit is contained in:
C3H3-AI 2026-08-30 23:40:59 +08:00
parent 7f968ac1d8
commit 64ba1463a8
6 changed files with 383 additions and 0 deletions

View file

@ -3,6 +3,10 @@ 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,
@ -84,6 +88,11 @@ export function createBridgeStatus({ allowedSenderCount = 1 } = {}) {
agentPreset: 'standard',
authorizationMode: 'sender-open-id-allowlist',
allowedSenderCount,
slashCommandRegistration: 'idle',
slashCommandsRegistered: 0,
slashCommandsExisting: 0,
slashCommandsFailed: 0,
slashCommandsError: null,
};
}
@ -119,6 +128,7 @@ export class FeishuRuntime {
#abortController = null;
#pendingCardActionProbes = new Map();
#status;
#slashCommands = true;
constructor({
lark,
@ -137,6 +147,7 @@ export class FeishuRuntime {
replyTimeoutMs = 600000,
connectTimeoutMs = 15000,
requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
slashCommands = true,
wsAgent,
logger = console,
}) {
@ -169,6 +180,7 @@ export class FeishuRuntime {
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 });
@ -368,6 +380,12 @@ export class FeishuRuntime {
});
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.
@ -598,6 +616,43 @@ export class FeishuRuntime {
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;