fix(feishu): fetch thread context so AI can see bot replies in topic threads (#45254)

* fix(feishu): fetch thread context so AI can see bot replies in topic threads

When a user replies in a Feishu topic thread, the AI previously could only
see the quoted parent message but not the bot's own prior replies in the
thread. This made multi-turn conversations in threads feel broken.

- Add `threadId` (omt_xxx) to `FeishuMessageInfo` and `getMessageFeishu`
- Add `listFeishuThreadMessages()` using `container_id_type=thread` API
  to fetch all messages in a thread including bot replies
- In `handleFeishuMessage`, fetch ThreadStarterBody and ThreadHistoryBody
  for topic session modes and pass them to the AI context
- Reuse quoted message result when rootId === parentId to avoid redundant
  API calls; exclude root message from thread history to prevent duplication
- Fall back to inbound ctx.threadId when rootId is absent or API fails
- Fetch newest messages first (ByCreateTimeDesc + reverse) so long threads
  keep the most recent turns instead of the oldest

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(feishu): skip redundant thread context injection on subsequent turns

Only inject ThreadHistoryBody on the first turn of a thread session.
On subsequent turns the session already contains prior context, so
re-injecting thread history (and starter) would waste tokens.

The heuristic checks whether the current user has already sent a
non-root message in the thread — if so, the session has prior turns
and thread context injection is skipped entirely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(feishu): handle thread_id-only events in prior-turn detection

When ctx.rootId is undefined (thread_id-only events), the starter
message exclusion check `msg.messageId !== ctx.rootId` was always
true, causing the first follow-up to be misclassified as a prior
turn. Fall back to the first message in the chronologically-sorted
thread history as the starter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(feishu): bootstrap topic thread context via session state

* test(memory): pin remote embedding hostnames in offline suites

* fix(feishu): use plugin-safe session runtime for thread bootstrap

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
Brian Qu 2026-03-15 07:01:59 +08:00 committed by GitHub
parent 3704293e6f
commit 8a607d7553
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 483 additions and 37 deletions

View File

@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai
- Agents/usage tracking: stop forcing `supportsUsageInStreaming: false` on non-native openai-completions endpoints so providers like DashScope, DeepSeek, and other OpenAI-compatible backends report token usage and cost instead of showing all zeros. (#46142)
- Node/startup: remove leftover debug `console.log("node host PATH: ...")` that printed the resolved PATH on every `openclaw node run` invocation. (#46411)
- Control UI/dashboard: preserve structured gateway shutdown reasons across restart disconnects so config-triggered restarts no longer fall back to `disconnected (1006): no reason`. (#46532) Thanks @vincentkoc.
- Feishu/topic threads: fetch full thread context, including prior bot replies, when starting a topic-thread session so follow-up turns in Feishu topics keep the right conversation state. Thanks @Coobiw.
- Browser/profiles: drop the auto-created `chrome-relay` browser profile; users who need the Chrome extension relay must now create their own profile via `openclaw browser create-profile`. (#45777) Thanks @odysseus0.
## 2026.3.13

View File

@ -15,9 +15,12 @@ const {
mockCreateFeishuReplyDispatcher,
mockSendMessageFeishu,
mockGetMessageFeishu,
mockListFeishuThreadMessages,
mockDownloadMessageResourceFeishu,
mockCreateFeishuClient,
mockResolveAgentRoute,
mockReadSessionUpdatedAt,
mockResolveStorePath,
} = vi.hoisted(() => ({
mockCreateFeishuReplyDispatcher: vi.fn(() => ({
dispatcher: vi.fn(),
@ -26,6 +29,7 @@ const {
})),
mockSendMessageFeishu: vi.fn().mockResolvedValue({ messageId: "pairing-msg", chatId: "oc-dm" }),
mockGetMessageFeishu: vi.fn().mockResolvedValue(null),
mockListFeishuThreadMessages: vi.fn().mockResolvedValue([]),
mockDownloadMessageResourceFeishu: vi.fn().mockResolvedValue({
buffer: Buffer.from("video"),
contentType: "video/mp4",
@ -40,6 +44,8 @@ const {
mainSessionKey: "agent:main:main",
matchedBy: "default",
})),
mockReadSessionUpdatedAt: vi.fn(),
mockResolveStorePath: vi.fn(() => "/tmp/feishu-sessions.json"),
}));
vi.mock("./reply-dispatcher.js", () => ({
@ -49,6 +55,7 @@ vi.mock("./reply-dispatcher.js", () => ({
vi.mock("./send.js", () => ({
sendMessageFeishu: mockSendMessageFeishu,
getMessageFeishu: mockGetMessageFeishu,
listFeishuThreadMessages: mockListFeishuThreadMessages,
}));
vi.mock("./media.js", () => ({
@ -140,6 +147,8 @@ describe("handleFeishuMessage command authorization", () => {
beforeEach(() => {
vi.clearAllMocks();
mockShouldComputeCommandAuthorized.mockReset().mockReturnValue(true);
mockReadSessionUpdatedAt.mockReturnValue(undefined);
mockResolveStorePath.mockReturnValue("/tmp/feishu-sessions.json");
mockResolveAgentRoute.mockReturnValue({
agentId: "main",
channel: "feishu",
@ -166,6 +175,12 @@ describe("handleFeishuMessage command authorization", () => {
resolveAgentRoute:
mockResolveAgentRoute as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
},
session: {
readSessionUpdatedAt:
mockReadSessionUpdatedAt as unknown as PluginRuntime["channel"]["session"]["readSessionUpdatedAt"],
resolveStorePath:
mockResolveStorePath as unknown as PluginRuntime["channel"]["session"]["resolveStorePath"],
},
reply: {
resolveEnvelopeFormatOptions: vi.fn(
() => ({}),
@ -1709,6 +1724,123 @@ describe("handleFeishuMessage command authorization", () => {
);
});
it("bootstraps topic thread context only for a new thread session", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
mockGetMessageFeishu.mockResolvedValue({
messageId: "om_topic_root",
chatId: "oc-group",
content: "root starter",
contentType: "text",
threadId: "omt_topic_1",
});
mockListFeishuThreadMessages.mockResolvedValue([
{
messageId: "om_bot_reply",
senderId: "app_1",
senderType: "app",
content: "assistant reply",
contentType: "text",
createTime: 1710000000000,
},
{
messageId: "om_follow_up",
senderId: "ou-topic-user",
senderType: "user",
content: "follow-up question",
contentType: "text",
createTime: 1710000001000,
},
]);
const cfg: ClawdbotConfig = {
channels: {
feishu: {
groups: {
"oc-group": {
requireMention: false,
groupSessionScope: "group_topic",
},
},
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: { sender_id: { open_id: "ou-topic-user" } },
message: {
message_id: "om_topic_followup_existing_session",
root_id: "om_topic_root",
chat_id: "oc-group",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "current turn" }),
},
};
await dispatchMessage({ cfg, event });
expect(mockReadSessionUpdatedAt).toHaveBeenCalledWith({
storePath: "/tmp/feishu-sessions.json",
sessionKey: "agent:main:feishu:dm:ou-attacker",
});
expect(mockListFeishuThreadMessages).toHaveBeenCalledWith(
expect.objectContaining({
rootMessageId: "om_topic_root",
}),
);
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
ThreadStarterBody: "root starter",
ThreadHistoryBody: "assistant reply\n\nfollow-up question",
ThreadLabel: "Feishu thread in oc-group",
MessageThreadId: "om_topic_root",
}),
);
});
it("skips topic thread bootstrap when the thread session already exists", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
mockReadSessionUpdatedAt.mockReturnValue(1710000000000);
const cfg: ClawdbotConfig = {
channels: {
feishu: {
groups: {
"oc-group": {
requireMention: false,
groupSessionScope: "group_topic",
},
},
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: { sender_id: { open_id: "ou-topic-user" } },
message: {
message_id: "om_topic_followup",
root_id: "om_topic_root",
chat_id: "oc-group",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "current turn" }),
},
};
await dispatchMessage({ cfg, event });
expect(mockGetMessageFeishu).not.toHaveBeenCalled();
expect(mockListFeishuThreadMessages).not.toHaveBeenCalled();
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
ThreadStarterBody: undefined,
ThreadHistoryBody: undefined,
ThreadLabel: "Feishu thread in oc-group",
MessageThreadId: "om_topic_root",
}),
);
});
it("does not dispatch twice for the same image message_id (concurrent dedupe)", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);

View File

@ -29,7 +29,7 @@ import {
import { parsePostContent } from "./post.js";
import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
import { getFeishuRuntime } from "./runtime.js";
import { getMessageFeishu, sendMessageFeishu } from "./send.js";
import { getMessageFeishu, listFeishuThreadMessages, sendMessageFeishu } from "./send.js";
import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js";
import type { DynamicAgentCreationConfig } from "./types.js";
@ -1239,16 +1239,17 @@ export async function handleFeishuMessage(params: {
const mediaPayload = buildAgentMediaPayload(mediaList);
// Fetch quoted/replied message content if parentId exists
let quotedMessageInfo: Awaited<ReturnType<typeof getMessageFeishu>> = null;
let quotedContent: string | undefined;
if (ctx.parentId) {
try {
const quotedMsg = await getMessageFeishu({
quotedMessageInfo = await getMessageFeishu({
cfg,
messageId: ctx.parentId,
accountId: account.accountId,
});
if (quotedMsg) {
quotedContent = quotedMsg.content;
if (quotedMessageInfo) {
quotedContent = quotedMessageInfo.content;
log(
`feishu[${account.accountId}]: fetched quoted message: ${quotedContent?.slice(0, 100)}`,
);
@ -1258,6 +1259,11 @@ export async function handleFeishuMessage(params: {
}
}
const isTopicSessionForThread =
isGroup &&
(groupSession?.groupSessionScope === "group_topic" ||
groupSession?.groupSessionScope === "group_topic_sender");
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
const messageBody = buildFeishuAgentBody({
ctx,
@ -1309,13 +1315,140 @@ export async function handleFeishuMessage(params: {
}))
: undefined;
const threadContextBySessionKey = new Map<
string,
{
threadStarterBody?: string;
threadHistoryBody?: string;
threadLabel?: string;
}
>();
let rootMessageInfo: Awaited<ReturnType<typeof getMessageFeishu>> | undefined;
let rootMessageFetched = false;
const getRootMessageInfo = async () => {
if (!ctx.rootId) {
return null;
}
if (!rootMessageFetched) {
rootMessageFetched = true;
if (ctx.rootId === ctx.parentId && quotedMessageInfo) {
rootMessageInfo = quotedMessageInfo;
} else {
try {
rootMessageInfo = await getMessageFeishu({
cfg,
messageId: ctx.rootId,
accountId: account.accountId,
});
} catch (err) {
log(`feishu[${account.accountId}]: failed to fetch root message: ${String(err)}`);
rootMessageInfo = null;
}
}
}
return rootMessageInfo ?? null;
};
const resolveThreadContextForAgent = async (agentId: string, agentSessionKey: string) => {
const cached = threadContextBySessionKey.get(agentSessionKey);
if (cached) {
return cached;
}
const threadContext: {
threadStarterBody?: string;
threadHistoryBody?: string;
threadLabel?: string;
} = {
threadLabel:
(ctx.rootId || ctx.threadId) && isTopicSessionForThread
? `Feishu thread in ${ctx.chatId}`
: undefined,
};
if (!(ctx.rootId || ctx.threadId) || !isTopicSessionForThread) {
threadContextBySessionKey.set(agentSessionKey, threadContext);
return threadContext;
}
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { agentId });
const previousThreadSessionTimestamp = core.channel.session.readSessionUpdatedAt({
storePath,
sessionKey: agentSessionKey,
});
if (previousThreadSessionTimestamp) {
log(
`feishu[${account.accountId}]: skipping thread bootstrap for existing session ${agentSessionKey}`,
);
threadContextBySessionKey.set(agentSessionKey, threadContext);
return threadContext;
}
const rootMsg = await getRootMessageInfo();
let feishuThreadId = ctx.threadId ?? rootMsg?.threadId;
if (feishuThreadId) {
log(`feishu[${account.accountId}]: resolved thread ID: ${feishuThreadId}`);
}
if (!feishuThreadId) {
log(
`feishu[${account.accountId}]: no threadId found for root message ${ctx.rootId ?? "none"}, skipping thread history`,
);
threadContextBySessionKey.set(agentSessionKey, threadContext);
return threadContext;
}
try {
const threadMessages = await listFeishuThreadMessages({
cfg,
threadId: feishuThreadId,
currentMessageId: ctx.messageId,
rootMessageId: ctx.rootId,
limit: 20,
accountId: account.accountId,
});
const senderScoped = groupSession?.groupSessionScope === "group_topic_sender";
const relevantMessages = senderScoped
? threadMessages.filter(
(msg) => msg.senderType === "app" || msg.senderId === ctx.senderOpenId,
)
: threadMessages;
const threadStarterBody = rootMsg?.content ?? relevantMessages[0]?.content;
const historyMessages =
rootMsg?.content || ctx.rootId ? relevantMessages : relevantMessages.slice(1);
const historyParts = historyMessages.map((msg) => {
const role = msg.senderType === "app" ? "assistant" : "user";
return core.channel.reply.formatAgentEnvelope({
channel: "Feishu",
from: `${msg.senderId ?? "Unknown"} (${role})`,
timestamp: msg.createTime,
body: msg.content,
envelope: envelopeOptions,
});
});
threadContext.threadStarterBody = threadStarterBody;
threadContext.threadHistoryBody =
historyParts.length > 0 ? historyParts.join("\n\n") : undefined;
log(
`feishu[${account.accountId}]: populated thread bootstrap with starter=${threadStarterBody ? "yes" : "no"} history=${historyMessages.length}`,
);
} catch (err) {
log(`feishu[${account.accountId}]: failed to fetch thread history: ${String(err)}`);
}
threadContextBySessionKey.set(agentSessionKey, threadContext);
return threadContext;
};
// --- Shared context builder for dispatch ---
const buildCtxPayloadForAgent = (
const buildCtxPayloadForAgent = async (
agentId: string,
agentSessionKey: string,
agentAccountId: string,
wasMentioned: boolean,
) =>
core.channel.reply.finalizeInboundContext({
) => {
const threadContext = await resolveThreadContextForAgent(agentId, agentSessionKey);
return core.channel.reply.finalizeInboundContext({
Body: combinedBody,
BodyForAgent: messageBody,
InboundHistory: inboundHistory,
@ -1335,6 +1468,12 @@ export async function handleFeishuMessage(params: {
Surface: "feishu" as const,
MessageSid: ctx.messageId,
ReplyToBody: quotedContent ?? undefined,
ThreadStarterBody: threadContext.threadStarterBody,
ThreadHistoryBody: threadContext.threadHistoryBody,
ThreadLabel: threadContext.threadLabel,
// Only use rootId (om_* message anchor) — threadId (omt_*) is a container
// ID and would produce invalid reply targets downstream.
MessageThreadId: ctx.rootId && isTopicSessionForThread ? ctx.rootId : undefined,
Timestamp: Date.now(),
WasMentioned: wasMentioned,
CommandAuthorized: commandAuthorized,
@ -1343,6 +1482,7 @@ export async function handleFeishuMessage(params: {
GroupSystemPrompt: isGroup ? groupConfig?.systemPrompt?.trim() || undefined : undefined,
...mediaPayload,
});
};
// Parse message create_time (Feishu uses millisecond epoch string).
const messageCreateTimeMs = event.message.create_time
@ -1402,7 +1542,8 @@ export async function handleFeishuMessage(params: {
}
const agentSessionKey = buildBroadcastSessionKey(route.sessionKey, route.agentId, agentId);
const agentCtx = buildCtxPayloadForAgent(
const agentCtx = await buildCtxPayloadForAgent(
agentId,
agentSessionKey,
route.accountId,
ctx.mentionedBot && agentId === activeAgentId,
@ -1502,7 +1643,8 @@ export async function handleFeishuMessage(params: {
);
} else {
// --- Single-agent dispatch (existing behavior) ---
const ctxPayload = buildCtxPayloadForAgent(
const ctxPayload = await buildCtxPayloadForAgent(
route.agentId,
route.sessionKey,
route.accountId,
ctx.mentionedBot,

View File

@ -1,12 +1,14 @@
import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getMessageFeishu } from "./send.js";
import { getMessageFeishu, listFeishuThreadMessages } from "./send.js";
const { mockClientGet, mockCreateFeishuClient, mockResolveFeishuAccount } = vi.hoisted(() => ({
mockClientGet: vi.fn(),
mockCreateFeishuClient: vi.fn(),
mockResolveFeishuAccount: vi.fn(),
}));
const { mockClientGet, mockClientList, mockCreateFeishuClient, mockResolveFeishuAccount } =
vi.hoisted(() => ({
mockClientGet: vi.fn(),
mockClientList: vi.fn(),
mockCreateFeishuClient: vi.fn(),
mockResolveFeishuAccount: vi.fn(),
}));
vi.mock("./client.js", () => ({
createFeishuClient: mockCreateFeishuClient,
@ -27,6 +29,7 @@ describe("getMessageFeishu", () => {
im: {
message: {
get: mockClientGet,
list: mockClientList,
},
},
});
@ -165,4 +168,68 @@ describe("getMessageFeishu", () => {
}),
);
});
it("reuses the same content parsing for thread history messages", async () => {
mockClientList.mockResolvedValueOnce({
code: 0,
data: {
items: [
{
message_id: "om_root",
msg_type: "text",
body: {
content: JSON.stringify({ text: "root starter" }),
},
},
{
message_id: "om_card",
msg_type: "interactive",
body: {
content: JSON.stringify({
body: {
elements: [{ tag: "markdown", content: "hello from card 2.0" }],
},
}),
},
sender: {
id: "app_1",
sender_type: "app",
},
create_time: "1710000000000",
},
{
message_id: "om_file",
msg_type: "file",
body: {
content: JSON.stringify({ file_key: "file_v3_123" }),
},
sender: {
id: "ou_1",
sender_type: "user",
},
create_time: "1710000001000",
},
],
},
});
const result = await listFeishuThreadMessages({
cfg: {} as ClawdbotConfig,
threadId: "omt_1",
rootMessageId: "om_root",
});
expect(result).toEqual([
expect.objectContaining({
messageId: "om_file",
contentType: "file",
content: "[file message]",
}),
expect.objectContaining({
messageId: "om_card",
contentType: "interactive",
content: "hello from card 2.0",
}),
]);
});
});

View File

@ -65,6 +65,7 @@ type FeishuMessageGetItem = {
message_id?: string;
chat_id?: string;
chat_type?: FeishuChatType;
thread_id?: string;
msg_type?: string;
body?: { content?: string };
sender?: FeishuMessageSender;
@ -151,13 +152,19 @@ function parseInteractiveCardContent(parsed: unknown): string {
return "[Interactive Card]";
}
const candidate = parsed as { elements?: unknown };
if (!Array.isArray(candidate.elements)) {
// Support both schema 1.0 (top-level `elements`) and 2.0 (`body.elements`).
const candidate = parsed as { elements?: unknown; body?: { elements?: unknown } };
const elements = Array.isArray(candidate.elements)
? candidate.elements
: Array.isArray(candidate.body?.elements)
? candidate.body!.elements
: null;
if (!elements) {
return "[Interactive Card]";
}
const texts: string[] = [];
for (const element of candidate.elements) {
for (const element of elements) {
if (!element || typeof element !== "object") {
continue;
}
@ -177,7 +184,7 @@ function parseInteractiveCardContent(parsed: unknown): string {
return texts.join("\n").trim() || "[Interactive Card]";
}
function parseQuotedMessageContent(rawContent: string, msgType: string): string {
function parseFeishuMessageContent(rawContent: string, msgType: string): string {
if (!rawContent) {
return "";
}
@ -218,6 +225,30 @@ function parseQuotedMessageContent(rawContent: string, msgType: string): string
return `[${msgType || "unknown"} message]`;
}
function parseFeishuMessageItem(
item: FeishuMessageGetItem,
fallbackMessageId?: string,
): FeishuMessageInfo {
const msgType = item.msg_type ?? "text";
const rawContent = item.body?.content ?? "";
return {
messageId: item.message_id ?? fallbackMessageId ?? "",
chatId: item.chat_id ?? "",
chatType:
item.chat_type === "group" || item.chat_type === "private" || item.chat_type === "p2p"
? item.chat_type
: undefined,
senderId: item.sender?.id,
senderOpenId: item.sender?.id_type === "open_id" ? item.sender?.id : undefined,
senderType: item.sender?.sender_type,
content: parseFeishuMessageContent(rawContent, msgType),
contentType: msgType,
createTime: item.create_time ? parseInt(String(item.create_time), 10) : undefined,
threadId: item.thread_id || undefined,
};
}
/**
* Get a message by its ID.
* Useful for fetching quoted/replied message content.
@ -255,29 +286,98 @@ export async function getMessageFeishu(params: {
return null;
}
const msgType = item.msg_type ?? "text";
const rawContent = item.body?.content ?? "";
const content = parseQuotedMessageContent(rawContent, msgType);
return {
messageId: item.message_id ?? messageId,
chatId: item.chat_id ?? "",
chatType:
item.chat_type === "group" || item.chat_type === "private" || item.chat_type === "p2p"
? item.chat_type
: undefined,
senderId: item.sender?.id,
senderOpenId: item.sender?.id_type === "open_id" ? item.sender?.id : undefined,
senderType: item.sender?.sender_type,
content,
contentType: msgType,
createTime: item.create_time ? parseInt(String(item.create_time), 10) : undefined,
};
return parseFeishuMessageItem(item, messageId);
} catch {
return null;
}
}
export type FeishuThreadMessageInfo = {
messageId: string;
senderId?: string;
senderType?: string;
content: string;
contentType: string;
createTime?: number;
};
/**
* List messages in a Feishu thread (topic).
* Uses container_id_type=thread to directly query thread messages,
* which includes both the root message and all replies (including bot replies).
*/
export async function listFeishuThreadMessages(params: {
cfg: ClawdbotConfig;
threadId: string;
currentMessageId?: string;
/** Exclude the root message (already provided separately as ThreadStarterBody). */
rootMessageId?: string;
limit?: number;
accountId?: string;
}): Promise<FeishuThreadMessageInfo[]> {
const { cfg, threadId, currentMessageId, rootMessageId, limit = 20, accountId } = params;
const account = resolveFeishuAccount({ cfg, accountId });
if (!account.configured) {
throw new Error(`Feishu account "${account.accountId}" not configured`);
}
const client = createFeishuClient(account);
const response = (await client.im.message.list({
params: {
container_id_type: "thread",
container_id: threadId,
// Fetch newest messages first so long threads keep the most recent turns.
// Results are reversed below to restore chronological order.
sort_type: "ByCreateTimeDesc",
page_size: Math.min(limit + 1, 50),
},
})) as {
code?: number;
msg?: string;
data?: {
items?: Array<
{
message_id?: string;
root_id?: string;
parent_id?: string;
} & FeishuMessageGetItem
>;
};
};
if (response.code !== 0) {
throw new Error(
`Feishu thread list failed: code=${response.code} msg=${response.msg ?? "unknown"}`,
);
}
const items = response.data?.items ?? [];
const results: FeishuThreadMessageInfo[] = [];
for (const item of items) {
if (currentMessageId && item.message_id === currentMessageId) continue;
if (rootMessageId && item.message_id === rootMessageId) continue;
const parsed = parseFeishuMessageItem(item);
results.push({
messageId: parsed.messageId,
senderId: parsed.senderId,
senderType: parsed.senderType,
content: parsed.content,
contentType: parsed.contentType,
createTime: parsed.createTime,
});
if (results.length >= limit) break;
}
// Restore chronological order (oldest first) since we fetched newest-first.
results.reverse();
return results;
}
export type SendFeishuMessageParams = {
cfg: ClawdbotConfig;
to: string;

View File

@ -72,6 +72,8 @@ export type FeishuMessageInfo = {
content: string;
contentType: string;
createTime?: number;
/** Feishu thread ID (omt_xxx) — present when the message belongs to a topic thread. */
threadId?: string;
};
export type FeishuProbeResult = BaseProbeResult<string> & {

View File

@ -88,6 +88,8 @@ fi
pnpm -s exec tsc -p "$A2UI_RENDERER_DIR/tsconfig.json"
if command -v rolldown >/dev/null 2>&1 && rolldown --version >/dev/null 2>&1; then
rolldown -c "$A2UI_APP_DIR/rolldown.config.mjs"
elif [[ -f "$ROOT_DIR/node_modules/.pnpm/node_modules/rolldown/bin/cli.mjs" ]]; then
node "$ROOT_DIR/node_modules/.pnpm/node_modules/rolldown/bin/cli.mjs" -c "$A2UI_APP_DIR/rolldown.config.mjs"
elif [[ -f "$ROOT_DIR/node_modules/.pnpm/rolldown@1.0.0-rc.9/node_modules/rolldown/bin/cli.mjs" ]]; then
node "$ROOT_DIR/node_modules/.pnpm/rolldown@1.0.0-rc.9/node_modules/rolldown/bin/cli.mjs" \
-c "$A2UI_APP_DIR/rolldown.config.mjs"