Chat history: extract Teams timestamp, classify pre-join messages as history, no AI trigger

Made-with: Cursor
This commit is contained in:
ValueOn AG 2026-02-27 13:56:20 +01:00
parent 7e62c2fc65
commit 2e2fbfe8ed
3 changed files with 59 additions and 14 deletions

View file

@ -6,6 +6,7 @@ export interface ChatMessageEntry {
text: string; text: string;
timestamp: Date; timestamp: Date;
source: 'chat'; source: 'chat';
isHistory: boolean;
} }
/** /**
@ -20,7 +21,7 @@ export class ChatProcedure {
private _onChatMessage: (entry: ChatMessageEntry) => void; private _onChatMessage: (entry: ChatMessageEntry) => void;
private _isSubscribed: boolean = false; private _isSubscribed: boolean = false;
private _lastMessageText: string = ''; private _lastMessageText: string = '';
private _chatReadyAtMs: number = 0; private _botJoinedAtMs: number = 0;
private _recentMessageKeyTimestamps: Map<string, number> = new Map(); private _recentMessageKeyTimestamps: Map<string, number> = new Map();
constructor( constructor(
@ -33,6 +34,14 @@ export class ChatProcedure {
this._onChatMessage = onChatMessage; this._onChatMessage = onChatMessage;
} }
/**
* Set the bot's join timestamp so we can classify messages as history vs live.
*/
setBotJoinedAt(ms: number): void {
this._botJoinedAtMs = ms;
this._logger.info(`Chat history cutoff set: messages before ${new Date(ms).toISOString()} are history`);
}
/** /**
* Open the chat panel and start monitoring messages. * Open the chat panel and start monitoring messages.
*/ */
@ -121,6 +130,7 @@ export class ChatProcedure {
speaker: string; speaker: string;
text: string; text: string;
timestamp: string; timestamp: string;
teamsTimestamp?: string;
messageKey?: string; messageKey?: string;
}) => { }) => {
this._handleChatMessage(msg); this._handleChatMessage(msg);
@ -156,8 +166,26 @@ export class ChatProcedure {
return noisePatterns.some(p => lower.includes(p)); return noisePatterns.some(p => lower.includes(p));
} }
function _extractTeamsTimestamp(el: HTMLElement): string | undefined {
const timeSelectors = [
'time[datetime]',
'[data-tid="message-timestamp"] time',
'[data-tid*="timestamp"] time',
'[data-tid="message-timestamp"]',
];
for (const sel of timeSelectors) {
const timeEl = el.querySelector(sel) || el.closest?.('[data-tid*="message"]')?.querySelector(sel);
if (timeEl) {
const dt = timeEl.getAttribute('datetime');
if (dt) return dt;
const title = timeEl.getAttribute('title');
if (title) return title;
}
}
return undefined;
}
function _extractChatMessage(el: HTMLElement): boolean { function _extractChatMessage(el: HTMLElement): boolean {
// Strategy 1: Standard selectors
const messageSelectors = [ const messageSelectors = [
'[data-tid="chat-message"]', '[data-tid="chat-message"]',
'.fui-ChatMessage', '.fui-ChatMessage',
@ -203,10 +231,12 @@ export class ChatProcedure {
} }
if (text && text.length > 0) { if (text && text.length > 0) {
const teamsTs = _extractTeamsTimestamp(messageEl) || _extractTeamsTimestamp(el);
(window as any).__onChatMessageEvent({ (window as any).__onChatMessageEvent({
speaker: author, speaker: author,
text, text,
timestamp: new Date().toISOString(), timestamp: teamsTs || new Date().toISOString(),
teamsTimestamp: teamsTs,
messageKey: `${author}::${text}`, messageKey: `${author}::${text}`,
}); });
return true; return true;
@ -239,10 +269,12 @@ export class ChatProcedure {
// Check if this looks like a time-stamped message (not just any two children) // Check if this looks like a time-stamped message (not just any two children)
const hasTid = children[i].getAttribute('data-tid') || ''; const hasTid = children[i].getAttribute('data-tid') || '';
if (hasTid.includes('author') || hasTid.includes('name') || hasTid.includes('sender')) { if (hasTid.includes('author') || hasTid.includes('name') || hasTid.includes('sender')) {
const teamsTs = _extractTeamsTimestamp(el);
(window as any).__onChatMessageEvent({ (window as any).__onChatMessageEvent({
speaker: candidateName, speaker: candidateName,
text: candidateBody, text: candidateBody,
timestamp: new Date().toISOString(), timestamp: teamsTs || new Date().toISOString(),
teamsTimestamp: teamsTs,
messageKey: `${candidateName}::${candidateBody}`, messageKey: `${candidateName}::${candidateBody}`,
}); });
return true; return true;
@ -325,19 +357,21 @@ export class ChatProcedure {
}); });
this._logger.info(`Chat MutationObserver set up (target: ${chatObserverTarget})`); this._logger.info(`Chat MutationObserver set up (target: ${chatObserverTarget})`);
// Guard against Teams replaying historical thread entries as fresh mutations.
this._chatReadyAtMs = Date.now() + 12000;
this._logger.info('Chat monitor warmup active for 12s');
} }
/** /**
* Handle a chat message event from the browser. * Handle a chat message event from the browser.
*/ */
private _handleChatMessage(msg: { speaker: string; text: string; timestamp: string; messageKey?: string }): void { private _handleChatMessage(msg: {
speaker: string;
text: string;
timestamp: string;
teamsTimestamp?: string;
messageKey?: string;
}): void {
if (!this._isSubscribed || !msg.text) return; if (!this._isSubscribed || !msg.text) return;
const nowMs = Date.now(); const nowMs = Date.now();
if (this._chatReadyAtMs > 0 && nowMs < this._chatReadyAtMs) return;
// Dedup // Dedup
if (msg.text === this._lastMessageText) return; if (msg.text === this._lastMessageText) return;
@ -353,13 +387,22 @@ export class ChatProcedure {
} }
this._lastMessageText = msg.text; this._lastMessageText = msg.text;
this._logger.info(`Chat: [${msg.speaker}] ${msg.text}`); // Classify as history (sent before bot joined) vs live
const messageTimestamp = new Date(msg.teamsTimestamp || msg.timestamp);
const messageMs = messageTimestamp.getTime();
const isHistory = this._botJoinedAtMs > 0 && messageMs < this._botJoinedAtMs;
this._logger.info(
`Chat${isHistory ? ' [HISTORY]' : ''}: [${msg.speaker}] ${msg.text}` +
(msg.teamsTimestamp ? ` (teams_ts=${msg.teamsTimestamp})` : ''),
);
this._onChatMessage({ this._onChatMessage({
speaker: msg.speaker, speaker: msg.speaker,
text: msg.text, text: msg.text,
timestamp: new Date(msg.timestamp), timestamp: messageTimestamp,
source: 'chat', source: 'chat',
isHistory,
}); });
} }

View file

@ -1025,8 +1025,7 @@ export class BotOrchestrator {
this._page, this._page,
this._logger, this._logger,
(entry: ChatMessageEntry) => { (entry: ChatMessageEntry) => {
// Send chat message to Gateway as a special transcript this._sendChatMessage(entry.speaker, entry.text, entry.isHistory);
this._sendChatMessage(entry.speaker, entry.text);
this._callbacks.onTranscript({ this._callbacks.onTranscript({
speaker: entry.speaker, speaker: entry.speaker,
text: entry.text, text: entry.text,
@ -1203,6 +1202,7 @@ export class BotOrchestrator {
*/ */
private async _enableChat(): Promise<void> { private async _enableChat(): Promise<void> {
try { try {
this._chatProcedure!.setBotJoinedAt(Date.now());
await this._chatProcedure!.enableChatMonitoring(); await this._chatProcedure!.enableChatMonitoring();
await this._chatProcedure!.subscribeToChatMessages(); await this._chatProcedure!.subscribeToChatMessages();
this._logger.info('Chat monitoring enabled and subscribed'); this._logger.info('Chat monitoring enabled and subscribed');
@ -1252,7 +1252,7 @@ export class BotOrchestrator {
/** /**
* Send a chat message event to the Gateway. * Send a chat message event to the Gateway.
*/ */
private _sendChatMessage(speaker: string, text: string): void { private _sendChatMessage(speaker: string, text: string, isHistory: boolean = false): void {
const message: ChatMessage = { const message: ChatMessage = {
type: 'chatMessage', type: 'chatMessage',
sessionId: this._sessionId, sessionId: this._sessionId,
@ -1260,6 +1260,7 @@ export class BotOrchestrator {
speaker, speaker,
text, text,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
isHistory,
}, },
}; };
this._sendToGateway(message); this._sendToGateway(message);

View file

@ -47,6 +47,7 @@ export interface ChatMessage {
speaker: string; speaker: string;
text: string; text: string;
timestamp: string; timestamp: string;
isHistory?: boolean;
}; };
} }