diff --git a/src/lib/config.server.ts b/src/lib/config.server.ts index 3978873be..844c16bbc 100644 --- a/src/lib/config.server.ts +++ b/src/lib/config.server.ts @@ -106,6 +106,10 @@ export const SLACK_USER_FEEDBACK_WEBHOOK_URL = getEnvVariable('SLACK_USER_FEEDBA // Posts deploy threat alerts to a dedicated Slack channel. // Expected to be a Slack Incoming Webhook URL. export const SLACK_DEPLOY_THREAT_WEBHOOK_URL = getEnvVariable('SLACK_DEPLOY_THREAT_WEBHOOK_URL'); +// Bot token + channel for internal Kilo feedback notifications via chat.postMessage. +// The bot token must have the chat:write scope. +export const SLACK_FEEDBACK_BOT_TOKEN = getEnvVariable('SLACK_FEEDBACK_BOT_TOKEN'); +export const SLACK_FEEDBACK_CHANNEL_ID = getEnvVariable('SLACK_FEEDBACK_CHANNEL_ID'); export const ENABLE_MILVUS_DUAL_WRITE = true; // AI Attribution Service diff --git a/src/lib/feedback/app-builder-feedback-slack.ts b/src/lib/feedback/app-builder-feedback-slack.ts new file mode 100644 index 000000000..4fd30731b --- /dev/null +++ b/src/lib/feedback/app-builder-feedback-slack.ts @@ -0,0 +1,53 @@ +import 'server-only'; + +import { + postInternalSlackMessage, + postInternalSlackThreadReply, +} from '@/lib/slack/internal-notifications'; + +const FEEDBACK_PREVIEW_LIMIT = 500; + +/** + * Sends an app builder feedback notification to the internal Kilo Slack channel. + * If the feedback text exceeds the preview limit, the full text is posted as a thread reply. + * Fire-and-forget safe — catches and logs all errors. + */ +export async function notifyAppBuilderFeedback( + projectId: string, + feedbackText: string +): Promise { + const adminLink = `https://app.kilo.ai/admin/app-builder/${projectId}`; + const trimmedFeedback = feedbackText.trim(); + const wasTruncated = trimmedFeedback.length > FEEDBACK_PREVIEW_LIMIT; + const previewText = + trimmedFeedback.slice(0, FEEDBACK_PREVIEW_LIMIT) + (wasTruncated ? '...' : ''); + + try { + const ts = await postInternalSlackMessage( + [ + { + type: 'header', + text: { + type: 'plain_text', + text: 'New App Builder feedback :hammer_and_wrench:', + }, + }, + { + type: 'section', + text: { type: 'mrkdwn', text: `<${adminLink}|View project>` }, + }, + { + type: 'section', + text: { type: 'plain_text', text: previewText }, + }, + ], + 'New App Builder feedback' + ); + + if (wasTruncated && ts) { + await postInternalSlackThreadReply(ts, trimmedFeedback); + } + } catch (error) { + console.error('[AppBuilderFeedback] Failed to post to Slack', error); + } +} diff --git a/src/lib/slack/internal-notifications.ts b/src/lib/slack/internal-notifications.ts new file mode 100644 index 000000000..3bb29c6e2 --- /dev/null +++ b/src/lib/slack/internal-notifications.ts @@ -0,0 +1,57 @@ +import 'server-only'; + +import { WebClient } from '@slack/web-api'; +import type { Block, KnownBlock } from '@slack/web-api'; +import { SLACK_FEEDBACK_BOT_TOKEN, SLACK_FEEDBACK_CHANNEL_ID } from '@/lib/config.server'; + +function makeClient(): WebClient { + if (!SLACK_FEEDBACK_BOT_TOKEN) { + throw new Error('SLACK_FEEDBACK_BOT_TOKEN is not configured'); + } + return new WebClient(SLACK_FEEDBACK_BOT_TOKEN); +} + +/** + * Posts a message to the internal Kilo Slack feedback channel. + * Returns the message ts on success (needed for threading), or undefined if not configured. + * Throws on API errors. + */ +export async function postInternalSlackMessage( + blocks: (Block | KnownBlock)[], + fallbackText: string +): Promise { + if (!SLACK_FEEDBACK_BOT_TOKEN || !SLACK_FEEDBACK_CHANNEL_ID) { + return undefined; + } + + const client = makeClient(); + const result = await client.chat.postMessage({ + channel: SLACK_FEEDBACK_CHANNEL_ID, + text: fallbackText, + unfurl_links: false, + unfurl_media: false, + blocks, + }); + + return result.ts; +} + +/** + * Posts a thread reply to an existing message in the internal Kilo Slack feedback channel. + * Throws on API errors. + */ +export async function postInternalSlackThreadReply( + threadTs: string, + text: string +): Promise { + if (!SLACK_FEEDBACK_BOT_TOKEN || !SLACK_FEEDBACK_CHANNEL_ID) { + return; + } + + const client = makeClient(); + await client.chat.postMessage({ + channel: SLACK_FEEDBACK_CHANNEL_ID, + thread_ts: threadTs, + text, + }); +} diff --git a/src/routers/app-builder-feedback-router.ts b/src/routers/app-builder-feedback-router.ts index 39bd30692..447576ba4 100644 --- a/src/routers/app-builder-feedback-router.ts +++ b/src/routers/app-builder-feedback-router.ts @@ -6,7 +6,7 @@ import { app_builder_feedback } from '@/db/schema'; import { getProjectWithOwnershipCheck } from '@/lib/app-builder/app-builder-service'; import { ensureOrganizationAccess } from '@/routers/organizations/utils'; import * as z from 'zod'; -import { SLACK_USER_FEEDBACK_WEBHOOK_URL } from '@/lib/config.server'; +import { notifyAppBuilderFeedback } from '@/lib/feedback/app-builder-feedback-slack'; import type { Owner } from '@/lib/integrations/core/types'; const recentMessageSchema = z.object({ @@ -57,47 +57,9 @@ export const appBuilderFeedbackRouter = createTRPCRouter({ .returning({ id: app_builder_feedback.id }); // Best-effort Slack notification - if (SLACK_USER_FEEDBACK_WEBHOOK_URL) { - const adminLink = `https://app.kilo.ai/admin/app-builder/${input.project_id}`; - const trimmedFeedback = input.feedback_text.trim(); - const wasTruncated = trimmedFeedback.length > 500; - const feedbackText = trimmedFeedback.slice(0, 500) + (wasTruncated ? '...' : ''); - - fetch(SLACK_USER_FEEDBACK_WEBHOOK_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - text: 'New App Builder feedback', - unfurl_links: false, - unfurl_media: false, - blocks: [ - { - type: 'header', - text: { - type: 'plain_text', - text: 'New App Builder feedback :hammer_and_wrench:', - }, - }, - { - type: 'section', - text: { - type: 'mrkdwn', - text: `<${adminLink}|View project>`, - }, - }, - { - type: 'section', - text: { - type: 'plain_text', - text: feedbackText, - }, - }, - ], - }), - }).catch(error => { - console.error('[AppBuilderFeedback] Failed to post to Slack webhook', error); - }); - } + notifyAppBuilderFeedback(input.project_id, input.feedback_text).catch(error => { + console.error('[AppBuilderFeedback] Failed to post to Slack', error); + }); return inserted; }),