diff --git a/apps/backend/.env b/apps/backend/.env index 66c370708e..c537937d63 100644 --- a/apps/backend/.env +++ b/apps/backend/.env @@ -83,3 +83,5 @@ STACK_OPENAI_API_KEY=# enter your openai api key STACK_FEATUREBASE_API_KEY=# enter your featurebase api key STACK_STRIPE_SECRET_KEY=# enter your stripe api key STACK_STRIPE_WEBHOOK_SECRET=# enter your stripe webhook secret +STACK_TELEGRAM_BOT_TOKEN= # enter you telegram bot token +STACK_TELEGRAM_CHAT_ID=# enter your telegram chat id diff --git a/apps/backend/src/app/api/latest/internal/init-script-callback/route.tsx b/apps/backend/src/app/api/latest/internal/init-script-callback/route.tsx new file mode 100644 index 0000000000..4653a80eae --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/init-script-callback/route.tsx @@ -0,0 +1,133 @@ +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { adaptSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; +import { InferType } from "yup"; + +const TELEGRAM_HOSTNAME = "api.telegram.org"; +const TELEGRAM_ENDPOINT_PATH = "/sendMessage"; +const STACK_TRACE_MAX_LENGTH = 4000; +const MESSAGE_PREFIX = "_".repeat(50); + + +const completionPayloadSchema = yupObject({ + success: yupBoolean().defined(), + distinctId: yupString().optional(), + options: adaptSchema.defined(), + args: yupArray(yupString().defined()).defined(), + isNonInteractive: yupBoolean().defined(), + timestamp: yupString().defined(), + projectPath: yupString().optional(), + error: yupObject({ + name: yupString().optional(), + message: yupString().defined(), + stack: yupString().optional(), + }).optional(), +}).defined(); + +export const POST = createSmartRouteHandler({ + request: yupObject({ + auth: yupObject({ + type: adaptSchema, + user: adaptSchema, + project: adaptSchema, + }).nullable(), + body: completionPayloadSchema, + method: yupString().oneOf(["POST"]).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + success: yupBoolean().oneOf([true]).defined(), + }).defined(), + }), + async handler({ body }) { + const botToken = getEnvVariable("STACK_TELEGRAM_BOT_TOKEN", ""); + const chatId = getEnvVariable("STACK_TELEGRAM_CHAT_ID", ""); + + if (!botToken || !chatId) { + throw new StackAssertionError("Telegram integration is not configured."); + } + + const message = buildMessage(body); + await postToTelegram({ botToken, chatId, message }); + + return { + statusCode: 200, + bodyType: "json", + body: { + success: true, + }, + }; + }, +}); + +function buildMessage(payload: InferType): string { + const { success, distinctId, options, args, isNonInteractive, timestamp, projectPath, error } = payload; + const status = success ? "[SUCCESS]" : "[FAILURE]"; + const optionSummary = safeJson(options); + const argSummary = args.length ? safeJson(args) : "[]"; + const errorSummary = error?.message ? `${error.name ? `${error.name}: ` : ""}${error.message}` : "none"; + + const lines = [ + `Stack init completed ${status}`, + `Timestamp: ${timestamp}`, + distinctId ? `DistinctId: ${distinctId}` : undefined, + `NonInteractiveEnv: ${isNonInteractive}`, + projectPath ? `ProjectPath: ${projectPath}` : undefined, + `Options: ${optionSummary}`, + `Args: ${argSummary}`, + `Error: ${errorSummary}`, + ].filter((line): line is string => Boolean(line)); + + if (error?.stack) { + lines.push(`Stack: ${truncate(error.stack, STACK_TRACE_MAX_LENGTH)}`); + } + + return `${MESSAGE_PREFIX}\n\n${lines.join("\n")}`; +} + +async function postToTelegram({ botToken, chatId, message }: { botToken: string, chatId: string, message: string }): Promise { + const response = await fetch(`https://${TELEGRAM_HOSTNAME}/bot${botToken}${TELEGRAM_ENDPOINT_PATH}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + chat_id: chatId, + text: message, + }), + }); + + if (!response.ok) { + const body = await safeReadBody(response); + throw new StackAssertionError("Failed to send Telegram notification.", { + status: response.status, + body, + }); + } +} + +async function safeReadBody(response: Response): Promise { + try { + return await response.text(); + } catch { + return undefined; + } +} + +function safeJson(value: unknown): string { + try { + return JSON.stringify(value, null, 2); + } catch { + return "[unserializable]"; + } +} + +function truncate(value: string, maxLength: number): string { + if (value.length <= maxLength) { + return value; + } + return `${value.slice(0, maxLength - 3)}...`; +} diff --git a/packages/init-stack/package.json b/packages/init-stack/package.json index fbec65567b..c57efd1ce7 100644 --- a/packages/init-stack/package.json +++ b/packages/init-stack/package.json @@ -12,7 +12,7 @@ "lint": "eslint --ext .tsx,.ts .", "typecheck": "tsc --noEmit", "init-stack": "node dist/index.js", - "init-stack:local": "STACK_NEXT_INSTALL_PACKAGE_NAME_OVERRIDE=../../stack STACK_JS_INSTALL_PACKAGE_NAME_OVERRIDE=../../js STACK_REACT_INSTALL_PACKAGE_NAME_OVERRIDE=../../react node dist/index.js", + "init-stack:local": "STACK_INIT_API_BASE_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}02 STACK_NEXT_INSTALL_PACKAGE_NAME_OVERRIDE=../../stack STACK_JS_INSTALL_PACKAGE_NAME_OVERRIDE=../../js STACK_REACT_INSTALL_PACKAGE_NAME_OVERRIDE=../../react node dist/index.js", "test-run": "pnpm run build && pnpm run test-run-js && pnpm run test-run-node && pnpm run test-run-next && pnpm run test-run-neon && pnpm run test-run-no-browser", "test-run:manual": "pnpm run build && pnpm run test-run-js:manual && pnpm run test-run-node:manual && pnpm run test-run-next:manual && pnpm run test-run-neon:manual", "ensure-neon": "grep -q '\"@neondatabase/serverless\"' ./test-run-output/package.json && echo 'Initialized Neon successfully!'", diff --git a/packages/init-stack/src/index.ts b/packages/init-stack/src/index.ts index d5853c2fe4..52dc166ea3 100644 --- a/packages/init-stack/src/index.ts +++ b/packages/init-stack/src/index.ts @@ -8,6 +8,7 @@ import * as os from 'os'; import * as path from "path"; import { PostHog } from 'posthog-node'; import packageJson from '../package.json'; +import { invokeCallback } from "./telegram"; import { scheduleMcpConfiguration } from "./mcp"; import { Colorize, configureVerboseLogging, logVerbose, templateIdentity } from "./util"; @@ -420,6 +421,16 @@ async function main(): Promise { commandsExecuted, }); + await invokeCallback({ + success: true, + distinctId, + options, + args: program.args, + isNonInteractive: isNonInteractiveEnv(), + timestamp: new Date().toISOString(), + projectPath, + }); + // Success! console.log(` ${colorize.green`===============================================`} @@ -474,6 +485,29 @@ main().catch(async (err) => { console.error(`Error message: ${err.message}`); } console.error(); + const fallbackErrorMessage = (() => { + if (err instanceof Error) return err.message; + if (typeof err === "string") return err; + try { + return JSON.stringify(err); + } catch { + return "Unknown error"; + } + })(); + await invokeCallback({ + success: false, + distinctId, + options, + args: program.args, + isNonInteractive: isNonInteractiveEnv(), + timestamp: new Date().toISOString(), + projectPath: savedProjectPath, + error: { + name: err instanceof Error ? err.name : undefined, + message: fallbackErrorMessage, + stack: err instanceof Error ? err.stack : undefined, + }, + }); await ph_client.shutdown(); process.exit(1); }); diff --git a/packages/init-stack/src/telegram.ts b/packages/init-stack/src/telegram.ts new file mode 100644 index 0000000000..d817f293f7 --- /dev/null +++ b/packages/init-stack/src/telegram.ts @@ -0,0 +1,31 @@ +type TelegramErrorInfo = { + name?: string, + message: string, + stack?: string, +}; + +export type TelegramCompletionPayload = { + success: boolean, + distinctId?: string, + options: Record, + args: string[], + isNonInteractive: boolean, + timestamp: string, + projectPath?: string, + error?: TelegramErrorInfo, +}; + +const API_BASE_ENV = "STACK_INIT_API_BASE_URL"; +const DEFAULT_API_BASE_URL = "https://api.stack-auth.com"; +const CALLBACK_ENDPOINT = "/api/latest/internal/init-script-callback"; + +export async function invokeCallback(payload: TelegramCompletionPayload): Promise { + const baseUrl = process.env[API_BASE_ENV] ?? DEFAULT_API_BASE_URL; + await fetch(`${baseUrl}${CALLBACK_ENDPOINT}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); +}