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
80 changes: 45 additions & 35 deletions plugin-nostr/lib/service.js
Original file line number Diff line number Diff line change
Expand Up @@ -5845,42 +5845,52 @@ 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: [] };
// 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 (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);
if (hasReply) {
logger.info(`[NOSTR] Skipping home feed reply to ${evt.id.slice(0, 8)} (found existing reply)`);
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`);
success = false;
break;
}
}

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);

success = await this.postReply(evt, text);
break;
Comment on lines +5849 to 5894
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing reply memory makes dedupe ineffective

We now check getMemories() before replying, but nothing in this path ever writes a memory whose content.inReplyTo matches the home‑feed event. After a restart (or whenever homeFeedProcessedEvents is cleared) that query still comes back empty, so the agent will reply again—the original bug remains. Persist the reply the way mention handling already does so the dedupe gate has something to find.

-        const { roomId } = await this._ensureNostrContext(evt.pubkey, undefined, convId);
+        const { roomId, entityId } = await this._ensureNostrContext(evt.pubkey, undefined, convId);
@@
-        success = await this.postReply(evt, text);
+        success = await this.postReply(evt, text);
+        if (success) {
+          try {
+            const replyMemoryId = this.createUniqueUuid(this.runtime, `${evt.id}:reply:${Date.now()}`);
+            await this._createMemorySafe({
+              id: replyMemoryId,
+              entityId,
+              agentId: this.runtime.agentId,
+              roomId,
+              content: {
+                text,
+                source: 'nostr',
+                inReplyTo: eventMemoryId,
+              },
+              createdAt: Date.now(),
+            }, 'messages');
+          } catch (err) {
+            this.logger?.debug?.('[NOSTR] Failed to persist home feed reply memory:', err?.message || err);
+          }
+        }

Committable suggestion skipped: line range outside the PR's diff.

}
}
Expand Down
154 changes: 154 additions & 0 deletions plugin-nostr/test/service.homeFeedDeduplication.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
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(() => []),
Copy link

Copilot AI Oct 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Mock getMemories to return a promise consistently (it’s awaited in production code) to avoid accidental sync/async mismatches: getMemories: vi.fn(() => Promise.resolve([])).

Suggested change
getMemories: vi.fn(() => []),
getMemories: vi.fn(() => Promise.resolve([])),

Copilot uses AI. Check for mistakes.
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.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',
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);
});
});
});