diff --git a/apps/api/src/handlers/tasks/submitTaskSuggestions.ts b/apps/api/src/handlers/tasks/submitTaskSuggestions.ts index e65a53a89..6a186770e 100644 --- a/apps/api/src/handlers/tasks/submitTaskSuggestions.ts +++ b/apps/api/src/handlers/tasks/submitTaskSuggestions.ts @@ -1308,34 +1308,46 @@ export async function submitTaskSuggestions( resolveScheduledSuggestionSlackConfig(payload.suggestionSource) .automationKey, ); - const slackDelivered = - suggestionRuntime.destination?.provider === 'discord' - ? false - : await postSuggestedTasksSummaryToSlack({ - sourceTaskId: taskId, - createdByUserId, - suggestionSource: payload.suggestionSource, - historicalThreadFeedbackDebugSnippet: - payload.historicalThreadFeedbackDebugSnippet ?? null, - suggestions: persistedSuggestions, - }); - - if (!slackDelivered) { - const discordDelivered = await postScheduledSuggestionsToDiscord({ - sourceTaskId: taskId, - createdByUserId, - suggestionSource: payload.suggestionSource, - suggestions: persistedSuggestions, - }); - - if (!discordDelivered) { - const telegramDelivered = await postScheduledSuggestionsToTelegram({ + // An automation with its own non-Slack destination skips higher-precedence + // surfaces — the summary belongs on that surface. + const preferredProvider = suggestionRuntime.destination?.provider; + const preferredNonSlack = + preferredProvider === 'discord' || + preferredProvider === 'telegram' || + preferredProvider === 'teams'; + const slackDelivered = preferredNonSlack + ? false + : await postSuggestedTasksSummaryToSlack({ sourceTaskId: taskId, createdByUserId, suggestionSource: payload.suggestionSource, + historicalThreadFeedbackDebugSnippet: + payload.historicalThreadFeedbackDebugSnippet ?? null, suggestions: persistedSuggestions, }); + if (!slackDelivered) { + const discordDelivered = + preferredProvider === 'telegram' || preferredProvider === 'teams' + ? false + : await postScheduledSuggestionsToDiscord({ + sourceTaskId: taskId, + createdByUserId, + suggestionSource: payload.suggestionSource, + suggestions: persistedSuggestions, + }); + + if (!discordDelivered) { + const telegramDelivered = + preferredProvider === 'teams' + ? false + : await postScheduledSuggestionsToTelegram({ + sourceTaskId: taskId, + createdByUserId, + suggestionSource: payload.suggestionSource, + suggestions: persistedSuggestions, + }); + if (!telegramDelivered) { await postScheduledSuggestionsToTeams({ sourceTaskId: taskId, diff --git a/apps/api/src/handlers/teams/__tests__/automation-suggestions.test.ts b/apps/api/src/handlers/teams/__tests__/automation-suggestions.test.ts index 77591a72b..a05892998 100644 --- a/apps/api/src/handlers/teams/__tests__/automation-suggestions.test.ts +++ b/apps/api/src/handlers/teams/__tests__/automation-suggestions.test.ts @@ -3,25 +3,23 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const { buildRootMessageMock, findPrimaryConversationMock, - findTelegramPrimaryChatIdMock, + getAutomationRuntimeMock, insertMock, insertOnConflictDoNothingMock, insertValuesMock, postMessageMock, selectLimitMock, selectWhereRowsMock, - telegramCredentialsMock, } = vi.hoisted(() => ({ buildRootMessageMock: vi.fn(), findPrimaryConversationMock: vi.fn(), - findTelegramPrimaryChatIdMock: vi.fn(), + getAutomationRuntimeMock: vi.fn(), insertMock: vi.fn(), insertOnConflictDoNothingMock: vi.fn(), insertValuesMock: vi.fn(), postMessageMock: vi.fn(), selectLimitMock: vi.fn(), selectWhereRowsMock: vi.fn(), - telegramCredentialsMock: vi.fn(), })); vi.mock('@roomote/env', () => ({ @@ -39,16 +37,21 @@ vi.mock('@roomote/db/server', () => ({ metadata: 'metadata', }, environments: { id: 'id', name: 'name' }, - slackInstallations: { id: 'id', isActive: 'isActive' }, + teamsInstallations: { + conversationId: 'conversationId', + serviceUrl: 'serviceUrl', + isActive: 'isActive', + }, and: vi.fn((...conditions: unknown[]) => ({ and: conditions })), eq: vi.fn((left: unknown, right: unknown) => ({ eq: [left, right] })), inArray: vi.fn((column: unknown, values: unknown) => ({ inArray: [column, values], })), + isNotNull: vi.fn((column: unknown) => ({ isNotNull: column })), sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ sql: [Array.from(strings), values], })), - resolveTelegramRuntimeCredentials: telegramCredentialsMock, + getAutomationRuntime: getAutomationRuntimeMock, db: { insert: insertMock, select: vi.fn(() => ({ @@ -75,10 +78,6 @@ vi.mock('../automation-messaging.js', () => ({ postTeamsAutomationMessageBestEffort: postMessageMock, })); -vi.mock('../../telegram/primary-chat.js', () => ({ - findTelegramPrimaryChatId: findTelegramPrimaryChatIdMock, -})); - vi.mock('../../tasks/scheduled-suggestion-root-summary.js', () => ({ buildScheduledSuggestionRootMessage: buildRootMessageMock, })); @@ -99,15 +98,10 @@ function buildSuggestion(id: string, title: string) { describe('postScheduledSuggestionsToTeams', () => { beforeEach(() => { vi.clearAllMocks(); - // The only `.limit()` lookup is now the dedup query (Slack/Telegram - // self-suppression was removed; precedence is owned by the caller). + // The primary-conversation path only performs the dedup lookup. A + // configured Teams target adds one installation lookup before it. selectLimitMock.mockResolvedValue([]); - telegramCredentialsMock.mockResolvedValue({ - botToken: null, - webhookSecret: null, - botUsername: null, - }); - findTelegramPrimaryChatIdMock.mockResolvedValue(null); + getAutomationRuntimeMock.mockResolvedValue({ destination: null }); findPrimaryConversationMock.mockResolvedValue({ conversationId: '19:channel@thread.tacv2', serviceUrl: 'https://smba.trafficmanager.net/amer/', @@ -167,6 +161,39 @@ describe('postScheduledSuggestionsToTeams', () => { expect(rows[0]!.channelId).toBe('19:channel@thread.tacv2'); }); + it('uses the configured Teams destination instead of the primary conversation', async () => { + getAutomationRuntimeMock.mockResolvedValue({ + destination: { + provider: 'teams', + channelId: '19:configured@thread.tacv2', + source: 'automation_target', + }, + }); + selectLimitMock + .mockResolvedValueOnce([ + { + conversationId: '19:configured@thread.tacv2', + serviceUrl: 'https://smba.trafficmanager.net/configured/', + }, + ]) + .mockResolvedValueOnce([]); + + await postScheduledSuggestionsToTeams({ + sourceTaskId: 'task-configured', + createdByUserId: 'user-1', + suggestionSource: 'suggest_ideas', + suggestions: [buildSuggestion('aaa', 'Fix crash')], + }); + + expect(findPrimaryConversationMock).not.toHaveBeenCalled(); + expect(postMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + conversationId: '19:configured@thread.tacv2', + serviceUrl: 'https://smba.trafficmanager.net/configured/', + }), + ); + }); + it('skips when tracked messages already exist for the source task', async () => { // The dedup lookup finds a tracked row (surface self-suppression removed). selectLimitMock.mockResolvedValueOnce([{ id: 'existing' }]); diff --git a/apps/api/src/handlers/teams/automation-suggestions.ts b/apps/api/src/handlers/teams/automation-suggestions.ts index 020b673dd..34fde0d06 100644 --- a/apps/api/src/handlers/teams/automation-suggestions.ts +++ b/apps/api/src/handlers/teams/automation-suggestions.ts @@ -3,8 +3,11 @@ import { db, environments, eq, + getAutomationRuntime, inArray, + isNotNull, sql, + teamsInstallations, trackedMessages, } from '@roomote/db/server'; import { getScheduledSuggestionBackgroundAutomationDescriptor } from '@roomote/types'; @@ -56,7 +59,39 @@ export async function postScheduledSuggestionsToTeams(params: { return; } - const conversation = await findTeamsPrimaryConversation(); + const slackConfig = resolveScheduledSuggestionSlackConfig( + params.suggestionSource, + ); + const runtime = await getAutomationRuntime(slackConfig.automationKey); + let conversation = + runtime.destination?.provider === 'teams' + ? await (async () => { + const [row] = await db + .select({ + conversationId: teamsInstallations.conversationId, + serviceUrl: teamsInstallations.serviceUrl, + }) + .from(teamsInstallations) + .where( + and( + eq( + teamsInstallations.conversationId, + runtime.destination!.channelId, + ), + eq(teamsInstallations.isActive, true), + isNotNull(teamsInstallations.serviceUrl), + ), + ) + .limit(1); + return row?.serviceUrl + ? { + conversationId: row.conversationId, + serviceUrl: row.serviceUrl, + } + : null; + })() + : null; + conversation ??= await findTeamsPrimaryConversation(); if (!conversation) { apiLogger.debug( @@ -65,10 +100,6 @@ export async function postScheduledSuggestionsToTeams(params: { return; } - const slackConfig = resolveScheduledSuggestionSlackConfig( - params.suggestionSource, - ); - const [existingSummaryMessage] = await db .select({ id: trackedMessages.id }) .from(trackedMessages) diff --git a/apps/api/src/handlers/telegram/__tests__/automation-suggestions.test.ts b/apps/api/src/handlers/telegram/__tests__/automation-suggestions.test.ts index fecaaaaea..1930a5396 100644 --- a/apps/api/src/handlers/telegram/__tests__/automation-suggestions.test.ts +++ b/apps/api/src/handlers/telegram/__tests__/automation-suggestions.test.ts @@ -3,10 +3,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const { envMock, findTelegramPrimaryChatIdMock, + getAutomationRuntimeMock, + getAutomationTelegramTopicThreadIdMock, + persistAutomationTelegramTopicThreadMock, + createTelegramForumTopicBestEffortMock, + postTelegramMessageBestEffortMock, insertMock, insertOnConflictDoNothingMock, insertValuesMock, - postTelegramMessageBestEffortMock, selectLimitMock, buildRootMessageMock, } = vi.hoisted(() => ({ @@ -14,10 +18,14 @@ const { R_TELEGRAM_BOT_TOKEN: 'bot-token' as string | undefined, }, findTelegramPrimaryChatIdMock: vi.fn(), + getAutomationRuntimeMock: vi.fn(), + getAutomationTelegramTopicThreadIdMock: vi.fn(), + persistAutomationTelegramTopicThreadMock: vi.fn(), + createTelegramForumTopicBestEffortMock: vi.fn(), + postTelegramMessageBestEffortMock: vi.fn(), insertMock: vi.fn(), insertOnConflictDoNothingMock: vi.fn(), insertValuesMock: vi.fn(), - postTelegramMessageBestEffortMock: vi.fn(), selectLimitMock: vi.fn(), buildRootMessageMock: vi.fn(), })); @@ -45,6 +53,10 @@ vi.mock('@roomote/db/server', () => ({ webhookSecret: null, botUsername: null, })), + getAutomationRuntime: getAutomationRuntimeMock, + getAutomationTelegramTopicThreadId: getAutomationTelegramTopicThreadIdMock, + persistAutomationTelegramTopicThread: + persistAutomationTelegramTopicThreadMock, db: { insert: insertMock, select: vi.fn(() => ({ @@ -60,14 +72,18 @@ vi.mock('../primary-chat.js', () => ({ })); vi.mock('../replies.js', () => ({ - postTelegramMessageInNewTopicBestEffort: postTelegramMessageBestEffortMock, + createTelegramForumTopicBestEffort: createTelegramForumTopicBestEffortMock, + postTelegramMessageBestEffort: postTelegramMessageBestEffortMock, })); vi.mock('../../tasks/scheduled-suggestion-root-summary.js', () => ({ buildScheduledSuggestionRootMessage: buildRootMessageMock, })); -import { postScheduledSuggestionsToTelegram } from '../automation-suggestions'; +import { + postScheduledSuggestionsToTelegram, + SUGGEST_IDEAS_TELEGRAM_TOPIC_NAME, +} from '../automation-suggestions'; function buildSuggestion(id: string, title: string) { return { @@ -84,21 +100,27 @@ describe('postScheduledSuggestionsToTelegram', () => { vi.clearAllMocks(); envMock.R_TELEGRAM_BOT_TOKEN = 'bot-token'; findTelegramPrimaryChatIdMock.mockResolvedValue('8846357662'); + getAutomationRuntimeMock.mockResolvedValue({ + destination: null, + targets: [], + }); + getAutomationTelegramTopicThreadIdMock.mockReturnValue(null); + persistAutomationTelegramTopicThreadMock.mockResolvedValue(undefined); + createTelegramForumTopicBestEffortMock.mockResolvedValue({ + threadId: '88', + }); + postTelegramMessageBestEffortMock.mockResolvedValue({ + messageId: '950', + }); insertMock.mockReturnValue({ values: insertValuesMock }); insertValuesMock.mockReturnValue({ onConflictDoNothing: insertOnConflictDoNothingMock, }); insertOnConflictDoNothingMock.mockResolvedValue(undefined); - postTelegramMessageBestEffortMock.mockResolvedValue({ - messageId: '950', - threadId: '88', - }); buildRootMessageMock.mockResolvedValue({ summaryText: 'I triaged the latest Sentry issues.', actionFooterText: 'footer', }); - // The only `.limit()` lookup is now the dedup query (Slack self-suppression - // was removed; surface precedence is owned by the caller). selectLimitMock.mockResolvedValue([]); }); @@ -113,41 +135,66 @@ describe('postScheduledSuggestionsToTelegram', () => { ], }); + expect(createTelegramForumTopicBestEffortMock).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Suggested tasks' }), + ); expect(postTelegramMessageBestEffortMock).toHaveBeenCalledTimes(1); const posted = postTelegramMessageBestEffortMock.mock.calls[0]![0] as { - topicName: string; text: string; buttons: Array>; + threadId?: string; }; expect(posted.text).toContain('I triaged the latest Sentry issues.'); expect(posted.text).toContain('Fix crash'); - expect(posted.topicName).toBe('Suggested tasks'); + expect(posted.threadId).toBe('88'); expect(posted.buttons).toEqual([ [expect.objectContaining({ callbackData: 'idea:aaa' })], [expect.objectContaining({ callbackData: 'idea:bbb' })], ]); + }); - const rows = insertValuesMock.mock.calls[0]![0] as Array<{ - kind: string; - surface: string; - dedupeKey: string; - messageTs: string; - workItemId: string; - metadata: { suggestionType: string; suggestionKey: string }; - }>; - expect(rows.map((row) => row.metadata.suggestionType)).toEqual([ - 'sentry_triage', - 'sentry_triage', - ]); - expect(rows.every((row) => row.kind === 'suggestion_card')).toBe(true); - expect(rows.every((row) => row.surface === 'telegram')).toBe(true); - expect(rows.map((row) => row.workItemId)).toEqual(['aaa', 'bbb']); - expect(new Set(rows.map((row) => row.dedupeKey)).size).toBe(2); + it('reuses a sticky Suggest Ideas topic on later runs', async () => { + getAutomationTelegramTopicThreadIdMock.mockReturnValue('topic-7'); + + await postScheduledSuggestionsToTelegram({ + sourceTaskId: 'task-2', + createdByUserId: 'user-1', + suggestionSource: 'suggest_ideas', + suggestions: [buildSuggestion('aaa', 'Ship idea')], + }); + + expect(createTelegramForumTopicBestEffortMock).not.toHaveBeenCalled(); + expect(postTelegramMessageBestEffortMock).toHaveBeenCalledWith( + expect.objectContaining({ + threadId: 'topic-7', + text: expect.stringContaining('Ship idea'), + }), + ); + expect(persistAutomationTelegramTopicThreadMock).not.toHaveBeenCalled(); + }); + + it('creates and persists a sticky Suggest Ideas topic when none exists', async () => { + await postScheduledSuggestionsToTelegram({ + sourceTaskId: 'task-3', + createdByUserId: null, + suggestionSource: 'suggest_ideas', + suggestions: [buildSuggestion('aaa', 'First idea')], + }); + + expect(createTelegramForumTopicBestEffortMock).toHaveBeenCalledWith({ + chatId: '8846357662', + name: SUGGEST_IDEAS_TELEGRAM_TOPIC_NAME, + }); + expect(persistAutomationTelegramTopicThreadMock).toHaveBeenCalledWith({ + automationKey: 'suggester', + chatId: '8846357662', + threadId: '88', + topicName: SUGGEST_IDEAS_TELEGRAM_TOPIC_NAME, + }); }); it('skips when tracked messages already exist for the source task', async () => { - // The dedup lookup finds a tracked row (Slack self-suppression removed). selectLimitMock.mockResolvedValueOnce([{ id: 'install-1' }]); const delivered = await postScheduledSuggestionsToTelegram({ @@ -158,11 +205,74 @@ describe('postScheduledSuggestionsToTelegram', () => { }); expect(postTelegramMessageBestEffortMock).not.toHaveBeenCalled(); - // Already delivered on a prior run -> reported as delivered so Teams stays - // suppressed. expect(delivered).toBe(true); }); + it('recreates and persists a sticky topic when an existing topic post fails', async () => { + getAutomationTelegramTopicThreadIdMock.mockReturnValue('stale-topic'); + postTelegramMessageBestEffortMock + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ messageId: '999' }); + createTelegramForumTopicBestEffortMock.mockResolvedValue({ + threadId: 'fresh-topic', + }); + + const delivered = await postScheduledSuggestionsToTelegram({ + sourceTaskId: 'task-repair', + createdByUserId: null, + suggestionSource: 'suggest_ideas', + suggestions: [buildSuggestion('aaa', 'Repair idea')], + }); + + expect(delivered).toBe(true); + expect(createTelegramForumTopicBestEffortMock).toHaveBeenCalledWith({ + chatId: '8846357662', + name: SUGGEST_IDEAS_TELEGRAM_TOPIC_NAME, + }); + expect(persistAutomationTelegramTopicThreadMock).toHaveBeenCalledWith({ + automationKey: 'suggester', + chatId: '8846357662', + threadId: 'fresh-topic', + topicName: SUGGEST_IDEAS_TELEGRAM_TOPIC_NAME, + }); + expect(postTelegramMessageBestEffortMock).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ threadId: 'stale-topic' }), + ); + expect(postTelegramMessageBestEffortMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ threadId: 'fresh-topic' }), + ); + }); + + it('falls back to the parent chat when topic creation is unavailable', async () => { + createTelegramForumTopicBestEffortMock.mockResolvedValue(null); + postTelegramMessageBestEffortMock.mockResolvedValue({ messageId: '111' }); + + const delivered = await postScheduledSuggestionsToTelegram({ + sourceTaskId: 'task-fallback', + createdByUserId: null, + suggestionSource: 'suggest_ideas', + suggestions: [buildSuggestion('aaa', 'Chat idea')], + }); + + expect(delivered).toBe(true); + expect(persistAutomationTelegramTopicThreadMock).not.toHaveBeenCalled(); + expect(postTelegramMessageBestEffortMock).toHaveBeenCalledWith( + expect.objectContaining({ + chatId: '8846357662', + text: expect.stringContaining('Chat idea'), + }), + ); + expect( + ( + postTelegramMessageBestEffortMock.mock.calls[0]![0] as { + threadId?: string; + } + ).threadId, + ).toBeUndefined(); + }); + it('caps buttons at five and notes the overflow', async () => { await postScheduledSuggestionsToTelegram({ sourceTaskId: 'task-1', diff --git a/apps/api/src/handlers/telegram/automation-suggestions.ts b/apps/api/src/handlers/telegram/automation-suggestions.ts index 4161179d4..a745b4e3f 100644 --- a/apps/api/src/handlers/telegram/automation-suggestions.ts +++ b/apps/api/src/handlers/telegram/automation-suggestions.ts @@ -3,19 +3,29 @@ import { and, db, eq, + getAutomationRuntime, + getAutomationTelegramTopicThreadId, + persistAutomationTelegramTopicThread, resolveTelegramRuntimeCredentials, sql, trackedMessages, } from '@roomote/db/server'; +import type { BackgroundAutomationKey } from '@roomote/types'; import { apiLogger } from '../../logging.js'; import { resolveScheduledSuggestionSlackConfig } from '../tasks/background-automation-slack.js'; import { buildScheduledSuggestionRootMessage } from '../tasks/scheduled-suggestion-root-summary.js'; import { findTelegramPrimaryChatId } from './primary-chat.js'; -import { postTelegramMessageInNewTopicBestEffort } from './replies.js'; +import { + createTelegramForumTopicBestEffort, + postTelegramMessageBestEffort, +} from './replies.js'; const MAX_TELEGRAM_AUTOMATION_SUGGESTIONS = 5; +/** Sticky forum-topic name for Suggest Ideas recurring delivery. */ +export const SUGGEST_IDEAS_TELEGRAM_TOPIC_NAME = 'Suggest Ideas'; + type TelegramAutomationSuggestion = { id: string; title: string; @@ -24,17 +34,93 @@ type TelegramAutomationSuggestion = { targetRepositoryFullName: string | null; }; +async function resolveTelegramSuggestionChatId( + automationKey: BackgroundAutomationKey, +): Promise { + const runtime = await getAutomationRuntime(automationKey); + if (runtime.destination?.provider === 'telegram') { + return runtime.destination.channelId; + } + return findTelegramPrimaryChatId(); +} + +/** + * Post into an existing sticky topic when present; otherwise create a recurring + * topic once, persist its thread id, and post there. Falls back to the parent + * chat when topics are unavailable. + */ +async function postToStickyOrNewTopic(params: { + automationKey: BackgroundAutomationKey; + chatId: string; + stickyTopicName: string; + text: string; + buttons: CommunicationMessageButton[][]; +}): Promise<{ messageId: string; threadId?: string } | null> { + const runtime = await getAutomationRuntime(params.automationKey); + const existingThreadId = getAutomationTelegramTopicThreadId(runtime); + + if (existingThreadId) { + const reused = await postTelegramMessageBestEffort({ + chatId: params.chatId, + threadId: existingThreadId, + text: params.text, + textFormat: 'markdown', + buttons: params.buttons, + }); + if (reused) { + return { messageId: reused.messageId, threadId: existingThreadId }; + } + apiLogger.debug( + `[AutomationSuggestionLifecycle] Sticky Telegram topic ${existingThreadId} failed for ${params.automationKey}; recreating`, + ); + } + + const topic = await createTelegramForumTopicBestEffort({ + chatId: params.chatId, + name: params.stickyTopicName, + }); + + if (topic?.threadId) { + await persistAutomationTelegramTopicThread({ + automationKey: params.automationKey, + chatId: params.chatId, + threadId: topic.threadId, + topicName: params.stickyTopicName, + }); + const posted = await postTelegramMessageBestEffort({ + chatId: params.chatId, + threadId: topic.threadId, + text: params.text, + textFormat: 'markdown', + buttons: params.buttons, + }); + return posted + ? { messageId: posted.messageId, threadId: topic.threadId } + : null; + } + + // Topics unavailable (no Threaded Mode / Manage Topics): post in the chat. + const fallback = await postTelegramMessageBestEffort({ + chatId: params.chatId, + text: params.text, + textFormat: 'markdown', + buttons: params.buttons, + }); + return fallback ? { messageId: fallback.messageId } : null; +} + /** * Telegram counterpart of the scheduled-automation Slack summaries * (suggester, Sentry triage, Dependabot triage, security/code-quality * auditors, CI failure triage). Posts one message to the captured primary - * chat: the automation's summary plus a start button per suggestion — the same - * single-notification shape as the onboarding suggestions intro. + * chat orconfigured Telegram destination. + * + * Suggest Ideas reuses a sticky "Suggest Ideas" forum topic (create once, + * recreate on failure). Other automations still open a one-shot "Suggested + * tasks" topic per delivery for backwards compatibility with existing flows. * * Returns whether the summary was DELIVERED (posted now, or already present - * from a prior run). Surface precedence (Slack > Telegram > Teams) is owned by - * the caller in submitTaskSuggestions, which only invokes this when Slack did - * not deliver — this function no longer self-suppresses on Slack existence. + * from a prior run). Surface precedence is owned by the caller. */ export async function postScheduledSuggestionsToTelegram(params: { sourceTaskId: string; @@ -59,19 +145,20 @@ export async function postScheduledSuggestionsToTelegram(params: { return false; } - const chatId = await findTelegramPrimaryChatId(); + const slackConfig = resolveScheduledSuggestionSlackConfig( + params.suggestionSource, + ); + const chatId = await resolveTelegramSuggestionChatId( + slackConfig.automationKey, + ); if (!chatId) { apiLogger.debug( - `[AutomationSuggestionLifecycle] Skip Telegram automation summary because no primary chat is captured for sourceTaskId=${sourceTaskId}`, + `[AutomationSuggestionLifecycle] Skip Telegram automation summary because no chat is available for sourceTaskId=${sourceTaskId}`, ); return false; } - const slackConfig = resolveScheduledSuggestionSlackConfig( - params.suggestionSource, - ); - const [existingSummaryMessage] = await db .select({ id: trackedMessages.id }) .from(trackedMessages) @@ -121,13 +208,32 @@ export async function postScheduledSuggestionsToTelegram(params: { }, ], ); - const posted = await postTelegramMessageInNewTopicBestEffort({ - chatId, - topicName: 'Suggested tasks', - text: messageLines.join('\n'), - textFormat: 'markdown', - buttons, - }); + + const useStickyTopic = slackConfig.automationKey === 'suggester'; + const posted = useStickyTopic + ? await postToStickyOrNewTopic({ + automationKey: 'suggester', + chatId, + stickyTopicName: SUGGEST_IDEAS_TELEGRAM_TOPIC_NAME, + text: messageLines.join('\n'), + buttons, + }) + : await (async () => { + const topic = await createTelegramForumTopicBestEffort({ + chatId, + name: 'Suggested tasks', + }); + const result = await postTelegramMessageBestEffort({ + chatId, + ...(topic ? { threadId: topic.threadId } : {}), + text: messageLines.join('\n'), + textFormat: 'markdown', + buttons, + }); + return result + ? { messageId: result.messageId, ...(topic ?? {}) } + : null; + })(); if (!posted) { return false; @@ -145,6 +251,7 @@ export async function postScheduledSuggestionsToTelegram(params: { kind: 'suggestion_card' as const, dedupeKey: `${chatId}:${messageTs}`, channelId: chatId, + ...(posted.threadId ? { threadTs: posted.threadId } : {}), messageTs, workItemId: suggestion.id, createdByUserId, @@ -160,7 +267,7 @@ export async function postScheduledSuggestionsToTelegram(params: { }); apiLogger.debug( - `[AutomationSuggestionLifecycle] Published ${limitedSuggestions.length} ${slackConfig.automationKey} suggestions to Telegram chat ${chatId} for sourceTaskId=${sourceTaskId}`, + `[AutomationSuggestionLifecycle] Published ${limitedSuggestions.length} ${slackConfig.automationKey} suggestions to Telegram chat ${chatId}${posted.threadId ? ` topic ${posted.threadId}` : ''} for sourceTaskId=${sourceTaskId}`, ); return true; diff --git a/apps/docs/automations.mdx b/apps/docs/automations.mdx index 6e1c0e91d..dd2e69431 100644 --- a/apps/docs/automations.mdx +++ b/apps/docs/automations.mdx @@ -139,6 +139,13 @@ Discord channel (see [Communications](/communications#supported-providers) for connecting those surfaces). +**Suggest Ideas** can also report to Teams or Telegram as first-class +destinations. When you select Telegram, Roomote creates a sticky **Suggest +Ideas** forum topic in your primary Telegram chat (create once, reuse on later +runs) and posts digests there. You cannot pick an existing Telegram thread — +Roomote owns the recurring topic. When you select Teams, digests go to the +primary Teams conversation captured for the deployment. + Cards also show capability badges for what each automation supports today: the chat surfaces it can report to and the source-control providers it works with. Triage Dependabot Alerts and Triage CodeQL Alerts are GitHub-only by 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 7c1d4f645..bbb614f56 100644 --- a/apps/web/src/components/settings/automations/AutomationsSettings.client.test.tsx +++ b/apps/web/src/components/settings/automations/AutomationsSettings.client.test.tsx @@ -78,6 +78,8 @@ const baseFormState: FormState = { suggesterFrequency: 'off', suggesterSlackChannel: '', suggesterDiscordChannel: '', + suggesterUseTelegram: false, + suggesterUseTeams: false, suggesterInstructions: '', suggesterRoutingMode: DEFAULT_SUGGESTER_ROUTING_MODE, suggesterRoutingInstructions: '', 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 33a116207..505b1605a 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 @@ -33,6 +33,8 @@ const state = vi.hoisted(() => ({ capabilities: { slackConnected: true, discordConnected: false, + telegramConnected: false, + teamsConnected: false, requiresSlackReconnect: false, missingScopes: [], slackWorkspaceDomain: 'acme', @@ -90,6 +92,8 @@ const state = vi.hoisted(() => ({ suggesterFrequency: 'off' as const, suggesterSlackChannelId: null, suggesterDiscordChannelId: null, + suggesterTelegramChatId: null, + suggesterTeamsChannelId: null, suggesterInstructions: null, suggesterRoutingMode: 'manager_channel' as const, suggesterRoutingInstructions: null, @@ -622,10 +626,11 @@ describe('AutomationsSettings', () => { // GitHub/GitLab/Gitea, and manager stats is provider-neutral now and // shows no source-control badge. expect((await screen.findAllByText('GitHub only')).length).toBe(2); - // The suggester supports Slack and Discord destinations; the other - // manager automations post to all configured communication providers. + // Full chat coverage for the suggester (no limited-comms badge); the other + // manager automations already cover all communication providers. expect(screen.queryByText('Slack only')).toBeNull(); - expect(screen.getAllByText('Slack · Discord only').length).toBe(1); + expect(screen.queryByText('Slack · Discord · Telegram only')).toBeNull(); + expect(screen.queryByText(/Telegram only$/)).toBeNull(); // conflict_resolver and ci_failure_triage support GitHub, GitLab, and // Azure DevOps (no Gitea/Bitbucket signal). expect( diff --git a/apps/web/src/components/settings/automations/AutomationsSettings.tsx b/apps/web/src/components/settings/automations/AutomationsSettings.tsx index 92566942c..15b7c2273 100644 --- a/apps/web/src/components/settings/automations/AutomationsSettings.tsx +++ b/apps/web/src/components/settings/automations/AutomationsSettings.tsx @@ -152,6 +152,8 @@ type FieldErrors = Partial< | 'suggesterDiscordChannel' | 'announcerDiscordChannel' | 'platformIssueDiscordChannel' + | 'suggesterUseTelegram' + | 'suggesterUseTeams' | 'sentryTriageProjectSlugs' | 'suggesterInstructions' | 'suggesterRoutingInstructions' @@ -220,6 +222,9 @@ const SLACK_TO_DISCORD_DESTINATION_FIELDS = Object.fromEntries( * are prefixed to distinguish them from (unprefixed) Slack channel ids. */ export const DISCORD_DESTINATION_OPTION_PREFIX = 'discord:'; +/** Synthetic option id for Suggest Ideas Telegram sticky-topic destination. */ +const TELEGRAM_DESTINATION_OPTION = 'telegram:primary'; +const TEAMS_DESTINATION_OPTION = 'teams:primary'; type SlackChannelOption = { id: string; @@ -718,6 +723,8 @@ function mapSettingsToFormState( suggesterSlackChannelId: string | null; suggesterSlackChannelName?: string | null; suggesterDiscordChannelId: string | null; + suggesterTelegramChatId: string | null; + suggesterTeamsChannelId: string | null; suggesterInstructions: string | null; suggesterRoutingMode: SuggesterRoutingMode; suggesterRoutingInstructions: string | null; @@ -825,6 +832,8 @@ function mapSettingsToFormState( settings.suggesterSlackChannelId ?? '', suggesterDiscordChannel: settings.suggesterDiscordChannelId ?? '', + suggesterUseTelegram: Boolean(settings.suggesterTelegramChatId), + suggesterUseTeams: Boolean(settings.suggesterTeamsChannelId), suggesterInstructions: settings.suggesterInstructions ?? '', suggesterRoutingMode: settings.suggesterRoutingMode ?? DEFAULT_SUGGESTER_ROUTING_MODE, @@ -2361,6 +2370,10 @@ export function AutomationsSettings() { savedChannelId, savedDiscordChannelId, warningChannelId, + allowTelegram = false, + savedTelegramSelected = false, + allowTeams = false, + savedTeamsSelected = false, }: { field: AutomationSlackDestinationField; inputId: string; @@ -2369,14 +2382,32 @@ export function AutomationsSettings() { savedChannelId: string | null; savedDiscordChannelId: string | null; warningChannelId: string | null; + /** Suggest Ideas: offer sticky Telegram primary-chat topic. */ + allowTelegram?: boolean; + savedTelegramSelected?: boolean; + /** Suggest Ideas: offer primary Teams conversation. */ + allowTeams?: boolean; + savedTeamsSelected?: boolean; }) => { const discordField = SLACK_TO_DISCORD_DESTINATION_FIELDS[field]; const value = formState?.[field] ?? ''; const discordValue = formState?.[discordField] ?? ''; + const useTelegram = + allowTelegram && (formState?.suggesterUseTelegram ?? false); + const useTeams = allowTeams && (formState?.suggesterUseTeams ?? false); + const telegramConnected = + settingsQuery.data?.capabilities.telegramConnected ?? false; + const teamsConnected = + settingsQuery.data?.capabilities.teamsConnected ?? false; const showDiscordOptions = discordConnected || Boolean(discordValue); - // The historical labels say "Slack channel"; once Discord channels are - // offered in the same picker that wording reads as a bug. - const effectiveLabel = showDiscordOptions + const showTelegramOption = + allowTelegram && + (telegramConnected || useTelegram || savedTelegramSelected); + const showTeamsOption = + allowTeams && (teamsConnected || useTeams || savedTeamsSelected); + const multiProvider = + showDiscordOptions || showTelegramOption || showTeamsOption; + const effectiveLabel = multiProvider ? label.replace(/ Slack channel$/u, ' channel') : label; const options = [ @@ -2385,32 +2416,77 @@ export function AutomationsSettings() { ? buildAutomationDiscordDestinationOptions({ channels: discordChannelsQuery.data?.channels ?? [], selectedChannelId: discordValue || null, - includeProviderSuffix: slackConnected, + includeProviderSuffix: + slackConnected || showTelegramOption || showTeamsOption, }) : []), + ...(showTeamsOption + ? [ + { + id: TEAMS_DESTINATION_OPTION, + name: 'Teams', + label: 'Teams · primary conversation', + }, + ] + : []), + ...(showTelegramOption + ? [ + { + id: TELEGRAM_DESTINATION_OPTION, + name: 'Telegram', + label: 'Telegram · recurring topic', + }, + ] + : []), ]; - // One-of destination: a selected Discord channel wins the combobox - // value; picking one provider clears the other on change. - const selectedValue = discordValue - ? `${DISCORD_DESTINATION_OPTION_PREFIX}${discordValue}` - : value || null; + const selectedValue = useTelegram + ? TELEGRAM_DESTINATION_OPTION + : useTeams + ? TEAMS_DESTINATION_OPTION + : discordValue + ? `${DISCORD_DESTINATION_OPTION_PREFIX}${discordValue}` + : value || null; + + const destinationHelper = useTelegram + ? 'Roomote will create a recurring Suggest Ideas topic in your Telegram chat and keep posting there. You can’t pick an existing thread.' + : useTeams + ? 'Roomote will post Suggest Ideas digests to your primary Teams conversation.' + : helperText; + + const clearSuggesterAltDestinations = { + ...(allowTelegram ? { suggesterUseTelegram: false } : {}), + ...(allowTeams ? { suggesterUseTeams: false } : {}), + }; return ( setFormState((prev) => { if (!prev) { return prev; } + if (nextValue === TELEGRAM_DESTINATION_OPTION) { + return { + ...prev, + [field]: '', + [discordField]: '', + suggesterUseTelegram: true, + ...(allowTeams ? { suggesterUseTeams: false } : {}), + }; + } + + if (nextValue === TEAMS_DESTINATION_OPTION) { + return { + ...prev, + [field]: '', + [discordField]: '', + suggesterUseTeams: true, + ...(allowTelegram ? { suggesterUseTelegram: false } : {}), + }; + } + if (nextValue?.startsWith(DISCORD_DESTINATION_OPTION_PREFIX)) { return { ...prev, @@ -2440,6 +2543,7 @@ export function AutomationsSettings() { [discordField]: nextValue.slice( DISCORD_DESTINATION_OPTION_PREFIX.length, ), + ...clearSuggesterAltDestinations, }; } @@ -2447,6 +2551,7 @@ export function AutomationsSettings() { ...prev, [field]: nextValue ?? '', [discordField]: '', + ...clearSuggesterAltDestinations, }; }) } @@ -4038,6 +4143,14 @@ export function AutomationsSettings() { .suggesterDiscordChannelId ?? null, warningChannelId: slackChannelAccessWarnings.suggesterSlackChannel, + allowTelegram: true, + savedTelegramSelected: Boolean( + settingsQuery.data?.settings.suggesterTelegramChatId, + ), + allowTeams: true, + savedTeamsSelected: Boolean( + settingsQuery.data?.settings.suggesterTeamsChannelId, + ), })}
diff --git a/apps/web/src/components/settings/automations/formState.ts b/apps/web/src/components/settings/automations/formState.ts index e814c558a..c401781ea 100644 --- a/apps/web/src/components/settings/automations/formState.ts +++ b/apps/web/src/components/settings/automations/formState.ts @@ -91,6 +91,16 @@ export type FormState = { codeqlTriageFrequency: CodeqlTriageFrequency; suggesterFrequency: SuggesterFrequency; suggesterInstructions: string; + /** + * When true, Suggest Ideas delivers to a sticky Telegram forum topic + * (created once, reused). Mutually exclusive with Slack/Discord channels. + */ + suggesterUseTelegram: boolean; + /** + * When true, Suggest Ideas delivers to the primary Teams conversation. + * Mutually exclusive with Slack/Discord/Telegram destinations. + */ + suggesterUseTeams: boolean; suggesterRoutingMode: SuggesterRoutingMode; suggesterRoutingInstructions: string; announcerFrequency: AnnouncerFrequency; @@ -163,6 +173,8 @@ const CODEQL_TRIAGE_FIELDS: Array = [ const SUGGESTER_FIELDS: Array = [ 'suggesterFrequency', ...DESTINATION_CHANNEL_FIELDS_BY_AUTOMATION_ID.suggester, + 'suggesterUseTelegram', + 'suggesterUseTeams', 'suggesterInstructions', 'suggesterRoutingMode', 'suggesterRoutingInstructions', @@ -339,6 +351,8 @@ export function buildAutomationSettingsSaveInput( issueFixerInstructions: stateToSave.issueFixerInstructions.trim() || null, suggesterFrequency: stateToSave.suggesterFrequency, suggesterInstructions: stateToSave.suggesterInstructions.trim() || null, + suggesterUseTelegram: stateToSave.suggesterUseTelegram, + suggesterUseTeams: stateToSave.suggesterUseTeams, suggesterRoutingMode: stateToSave.suggesterRoutingMode, suggesterRoutingInstructions: stateToSave.suggesterRoutingInstructions.trim() || null, diff --git a/apps/web/src/trpc/commands/automations/settings-read.ts b/apps/web/src/trpc/commands/automations/settings-read.ts index f2b193971..a7735728f 100644 --- a/apps/web/src/trpc/commands/automations/settings-read.ts +++ b/apps/web/src/trpc/commands/automations/settings-read.ts @@ -14,11 +14,13 @@ import { getBackgroundAgentSettingsForDeployment, inArray, listAutomations, + resolveTelegramRuntimeCredentials, tasks, } from '@roomote/db/server'; import { findDiscordDestinationByChannelId, findTeamsConversationDisplayName, + findTeamsPrimaryConversation, resolveAutomationRuntimeDestination, type ResolvedAutomationDestination, } from '@roomote/sdk/server'; @@ -192,6 +194,10 @@ async function resolveDestinationDisplayName( : null; } + if (destination.provider === 'telegram') { + return 'Telegram · Suggest Ideas topic'; + } + // Telegram chats have no reliable display name; the UI shows the chat id. return null; } @@ -257,6 +263,8 @@ export async function getBackgroundAgentSettingsCommand( capabilities: { slackConnected: boolean; discordConnected: boolean; + telegramConnected: boolean; + teamsConnected: boolean; sentryConnected: boolean; missingScopes: readonly string[]; requiredScopes: string[]; @@ -291,6 +299,8 @@ export async function getBackgroundAgentSettingsCommand( settings, slackInstallation, discordInstallation, + telegramCredentials, + teamsPrimaryConversation, sentryConnected, recentRuns, status, @@ -301,6 +311,8 @@ export async function getBackgroundAgentSettingsCommand( where: eq(discordInstallations.isActive, true), columns: { id: true }, }), + resolveTelegramRuntimeCredentials(), + findTeamsPrimaryConversation(), hasActiveSentryIntegration(), listRecentAutomationTasks(), buildAutomationStatus(), @@ -387,6 +399,8 @@ export async function getBackgroundAgentSettingsCommand( capabilities: { slackConnected: Boolean(slackInstallation?.isActive), discordConnected: Boolean(discordInstallation), + telegramConnected: Boolean(telegramCredentials.botToken), + teamsConnected: Boolean(teamsPrimaryConversation), sentryConnected, missingScopes, requiredScopes: [...REQUIRED_BACKGROUND_AGENT_SCOPES], diff --git a/apps/web/src/trpc/commands/automations/settings-update.ts b/apps/web/src/trpc/commands/automations/settings-update.ts index 9ce5ac5ef..4dc8cb239 100644 --- a/apps/web/src/trpc/commands/automations/settings-update.ts +++ b/apps/web/src/trpc/commands/automations/settings-update.ts @@ -21,6 +21,8 @@ import { } from '@roomote/db/server'; import { findDiscordDestinationByChannelId, + findTeamsPrimaryConversation, + findTelegramPrimaryChatId, resolveAutomationRuntimeDestination, } from '@roomote/sdk/server'; import { validateSuggestionRoutingInstructions } from '@roomote/cloud-agents/server'; @@ -134,6 +136,7 @@ function buildSuggesterAutomationSettings(params: { function buildDestinationChannelTargets( slackChannelId: string | null | undefined, discordChannelId: string | null | undefined, + extraTargets: readonly AutomationTarget[] = [], ): AutomationTarget[] { return [ ...(slackChannelId @@ -154,6 +157,7 @@ function buildDestinationChannelTargets( }, ] : []), + ...extraTargets, ]; } @@ -529,6 +533,66 @@ export async function updateBackgroundAgentSettingsCommand( const platformIssueDiscordResult = destinationResults.platformIssueAlerts.discord; + // Suggest Ideas may target Telegram (sticky topic) or Teams (primary + // conversation) as one-of destinations alongside Slack/Discord channels. + const savingSuggester = input.savingAutomation === 'suggester'; + const wantSuggesterTelegram = savingSuggester + ? input.suggesterUseTelegram === true + : Boolean(existingSettings.suggesterTelegramChatId); + const wantSuggesterTeams = savingSuggester + ? input.suggesterUseTeams === true + : Boolean(existingSettings.suggesterTeamsChannelId); + let suggesterTelegramTarget: AutomationTarget | null = null; + let suggesterTeamsTarget: AutomationTarget | null = null; + + if (wantSuggesterTelegram) { + const telegramChatId = + (savingSuggester ? await findTelegramPrimaryChatId() : null) ?? + existingSettings.suggesterTelegramChatId; + + if (!telegramChatId) { + fieldErrors.suggesterUseTelegram = + 'Connect Telegram and capture a primary chat before routing Suggest Ideas there.'; + } else { + // Sticky topic id is merged at upsert time from the latest DB row so a + // concurrent first delivery that persists threadId is not clobbered by a + // stale pre-transaction snapshot. + suggesterTelegramTarget = { + provider: 'telegram', + targetKind: 'telegram_chat', + externalRef: telegramChatId, + metadata: { + topicName: 'Suggest Ideas', + }, + }; + destinationResults.suggester.slack = { channelId: null }; + destinationResults.suggester.discord = { channelId: null }; + } + } else if (wantSuggesterTeams) { + const teamsConversation = + (savingSuggester ? await findTeamsPrimaryConversation() : null) ?? + (existingSettings.suggesterTeamsChannelId + ? { + conversationId: existingSettings.suggesterTeamsChannelId, + serviceUrl: '', + conversationType: null, + } + : null); + + if (!teamsConversation?.conversationId) { + fieldErrors.suggesterUseTeams = + 'Connect Microsoft Teams and capture a conversation before routing Suggest Ideas there.'; + } else { + suggesterTeamsTarget = { + provider: 'teams', + targetKind: 'teams_channel', + externalRef: teamsConversation.conversationId, + }; + destinationResults.suggester.slack = { channelId: null }; + destinationResults.suggester.discord = { channelId: null }; + } + } + for (const result of Object.values(destinationResults)) { if (result.discord.error) { fieldErrors[result.discord.error.field] = result.discord.error.message; @@ -797,7 +861,11 @@ export async function updateBackgroundAgentSettingsCommand( key: 'suggester', frequency: effectiveSuggesterFrequency, channelId: - suggesterChannelResult.channelId ?? suggesterDiscordResult.channelId, + suggesterChannelResult.channelId ?? + suggesterDiscordResult.channelId ?? + suggesterTelegramTarget?.externalRef ?? + suggesterTeamsTarget?.externalRef ?? + null, field: 'suggesterSlackChannel', }, { @@ -987,15 +1055,30 @@ export async function updateBackgroundAgentSettingsCommand( throw new Error(`Missing destination descriptor for ${automationId}`); } const result = destinationResults[automationId]; + const suggesterExtraTargets: AutomationTarget[] = + automationId === 'suggester' + ? [ + ...(suggesterTelegramTarget ? [suggesterTelegramTarget] : []), + ...(suggesterTeamsTarget ? [suggesterTeamsTarget] : []), + ] + : []; return { targets: [ ...buildDestinationChannelTargets( result.slack.channelId, result.discord.channelId, + suggesterExtraTargets, ), ...extraTargets, ], - managedTargetKinds: [...descriptor.managedTargetKinds], + managedTargetKinds: + automationId === 'suggester' + ? [ + ...descriptor.managedTargetKinds, + 'telegram_chat' as const, + 'teams_channel' as const, + ] + : [...descriptor.managedTargetKinds], }; } diff --git a/apps/web/src/trpc/commands/automations/types.ts b/apps/web/src/trpc/commands/automations/types.ts index 260be6657..e84c73179 100644 --- a/apps/web/src/trpc/commands/automations/types.ts +++ b/apps/web/src/trpc/commands/automations/types.ts @@ -50,6 +50,8 @@ export type BackgroundAgentFieldErrorKey = | 'suggesterDiscordChannel' | 'announcerDiscordChannel' | 'platformIssueDiscordChannel' + | 'suggesterUseTelegram' + | 'suggesterUseTeams' | 'sentryTriageProjectSlugs' | 'suggesterInstructions' | 'suggesterRoutingInstructions' @@ -271,6 +273,10 @@ export interface UpdateBackgroundAgentSettingsInput extends ScheduleOnlyAutomati suggesterFrequency: SuggesterFrequency; suggesterSlackChannel: string | null; suggesterDiscordChannel?: string | null; + /** When true, Suggest Ideas delivers to a sticky Telegram topic. */ + suggesterUseTelegram?: boolean; + /** When true, Suggest Ideas delivers to the primary Teams conversation. */ + suggesterUseTeams?: boolean; suggesterInstructions: string | null; suggesterRoutingMode?: SuggesterRoutingMode; suggesterRoutingInstructions?: string | null; diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index 0cd1d1936..96d1da529 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -535,6 +535,16 @@ const automationsRouter = createRouter({ .max(160) .nullable() .optional(), + /** + * When true, Suggest Ideas posts to Telegram via a sticky recurring + * forum topic in the primary chat (no thread picker). + */ + suggesterUseTelegram: z.boolean().optional(), + /** + * When true, Suggest Ideas posts to the primary Microsoft Teams + * conversation captured for this deployment. + */ + suggesterUseTeams: z.boolean().optional(), suggesterInstructions: z.string().max(10_000).nullable(), suggesterRoutingMode: z.enum(SUGGESTER_ROUTING_MODES), suggesterRoutingInstructions: z.string().max(10_000).nullable(), diff --git a/packages/db/src/lib/__tests__/automation-target-concurrency.test.ts b/packages/db/src/lib/__tests__/automation-target-concurrency.test.ts new file mode 100644 index 000000000..ba5361434 --- /dev/null +++ b/packages/db/src/lib/__tests__/automation-target-concurrency.test.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import type { AutomationTarget } from '@roomote/types'; + +import { automations, db, eq } from '../../server'; +import { + persistAutomationTelegramTopicThread, + upsertAutomation, +} from '../automations'; + +const AUTOMATION_KEY = 'platform_issue_alerts'; +const CHAT_ID = '-100123'; + +describe('automation target concurrency', () => { + afterEach(async () => { + await db.delete(automations).where(eq(automations.key, AUTOMATION_KEY)); + }); + + it('serializes a settings target merge with sticky topic persistence', async () => { + const telegramTarget: AutomationTarget = { + provider: 'telegram', + targetKind: 'telegram_chat', + externalRef: CHAT_ID, + metadata: { topicName: 'Suggest Ideas' }, + }; + + await upsertAutomation(db, { + key: AUTOMATION_KEY, + enabled: true, + targets: [telegramTarget], + }); + + let persistTopic!: Promise; + + await db.transaction(async (settingsTx) => { + await upsertAutomation(settingsTx, { + key: AUTOMATION_KEY, + enabled: true, + targets: [telegramTarget], + managedTargetKinds: ['telegram_chat'], + }); + + persistTopic = persistAutomationTelegramTopicThread({ + automationKey: AUTOMATION_KEY, + chatId: CHAT_ID, + threadId: 'topic-7', + topicName: 'Suggest Ideas', + }); + + const persistenceState = await Promise.race([ + persistTopic.then(() => 'completed' as const), + new Promise<'blocked'>((resolve) => + setTimeout(() => resolve('blocked'), 50), + ), + ]); + expect(persistenceState).toBe('blocked'); + }); + + await persistTopic; + + const automation = await db.query.automations.findFirst({ + columns: { targets: true }, + where: eq(automations.key, AUTOMATION_KEY), + }); + expect(automation?.targets).toEqual([ + { + ...telegramTarget, + metadata: { topicName: 'Suggest Ideas', threadId: 'topic-7' }, + }, + ]); + }); +}); diff --git a/packages/db/src/lib/__tests__/upsert-automation-targets.test.ts b/packages/db/src/lib/__tests__/upsert-automation-targets.test.ts index f5b82aa89..c78caada2 100644 --- a/packages/db/src/lib/__tests__/upsert-automation-targets.test.ts +++ b/packages/db/src/lib/__tests__/upsert-automation-targets.test.ts @@ -8,16 +8,23 @@ function buildFakeTx(existingTargets: AutomationTarget[] | null) { const onConflictDoUpdate = vi.fn(async () => undefined); const values = vi.fn(() => ({ onConflictDoUpdate })); const insert = vi.fn(() => ({ values })); + const execute = vi.fn(async () => undefined); const findFirst = vi.fn(async () => existingTargets === null ? undefined : { targets: existingTargets }, ); const tx = { + execute, insert, query: { automations: { findFirst } }, } as unknown as DatabaseOrTransaction; + const transaction = vi.fn( + async (callback: (nestedTx: DatabaseOrTransaction) => Promise) => + callback(tx), + ); + Object.assign(tx, { transaction }); - return { tx, values, findFirst }; + return { tx, values, findFirst, execute, transaction }; } const teamsTarget: AutomationTarget = { @@ -34,7 +41,7 @@ const slackTarget: AutomationTarget = { describe('upsertAutomation target preservation', () => { it('preserves targets of unmanaged kinds when managedTargetKinds is set', async () => { - const { tx, values } = buildFakeTx([ + const { tx, values, execute, transaction } = buildFakeTx([ { ...slackTarget, externalRef: 'C_OLD' }, teamsTarget, ]); @@ -51,10 +58,14 @@ describe('upsertAutomation target preservation', () => { targets: [teamsTarget, slackTarget], }), ); + expect(transaction).toHaveBeenCalledTimes(1); + expect(execute).toHaveBeenCalledTimes(1); }); it('replaces targets wholesale when managedTargetKinds is omitted', async () => { - const { tx, values, findFirst } = buildFakeTx([teamsTarget]); + const { tx, values, findFirst, execute, transaction } = buildFakeTx([ + teamsTarget, + ]); await upsertAutomation(tx, { key: 'announcer', @@ -66,6 +77,8 @@ describe('upsertAutomation target preservation', () => { expect(values).toHaveBeenCalledWith( expect.objectContaining({ targets: [slackTarget] }), ); + expect(transaction).not.toHaveBeenCalled(); + expect(execute).not.toHaveBeenCalled(); }); it('clears managed kinds while preserving others when the new list is empty', async () => { @@ -97,4 +110,43 @@ describe('upsertAutomation target preservation', () => { expect.objectContaining({ targets: [slackTarget] }), ); }); + + it('merges sticky Telegram topic ids that the next write omitted', async () => { + const priorTelegram: AutomationTarget = { + provider: 'telegram', + targetKind: 'telegram_chat', + externalRef: '-100123', + metadata: { threadId: 'topic-7', topicName: 'Suggest Ideas' }, + }; + const nextTelegram: AutomationTarget = { + provider: 'telegram', + targetKind: 'telegram_chat', + externalRef: '-100123', + metadata: { topicName: 'Suggest Ideas' }, + }; + const { tx, values } = buildFakeTx([priorTelegram]); + + await upsertAutomation(tx, { + key: 'suggester', + enabled: true, + targets: [nextTelegram], + managedTargetKinds: ['telegram_chat', 'slack_channel', 'discord_channel'], + }); + + expect(values).toHaveBeenCalledWith( + expect.objectContaining({ + targets: [ + { + provider: 'telegram', + targetKind: 'telegram_chat', + externalRef: '-100123', + metadata: { + topicName: 'Suggest Ideas', + threadId: 'topic-7', + }, + }, + ], + }), + ); + }); }); diff --git a/packages/db/src/lib/automations.ts b/packages/db/src/lib/automations.ts index 114217591..15dd6ebf5 100644 --- a/packages/db/src/lib/automations.ts +++ b/packages/db/src/lib/automations.ts @@ -1,4 +1,4 @@ -import { eq } from 'drizzle-orm'; +import { eq, sql } from 'drizzle-orm'; import { type AnnouncerFrequency, @@ -43,6 +43,19 @@ import type { SecurityAuditorScanCursor, } from '../types'; +function automationTargetsLockKey(key: BackgroundAutomationKey): string { + return `automation-targets:${key}`; +} + +async function lockAutomationTargets( + tx: DatabaseOrTransaction, + key: BackgroundAutomationKey, +): Promise { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtext(${automationTargetsLockKey(key)}))`, + ); +} + function getScheduleModes( automationKey: TriggerableBackgroundAutomationKey, ): TMode[] { @@ -218,6 +231,92 @@ export function getAutomationDiscordChannelTarget( ); } +export function getAutomationTelegramChatTarget( + automation: Pick | undefined, +): string | null { + return ( + getAutomationTargetRefs(automation, 'telegram', 'telegram_chat')[0] ?? null + ); +} + +export function getAutomationTeamsChannelTarget( + automation: Pick | undefined, +): string | null { + return ( + getAutomationTargetRefs(automation, 'teams', 'teams_channel')[0] ?? null + ); +} + +/** Sticky Telegram forum-topic id owned by a telegram_chat target, if any. */ +export function getAutomationTelegramTopicThreadId( + automation: Pick | undefined, +): string | null { + const target = (automation?.targets ?? []).find( + (entry) => + entry.provider === 'telegram' && entry.targetKind === 'telegram_chat', + ); + if (!target) { + return null; + } + const threadId = asString(asObject(target.metadata).threadId); + return threadId?.trim() || null; +} + +/** + * Persist (or replace) the sticky Telegram forum topic id on the automation's + * telegram_chat target. Creates the target when missing so first-run topic + * creation can survive a save-with-openai race. + */ +export async function persistAutomationTelegramTopicThread(params: { + automationKey: BackgroundAutomationKey; + chatId: string; + threadId: string; + topicName?: string; +}): Promise { + const chatId = params.chatId.trim(); + const threadId = params.threadId.trim(); + if (!chatId || !threadId) { + return; + } + + await db.transaction(async (tx) => { + await lockAutomationTargets(tx, params.automationKey); + + const existing = await tx.query.automations.findFirst({ + columns: { targets: true }, + where: eq(automations.key, params.automationKey), + }); + const targets = [...(existing?.targets ?? [])]; + const index = targets.findIndex( + (target) => + target.provider === 'telegram' && target.targetKind === 'telegram_chat', + ); + const topicName = params.topicName?.trim(); + const nextMetadata = { + ...(index >= 0 ? asObject(targets[index]?.metadata) : {}), + threadId, + ...(topicName ? { topicName } : {}), + }; + const nextTarget: AutomationTarget = { + provider: 'telegram', + targetKind: 'telegram_chat', + externalRef: chatId, + metadata: nextMetadata, + }; + + if (index >= 0) { + targets[index] = nextTarget; + } else { + targets.push(nextTarget); + } + + await tx + .update(automations) + .set({ targets, updatedAt: new Date() }) + .where(eq(automations.key, params.automationKey)); + }); +} + /** * Two-level Slack channel resolution: the automation's own slack_channel * target wins, otherwise the deployment-wide manager channel. @@ -424,6 +523,21 @@ export type UpsertAutomationInput = { export async function upsertAutomation( tx: DatabaseOrTransaction, input: UpsertAutomationInput, +): Promise { + if (input.targets !== undefined && input.managedTargetKinds !== undefined) { + await tx.transaction(async (lockedTx) => { + await lockAutomationTargets(lockedTx, input.key); + await upsertAutomationValues(lockedTx, input); + }); + return; + } + + await upsertAutomationValues(tx, input); +} + +async function upsertAutomationValues( + tx: DatabaseOrTransaction, + input: UpsertAutomationInput, ): Promise { const now = input.updatedAt ?? new Date(); const schedule = input.enabled ? (input.schedule ?? {}) : {}; @@ -435,10 +549,49 @@ export async function upsertAutomation( columns: { targets: true }, where: eq(automations.key, input.key), }); - const preserved = (existing?.targets ?? []).filter( + const existingTargets = existing?.targets ?? []; + const preserved = existingTargets.filter( (target) => !managedKinds.has(target.targetKind), ); - targets = [...preserved, ...input.targets]; + // Merge sticky Telegram topic metadata when a write omits threadId but the + // DB already has one for the same chat. Concurrent first-delivery topic + // persistence must outlive an overlapping settings save. + const nextManaged = input.targets.map((target) => { + if ( + target.provider !== 'telegram' || + target.targetKind !== 'telegram_chat' + ) { + return target; + } + + const incomingThreadId = asString(asObject(target.metadata).threadId); + if (incomingThreadId?.trim()) { + return target; + } + + const prior = existingTargets.find( + (entry) => + entry.provider === 'telegram' && + entry.targetKind === 'telegram_chat' && + entry.externalRef === target.externalRef, + ); + const priorThreadId = asString( + asObject(prior?.metadata).threadId, + )?.trim(); + if (!prior || !priorThreadId) { + return target; + } + + return { + ...target, + metadata: { + ...asObject(prior.metadata), + ...asObject(target.metadata), + threadId: priorThreadId, + }, + }; + }); + targets = [...preserved, ...nextManaged]; } const values: typeof automations.$inferInsert = { @@ -829,6 +982,8 @@ export function normalizeBackgroundAgentSettings( ]; }), ), + suggesterTelegramChatId: getAutomationTelegramChatTarget(suggester), + suggesterTeamsChannelId: getAutomationTeamsChannelTarget(suggester), } as BackgroundAgentSettings; } diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index b59c71891..e3e39ba0c 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -464,6 +464,10 @@ export type BackgroundAgentSettings = StoredBackgroundAgentSettings & { suggesterFrequency: SuggesterFrequency; suggesterSlackChannelId: string | null; suggesterDiscordChannelId: string | null; + /** Primary Telegram chat id when Suggest Ideas posts to a sticky topic. */ + suggesterTelegramChatId: string | null; + /** Primary Teams conversation id when Suggest Ideas posts to Teams. */ + suggesterTeamsChannelId: string | null; suggesterInstructions: string | null; suggesterRoutingMode: SuggesterRoutingMode; suggesterRoutingInstructions: string | null; diff --git a/packages/sdk/src/server/automations/__tests__/suggester-route-dispatch.test.ts b/packages/sdk/src/server/automations/__tests__/suggester-route-dispatch.test.ts index 7002f3437..3fddc9e8a 100644 --- a/packages/sdk/src/server/automations/__tests__/suggester-route-dispatch.test.ts +++ b/packages/sdk/src/server/automations/__tests__/suggester-route-dispatch.test.ts @@ -217,6 +217,47 @@ describe('dispatchSuggestionRoutes', () => { consoleSpy.mockRestore(); }); + it('omits Slack channel metadata when the destination is Telegram', async () => { + const params = { + ...buildParams(), + routePlan: { + ...buildParams().routePlan, + routes: [ + { + ...buildParams().routePlan.routes[0]!, + channelId: '-100123', + channelName: '-100123', + }, + ], + }, + destinationPayloadFields: { + communicationProvider: 'telegram', + communicationChannelId: '-100123', + }, + }; + + await dispatchSuggestionRoutes(params); + + expect(mockEnqueueTask).toHaveBeenCalledWith( + expect.objectContaining({ + task: expect.objectContaining({ + payload: expect.objectContaining({ + communicationProvider: 'telegram', + communicationChannelId: '-100123', + notifySlack: true, + suggestionSource: 'suggest_ideas', + }), + }), + }), + ); + const enqueueArg = mockEnqueueTask.mock.calls[0]![0] as { + task: { payload: Record }; + channels?: { slackChannelId?: string }; + }; + expect(enqueueArg.task.payload.slackChannel).toBeUndefined(); + expect(enqueueArg.channels).toBeUndefined(); + }); + it('records a failed pass when every route fails', async () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const params = buildParams(); diff --git a/packages/sdk/src/server/automations/scheduling-utils.ts b/packages/sdk/src/server/automations/scheduling-utils.ts index 6e2d54d22..ca21bc653 100644 --- a/packages/sdk/src/server/automations/scheduling-utils.ts +++ b/packages/sdk/src/server/automations/scheduling-utils.ts @@ -2,7 +2,7 @@ import { SlackNotifier } from '@roomote/slack'; import { getRedis } from '@roomote/redis'; -export interface SlackDeploymentContext { +interface SlackDeploymentContext { slackBotToken: string; slackTeamId: string; } diff --git a/packages/sdk/src/server/automations/suggester-route-dispatch.ts b/packages/sdk/src/server/automations/suggester-route-dispatch.ts index 3ab737aed..ae69e9ca4 100644 --- a/packages/sdk/src/server/automations/suggester-route-dispatch.ts +++ b/packages/sdk/src/server/automations/suggester-route-dispatch.ts @@ -31,8 +31,17 @@ async function enqueueSuggestionRoute(params: { repositoryFullNames: string[]; route: SuggestionDispatchRoute; triggerKind: 'manual' | 'scheduled'; + destinationPayloadFields?: Record; }): Promise<{ error?: string; success: boolean; taskId?: string }> { try { + const destinationFields = params.destinationPayloadFields ?? {}; + // Non-Slack destinations stamp communicationProvider/ChannelId. Only attach + // Slack channel metadata when the scan actually reports to Slack — otherwise + // workers poll the wrong surface using a Telegram/Discord chat id. + const isSlackDestination = + !destinationFields.communicationProvider || + destinationFields.communicationProvider === 'slack'; + // Suggestion scans run as the deployment service principal. const launchResult = await enqueueTask({ task: { @@ -40,7 +49,9 @@ async function enqueueSuggestionRoute(params: { payload: { repo: ALL_REPOSITORIES, selectedRepositories: params.repositoryFullNames, - teamId: params.deployment.slackTeamId, + ...(params.deployment.slackTeamId + ? { teamId: params.deployment.slackTeamId } + : {}), description: buildSuggestedTasksPrompt({ repositoryFullNames: params.repositoryFullNames, repositoryCoverage: params.repositoryCoverage, @@ -62,9 +73,12 @@ async function enqueueSuggestionRoute(params: { }), trigger: 'scheduled', notifySlack: true, - slackChannel: params.route.channelId, suggestionSource: 'suggest_ideas', visibleInTranscript: false, + ...(isSlackDestination + ? { slackChannel: params.route.channelId } + : {}), + ...destinationFields, }, }, initiator: { kind: 'automation', key: 'suggester' }, @@ -72,7 +86,9 @@ async function enqueueSuggestionRoute(params: { surface: 'system', trigger: params.triggerKind === 'manual' ? 'manual' : 'schedule', visibility: 'hidden', - channels: { slackChannelId: params.route.channelId }, + ...(isSlackDestination + ? { channels: { slackChannelId: params.route.channelId } } + : {}), }); return { success: true, taskId: launchResult.taskId }; @@ -97,6 +113,7 @@ export async function dispatchSuggestionRoutes(params: { repositoryFullNames: string[]; routePlan: SuggestionDispatchPlan; triggerKind: 'manual' | 'scheduled'; + destinationPayloadFields?: Record; }): Promise<{ errors: string[]; firstLaunchedTaskId: string | null; @@ -121,6 +138,7 @@ export async function dispatchSuggestionRoutes(params: { )), }, triggerKind: params.triggerKind, + destinationPayloadFields: params.destinationPayloadFields, }); if (result.success) { diff --git a/packages/sdk/src/server/automations/suggester-route-planner.ts b/packages/sdk/src/server/automations/suggester-route-planner.ts index f5214f669..c28e98e35 100644 --- a/packages/sdk/src/server/automations/suggester-route-planner.ts +++ b/packages/sdk/src/server/automations/suggester-route-planner.ts @@ -2,13 +2,15 @@ import { planSuggestionRoutes } from '@roomote/cloud-agents/server'; import { SlackNotifier } from '@roomote/slack'; import { loadAutomationThreadFeedbackReport } from './automation-thread-feedback'; -import type { SlackDeploymentContext } from './scheduling-utils'; const LOG_PREFIX = '[suggester]'; const DEFAULT_FALLBACK_ROUTE_INSTRUCTIONS = 'Only surface ideas that do not clearly belong to any defined routed group. Use this fallback route for ambiguous or uncategorized ideas.'; -export type SuggesterDeploymentContext = SlackDeploymentContext; +export type SuggesterDeploymentContext = { + slackBotToken: string | null; + slackTeamId: string | null; +}; export type RepositoryCoverage = Array<{ repositoryFullName: string; @@ -71,6 +73,10 @@ async function buildGroupedSuggestionDispatchRoutes(params: { repositoryCoverage: RepositoryCoverage; routingInstructions: string; }): Promise { + if (!params.deployment.slackBotToken) { + return null; + } + try { const notifier = new SlackNotifier(params.deployment.slackBotToken); const [availableChannels, managerChannelName] = await Promise.all([ diff --git a/packages/sdk/src/server/automations/suggester.ts b/packages/sdk/src/server/automations/suggester.ts index e99bb06c5..af072b715 100644 --- a/packages/sdk/src/server/automations/suggester.ts +++ b/packages/sdk/src/server/automations/suggester.ts @@ -16,6 +16,12 @@ import { } from '@roomote/feature-flags/server'; import { getRedis } from '@roomote/redis'; import { type WorkItemStatus } from '@roomote/types'; +import { + buildDestinationTaskPayloadFields, + listConnectedCommunicationProviders, + resolveAutomationRuntimeDestination, + type ResolvedAutomationDestination, +} from './destination'; import { getActiveRepositoryFullNames, hasAnyActiveRepository, @@ -50,13 +56,24 @@ async function findEligibleDeployments(): Promise< return []; } - return db + const rows = await db .select({ slackBotToken: slackInstallations.botAccessToken, slackTeamId: slackInstallations.teamId, }) .from(slackInstallations) .where(eq(slackInstallations.isActive, true)); + + if (rows.length > 0) { + return rows; + } + + // No Slack: still eligible when another connected surface can carry + // Suggest Ideas (Discord channel destination or Telegram sticky topic). + const connectedProviders = await listConnectedCommunicationProviders(); + return connectedProviders.length > 0 + ? [{ slackBotToken: null, slackTeamId: null }] + : []; } async function buildRepositoryCoverage( @@ -119,6 +136,16 @@ async function countOpenSuggestions(): Promise { return result?.openSuggestionCount ?? 0; } +function resolveDispatchChannelId( + destination: ResolvedAutomationDestination | null, + slackChannelId: string | null, +): string | null { + if (destination) { + return destination.channelId; + } + return slackChannelId; +} + export async function suggesterJob( opts: AutomationRunOpts = {}, ): Promise { @@ -129,7 +156,8 @@ export async function suggesterJob( const eligibleDeployments = await findEligibleDeployments(); if (eligibleDeployments.length === 0) { - result.skippedReason = 'GitHub and Slack must both be connected.'; + result.skippedReason = + 'At least one active repository and a connected communication surface are required.'; } let processed = 0; @@ -144,7 +172,14 @@ export async function suggesterJob( }); const runtime = await getAutomationRuntime('suggester'); const frequency = runtime.enabled ? runtime.scheduleMode : 'off'; - const channelId = runtime.slackChannelId; + const destination = await resolveAutomationRuntimeDestination({ + runtime, + slackConnected: deployment.slackBotToken !== null, + }); + const channelId = resolveDispatchChannelId( + destination, + runtime.slackChannelId, + ); if (!frequency || frequency === 'off' || !(frequency in WINDOW_DAYS)) { result.skippedReason = 'Automation is disabled.'; @@ -154,17 +189,23 @@ export async function suggesterJob( if (!channelId) { console.log( - `${LOG_PREFIX} Skipping deployment: suggester channel not configured`, + `${LOG_PREFIX} Skipping deployment: suggester destination not configured`, ); - result.skippedReason = 'Suggester channel is not configured.'; + result.skippedReason = 'Suggester destination is not configured.'; skipped++; continue; } - const timezone = await resolveSlackWorkspaceTimezone( - deployment, - LOG_PREFIX, - ); + const timezone = + deployment.slackBotToken && deployment.slackTeamId + ? await resolveSlackWorkspaceTimezone( + { + slackBotToken: deployment.slackBotToken, + slackTeamId: deployment.slackTeamId, + }, + LOG_PREFIX, + ) + : 'UTC'; if ( !opts.manualTrigger && @@ -246,9 +287,16 @@ export async function suggesterJob( ); } + // Grouped multi-channel routing is Slack-only. Discord/Telegram destinations + // always take the single-route path and land digests via notifySlack fallback. + const useGroupedRouting = + suggestionRoutingEnabled && + destination?.provider === 'slack' && + deployment.slackBotToken !== null; + const routePlan = await prepareSuggestionDispatchPlan({ deployment, - groupedRoutingEnabled: suggestionRoutingEnabled, + groupedRoutingEnabled: useGroupedRouting, managerChannelId: channelId, now, repositoryCoverage, @@ -271,6 +319,9 @@ export async function suggesterJob( repositoryFullNames: environmentBackedRepositoryFullNames, routePlan, triggerKind: opts.manualTrigger ? 'manual' : 'scheduled', + destinationPayloadFields: destination + ? buildDestinationTaskPayloadFields(destination) + : {}, }); if (dispatchResult.successfulRoutes > 0) { diff --git a/packages/types/src/__tests__/background-automation-registry.test.ts b/packages/types/src/__tests__/background-automation-registry.test.ts index 02bbbe3fc..455163874 100644 --- a/packages/types/src/__tests__/background-automation-registry.test.ts +++ b/packages/types/src/__tests__/background-automation-registry.test.ts @@ -78,12 +78,14 @@ describe('background automation registry', () => { }); }); - it('allows Slack and Discord destinations for the suggester', () => { + it('allows all communication destinations for the suggester', () => { const descriptor = getTriggerableBackgroundAutomationDescriptorByKey('suggester'); expect(descriptor?.supportedCommunicationProviders).toEqual([ 'slack', + 'teams', + 'telegram', 'discord', ]); }); diff --git a/packages/types/src/background-automation-registry.ts b/packages/types/src/background-automation-registry.ts index 327fedaec..55283cd81 100644 --- a/packages/types/src/background-automation-registry.ts +++ b/packages/types/src/background-automation-registry.ts @@ -151,10 +151,9 @@ export const TRIGGERABLE_BACKGROUND_AUTOMATION_DESCRIPTORS = [ // Provider-agnostic: suggestion scans work with any synced repository. manualTriggerRequirements: ['slack', 'repository'], usesManagerChannel: true, - // Scheduled suggestion delivery supports a per-automation Slack or - // Discord destination target; Teams/Telegram remain fallback-only - // surfaces without a configurable destination. - supportedCommunicationProviders: ['slack', 'discord'], + // Full chat coverage: Slack/Discord pick an existing channel; Telegram + // auto-creates a sticky topic; Teams uses the primary conversation. + supportedCommunicationProviders: ['slack', 'teams', 'telegram', 'discord'], supportedSourceControlProviders: sourceControlProviders, scheduledSuggestionSource: 'suggest_ideas', },