kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
1import { and, eq } from "drizzle-orm";
2import { HTTPException } from "hono/http-exception";
3import * as v from "valibot";
4import db from "../../database";
5import { integrationTable } from "../../database/schema";
6import {
7 defaultTelegramEvents,
8 normalizeTelegramConfig,
9 type TelegramConfig,
10 telegramConfigSchema,
11 telegramEventsSchema,
12} from "../../plugins/telegram/config";
13
14export const telegramIntegrationPatchBodySchema = v.object({
15 botToken: v.optional(v.string()),
16 chatId: v.optional(v.string()),
17 threadId: v.optional(v.nullable(v.number())),
18 chatLabel: v.optional(v.nullable(v.string())),
19 isActive: v.optional(v.boolean()),
20 events: v.optional(telegramEventsSchema),
21});
22
23export type TelegramIntegrationPatchBody = v.InferOutput<
24 typeof telegramIntegrationPatchBodySchema
25>;
26
27export function buildNextTelegramConfigFromPatch(
28 body: TelegramIntegrationPatchBody,
29 currentConfig: TelegramConfig,
30): TelegramConfig {
31 const nextBotToken =
32 "botToken" in body ? (body.botToken?.trim() ?? "") : currentConfig.botToken;
33 const nextChatId =
34 "chatId" in body ? (body.chatId?.trim() ?? "") : currentConfig.chatId;
35 return {
36 botToken: nextBotToken,
37 chatId: nextChatId,
38 threadId:
39 body.threadId === undefined
40 ? currentConfig.threadId
41 : (body.threadId ?? undefined),
42 chatLabel:
43 body.chatLabel === undefined
44 ? currentConfig.chatLabel
45 : (body.chatLabel ?? undefined),
46 events: {
47 ...(currentConfig.events ?? {}),
48 ...(body.events ?? {}),
49 },
50 };
51}
52
53function maskBotToken(value: string): string {
54 const [prefix, suffix = ""] = value.split(":", 2);
55 if (!suffix) {
56 return "Configured";
57 }
58
59 const maskedSuffix =
60 suffix.length > 8 ? `${suffix.slice(0, 4)}…${suffix.slice(-4)}` : "••••";
61 return `${prefix}:${maskedSuffix}`;
62}
63
64function sanitizeTelegramConfigForLog(rawConfig: string): string {
65 try {
66 const parsed = JSON.parse(rawConfig) as Record<string, unknown>;
67 for (const key of [
68 "botToken",
69 "chatId",
70 "threadId",
71 "chatLabel",
72 ] as const) {
73 if (key in parsed) {
74 parsed[key] = "[REDACTED]";
75 }
76 }
77 return JSON.stringify(parsed);
78 } catch {
79 return "[UNPARSEABLE]";
80 }
81}
82
83type TelegramIntegrationRecord = {
84 id: string;
85 projectId: string;
86 config: string;
87 isActive: boolean | null;
88 createdAt: Date;
89 updatedAt: Date;
90};
91
92export function parseTelegramIntegrationConfig(
93 integration: Pick<TelegramIntegrationRecord, "config" | "id" | "projectId">,
94): TelegramConfig {
95 try {
96 const parsed = v.parse(
97 telegramConfigSchema,
98 JSON.parse(integration.config),
99 );
100 return normalizeTelegramConfig(parsed);
101 } catch (error) {
102 console.error("Failed to parse Telegram integration config", {
103 error,
104 integrationId: integration.id,
105 projectId: integration.projectId,
106 sanitizedConfig: sanitizeTelegramConfigForLog(integration.config),
107 });
108 throw new HTTPException(500, {
109 message: "Stored Telegram integration configuration is invalid",
110 });
111 }
112}
113
114export function toResponse(integration: TelegramIntegrationRecord) {
115 const config = parseTelegramIntegrationConfig(integration);
116
117 return {
118 id: integration.id,
119 projectId: integration.projectId,
120 chatId: config.chatId,
121 threadId: config.threadId ?? null,
122 chatLabel: config.chatLabel ?? null,
123 botTokenConfigured: Boolean(config.botToken),
124 maskedBotToken: config.botToken ? maskBotToken(config.botToken) : "",
125 events: {
126 ...defaultTelegramEvents,
127 ...(config.events ?? {}),
128 },
129 isActive: integration.isActive,
130 createdAt: integration.createdAt,
131 updatedAt: integration.updatedAt,
132 };
133}
134
135export async function getTelegramIntegration(projectId: string) {
136 const integration = await db.query.integrationTable.findFirst({
137 where: and(
138 eq(integrationTable.projectId, projectId),
139 eq(integrationTable.type, "telegram"),
140 ),
141 });
142
143 if (!integration) {
144 return null;
145 }
146
147 return toResponse(integration);
148}