Skip to content

Commit 43852b2

Browse files
committed
fix: prevent duplicate Matrix messages with simple content+time deduplication
- Add createMessageKey() helper for simple deduplication based on content + sender + timestamp - Apply deduplication in both handleGooseSessionSync and handleRegularMessage handlers - Round timestamps to nearest second to catch near-simultaneous duplicates - Maintain all existing functionality (role detection, sender attribution, metadata) - Fixes issue where single user message from other Goose instances appeared twice in chat Resolves Matrix collaboration message duplication issue.
1 parent 4d1cc38 commit 43852b2

File tree

1 file changed

+228
-23
lines changed

1 file changed

+228
-23
lines changed

ui/desktop/src/hooks/useSessionSharing.ts

Lines changed: 228 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,17 @@ export const useSessionSharing = ({
117117
currentUserRef.current = currentUser;
118118
}, [currentUser]);
119119

120-
// Track processed message IDs to prevent duplicates - ALWAYS call this hook
121-
const processedMessageIds = useRef<Set<string>>(new Set());
120+
// Track processed messages to prevent duplicates - ALWAYS call this hook
121+
const processedMessages = useRef<Set<string>>(new Set());
122+
123+
// Helper function to create a deduplication key based on content and timestamp
124+
const createMessageKey = (content: string, sender: string, timestamp?: number) => {
125+
const time = timestamp || Date.now();
126+
// Round timestamp to nearest second to catch near-simultaneous duplicates
127+
const roundedTime = Math.floor(time / 1000);
128+
// Use first 50 chars of content + sender + rounded timestamp
129+
return `${sender}-${roundedTime}-${content.substring(0, 50)}`;
130+
};
122131

