From 289b69380fb3dc8dcbe056ba7818723516d011db Mon Sep 17 00:00:00 2001 From: Martin Porwoll Date: Sat, 14 Feb 2026 13:28:33 +0000 Subject: [PATCH] feat(youtube): add reply thread import to comment sync Add getCommentReplies method to YouTubeClient for fetching reply threads via the YouTube comments.list API. Modify CommentsSyncService to import reply threads during sync, storing them as type 'reply' with parentInteraction relationship in community-interactions. Co-Authored-By: Claude Opus 4.6 --- .../youtube/CommentsSyncService.ts | 131 +++++++++- src/lib/integrations/youtube/YouTubeClient.ts | 42 +++ .../unit/youtube/comment-replies.unit.spec.ts | 242 ++++++++++++++++++ 3 files changed, 410 insertions(+), 5 deletions(-) create mode 100644 tests/unit/youtube/comment-replies.unit.spec.ts diff --git a/src/lib/integrations/youtube/CommentsSyncService.ts b/src/lib/integrations/youtube/CommentsSyncService.ts index 4aa009b..703e98b 100644 --- a/src/lib/integrations/youtube/CommentsSyncService.ts +++ b/src/lib/integrations/youtube/CommentsSyncService.ts @@ -15,6 +15,7 @@ interface SyncResult { success: boolean newComments: number updatedComments: number + newReplies: number errors: string[] syncedAt: Date } @@ -43,6 +44,7 @@ export class CommentsSyncService { success: false, newComments: 0, updatedComments: 0, + newReplies: 0, errors: [], syncedAt: new Date(), } @@ -102,13 +104,15 @@ export class CommentsSyncService { const processResult = await this.processComment( comment, account, - analyzeWithAI + analyzeWithAI, + youtubeClient, ) if (processResult.isNew) { result.newComments++ } else { result.updatedComments++ } + result.newReplies += processResult.newReplies } catch (error) { result.errors.push(`Fehler bei Kommentar ${comment.id}: ${error}`) } @@ -139,8 +143,9 @@ export class CommentsSyncService { private async processComment( comment: CommentThread, account: any, - analyzeWithAI: boolean - ): Promise<{ isNew: boolean }> { + analyzeWithAI: boolean, + youtubeClient?: YouTubeClient, + ): Promise<{ isNew: boolean; newReplies: number }> { const snippet = comment.snippet.topLevelComment.snippet // Prüfen ob Kommentar bereits existiert @@ -228,11 +233,14 @@ export class CommentsSyncService { }), } + let parentInteractionId: number | null = null + if (isNew) { - await this.payload.create({ + const created = await this.payload.create({ collection: 'community-interactions', data: interactionData, }) + parentInteractionId = created.id } else { await this.payload.update({ collection: 'community-interactions', @@ -245,9 +253,122 @@ export class CommentsSyncService { assignedTo: existing.docs[0].assignedTo, }, }) + parentInteractionId = existing.docs[0].id } - return { isNew } + // Reply-Threads synchronisieren + let newReplies = 0 + if (youtubeClient && comment.snippet.totalReplyCount > 0) { + newReplies = await this.processReplyThread( + comment, + account, + parentInteractionId, + platformId, + linkedContentId, + youtubeClient, + analyzeWithAI, + ) + } + + return { isNew, newReplies } + } + + /** + * Antworten eines Kommentar-Threads synchronisieren + */ + private async processReplyThread( + comment: CommentThread, + account: any, + parentInteractionId: number | null, + platformId: any, + linkedContentId: any, + youtubeClient: YouTubeClient, + analyzeWithAI: boolean, + ): Promise { + let newReplies = 0 + + try { + const { replies } = await youtubeClient.getCommentReplies( + comment.snippet.topLevelComment.id, + ) + + for (const reply of replies) { + const replyExists = await this.payload.find({ + collection: 'community-interactions', + where: { + externalId: { equals: reply.id }, + }, + limit: 1, + }) + + if (replyExists.totalDocs > 0) { + continue + } + + let replyAnalysis = null + if (analyzeWithAI) { + replyAnalysis = await this.claudeService.analyzeComment( + reply.snippet.textOriginal, + ) + } + + await this.payload.create({ + collection: 'community-interactions', + data: { + platform: platformId, + socialAccount: account.id, + linkedContent: linkedContentId, + type: 'reply' as const, + externalId: reply.id, + parentInteraction: parentInteractionId, + author: { + name: reply.snippet.authorDisplayName, + handle: reply.snippet.authorChannelId?.value, + profileUrl: reply.snippet.authorChannelUrl, + avatarUrl: reply.snippet.authorProfileImageUrl, + isVerified: false, + isSubscriber: false, + isMember: false, + }, + message: reply.snippet.textOriginal, + messageHtml: reply.snippet.textDisplay, + publishedAt: new Date(reply.snippet.publishedAt).toISOString(), + engagement: { + likes: reply.snippet.likeCount || 0, + replies: 0, + isHearted: false, + isPinned: false, + }, + ...(replyAnalysis && { + analysis: { + sentiment: replyAnalysis.sentiment, + sentimentScore: replyAnalysis.sentimentScore, + confidence: replyAnalysis.confidence, + topics: replyAnalysis.topics.map((t) => ({ topic: t })), + language: replyAnalysis.language, + suggestedReply: replyAnalysis.suggestedReply, + analyzedAt: new Date().toISOString(), + }, + flags: { + isMedicalQuestion: replyAnalysis.isMedicalQuestion, + requiresEscalation: replyAnalysis.requiresEscalation, + isSpam: replyAnalysis.isSpam, + isFromInfluencer: false, + }, + }), + }, + }) + + newReplies++ + } + } catch (error) { + console.error( + `Error syncing replies for comment ${comment.id}:`, + error, + ) + } + + return newReplies } } diff --git a/src/lib/integrations/youtube/YouTubeClient.ts b/src/lib/integrations/youtube/YouTubeClient.ts index 88e097e..5aa4757 100644 --- a/src/lib/integrations/youtube/YouTubeClient.ts +++ b/src/lib/integrations/youtube/YouTubeClient.ts @@ -232,6 +232,48 @@ export class YouTubeClient { } } + /** + * Antworten auf einen Kommentar-Thread abrufen + */ + async getCommentReplies( + parentCommentId: string, + maxResults: number = 100 + ): Promise<{ + replies: Array<{ + id: string + snippet: { + parentId: string + textOriginal: string + textDisplay: string + authorDisplayName: string + authorProfileImageUrl: string + authorChannelUrl: string + authorChannelId: { value: string } + likeCount: number + publishedAt: string + updatedAt: string + } + }> + nextPageToken?: string + }> { + try { + const response = await this.youtube.comments.list({ + part: ['snippet'], + parentId: parentCommentId, + maxResults, + textFormat: 'plainText', + }) + + return { + replies: (response.data.items || []) as any[], + nextPageToken: response.data.nextPageToken || undefined, + } + } catch (error) { + console.error('Error fetching comment replies:', error) + throw error + } + } + /** * Kanal-Statistiken abrufen */ diff --git a/tests/unit/youtube/comment-replies.unit.spec.ts b/tests/unit/youtube/comment-replies.unit.spec.ts new file mode 100644 index 0000000..7f91270 --- /dev/null +++ b/tests/unit/youtube/comment-replies.unit.spec.ts @@ -0,0 +1,242 @@ +/** + * YouTubeClient.getCommentReplies Unit Tests + * + * Tests the comment reply retrieval method that fetches replies + * for a given parent comment thread from the YouTube Data API. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const mockCommentsList = vi.fn() + +vi.mock('googleapis', () => { + class MockOAuth2 { + setCredentials(): void { + // no-op + } + } + + return { + google: { + auth: { + OAuth2: MockOAuth2, + }, + youtube: () => ({ + videos: { list: vi.fn() }, + commentThreads: { list: vi.fn() }, + comments: { + list: mockCommentsList, + insert: vi.fn(), + setModerationStatus: vi.fn(), + delete: vi.fn(), + }, + channels: { list: vi.fn() }, + }), + }, + } +}) + +const mockPayload = {} as import('payload').Payload + +describe('YouTubeClient.getCommentReplies', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return parsed replies for a parent comment', async () => { + mockCommentsList.mockResolvedValueOnce({ + data: { + items: [ + { + id: 'reply-1', + snippet: { + parentId: 'comment-abc', + textOriginal: 'Great point!', + textDisplay: 'Great point!', + authorDisplayName: 'User A', + authorProfileImageUrl: 'https://example.com/a.jpg', + authorChannelUrl: 'https://youtube.com/channel/UC_a', + authorChannelId: { value: 'UC_a' }, + likeCount: 5, + publishedAt: '2026-01-15T10:00:00Z', + updatedAt: '2026-01-15T10:00:00Z', + }, + }, + { + id: 'reply-2', + snippet: { + parentId: 'comment-abc', + textOriginal: 'Thanks for sharing', + textDisplay: 'Thanks for sharing', + authorDisplayName: 'User B', + authorProfileImageUrl: 'https://example.com/b.jpg', + authorChannelUrl: 'https://youtube.com/channel/UC_b', + authorChannelId: { value: 'UC_b' }, + likeCount: 2, + publishedAt: '2026-01-15T11:00:00Z', + updatedAt: '2026-01-15T11:00:00Z', + }, + }, + ], + nextPageToken: undefined, + }, + }) + + const { YouTubeClient } = await import( + '@/lib/integrations/youtube/YouTubeClient' + ) + + const client = new YouTubeClient( + { + clientId: 'test-id', + clientSecret: 'test-secret', + accessToken: 'test-token', + refreshToken: 'test-refresh', + }, + mockPayload, + ) + + const result = await client.getCommentReplies('comment-abc') + + expect(result.replies).toHaveLength(2) + expect(result.replies[0].id).toBe('reply-1') + expect(result.replies[0].snippet.parentId).toBe('comment-abc') + expect(result.replies[0].snippet.textOriginal).toBe('Great point!') + expect(result.replies[0].snippet.likeCount).toBe(5) + expect(result.replies[1].id).toBe('reply-2') + expect(result.nextPageToken).toBeUndefined() + + expect(mockCommentsList).toHaveBeenCalledWith({ + part: ['snippet'], + parentId: 'comment-abc', + maxResults: 100, + textFormat: 'plainText', + }) + }) + + it('should pass custom maxResults parameter', async () => { + mockCommentsList.mockResolvedValueOnce({ + data: { + items: [], + nextPageToken: undefined, + }, + }) + + const { YouTubeClient } = await import( + '@/lib/integrations/youtube/YouTubeClient' + ) + + const client = new YouTubeClient( + { + clientId: 'test-id', + clientSecret: 'test-secret', + accessToken: 'test-token', + refreshToken: 'test-refresh', + }, + mockPayload, + ) + + await client.getCommentReplies('comment-xyz', 20) + + expect(mockCommentsList).toHaveBeenCalledWith({ + part: ['snippet'], + parentId: 'comment-xyz', + maxResults: 20, + textFormat: 'plainText', + }) + }) + + it('should return empty replies array when no items exist', async () => { + mockCommentsList.mockResolvedValueOnce({ + data: { + items: null, + nextPageToken: undefined, + }, + }) + + const { YouTubeClient } = await import( + '@/lib/integrations/youtube/YouTubeClient' + ) + + const client = new YouTubeClient( + { + clientId: 'test-id', + clientSecret: 'test-secret', + accessToken: 'test-token', + refreshToken: 'test-refresh', + }, + mockPayload, + ) + + const result = await client.getCommentReplies('comment-empty') + + expect(result.replies).toEqual([]) + expect(result.nextPageToken).toBeUndefined() + }) + + it('should return nextPageToken when more replies are available', async () => { + mockCommentsList.mockResolvedValueOnce({ + data: { + items: [ + { + id: 'reply-page1', + snippet: { + parentId: 'comment-many', + textOriginal: 'First page reply', + textDisplay: 'First page reply', + authorDisplayName: 'User C', + authorProfileImageUrl: 'https://example.com/c.jpg', + authorChannelUrl: 'https://youtube.com/channel/UC_c', + authorChannelId: { value: 'UC_c' }, + likeCount: 0, + publishedAt: '2026-02-01T12:00:00Z', + updatedAt: '2026-02-01T12:00:00Z', + }, + }, + ], + nextPageToken: 'page2token', + }, + }) + + const { YouTubeClient } = await import( + '@/lib/integrations/youtube/YouTubeClient' + ) + + const client = new YouTubeClient( + { + clientId: 'test-id', + clientSecret: 'test-secret', + accessToken: 'test-token', + refreshToken: 'test-refresh', + }, + mockPayload, + ) + + const result = await client.getCommentReplies('comment-many') + + expect(result.replies).toHaveLength(1) + expect(result.nextPageToken).toBe('page2token') + }) + + it('should propagate API errors', async () => { + mockCommentsList.mockRejectedValueOnce(new Error('commentListForbidden')) + + const { YouTubeClient } = await import( + '@/lib/integrations/youtube/YouTubeClient' + ) + + const client = new YouTubeClient( + { + clientId: 'test-id', + clientSecret: 'test-secret', + accessToken: 'test-token', + refreshToken: 'test-refresh', + }, + mockPayload, + ) + + await expect( + client.getCommentReplies('comment-forbidden'), + ).rejects.toThrow('commentListForbidden') + }) +})