mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix(feishu): preserve rejected reply delivery outcomes
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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, "", " "])(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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("");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user