diff --git a/apps/api/src/handlers/telegram/__tests__/index.test.ts b/apps/api/src/handlers/telegram/__tests__/index.test.ts index bcd724c8d..fe4f9ea8a 100644 --- a/apps/api/src/handlers/telegram/__tests__/index.test.ts +++ b/apps/api/src/handlers/telegram/__tests__/index.test.ts @@ -43,6 +43,11 @@ const { getFastSessionMock, isFastProviderMessageMock, recordFastConversationMessageMock, + claimPendingPrReviewActionMock, + completePendingPrReviewActionDispatchMock, + dispatchPrReviewFollowUpMock, + enableAutoHandlePrReviewFeedbackMock, + retirePrReviewActionMessagesBestEffortMock, } = vi.hoisted(() => ({ addReactionMock: vi.fn(), answerCallbackQueryMock: vi.fn(), @@ -90,6 +95,11 @@ const { getFastSessionMock: vi.fn(), isFastProviderMessageMock: vi.fn(), recordFastConversationMessageMock: vi.fn(), + claimPendingPrReviewActionMock: vi.fn(), + completePendingPrReviewActionDispatchMock: vi.fn(), + dispatchPrReviewFollowUpMock: vi.fn(), + enableAutoHandlePrReviewFeedbackMock: vi.fn(), + retirePrReviewActionMessagesBestEffortMock: vi.fn(), })); vi.mock('@roomote/env', () => ({ @@ -282,10 +292,14 @@ vi.mock('@roomote/sdk/server', () => ({ recordFastAgentConversationMessageBestEffort: recordFastConversationMessageMock, TELEGRAM_PRIMARY_CHAT_ENV_VAR_NAME: 'TELEGRAM_PRIMARY_CHAT_ID', - claimPendingPrReviewAction: vi.fn(async () => null), + claimPendingPrReviewAction: claimPendingPrReviewActionMock, claimPendingPrReviewActionsForThread: vi.fn(async () => []), - dispatchPrReviewFollowUp: vi.fn(), - enableAutoHandlePrReviewFeedback: vi.fn(), + completePendingPrReviewActionDispatch: + completePendingPrReviewActionDispatchMock, + dispatchPrReviewFollowUp: dispatchPrReviewFollowUpMock, + enableAutoHandlePrReviewFeedback: enableAutoHandlePrReviewFeedbackMock, + retirePrReviewActionMessagesBestEffort: + retirePrReviewActionMessagesBestEffortMock, })); vi.mock('@roomote/communication/telegram-provider', () => ({ @@ -442,6 +456,77 @@ describe('Telegram webhook handler', () => { taskId: 'task-new', }); postMessageMock.mockResolvedValue({ messageId: 'telegram-response' }); + claimPendingPrReviewActionMock.mockResolvedValue(null); + completePendingPrReviewActionDispatchMock.mockResolvedValue(undefined); + dispatchPrReviewFollowUpMock.mockResolvedValue({ + outcome: 'queued', + runId: 42, + }); + enableAutoHandlePrReviewFeedbackMock.mockResolvedValue(undefined); + retirePrReviewActionMessagesBestEffortMock.mockResolvedValue(undefined); + }); + + it('retires Auto-resolve controls through the managed Telegram footer path', async () => { + mockTelegramLinkedSender('linked-user-1'); + claimPendingPrReviewActionMock.mockResolvedValueOnce({ + nonce: 'review-action-1', + provider: 'telegram', + taskId: 'task-1', + repository: 'acme/web', + prNumber: 42, + prUrl: 'https://github.com/acme/web/pull/42', + channelId: '222', + threadId: '7', + followUpPrompt: 'Resolve the review feedback.', + messageId: '777', + }); + + const response = await postTelegramUpdate({ + update_id: 905, + callback_query: { + id: 'cb-review-auto', + from: { id: 111, first_name: 'Ada' }, + data: 'prr:a:review-action-1', + message: { + message_id: 777, + message_thread_id: 7, + chat: { id: 222, type: 'supergroup' }, + }, + }, + }); + + expect(response.status).toBe(200); + expect(retirePrReviewActionMessagesBestEffortMock).toHaveBeenCalledWith([ + { + provider: 'telegram', + channelId: '222', + threadId: '7', + messageId: '777', + }, + ]); + expect(editMessageReplyMarkupMock).not.toHaveBeenCalled(); + expect(dispatchPrReviewFollowUpMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: 'telegram', + taskId: 'task-1', + followUpPrompt: 'Resolve the review feedback.', + }), + ); + expect(enableAutoHandlePrReviewFeedbackMock).toHaveBeenCalledWith({ + taskId: 'task-1', + repository: 'acme/web', + prNumber: 42, + userId: 'linked-user-1', + }); + expect(postMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: '222', + replyToMessageId: '777', + text: expect.stringContaining( + 'Future review feedback on this PR will get resolved automatically.', + ), + }), + ); }); it('queues a new reaction on the owner’s bound Fast message', async () => { diff --git a/apps/api/src/handlers/telegram/pr-review-action.ts b/apps/api/src/handlers/telegram/pr-review-action.ts index a53b33c7c..0c7321d64 100644 --- a/apps/api/src/handlers/telegram/pr-review-action.ts +++ b/apps/api/src/handlers/telegram/pr-review-action.ts @@ -5,6 +5,7 @@ import { completePendingPrReviewActionDispatch, dispatchPrReviewFollowUp, enableAutoHandlePrReviewFeedback, + retirePrReviewActionMessagesBestEffort, } from '@roomote/sdk/server'; import type { PrReviewActionChoice } from '@roomote/types'; @@ -12,7 +13,6 @@ import { apiLogger } from '../../logging.js'; import { resolveTelegramSenderUserId } from './linked-user.js'; import { answerTelegramCallbackQueryBestEffort, - clearTelegramMessageButtonsBestEffort, postTelegramMessageBestEffort, } from './replies.js'; @@ -63,21 +63,25 @@ export async function handleTelegramPrReviewActionCallback(params: { actingUserId: senderUserId ?? undefined, }); + if (chatId && messageId) { + await retirePrReviewActionMessagesBestEffort([ + { + provider: 'telegram', + channelId: chatId, + threadId: threadId ?? null, + messageId, + }, + ]); + } + if (!pending) { await answerTelegramCallbackQueryBestEffort({ callbackQueryId: query.id, text: 'This offer was already handled or has expired.', }); - if (chatId && messageId) { - await clearTelegramMessageButtonsBestEffort({ chatId, messageId }); - } return; } - if (chatId && messageId) { - await clearTelegramMessageButtonsBestEffort({ chatId, messageId }); - } - if (choice === 'dismiss') { await answerTelegramCallbackQueryBestEffort({ callbackQueryId: query.id, @@ -189,14 +193,7 @@ export function retireTelegramPrReviewOffersBestEffort({ threadId, }); - for (const pending of claimed) { - if (pending.messageId) { - await clearTelegramMessageButtonsBestEffort({ - chatId, - messageId: pending.messageId, - }); - } - } + await retirePrReviewActionMessagesBestEffort(claimed); })().catch((error: unknown) => { apiLogger.warn( `[telegram] Failed to retire PR review offers for chat ${chatId}: ${ diff --git a/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts b/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts index 4516e06c0..bb44032bb 100644 --- a/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts +++ b/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts @@ -3503,6 +3503,8 @@ describe('deliverFastAgentParentEvent', () => { text: 'Resolve these issues', callbackData: `prr:y:${nonce}`, }, + ], + [ { text: 'Auto-resolve on this PR', callbackData: `prr:a:${nonce}`, diff --git a/packages/sdk/src/server/lib/fast-agent-parent-event.ts b/packages/sdk/src/server/lib/fast-agent-parent-event.ts index 4dc4f6ae6..04a7b6fbe 100644 --- a/packages/sdk/src/server/lib/fast-agent-parent-event.ts +++ b/packages/sdk/src/server/lib/fast-agent-parent-event.ts @@ -1991,6 +1991,8 @@ async function createTelegramFastAgentParentTurn( action.nonce, ), }, + ], + [ { text: PR_REVIEW_ACTION_LABELS.auto, callbackData: buildPrReviewActionCallbackData( diff --git a/packages/sdk/src/server/lib/task-runs/__tests__/pr-review-action-telegram.integration.test.ts b/packages/sdk/src/server/lib/task-runs/__tests__/pr-review-action-telegram.integration.test.ts new file mode 100644 index 000000000..86e0422ec --- /dev/null +++ b/packages/sdk/src/server/lib/task-runs/__tests__/pr-review-action-telegram.integration.test.ts @@ -0,0 +1,175 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + store: new Map(), + adapter: null as unknown, +})); + +vi.mock('@roomote/redis', () => ({ + getRedis: () => ({ + get: async (key: string) => mocks.store.get(key) ?? null, + set: async (key: string, value: string, ...args: unknown[]) => { + if (args.includes('NX') && mocks.store.has(key)) return null; + if (args.includes('XX') && !mocks.store.has(key)) return null; + mocks.store.set(key, value); + return 'OK'; + }, + eval: async ( + script: string, + keyCount: number, + firstKey: string, + ...args: (string | number)[] + ) => { + if (keyCount === 2) { + const [recordKey, ownerId, record, ttl] = args as [ + string, + string, + string, + string | number, + ]; + if (mocks.store.get(firstKey) !== ownerId) return 0; + if (ttl === 'keepTtl' && !mocks.store.has(recordKey)) return 0; + mocks.store.set(recordKey, record); + return 1; + } + + const ownerId = String(args[0]); + if (mocks.store.get(firstKey) !== ownerId) return 0; + if (script.includes("redis.call('del'")) mocks.store.delete(firstKey); + return 1; + }, + zadd: async () => 1, + }), +})); + +vi.mock('@roomote/db/server', () => ({ + and: vi.fn(), + attachCanonicalPrReviewActionMessage: vi.fn(), + claimCanonicalPrReviewAction: vi.fn(), + completeCanonicalPrReviewActionDispatch: vi.fn(), + db: { query: { slackInstallations: {} } }, + eq: vi.fn(), + findPrReviewAutoPreference: vi.fn(), + retireCanonicalPrReviewActionsForDestination: vi.fn(), + retireCanonicalPrReviewActionsForPullRequest: vi.fn(), + slackInstallations: {}, + upsertPrReviewAutoPreference: vi.fn(), +})); + +vi.mock('@roomote/slack', () => ({ + buildResolvedSlackPrReviewMessageBlocks: vi.fn(), + SlackNotifier: class {}, +})); + +vi.mock('../../communication-providers', () => ({ + getCommunicationProviderAdapter: async () => mocks.adapter, +})); + +import { + getThreadReplyFooterRecord, + postTextThreadReplyWithFooter, +} from '@roomote/communication'; +import { TelegramCommunicationProvider } from '@roomote/communication/telegram-provider'; +import { retirePrReviewActionMessagesBestEffort } from '../pr-review-action'; + +function telegramResponse(result: unknown): Response { + return new Response(JSON.stringify({ ok: true, result }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +} + +describe('Telegram PR review action carrier lifecycle', () => { + beforeEach(() => { + mocks.store.clear(); + mocks.adapter = null; + }); + + it('keeps retired buttons absent when the managed footer later relocates', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(telegramResponse({ message_id: 101 })) + .mockResolvedValueOnce(telegramResponse(true)) + .mockResolvedValueOnce(telegramResponse({ message_id: 102 })) + .mockResolvedValueOnce(telegramResponse(true)); + const provider = new TelegramCommunicationProvider({ + botToken: 'bot-token', + apiBaseUrl: 'https://telegram.example.test', + fetch: fetchMock as typeof fetch, + }); + mocks.adapter = provider; + const buttons = [ + [{ text: 'Resolve these issues', callbackData: 'prr:y:nonce' }], + [ + { text: 'Auto-resolve on this PR', callbackData: 'prr:a:nonce' }, + { text: 'Dismiss', callbackData: 'prr:d:nonce' }, + ], + ]; + + await postTextThreadReplyWithFooter({ + provider, + input: { + channelId: '222', + threadId: '7', + text: 'Review feedback', + buttons, + }, + footerText: 'Reply anytime', + }); + await retirePrReviewActionMessagesBestEffort([ + { + provider: 'telegram', + channelId: '222', + threadId: '7', + messageId: '101', + }, + ]); + + expect( + (await getThreadReplyFooterRecord('telegram', '222', '7'))?.buttons, + ).toBeUndefined(); + + await postTextThreadReplyWithFooter({ + provider, + input: { + channelId: '222', + threadId: '7', + text: 'Auto-resolve enabled', + }, + footerText: 'Reply anytime', + }); + + const requests = fetchMock.mock.calls.map(([url, init]) => ({ + method: String(url).split('/').at(-1), + body: JSON.parse(String((init as RequestInit).body)) as { + reply_markup?: { inline_keyboard: unknown[][] }; + }, + })); + expect(requests[0]).toMatchObject({ + method: 'sendMessage', + body: { + reply_markup: { + inline_keyboard: [ + [{ text: 'Resolve these issues', callback_data: 'prr:y:nonce' }], + [ + { + text: 'Auto-resolve on this PR', + callback_data: 'prr:a:nonce', + }, + { text: 'Dismiss', callback_data: 'prr:d:nonce' }, + ], + ], + }, + }, + }); + expect(requests[1]).toMatchObject({ + method: 'editMessageReplyMarkup', + body: { reply_markup: { inline_keyboard: [] } }, + }); + expect(requests[2]?.body.reply_markup).toBeUndefined(); + expect(requests[3]).toMatchObject({ + method: 'editMessageText', + body: { reply_markup: { inline_keyboard: [] } }, + }); + }); +});