From 83f9792b2262c96ff496635528bc331a9bbbed8e Mon Sep 17 00:00:00 2001 From: Anabelle Handdoek Date: Sat, 18 Oct 2025 09:40:49 -0500 Subject: [PATCH 1/3] Fix duplicate replies to home feed posts Add reply deduplication logic in processHomeFeed function to prevent the agent from replying multiple times to the same home feed post. The fix checks for existing replies in memory before posting a new reply, similar to the deduplication logic used for mention handling. This resolves issue #71: Bug: Duplicate replies to home feed posts - Added deduplication check before calling postReply in home feed reply logic - Check looks for existing replies in recent memory (up to 100 messages) - Uses the same pattern as mention handling for consistency --- plugin-nostr/lib/service.js | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/plugin-nostr/lib/service.js b/plugin-nostr/lib/service.js index 64b2be0..25154be 100644 --- a/plugin-nostr/lib/service.js +++ b/plugin-nostr/lib/service.js @@ -5873,14 +5873,24 @@ Response (YES/NO):`; const text = await this.generateReplyTextLLM(evt, roomId, threadContext, imageContext); - // Check if LLM generation failed (returned null) - if (!text || !text.trim()) { - logger.warn(`[NOSTR] Skipping home feed reply to ${evt.id.slice(0, 8)} - LLM generation failed`); - success = false; - break; - } - - success = await this.postReply(evt, text); + // Check if LLM generation failed (returned null) + if (!text || !text.trim()) { + logger.warn(`[NOSTR] Skipping home feed reply to ${evt.id.slice(0, 8)} - LLM generation failed`); + success = false; + break; + } + + // Check if we've already replied to this event + const eventMemoryId = this.createUniqueUuid(this.runtime, evt.id); + const recent = await this.runtime.getMemories({ tableName: 'messages', roomId, count: 100 }); + const hasReply = recent.some((m) => m.content?.inReplyTo === eventMemoryId || m.content?.inReplyTo === evt.id); + if (hasReply) { + logger.info(`[NOSTR] Skipping home feed reply to ${evt.id.slice(0, 8)} (found existing reply)`); + success = false; + break; + } + + success = await this.postReply(evt, text); break; } } From 6851a864addee60b00a90bea4305f7542611a639 Mon Sep 17 00:00:00 2001 From: Anabelle Handdoek Date: Sat, 18 Oct 2025 09:42:37 -0500 Subject: [PATCH 2/3] Address PR review feedback - Move deduplication check before LLM call to prevent unnecessary API usage - Clean up test code by removing console.log statements and refactoring duplicated mocks - Add helper function for test setup to improve maintainability --- plugin-nostr/lib/service.js | 64 ++++---- .../service.homeFeedDeduplication.test.js | 150 ++++++++++++++++++ 2 files changed, 182 insertions(+), 32 deletions(-) create mode 100644 plugin-nostr/test/service.homeFeedDeduplication.test.js diff --git a/plugin-nostr/lib/service.js b/plugin-nostr/lib/service.js index 25154be..9dfaf02 100644 --- a/plugin-nostr/lib/service.js +++ b/plugin-nostr/lib/service.js @@ -5845,42 +5845,15 @@ Response (YES/NO):`; const convId = this._getConversationIdFromEvent(evt); const { roomId } = await this._ensureNostrContext(evt.pubkey, undefined, convId); - // Decide whether to engage based on thread context - const shouldEngage = this._shouldEngageWithThread(evt, threadContext); - if (!shouldEngage) { - logger.debug(`[NOSTR] Home feed skipping reply to ${evt.id.slice(0, 8)} after thread analysis - not suitable for engagement`); - success = false; - break; - } - - // Process images in home feed post content (if enabled) - let imageContext = { imageDescriptions: [], imageUrls: [] }; - if (this.imageProcessingEnabled) { - try { - logger.info(`[NOSTR] Processing images in home feed post: "${evt.content?.slice(0, 200)}..."`); - const { processImageContent } = require('./image-vision'); - const fullImageContext = await processImageContent(evt.content || '', this.runtime); - imageContext = { - imageDescriptions: fullImageContext.imageDescriptions.slice(0, this.maxImagesPerMessage), - imageUrls: fullImageContext.imageUrls.slice(0, this.maxImagesPerMessage) - }; - logger.info(`[NOSTR] Processed ${imageContext.imageDescriptions.length} images from home feed post`); - } catch (error) { - logger.error(`[NOSTR] Error in home feed image processing: ${error.message || error}`); - imageContext = { imageDescriptions: [], imageUrls: [] }; - } - } - - const text = await this.generateReplyTextLLM(evt, roomId, threadContext, imageContext); - - // Check if LLM generation failed (returned null) - if (!text || !text.trim()) { - logger.warn(`[NOSTR] Skipping home feed reply to ${evt.id.slice(0, 8)} - LLM generation failed`); + // Decide whether to engage based on thread context + const shouldEngage = this._shouldEngageWithThread(evt, threadContext); + if (!shouldEngage) { + logger.debug(`[NOSTR] Home feed skipping reply to ${evt.id.slice(0, 8)} after thread analysis - not suitable for engagement`); success = false; break; } - // Check if we've already replied to this event + // Check if we've already replied to this event (early exit to avoid unnecessary LLM calls) const eventMemoryId = this.createUniqueUuid(this.runtime, evt.id); const recent = await this.runtime.getMemories({ tableName: 'messages', roomId, count: 100 }); const hasReply = recent.some((m) => m.content?.inReplyTo === eventMemoryId || m.content?.inReplyTo === evt.id); @@ -5890,6 +5863,33 @@ Response (YES/NO):`; break; } + // Process images in home feed post content (if enabled) + let imageContext = { imageDescriptions: [], imageUrls: [] }; + if (this.imageProcessingEnabled) { + try { + logger.info(`[NOSTR] Processing images in home feed post: "${evt.content?.slice(0, 200)}..."`); + const { processImageContent } = require('./image-vision'); + const fullImageContext = await processImageContent(evt.content || '', this.runtime); + imageContext = { + imageDescriptions: fullImageContext.imageDescriptions.slice(0, this.maxImagesPerMessage), + imageUrls: fullImageContext.imageUrls.slice(0, this.maxImagesPerMessage) + }; + logger.info(`[NOSTR] Processed ${imageContext.imageDescriptions.length} images from home feed post`); + } catch (error) { + logger.error(`[NOSTR] Error in home feed image processing: ${error.message || error}`); + imageContext = { imageDescriptions: [], imageUrls: [] }; + } + } + + const text = await this.generateReplyTextLLM(evt, roomId, threadContext, imageContext); + + // Check if LLM generation failed (returned null) + if (!text || !text.trim()) { + logger.warn(`[NOSTR] Skipping home feed reply to ${evt.id.slice(0, 8)} - LLM generation failed`); + success = false; + break; + } + success = await this.postReply(evt, text); break; } diff --git a/plugin-nostr/test/service.homeFeedDeduplication.test.js b/plugin-nostr/test/service.homeFeedDeduplication.test.js new file mode 100644 index 0000000..9c7e4f5 --- /dev/null +++ b/plugin-nostr/test/service.homeFeedDeduplication.test.js @@ -0,0 +1,150 @@ +import { NostrService } from '../lib/service.js'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; + +describe('NostrService Home Feed Reply Deduplication', () => { + let service; + let mockRuntime; + let mockPool; + + beforeEach(() => { + // Mock runtime with minimal required interface + mockRuntime = { + character: { name: 'Test', postExamples: ['test'] }, + getSetting: vi.fn((key) => { + const settings = { + 'NOSTR_RELAYS': 'wss://test.relay', + 'NOSTR_PRIVATE_KEY': '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', // Test fixture - not a real secret + 'NOSTR_LISTEN_ENABLE': 'false', // Disable listening to prevent subscriptions + 'NOSTR_POST_ENABLE': 'false', // Disable posting to prevent scheduled posts + 'NOSTR_REPLY_ENABLE': 'true', + 'NOSTR_DM_ENABLE': 'false', + 'NOSTR_DM_REPLY_ENABLE': 'false', + 'NOSTR_CONTEXT_ACCUMULATOR_ENABLED': 'false', + 'NOSTR_CONTEXT_LLM_ANALYSIS': 'false', + 'NOSTR_HOME_FEED_ENABLE': 'true', + 'NOSTR_DISCOVERY_ENABLE': 'false', + 'NOSTR_ENABLE_PING': 'false', + 'NOSTR_POST_DAILY_DIGEST_ENABLE': 'false', + 'NOSTR_CONNECTION_MONITOR_ENABLE': 'false', + 'NOSTR_UNFOLLOW_ENABLE': 'false', + 'NOSTR_DM_THROTTLE_SEC': '60', + 'NOSTR_REPLY_THROTTLE_SEC': '60', + 'NOSTR_REPLY_INITIAL_DELAY_MIN_MS': '0', + 'NOSTR_REPLY_INITIAL_DELAY_MAX_MS': '0', + 'NOSTR_DISCOVERY_INTERVAL_MIN': '900', + 'NOSTR_DISCOVERY_INTERVAL_MAX': '1800', + 'NOSTR_HOME_FEED_INTERVAL_MIN': '300', + 'NOSTR_HOME_FEED_INTERVAL_MAX': '900', + 'NOSTR_HOME_FEED_REACTION_CHANCE': '0', + 'NOSTR_HOME_FEED_REPOST_CHANCE': '0', + 'NOSTR_HOME_FEED_QUOTE_CHANCE': '0', + 'NOSTR_HOME_FEED_REPLY_CHANCE': '1.0', // Always choose reply for testing + 'NOSTR_HOME_FEED_MAX_INTERACTIONS': '10', + 'NOSTR_MIN_DELAY_BETWEEN_POSTS_MS': '0', + 'NOSTR_MAX_DELAY_BETWEEN_POSTS_MS': '0', + 'NOSTR_MENTION_PRIORITY_BOOST_MS': '5000', + 'NOSTR_MAX_EVENT_AGE_DAYS': '2', + 'NOSTR_ZAP_THANKS_ENABLE': 'false', + 'NOSTR_IMAGE_PROCESSING_ENABLED': 'false' + }; + return settings[key] || ''; + }), + useModel: vi.fn(() => Promise.resolve({ text: 'Test reply' })), + createMemory: vi.fn(), + getMemoryById: vi.fn(), + getMemories: vi.fn(() => []), + ensureWorldExists: vi.fn(), + ensureRoomExists: vi.fn(), + ensureConnection: vi.fn(), + agentId: 'test-agent', + createUniqueUuid: vi.fn((_, seed) => `uuid-${seed}`) + }; + + // Mock pool + mockPool = { + subscribeMany: vi.fn(() => vi.fn()), + publish: vi.fn(), + close: vi.fn() + }; + + mockRuntime.createSimplePool = vi.fn(() => mockPool); + + service = null; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // Helper function to set up common mocks + function setupHomeFeedMocks(service) { + service._loadCurrentContacts = vi.fn(() => Promise.resolve(new Set(['test-pubkey']))); + service._list = vi.fn(() => Promise.resolve([{ + id: 'test-event-id', + pubkey: 'test-pubkey', + content: 'Test post content', + created_at: Math.floor(Date.now() / 1000), + kind: 1 + }])); + service._isQualityContent = vi.fn(() => true); + service._analyzePostForInteraction = vi.fn(() => Promise.resolve(true)); + service._isUserMuted = vi.fn(() => Promise.resolve(false)); + service._getThreadContext = vi.fn(() => Promise.resolve([])); + service._getConversationIdFromEvent = vi.fn(() => 'test-conv-id'); + service._ensureNostrContext = vi.fn(() => Promise.resolve({ roomId: 'test-room-id' })); + service._shouldEngageWithThread = vi.fn(() => true); + service.generateReplyTextLLM = vi.fn(() => Promise.resolve('Test reply text')); + service.postReply = vi.fn(() => Promise.resolve(true)); + service.imageProcessingEnabled = false; + service.createUniqueUuid = vi.fn((_, seed) => `uuid-${seed}`); + service._chooseInteractionType = vi.fn(() => 'reply'); + } + + describe('processHomeFeed Reply Deduplication', () => { + it('should not reply twice to the same home feed post', async () => { + service = await NostrService.start(mockRuntime); + + setupHomeFeedMocks(service); + + // Mock getMemories to return existing reply on second call + let callCount = 0; + mockRuntime.getMemories = vi.fn(() => { + callCount++; + if (callCount === 1) { + // First call - no existing replies + return Promise.resolve([]); + } else { + // Second call - existing reply found + return Promise.resolve([{ + content: { inReplyTo: 'uuid-test-event-id' } + }]); + } + }); + + // First call to processHomeFeed - should reply + await service.processHomeFeed(); + expect(service.postReply).toHaveBeenCalledTimes(1); + + // Reset call count and mocks for second call + callCount = 0; + service.postReply.mockClear(); + + // Second call to processHomeFeed - should NOT reply due to deduplication + await service.processHomeFeed(); + expect(service.postReply).not.toHaveBeenCalled(); + }); + + it('should reply when no existing reply is found', async () => { + service = await NostrService.start(mockRuntime); + + setupHomeFeedMocks(service); + + // Mock getMemories to always return no existing replies + mockRuntime.getMemories = vi.fn(() => Promise.resolve([])); + + // Process home feed - should reply + await service.processHomeFeed(); + expect(service.postReply).toHaveBeenCalledTimes(1); + }); + }); +}); \ No newline at end of file From 3404a5e1f4bbf65903a1e22f7142101ffc8702d4 Mon Sep 17 00:00:00 2001 From: Anabelle Handdoek Date: Sat, 18 Oct 2025 09:52:21 -0500 Subject: [PATCH 3/3] Fix test setup to properly initialize service pool for processHomeFeed --- plugin-nostr/test/service.homeFeedDeduplication.test.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugin-nostr/test/service.homeFeedDeduplication.test.js b/plugin-nostr/test/service.homeFeedDeduplication.test.js index 9c7e4f5..7c23968 100644 --- a/plugin-nostr/test/service.homeFeedDeduplication.test.js +++ b/plugin-nostr/test/service.homeFeedDeduplication.test.js @@ -78,6 +78,10 @@ describe('NostrService Home Feed Reply Deduplication', () => { // Helper function to set up common mocks function setupHomeFeedMocks(service) { + service.pool = mockPool; // Ensure pool is set for processHomeFeed + service.sk = 'test-sk'; // Mock private key + service.relays = ['wss://test.relay']; // Mock relays + service.pkHex = 'test-pk-hex'; // Mock public key hex service._loadCurrentContacts = vi.fn(() => Promise.resolve(new Set(['test-pubkey']))); service._list = vi.fn(() => Promise.resolve([{ id: 'test-event-id',