123132
// Listen for session-related Matrix messages
124133
useEffect(() => {
@@ -132,8 +141,18 @@ export const useSessionSharing = ({
132141
sessionId,
133142
roomId: stateRef.current.roomId,
134143
isShared: stateRef.current.isShared,
135-
participantsCount: stateRef.current.participants.length
144+
participantsCount: stateRef.current.participants.length,
145+
onMessageSyncAvailable: !!onMessageSync
136146
});
147+
148+
// Debug: Test the onMessageSync callback immediately
149+
if (onMessageSync) {
150+
console.log('🔧 useSessionSharing: Testing onMessageSync callback...');
151+
// Don't actually call it, just confirm it exists
152+
console.log('🔧 useSessionSharing: onMessageSync callback is available and callable');
153+
} else {
154+
console.warn('⚠️ useSessionSharing: onMessageSync callback is NOT available!');
155+
}
137156

138157
const handleSessionMessage = (data: any) => {
139158
const { content, sender, roomId, senderInfo } = data;
@@ -215,7 +234,9 @@ export const useSessionSharing = ({
215234
isFromCurrentRoom,
216235
isSessionMatch,
217236
shouldProcessMessage,
218-
sender
237+
sender,
238+
messageRole: messageData.role,
239+
messageContent: messageData.content?.substring(0, 50) + '...'
219240
});
220241

221242
if (shouldProcessMessage) {
@@ -239,25 +260,79 @@ export const useSessionSharing = ({
239260
}
240261
}
241262

263+
// Enhanced role detection for session messages
264+
let finalRole = messageData.role as 'user' | 'assistant';
265+
266+
// If the role is 'assistant', double-check that it's actually from a Goose instance
267+
if (finalRole === 'assistant') {
268+
const isFromGoose = senderData?.displayName?.toLowerCase().includes('goose') ||
269+
senderData?.userId?.toLowerCase().includes('goose') ||
270+
messageData.content?.includes('🦆') ||
271+
messageData.content?.includes('🤖');
272+
273+
if (!isFromGoose) {
274+
console.log('🔍 Role correction: Message marked as assistant but not from Goose, changing to user');
275+
finalRole = 'user';
276+
}
277+
}
278+
279+
// If the role is 'user' but content looks like a Goose response, correct it
280+
if (finalRole === 'user') {
281+
const looksLikeGooseResponse = messageData.content && (
282+
messageData.content.includes('🦆') ||
283+
messageData.content.includes('🤖') ||
284+
messageData.content.startsWith('I\'m') ||
285+
messageData.content.includes('I can help') ||
286+
messageData.content.includes('Let me') ||
287+
(messageData.content.length > 100 && messageData.content.includes('\n\n')) ||
288+
/```[\s\S]*```/.test(messageData.content) // Contains code blocks
289+
);
290+
291+
const isFromGoose = senderData?.displayName?.toLowerCase().includes('goose') ||
292+
senderData?.userId?.toLowerCase().includes('goose');
293+
294+
if (looksLikeGooseResponse || isFromGoose) {
295+
console.log('🔍 Role correction: Message marked as user but looks like Goose response, changing to assistant');
296+
finalRole = 'assistant';
297+
}
298+
}
299+
242300
// Convert to local message format with proper sender attribution
243301
const message: Message = {
244302
id: `shared-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
245-
role: messageData.role,
303+
role: finalRole,
246304
created: Math.floor(Date.now() / 1000),
247305
content: [{
248306
type: 'text',
249307
text: messageData.content,
250308
}],
251309
sender: senderData, // Include sender information
310+
metadata: {
311+
originalRole: messageData.role,
312+
correctedRole: finalRole,
313+
isFromMatrix: true,
314+
skipLocalResponse: true, // Prevent triggering local AI response
315+
preventAutoResponse: true,
316+
isFromCollaborator: true,
317+
sessionMessageId: messageData.sessionId
318+
}
252319
};
253320

254-
console.log('💬 Syncing message to local session with sender:', message);
321+
console.log('💬 Syncing session message to local session:', {
322+
messageId: message.id,
323+
originalRole: messageData.role,
324+
finalRole: finalRole,
325+
sender: senderData?.displayName || senderData?.userId,
326+
content: messageData.content?.substring(0, 50) + '...'
327+
});
328+
255329
onMessageSync?.(message);
256330
} else {
257331
console.log('🚫 Skipping session message - not from current room/session');
258332
}
259333
} catch (error) {
260334
console.error('Failed to parse session message:', error);
335+
console.error('Raw content that failed to parse:', content);
261336
}
262337
}
263338
};
@@ -269,18 +344,16 @@ export const useSessionSharing = ({
269344
const currentState = stateRef.current;
270345
const currentUserFromRef = currentUserRef.current;
271346

272-
// Create a unique message ID for deduplication
273-
const messageId = event?.getId?.() || `${sender}-${timestamp?.getTime?.() || Date.now()}-${content?.substring(0, 20)}`;
274-
275-
// Check if we've already processed this message
276-
if (processedMessageIds.current.has(messageId)) {
277-
console.log('🚫 Skipping duplicate message:', messageId);
347+
// Simple deduplication: check if we've seen this exact message content at this time
348+
const messageKey = createMessageKey(content || '', sender, timestamp?.getTime?.());
349+
if (processedMessages.current.has(messageKey)) {
350+
console.log('🚫 Skipping duplicate regular message - same content and time:', messageKey);
278351
return;
279352
}
280353

281354
// Debug logging for all incoming messages to understand the flow
282355
console.log('🔍 handleRegularMessage called:', {
283-
messageId,
356+
messageKey,
284357
content: content?.substring(0, 50) + '...',
285358
sender,
286359
roomId,
@@ -291,16 +364,16 @@ export const useSessionSharing = ({
291364

292365
// Only process messages from Matrix rooms that are part of our session
293366
if (currentState.roomId && roomId === currentState.roomId && sender !== currentUserFromRef?.userId) {
294-
console.log('💬 Processing message in session room:', { messageId, content, sender, roomId, senderInfo });
367+
console.log('💬 Processing message in session room:', { messageKey, content, sender, roomId, senderInfo });
295368

296-
// Skip if this is a goose-session-message (should be handled by handleSessionMessage)
369+
// Skip if this is a goose-session-message (should be handled by handleGooseSessionSync)
297370
if (content && content.includes('goose-session-message:')) {
298-
console.log('🚫 Skipping handleRegularMessage - this is a session message, will be handled by handleSessionMessage');
371+
console.log('🚫 Skipping handleRegularMessage - this is a session message, will be handled by handleGooseSessionSync');
299372
return;
300373
}
301374

302375
// Mark this message as processed
303-
processedMessageIds.current.add(messageId);
376+
processedMessages.current.add(messageKey);
304377

305378
// Find sender info from friends or participants
306379
let senderData = senderInfo;
@@ -430,15 +503,147 @@ export const useSessionSharing = ({
430503
if (currentState.roomId && roomId === currentState.roomId && sender !== currentUserFromRef?.userId) {
431504
console.log('🔄 Processing gooseSessionSync message in session room:', { content, sender, roomId, senderInfo });
432505

433-
// Skip if this is already a goose-session-message (to avoid double processing)
506+
// If this is a goose-session-message, process it here since handleSessionMessage isn't being called
434507
if (content && content.includes('goose-session-message:')) {
435-
console.log('🚫 Skipping gooseSessionSync - already a session message, will be handled by handleSessionMessage');
436-
return;
508+
console.log('🔄 Processing goose-session-message in gooseSessionSync handler');
509+
510+
// Call the same logic as handleSessionMessage for session messages
511+
try {
512+
const messageData = JSON.parse(content.split('goose-session-message:')[1]);
513+
514+
// Simple deduplication: check if we've seen this exact message content at this time
515+
const messageKey = createMessageKey(messageData.content || '', sender, messageData.timestamp);
516+
if (processedMessages.current.has(messageKey)) {
517+
console.log('🚫 Skipping duplicate message - same content and time:', messageKey);
518+
return;
519+
}
520+
521+
// Mark this message as processed
522+
processedMessages.current.add(messageKey);
523+
524+
// In Matrix collaboration, we want to process session messages from the current room only
525+
const isMatrixRoom = sessionId && sessionId.startsWith('!');
526+
const isFromCurrentRoom = !roomId || roomId === sessionId;
527+
const isSessionMatch = messageData.sessionId === sessionId;
528+
529+
// For Matrix rooms, prioritize room ID matching over session ID matching
530+
const shouldProcessMessage = isMatrixRoom ? isFromCurrentRoom : (isSessionMatch || isFromCurrentRoom);
531+
532+
console.log('🔍 Session message processing check (gooseSessionSync):', {
533+
messageSessionId: messageData.sessionId,
534+
currentSessionId: sessionId,
535+
messageRoomId: roomId,
536+
isMatrixRoom,
537+
isFromCurrentRoom,
538+
isSessionMatch,
539+
shouldProcessMessage,
540+
sender,
541+
messageRole: messageData.role,
542+
messageContent: messageData.content?.substring(0, 50) + '...'
543+
});
544+
545+
if (shouldProcessMessage) {
546+
// Get sender information for proper attribution
547+
let senderData = senderInfo;
548+
if (!senderData && sender) {
549+
// Try to find sender in friends list
550+
const friend = friendsRef.current.find(f => f.userId === sender);
551+
if (friend) {
552+
senderData = {
553+
userId: friend.userId,
554+
displayName: friend.displayName,
555+
avatarUrl: friend.avatarUrl,
556+
};
557+
} else {
558+
// Fallback to basic sender info from Matrix ID
559+
senderData = {
560+
userId: sender,
561+
displayName: sender.split(':')[0].substring(1), // Extract username from Matrix ID
562+
};
563+
}
564+
}
565+
566+
// Enhanced role detection for session messages
567+
let finalRole = messageData.role as 'user' | 'assistant';
568+
569+
// If the role is 'assistant', double-check that it's actually from a Goose instance
570+
if (finalRole === 'assistant') {
571+
const isFromGoose = senderData?.displayName?.toLowerCase().includes('goose') ||
572+
senderData?.userId?.toLowerCase().includes('goose') ||
573+
messageData.content?.includes('🦆') ||
574+
messageData.content?.includes('🤖');
575+
576+
if (!isFromGoose) {
577+
console.log('🔍 Role correction: Message marked as assistant but not from Goose, changing to user');
578+
finalRole = 'user';
579+
}
580+
}
581+
582+
// If the role is 'user' but content looks like a Goose response, correct it
583+
if (finalRole === 'user') {
584+
const looksLikeGooseResponse = messageData.content && (
585+
messageData.content.includes('🦆') ||
586+
messageData.content.includes('🤖') ||
587+
messageData.content.startsWith('I\'m') ||
588+
messageData.content.includes('I can help') ||
589+
messageData.content.includes('Let me') ||
590+
(messageData.content.length > 100 && messageData.content.includes('\n\n')) ||
591+
/```[\s\S]*```/.test(messageData.content) // Contains code blocks
592+
);
593+
594+
const isFromGoose = senderData?.displayName?.toLowerCase().includes('goose') ||
595+
senderData?.userId?.toLowerCase().includes('goose');
596+
597+
if (looksLikeGooseResponse || isFromGoose) {
598+
console.log('🔍 Role correction: Message marked as user but looks like Goose response, changing to assistant');
599+
finalRole = 'assistant';
600+
}
601+
}
602+
603+
// Convert to local message format with proper sender attribution
604+
const message: Message = {
605+
id: `shared-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
606+
role: finalRole,
607+
created: Math.floor(Date.now() / 1000),
608+
content: [{
609+
type: 'text',
610+
text: messageData.content,
611+
}],
612+
sender: senderData, // Include sender information
613+
metadata: {
614+
originalRole: messageData.role,
615+
correctedRole: finalRole,
616+
isFromMatrix: true,
617+
skipLocalResponse: true, // Prevent triggering local AI response
618+
preventAutoResponse: true,
619+
isFromCollaborator: true,
620+
sessionMessageId: messageData.sessionId
621+
}
622+
};
623+
624+
console.log('💬 *** PROCESSING SESSION MESSAGE IN GOOSE SESSION SYNC ***:', {
625+
messageId: message.id,
626+
originalRole: messageData.role,
627+
finalRole: finalRole,
628+
sender: senderData?.displayName || senderData?.userId,
629+
content: messageData.content?.substring(0, 50) + '...'
630+
});
631+
632+
console.log('💬 *** CALLING onMessageSync FROM GOOSE SESSION SYNC ***');
633+
onMessageSync?.(message);
634+
} else {
635+
console.log('🚫 Skipping session message - not from current room/session (gooseSessionSync)');
636+
}
637+
} catch (error) {
638+
console.error('Failed to parse session message in gooseSessionSync:', error);
639+
console.error('Raw content that failed to parse:', content);
640+
}
641+
642+
return; // Exit early after processing session message
437643
}
438644

439-
// DON'T call handleRegularMessage here - it will be handled by the regular message handler
440-
// This was causing duplicate messages because both handlers were processing the same message
441-
console.log('🚫 Skipping gooseSessionSync processing - regular message handler will process this to avoid duplication');
645+
// For non-session messages, let the regular message handler process them
646+
console.log('🔄 Non-session message in gooseSessionSync - letting regular handler process');
442647
} else {
443648
console.log('🚫 Skipping gooseSessionSync - not from current session room or from self');
444649
}

0 commit comments

Comments
 (0)