fix(feishu): preserve rejected reply delivery outcomes

This commit is contained in:
joshavant
2026-08-11 17:25:15 -05:00
parent 42ad83142e
commit 9623c33fb2
13 changed files with 364 additions and 46 deletions
+2 -2
View File
@@ -445,8 +445,8 @@ describe("broadcast dispatch", () => {
mockDispatchReply
.mockResolvedValueOnce({ queuedFinal: false, counts: { final: 1 } })
.mockResolvedValueOnce({
queuedFinal: true,
counts: { final: 1 },
queuedFinal: false,
counts: { final: 0 },
failedCounts: { tool: 0, block: 0, final: 1 },
});
const ensureNoVisibleReplyFallback = vi.fn();
+2 -2
View File
@@ -1175,8 +1175,8 @@ describe("handleFeishuMessage command authorization", () => {
it("sends no-visible fallback when queued final delivery fails", async () => {
mockDispatchReplyFromConfig.mockResolvedValueOnce({
queuedFinal: true,
counts: { tool: 0, block: 0, final: 1 },
queuedFinal: false,
counts: { tool: 0, block: 0, final: 0 },
failedCounts: { tool: 0, block: 0, final: 1 },
});
const ensureNoVisibleReplyFallback = vi.fn();
+2 -2
View File
@@ -114,11 +114,11 @@ function shouldSendNoVisibleReplyFallback(dispatchResult: {
dispatchResult.noVisibleReplyFallbackEligible === true &&
dispatchResult.queuedFinal !== true &&
finalCount === 0;
const queuedFinalFailed = dispatchResult.queuedFinal === true && failedFinalCount > 0;
const finalDeliveryFailed = failedFinalCount > 0;
return (
dispatchResult.sendPolicyDenied !== true &&
dispatchResult.sourceReplyDeliveryMode !== "message_tool_only" &&
(emptyEligibleDispatch || queuedFinalFailed)
(emptyEligibleDispatch || finalDeliveryFailed)
);
}
+5
View File
@@ -11,6 +11,7 @@ import {
getFeishuSendRateLimitCode,
getFeishuSendRateLimitCodeFromResponse,
} from "./send-rate-limit.js";
import { createFeishuRejectedMessageApiError } from "./send-result.js";
export function encodeQuery(params: Record<string, string | undefined>): string {
const query = new URLSearchParams();
@@ -128,6 +129,10 @@ export async function requestFeishuApi<T>(
},
);
} catch (error) {
const rejection = createFeishuRejectedMessageApiError(error, errorPrefix);
if (rejection && getFeishuSendRateLimitCode(error) === undefined) {
throw rejection;
}
throw createFeishuApiError(error, errorPrefix, options);
}
}
+7 -5
View File
@@ -2,6 +2,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { Readable } from "node:stream";
import { PlatformMessageNotDispatchedError } from "openclaw/plugin-sdk/error-runtime";
import { withTempDir } from "openclaw/plugin-sdk/test-env";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../runtime-api.js";
@@ -430,7 +431,7 @@ describe("sendMediaFeishu msg_type routing", () => {
expect(callData<{ msg_type?: string }>(messageCreateMock).msg_type).toBe("image");
});
it("preserves Feishu diagnostics when media sends reject before response checks", async () => {
it("classifies a structured media message rejection with bounded diagnostics", async () => {
messageCreateMock.mockRejectedValueOnce(
Object.assign(new Error("Request failed with status code 400"), {
response: {
@@ -447,15 +448,16 @@ describe("sendMediaFeishu msg_type routing", () => {
}),
);
const send = sendMediaFeishu({
const error = await sendMediaFeishu({
cfg: emptyConfig,
to: "user:ou_target",
mediaBuffer: validPngImage,
fileName: "photo.png",
});
}).catch((caught: unknown) => caught);
await expect(send).rejects.toThrow(/Feishu image send failed: .*"feishu_code":9499/);
await expect(send).rejects.toThrow(/"feishu_log_id":"20260429124731MEDIA"/);
expect(error).toBeInstanceOf(PlatformMessageNotDispatchedError);
expect(error).toMatchObject({ retryable: false });
expect((error as Error).message).toBe("Feishu image send failed: Bad Request (code=9499)");
});
it("uses msg_type=media when replying with mp4", async () => {
@@ -1799,6 +1799,45 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
});
});
it("settles an identity-less streaming final exactly once without static fallback", async () => {
const { result, options } = createDispatcherHarness();
const delivery = await options.deliver({ text: "accepted no-id final" }, { kind: "final" });
requireStreamingInstance(0).closeWithResult.mockResolvedValueOnce({
visibleReplySent: true,
content: "accepted no-id final",
});
await options.onIdle?.();
await expect(delivery?.finalization).resolves.toEqual({
visibleReplySent: true,
content: "accepted no-id final",
});
expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
await expect(result.ensureNoVisibleReplyFallback("accepted-no-id-stream")).resolves.toBe(false);
expect(requireStreamingInstance(0).closeWithResult).toHaveBeenCalledTimes(1);
});
it("keeps identity-less accepted-card custody when final content update fails", async () => {
const { result, options } = createDispatcherHarness();
const delivery = await options.deliver({ text: "unaccepted final text" }, { kind: "final" });
requireStreamingInstance(0).closeWithResult.mockRejectedValueOnce(
new FeishuStreamingFinalizationError(new Error("final update failed"), {
visibleReplySent: true,
}),
);
await expect(options.onIdle?.()).rejects.toThrow("final update failed");
await expect(delivery?.finalization).rejects.toMatchObject({
code: "CHANNEL_PARTIAL_DELIVERY",
deliveryResult: { visibleReplySent: true, content: "" },
});
expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
await expect(result.ensureNoVisibleReplyFallback("accepted-no-id-stream")).resolves.toBe(false);
});
it("allows recovery after a final rewrite leaves only an earlier preview visible", async () => {
const { result, options } = createDispatcherHarness();
result.replyOptions.onPartialReply?.({ text: "accepted preview" });
+1 -1
View File
@@ -1048,7 +1048,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
[...(providerFinalized ? [providerFinalized] : []), completion.result],
deliveryError === undefined
? (completion.result.content ?? providerFinalized?.content)
: (providerFinalized?.content ?? completion.result.content),
: (providerFinalized?.content ?? ""),
);
if (deliveryError !== undefined) {
completion.reject(
+41 -1
View File
@@ -1,6 +1,46 @@
import { isChannelPartialDeliveryError } from "openclaw/plugin-sdk/channel-inbound";
import { PlatformMessageNotDispatchedError } from "openclaw/plugin-sdk/error-runtime";
import { describe, expect, it } from "vitest";
import { toFeishuSendResult } from "./send-result.js";
import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js";
describe("assertFeishuMessageApiSuccess", () => {
it("classifies a fulfilled permanent provider rejection without exposing raw response data", () => {
const secretBearingResponse = {
code: 230099,
msg: "card table number over limit",
requestHeaders: { authorization: "secret" },
card: { body: "private" },
};
let caught: unknown;
try {
assertFeishuMessageApiSuccess(secretBearingResponse, "Feishu card send failed");
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(PlatformMessageNotDispatchedError);
expect(caught).toMatchObject({ retryable: false, cause: secretBearingResponse });
expect((caught as Error).message).toBe(
"Feishu card send failed: card table number over limit (code=230099)",
);
expect((caught as Error).message).not.toContain("secret");
expect((caught as Error).message).not.toContain("private");
});
it("keeps a code-less fulfilled response ambiguous instead of accepting it", () => {
let caught: unknown;
try {
assertFeishuMessageApiSuccess({ msg: "malformed gateway response" }, "Feishu send failed");
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(Error);
expect(caught).not.toBeInstanceOf(PlatformMessageNotDispatchedError);
expect((caught as Error).message).toBe("Feishu send failed: malformed gateway response");
});
});
describe("toFeishuSendResult", () => {
it.each([undefined, "", " "])(
+48 -2
View File
@@ -5,6 +5,9 @@ import {
type MessageReceipt,
type MessageReceiptPartKind,
} from "openclaw/plugin-sdk/channel-outbound";
import { PlatformMessageNotDispatchedError } from "openclaw/plugin-sdk/error-runtime";
import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
type FeishuMessageApiResponse = {
code?: number;
@@ -14,6 +17,44 @@ type FeishuMessageApiResponse = {
};
};
const FEISHU_PROVIDER_ERROR_MESSAGE_MAX_CHARS = 500;
function createFeishuMessageRejection(params: {
response: unknown;
errorPrefix: string;
cause: unknown;
}): PlatformMessageNotDispatchedError | undefined {
const response = isRecord(params.response) ? params.response : undefined;
if (!response) {
return undefined;
}
if (typeof response.code !== "number" || response.code === 0) {
return undefined;
}
const providerMessage = normalizeOptionalString(response.msg);
const detail = providerMessage
? `${sliceUtf16Safe(providerMessage, 0, FEISHU_PROVIDER_ERROR_MESSAGE_MAX_CHARS)} (code=${response.code})`
: `code ${response.code}`;
return new PlatformMessageNotDispatchedError(`${params.errorPrefix}: ${detail}`, {
cause: params.cause,
retryable: false,
});
}
export function createFeishuRejectedMessageApiError(
error: unknown,
errorPrefix: string,
): PlatformMessageNotDispatchedError | undefined {
if (!isRecord(error)) {
return undefined;
}
const response = isRecord(error.response) ? error.response : undefined;
const data = isRecord(response?.data) ? response.data : undefined;
return data
? createFeishuMessageRejection({ response: data, errorPrefix, cause: error })
: undefined;
}
export function resolveFeishuReceiptKind(msgType?: string): MessageReceiptPartKind {
switch (msgType) {
case "audio":
@@ -59,9 +100,14 @@ export function assertFeishuMessageApiSuccess(
response: FeishuMessageApiResponse,
errorPrefix: string,
) {
if (response.code !== 0) {
throw new Error(`${errorPrefix}: ${response.msg || `code ${response.code}`}`);
if (response.code === 0) {
return;
}
const rejection = createFeishuMessageRejection({ response, errorPrefix, cause: response });
if (rejection) {
throw rejection;
}
throw new Error(`${errorPrefix}: ${response.msg || `code ${response.code}`}`);
}
export function toFeishuSendResult(
+34
View File
@@ -5,6 +5,7 @@
* so no fake timers are needed. Related: issue #70879.
*/
import { PlatformMessageNotDispatchedError } from "openclaw/plugin-sdk/error-runtime";
import { describe, expect, it, vi } from "vitest";
import { requestFeishuApi } from "./comment-shared.js";
import {
@@ -124,6 +125,23 @@ describe("requestFeishuApi — retry on rate-limit", () => {
});
describe("requestFeishuApi — no retry for non-rate-limit errors", () => {
it("classifies a structured rejected message response as a permanent non-dispatch", async () => {
const rejected = axiosError(230099);
rejected.response.data.msg = "card table number over limit";
const request = vi.fn().mockRejectedValue(rejected);
const error = await requestFeishuApi(request, "Feishu card send failed", NO_DELAY).catch(
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(PlatformMessageNotDispatchedError);
expect(error).toMatchObject({ retryable: false, cause: rejected });
expect((error as Error).message).toBe(
"Feishu card send failed: card table number over limit (code=230099)",
);
expect(request).toHaveBeenCalledTimes(1);
});
it("throws immediately without retry for a non-rate-limit Feishu code", async () => {
const request = vi.fn().mockRejectedValue(axiosError(230001));
@@ -137,6 +155,22 @@ describe("requestFeishuApi — no retry for non-rate-limit errors", () => {
await expect(requestFeishuApi(request, "prefix", NO_DELAY)).rejects.toThrow(/network failure/);
expect(request).toHaveBeenCalledTimes(1);
});
it("keeps code-less transport failures ambiguous", async () => {
const request = vi.fn().mockRejectedValue(
Object.assign(new Error("socket closed"), {
response: { status: 502, data: { msg: "gateway unavailable" } },
}),
);
const error = await requestFeishuApi(request, "prefix", NO_DELAY).catch(
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(Error);
expect(error).not.toBeInstanceOf(PlatformMessageNotDispatchedError);
expect(request).toHaveBeenCalledTimes(1);
});
});
describe("getFeishuSendRateLimitCode — expanded rate-limit signals", () => {
@@ -140,7 +140,9 @@ describe("feishu streaming card error-path body release", () => {
await session.start("chat_id", "open_id");
loopback.settingsStatus = 500;
await expect(session.close()).resolves.toBe(false);
await expect(session.closeWithResult()).rejects.toMatchObject({
result: { visibleReplySent: false },
});
expect(loopback.releases).toEqual([
{ bodyIsNull: false, bodyUsed: true },
+161 -8
View File
@@ -1,5 +1,6 @@
// Feishu tests cover streaming card plugin behavior.
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { PlatformMessageNotDispatchedError } from "openclaw/plugin-sdk/error-runtime";
import type { LookupFn } from "openclaw/plugin-sdk/ssrf-runtime";
import { withFetchPreconnect } from "openclaw/plugin-sdk/test-env";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -15,7 +16,7 @@ type FeishuStreamingFetch = typeof fetch;
type StreamingSessionState = {
cardId: string;
messageId: string;
messageId?: string;
sequence: number;
currentText: string;
sentText: string;
@@ -287,6 +288,154 @@ describe("FeishuStreamingSession", () => {
return { authTokens, client, deps };
}
function createStartHarness(
response: { code: number; msg: string; messageId?: string },
rejectCardKitPath?: "content" | "settings",
) {
const sendResponse = async () => ({
code: response.code,
msg: response.msg,
data: response.messageId ? { message_id: response.messageId } : {},
});
const messageCreate = vi.fn(sendResponse);
const messageReply = vi.fn(sendResponse);
const messageDelete = vi.fn(async () => ({ code: 0, msg: "ok" }));
const client = {
im: { message: { create: messageCreate, reply: messageReply, delete: messageDelete } },
} as unknown as ConstructorParameters<typeof FeishuStreamingSession>[0];
const requests: Array<{ path: string; body: string }> = [];
const deps = createMemoryFetch((url, body) => {
requests.push({ path: url.pathname, body });
if (url.pathname.includes("/auth/")) {
return jsonResponse({
code: 0,
msg: "ok",
tenant_access_token: "token",
expire: 7200,
});
}
if (url.pathname.endsWith("/cardkit/v1/cards")) {
return jsonResponse({ code: 0, msg: "ok", data: { card_id: "card_start" } });
}
if (
(rejectCardKitPath === "content" && url.pathname.includes("/elements/content/content")) ||
(rejectCardKitPath === "settings" && url.pathname.endsWith("/settings"))
) {
return jsonResponse({ code: 19001, msg: `${rejectCardKitPath} rejected` });
}
return jsonResponse({ code: 0, msg: "ok" });
});
return { client, deps, messageCreate, messageReply, messageDelete, requests };
}
it.each([
{ mode: "reply", options: { replyToMessageId: "om_parent" } },
{ mode: "root-create", options: { rootId: "om_root" } },
{ mode: "create", options: undefined },
])("classifies fulfilled permanent rejection in $mode mode", async ({ mode, options }) => {
const harness = createStartHarness({
code: 230099,
msg: "card table number over limit",
});
const session = new FeishuStreamingSession(
harness.client,
{ appId: `app_rejected_${mode}`, appSecret: "secret" },
undefined,
harness.deps,
);
const error = await session
.start("oc_chat", "chat_id", options)
.catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(PlatformMessageNotDispatchedError);
expect(error).toMatchObject({ retryable: false });
expect((error as Error).message).toBe(
"Send card failed: card table number over limit (code=230099)",
);
expect(session.isActive()).toBe(false);
expect(harness.messageCreate.mock.calls.length + harness.messageReply.mock.calls.length).toBe(
1,
);
});
it.each([
{ mode: "reply", options: { replyToMessageId: "om_parent" } },
{ mode: "root-create", options: { rootId: "om_root" } },
{ mode: "create", options: undefined },
])("finalizes an accepted no-identity card in place in $mode mode", async ({ mode, options }) => {
const harness = createStartHarness({ code: 0, msg: "ok" });
const session = new FeishuStreamingSession(
harness.client,
{ appId: `app_no_id_${mode}`, appSecret: "secret" },
undefined,
harness.deps,
);
await session.start("oc_chat", "chat_id", options);
expect(session.isActive()).toBe(true);
const result = await session.closeWithResult("exact final answer");
expect(result).toEqual({ visibleReplySent: true, content: "exact final answer" });
expect(session.isActive()).toBe(false);
expect(harness.messageCreate.mock.calls.length + harness.messageReply.mock.calls.length).toBe(
1,
);
expect(
harness.requests.filter(({ path }) => path.includes("/elements/content/content")),
).toHaveLength(1);
expect(harness.requests.filter(({ path }) => path.endsWith("/settings"))).toHaveLength(1);
expect(harness.messageDelete).not.toHaveBeenCalled();
});
it("disposes an accepted no-identity card in place without attempting message deletion", async () => {
const harness = createStartHarness({ code: 0, msg: "ok" });
const session = new FeishuStreamingSession(
harness.client,
{ appId: "app_no_id_discard", appSecret: "secret" },
undefined,
harness.deps,
);
await session.start("oc_chat", "chat_id");
await session.discard();
await session.discard();
expect(harness.messageDelete).not.toHaveBeenCalled();
expect(harness.requests.filter(({ path }) => path.endsWith("/settings"))).toHaveLength(1);
expect(session.isActive()).toBe(false);
});
it.each(["content", "settings"] as const)(
"reports accepted no-identity custody when %s finalization fails",
async (rejectedPath) => {
const harness = createStartHarness({ code: 0, msg: "ok" }, rejectedPath);
const session = new FeishuStreamingSession(
harness.client,
{ appId: `app_no_id_failed_${rejectedPath}`, appSecret: "secret" },
undefined,
harness.deps,
);
await session.start("oc_chat", "chat_id");
const error = await session
.closeWithResult("final answer")
.catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(FeishuStreamingFinalizationError);
expect(error).toMatchObject({
result: {
visibleReplySent: true,
...(rejectedPath === "settings" ? { content: "final answer" } : {}),
},
});
expect((error as FeishuStreamingFinalizationError).result.messageId).toBeUndefined();
expect(harness.messageDelete).not.toHaveBeenCalled();
expect(harness.requests.filter(({ path }) => path.endsWith("/settings"))).toHaveLength(1);
expect(session.isActive()).toBe(false);
},
);
it("rejects oversized streaming tenant-token JSON before buffering the full body", async () => {
let streamState:
| {
@@ -637,7 +786,7 @@ describe("FeishuStreamingSession", () => {
expect(updateBodies).toHaveLength(0);
expect(replaceBodies).toHaveLength(0);
await session.close();
await session.closeWithResult();
expect(updateBodies).toHaveLength(0);
expect(replaceBodies).toHaveLength(1);
@@ -693,8 +842,8 @@ describe("FeishuStreamingSession", () => {
});
await session.update(next);
// close() reports whether any accepted content remains visible, even when the final rewrite fails.
await expect(session.close()).resolves.toBe(true);
const error = await session.closeWithResult().catch((caught: unknown) => caught);
expect(error).toMatchObject({ result: { visibleReplySent: true, content: previous } });
expect(replaceBodies).toHaveLength(1);
expect(settingsBodies).toHaveLength(1);
@@ -887,7 +1036,7 @@ describe("FeishuStreamingSession", () => {
lastUpdateTime: 3_000,
});
await session.close("final answer");
await session.closeWithResult("final answer");
expect(updateBodies).toHaveLength(0);
expect(replaceBodies).toHaveLength(1);
@@ -955,7 +1104,7 @@ describe("FeishuStreamingSession", () => {
lastUpdateTime: 3_000,
});
await session.close(finalText);
await session.closeWithResult(finalText);
expect(settingsBodies).toHaveLength(1);
const settingsPayload = JSON.parse(settingsBodies[0] ?? "{}") as { settings?: string };
@@ -1005,7 +1154,9 @@ describe("FeishuStreamingSession", () => {
lastUpdateTime: 3_000,
});
await session.close("final answer");
await expect(session.closeWithResult("final answer")).rejects.toBeInstanceOf(
FeishuStreamingFinalizationError,
);
expect(updateBodies).toHaveLength(0);
expect(replaceBodies).toHaveLength(1);
@@ -1043,7 +1194,9 @@ describe("FeishuStreamingSession", () => {
lastUpdateTime: 3_000,
});
await expect(session.close("final answer")).resolves.toBe(false);
await expect(session.closeWithResult("final answer")).rejects.toMatchObject({
result: { visibleReplySent: false },
});
expect(updateBodies).toHaveLength(1);
expect(replaceBodies).toHaveLength(0);
+19 -22
View File
@@ -14,6 +14,7 @@ import { FEISHU_HTTP_TIMEOUT_MS } from "./client-timeout.js";
import { getFeishuUserAgent } from "./client.js";
import { requestFeishuApi } from "./comment-shared.js";
import { readFeishuJsonResponse } from "./json-response.js";
import { assertFeishuMessageApiSuccess } from "./send-result.js";
import { resolveFeishuCardTemplate, type CardHeaderConfig } from "./send.js";
import { resolveStreamingCardSendMode } from "./streaming-card-send-mode.js";
import type { FeishuDomain } from "./types.js";
@@ -26,7 +27,7 @@ type Credentials = {
};
type CardState = {
cardId: string;
messageId: string;
messageId: string | undefined;
sequence: number;
currentText: string;
sentText: string;
@@ -399,19 +400,18 @@ export class FeishuStreamingSession {
"Send card failed",
);
}
if (sendRes.code !== 0 || !sendRes.data?.message_id) {
throw new Error(`Send card failed: ${sendRes.msg}`);
}
assertFeishuMessageApiSuccess(sendRes, "Send card failed");
const messageId = sendRes.data?.message_id?.trim() || undefined;
this.state = {
cardId,
messageId: sendRes.data.message_id,
messageId,
sequence: 1,
currentText: "",
sentText: "",
hasNote: Boolean(options?.note),
};
this.log?.(`Started streaming: cardId=${cardId}, messageId=${sendRes.data.message_id}`);
this.log?.(`Started streaming: cardId=${cardId}, messageId=${messageId ?? "unavailable"}`);
}
private async updateCardContent(
@@ -640,7 +640,7 @@ export class FeishuStreamingSession {
const apiBase = resolveApiBase(this.creds.domain);
// A failed final rewrite does not erase previously accepted visible content.
// sentText advances only for an accepted write; the return value reports any visible content.
let visibleContentSent = Boolean(this.state.sentText.trim());
let visibleContentSent = !this.state.messageId || Boolean(this.state.sentText.trim());
let finalWriteError: unknown;
// Only send final update if content differs from what's already displayed.
@@ -722,8 +722,8 @@ export class FeishuStreamingSession {
this.log?.(`Closed streaming: cardId=${finalState.cardId}`);
const result: FeishuStreamingCloseResult = {
visibleReplySent: visibleContentSent,
...(visibleContentSent ? { content: finalState.sentText } : {}),
messageId: finalState.messageId,
...(finalState.sentText.trim() ? { content: finalState.sentText } : {}),
...(finalState.messageId ? { messageId: finalState.messageId } : {}),
};
if (finalWriteError !== undefined || closeError !== undefined) {
const cause =
@@ -738,21 +738,18 @@ export class FeishuStreamingSession {
return result;
}
async close(finalText?: string, options?: { note?: string }): Promise<boolean> {
try {
return (await this.closeWithResult(finalText, options)).visibleReplySent;
} catch (error: unknown) {
if (error instanceof FeishuStreamingFinalizationError) {
return error.result.visibleReplySent;
}
throw error;
}
}
async discard(): Promise<void> {
if (!this.state || this.closed) {
return;
}
const messageId = this.state.messageId;
if (!messageId) {
// CardKit accepted this single-send entity, but Feishu returned no message
// identity. Finalize it in place; retrying or deleting by a fabricated id
// would duplicate content or target an unrelated message.
await this.closeWithResult("");
return;
}
this.closed = true;
this.clearFlushTimer();
await this.queue;
@@ -760,7 +757,7 @@ export class FeishuStreamingSession {
const currentState = this.state;
try {
const response = await this.client.im.message.delete({
path: { message_id: currentState.messageId },
path: { message_id: messageId },
});
if (response.code !== undefined && response.code !== 0) {
throw new Error(`Delete streaming card message failed: ${response.msg ?? response.code}`);
@@ -771,7 +768,7 @@ export class FeishuStreamingSession {
} catch (error) {
this.log?.(`Discard failed: ${String(error)}`);
this.closed = false;
await this.close("");
await this.closeWithResult("");
}
}