fix(feishu): use message create_time for inbound timestamps (#52809)

* fix(feishu): use message create_time instead of Date.now() for Timestamp field

When a message is sent offline and later retried by the Feishu client
upon reconnection, Date.now() captures the *delivery* time rather than
the *authoring* time.  This causes downstream consumers to see a
timestamp that can be minutes or hours after the user actually composed
the message, leading to incorrect temporal semantics — for example, a
"delete this" command may target the wrong resource because the agent
believes the instruction was issued much later than it actually was.

Replace every Date.now() used for message timestamps with the original
create_time from the Feishu event payload (millisecond-epoch string),
falling back to Date.now() only when the field is absent.  The
definition is also hoisted to the top of handleFeishuMessage so that
both the pending-history path and the main inbound-payload path share
the same authoritative value.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(feishu): verify Timestamp uses message create_time

Add two test cases:
1. When create_time is present, Timestamp must equal the parsed value
2. When create_time is absent, Timestamp falls back to Date.now()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: revert unrelated formatting change to lifecycle.test.ts

This file was inadvertently formatted in a prior commit. Reverting to
match main and keep the PR scoped to the Feishu timestamp fix only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(feishu): use message create_time for inbound timestamps (#52809) (thanks @schumilin)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: George Zhang <georgezhangtj97@gmail.com>
This commit is contained in:
Lin Z
2026-03-25 23:36:12 +08:00
committed by GitHub
parent bd4237c16c
commit a0b9dc0078
3 changed files with 82 additions and 6 deletions

View File

@@ -733,6 +733,77 @@ describe("handleFeishuMessage command authorization", () => {
);
});
it("uses message create_time as Timestamp instead of Date.now()", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
const cfg: ClawdbotConfig = {
channels: {
feishu: {
dmPolicy: "open",
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: "ou-attacker",
},
},
message: {
message_id: "msg-create-time",
chat_id: "oc-dm",
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({ text: "delete this" }),
create_time: "1700000000000",
},
};
await dispatchMessage({ cfg, event });
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
Timestamp: 1700000000000,
}),
);
});
it("falls back to Date.now() when create_time is absent", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
const cfg: ClawdbotConfig = {
channels: {
feishu: {
dmPolicy: "open",
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: "ou-attacker",
},
},
message: {
message_id: "msg-no-create-time",
chat_id: "oc-dm",
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({ text: "hello" }),
},
};
const before = Date.now();
await dispatchMessage({ cfg, event });
const after = Date.now();
const call = mockFinalizeInboundContext.mock.calls[0]?.[0] as { Timestamp: number };
expect(call.Timestamp).toBeGreaterThanOrEqual(before);
expect(call.Timestamp).toBeLessThanOrEqual(after);
});
it("replies pairing challenge to DM chat_id instead of user:sender id", async () => {
const cfg: ClawdbotConfig = {
channels: {

View File

@@ -357,6 +357,14 @@ export async function handleFeishuMessage(params: {
? [...new Set(rawBroadcastAgents.map((id) => normalizeAgentId(id)))]
: null;
// Parse message create_time early so every downstream consumer (pending
// history, inbound payload, etc.) uses the original authoring timestamp
// instead of the delivery/processing time. Feishu uses a millisecond
// epoch string; fall back to Date.now() only when the field is absent.
const messageCreateTimeMs = event.message.create_time
? parseInt(event.message.create_time, 10)
: Date.now();
let requireMention = false; // DMs never require mention; groups may override below
if (isGroup) {
if (groupConfig?.enabled === false) {
@@ -434,7 +442,7 @@ export async function handleFeishuMessage(params: {
entry: {
sender: ctx.senderOpenId,
body: `${ctx.senderName ?? ctx.senderOpenId}: ${ctx.content}`,
timestamp: Date.now(),
timestamp: messageCreateTimeMs,
messageId: ctx.messageId,
},
});
@@ -919,7 +927,7 @@ export async function handleFeishuMessage(params: {
// 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: messageCreateTimeMs,
WasMentioned: wasMentioned,
CommandAuthorized: commandAuthorized,
OriginatingChannel: "feishu" as const,
@@ -929,10 +937,6 @@ export async function handleFeishuMessage(params: {
});
};
// Parse message create_time (Feishu uses millisecond epoch string).
const messageCreateTimeMs = event.message.create_time
? parseInt(event.message.create_time, 10)
: undefined;
// Determine reply target based on group session mode:
// - Topic-mode groups (group_topic / group_topic_sender): reply to the topic
// root so the bot stays in the same thread.