mirror of https://github.com/openclaw/openclaw.git
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:
parent
3704293e6f
commit
8a607d7553
|
|
@ -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)
|
- 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)
|
- 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.
|
- 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.
|
- 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
|
## 2026.3.13
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,12 @@ const {
|
||||||
mockCreateFeishuReplyDispatcher,
|
mockCreateFeishuReplyDispatcher,
|
||||||
mockSendMessageFeishu,
|
mockSendMessageFeishu,
|
||||||
mockGetMessageFeishu,
|
mockGetMessageFeishu,
|
||||||
|
mockListFeishuThreadMessages,
|
||||||
mockDownloadMessageResourceFeishu,
|
mockDownloadMessageResourceFeishu,
|
||||||
mockCreateFeishuClient,
|
mockCreateFeishuClient,
|
||||||
mockResolveAgentRoute,
|
mockResolveAgentRoute,
|
||||||
|
mockReadSessionUpdatedAt,
|
||||||
|
mockResolveStorePath,
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
mockCreateFeishuReplyDispatcher: vi.fn(() => ({
|
mockCreateFeishuReplyDispatcher: vi.fn(() => ({
|
||||||
dispatcher: vi.fn(),
|
dispatcher: vi.fn(),
|
||||||
|
|
@ -26,6 +29,7 @@ const {
|
||||||
})),
|
})),
|
||||||
mockSendMessageFeishu: vi.fn().mockResolvedValue({ messageId: "pairing-msg", chatId: "oc-dm" }),
|
mockSendMessageFeishu: vi.fn().mockResolvedValue({ messageId: "pairing-msg", chatId: "oc-dm" }),
|
||||||
mockGetMessageFeishu: vi.fn().mockResolvedValue(null),
|
mockGetMessageFeishu: vi.fn().mockResolvedValue(null),
|
||||||
|
mockListFeishuThreadMessages: vi.fn().mockResolvedValue([]),
|
||||||
mockDownloadMessageResourceFeishu: vi.fn().mockResolvedValue({
|
mockDownloadMessageResourceFeishu: vi.fn().mockResolvedValue({
|
||||||
buffer: Buffer.from("video"),
|
buffer: Buffer.from("video"),
|
||||||
contentType: "video/mp4",
|
contentType: "video/mp4",
|
||||||
|
|
@ -40,6 +44,8 @@ const {
|
||||||
mainSessionKey: "agent:main:main",
|
mainSessionKey: "agent:main:main",
|
||||||
matchedBy: "default",
|
matchedBy: "default",
|
||||||
})),
|
})),
|
||||||
|
mockReadSessionUpdatedAt: vi.fn(),
|
||||||
|
mockResolveStorePath: vi.fn(() => "/tmp/feishu-sessions.json"),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./reply-dispatcher.js", () => ({
|
vi.mock("./reply-dispatcher.js", () => ({
|
||||||
|
|
@ -49,6 +55,7 @@ vi.mock("./reply-dispatcher.js", () => ({
|
||||||
vi.mock("./send.js", () => ({
|
vi.mock("./send.js", () => ({
|
||||||
sendMessageFeishu: mockSendMessageFeishu,
|
sendMessageFeishu: mockSendMessageFeishu,
|
||||||
getMessageFeishu: mockGetMessageFeishu,
|
getMessageFeishu: mockGetMessageFeishu,
|
||||||
|
listFeishuThreadMessages: mockListFeishuThreadMessages,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./media.js", () => ({
|
vi.mock("./media.js", () => ({
|
||||||
|
|
@ -140,6 +147,8 @@ describe("handleFeishuMessage command authorization", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockShouldComputeCommandAuthorized.mockReset().mockReturnValue(true);
|
mockShouldComputeCommandAuthorized.mockReset().mockReturnValue(true);
|
||||||
|
mockReadSessionUpdatedAt.mockReturnValue(undefined);
|
||||||
|
mockResolveStorePath.mockReturnValue("/tmp/feishu-sessions.json");
|
||||||
mockResolveAgentRoute.mockReturnValue({
|
mockResolveAgentRoute.mockReturnValue({
|
||||||
agentId: "main",
|
agentId: "main",
|
||||||
channel: "feishu",
|
channel: "feishu",
|
||||||
|
|
@ -166,6 +175,12 @@ describe("handleFeishuMessage command authorization", () => {
|
||||||
resolveAgentRoute:
|
resolveAgentRoute:
|
||||||
mockResolveAgentRoute as unknown as PluginRuntime["channel"]["routing"]["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: {
|
reply: {
|
||||||
resolveEnvelopeFormatOptions: vi.fn(
|
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 () => {
|
it("does not dispatch twice for the same image message_id (concurrent dedupe)", async () => {
|
||||||
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ import {
|
||||||
import { parsePostContent } from "./post.js";
|
import { parsePostContent } from "./post.js";
|
||||||
import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
|
import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
|
||||||
import { getFeishuRuntime } from "./runtime.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 { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js";
|
||||||
import type { DynamicAgentCreationConfig } from "./types.js";
|
import type { DynamicAgentCreationConfig } from "./types.js";
|
||||||
|
|
||||||
|
|
@ -1239,16 +1239,17 @@ export async function handleFeishuMessage(params: {
|
||||||
const mediaPayload = buildAgentMediaPayload(mediaList);
|
const mediaPayload = buildAgentMediaPayload(mediaList);
|
||||||
|
|
||||||
// Fetch quoted/replied message content if parentId exists
|
// Fetch quoted/replied message content if parentId exists
|
||||||
|
let quotedMessageInfo: Awaited<ReturnType<typeof getMessageFeishu>> = null;
|
||||||
let quotedContent: string | undefined;
|
let quotedContent: string | undefined;
|
||||||
if (ctx.parentId) {
|
if (ctx.parentId) {
|
||||||
try {
|
try {
|
||||||
const quotedMsg = await getMessageFeishu({
|
quotedMessageInfo = await getMessageFeishu({
|
||||||
cfg,
|
cfg,
|
||||||
messageId: ctx.parentId,
|
messageId: ctx.parentId,
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
});
|
});
|
||||||
if (quotedMsg) {
|
if (quotedMessageInfo) {
|
||||||
quotedContent = quotedMsg.content;
|
quotedContent = quotedMessageInfo.content;
|
||||||
log(
|
log(
|
||||||
`feishu[${account.accountId}]: fetched quoted message: ${quotedContent?.slice(0, 100)}`,
|
`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 envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
||||||
const messageBody = buildFeishuAgentBody({
|
const messageBody = buildFeishuAgentBody({
|
||||||
ctx,
|
ctx,
|
||||||
|
|
@ -1309,13 +1315,140 @@ export async function handleFeishuMessage(params: {
|
||||||
}))
|
}))
|
||||||
: undefined;
|
: 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 ---
|
// --- Shared context builder for dispatch ---
|
||||||
const buildCtxPayloadForAgent = (
|
const buildCtxPayloadForAgent = async (
|
||||||
|
agentId: string,
|
||||||
agentSessionKey: string,
|
agentSessionKey: string,
|
||||||
agentAccountId: string,
|
agentAccountId: string,
|
||||||
wasMentioned: boolean,
|
wasMentioned: boolean,
|
||||||
) =>
|
) => {
|
||||||
core.channel.reply.finalizeInboundContext({
|
const threadContext = await resolveThreadContextForAgent(agentId, agentSessionKey);
|
||||||
|
return core.channel.reply.finalizeInboundContext({
|
||||||
Body: combinedBody,
|
Body: combinedBody,
|
||||||
BodyForAgent: messageBody,
|
BodyForAgent: messageBody,
|
||||||
InboundHistory: inboundHistory,
|
InboundHistory: inboundHistory,
|
||||||
|
|
@ -1335,6 +1468,12 @@ export async function handleFeishuMessage(params: {
|
||||||
Surface: "feishu" as const,
|
Surface: "feishu" as const,
|
||||||
MessageSid: ctx.messageId,
|
MessageSid: ctx.messageId,
|
||||||
ReplyToBody: quotedContent ?? undefined,
|
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(),
|
Timestamp: Date.now(),
|
||||||
WasMentioned: wasMentioned,
|
WasMentioned: wasMentioned,
|
||||||
CommandAuthorized: commandAuthorized,
|
CommandAuthorized: commandAuthorized,
|
||||||
|
|
@ -1343,6 +1482,7 @@ export async function handleFeishuMessage(params: {
|
||||||
GroupSystemPrompt: isGroup ? groupConfig?.systemPrompt?.trim() || undefined : undefined,
|
GroupSystemPrompt: isGroup ? groupConfig?.systemPrompt?.trim() || undefined : undefined,
|
||||||
...mediaPayload,
|
...mediaPayload,
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Parse message create_time (Feishu uses millisecond epoch string).
|
// Parse message create_time (Feishu uses millisecond epoch string).
|
||||||
const messageCreateTimeMs = event.message.create_time
|
const messageCreateTimeMs = event.message.create_time
|
||||||
|
|
@ -1402,7 +1542,8 @@ export async function handleFeishuMessage(params: {
|
||||||
}
|
}
|
||||||
|
|
||||||
const agentSessionKey = buildBroadcastSessionKey(route.sessionKey, route.agentId, agentId);
|
const agentSessionKey = buildBroadcastSessionKey(route.sessionKey, route.agentId, agentId);
|
||||||
const agentCtx = buildCtxPayloadForAgent(
|
const agentCtx = await buildCtxPayloadForAgent(
|
||||||
|
agentId,
|
||||||
agentSessionKey,
|
agentSessionKey,
|
||||||
route.accountId,
|
route.accountId,
|
||||||
ctx.mentionedBot && agentId === activeAgentId,
|
ctx.mentionedBot && agentId === activeAgentId,
|
||||||
|
|
@ -1502,7 +1643,8 @@ export async function handleFeishuMessage(params: {
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// --- Single-agent dispatch (existing behavior) ---
|
// --- Single-agent dispatch (existing behavior) ---
|
||||||
const ctxPayload = buildCtxPayloadForAgent(
|
const ctxPayload = await buildCtxPayloadForAgent(
|
||||||
|
route.agentId,
|
||||||
route.sessionKey,
|
route.sessionKey,
|
||||||
route.accountId,
|
route.accountId,
|
||||||
ctx.mentionedBot,
|
ctx.mentionedBot,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
|
import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
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(() => ({
|
const { mockClientGet, mockClientList, mockCreateFeishuClient, mockResolveFeishuAccount } =
|
||||||
|
vi.hoisted(() => ({
|
||||||
mockClientGet: vi.fn(),
|
mockClientGet: vi.fn(),
|
||||||
|
mockClientList: vi.fn(),
|
||||||
mockCreateFeishuClient: vi.fn(),
|
mockCreateFeishuClient: vi.fn(),
|
||||||
mockResolveFeishuAccount: vi.fn(),
|
mockResolveFeishuAccount: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
@ -27,6 +29,7 @@ describe("getMessageFeishu", () => {
|
||||||
im: {
|
im: {
|
||||||
message: {
|
message: {
|
||||||
get: mockClientGet,
|
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",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ type FeishuMessageGetItem = {
|
||||||
message_id?: string;
|
message_id?: string;
|
||||||
chat_id?: string;
|
chat_id?: string;
|
||||||
chat_type?: FeishuChatType;
|
chat_type?: FeishuChatType;
|
||||||
|
thread_id?: string;
|
||||||
msg_type?: string;
|
msg_type?: string;
|
||||||
body?: { content?: string };
|
body?: { content?: string };
|
||||||
sender?: FeishuMessageSender;
|
sender?: FeishuMessageSender;
|
||||||
|
|
@ -151,13 +152,19 @@ function parseInteractiveCardContent(parsed: unknown): string {
|
||||||
return "[Interactive Card]";
|
return "[Interactive Card]";
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidate = parsed as { elements?: unknown };
|
// Support both schema 1.0 (top-level `elements`) and 2.0 (`body.elements`).
|
||||||
if (!Array.isArray(candidate.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]";
|
return "[Interactive Card]";
|
||||||
}
|
}
|
||||||
|
|
||||||
const texts: string[] = [];
|
const texts: string[] = [];
|
||||||
for (const element of candidate.elements) {
|
for (const element of elements) {
|
||||||
if (!element || typeof element !== "object") {
|
if (!element || typeof element !== "object") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -177,7 +184,7 @@ function parseInteractiveCardContent(parsed: unknown): string {
|
||||||
return texts.join("\n").trim() || "[Interactive Card]";
|
return texts.join("\n").trim() || "[Interactive Card]";
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseQuotedMessageContent(rawContent: string, msgType: string): string {
|
function parseFeishuMessageContent(rawContent: string, msgType: string): string {
|
||||||
if (!rawContent) {
|
if (!rawContent) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
@ -218,6 +225,30 @@ function parseQuotedMessageContent(rawContent: string, msgType: string): string
|
||||||
return `[${msgType || "unknown"} message]`;
|
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.
|
* Get a message by its ID.
|
||||||
* Useful for fetching quoted/replied message content.
|
* Useful for fetching quoted/replied message content.
|
||||||
|
|
@ -255,29 +286,98 @@ export async function getMessageFeishu(params: {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const msgType = item.msg_type ?? "text";
|
return parseFeishuMessageItem(item, messageId);
|
||||||
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,
|
|
||||||
};
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
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 = {
|
export type SendFeishuMessageParams = {
|
||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
to: string;
|
to: string;
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,8 @@ export type FeishuMessageInfo = {
|
||||||
content: string;
|
content: string;
|
||||||
contentType: string;
|
contentType: string;
|
||||||
createTime?: number;
|
createTime?: number;
|
||||||
|
/** Feishu thread ID (omt_xxx) — present when the message belongs to a topic thread. */
|
||||||
|
threadId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FeishuProbeResult = BaseProbeResult<string> & {
|
export type FeishuProbeResult = BaseProbeResult<string> & {
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,8 @@ fi
|
||||||
pnpm -s exec tsc -p "$A2UI_RENDERER_DIR/tsconfig.json"
|
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
|
if command -v rolldown >/dev/null 2>&1 && rolldown --version >/dev/null 2>&1; then
|
||||||
rolldown -c "$A2UI_APP_DIR/rolldown.config.mjs"
|
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
|
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" \
|
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"
|
-c "$A2UI_APP_DIR/rolldown.config.mjs"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue