Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions src/__tests__/message-time.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* Tests for formatCreateTime — 飞书消息时间戳格式化
*
* 将毫秒级时间戳字符串格式化为可读的时间前缀:
* - 同一天: "[HH:MM]"
* - 同年跨天: "[MM-DD HH:MM]"
* - 跨年: "[YYYY-MM-DD HH:MM]"
* - 使用 UTC+8 时区
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { _testFormatCreateTime as formatCreateTime } from '../feishu/event-handler.js';

// 固定 "当前时间" 为 2026-03-10 15:30:00 UTC+8 = 2026-03-10T07:30:00Z
const NOW_UTC = new Date('2026-03-10T07:30:00Z').getTime();

describe('formatCreateTime', () => {
afterEach(() => {
vi.useRealTimers();
});

function withFakeNow(fn: () => void) {
vi.useFakeTimers();
vi.setSystemTime(NOW_UTC);
fn();
}

it('returns undefined for missing or invalid input', () => {
expect(formatCreateTime(undefined)).toBeUndefined();
expect(formatCreateTime('')).toBeUndefined();
expect(formatCreateTime('abc')).toBeUndefined();
expect(formatCreateTime('0')).toBeUndefined();
});

it('formats same-day message as [HH:MM]', () => {
withFakeNow(() => {
// 2026-03-10 11:05:00 UTC+8 = 2026-03-10T03:05:00Z
const ts = String(new Date('2026-03-10T03:05:00Z').getTime());
expect(formatCreateTime(ts)).toBe('[11:05]');
});
});

it('formats same-day message with zero-padded hours', () => {
withFakeNow(() => {
// 2026-03-10 08:03:00 UTC+8 = 2026-03-10T00:03:00Z
const ts = String(new Date('2026-03-10T00:03:00Z').getTime());
expect(formatCreateTime(ts)).toBe('[08:03]');
});
});

it('formats cross-day same-year message as [MM-DD HH:MM]', () => {
withFakeNow(() => {
// 2026-03-09 22:30:00 UTC+8 = 2026-03-09T14:30:00Z
const ts = String(new Date('2026-03-09T14:30:00Z').getTime());
expect(formatCreateTime(ts)).toBe('[03-09 22:30]');
});
});

it('formats cross-year message as [YYYY-MM-DD HH:MM]', () => {
withFakeNow(() => {
// 2025-12-31 23:59:00 UTC+8 = 2025-12-31T15:59:00Z
const ts = String(new Date('2025-12-31T15:59:00Z').getTime());
expect(formatCreateTime(ts)).toBe('[2025-12-31 23:59]');
});
});

it('handles UTC+8 date boundary correctly (UTC is previous day but UTC+8 is same day)', () => {
withFakeNow(() => {
// "Now" is 2026-03-10 15:30 UTC+8
// Message at 2026-03-10 00:30:00 UTC+8 = 2026-03-09T16:30:00Z
// In UTC this is Mar 9, but in UTC+8 it's Mar 10 (same day as "now")
const ts = String(new Date('2026-03-09T16:30:00Z').getTime());
expect(formatCreateTime(ts)).toBe('[00:30]');
});
});
});
5 changes: 3 additions & 2 deletions src/feishu/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,7 +442,7 @@ export class FeishuClient {
containerId: string,
containerType: 'chat' | 'thread' = 'chat',
limit: number = 10,
): Promise<Array<{ messageId: string; senderId: string; senderType: 'user' | 'app'; content: string; msgType: string }>> {
): Promise<Array<{ messageId: string; senderId: string; senderType: 'user' | 'app'; content: string; msgType: string; createTime?: string }>> {
try {
const resp = await this.client.im.message.list({
params: {
Expand All @@ -457,7 +457,7 @@ export class FeishuClient {
return [];
}
const items = resp.data?.items ?? [];
const messages: Array<{ messageId: string; senderId: string; senderType: 'user' | 'app'; content: string; msgType: string }> = [];
const messages: Array<{ messageId: string; senderId: string; senderType: 'user' | 'app'; content: string; msgType: string; createTime?: string }> = [];
for (const item of items) {
if (item.deleted) continue;
const msgType = item.msg_type ?? '';
Expand Down Expand Up @@ -501,6 +501,7 @@ export class FeishuClient {
senderType,
content: content.trim(),
msgType,
createTime: item.create_time ?? undefined,
});
}
// API 返回的是 ByCreateTimeDesc(最新在前),反转为时间正序
Expand Down
84 changes: 73 additions & 11 deletions src/feishu/event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,8 +319,8 @@ function processQueue(queueKey: string, agentId: AgentId = 'dev'): void {
const useDirectMode = agentCfg?.replyMode === 'direct';

const executeFn = useDirectMode
? executeDirectTask(task.message, task.chatId, task.userId, task.messageId, task.images, agentId, task.threadId, task.rootId)
: executeClaudeTask(task.message, task.chatId, task.userId, task.messageId, task.rootId, task.threadId, task.images, agentId);
? executeDirectTask(task.message, task.chatId, task.userId, task.messageId, task.images, agentId, task.threadId, task.rootId, task.createTime)
: executeClaudeTask(task.message, task.chatId, task.userId, task.messageId, task.rootId, task.threadId, task.images, agentId, task.createTime);

// 注册 task promise:graceful shutdown 时等待结果卡片发送完成
claudeExecutor.registerTask(executeFn);
Expand Down Expand Up @@ -451,6 +451,8 @@ interface ParsedMessage {
images?: ImageAttachment[];
/** 发送者类型: 'user' = 人类用户, 'app' = 应用/机器人 */
senderType?: string;
/** 消息创建时间(毫秒级时间戳字符串,来自飞书 message.create_time) */
createTime?: string;
}

/**
Expand Down Expand Up @@ -483,7 +485,7 @@ async function handleMessageEvent(data: MessageEventData, accountId: string = 'd
const parsed = await parseMessage(data);
if (!parsed) return;

const { text, messageId, userId, chatId, chatType, mentionedBot, rootId, threadId, images, mentions, senderType } = parsed;
const { text, messageId, userId, chatId, chatType, mentionedBot, rootId, threadId, images, mentions, senderType, createTime } = parsed;

// -- 被动收集:消息发送者为 bot 时记录到 registry --
if (senderType === 'app' && userId && chatId) {
Expand Down Expand Up @@ -648,7 +650,7 @@ async function handleMessageEvent(data: MessageEventData, accountId: string = 'd
// enqueue 返回的 Promise 的错误处理在 processQueue/executeClaudeTask 中完成
const isDirectMode = agentConfig?.replyMode === 'direct';
const queueKey = makeQueueKey(chatId, effectiveThreadId, agentId, isDirectMode ? userId : undefined);
taskQueue.enqueue(queueKey, chatId, userId, effectiveText, messageId, rootId, effectiveThreadId, images).catch(() => {});
taskQueue.enqueue(queueKey, chatId, userId, effectiveText, messageId, rootId, effectiveThreadId, images, createTime).catch(() => {});
processQueue(queueKey, agentId);
}

Expand Down Expand Up @@ -975,6 +977,49 @@ async function resolveUserNames(
}));
}

/**
* 将飞书毫秒级时间戳格式化为可读时间字符串。
* 同一天只显示 "HH:MM",跨天显示 "MM-DD HH:MM",跨年显示 "YYYY-MM-DD HH:MM"。
* 使用 UTC+8(中国标准时间)。
*/
function formatCreateTime(createTimeMs?: string): string | undefined {
if (!createTimeMs) return undefined;
const ms = Number(createTimeMs);
if (!ms || isNaN(ms)) return undefined;

const date = new Date(ms);
const now = new Date();

// 转换为 UTC+8 的各分量
const utc8Offset = 8 * 60 * 60 * 1000;
const d = new Date(date.getTime() + utc8Offset);
const n = new Date(now.getTime() + utc8Offset);

const hh = String(d.getUTCHours()).padStart(2, '0');
const mm = String(d.getUTCMinutes()).padStart(2, '0');

// 同一天:只显示时间
if (d.getUTCFullYear() === n.getUTCFullYear() &&
d.getUTCMonth() === n.getUTCMonth() &&
d.getUTCDate() === n.getUTCDate()) {
return `[${hh}:${mm}]`;
}

const mon = String(d.getUTCMonth() + 1).padStart(2, '0');
const day = String(d.getUTCDate()).padStart(2, '0');

// 同一年:显示月-日 时间
if (d.getUTCFullYear() === n.getUTCFullYear()) {
return `[${mon}-${day} ${hh}:${mm}]`;
}

// 跨年:显示完整日期
return `[${d.getUTCFullYear()}-${mon}-${day} ${hh}:${mm}]`;
}

/** 仅测试用:导出 formatCreateTime */
export const _testFormatCreateTime = formatCreateTime;

/**
* 格式化历史消息为上下文文本(共享逻辑)。
*
Expand All @@ -988,7 +1033,7 @@ async function resolveUserNames(
* @param selfBotOpenIds 所有自己管理的 bot open_id 集合(用于区分自己的回复和其他 bot 的回复)
*/
async function formatHistoryMessages(
messages: Array<{ messageId: string; senderId: string; senderType: 'user' | 'app'; content: string; msgType: string }>,
messages: Array<{ messageId: string; senderId: string; senderType: 'user' | 'app'; content: string; msgType: string; createTime?: string }>,
chatId?: string,
selfBotOpenIds?: Set<string>,
): Promise<string | undefined> {
Expand Down Expand Up @@ -1025,7 +1070,9 @@ async function formatHistoryMessages(
const text = m.content.length > maxLen
? m.content.slice(0, maxLen) + '...'
: m.content;
return `${role}: ${text}`;
// 时间前缀:毫秒时间戳 → "HH:MM" (UTC+8)
const timePrefix = formatCreateTime(m.createTime);
return timePrefix ? `${timePrefix} ${role}: ${text}` : `${role}: ${text}`;
});

// 2. 总量保护:如果超出预算,从最旧的消息开始丢弃(保留最近的)
Expand Down Expand Up @@ -1075,6 +1122,7 @@ async function executeClaudeTask(
eventThreadId?: string,
images?: ImageAttachment[],
agentId: AgentId = 'dev',
createTime?: string,
): Promise<void> {
// 1. 解析话题上下文(thread + 路由 + 工作区隔离 + greeting)
const resolved = await resolveThreadContext({
Expand Down Expand Up @@ -1155,10 +1203,16 @@ async function executeClaudeTask(
// 群聊话题中标注发送者身份,与历史消息的 [姓名] 格式保持一致
const senderTaggedPrompt = await tagSenderIdentity(prompt, userId, chatId, !!threadId);

// 在用户消息末尾附带发送时间元数据(不放 system prompt,避免 cache miss)
const msgTimeSuffix = formatCreateTime(createTime);
const promptWithTime = msgTimeSuffix
? `${senderTaggedPrompt}\n\n<msg-time>${msgTimeSuffix}</msg-time>`
: senderTaggedPrompt;

// 每次都注入增量飞书聊天历史,拼入 user prompt(不是 system prompt)
// resume 时通过 afterMsgId 去重,只注入上次交互后新增的消息
// 确保 dev-bot 能看到中间 @其他bot 的对话等未直接参与的消息
let effectivePrompt = senderTaggedPrompt;
let effectivePrompt = promptWithTime;
if (!historySummaries) {
// 收集所有自己管理的 bot open_id,用于历史消息差异化截断
const selfBotOpenIds = accountManager.getAllBotOpenIds();
Expand All @@ -1167,7 +1221,7 @@ async function executeClaudeTask(
const afterMsgId = activeConversationId ? _historyDedup.get(sessionKey) : undefined;
const history = await buildChatHistoryContext(chatId, threadId, messageId, afterMsgId, selfBotOpenIds);
if (history.text) {
effectivePrompt = history.text + '\n\n---\n\n' + senderTaggedPrompt;
effectivePrompt = history.text + '\n\n---\n\n' + promptWithTime;
}
if (history.newestMsgId) {
_historyDedup.set(sessionKey, history.newestMsgId);
Expand Down Expand Up @@ -1533,6 +1587,7 @@ async function executeDirectTask(
agentId: AgentId = 'pm',
eventThreadId?: string,
rootId?: string,
createTime?: string,
): Promise<void> {
const agentCfg = agentRegistry.getOrThrow(agentId);
const session = sessionManager.getOrCreate(chatId, userId, agentId);
Expand Down Expand Up @@ -1599,15 +1654,21 @@ async function executeDirectTask(
// 群聊/话题中标注发送者身份,避免多用户共享 session 时模型混淆对话对象
const senderTaggedPrompt = await tagSenderIdentity(rawPrompt, userId, chatId, !!(eventThreadId || chatId));

// 在用户消息末尾附带发送时间元数据
const msgTimeSuffix = formatCreateTime(createTime);
const promptWithTime = msgTimeSuffix
? `${senderTaggedPrompt}\n\n<msg-time>${msgTimeSuffix}</msg-time>`
: senderTaggedPrompt;

// 每次 @bot 都注入最新聊天历史(resume 时通过 afterMsgId 去重,只注入新消息)
const selfBotOpenIds = accountManager.getAllBotOpenIds();
if (feishuClient.botOpenId) selfBotOpenIds.add(feishuClient.botOpenId);

let effectivePrompt = senderTaggedPrompt;
let effectivePrompt = promptWithTime;
const afterMsgId = activeConversationId ? _historyDedup.get(sessionKey) : undefined;
const history = await buildDirectTaskHistory(chatId, eventThreadId, messageId, afterMsgId, selfBotOpenIds);
if (history.text) {
effectivePrompt = history.text + '\n\n---\n\n' + senderTaggedPrompt;
effectivePrompt = history.text + '\n\n---\n\n' + promptWithTime;
}
if (history.newestMsgId) {
_historyDedup.set(sessionKey, history.newestMsgId);
Expand Down Expand Up @@ -1726,7 +1787,7 @@ async function buildDirectTaskHistory(
selfBotOpenIds?: Set<string>,
): Promise<HistoryResult> {
try {
let messages: Array<{ messageId: string; senderId: string; senderType: 'user' | 'app'; content: string; msgType: string }>;
let messages: Array<{ messageId: string; senderId: string; senderType: 'user' | 'app'; content: string; msgType: string; createTime?: string }>;

if (!threadId) {
// 主聊天区:直接取父群最近消息
Expand Down Expand Up @@ -2124,6 +2185,7 @@ async function parseMessage(data: MessageEventData): Promise<ParsedMessage | nul
threadId: message.thread_id || undefined,
images,
senderType: sender.sender_type,
createTime: message.create_time || undefined,
};
}

Expand Down
2 changes: 2 additions & 0 deletions src/session/queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export class TaskQueue {
rootId?: string,
threadId?: string,
images?: import('../claude/types.js').ImageAttachment[],
createTime?: string,
): Promise<string> {
return new Promise((resolve, reject) => {
const task: QueueTask = {
Expand All @@ -36,6 +37,7 @@ export class TaskQueue {
rootId,
threadId,
images,
createTime,
resolve,
reject,
createdAt: new Date(),
Expand Down
2 changes: 2 additions & 0 deletions src/session/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ export interface QueueTask {
threadId?: string;
/** 图片附件列表 (用户发送图片消息时) */
images?: import('../claude/types.js').ImageAttachment[];
/** 消息创建时间(毫秒级时间戳字符串,来自飞书 message.create_time) */
createTime?: string;
resolve: (result: string) => void;
reject: (error: Error) => void;
createdAt: Date;
Expand Down
Loading