diff --git a/apps/web/src/trpc/commands/setup-new/index.test.ts b/apps/web/src/trpc/commands/setup-new/index.test.ts index 46a2fa1c1f..95eed815b7 100644 --- a/apps/web/src/trpc/commands/setup-new/index.test.ts +++ b/apps/web/src/trpc/commands/setup-new/index.test.ts @@ -1,4 +1,5 @@ import type { UserAuthSuccess } from '@/types'; +import type { AutomationTarget } from '@roomote/types'; const { mockTxSelect, @@ -23,6 +24,10 @@ const { mockEnqueueAutomationRecommendations, mockEnqueueAutomationRecommendationInitialRun, mockUpsertAutomation, + mockCreateCustomAutomation, + mockUpdateCustomAutomation, + mockGetCustomAutomationById, + mockResolveSetupAutomationReportTarget, mockCaptureActivationAutomationChanged, mockTriggerAutomationCommand, mockTriggerCustomAutomationCommand, @@ -56,6 +61,20 @@ const { mockEnqueueAutomationRecommendations: vi.fn(async () => undefined), mockEnqueueAutomationRecommendationInitialRun: vi.fn(async () => undefined), mockUpsertAutomation: vi.fn(async () => undefined), + mockCreateCustomAutomation: vi.fn(async (input) => ({ + id: 'custom-automation-1', + ...input, + })), + mockUpdateCustomAutomation: vi.fn(async (id, input) => ({ id, ...input })), + mockGetCustomAutomationById: vi.fn< + (...args: unknown[]) => Promise<{ + id: string; + target: AutomationTarget; + } | null> + >(async () => null), + mockResolveSetupAutomationReportTarget: vi.fn< + (...args: unknown[]) => Promise + >(async () => null), mockCaptureActivationAutomationChanged: vi.fn(async () => undefined), mockTriggerAutomationCommand: vi.fn(async () => ({ outcome: 'launched' as const, @@ -149,6 +168,7 @@ vi.mock('@roomote/sdk/server', () => ({ enqueueAutomationRecommendations: mockEnqueueAutomationRecommendations, enqueueAutomationRecommendationInitialRun: mockEnqueueAutomationRecommendationInitialRun, + resolveSetupAutomationReportTarget: mockResolveSetupAutomationReportTarget, })); vi.mock('@roomote/db/server', () => ({ @@ -188,6 +208,9 @@ vi.mock('@roomote/db/server', () => ({ updatedAtRemote: 'pull_request_facts.updated_at_remote', }, upsertAutomation: mockUpsertAutomation, + createCustomAutomation: mockCreateCustomAutomation, + updateCustomAutomation: mockUpdateCustomAutomation, + getCustomAutomationById: mockGetCustomAutomationById, isChatGptSubscriptionConnected: vi.fn(async () => false), isGitHubCopilotSubscriptionConnected: vi.fn(async () => false), isXaiSubscriptionConnected: vi.fn(async () => false), @@ -1326,6 +1349,103 @@ describe('setup recommendation commands', () => { expect(result?.applicationState).toBe('applied'); }); + it('defaults a recommended custom automation to the resolved report destination', async () => { + const reportTarget = { + provider: 'email' as const, + targetKind: 'email_user' as const, + externalRef: 'setup-test-user', + metadata: { emailIdentityId: 'verified:setup-test-user:hash' }, + }; + mockResolveSetupAutomationReportTarget.mockResolvedValue(reportTarget); + mockRecommendationTransaction({ + automationRecommendations: { + version: 1, + inputFingerprint: 'recommendation-fingerprint', + catalogVersion: 1, + status: 'ready', + startedAt: new Date().toISOString(), + completedAt: new Date().toISOString(), + partial: false, + errorCode: null, + dismissed: false, + recommendations: [ + { + id: 'cookbook.scheduled-housekeeping:1', + candidateId: 'cookbook.scheduled-housekeeping', + rank: 1, + score: 1, + explanation: 'Review maintenance opportunities.', + enabled: true, + lastRunTaskId: null, + automationId: null, + }, + ], + }, + }); + + await applySetupRecommendationsCommand(buildMockAuth()); + + expect(mockResolveSetupAutomationReportTarget).toHaveBeenCalledWith( + 'setup-test-user', + expect.anything(), + ); + expect(mockCreateCustomAutomation).toHaveBeenCalledWith( + expect.objectContaining({ target: reportTarget }), + expect.anything(), + ); + }); + + it('preserves an existing explicit destination when re-enabling a recommendation', async () => { + const existingTarget = { + provider: 'teams' as const, + targetKind: 'teams_channel' as const, + externalRef: 'conversation-1', + }; + mockGetCustomAutomationById.mockResolvedValue({ + id: 'custom-automation-1', + target: existingTarget, + }); + mockResolveSetupAutomationReportTarget.mockResolvedValue({ + provider: 'email', + targetKind: 'email_user', + externalRef: 'setup-test-user', + metadata: { emailIdentityId: 'verified:setup-test-user:hash' }, + }); + mockRecommendationTransaction({ + automationRecommendations: { + version: 1, + inputFingerprint: 'recommendation-fingerprint', + catalogVersion: 1, + status: 'ready', + startedAt: new Date().toISOString(), + completedAt: new Date().toISOString(), + partial: false, + errorCode: null, + dismissed: false, + recommendations: [ + { + id: 'cookbook.scheduled-housekeeping:1', + candidateId: 'cookbook.scheduled-housekeeping', + rank: 1, + score: 1, + explanation: 'Review maintenance opportunities.', + enabled: true, + lastRunTaskId: null, + automationId: 'custom-automation-1', + }, + ], + }, + }); + + await applySetupRecommendationsCommand(buildMockAuth()); + + expect(mockUpdateCustomAutomation).toHaveBeenCalledWith( + 'custom-automation-1', + expect.objectContaining({ target: existingTarget }), + expect.anything(), + ); + }); + it('keeps a skipped pending batch unapplied and disabled', async () => { mockRecommendationTransaction({ automationRecommendations: { diff --git a/apps/web/src/trpc/commands/setup-new/index.ts b/apps/web/src/trpc/commands/setup-new/index.ts index 4499a81987..2d4407893a 100644 --- a/apps/web/src/trpc/commands/setup-new/index.ts +++ b/apps/web/src/trpc/commands/setup-new/index.ts @@ -44,6 +44,7 @@ import { buildAutomationRecommendationFingerprint, enqueueAutomationRecommendationInitialRun, enqueueAutomationRecommendations, + resolveSetupAutomationReportTarget, } from '@roomote/sdk/server'; import { buildRecommendedDeploymentModelConfig, @@ -106,6 +107,7 @@ import { AUTOMATION_RECOMMENDATIONS_CATALOG_VERSION, AUTOMATION_RECOMMENDATION_CATALOG, ALL_REPOSITORIES, + isConfiguredAutomationTarget, } from '@roomote/types'; import type { UserAuthSuccess } from '@/types'; @@ -2611,6 +2613,9 @@ async function applySetupRecommendationInTx( const existing = recommendation.automationId ? await getCustomAutomationById(recommendation.automationId, tx) : null; + const reportTarget = enabled + ? await resolveSetupAutomationReportTarget(auth.userId, tx) + : null; const automation = existing ? await updateCustomAutomation( existing.id, @@ -2620,7 +2625,9 @@ async function applySetupRecommendationInTx( enabled, scheduleMode: candidate.template.scheduleMode, environmentId: ALL_REPOSITORIES, - target: {}, + target: isConfiguredAutomationTarget(existing.target) + ? existing.target + : (reportTarget ?? {}), }, tx, ) @@ -2631,7 +2638,7 @@ async function applySetupRecommendationInTx( enabled, scheduleMode: candidate.template.scheduleMode, environmentId: ALL_REPOSITORIES, - target: {}, + target: reportTarget ?? {}, createdByUserId: auth.userId, }, tx, diff --git a/packages/sdk/src/server/index.ts b/packages/sdk/src/server/index.ts index 7d7893f796..7a97fbb394 100644 --- a/packages/sdk/src/server/index.ts +++ b/packages/sdk/src/server/index.ts @@ -39,6 +39,7 @@ export { type AutomationRecommendationInitialRunJob, type AutomationSignalPrefetchJob, } from './lib/automation-recommendations'; +export { resolveSetupAutomationReportTarget } from './lib/setup-automation-delivery'; export { recordLlmUsage, type RecordLlmUsageInput, diff --git a/packages/sdk/src/server/lib/setup-automation-delivery.test.ts b/packages/sdk/src/server/lib/setup-automation-delivery.test.ts new file mode 100644 index 0000000000..86bc20728a --- /dev/null +++ b/packages/sdk/src/server/lib/setup-automation-delivery.test.ts @@ -0,0 +1,173 @@ +import type { DatabaseOrTransaction } from '@roomote/db/server'; + +const mocks = vi.hoisted(() => ({ + settings: vi.fn(), + installations: vi.fn(), + membership: vi.fn(), + discord: vi.fn(), + teams: vi.fn(), + teamsCredentials: vi.fn(), + discordCredentials: vi.fn(), + connectedProviders: vi.fn(), + directMessage: vi.fn(), + emailIdentities: vi.fn(), +})); + +vi.mock('@roomote/db/server', () => ({ + db: { + query: { deploymentSettings: { findFirst: mocks.settings } }, + select: () => ({ + from: () => ({ + innerJoin: () => ({ + where: () => ({ limit: mocks.installations }), + }), + }), + }), + }, + eq: (...args: unknown[]) => args, + and: (...args: unknown[]) => args, + deploymentSettings: { id: 'settings.id' }, + slackInstallations: { + id: 'installation.id', + botAccessToken: 'installation.token', + isActive: 'installation.active', + teamId: 'installation.team', + }, + slackInstallationChannels: { + slackInstallationId: 'channel.installation', + channelId: 'channel.id', + }, + resolveTeamsBotRuntimeCredentials: mocks.teamsCredentials, + resolveDiscordRuntimeCredentials: mocks.discordCredentials, +})); + +vi.mock('@roomote/slack', () => ({ + SlackNotifier: class { + isAppInChannel = mocks.membership; + }, +})); +vi.mock('./discord-persistence', () => ({ + findDiscordDestinationByChannelId: mocks.discord, +})); +vi.mock('../automations/destination', () => ({ + findTeamsConversationRoute: mocks.teams, + listConnectedCommunicationProviders: mocks.connectedProviders, +})); +vi.mock('./agentmail/outbound', () => ({ + listAvailableAgentMailOutboundIdentities: mocks.emailIdentities, +})); +vi.mock('./user-direct-message', () => ({ + findUserDirectMessageDestination: mocks.directMessage, +})); + +import { resolveSetupAutomationReportTarget } from './setup-automation-delivery'; + +describe('resolveSetupAutomationReportTarget', () => { + beforeEach(() => { + vi.resetAllMocks(); + mocks.settings.mockResolvedValue({ setupNewState: {} }); + mocks.installations.mockResolvedValue([]); + mocks.membership.mockResolvedValue(false); + mocks.discord.mockResolvedValue(null); + mocks.discordCredentials.mockResolvedValue({ botToken: 'token' }); + mocks.connectedProviders.mockResolvedValue([]); + mocks.directMessage.mockResolvedValue(null); + mocks.teams.mockResolvedValue(null); + mocks.teamsCredentials.mockResolvedValue({ + botAppId: 'app', + botAppPassword: 'password', + }); + mocks.emailIdentities.mockResolvedValue([]); + }); + + it('prefers a usable configured chat destination over Email', async () => { + mocks.settings.mockResolvedValue({ + managerSlackChannelId: ' C123 ', + setupNewState: {}, + }); + mocks.installations.mockResolvedValue([ + { botAccessToken: 'token', teamId: 'T123' }, + ]); + mocks.membership.mockResolvedValue(true); + mocks.emailIdentities.mockResolvedValue([ + { id: 'verified:user:hash', emailAddress: 'user@example.com' }, + ]); + + await expect(resolveSetupAutomationReportTarget('user-1')).resolves.toEqual( + { + provider: 'slack', + targetKind: 'slack_channel', + externalRef: 'C123', + metadata: { slackTeamId: 'T123' }, + }, + ); + expect(mocks.emailIdentities).not.toHaveBeenCalled(); + }); + + it('uses Email when no usable chat destination exists', async () => { + mocks.emailIdentities.mockResolvedValue([ + { id: 'verified:user:hash', emailAddress: 'user@example.com' }, + ]); + + await expect(resolveSetupAutomationReportTarget('user-1')).resolves.toEqual( + { + provider: 'email', + targetKind: 'email_user', + externalRef: 'user-1', + metadata: { emailIdentityId: 'verified:user:hash' }, + }, + ); + }); + + it('prefers a usable chat direct message over Email', async () => { + mocks.connectedProviders.mockResolvedValue(['teams']); + mocks.directMessage.mockResolvedValue({ + channelId: 'conversation-1', + serviceUrl: 'https://teams.example.test', + }); + mocks.emailIdentities.mockResolvedValue([ + { id: 'verified:user:hash', emailAddress: 'user@example.com' }, + ]); + + await expect(resolveSetupAutomationReportTarget('user-1')).resolves.toEqual( + { + provider: 'teams', + targetKind: 'teams_user', + externalRef: 'user-1', + }, + ); + expect(mocks.emailIdentities).not.toHaveBeenCalled(); + }); + + it('falls back to Email when configured chat is unavailable', async () => { + mocks.settings.mockResolvedValue({ + managerDiscordChannelId: '123', + setupNewState: {}, + }); + mocks.emailIdentities.mockResolvedValue([ + { id: 'verified:user:hash', emailAddress: 'user@example.com' }, + ]); + + await expect( + resolveSetupAutomationReportTarget('user-1'), + ).resolves.toMatchObject({ provider: 'email' }); + }); + + it('returns null when neither chat nor Email is available', async () => { + await expect( + resolveSetupAutomationReportTarget('user-1'), + ).resolves.toBeNull(); + }); + + it('uses the supplied transaction for setup settings', async () => { + const findFirst = vi.fn().mockResolvedValue({ setupNewState: {} }); + const client = { + query: { deploymentSettings: { findFirst } }, + } as unknown as DatabaseOrTransaction; + + await resolveSetupAutomationReportTarget('user-1', client); + + expect(findFirst).toHaveBeenCalled(); + expect(mocks.settings).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/sdk/src/server/lib/setup-automation-delivery.ts b/packages/sdk/src/server/lib/setup-automation-delivery.ts new file mode 100644 index 0000000000..3d3c563eaf --- /dev/null +++ b/packages/sdk/src/server/lib/setup-automation-delivery.ts @@ -0,0 +1,194 @@ +import { + and, + db, + deploymentSettings, + eq, + resolveDiscordRuntimeCredentials, + resolveTeamsBotRuntimeCredentials, + slackInstallationChannels, + slackInstallations, + type DatabaseOrTransaction, +} from '@roomote/db/server'; +import { SlackNotifier } from '@roomote/slack'; +import { + AUTOMATION_TARGET_EMAIL_IDENTITY_KEY, + getAutomationTargetKind, + hasSetupChatHandoffDestination, + normalizeSetupNewState, + type AutomationTarget, +} from '@roomote/types'; + +import { + findTeamsConversationRoute, + listConnectedCommunicationProviders, +} from '../automations/destination'; +import { listAvailableAgentMailOutboundIdentities } from './agentmail/outbound'; +import { findDiscordDestinationByChannelId } from './discord-persistence'; +import { findUserDirectMessageDestination } from './user-direct-message'; + +/** Resolve an existing setup chat destination before falling back to Email. */ +export async function resolveSetupAutomationReportTarget( + ownerUserId: string, + client: DatabaseOrTransaction = db, +): Promise { + let settings; + try { + settings = await client.query.deploymentSettings.findFirst({ + where: eq(deploymentSettings.id, 'default'), + columns: { + managerSlackChannelId: true, + managerDiscordChannelId: true, + setupNewState: true, + }, + }); + } catch { + return null; + } + + const state = normalizeSetupNewState(settings?.setupNewState ?? {}); + const candidates: Array<{ + provider: 'slack' | 'discord' | 'teams' | 'telegram'; + channelId: string; + slackTeamId?: string; + }> = []; + if (settings?.managerSlackChannelId?.trim()) { + candidates.push({ + provider: 'slack', + channelId: settings.managerSlackChannelId.trim(), + }); + } + if (settings?.managerDiscordChannelId?.trim()) { + candidates.push({ + provider: 'discord', + channelId: settings.managerDiscordChannelId.trim(), + }); + } + if (hasSetupChatHandoffDestination(state)) { + const provider = state.chatHandoffProvider ?? 'slack'; + const channelId = ( + state.chatHandoffProvider + ? state.chatHandoffChannelId + : state.slackChannel + )?.trim(); + if ( + provider !== 'agentmail' && + channelId && + (provider !== 'slack' || state.slackTeamId?.trim()) + ) { + candidates.push({ + provider, + channelId, + ...(provider === 'slack' + ? { slackTeamId: state.slackTeamId!.trim() } + : {}), + }); + } + } + + for (const { provider, channelId, slackTeamId } of candidates) { + const target: AutomationTarget = { + provider, + targetKind: getAutomationTargetKind(provider, 'channel'), + externalRef: channelId, + }; + try { + if (provider === 'slack') { + if (!/^[CG][A-Z0-9]+$/.test(channelId)) continue; + const installations = await client + .select({ + botAccessToken: slackInstallations.botAccessToken, + teamId: slackInstallations.teamId, + }) + .from(slackInstallations) + .innerJoin( + slackInstallationChannels, + and( + eq( + slackInstallationChannels.slackInstallationId, + slackInstallations.id, + ), + eq(slackInstallationChannels.channelId, channelId), + ), + ) + .where( + and( + eq(slackInstallations.isActive, true), + ...(slackTeamId + ? [eq(slackInstallations.teamId, slackTeamId)] + : []), + ), + ) + .limit(2); + const installation = + installations.length === 1 ? installations[0] : null; + if ( + installation?.botAccessToken && + installation.teamId && + (await new SlackNotifier(installation.botAccessToken).isAppInChannel( + channelId, + )) === true + ) { + return { + ...target, + metadata: { slackTeamId: installation.teamId }, + }; + } + continue; + } + if (provider === 'discord') { + if ( + (await resolveDiscordRuntimeCredentials()).botToken && + (await findDiscordDestinationByChannelId(channelId)) + ) { + return target; + } + continue; + } + if (provider === 'teams') { + const credentials = await resolveTeamsBotRuntimeCredentials(); + if (!credentials.botAppId || !credentials.botAppPassword) continue; + const route = await findTeamsConversationRoute(channelId); + if (route?.serviceUrl.trim()) { + return { ...target, metadata: { serviceUrl: route.serviceUrl } }; + } + } + // A Telegram chat id alone is not evidence of an active bot-bound route. + } catch { + // A stale chat candidate must not prevent trying another route or Email. + } + } + + try { + const providers = await listConnectedCommunicationProviders(); + for (const provider of providers) { + try { + if (await findUserDirectMessageDestination(provider, ownerUserId)) { + return { + provider, + targetKind: getAutomationTargetKind(provider, 'direct_message'), + externalRef: ownerUserId, + }; + } + } catch { + // Continue through the connected chat providers before trying Email. + } + } + } catch { + // Provider discovery failure still permits the independently validated fallback. + } + + try { + const [identity] = + await listAvailableAgentMailOutboundIdentities(ownerUserId); + return identity + ? { + provider: 'email', + targetKind: getAutomationTargetKind('email', 'direct_message'), + externalRef: ownerUserId, + metadata: { [AUTOMATION_TARGET_EMAIL_IDENTITY_KEY]: identity.id }, + } + : null; + } catch { + return null; + } +}