diff --git a/apps/api/src/handlers/custom-automations/__tests__/custom-automations-routes.test.ts b/apps/api/src/handlers/custom-automations/__tests__/custom-automations-routes.test.ts index 4decb93773..4e0b55bcac 100644 --- a/apps/api/src/handlers/custom-automations/__tests__/custom-automations-routes.test.ts +++ b/apps/api/src/handlers/custom-automations/__tests__/custom-automations-routes.test.ts @@ -32,6 +32,7 @@ const { mockListConnectedCommunicationProviders, mockCanStartAgentMailConversationWithUser, mockListAvailableAgentMailOutboundIdentities, + mockResolveDefaultAutomationTarget, mockResolveCustomAutomationSchedule, mockRunCustomAutomationNow, mockCaptureActivationCustomAutomationChanged, @@ -47,6 +48,7 @@ const { mockListConnectedCommunicationProviders: vi.fn(), mockCanStartAgentMailConversationWithUser: vi.fn(), mockListAvailableAgentMailOutboundIdentities: vi.fn(), + mockResolveDefaultAutomationTarget: vi.fn(), mockResolveCustomAutomationSchedule: vi.fn(), mockRunCustomAutomationNow: vi.fn(), mockCaptureActivationCustomAutomationChanged: vi.fn(), @@ -67,11 +69,16 @@ vi.mock('@roomote/db/server', () => ({ })); vi.mock('@roomote/sdk/server', () => ({ + CUSTOM_AUTOMATION_DESTINATION_CAPABILITIES: { + chatProviders: ['slack', 'teams', 'telegram', 'discord'], + email: true, + }, listConnectedCommunicationProviders: mockListConnectedCommunicationProviders, canStartAgentMailConversationWithUser: mockCanStartAgentMailConversationWithUser, listAvailableAgentMailOutboundIdentities: mockListAvailableAgentMailOutboundIdentities, + resolveDefaultAutomationTarget: mockResolveDefaultAutomationTarget, resolveCustomAutomationSchedule: mockResolveCustomAutomationSchedule, runCustomAutomationNow: mockRunCustomAutomationNow, })); @@ -189,6 +196,7 @@ describe('custom-automations MCP routes', () => { kind: 'verified', }, ]); + mockResolveDefaultAutomationTarget.mockResolvedValue(null); mockGetDeploymentTaskModelOptions.mockResolvedValue({ models: ENABLED_MODELS, defaultModelId: 'openai/gpt-5.6-luna', @@ -497,6 +505,20 @@ describe('custom-automations MCP routes', () => { }); }); + it('excludes shared channel defaults from destination discovery', async () => { + const { app } = createApp(); + + const response = await app.request('/custom-automations/destinations'); + + expect(response.status).toBe(200); + expect(mockResolveDefaultAutomationTarget).toHaveBeenCalledWith( + expect.objectContaining({ + ownerUserId: 'member-1', + includeSharedChannels: false, + }), + ); + }); + it.each(['other', null])( 'denies every ID operation for owner %s without side effects', async (createdByUserId) => { @@ -851,6 +873,13 @@ describe('custom-automations MCP routes', () => { it('lists and stores only a server-verified Email identity', async () => { const { app } = createApp(); + const defaultTarget = { + provider: 'email' as const, + targetKind: 'email_user' as const, + externalRef: 'admin-1', + metadata: { emailIdentityId: 'verified:admin-1:digest' }, + }; + mockResolveDefaultAutomationTarget.mockResolvedValue(defaultTarget); mockResolveCustomAutomationSchedule.mockResolvedValue({ status: 'resolved', scheduleMode: 'daily', @@ -870,6 +899,7 @@ describe('custom-automations MCP routes', () => { kind: 'verified', }, ], + defaultTarget, }); const res = await postCreate( app, diff --git a/apps/api/src/handlers/custom-automations/index.ts b/apps/api/src/handlers/custom-automations/index.ts index 56e435b70e..ed1da952d7 100644 --- a/apps/api/src/handlers/custom-automations/index.ts +++ b/apps/api/src/handlers/custom-automations/index.ts @@ -16,10 +16,12 @@ import { users, } from '@roomote/db/server'; import { + CUSTOM_AUTOMATION_DESTINATION_CAPABILITIES, listConnectedCommunicationProviders, listAvailableAgentMailOutboundIdentities, canStartAgentMailConversationWithUser, resolveCustomAutomationSchedule, + resolveDefaultAutomationTarget, runCustomAutomationNow, } from '@roomote/sdk/server'; import { @@ -400,16 +402,27 @@ customAutomationsRouter.get('/models', async (c) => customAutomationsRouter.get('/destinations', async (c) => { const automationId = c.req.query('automationId'); let ownerUserId = actorId(c); + let existingTarget: OptionalAutomationTarget | null = null; if (automationId) { const automation = await getCustomAutomationById(automationId); if (!automation || !canManage(c, automation)) { return c.json({ error: 'Custom automation was not found.' }, 404); } ownerUserId = automation.createdByUserId ?? ownerUserId; + existingTarget = automation.target; } + const [emailIdentities, defaultTarget] = await Promise.all([ + listAvailableAgentMailOutboundIdentities(ownerUserId), + resolveDefaultAutomationTarget({ + ownerUserId, + capabilities: CUSTOM_AUTOMATION_DESTINATION_CAPABILITIES, + existingTarget, + includeSharedChannels: c.get('customAutomationUser').role === 'admin', + }), + ]); return c.json({ - emailIdentities: - await listAvailableAgentMailOutboundIdentities(ownerUserId), + emailIdentities, + defaultTarget, }); }); 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 15d9357f4d..52aceadb7b 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 @@ -18,6 +18,15 @@ const state = vi.hoisted(() => ({ customAutomationsPending: false, customAutomationRunPendingId: null as string | null, customAutomationTimeZone: 'UTC' as string | undefined, + customAutomationDefaultTarget: undefined as + | { + provider: 'slack' | 'discord' | 'teams' | 'telegram' | 'email'; + targetKind: string; + externalRef: string; + metadata?: Record; + } + | null + | undefined, customAutomations: [] as Array<{ id: string; name: string; @@ -337,14 +346,34 @@ vi.mock('@tanstack/react-query', () => ({ state.queriedKeys.push(queryOptions.queryKey); const key1 = queryOptions.queryKey?.[1]; if (key1 === 'getCustomAutomationOptions') { + const managerSlackChannelId = + state.settingsQuery.data.settings.managerSlackChannelId; + const managerDiscordChannelId = + state.settingsQuery.data.settings.managerDiscordChannelId; return { isPending: state.settingsQuery.isPending, data: { capabilities: state.settingsQuery.data.capabilities, - managerSlackChannelId: - state.settingsQuery.data.settings.managerSlackChannelId, - managerDiscordChannelId: - state.settingsQuery.data.settings.managerDiscordChannelId, + managerSlackChannelId, + managerDiscordChannelId, + defaultTarget: + state.customAutomationDefaultTarget !== undefined + ? state.customAutomationDefaultTarget + : managerSlackChannelId && + state.settingsQuery.data.capabilities.slackConnected + ? { + provider: 'slack', + targetKind: 'slack_channel', + externalRef: managerSlackChannelId, + } + : managerDiscordChannelId && + state.settingsQuery.data.capabilities.discordConnected + ? { + provider: 'discord', + targetKind: 'discord_channel', + externalRef: managerDiscordChannelId, + } + : null, effectiveTimeZone: state.customAutomationTimeZone, }, }; @@ -712,6 +741,7 @@ describe('AutomationsSettings', () => { state.settingsQuery.data.reviewer.relayUsers = []; state.customAutomations = []; state.customAutomationTimeZone = 'UTC'; + state.customAutomationDefaultTarget = undefined; state.customAutomationsPending = false; state.settingsQuery.isPending = false; state.environments = []; @@ -1904,6 +1934,11 @@ describe('AutomationsSettings', () => { state.settingsQuery.data.capabilities.discordConnected = true; state.settingsQuery.data.capabilities.teamsConnected = true; state.settingsQuery.data.settings.managerSlackChannelId = null as never; + state.customAutomationDefaultTarget = { + provider: 'discord', + targetKind: 'discord_user', + externalRef: 'user-1', + }; render(); diff --git a/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx b/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx index e8f6ba5de9..125b478658 100644 --- a/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx +++ b/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx @@ -23,6 +23,7 @@ import { AUTOMATION_RESULT_PRIORITIES, type AutomationResultPriority, type CustomAutomationScheduleMode, + type OptionalAutomationTarget, type ReasoningEffort, } from '@roomote/types'; @@ -292,12 +293,12 @@ function CustomAutomationRunButton({ ); } -function targetFromRow(row: CustomAutomationListItem): { +function targetFromAutomationTarget(target: OptionalAutomationTarget): { provider: CustomAutomationFormState['targetProvider']; mode: CustomAutomationFormState['targetMode']; channelId: string; } { - if (!row.target.provider || !row.target.externalRef) { + if (!target.provider || !target.externalRef) { return { provider: 'none', mode: 'channel', @@ -306,26 +307,30 @@ function targetFromRow(row: CustomAutomationListItem): { } const provider = - row.target.provider === 'discord' || - row.target.provider === 'teams' || - row.target.provider === 'telegram' || - row.target.provider === 'email' - ? row.target.provider + target.provider === 'discord' || + target.provider === 'teams' || + target.provider === 'telegram' || + target.provider === 'email' + ? target.provider : 'slack'; return { provider, - mode: isBackgroundAutomationUserTargetKind(row.target.targetKind) + mode: isBackgroundAutomationUserTargetKind(target.targetKind) ? 'direct_message' : 'channel', channelId: - row.target.provider === 'email' - ? (getAutomationTargetEmailIdentityId(row.target) ?? '') - : isBackgroundAutomationUserTargetKind(row.target.targetKind) + target.provider === 'email' + ? (getAutomationTargetEmailIdentityId(target) ?? '') + : isBackgroundAutomationUserTargetKind(target.targetKind) ? '' - : (row.target.externalRef ?? ''), + : (target.externalRef ?? ''), }; } +function targetFromRow(row: CustomAutomationListItem) { + return targetFromAutomationTarget(row.target); +} + function formFromRow( row: CustomAutomationListItem, connectedProviders: readonly ConnectedDestinationProvider[] | null, @@ -1199,29 +1204,16 @@ export function CustomAutomationsSection({ size="sm" disabled={busy || atCap || !capabilitiesLoaded} onClick={() => { - const managerProvider = - managerSlackChannelId && capabilities?.slackConnected - ? 'slack' - : managerDiscordChannelId && capabilities?.discordConnected - ? 'discord' - : null; - const targetProvider = - managerProvider ?? connectedDestinationOptions[0]?.value ?? 'none'; + const target = targetFromAutomationTarget( + optionsQuery.data?.defaultTarget ?? {}, + ); setIsCreating(true); setEditingId(null); setForm({ ...EMPTY_FORM, - targetProvider, - targetMode: - targetProvider === 'email' ? 'direct_message' : 'channel', - targetChannelId: - targetProvider === 'slack' - ? managerSlackChannelId - : targetProvider === 'discord' - ? managerDiscordChannelId - : targetProvider === 'email' - ? (emailOptions[0]?.id ?? '') - : '', + targetProvider: target.provider, + targetMode: target.mode, + targetChannelId: target.channelId, }); setResolvedCron(null); setScheduleSummary(null); diff --git a/apps/web/src/trpc/commands/automations/__tests__/custom-automations-telemetry.test.ts b/apps/web/src/trpc/commands/automations/__tests__/custom-automations-telemetry.test.ts index 19513fd772..51e4cbdf6b 100644 --- a/apps/web/src/trpc/commands/automations/__tests__/custom-automations-telemetry.test.ts +++ b/apps/web/src/trpc/commands/automations/__tests__/custom-automations-telemetry.test.ts @@ -23,6 +23,7 @@ const mocks = vi.hoisted(() => ({ listConnectedCommunicationProviders: vi.fn(), canStartAgentMailConversationWithUser: vi.fn(), listAvailableAgentMailOutboundIdentities: vi.fn(), + resolveDefaultAutomationTarget: vi.fn(), captureActivationCustomAutomationChanged: vi.fn(), })); @@ -45,6 +46,7 @@ vi.mock('@roomote/sdk/server', async (importOriginal) => ({ mocks.canStartAgentMailConversationWithUser, listAvailableAgentMailOutboundIdentities: mocks.listAvailableAgentMailOutboundIdentities, + resolveDefaultAutomationTarget: mocks.resolveDefaultAutomationTarget, runCustomAutomationNow: mocks.runCustomAutomationNow, resolveDeploymentTimeZone: mocks.resolveDeploymentTimeZone, })); @@ -103,6 +105,7 @@ describe('custom automation activation telemetry', () => { mocks.listConnectedCommunicationProviders.mockResolvedValue(['slack']); mocks.canStartAgentMailConversationWithUser.mockResolvedValue(false); mocks.listAvailableAgentMailOutboundIdentities.mockResolvedValue([]); + mocks.resolveDefaultAutomationTarget.mockResolvedValue(null); }); it('tracks creation with only the destination provider classification', async () => { @@ -272,6 +275,7 @@ describe('custom automation ownership', () => { vi.clearAllMocks(); mocks.canStartAgentMailConversationWithUser.mockResolvedValue(false); mocks.listAvailableAgentMailOutboundIdentities.mockResolvedValue([]); + mocks.resolveDefaultAutomationTarget.mockResolvedValue(null); }); it('returns only member-safe connection flags and timezone without reading admin settings', async () => { @@ -294,12 +298,19 @@ describe('custom automation ownership', () => { }, managerSlackChannelId: null, managerDiscordChannelId: null, + defaultTarget: null, emailIdentities: [], effectiveTimeZone: 'America/New_York', }); expect( mocks.getBackgroundAgentSettingsForDeployment, ).not.toHaveBeenCalled(); + expect(mocks.resolveDefaultAutomationTarget).toHaveBeenCalledWith( + expect.objectContaining({ + ownerUserId: 'member-1', + includeSharedChannels: false, + }), + ); }); it("lists the automation owner's Email identities when an admin edits on their behalf", async () => { @@ -355,6 +366,7 @@ describe('custom automation ownership', () => { ], managerSlackChannelId: null, managerDiscordChannelId: null, + defaultTarget: null, effectiveTimeZone: 'UTC', }); }); @@ -381,6 +393,7 @@ describe('custom automation ownership', () => { }, managerSlackChannelId: 'private-slack', managerDiscordChannelId: 'private-discord', + defaultTarget: null, emailIdentities: [], effectiveTimeZone: 'UTC', }, diff --git a/apps/web/src/trpc/commands/automations/custom-automations.ts b/apps/web/src/trpc/commands/automations/custom-automations.ts index 6503f6a166..1f08446983 100644 --- a/apps/web/src/trpc/commands/automations/custom-automations.ts +++ b/apps/web/src/trpc/commands/automations/custom-automations.ts @@ -15,8 +15,10 @@ import { type CustomAutomation, } from '@roomote/db/server'; import { + CUSTOM_AUTOMATION_DESTINATION_CAPABILITIES, listConnectedCommunicationProviders, listAvailableAgentMailOutboundIdentities, + resolveDefaultAutomationTarget, getCustomAutomationNextRunAt, resolveCustomAutomationSchedule, resolveDeploymentTimeZone, @@ -348,16 +350,22 @@ export async function getCustomAutomationOptionsCommand( // Email identities belong to the automation owner (runs execute as the // creator), so editing someone else's automation lists the owner's // identities rather than the viewer's. - const ownerUserId = input.automationId - ? ((await getOwnedAutomation(auth, input.automationId)).createdByUserId ?? - auth.userId) - : auth.userId; - const [providers, emailIdentities, { timeZone }, settings] = + const automation = input.automationId + ? await getOwnedAutomation(auth, input.automationId) + : null; + const ownerUserId = automation?.createdByUserId ?? auth.userId; + const [providers, emailIdentities, { timeZone }, settings, defaultTarget] = await Promise.all([ listConnectedCommunicationProviders(), listAvailableAgentMailOutboundIdentities(ownerUserId), resolveDeploymentTimeZone(), auth.isAdmin ? getBackgroundAgentSettingsForDeployment() : null, + resolveDefaultAutomationTarget({ + ownerUserId, + capabilities: CUSTOM_AUTOMATION_DESTINATION_CAPABILITIES, + existingTarget: automation?.target, + includeSharedChannels: auth.isAdmin, + }), ]); return { @@ -372,6 +380,7 @@ export async function getCustomAutomationOptionsCommand( // Channel catalogs are bot-scoped, not evidence of a member's access. managerSlackChannelId: settings?.managerSlackChannelId ?? null, managerDiscordChannelId: settings?.managerDiscordChannelId ?? null, + defaultTarget, effectiveTimeZone: timeZone, }; } 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..98f71df10d 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,8 @@ import type { UserAuthSuccess } from '@/types'; +import type { + AutomationTarget, + OptionalAutomationTarget, +} from '@roomote/types'; const { mockTxSelect, @@ -23,6 +27,10 @@ const { mockEnqueueAutomationRecommendations, mockEnqueueAutomationRecommendationInitialRun, mockUpsertAutomation, + mockCreateCustomAutomation, + mockUpdateCustomAutomation, + mockGetCustomAutomationById, + mockResolveDefaultAutomationTarget, mockCaptureActivationAutomationChanged, mockTriggerAutomationCommand, mockTriggerCustomAutomationCommand, @@ -56,6 +64,21 @@ 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: OptionalAutomationTarget; + createdByUserId?: string | null; + } | null> + >(async () => null), + mockResolveDefaultAutomationTarget: vi.fn< + (...args: unknown[]) => Promise + >(async () => null), mockCaptureActivationAutomationChanged: vi.fn(async () => undefined), mockTriggerAutomationCommand: vi.fn(async () => ({ outcome: 'launched' as const, @@ -142,6 +165,10 @@ vi.mock('../automations/custom-automations', () => ({ vi.mock('@roomote/sdk/server', () => ({ AUTOMATION_RECOMMENDATION_REPOSITORY_CAP: 10, + CUSTOM_AUTOMATION_DESTINATION_CAPABILITIES: { + chatProviders: ['slack', 'teams', 'telegram', 'discord'], + email: true, + }, buildAutomationRecommendationFingerprint: vi.fn( (repositoryIds: string[], provider: string | null) => `${provider ?? 'none'}:${repositoryIds.join(',')}`, @@ -149,6 +176,7 @@ vi.mock('@roomote/sdk/server', () => ({ enqueueAutomationRecommendations: mockEnqueueAutomationRecommendations, enqueueAutomationRecommendationInitialRun: mockEnqueueAutomationRecommendationInitialRun, + resolveDefaultAutomationTarget: mockResolveDefaultAutomationTarget, })); vi.mock('@roomote/db/server', () => ({ @@ -164,6 +192,7 @@ vi.mock('@roomote/db/server', () => ({ setupNewState: 'deployment_settings.setup_new_state', runtimeModelConfig: 'deployment_settings.runtime_model_config', }, + automations: { key: 'automations.key' }, environmentVariables: { name: 'environment_variables.name', }, @@ -188,6 +217,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), @@ -1179,6 +1211,9 @@ describe('setup recommendation commands', () => { execute: txExecuteMock, select: mockTxSelect, insert: vi.fn(() => ({ values: insertValuesMock })), + query: { + automations: { findFirst: vi.fn(async () => null) }, + }, }; mockTxSelect.mockReset(); @@ -1200,6 +1235,8 @@ describe('setup recommendation commands', () => { beforeEach(() => { vi.clearAllMocks(); + mockGetCustomAutomationById.mockResolvedValue(null); + mockResolveDefaultAutomationTarget.mockResolvedValue(null); mockTxSelect.mockReset(); mockTxSelect.mockReturnValue(createGroupBySelectChain([])); vi.mocked(getRepositories).mockResolvedValue([ @@ -1326,6 +1363,185 @@ describe('setup recommendation commands', () => { expect(result?.applicationState).toBe('applied'); }); + it('defaults a built-in report only within its declared capabilities', async () => { + const reportTarget = { + provider: 'slack' as const, + targetKind: 'slack_channel' as const, + externalRef: 'C123', + }; + mockResolveDefaultAutomationTarget.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: 'built-in.ci-failure-triage:1', + candidateId: 'built-in.ci-failure-triage', + rank: 1, + score: 1, + explanation: 'Fix broken builds.', + enabled: true, + lastRunTaskId: null, + automationId: null, + }, + ], + }, + }); + + await applySetupRecommendationsCommand(buildMockAuth()); + + expect(mockResolveDefaultAutomationTarget).toHaveBeenCalledWith( + expect.objectContaining({ + capabilities: expect.objectContaining({ email: false }), + }), + ); + expect(mockUpsertAutomation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ targets: [reportTarget] }), + ); + }); + + it('uses the shared default for a recommended custom automation', async () => { + const reportTarget = { + provider: 'email' as const, + targetKind: 'email_user' as const, + externalRef: 'setup-test-user', + metadata: { emailIdentityId: 'verified:setup-test-user:hash' }, + }; + mockResolveDefaultAutomationTarget.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(mockResolveDefaultAutomationTarget).toHaveBeenCalledWith( + expect.objectContaining({ + ownerUserId: 'setup-test-user', + includeSetupHandoff: true, + }), + ); + expect(mockCreateCustomAutomation).toHaveBeenCalledWith( + expect.objectContaining({ target: reportTarget }), + expect.anything(), + ); + }); + + it('preserves an existing explicit destination without resolving a default', async () => { + const existingTarget = { + provider: 'teams' as const, + targetKind: 'teams_channel' as const, + externalRef: 'conversation-1', + }; + mockGetCustomAutomationById.mockResolvedValue({ + id: 'custom-automation-1', + target: existingTarget, + }); + 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(mockResolveDefaultAutomationTarget).not.toHaveBeenCalled(); + expect(mockUpdateCustomAutomation).toHaveBeenCalledWith( + 'custom-automation-1', + expect.objectContaining({ target: existingTarget }), + expect.anything(), + ); + }); + + it('resolves a reapplied recommendation for its persisted owner', async () => { + mockGetCustomAutomationById.mockResolvedValue({ + id: 'custom-automation-1', + target: {}, + createdByUserId: 'original-owner', + }); + 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(mockResolveDefaultAutomationTarget).toHaveBeenCalledWith( + expect.objectContaining({ ownerUserId: 'original-owner' }), + ); + }); + 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..0adb5a75a7 100644 --- a/apps/web/src/trpc/commands/setup-new/index.ts +++ b/apps/web/src/trpc/commands/setup-new/index.ts @@ -7,6 +7,7 @@ import { } from '@roomote/telemetry/server'; import { db, + automations, deploymentSettings, environments, environmentVariables, @@ -42,8 +43,10 @@ import { import { AUTOMATION_RECOMMENDATION_REPOSITORY_CAP, buildAutomationRecommendationFingerprint, + CUSTOM_AUTOMATION_DESTINATION_CAPABILITIES, enqueueAutomationRecommendationInitialRun, enqueueAutomationRecommendations, + resolveDefaultAutomationTarget, } from '@roomote/sdk/server'; import { buildRecommendedDeploymentModelConfig, @@ -106,6 +109,9 @@ import { AUTOMATION_RECOMMENDATIONS_CATALOG_VERSION, AUTOMATION_RECOMMENDATION_CATALOG, ALL_REPOSITORIES, + getTriggerableBackgroundAutomationDescriptorByKey, + isAutomationDestinationTarget, + isConfiguredAutomationTarget, } from '@roomote/types'; import type { UserAuthSuccess } from '@/types'; @@ -2598,12 +2604,39 @@ async function applySetupRecommendationInTx( candidate: (typeof AUTOMATION_RECOMMENDATION_CATALOG)[number], ): Promise { if (candidate.source === 'built_in') { + const descriptor = getTriggerableBackgroundAutomationDescriptorByKey( + candidate.automationKey, + ); + const existing = descriptor?.usesManagerChannel + ? await tx.query.automations.findFirst({ + where: eq(automations.key, candidate.automationKey), + columns: { targets: true }, + }) + : null; + const existingTarget = existing?.targets.find( + isAutomationDestinationTarget, + ); + const defaultTarget = + enabled && descriptor?.usesManagerChannel && !existingTarget + ? await resolveDefaultAutomationTarget({ + ownerUserId: auth.userId, + capabilities: { + chatProviders: descriptor.supportedCommunicationProviders, + email: false, + }, + includeSetupHandoff: true, + client: tx, + }) + : null; await upsertAutomation(tx, { key: candidate.automationKey, enabled, schedule: { mode: enabled ? candidate.defaultScheduleMode : 'off', }, + ...(defaultTarget + ? { targets: [...(existing?.targets ?? []), defaultTarget] } + : {}), }); return null; } @@ -2611,6 +2644,15 @@ async function applySetupRecommendationInTx( const existing = recommendation.automationId ? await getCustomAutomationById(recommendation.automationId, tx) : null; + const reportTarget = + enabled && !isConfiguredAutomationTarget(existing?.target) + ? await resolveDefaultAutomationTarget({ + ownerUserId: existing?.createdByUserId ?? auth.userId, + capabilities: CUSTOM_AUTOMATION_DESTINATION_CAPABILITIES, + includeSetupHandoff: true, + client: tx, + }) + : null; const automation = existing ? await updateCustomAutomation( existing.id, @@ -2620,7 +2662,9 @@ async function applySetupRecommendationInTx( enabled, scheduleMode: candidate.template.scheduleMode, environmentId: ALL_REPOSITORIES, - target: {}, + target: isConfiguredAutomationTarget(existing.target) + ? existing.target + : (reportTarget ?? {}), }, tx, ) @@ -2631,7 +2675,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..3a02a7d51f 100644 --- a/packages/sdk/src/server/index.ts +++ b/packages/sdk/src/server/index.ts @@ -39,6 +39,11 @@ export { type AutomationRecommendationInitialRunJob, type AutomationSignalPrefetchJob, } from './lib/automation-recommendations'; +export { + CUSTOM_AUTOMATION_DESTINATION_CAPABILITIES, + resolveDefaultAutomationTarget, + type AutomationDestinationCapabilities, +} from './lib/default-automation-destination'; export { recordLlmUsage, type RecordLlmUsageInput, diff --git a/packages/sdk/src/server/lib/default-automation-destination.test.ts b/packages/sdk/src/server/lib/default-automation-destination.test.ts new file mode 100644 index 0000000000..1d9870bbd6 --- /dev/null +++ b/packages/sdk/src/server/lib/default-automation-destination.test.ts @@ -0,0 +1,287 @@ +import type { DatabaseOrTransaction } from '@roomote/db/server'; + +const mocks = vi.hoisted(() => ({ + settings: vi.fn(), + installations: vi.fn(), + membership: vi.fn(), + discord: vi.fn(), + discordPrimary: vi.fn(), + teams: vi.fn(), + teamsPrimary: vi.fn(), + telegramPrimary: vi.fn(), + teamsCredentials: vi.fn(), + telegramCredentials: 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, + resolveTelegramRuntimeCredentials: mocks.telegramCredentials, + resolveDiscordRuntimeCredentials: mocks.discordCredentials, +})); + +vi.mock('@roomote/slack', () => ({ + SlackNotifier: class { + isAppInChannel = mocks.membership; + }, +})); +vi.mock('./discord-persistence', () => ({ + findDiscordDestinationByChannelId: mocks.discord, + findDiscordDefaultDestination: mocks.discordPrimary, +})); +vi.mock('./teams-primary-conversation', () => ({ + findTeamsPrimaryConversation: mocks.teamsPrimary, +})); +vi.mock('./telegram-primary-chat', () => ({ + findTelegramPrimaryChatId: mocks.telegramPrimary, +})); +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 { + CUSTOM_AUTOMATION_DESTINATION_CAPABILITIES, + resolveDefaultAutomationTarget, +} from './default-automation-destination'; + +describe('resolveDefaultAutomationTarget', () => { + beforeEach(() => { + vi.resetAllMocks(); + mocks.settings.mockResolvedValue({ setupNewState: {} }); + mocks.installations.mockResolvedValue([]); + mocks.membership.mockResolvedValue(false); + mocks.discord.mockResolvedValue(null); + mocks.discordPrimary.mockResolvedValue(null); + mocks.discordCredentials.mockResolvedValue({ botToken: 'token' }); + mocks.connectedProviders.mockResolvedValue([ + 'slack', + 'teams', + 'telegram', + 'discord', + ]); + mocks.directMessage.mockResolvedValue(null); + mocks.teams.mockResolvedValue(null); + mocks.teamsPrimary.mockResolvedValue(null); + mocks.telegramPrimary.mockResolvedValue(null); + mocks.teamsCredentials.mockResolvedValue({ + botAppId: 'app', + botAppPassword: 'password', + }); + mocks.telegramCredentials.mockResolvedValue({ botToken: 'token' }); + mocks.emailIdentities.mockResolvedValue([]); + }); + + it('prefers a usable configured channel over owner DM and Email', async () => { + mocks.settings.mockResolvedValue({ + managerSlackChannelId: ' C12345678 ', + setupNewState: {}, + }); + mocks.installations.mockResolvedValue([ + { botAccessToken: 'token', teamId: 'T123' }, + ]); + mocks.membership.mockResolvedValue(true); + mocks.directMessage.mockResolvedValue({ channelId: 'D123' }); + mocks.emailIdentities.mockResolvedValue([{ id: 'verified:user:hash' }]); + + await expect( + resolveDefaultAutomationTarget({ + ownerUserId: 'user-1', + capabilities: CUSTOM_AUTOMATION_DESTINATION_CAPABILITIES, + }), + ).resolves.toEqual({ + provider: 'slack', + targetKind: 'slack_channel', + externalRef: 'C12345678', + metadata: { slackTeamId: 'T123' }, + }); + expect(mocks.directMessage).not.toHaveBeenCalled(); + expect(mocks.emailIdentities).not.toHaveBeenCalled(); + }); + + it('continues after stale channels and unavailable DM providers', async () => { + mocks.settings.mockResolvedValue({ + managerSlackChannelId: 'CSTALE123', + managerDiscordChannelId: 'discord-stale', + setupNewState: {}, + }); + mocks.directMessage + .mockRejectedValueOnce(new Error('stale Slack link')) + .mockResolvedValueOnce({ + channelId: 'teams-conversation', + serviceUrl: 'https://teams.example.test', + }); + + await expect( + resolveDefaultAutomationTarget({ + ownerUserId: 'user-1', + capabilities: CUSTOM_AUTOMATION_DESTINATION_CAPABILITIES, + }), + ).resolves.toEqual({ + provider: 'teams', + targetKind: 'teams_user', + externalRef: 'user-1', + }); + }); + + it('skips stale DM mappings for disconnected providers', async () => { + mocks.connectedProviders.mockResolvedValue(['slack']); + mocks.directMessage.mockResolvedValue(null); + mocks.emailIdentities.mockResolvedValue([{ id: 'verified:user:hash' }]); + + await expect( + resolveDefaultAutomationTarget({ + ownerUserId: 'user-1', + capabilities: CUSTOM_AUTOMATION_DESTINATION_CAPABILITIES, + }), + ).resolves.toMatchObject({ provider: 'email' }); + expect(mocks.directMessage).toHaveBeenCalledTimes(1); + expect(mocks.directMessage).toHaveBeenCalledWith('slack', 'user-1'); + }); + + it('uses a primary conversation before an owner DM', async () => { + mocks.teamsPrimary.mockResolvedValue({ + conversationId: 'teams-channel', + serviceUrl: 'https://teams.example.test', + }); + mocks.teams.mockResolvedValue({ + workspaceId: 'tenant-1', + serviceUrl: 'https://teams.example.test', + }); + mocks.directMessage.mockResolvedValue({ channelId: 'D123' }); + + await expect( + resolveDefaultAutomationTarget({ + ownerUserId: 'user-1', + capabilities: CUSTOM_AUTOMATION_DESTINATION_CAPABILITIES, + }), + ).resolves.toEqual({ + provider: 'teams', + targetKind: 'teams_channel', + externalRef: 'teams-channel', + metadata: { serviceUrl: 'https://teams.example.test' }, + }); + expect(mocks.directMessage).not.toHaveBeenCalled(); + }); + + it('skips shared channel defaults for member-owned automation options', async () => { + mocks.settings.mockResolvedValue({ + managerSlackChannelId: 'C12345678', + setupNewState: {}, + }); + mocks.teamsPrimary.mockResolvedValue({ + conversationId: 'teams-channel', + serviceUrl: 'https://teams.example.test', + }); + mocks.directMessage.mockResolvedValue({ channelId: 'D123' }); + + await expect( + resolveDefaultAutomationTarget({ + ownerUserId: 'user-1', + capabilities: CUSTOM_AUTOMATION_DESTINATION_CAPABILITIES, + includeSharedChannels: false, + }), + ).resolves.toEqual({ + provider: 'slack', + targetKind: 'slack_user', + externalRef: 'user-1', + }); + expect(mocks.settings).not.toHaveBeenCalled(); + expect(mocks.teamsPrimary).not.toHaveBeenCalled(); + }); + + it('preserves a supported explicit target without probing defaults', async () => { + const explicit = { + provider: 'discord' as const, + targetKind: 'discord_channel' as const, + externalRef: 'explicit-channel', + }; + + await expect( + resolveDefaultAutomationTarget({ + ownerUserId: 'user-1', + capabilities: CUSTOM_AUTOMATION_DESTINATION_CAPABILITIES, + existingTarget: explicit, + }), + ).resolves.toBe(explicit); + expect(mocks.settings).not.toHaveBeenCalled(); + expect(mocks.directMessage).not.toHaveBeenCalled(); + }); + + it('does not select Email when the runner does not support it', async () => { + mocks.emailIdentities.mockResolvedValue([{ id: 'verified:user:hash' }]); + + await expect( + resolveDefaultAutomationTarget({ + ownerUserId: 'user-1', + capabilities: { chatProviders: ['slack'], email: false }, + }), + ).resolves.toBeNull(); + expect(mocks.emailIdentities).not.toHaveBeenCalled(); + }); + + it('uses verified Email only after chat candidates are exhausted', async () => { + mocks.emailIdentities.mockResolvedValue([{ id: 'verified:user:hash' }]); + + await expect( + resolveDefaultAutomationTarget({ + ownerUserId: 'user-1', + capabilities: CUSTOM_AUTOMATION_DESTINATION_CAPABILITIES, + }), + ).resolves.toEqual({ + provider: 'email', + targetKind: 'email_user', + externalRef: 'user-1', + metadata: { emailIdentityId: 'verified:user:hash' }, + }); + }); + + it('returns null when no supported destination is usable', async () => { + const findFirst = vi.fn().mockResolvedValue({ setupNewState: {} }); + const client = { + query: { deploymentSettings: { findFirst } }, + } as unknown as DatabaseOrTransaction; + + await expect( + resolveDefaultAutomationTarget({ + ownerUserId: 'user-1', + capabilities: CUSTOM_AUTOMATION_DESTINATION_CAPABILITIES, + client, + }), + ).resolves.toBeNull(); + expect(findFirst).toHaveBeenCalled(); + expect(mocks.settings).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/sdk/src/server/lib/default-automation-destination.ts b/packages/sdk/src/server/lib/default-automation-destination.ts new file mode 100644 index 0000000000..cb7ddae52b --- /dev/null +++ b/packages/sdk/src/server/lib/default-automation-destination.ts @@ -0,0 +1,304 @@ +import { + and, + db, + deploymentSettings, + eq, + resolveDiscordRuntimeCredentials, + resolveTeamsBotRuntimeCredentials, + resolveTelegramRuntimeCredentials, + slackInstallationChannels, + slackInstallations, + type DatabaseOrTransaction, +} from '@roomote/db/server'; +import { SlackNotifier } from '@roomote/slack'; +import { + AUTOMATION_TARGET_EMAIL_IDENTITY_KEY, + getAutomationTargetKind, + hasSetupChatHandoffDestination, + isConfiguredAutomationTarget, + normalizeSetupNewState, + type AutomationCapableCommunicationProvider, + type AutomationTarget, + type OptionalAutomationTarget, +} from '@roomote/types'; + +import { + findTeamsConversationRoute, + listConnectedCommunicationProviders, +} from '../automations/destination'; +import { listAvailableAgentMailOutboundIdentities } from './agentmail/outbound'; +import { + findDiscordDefaultDestination, + findDiscordDestinationByChannelId, +} from './discord-persistence'; +import { findTeamsPrimaryConversation } from './teams-primary-conversation'; +import { findTelegramPrimaryChatId } from './telegram-primary-chat'; +import { findUserDirectMessageDestination } from './user-direct-message'; + +export type AutomationDestinationCapabilities = { + chatProviders: readonly AutomationCapableCommunicationProvider[]; + email: boolean; +}; + +export const CUSTOM_AUTOMATION_DESTINATION_CAPABILITIES = { + chatProviders: ['slack', 'teams', 'telegram', 'discord'], + email: true, +} as const satisfies AutomationDestinationCapabilities; + +type DefaultAutomationTargetParams = { + ownerUserId: string; + capabilities: AutomationDestinationCapabilities; + existingTarget?: OptionalAutomationTarget | null; + includeSharedChannels?: boolean; + includeSetupHandoff?: boolean; + client?: DatabaseOrTransaction; +}; + +/** + * Selects a persisted default report target without replacing an existing + * explicit target. Defaults follow one shared waterfall: usable configured + * channels, a resolvable owner DM, supported Email, then no destination. + */ +export async function resolveDefaultAutomationTarget({ + ownerUserId, + capabilities, + existingTarget, + includeSharedChannels = true, + includeSetupHandoff = false, + client = db, +}: DefaultAutomationTargetParams): Promise { + if (isConfiguredAutomationTarget(existingTarget)) { + const supported = + existingTarget.provider === 'email' + ? capabilities.email + : capabilities.chatProviders.includes( + existingTarget.provider as AutomationCapableCommunicationProvider, + ); + return supported ? existingTarget : null; + } + + const settings = includeSharedChannels + ? await client.query.deploymentSettings + .findFirst({ + where: eq(deploymentSettings.id, 'default'), + columns: { + managerSlackChannelId: true, + managerDiscordChannelId: true, + setupNewState: true, + }, + }) + .catch(() => null) + : null; + + const channelCandidates: AutomationTarget[] = []; + if (settings?.managerSlackChannelId?.trim()) { + channelCandidates.push({ + provider: 'slack', + targetKind: 'slack_channel', + externalRef: settings.managerSlackChannelId.trim(), + }); + } + if (settings?.managerDiscordChannelId?.trim()) { + channelCandidates.push({ + provider: 'discord', + targetKind: 'discord_channel', + externalRef: settings.managerDiscordChannelId.trim(), + }); + } + + if (includeSetupHandoff) { + const state = normalizeSetupNewState(settings?.setupNewState ?? {}); + 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()) + ) { + channelCandidates.push({ + provider, + targetKind: getAutomationTargetKind(provider, 'channel'), + externalRef: channelId, + ...(provider === 'slack' + ? { metadata: { slackTeamId: state.slackTeamId!.trim() } } + : {}), + }); + } + } + } + + // These persisted primary conversations are channel-level defaults, so they + // precede owner DMs just like an explicitly configured manager channel. + if (includeSharedChannels && capabilities.chatProviders.includes('teams')) { + try { + const primary = await findTeamsPrimaryConversation(); + if (primary) { + channelCandidates.push({ + provider: 'teams', + targetKind: 'teams_channel', + externalRef: primary.conversationId, + }); + } + } catch { + // Continue to the next configured channel convention. + } + } + if ( + includeSharedChannels && + capabilities.chatProviders.includes('telegram') + ) { + try { + const chatId = await findTelegramPrimaryChatId(); + if (chatId) { + channelCandidates.push({ + provider: 'telegram', + targetKind: 'telegram_chat', + externalRef: chatId, + }); + } + } catch { + // Continue to the next configured channel convention. + } + } + if (includeSharedChannels && capabilities.chatProviders.includes('discord')) { + try { + const primary = await findDiscordDefaultDestination(); + if (primary) { + channelCandidates.push({ + provider: 'discord', + targetKind: 'discord_channel', + externalRef: primary.channelId, + }); + } + } catch { + // Continue to owner DMs when no primary channel is usable. + } + } + + for (const candidate of channelCandidates) { + if ( + !capabilities.chatProviders.includes( + candidate.provider as AutomationCapableCommunicationProvider, + ) + ) { + continue; + } + const resolved = await resolveUsableChannelTarget(candidate, client); + if (resolved) return resolved; + } + + const connectedProviders: AutomationCapableCommunicationProvider[] = + await listConnectedCommunicationProviders().catch(() => []); + for (const provider of capabilities.chatProviders) { + if (!connectedProviders.includes(provider)) continue; + try { + if (await findUserDirectMessageDestination(provider, ownerUserId)) { + return { + provider, + targetKind: getAutomationTargetKind(provider, 'direct_message'), + externalRef: ownerUserId, + }; + } + } catch { + // A stale provider link must not block later providers or Email. + } + } + + if (!capabilities.email) return null; + try { + const [identity] = + await listAvailableAgentMailOutboundIdentities(ownerUserId); + return identity + ? { + provider: 'email', + targetKind: 'email_user', + externalRef: ownerUserId, + metadata: { [AUTOMATION_TARGET_EMAIL_IDENTITY_KEY]: identity.id }, + } + : null; + } catch { + return null; + } +} + +async function resolveUsableChannelTarget( + target: AutomationTarget, + client: DatabaseOrTransaction, +): Promise { + try { + if (target.provider === 'slack') { + const teamId = + typeof target.metadata?.slackTeamId === 'string' + ? target.metadata.slackTeamId + : null; + if (!/^[CG][A-Z0-9]{8,}$/i.test(target.externalRef)) return null; + const installations = await client + .select({ + botAccessToken: slackInstallations.botAccessToken, + teamId: slackInstallations.teamId, + }) + .from(slackInstallations) + .innerJoin( + slackInstallationChannels, + and( + eq( + slackInstallationChannels.slackInstallationId, + slackInstallations.id, + ), + eq(slackInstallationChannels.channelId, target.externalRef), + ), + ) + .where( + and( + eq(slackInstallations.isActive, true), + ...(teamId ? [eq(slackInstallations.teamId, teamId)] : []), + ), + ) + .limit(2); + const installation = installations.length === 1 ? installations[0] : null; + if ( + installation?.botAccessToken && + installation.teamId && + (await new SlackNotifier(installation.botAccessToken).isAppInChannel( + target.externalRef, + )) === true + ) { + return { + ...target, + metadata: { ...target.metadata, slackTeamId: installation.teamId }, + }; + } + return null; + } + + if (target.provider === 'discord') { + return (await resolveDiscordRuntimeCredentials()).botToken && + (await findDiscordDestinationByChannelId(target.externalRef)) + ? target + : null; + } + + if (target.provider === 'teams') { + const credentials = await resolveTeamsBotRuntimeCredentials(); + if (!credentials.botAppId || !credentials.botAppPassword) return null; + const route = await findTeamsConversationRoute(target.externalRef); + return route?.serviceUrl.trim() + ? { ...target, metadata: { serviceUrl: route.serviceUrl } } + : null; + } + + if (target.provider === 'telegram') { + return (await resolveTelegramRuntimeCredentials()).botToken + ? target + : null; + } + } catch { + // Continue the waterfall when a configured candidate is stale. + } + return null; +} diff --git a/packages/types/src/manage-custom-automations-tool.test.ts b/packages/types/src/manage-custom-automations-tool.test.ts index 094def4b31..08c74276f9 100644 --- a/packages/types/src/manage-custom-automations-tool.test.ts +++ b/packages/types/src/manage-custom-automations-tool.test.ts @@ -430,6 +430,7 @@ describe('manage custom automations tool contract', () => { kind: 'verified', }, ], + defaultTarget: null, }); expect( buildManageCustomAutomationsRequest({ diff --git a/packages/types/src/manage-custom-automations-tool.ts b/packages/types/src/manage-custom-automations-tool.ts index e032d725e1..8034852f0d 100644 --- a/packages/types/src/manage-custom-automations-tool.ts +++ b/packages/types/src/manage-custom-automations-tool.ts @@ -257,6 +257,9 @@ export function compactManageCustomAutomationsResult( : {}; }) : [], + defaultTarget: asRecord(result.defaultTarget) + ? compactAutomation({ target: result.defaultTarget }) + : null, }; case 'resolve_schedule': return compactScheduleResolution(result);