diff --git a/apps/web/src/components/settings/automations/AutomationsSettings.client.test.tsx b/apps/web/src/components/settings/automations/AutomationsSettings.client.test.tsx index bbb614f56..9691b687e 100644 --- a/apps/web/src/components/settings/automations/AutomationsSettings.client.test.tsx +++ b/apps/web/src/components/settings/automations/AutomationsSettings.client.test.tsx @@ -89,6 +89,7 @@ const baseFormState: FormState = { announcerInstructions: '', platformIssueSlackChannel: '', platformIssueDiscordChannel: '', + platformIssueTeamsChannel: '', }; describe('Automations selection helpers', () => { diff --git a/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx b/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx index c6e890344..96ce40188 100644 --- a/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx +++ b/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx @@ -103,6 +103,7 @@ const state = vi.hoisted(() => ({ announcerInstructions: null, platformIssueSlackChannelId: null, platformIssueDiscordChannelId: null, + platformIssueTeamsChannelId: null, }, slackChannelDisplayNames: { channelAutoStartSlackChannels: { @@ -173,6 +174,10 @@ const state = vi.hoisted(() => ({ } | null, ]), ), + teamsConversations: [] as Array<{ + conversationId: string; + label: string; + }>, recentRuns: {}, automationStatus: {}, }, @@ -580,6 +585,28 @@ describe('AutomationsSettings', () => { ).toBeInTheDocument(); }); + it('offers installed Teams conversations for platform issue alerts', async () => { + state.settingsQuery.data.teamsConversations = [ + { conversationId: 'teams-alerts', label: 'Engineering · Alerts' }, + ]; + ( + state.settingsQuery.data.settings as Record + ).platformIssueTeamsChannelId = 'teams-alerts'; + + render(); + + fireEvent.click( + await screen.findByRole('button', { + name: 'Expand Alert on Config Errors', + }), + ); + + expect( + screen.getByLabelText('Or post alerts to this Teams conversation'), + ).toBeInTheDocument(); + expect(screen.getByText('Engineering · Alerts')).toBeInTheDocument(); + }); + it('hides the launch mode picker when decision mode is disabled', async () => { render(); diff --git a/apps/web/src/components/settings/automations/AutomationsSettings.tsx b/apps/web/src/components/settings/automations/AutomationsSettings.tsx index 15b7c2273..098bd9a7e 100644 --- a/apps/web/src/components/settings/automations/AutomationsSettings.tsx +++ b/apps/web/src/components/settings/automations/AutomationsSettings.tsx @@ -736,6 +736,7 @@ function mapSettingsToFormState( platformIssueSlackChannelId: string | null; platformIssueSlackChannelName?: string | null; platformIssueDiscordChannelId: string | null; + platformIssueTeamsChannelId: string | null; securityAuditorSlackChannelId: string | null; securityAuditorSlackChannelName?: string | null; securityAuditorDiscordChannelId: string | null; @@ -850,6 +851,7 @@ function mapSettingsToFormState( settings.platformIssueSlackChannelId ?? '', platformIssueDiscordChannel: settings.platformIssueDiscordChannelId ?? '', + platformIssueTeamsChannel: settings.platformIssueTeamsChannelId ?? '', securityAuditorSlackChannel: settings.securityAuditorSlackChannelName ?? settings.securityAuditorSlackChannelId ?? @@ -897,16 +899,21 @@ export function buildSlackWorkflowLaunchUrl( export function isPlatformIssueAlertsEnabled( formState: - | Pick< - FormState, - 'platformIssueSlackChannel' | 'platformIssueDiscordChannel' + | Partial< + Pick< + FormState, + | 'platformIssueSlackChannel' + | 'platformIssueDiscordChannel' + | 'platformIssueTeamsChannel' + > > | null | undefined, ): boolean { return Boolean( - formState?.platformIssueSlackChannel.trim() || - formState?.platformIssueDiscordChannel.trim(), + formState?.platformIssueSlackChannel?.trim() || + formState?.platformIssueDiscordChannel?.trim() || + formState?.platformIssueTeamsChannel?.trim(), ); } @@ -2544,6 +2551,9 @@ export function AutomationsSettings() { DISCORD_DESTINATION_OPTION_PREFIX.length, ), ...clearSuggesterAltDestinations, + ...(field === 'platformIssueSlackChannel' + ? { platformIssueTeamsChannel: '' } + : {}), }; } @@ -2552,6 +2562,9 @@ export function AutomationsSettings() { [field]: nextValue ?? '', [discordField]: '', ...clearSuggesterAltDestinations, + ...(field === 'platformIssueSlackChannel' + ? { platformIssueTeamsChannel: '' } + : {}), }; }) } @@ -4509,6 +4522,48 @@ If unclear, send to manager channel.`} warningChannelId: slackChannelAccessWarnings.platformIssueSlackChannel, })} + {(settingsQuery.data?.teamsConversations.length ?? 0) > 0 ? ( +
+ + +
+ ) : null} diff --git a/apps/web/src/components/settings/automations/formState.ts b/apps/web/src/components/settings/automations/formState.ts index c401781ea..2623e3041 100644 --- a/apps/web/src/components/settings/automations/formState.ts +++ b/apps/web/src/components/settings/automations/formState.ts @@ -101,6 +101,7 @@ export type FormState = { * Mutually exclusive with Slack/Discord/Telegram destinations. */ suggesterUseTeams: boolean; + platformIssueTeamsChannel: string; suggesterRoutingMode: SuggesterRoutingMode; suggesterRoutingInstructions: string; announcerFrequency: AnnouncerFrequency; @@ -186,8 +187,10 @@ const ANNOUNCER_FIELDS: Array = [ 'announcerInstructions', ]; -const PLATFORM_ISSUE_ALERT_FIELDS: Array = - DESTINATION_CHANNEL_FIELDS_BY_AUTOMATION_ID.platformIssueAlerts; +const PLATFORM_ISSUE_ALERT_FIELDS: Array = [ + ...DESTINATION_CHANNEL_FIELDS_BY_AUTOMATION_ID.platformIssueAlerts, + 'platformIssueTeamsChannel', +]; const SCHEDULE_ONLY_AUTOMATION_FIELDS = Object.fromEntries( SCHEDULE_ONLY_BACKGROUND_AUTOMATION_LIST.map((automation) => [ @@ -353,6 +356,8 @@ export function buildAutomationSettingsSaveInput( suggesterInstructions: stateToSave.suggesterInstructions.trim() || null, suggesterUseTelegram: stateToSave.suggesterUseTelegram, suggesterUseTeams: stateToSave.suggesterUseTeams, + platformIssueTeamsChannel: + stateToSave.platformIssueTeamsChannel.trim() || null, suggesterRoutingMode: stateToSave.suggesterRoutingMode, suggesterRoutingInstructions: stateToSave.suggesterRoutingInstructions.trim() || null, diff --git a/apps/web/src/trpc/commands/automations/__tests__/settings-update-discord.test.ts b/apps/web/src/trpc/commands/automations/__tests__/settings-update-discord.test.ts index e466f624d..1ec98f496 100644 --- a/apps/web/src/trpc/commands/automations/__tests__/settings-update-discord.test.ts +++ b/apps/web/src/trpc/commands/automations/__tests__/settings-update-discord.test.ts @@ -6,6 +6,7 @@ import { discordInstallations, eq, slackInstallations, + teamsInstallations, upsertAutomation, users, } from '@roomote/db/server'; @@ -125,6 +126,7 @@ function buildInput( announcerInstructions: null, platformIssueSlackChannel: null, platformIssueDiscordChannel: null, + platformIssueTeamsChannel: null, ...overrides, }; } @@ -200,6 +202,7 @@ describe('updateBackgroundAgentSettingsCommand Discord destinations', () => { await db.delete(deploymentSettings); await db.delete(discordInstallations); await db.delete(slackInstallations); + await db.delete(teamsInstallations); await db.delete(users); }); @@ -484,6 +487,115 @@ describe('updateBackgroundAgentSettingsCommand Discord destinations', () => { } }, 15_000); + it('persists an explicit Teams destination for platform issue alerts', async () => { + await db.insert(teamsInstallations).values({ + installationKey: 'team:teams-alerts', + tenantId: 'tenant-1', + conversationId: 'teams-alerts', + serviceUrl: 'https://smba.trafficmanager.net/amer/', + botAppId: 'bot-app-1', + isActive: true, + }); + + const result = await updateBackgroundAgentSettingsCommand( + adminAuth, + buildInput({ + savingAutomation: 'platformIssueAlerts', + platformIssueTeamsChannel: 'teams-alerts', + }), + ); + + expect(result.success).toBe(true); + expect(await getAutomationTargets('platform_issue_alerts')).toEqual([ + { + provider: 'teams', + targetKind: 'teams_channel', + externalRef: 'teams-alerts', + }, + ]); + if (result.success) { + expect(result.settings.platformIssueTeamsChannelId).toBe('teams-alerts'); + expect(result.settings.platformIssueSlackChannelId).toBeNull(); + expect(result.settings.platformIssueDiscordChannelId).toBeNull(); + } + }, 15_000); + + it('replaces the Teams destination when platform issue alerts switch to Slack', async () => { + await db.insert(teamsInstallations).values({ + installationKey: 'team:teams-alerts', + tenantId: 'tenant-1', + conversationId: 'teams-alerts', + serviceUrl: 'https://smba.trafficmanager.net/amer/', + botAppId: 'bot-app-1', + isActive: true, + }); + await updateBackgroundAgentSettingsCommand( + adminAuth, + buildInput({ + savingAutomation: 'platformIssueAlerts', + platformIssueTeamsChannel: 'teams-alerts', + }), + ); + await insertSlackInstallation(); + + const result = await updateBackgroundAgentSettingsCommand( + adminAuth, + buildInput({ + savingAutomation: 'platformIssueAlerts', + platformIssueSlackChannel: 'C123456NEW', + }), + ); + + expect(result.success).toBe(true); + expect(await getAutomationTargets('platform_issue_alerts')).toEqual([ + { + provider: 'slack', + targetKind: 'slack_channel', + externalRef: 'C123456NEW', + }, + ]); + }, 15_000); + + it('replaces the Teams destination when platform issue alerts switch to Discord', async () => { + await db.insert(teamsInstallations).values({ + installationKey: 'team:teams-alerts', + tenantId: 'tenant-1', + conversationId: 'teams-alerts', + serviceUrl: 'https://smba.trafficmanager.net/amer/', + botAppId: 'bot-app-1', + isActive: true, + }); + await updateBackgroundAgentSettingsCommand( + adminAuth, + buildInput({ + savingAutomation: 'platformIssueAlerts', + platformIssueTeamsChannel: 'teams-alerts', + }), + ); + await insertAvailableDiscordChannel({ + guildId: 'guild-1', + channelId: 'D111', + channelName: 'automation-reports', + }); + + const result = await updateBackgroundAgentSettingsCommand( + adminAuth, + buildInput({ + savingAutomation: 'platformIssueAlerts', + platformIssueDiscordChannel: 'D111', + }), + ); + + expect(result.success).toBe(true); + expect(await getAutomationTargets('platform_issue_alerts')).toEqual([ + { + provider: 'discord', + targetKind: 'discord_channel', + externalRef: 'D111', + }, + ]); + }, 15_000); + it('preserves a Discord target when an older client omits the optional field on a same-automation save', async () => { await insertAvailableDiscordChannel({ guildId: 'guild-1', diff --git a/apps/web/src/trpc/commands/automations/settings-read.ts b/apps/web/src/trpc/commands/automations/settings-read.ts index a7735728f..ecd56a540 100644 --- a/apps/web/src/trpc/commands/automations/settings-read.ts +++ b/apps/web/src/trpc/commands/automations/settings-read.ts @@ -20,6 +20,7 @@ import { import { findDiscordDestinationByChannelId, findTeamsConversationDisplayName, + listTeamsAutomationDestinations, findTeamsPrimaryConversation, resolveAutomationRuntimeDestination, type ResolvedAutomationDestination, @@ -219,6 +220,15 @@ async function resolveAutomationDestinations(params: { slackConnected: params.slackConnected, }); + // Platform issue alerts intentionally stop at an explicit target or + // Manager Channel; delivery never falls through to a primary conversation. + if ( + key === 'platform_issue_alerts' && + destination?.source === 'primary_conversation' + ) { + return [key, null] as const; + } + if (!destination) { return [key, null] as const; } @@ -292,6 +302,7 @@ export async function getBackgroundAgentSettingsCommand( automationStatus: Partial< Record >; + teamsConversations: Array<{ conversationId: string; label: string }>; }> { assertAdmin(auth); @@ -301,6 +312,7 @@ export async function getBackgroundAgentSettingsCommand( discordInstallation, telegramCredentials, teamsPrimaryConversation, + teamsConversations, sentryConnected, recentRuns, status, @@ -313,6 +325,7 @@ export async function getBackgroundAgentSettingsCommand( }), resolveTelegramRuntimeCredentials(), findTeamsPrimaryConversation(), + listTeamsAutomationDestinations(), hasActiveSentryIntegration(), listRecentAutomationTasks(), buildAutomationStatus(), @@ -409,6 +422,7 @@ export async function getBackgroundAgentSettingsCommand( ? slackInstallation.teamDomain : null, }, + teamsConversations, slackChannelAccessWarnings, slackChannelDisplayNames, resolvedDestinations, diff --git a/apps/web/src/trpc/commands/automations/settings-update.ts b/apps/web/src/trpc/commands/automations/settings-update.ts index 4dc8cb239..f3f23950e 100644 --- a/apps/web/src/trpc/commands/automations/settings-update.ts +++ b/apps/web/src/trpc/commands/automations/settings-update.ts @@ -21,6 +21,7 @@ import { } from '@roomote/db/server'; import { findDiscordDestinationByChannelId, + findTeamsConversationServiceUrl, findTeamsPrimaryConversation, findTelegramPrimaryChatId, resolveAutomationRuntimeDestination, @@ -533,6 +534,31 @@ export async function updateBackgroundAgentSettingsCommand( const platformIssueDiscordResult = destinationResults.platformIssueAlerts.discord; + const savingPlatformIssueAlerts = + input.savingAutomation === 'platformIssueAlerts'; + const platformIssueTeamsChannelId = savingPlatformIssueAlerts + ? (input.platformIssueTeamsChannel ?? null) + : existingSettings.platformIssueTeamsChannelId; + let platformIssueTeamsTarget: AutomationTarget | null = null; + + if (platformIssueTeamsChannelId) { + const serviceUrl = await findTeamsConversationServiceUrl( + platformIssueTeamsChannelId, + ); + if (!serviceUrl) { + fieldErrors.platformIssueTeamsChannel = + 'This Microsoft Teams conversation is not available to Roomote.'; + } else { + platformIssueTeamsTarget = { + provider: 'teams', + targetKind: 'teams_channel', + externalRef: platformIssueTeamsChannelId, + }; + destinationResults.platformIssueAlerts.slack = { channelId: null }; + destinationResults.platformIssueAlerts.discord = { channelId: null }; + } + } + // Suggest Ideas may target Telegram (sticky topic) or Teams (primary // conversation) as one-of destinations alongside Slack/Discord channels. const savingSuggester = input.savingAutomation === 'suggester'; @@ -1062,12 +1088,16 @@ export async function updateBackgroundAgentSettingsCommand( ...(suggesterTeamsTarget ? [suggesterTeamsTarget] : []), ] : []; + const platformIssueExtraTargets: AutomationTarget[] = + automationId === 'platformIssueAlerts' && platformIssueTeamsTarget + ? [platformIssueTeamsTarget] + : []; return { targets: [ ...buildDestinationChannelTargets( result.slack.channelId, result.discord.channelId, - suggesterExtraTargets, + [...suggesterExtraTargets, ...platformIssueExtraTargets], ), ...extraTargets, ], @@ -1078,7 +1108,9 @@ export async function updateBackgroundAgentSettingsCommand( 'telegram_chat' as const, 'teams_channel' as const, ] - : [...descriptor.managedTargetKinds], + : automationId === 'platformIssueAlerts' + ? [...descriptor.managedTargetKinds, 'teams_channel' as const] + : [...descriptor.managedTargetKinds], }; } @@ -1284,7 +1316,8 @@ export async function updateBackgroundAgentSettingsCommand( key: 'platform_issue_alerts', enabled: platformIssueChannelResult.channelId != null || - platformIssueDiscordResult.channelId != null, + platformIssueDiscordResult.channelId != null || + platformIssueTeamsTarget != null, ...destinationUpsertFields('platformIssueAlerts'), updatedAt: now, }); diff --git a/apps/web/src/trpc/commands/automations/types.ts b/apps/web/src/trpc/commands/automations/types.ts index e84c73179..3c7690db7 100644 --- a/apps/web/src/trpc/commands/automations/types.ts +++ b/apps/web/src/trpc/commands/automations/types.ts @@ -50,6 +50,7 @@ export type BackgroundAgentFieldErrorKey = | 'suggesterDiscordChannel' | 'announcerDiscordChannel' | 'platformIssueDiscordChannel' + | 'platformIssueTeamsChannel' | 'suggesterUseTelegram' | 'suggesterUseTeams' | 'sentryTriageProjectSlugs' @@ -286,6 +287,7 @@ export interface UpdateBackgroundAgentSettingsInput extends ScheduleOnlyAutomati announcerInstructions: string | null; platformIssueSlackChannel: string | null; platformIssueDiscordChannel?: string | null; + platformIssueTeamsChannel?: string | null; securityAuditorSlackChannel?: string | null; securityAuditorDiscordChannel?: string | null; codeQualityAuditorSlackChannel?: string | null; diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index 311d2bdf5..cd2fa22f2 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -566,6 +566,13 @@ const automationsRouter = createRouter({ .max(160) .nullable() .optional(), + platformIssueTeamsChannel: z + .string() + .trim() + .min(1) + .max(160) + .nullable() + .optional(), securityAuditorSlackChannel: z .string() .trim() diff --git a/packages/db/src/lib/automations.ts b/packages/db/src/lib/automations.ts index 15dd6ebf5..7213ca22e 100644 --- a/packages/db/src/lib/automations.ts +++ b/packages/db/src/lib/automations.ts @@ -984,6 +984,9 @@ export function normalizeBackgroundAgentSettings( ), suggesterTelegramChatId: getAutomationTelegramChatTarget(suggester), suggesterTeamsChannelId: getAutomationTeamsChannelTarget(suggester), + platformIssueTeamsChannelId: getAutomationTeamsChannelTarget( + automationMap.get('platform_issue_alerts'), + ), } as BackgroundAgentSettings; } diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index e3e39ba0c..157c3b067 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -479,6 +479,8 @@ export type BackgroundAgentSettings = StoredBackgroundAgentSettings & { announcerLastRunAt: Date | null; platformIssueSlackChannelId: string | null; platformIssueDiscordChannelId: string | null; + /** Explicit Teams conversation for platform issue alerts. */ + platformIssueTeamsChannelId: string | null; managerStatsFrequency: ManagerStatsFrequency; managerStatsSlackChannelId: string | null; managerStatsDiscordChannelId: string | null; diff --git a/packages/sdk/src/server/automations/destination.ts b/packages/sdk/src/server/automations/destination.ts index 351447928..14a27f076 100644 --- a/packages/sdk/src/server/automations/destination.ts +++ b/packages/sdk/src/server/automations/destination.ts @@ -101,6 +101,37 @@ export async function findTeamsConversationServiceUrl( return row?.serviceUrl ?? null; } +/** Active Teams conversations that have the service URL required for posts. */ +export async function listTeamsAutomationDestinations(): Promise< + Array<{ conversationId: string; label: string }> +> { + const rows = await db + .select({ + conversationId: teamsInstallations.conversationId, + channelName: teamsInstallations.channelName, + teamName: teamsInstallations.teamName, + }) + .from(teamsInstallations) + .where( + and( + eq(teamsInstallations.isActive, true), + isNotNull(teamsInstallations.serviceUrl), + ), + ); + + return [ + ...new Map( + rows.map((row) => [ + row.conversationId, + { + conversationId: row.conversationId, + label: row.channelName ?? row.teamName ?? row.conversationId, + }, + ]), + ).values(), + ]; +} + /** * Resolves where an automation run should report, extending the db-level * waterfall (own target -> Slack manager channel) with a primary-conversation diff --git a/packages/sdk/src/server/index.ts b/packages/sdk/src/server/index.ts index 1cfc4cc63..1c31103af 100644 --- a/packages/sdk/src/server/index.ts +++ b/packages/sdk/src/server/index.ts @@ -148,6 +148,12 @@ export { TELEGRAM_PRIMARY_CHAT_ENV_VAR_NAME, } from './lib/telegram-primary-chat'; +export { + findTeamsConversationServiceUrl, + findTeamsConversationDisplayName, + listTeamsAutomationDestinations, +} from './automations/destination'; + export { findTeamsPrimaryConversation, type TeamsPrimaryConversation, diff --git a/packages/sdk/src/server/lib/task-runs/__tests__/platform-issue-alert-delivery.test.ts b/packages/sdk/src/server/lib/task-runs/__tests__/platform-issue-alert-delivery.test.ts index 3e080b2df..7bbf90df2 100644 --- a/packages/sdk/src/server/lib/task-runs/__tests__/platform-issue-alert-delivery.test.ts +++ b/packages/sdk/src/server/lib/task-runs/__tests__/platform-issue-alert-delivery.test.ts @@ -2,10 +2,14 @@ const { mockCreateDiscordProvider, mockDiscordPostMessage, mockSlackPostMessage, + mockCreateTeamsProvider, + mockTeamsPostMessage, } = vi.hoisted(() => ({ mockCreateDiscordProvider: vi.fn(), mockDiscordPostMessage: vi.fn(), mockSlackPostMessage: vi.fn(), + mockCreateTeamsProvider: vi.fn(), + mockTeamsPostMessage: vi.fn(), })); vi.mock('../../discord-communication', () => ({ @@ -13,6 +17,11 @@ vi.mock('../../discord-communication', () => ({ mockCreateDiscordProvider, })); +vi.mock('../../teams-communication', () => ({ + createTeamsCommunicationProviderFromRuntimeCredentials: + mockCreateTeamsProvider, +})); + // Keep the test hermetic: the Slack fallback path constructs a SlackNotifier // from the active installation and posts the alert through it. vi.mock('@roomote/slack', async (importOriginal) => { @@ -34,6 +43,7 @@ import { deploymentSettings, eq, slackInstallations, + teamsInstallations, taskFactory, taskPlatformIssueReports, taskRuns, @@ -113,6 +123,8 @@ describe('platform issue alert delivery', () => { mockCreateDiscordProvider.mockReset(); mockDiscordPostMessage.mockReset(); mockSlackPostMessage.mockReset(); + mockCreateTeamsProvider.mockReset(); + mockTeamsPostMessage.mockReset(); mockCreateDiscordProvider.mockResolvedValue({ postMessage: mockDiscordPostMessage, }); @@ -122,10 +134,14 @@ describe('platform issue alert delivery', () => { messageId: 'm1', }); mockSlackPostMessage.mockResolvedValue('1727000000.000100'); + mockCreateTeamsProvider.mockResolvedValue({ + postMessage: mockTeamsPostMessage, + }); await db.delete(taskPlatformIssueReports); await db.delete(deploymentSettings); await db.delete(slackInstallations); + await db.delete(teamsInstallations); }); it('posts the alert to the automation Discord channel when its own destination is Discord', async () => { @@ -252,4 +268,73 @@ describe('platform issue alert delivery', () => { expect(reportRow?.report).toEqual(REPORT); expect(reportRow?.slackPostedAt).toBeNull(); }); + + it('posts to the explicitly selected Teams conversation with its stored service URL', async () => { + const taskId = 'task-platform-issue-teams'; + const runId = await seedTaskRun(taskId); + + await upsertAutomation(db, { + key: 'platform_issue_alerts', + enabled: true, + targets: [ + { + provider: 'teams', + targetKind: 'teams_channel', + externalRef: 'teams-conversation', + }, + ], + }); + await db.insert(teamsInstallations).values({ + installationKey: 'team:teams-conversation', + tenantId: 'tenant-1', + conversationId: 'teams-conversation', + serviceUrl: 'https://smba.trafficmanager.net/amer/', + botAppId: 'bot-app-1', + isActive: true, + }); + + await recordTaskMessageEnvelope({ + runId, + taskId, + envelope: buildReportEnvelope(), + }); + + expect(mockTeamsPostMessage).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: 'teams-conversation', + serviceUrl: 'https://smba.trafficmanager.net/amer/', + textFormat: 'markdown', + }), + ); + expect(mockSlackPostMessage).not.toHaveBeenCalled(); + const [post] = mockTeamsPostMessage.mock.calls[0] ?? []; + expect(post.text).toContain('utm_source=teams'); + }); + + it('does not fall back to an unselected Teams conversation', async () => { + const taskId = 'task-platform-issue-no-destination'; + const runId = await seedTaskRun(taskId); + + await upsertAutomation(db, { + key: 'platform_issue_alerts', + enabled: false, + targets: [], + }); + await db.insert(teamsInstallations).values({ + installationKey: 'team:unselected-conversation', + tenantId: 'tenant-1', + conversationId: 'unselected-conversation', + serviceUrl: 'https://smba.trafficmanager.net/amer/', + botAppId: 'bot-app-1', + isActive: true, + }); + + await recordTaskMessageEnvelope({ + runId, + taskId, + envelope: buildReportEnvelope(), + }); + + expect(mockTeamsPostMessage).not.toHaveBeenCalled(); + }); }); diff --git a/packages/sdk/src/server/lib/task-runs/record-task-message-envelope.ts b/packages/sdk/src/server/lib/task-runs/record-task-message-envelope.ts index c45481404..7103e97ea 100644 --- a/packages/sdk/src/server/lib/task-runs/record-task-message-envelope.ts +++ b/packages/sdk/src/server/lib/task-runs/record-task-message-envelope.ts @@ -27,6 +27,8 @@ import { trackSlackBotReply, } from '@roomote/slack'; import { createDiscordCommunicationProviderFromRuntimeCredentials } from '../discord-communication'; +import { createTeamsCommunicationProviderFromRuntimeCredentials } from '../teams-communication'; +import { findTeamsConversationServiceUrl } from '../../automations/destination'; import { appendManagerSlackFooter, degradeSlackMrkdwnToMarkdown, @@ -162,7 +164,7 @@ function getPlatformIssueReportFromToolPayload( function buildPlatformIssueTaskUrl( taskId: string, - utmSource: 'slack' | 'discord', + utmSource: 'slack' | 'discord' | 'teams', ): string { const url = new URL(`/task/${taskId}`, process.env.R_APP_URL); @@ -176,7 +178,7 @@ function buildPlatformIssueTaskUrl( function buildPlatformIssueAlertText(params: { taskId: string; report: { title: string; summary: string }; - utmSource: 'slack' | 'discord'; + utmSource: 'slack' | 'discord' | 'teams'; }): string { const taskUrl = buildPlatformIssueTaskUrl(params.taskId, params.utmSource); @@ -221,9 +223,9 @@ async function maybeNotifyPlatformIssue(params: { }), ]); - // When the platform_issue_alerts automation's own destination target is a - // Discord channel, the alert belongs there instead of Slack. + // An explicitly selected automation destination always owns the alert. const discordChannelId = settings.platformIssueDiscordChannelId; + const teamsChannelId = settings.platformIssueTeamsChannelId; if (discordChannelId) { const discord = @@ -252,43 +254,67 @@ async function maybeNotifyPlatformIssue(params: { return; } - // Two-level fallback: the platform_issue_alerts automation target wins, - // otherwise the deployment-wide manager channel. - const channelId = - settings.platformIssueSlackChannelId ?? settings.managerSlackChannelId; + if (teamsChannelId) { + const [teams, serviceUrl] = await Promise.all([ + createTeamsCommunicationProviderFromRuntimeCredentials(), + findTeamsConversationServiceUrl(teamsChannelId), + ]); - if (!channelId) { - return; - } + if (!teams || !serviceUrl) { + console.warn( + `[recordTaskMessageEnvelope] No Teams credentials or service URL, skipping platform issue Teams alert for task ${params.taskId}`, + ); + return; + } - if (!slackInstallation?.botAccessToken || !slackInstallation.isActive) { - console.warn( - `[recordTaskMessageEnvelope] No active Slack installation, skipping platform issue Slack alert for task ${params.taskId}`, - ); + await teams.postMessage({ + channelId: teamsChannelId, + serviceUrl, + text: degradeSlackMrkdwnToMarkdown( + buildPlatformIssueAlertText({ + taskId: params.taskId, + report: params.report, + utmSource: 'teams', + }), + ), + textFormat: 'markdown', + }); + await markPlatformIssueReportPosted(params.reportRowId); return; } - const slack = new SlackNotifier(slackInstallation.botAccessToken); + // Two-level fallback: the platform_issue_alerts automation target wins, + // otherwise the deployment-wide manager channel. + const channelId = + settings.platformIssueSlackChannelId ?? settings.managerSlackChannelId; - const messageTs = await slack.postMessage({ - channel: channelId, - text: buildPlatformIssueAlertText({ - taskId: params.taskId, - report: params.report, - utmSource: 'slack', - }), - unfurl_links: false, - unfurl_media: false, - }); + if ( + channelId && + slackInstallation?.botAccessToken && + slackInstallation.isActive + ) { + const slack = new SlackNotifier(slackInstallation.botAccessToken); + const messageTs = await slack.postMessage({ + channel: channelId, + text: buildPlatformIssueAlertText({ + taskId: params.taskId, + report: params.report, + utmSource: 'slack', + }), + unfurl_links: false, + unfurl_media: false, + }); - if (!messageTs) { - console.warn( - `[recordTaskMessageEnvelope] Failed to post platform issue Slack alert for task ${params.taskId}`, - ); + if (!messageTs) { + console.warn( + `[recordTaskMessageEnvelope] Failed to post platform issue Slack alert for task ${params.taskId}`, + ); + return; + } + + await markPlatformIssueReportPosted(params.reportRowId); return; } - - await markPlatformIssueReportPosted(params.reportRowId); } async function maybePersistPlatformIssueReport(params: {