From 198af0dfd279be79455887d91e263fea60695cc7 Mon Sep 17 00:00:00 2001 From: Roomote Date: Mon, 20 Jul 2026 15:46:44 +0000 Subject: [PATCH 1/4] feat: allow Suggest Ideas to deliver via sticky Telegram topics Make Telegram a first-class Suggest Ideas destination that auto-creates and reuses a recurring forum topic, without a thread picker. --- .../handlers/tasks/submitTaskSuggestions.ts | 20 +- .../__tests__/automation-suggestions.test.ts | 172 ++++++++++++++---- .../telegram/automation-suggestions.ts | 147 +++++++++++++-- apps/docs/automations.mdx | 7 + .../AutomationsSettings.client.test.tsx | 1 + ...AutomationsSettings.render.client.test.tsx | 10 +- .../automations/AutomationsSettings.tsx | 93 ++++++++-- .../settings/automations/formState.ts | 7 + .../commands/automations/settings-read.ts | 9 + .../commands/automations/settings-update.ts | 57 +++++- .../src/trpc/commands/automations/types.ts | 3 + apps/web/src/trpc/routers/_app.ts | 5 + packages/db/src/lib/automations.ts | 75 ++++++++ packages/db/src/types.ts | 2 + .../automations/suggester-route-dispatch.ts | 8 +- .../automations/suggester-route-planner.ts | 10 +- .../sdk/src/server/automations/suggester.ts | 71 +++++++- .../background-automation-registry.test.ts | 3 +- .../src/background-automation-registry.ts | 7 +- 19 files changed, 611 insertions(+), 96 deletions(-) diff --git a/apps/api/src/handlers/tasks/submitTaskSuggestions.ts b/apps/api/src/handlers/tasks/submitTaskSuggestions.ts index e65a53a89..b13bd55fa 100644 --- a/apps/api/src/handlers/tasks/submitTaskSuggestions.ts +++ b/apps/api/src/handlers/tasks/submitTaskSuggestions.ts @@ -1308,8 +1308,11 @@ export async function submitTaskSuggestions( resolveScheduledSuggestionSlackConfig(payload.suggestionSource) .automationKey, ); + // An automation with its own Discord/Telegram destination target skips + // higher-precedence surfaces — the summary belongs on that surface. + const preferredProvider = suggestionRuntime.destination?.provider; const slackDelivered = - suggestionRuntime.destination?.provider === 'discord' + preferredProvider === 'discord' || preferredProvider === 'telegram' ? false : await postSuggestedTasksSummaryToSlack({ sourceTaskId: taskId, @@ -1321,12 +1324,15 @@ export async function submitTaskSuggestions( }); if (!slackDelivered) { - const discordDelivered = await postScheduledSuggestionsToDiscord({ - sourceTaskId: taskId, - createdByUserId, - suggestionSource: payload.suggestionSource, - suggestions: persistedSuggestions, - }); + const discordDelivered = + preferredProvider === 'telegram' + ? false + : await postScheduledSuggestionsToDiscord({ + sourceTaskId: taskId, + createdByUserId, + suggestionSource: payload.suggestionSource, + suggestions: persistedSuggestions, + }); if (!discordDelivered) { const telegramDelivered = await postScheduledSuggestionsToTelegram({ 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 beceadd63..9e765c780 100644 --- a/apps/docs/automations.mdx +++ b/apps/docs/automations.mdx @@ -119,6 +119,13 @@ Discord channel (see [Communications](/communications#supported-providers) for connecting those surfaces). +**Suggest Ideas** can also report to Telegram as a first-class destination. +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. Other automations treat Telegram as a fallback +surface when no Slack destination is available. + 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, Triage CodeQL Alerts, and CI Failure Triage are 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 cdfedba5c..e33325274 100644 --- a/apps/web/src/components/settings/automations/AutomationsSettings.client.test.tsx +++ b/apps/web/src/components/settings/automations/AutomationsSettings.client.test.tsx @@ -77,6 +77,7 @@ const baseFormState: FormState = { suggesterFrequency: 'off', suggesterSlackChannel: '', suggesterDiscordChannel: '', + suggesterUseTelegram: 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 9fef40ef9..a1d3450d5 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,7 @@ const state = vi.hoisted(() => ({ capabilities: { slackConnected: true, discordConnected: false, + telegramConnected: false, requiresSlackReconnect: false, missingScopes: [], slackWorkspaceDomain: 'acme', @@ -89,6 +90,7 @@ const state = vi.hoisted(() => ({ suggesterFrequency: 'off' as const, suggesterSlackChannelId: null, suggesterDiscordChannelId: null, + suggesterTelegramChatId: null, suggesterInstructions: null, suggesterRoutingMode: 'manager_channel' as const, suggesterRoutingInstructions: null, @@ -580,10 +582,12 @@ describe('AutomationsSettings', () => { // Dependabot, CodeQL, Issue Fixer, and CI failure triage stay GitHub-only; manager // stats is provider-neutral now and shows no source-control badge. expect((await screen.findAllByText('GitHub only')).length).toBe(4); - // The suggester supports Slack and Discord destinations; the other - // manager automations post to all configured communication providers. + // The suggester supports Slack, Discord, and Telegram destinations; the + // other manager automations post to all configured communication providers. expect(screen.queryByText('Slack only')).toBeNull(); - expect(screen.getAllByText('Slack · Discord only').length).toBe(1); + expect(screen.getAllByText('Slack · Discord · Telegram only').length).toBe( + 1, + ); // conflict_resolver supports GitHub, GitLab, and Azure DevOps (no // Gitea/Bitbucket conflict signal). expect( diff --git a/apps/web/src/components/settings/automations/AutomationsSettings.tsx b/apps/web/src/components/settings/automations/AutomationsSettings.tsx index 837c232d4..b26d1fc02 100644 --- a/apps/web/src/components/settings/automations/AutomationsSettings.tsx +++ b/apps/web/src/components/settings/automations/AutomationsSettings.tsx @@ -151,6 +151,7 @@ type FieldErrors = Partial< | 'suggesterDiscordChannel' | 'announcerDiscordChannel' | 'platformIssueDiscordChannel' + | 'suggesterUseTelegram' | 'sentryTriageProjectSlugs' | 'suggesterInstructions' | 'suggesterRoutingInstructions' @@ -218,6 +219,8 @@ 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. */ +export const TELEGRAM_DESTINATION_OPTION = 'telegram:primary'; type SlackChannelOption = { id: string; @@ -716,6 +719,7 @@ function mapSettingsToFormState( suggesterSlackChannelId: string | null; suggesterSlackChannelName?: string | null; suggesterDiscordChannelId: string | null; + suggesterTelegramChatId: string | null; suggesterInstructions: string | null; suggesterRoutingMode: SuggesterRoutingMode; suggesterRoutingInstructions: string | null; @@ -820,6 +824,7 @@ function mapSettingsToFormState( settings.suggesterSlackChannelId ?? '', suggesterDiscordChannel: settings.suggesterDiscordChannelId ?? '', + suggesterUseTelegram: Boolean(settings.suggesterTelegramChatId), suggesterInstructions: settings.suggesterInstructions ?? '', suggesterRoutingMode: settings.suggesterRoutingMode ?? DEFAULT_SUGGESTER_ROUTING_MODE, @@ -2356,6 +2361,8 @@ export function AutomationsSettings() { savedChannelId, savedDiscordChannelId, warningChannelId, + allowTelegram = false, + savedTelegramSelected = false, }: { field: AutomationSlackDestinationField; inputId: string; @@ -2364,14 +2371,25 @@ export function AutomationsSettings() { savedChannelId: string | null; savedDiscordChannelId: string | null; warningChannelId: string | null; + /** Suggest Ideas only: offer sticky Telegram primary-chat topic. */ + allowTelegram?: boolean; + savedTelegramSelected?: boolean; }) => { const discordField = SLACK_TO_DISCORD_DESTINATION_FIELDS[field]; const value = formState?.[field] ?? ''; const discordValue = formState?.[discordField] ?? ''; + const useTelegram = + allowTelegram && (formState?.suggesterUseTelegram ?? false); + const telegramConnected = + settingsQuery.data?.capabilities.telegramConnected ?? 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); + // The historical labels say "Slack channel"; once multi-provider options + // are offered that wording reads as a bug. + const multiProvider = showDiscordOptions || showTelegramOption; + const effectiveLabel = multiProvider ? label.replace(/ Slack channel$/u, ' channel') : label; const options = [ @@ -2380,32 +2398,57 @@ export function AutomationsSettings() { ? buildAutomationDiscordDestinationOptions({ channels: discordChannelsQuery.data?.channels ?? [], selectedChannelId: discordValue || null, - includeProviderSuffix: slackConnected, + includeProviderSuffix: slackConnected || showTelegramOption, }) : []), + ...(showTelegramOption + ? [ + { + id: TELEGRAM_DESTINATION_OPTION, + name: 'Telegram', + label: + slackConnected || showDiscordOptions + ? 'Telegram · recurring topic' + : '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; + // One-of destination: Telegram/Discord win the combobox when selected. + const selectedValue = useTelegram + ? TELEGRAM_DESTINATION_OPTION + : discordValue + ? `${DISCORD_DESTINATION_OPTION_PREFIX}${discordValue}` + : value || null; + + const telegramHelper = useTelegram + ? 'Roomote will create a recurring Suggest Ideas topic in your Telegram chat and keep posting there. You can’t pick an existing thread.' + : helperText; return ( setFormState((prev) => { if (!prev) { return prev; } + if (nextValue === TELEGRAM_DESTINATION_OPTION) { + return { + ...prev, + [field]: '', + [discordField]: '', + suggesterUseTelegram: true, + }; + } + if (nextValue?.startsWith(DISCORD_DESTINATION_OPTION_PREFIX)) { return { ...prev, @@ -2435,6 +2492,7 @@ export function AutomationsSettings() { [discordField]: nextValue.slice( DISCORD_DESTINATION_OPTION_PREFIX.length, ), + ...(allowTelegram ? { suggesterUseTelegram: false } : {}), }; } @@ -2442,6 +2500,7 @@ export function AutomationsSettings() { ...prev, [field]: nextValue ?? '', [discordField]: '', + ...(allowTelegram ? { suggesterUseTelegram: false } : {}), }; }) } @@ -4004,6 +4063,10 @@ export function AutomationsSettings() { .suggesterDiscordChannelId ?? null, warningChannelId: slackChannelAccessWarnings.suggesterSlackChannel, + allowTelegram: true, + savedTelegramSelected: Boolean( + settingsQuery.data?.settings.suggesterTelegramChatId, + ), })}
diff --git a/apps/web/src/components/settings/automations/formState.ts b/apps/web/src/components/settings/automations/formState.ts index 20e4bdd9a..4e2ffef62 100644 --- a/apps/web/src/components/settings/automations/formState.ts +++ b/apps/web/src/components/settings/automations/formState.ts @@ -90,6 +90,11 @@ 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; suggesterRoutingMode: SuggesterRoutingMode; suggesterRoutingInstructions: string; announcerFrequency: AnnouncerFrequency; @@ -162,6 +167,7 @@ const CODEQL_TRIAGE_FIELDS: Array = [ const SUGGESTER_FIELDS: Array = [ 'suggesterFrequency', ...DESTINATION_CHANNEL_FIELDS_BY_AUTOMATION_ID.suggester, + 'suggesterUseTelegram', 'suggesterInstructions', 'suggesterRoutingMode', 'suggesterRoutingInstructions', @@ -334,6 +340,7 @@ export function buildAutomationSettingsSaveInput( ...buildScheduleOnlyAutomationSaveInput(stateToSave), suggesterFrequency: stateToSave.suggesterFrequency, suggesterInstructions: stateToSave.suggesterInstructions.trim() || null, + suggesterUseTelegram: stateToSave.suggesterUseTelegram, 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..7729dbc5d 100644 --- a/apps/web/src/trpc/commands/automations/settings-read.ts +++ b/apps/web/src/trpc/commands/automations/settings-read.ts @@ -14,6 +14,7 @@ import { getBackgroundAgentSettingsForDeployment, inArray, listAutomations, + resolveTelegramRuntimeCredentials, tasks, } from '@roomote/db/server'; import { @@ -192,6 +193,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 +262,7 @@ export async function getBackgroundAgentSettingsCommand( capabilities: { slackConnected: boolean; discordConnected: boolean; + telegramConnected: boolean; sentryConnected: boolean; missingScopes: readonly string[]; requiredScopes: string[]; @@ -291,6 +297,7 @@ export async function getBackgroundAgentSettingsCommand( settings, slackInstallation, discordInstallation, + telegramCredentials, sentryConnected, recentRuns, status, @@ -301,6 +308,7 @@ export async function getBackgroundAgentSettingsCommand( where: eq(discordInstallations.isActive, true), columns: { id: true }, }), + resolveTelegramRuntimeCredentials(), hasActiveSentryIntegration(), listRecentAutomationTasks(), buildAutomationStatus(), @@ -387,6 +395,7 @@ export async function getBackgroundAgentSettingsCommand( capabilities: { slackConnected: Boolean(slackInstallation?.isActive), discordConnected: Boolean(discordInstallation), + telegramConnected: Boolean(telegramCredentials.botToken), 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 a32fe1692..908c4a533 100644 --- a/apps/web/src/trpc/commands/automations/settings-update.ts +++ b/apps/web/src/trpc/commands/automations/settings-update.ts @@ -15,12 +15,14 @@ import { DEFAULT_CONFLICT_RESOLVER_LABEL, deploymentSettings, getAutomationRuntime, + getAutomationTelegramTopicThreadId, getBackgroundAgentSettingsForDeployment, MANAGER_CHANNEL_STARTER_AUTOMATION_SETTINGS, upsertAutomation, } from '@roomote/db/server'; import { findDiscordDestinationByChannelId, + findTelegramPrimaryChatId, resolveAutomationRuntimeDestination, } from '@roomote/sdk/server'; import { validateSuggestionRoutingInstructions } from '@roomote/cloud-agents/server'; @@ -133,6 +135,7 @@ function buildSuggesterAutomationSettings(params: { function buildDestinationChannelTargets( slackChannelId: string | null | undefined, discordChannelId: string | null | undefined, + telegramTarget?: AutomationTarget | null, ): AutomationTarget[] { return [ ...(slackChannelId @@ -153,6 +156,7 @@ function buildDestinationChannelTargets( }, ] : []), + ...(telegramTarget ? [telegramTarget] : []), ]; } @@ -523,6 +527,46 @@ export async function updateBackgroundAgentSettingsCommand( const platformIssueDiscordResult = destinationResults.platformIssueAlerts.discord; + // Suggest Ideas may target Telegram via a sticky recurring topic in the + // primary chat (no thread picker). Resolve that before destination + // validation so Telegram counts as a configured destination. + const savingSuggester = input.savingAutomation === 'suggester'; + const wantSuggesterTelegram = savingSuggester + ? input.suggesterUseTelegram === true + : Boolean(existingSettings.suggesterTelegramChatId); + let suggesterTelegramTarget: 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 { + const existingRuntime = await getAutomationRuntime('suggester'); + const stickyThreadId = + getAutomationTelegramTopicThreadId(existingRuntime); + suggesterTelegramTarget = { + provider: 'telegram', + targetKind: 'telegram_chat', + externalRef: telegramChatId, + ...(stickyThreadId + ? { + metadata: { + threadId: stickyThreadId, + topicName: 'Suggest Ideas', + }, + } + : {}), + }; + // Telegram is one-of: clear Slack/Discord channel results for suggester. + 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; @@ -791,7 +835,10 @@ export async function updateBackgroundAgentSettingsCommand( key: 'suggester', frequency: effectiveSuggesterFrequency, channelId: - suggesterChannelResult.channelId ?? suggesterDiscordResult.channelId, + suggesterChannelResult.channelId ?? + suggesterDiscordResult.channelId ?? + suggesterTelegramTarget?.externalRef ?? + null, field: 'suggesterSlackChannel', }, { @@ -980,15 +1027,21 @@ export async function updateBackgroundAgentSettingsCommand( throw new Error(`Missing destination descriptor for ${automationId}`); } const result = destinationResults[automationId]; + const telegramTarget = + automationId === 'suggester' ? suggesterTelegramTarget : null; return { targets: [ ...buildDestinationChannelTargets( result.slack.channelId, result.discord.channelId, + telegramTarget, ), ...extraTargets, ], - managedTargetKinds: [...descriptor.managedTargetKinds], + managedTargetKinds: + automationId === 'suggester' + ? [...descriptor.managedTargetKinds, 'telegram_chat' 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 fe1186fdb..3369ccaf8 100644 --- a/apps/web/src/trpc/commands/automations/types.ts +++ b/apps/web/src/trpc/commands/automations/types.ts @@ -50,6 +50,7 @@ export type BackgroundAgentFieldErrorKey = | 'suggesterDiscordChannel' | 'announcerDiscordChannel' | 'platformIssueDiscordChannel' + | 'suggesterUseTelegram' | 'sentryTriageProjectSlugs' | 'suggesterInstructions' | 'suggesterRoutingInstructions' @@ -269,6 +270,8 @@ 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; 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 e55bd83e9..8443834a5 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -527,6 +527,11 @@ 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(), 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/automations.ts b/packages/db/src/lib/automations.ts index 3cdf00020..4f165c7de 100644 --- a/packages/db/src/lib/automations.ts +++ b/packages/db/src/lib/automations.ts @@ -218,6 +218,80 @@ export function getAutomationDiscordChannelTarget( ); } +export function getAutomationTelegramChatTarget( + automation: Pick | undefined, +): string | null { + return ( + getAutomationTargetRefs(automation, 'telegram', 'telegram_chat')[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; + } + + const existing = await db.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 db + .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. @@ -828,6 +902,7 @@ export function normalizeBackgroundAgentSettings( ]; }), ), + suggesterTelegramChatId: getAutomationTelegramChatTarget(suggester), } as BackgroundAgentSettings; } diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 5ba0e440d..debe707ec 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -463,6 +463,8 @@ 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; suggesterInstructions: string | null; suggesterRoutingMode: SuggesterRoutingMode; suggesterRoutingInstructions: string | null; diff --git a/packages/sdk/src/server/automations/suggester-route-dispatch.ts b/packages/sdk/src/server/automations/suggester-route-dispatch.ts index 3ab737aed..99b01c3d8 100644 --- a/packages/sdk/src/server/automations/suggester-route-dispatch.ts +++ b/packages/sdk/src/server/automations/suggester-route-dispatch.ts @@ -31,6 +31,7 @@ async function enqueueSuggestionRoute(params: { repositoryFullNames: string[]; route: SuggestionDispatchRoute; triggerKind: 'manual' | 'scheduled'; + destinationPayloadFields?: Record; }): Promise<{ error?: string; success: boolean; taskId?: string }> { try { // Suggestion scans run as the deployment service principal. @@ -40,7 +41,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, @@ -65,6 +68,7 @@ async function enqueueSuggestionRoute(params: { slackChannel: params.route.channelId, suggestionSource: 'suggest_ideas', visibleInTranscript: false, + ...(params.destinationPayloadFields ?? {}), }, }, initiator: { kind: 'automation', key: 'suggester' }, @@ -97,6 +101,7 @@ export async function dispatchSuggestionRoutes(params: { repositoryFullNames: string[]; routePlan: SuggestionDispatchPlan; triggerKind: 'manual' | 'scheduled'; + destinationPayloadFields?: Record; }): Promise<{ errors: string[]; firstLaunchedTaskId: string | null; @@ -121,6 +126,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 32a49c545..886ffd563 100644 --- a/packages/types/src/__tests__/background-automation-registry.test.ts +++ b/packages/types/src/__tests__/background-automation-registry.test.ts @@ -78,13 +78,14 @@ describe('background automation registry', () => { }); }); - it('allows Slack and Discord destinations for the suggester', () => { + it('allows Slack, Discord, and Telegram destinations for the suggester', () => { const descriptor = getTriggerableBackgroundAutomationDescriptorByKey('suggester'); expect(descriptor?.supportedCommunicationProviders).toEqual([ 'slack', 'discord', + 'telegram', ]); }); diff --git a/packages/types/src/background-automation-registry.ts b/packages/types/src/background-automation-registry.ts index ef9b78a46..ee24fe2bd 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'], + // Slack/Discord pick an existing channel; Telegram auto-creates a + // sticky recurring forum topic in the primary chat (no thread picker). + supportedCommunicationProviders: ['slack', 'discord', 'telegram'], supportedSourceControlProviders: sourceControlProviders, scheduledSuggestionSource: 'suggest_ideas', }, From 3a4d569b2900c667f61d835de0faf0f51f1cffef Mon Sep 17 00:00:00 2001 From: Roomote Date: Mon, 20 Jul 2026 15:48:42 +0000 Subject: [PATCH 2/4] chore: fix knip unused exports after Suggest Ideas Telegram work --- .../src/components/settings/automations/AutomationsSettings.tsx | 2 +- packages/sdk/src/server/automations/scheduling-utils.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/settings/automations/AutomationsSettings.tsx b/apps/web/src/components/settings/automations/AutomationsSettings.tsx index b26d1fc02..0d421b056 100644 --- a/apps/web/src/components/settings/automations/AutomationsSettings.tsx +++ b/apps/web/src/components/settings/automations/AutomationsSettings.tsx @@ -220,7 +220,7 @@ const SLACK_TO_DISCORD_DESTINATION_FIELDS = Object.fromEntries( */ export const DISCORD_DESTINATION_OPTION_PREFIX = 'discord:'; /** Synthetic option id for Suggest Ideas Telegram sticky-topic destination. */ -export const TELEGRAM_DESTINATION_OPTION = 'telegram:primary'; +const TELEGRAM_DESTINATION_OPTION = 'telegram:primary'; type SlackChannelOption = { id: string; 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; } From 66514330b8e96a5f4169bb3dd10e3800bd50a842 Mon Sep 17 00:00:00 2001 From: Roomote Date: Mon, 20 Jul 2026 15:58:13 +0000 Subject: [PATCH 3/4] fix: keep Telegram suggester destinations off Slack metadata Only attach Slack channel fields for Slack destinations, and merge sticky Telegram topic ids when settings saves race first delivery. --- .../commands/automations/settings-update.ts | 18 +++----- .../upsert-automation-targets.test.ts | 39 +++++++++++++++++ packages/db/src/lib/automations.ts | 43 ++++++++++++++++++- .../suggester-route-dispatch.test.ts | 41 ++++++++++++++++++ .../automations/suggester-route-dispatch.ts | 18 ++++++-- 5 files changed, 142 insertions(+), 17 deletions(-) diff --git a/apps/web/src/trpc/commands/automations/settings-update.ts b/apps/web/src/trpc/commands/automations/settings-update.ts index 908c4a533..983c125d7 100644 --- a/apps/web/src/trpc/commands/automations/settings-update.ts +++ b/apps/web/src/trpc/commands/automations/settings-update.ts @@ -15,7 +15,6 @@ import { DEFAULT_CONFLICT_RESOLVER_LABEL, deploymentSettings, getAutomationRuntime, - getAutomationTelegramTopicThreadId, getBackgroundAgentSettingsForDeployment, MANAGER_CHANNEL_STARTER_AUTOMATION_SETTINGS, upsertAutomation, @@ -545,21 +544,16 @@ export async function updateBackgroundAgentSettingsCommand( fieldErrors.suggesterUseTelegram = 'Connect Telegram and capture a primary chat before routing Suggest Ideas there.'; } else { - const existingRuntime = await getAutomationRuntime('suggester'); - const stickyThreadId = - getAutomationTelegramTopicThreadId(existingRuntime); + // 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, - ...(stickyThreadId - ? { - metadata: { - threadId: stickyThreadId, - topicName: 'Suggest Ideas', - }, - } - : {}), + metadata: { + topicName: 'Suggest Ideas', + }, }; // Telegram is one-of: clear Slack/Discord channel results for suggester. destinationResults.suggester.slack = { channelId: null }; 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..96971cd02 100644 --- a/packages/db/src/lib/__tests__/upsert-automation-targets.test.ts +++ b/packages/db/src/lib/__tests__/upsert-automation-targets.test.ts @@ -97,4 +97,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 4f165c7de..395d09e4e 100644 --- a/packages/db/src/lib/automations.ts +++ b/packages/db/src/lib/automations.ts @@ -509,10 +509,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 = { 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/suggester-route-dispatch.ts b/packages/sdk/src/server/automations/suggester-route-dispatch.ts index 99b01c3d8..ae69e9ca4 100644 --- a/packages/sdk/src/server/automations/suggester-route-dispatch.ts +++ b/packages/sdk/src/server/automations/suggester-route-dispatch.ts @@ -34,6 +34,14 @@ async function enqueueSuggestionRoute(params: { 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: { @@ -65,10 +73,12 @@ async function enqueueSuggestionRoute(params: { }), trigger: 'scheduled', notifySlack: true, - slackChannel: params.route.channelId, suggestionSource: 'suggest_ideas', visibleInTranscript: false, - ...(params.destinationPayloadFields ?? {}), + ...(isSlackDestination + ? { slackChannel: params.route.channelId } + : {}), + ...destinationFields, }, }, initiator: { kind: 'automation', key: 'suggester' }, @@ -76,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 }; From 881972b5d2586a8f32b409b0ba8cbe5350ab6634 Mon Sep 17 00:00:00 2001 From: Roomote Date: Mon, 20 Jul 2026 16:21:55 +0000 Subject: [PATCH 4/4] feat: add Teams as a first-class Suggest Ideas destination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Offer Teams · primary conversation alongside Slack, Discord, and Telegram so Suggest Ideas covers all chat surfaces and no longer shows a limited-comms badge. --- .../handlers/tasks/submitTaskSuggestions.ts | 46 +++++---- .../handlers/teams/automation-suggestions.ts | 41 +++++++- apps/docs/automations.mdx | 12 +-- .../AutomationsSettings.client.test.tsx | 1 + ...AutomationsSettings.render.client.test.tsx | 11 ++- .../automations/AutomationsSettings.tsx | 96 ++++++++++++++----- .../settings/automations/formState.ts | 7 ++ .../commands/automations/settings-read.ts | 5 + .../commands/automations/settings-update.ts | 56 +++++++++-- .../src/trpc/commands/automations/types.ts | 3 + apps/web/src/trpc/routers/_app.ts | 5 + packages/db/src/lib/automations.ts | 9 ++ packages/db/src/types.ts | 2 + .../background-automation-registry.test.ts | 5 +- .../src/background-automation-registry.ts | 6 +- 15 files changed, 231 insertions(+), 74 deletions(-) diff --git a/apps/api/src/handlers/tasks/submitTaskSuggestions.ts b/apps/api/src/handlers/tasks/submitTaskSuggestions.ts index b13bd55fa..6a186770e 100644 --- a/apps/api/src/handlers/tasks/submitTaskSuggestions.ts +++ b/apps/api/src/handlers/tasks/submitTaskSuggestions.ts @@ -1308,24 +1308,27 @@ export async function submitTaskSuggestions( resolveScheduledSuggestionSlackConfig(payload.suggestionSource) .automationKey, ); - // An automation with its own Discord/Telegram destination target skips - // higher-precedence surfaces — the summary belongs on that surface. + // An automation with its own non-Slack destination skips higher-precedence + // surfaces — the summary belongs on that surface. const preferredProvider = suggestionRuntime.destination?.provider; - const slackDelivered = - preferredProvider === 'discord' || preferredProvider === 'telegram' - ? false - : await postSuggestedTasksSummaryToSlack({ - sourceTaskId: taskId, - createdByUserId, - suggestionSource: payload.suggestionSource, - historicalThreadFeedbackDebugSnippet: - payload.historicalThreadFeedbackDebugSnippet ?? null, - suggestions: persistedSuggestions, - }); + 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 === 'telegram' || preferredProvider === 'teams' ? false : await postScheduledSuggestionsToDiscord({ sourceTaskId: taskId, @@ -1335,12 +1338,15 @@ export async function submitTaskSuggestions( }); if (!discordDelivered) { - const telegramDelivered = await postScheduledSuggestionsToTelegram({ - sourceTaskId: taskId, - createdByUserId, - suggestionSource: payload.suggestionSource, - suggestions: persistedSuggestions, - }); + const telegramDelivered = + preferredProvider === 'teams' + ? false + : await postScheduledSuggestionsToTelegram({ + sourceTaskId: taskId, + createdByUserId, + suggestionSource: payload.suggestionSource, + suggestions: persistedSuggestions, + }); if (!telegramDelivered) { await postScheduledSuggestionsToTeams({ 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/docs/automations.mdx b/apps/docs/automations.mdx index 9e765c780..24569db19 100644 --- a/apps/docs/automations.mdx +++ b/apps/docs/automations.mdx @@ -119,12 +119,12 @@ Discord channel (see [Communications](/communications#supported-providers) for connecting those surfaces). -**Suggest Ideas** can also report to Telegram as a first-class destination. -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. Other automations treat Telegram as a fallback -surface when no Slack destination is available. +**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 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 e33325274..6eeb8e188 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,7 @@ const baseFormState: FormState = { 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 a1d3450d5..82c79d052 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 @@ -34,6 +34,7 @@ const state = vi.hoisted(() => ({ slackConnected: true, discordConnected: false, telegramConnected: false, + teamsConnected: false, requiresSlackReconnect: false, missingScopes: [], slackWorkspaceDomain: 'acme', @@ -91,6 +92,7 @@ const state = vi.hoisted(() => ({ suggesterSlackChannelId: null, suggesterDiscordChannelId: null, suggesterTelegramChatId: null, + suggesterTeamsChannelId: null, suggesterInstructions: null, suggesterRoutingMode: 'manager_channel' as const, suggesterRoutingInstructions: null, @@ -582,12 +584,11 @@ describe('AutomationsSettings', () => { // Dependabot, CodeQL, Issue Fixer, and CI failure triage stay GitHub-only; manager // stats is provider-neutral now and shows no source-control badge. expect((await screen.findAllByText('GitHub only')).length).toBe(4); - // The suggester supports Slack, Discord, and Telegram 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 · Telegram only').length).toBe( - 1, - ); + expect(screen.queryByText('Slack · Discord · Telegram only')).toBeNull(); + expect(screen.queryByText(/Telegram only$/)).toBeNull(); // conflict_resolver supports GitHub, GitLab, and Azure DevOps (no // Gitea/Bitbucket conflict signal). expect( diff --git a/apps/web/src/components/settings/automations/AutomationsSettings.tsx b/apps/web/src/components/settings/automations/AutomationsSettings.tsx index 0d421b056..bcbd48412 100644 --- a/apps/web/src/components/settings/automations/AutomationsSettings.tsx +++ b/apps/web/src/components/settings/automations/AutomationsSettings.tsx @@ -152,6 +152,7 @@ type FieldErrors = Partial< | 'announcerDiscordChannel' | 'platformIssueDiscordChannel' | 'suggesterUseTelegram' + | 'suggesterUseTeams' | 'sentryTriageProjectSlugs' | 'suggesterInstructions' | 'suggesterRoutingInstructions' @@ -221,6 +222,7 @@ const SLACK_TO_DISCORD_DESTINATION_FIELDS = Object.fromEntries( 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; @@ -720,6 +722,7 @@ function mapSettingsToFormState( suggesterSlackChannelName?: string | null; suggesterDiscordChannelId: string | null; suggesterTelegramChatId: string | null; + suggesterTeamsChannelId: string | null; suggesterInstructions: string | null; suggesterRoutingMode: SuggesterRoutingMode; suggesterRoutingInstructions: string | null; @@ -825,6 +828,7 @@ function mapSettingsToFormState( '', suggesterDiscordChannel: settings.suggesterDiscordChannelId ?? '', suggesterUseTelegram: Boolean(settings.suggesterTelegramChatId), + suggesterUseTeams: Boolean(settings.suggesterTeamsChannelId), suggesterInstructions: settings.suggesterInstructions ?? '', suggesterRoutingMode: settings.suggesterRoutingMode ?? DEFAULT_SUGGESTER_ROUTING_MODE, @@ -2363,6 +2367,8 @@ export function AutomationsSettings() { warningChannelId, allowTelegram = false, savedTelegramSelected = false, + allowTeams = false, + savedTeamsSelected = false, }: { field: AutomationSlackDestinationField; inputId: string; @@ -2371,24 +2377,31 @@ export function AutomationsSettings() { savedChannelId: string | null; savedDiscordChannelId: string | null; warningChannelId: string | null; - /** Suggest Ideas only: offer sticky Telegram primary-chat topic. */ + /** 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); const showTelegramOption = allowTelegram && (telegramConnected || useTelegram || savedTelegramSelected); - // The historical labels say "Slack channel"; once multi-provider options - // are offered that wording reads as a bug. - const multiProvider = showDiscordOptions || showTelegramOption; + const showTeamsOption = + allowTeams && (teamsConnected || useTeams || savedTeamsSelected); + const multiProvider = + showDiscordOptions || showTelegramOption || showTeamsOption; const effectiveLabel = multiProvider ? label.replace(/ Slack channel$/u, ' channel') : label; @@ -2398,57 +2411,77 @@ export function AutomationsSettings() { ? buildAutomationDiscordDestinationOptions({ channels: discordChannelsQuery.data?.channels ?? [], selectedChannelId: discordValue || null, - includeProviderSuffix: slackConnected || showTelegramOption, + includeProviderSuffix: + slackConnected || showTelegramOption || showTeamsOption, }) : []), + ...(showTeamsOption + ? [ + { + id: TEAMS_DESTINATION_OPTION, + name: 'Teams', + label: 'Teams · primary conversation', + }, + ] + : []), ...(showTelegramOption ? [ { id: TELEGRAM_DESTINATION_OPTION, name: 'Telegram', - label: - slackConnected || showDiscordOptions - ? 'Telegram · recurring topic' - : 'Telegram · recurring topic', + label: 'Telegram · recurring topic', }, ] : []), ]; - // One-of destination: Telegram/Discord win the combobox when selected. const selectedValue = useTelegram ? TELEGRAM_DESTINATION_OPTION - : discordValue - ? `${DISCORD_DESTINATION_OPTION_PREFIX}${discordValue}` - : value || null; + : useTeams + ? TEAMS_DESTINATION_OPTION + : discordValue + ? `${DISCORD_DESTINATION_OPTION_PREFIX}${discordValue}` + : value || null; - const telegramHelper = useTelegram + 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.' - : helperText; + : useTeams + ? 'Roomote will post Suggest Ideas digests to your primary Teams conversation.' + : helperText; + + const clearSuggesterAltDestinations = { + ...(allowTelegram ? { suggesterUseTelegram: false } : {}), + ...(allowTeams ? { suggesterUseTeams: false } : {}), + }; return ( setFormState((prev) => { @@ -2482,6 +2517,17 @@ export function AutomationsSettings() { [field]: '', [discordField]: '', suggesterUseTelegram: true, + ...(allowTeams ? { suggesterUseTeams: false } : {}), + }; + } + + if (nextValue === TEAMS_DESTINATION_OPTION) { + return { + ...prev, + [field]: '', + [discordField]: '', + suggesterUseTeams: true, + ...(allowTelegram ? { suggesterUseTelegram: false } : {}), }; } @@ -2492,7 +2538,7 @@ export function AutomationsSettings() { [discordField]: nextValue.slice( DISCORD_DESTINATION_OPTION_PREFIX.length, ), - ...(allowTelegram ? { suggesterUseTelegram: false } : {}), + ...clearSuggesterAltDestinations, }; } @@ -2500,7 +2546,7 @@ export function AutomationsSettings() { ...prev, [field]: nextValue ?? '', [discordField]: '', - ...(allowTelegram ? { suggesterUseTelegram: false } : {}), + ...clearSuggesterAltDestinations, }; }) } @@ -4067,6 +4113,10 @@ export function AutomationsSettings() { 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 4e2ffef62..d418efaf1 100644 --- a/apps/web/src/components/settings/automations/formState.ts +++ b/apps/web/src/components/settings/automations/formState.ts @@ -95,6 +95,11 @@ export type FormState = { * (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; @@ -168,6 +173,7 @@ const SUGGESTER_FIELDS: Array = [ 'suggesterFrequency', ...DESTINATION_CHANNEL_FIELDS_BY_AUTOMATION_ID.suggester, 'suggesterUseTelegram', + 'suggesterUseTeams', 'suggesterInstructions', 'suggesterRoutingMode', 'suggesterRoutingInstructions', @@ -341,6 +347,7 @@ export function buildAutomationSettingsSaveInput( 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 7729dbc5d..a7735728f 100644 --- a/apps/web/src/trpc/commands/automations/settings-read.ts +++ b/apps/web/src/trpc/commands/automations/settings-read.ts @@ -20,6 +20,7 @@ import { import { findDiscordDestinationByChannelId, findTeamsConversationDisplayName, + findTeamsPrimaryConversation, resolveAutomationRuntimeDestination, type ResolvedAutomationDestination, } from '@roomote/sdk/server'; @@ -263,6 +264,7 @@ export async function getBackgroundAgentSettingsCommand( slackConnected: boolean; discordConnected: boolean; telegramConnected: boolean; + teamsConnected: boolean; sentryConnected: boolean; missingScopes: readonly string[]; requiredScopes: string[]; @@ -298,6 +300,7 @@ export async function getBackgroundAgentSettingsCommand( slackInstallation, discordInstallation, telegramCredentials, + teamsPrimaryConversation, sentryConnected, recentRuns, status, @@ -309,6 +312,7 @@ export async function getBackgroundAgentSettingsCommand( columns: { id: true }, }), resolveTelegramRuntimeCredentials(), + findTeamsPrimaryConversation(), hasActiveSentryIntegration(), listRecentAutomationTasks(), buildAutomationStatus(), @@ -396,6 +400,7 @@ export async function getBackgroundAgentSettingsCommand( 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 983c125d7..4ea4d3ff5 100644 --- a/apps/web/src/trpc/commands/automations/settings-update.ts +++ b/apps/web/src/trpc/commands/automations/settings-update.ts @@ -21,6 +21,7 @@ import { } from '@roomote/db/server'; import { findDiscordDestinationByChannelId, + findTeamsPrimaryConversation, findTelegramPrimaryChatId, resolveAutomationRuntimeDestination, } from '@roomote/sdk/server'; @@ -134,7 +135,7 @@ function buildSuggesterAutomationSettings(params: { function buildDestinationChannelTargets( slackChannelId: string | null | undefined, discordChannelId: string | null | undefined, - telegramTarget?: AutomationTarget | null, + extraTargets: readonly AutomationTarget[] = [], ): AutomationTarget[] { return [ ...(slackChannelId @@ -155,7 +156,7 @@ function buildDestinationChannelTargets( }, ] : []), - ...(telegramTarget ? [telegramTarget] : []), + ...extraTargets, ]; } @@ -526,14 +527,17 @@ export async function updateBackgroundAgentSettingsCommand( const platformIssueDiscordResult = destinationResults.platformIssueAlerts.discord; - // Suggest Ideas may target Telegram via a sticky recurring topic in the - // primary chat (no thread picker). Resolve that before destination - // validation so Telegram counts as a configured destination. + // 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 = @@ -555,7 +559,29 @@ export async function updateBackgroundAgentSettingsCommand( topicName: 'Suggest Ideas', }, }; - // Telegram is one-of: clear Slack/Discord channel results for suggester. + 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 }; } @@ -832,6 +858,7 @@ export async function updateBackgroundAgentSettingsCommand( suggesterChannelResult.channelId ?? suggesterDiscordResult.channelId ?? suggesterTelegramTarget?.externalRef ?? + suggesterTeamsTarget?.externalRef ?? null, field: 'suggesterSlackChannel', }, @@ -1021,20 +1048,29 @@ export async function updateBackgroundAgentSettingsCommand( throw new Error(`Missing destination descriptor for ${automationId}`); } const result = destinationResults[automationId]; - const telegramTarget = - automationId === 'suggester' ? suggesterTelegramTarget : null; + const suggesterExtraTargets: AutomationTarget[] = + automationId === 'suggester' + ? [ + ...(suggesterTelegramTarget ? [suggesterTelegramTarget] : []), + ...(suggesterTeamsTarget ? [suggesterTeamsTarget] : []), + ] + : []; return { targets: [ ...buildDestinationChannelTargets( result.slack.channelId, result.discord.channelId, - telegramTarget, + suggesterExtraTargets, ), ...extraTargets, ], managedTargetKinds: automationId === 'suggester' - ? [...descriptor.managedTargetKinds, 'telegram_chat' as const] + ? [ + ...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 3369ccaf8..b3f93912f 100644 --- a/apps/web/src/trpc/commands/automations/types.ts +++ b/apps/web/src/trpc/commands/automations/types.ts @@ -51,6 +51,7 @@ export type BackgroundAgentFieldErrorKey = | 'announcerDiscordChannel' | 'platformIssueDiscordChannel' | 'suggesterUseTelegram' + | 'suggesterUseTeams' | 'sentryTriageProjectSlugs' | 'suggesterInstructions' | 'suggesterRoutingInstructions' @@ -272,6 +273,8 @@ export interface UpdateBackgroundAgentSettingsInput extends ScheduleOnlyAutomati 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 8443834a5..5280d75eb 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -532,6 +532,11 @@ const automationsRouter = createRouter({ * 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/automations.ts b/packages/db/src/lib/automations.ts index 395d09e4e..3e781a156 100644 --- a/packages/db/src/lib/automations.ts +++ b/packages/db/src/lib/automations.ts @@ -226,6 +226,14 @@ export function getAutomationTelegramChatTarget( ); } +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, @@ -942,6 +950,7 @@ 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 debe707ec..8a7019759 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -465,6 +465,8 @@ export type BackgroundAgentSettings = StoredBackgroundAgentSettings & { 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/types/src/__tests__/background-automation-registry.test.ts b/packages/types/src/__tests__/background-automation-registry.test.ts index 886ffd563..33d293f59 100644 --- a/packages/types/src/__tests__/background-automation-registry.test.ts +++ b/packages/types/src/__tests__/background-automation-registry.test.ts @@ -78,14 +78,15 @@ describe('background automation registry', () => { }); }); - it('allows Slack, Discord, and Telegram destinations for the suggester', () => { + it('allows all communication destinations for the suggester', () => { const descriptor = getTriggerableBackgroundAutomationDescriptorByKey('suggester'); expect(descriptor?.supportedCommunicationProviders).toEqual([ 'slack', - 'discord', + 'teams', 'telegram', + 'discord', ]); }); diff --git a/packages/types/src/background-automation-registry.ts b/packages/types/src/background-automation-registry.ts index ee24fe2bd..e68aaeeab 100644 --- a/packages/types/src/background-automation-registry.ts +++ b/packages/types/src/background-automation-registry.ts @@ -151,9 +151,9 @@ export const TRIGGERABLE_BACKGROUND_AUTOMATION_DESCRIPTORS = [ // Provider-agnostic: suggestion scans work with any synced repository. manualTriggerRequirements: ['slack', 'repository'], usesManagerChannel: true, - // Slack/Discord pick an existing channel; Telegram auto-creates a - // sticky recurring forum topic in the primary chat (no thread picker). - supportedCommunicationProviders: ['slack', 'discord', 'telegram'], + // 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', },