kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
1import { createHash } from "node:crypto";
2import { and, eq } from "drizzle-orm";
3import db from "../../database";
4import {
5 projectTable,
6 taskTable,
7 userTable,
8 workspaceTable,
9} from "../../database/schema";
10import type {
11 PluginContext,
12 TaskCommentCreatedEvent,
13 TaskCreatedEvent,
14 TaskDescriptionChangedEvent,
15 TaskPriorityChangedEvent,
16 TaskStatusChangedEvent,
17 TaskTitleChangedEvent,
18} from "../types";
19import { postToTelegram } from "./client";
20import type { TelegramConfig, TelegramEventKey } from "./config";
21import { normalizeTelegramConfig, validateTelegramConfig } from "./config";
22
23type TelegramEventData = {
24 taskTitle: string;
25 taskNumber: number | null;
26 projectName: string;
27 taskUrl: string | null;
28 actorName: string | null;
29 status: string | null;
30 priority: string | null;
31};
32
33function isEnabled(config: TelegramConfig, key: TelegramEventKey): boolean {
34 return config.events?.[key] ?? false;
35}
36
37function toSentenceCase(value: string | null): string {
38 if (!value) return "Unknown";
39 return value
40 .replace(/[-_]+/g, " ")
41 .replace(/\b\w/g, (char) => char.toUpperCase());
42}
43
44function truncate(value: string, maxLength: number): string {
45 if (value.length <= maxLength) {
46 return value;
47 }
48
49 return `${value.slice(0, maxLength - 1)}…`;
50}
51
52function escapeHtml(value: string): string {
53 return value
54 .replace(/&/g, "&")
55 .replace(/</g, "<")
56 .replace(/>/g, ">");
57}
58
59function redactBotToken(botToken: string): string {
60 const [prefix, suffix = ""] = botToken.split(":", 2);
61 if (!suffix) {
62 return "redacted";
63 }
64
65 return `${prefix}:${
66 suffix.length > 8 ? `${suffix.slice(0, 4)}…${suffix.slice(-4)}` : "••••"
67 }`;
68}
69
70function getSafeTelegramTargetIdentifier(config: TelegramConfig): string {
71 const hash = createHash("sha256")
72 .update(`${config.chatId}:${config.threadId ?? "none"}`)
73 .digest("hex")
74 .slice(0, 12);
75
76 return `tg:${hash}`;
77}
78
79function getTaskUrl(
80 clientUrl: string | undefined,
81 workspaceId: string,
82 projectId: string,
83 taskId: string,
84): string | null {
85 const normalizedClientUrl = clientUrl?.trim();
86 if (!normalizedClientUrl) {
87 return null;
88 }
89
90 try {
91 return new URL(
92 `/dashboard/workspace/${workspaceId}/project/${projectId}/task/${taskId}`,
93 normalizedClientUrl,
94 ).toString();
95 } catch {
96 return null;
97 }
98}
99
100async function getTelegramEventData(
101 taskId: string,
102 projectId: string,
103 userId: string | null,
104): Promise<TelegramEventData | null> {
105 const taskPromise = db
106 .select({
107 title: taskTable.title,
108 number: taskTable.number,
109 status: taskTable.status,
110 priority: taskTable.priority,
111 projectName: projectTable.name,
112 projectId: projectTable.id,
113 workspaceId: workspaceTable.id,
114 })
115 .from(taskTable)
116 .innerJoin(projectTable, eq(taskTable.projectId, projectTable.id))
117 .innerJoin(workspaceTable, eq(projectTable.workspaceId, workspaceTable.id))
118 .where(and(eq(taskTable.id, taskId), eq(projectTable.id, projectId)))
119 .limit(1);
120
121 const userPromise = userId
122 ? db
123 .select({ name: userTable.name })
124 .from(userTable)
125 .where(eq(userTable.id, userId))
126 .limit(1)
127 : Promise.resolve([]);
128
129 const [[taskRow], [user]] = await Promise.all([taskPromise, userPromise]);
130
131 if (!taskRow) {
132 return null;
133 }
134
135 return {
136 taskTitle: taskRow.title,
137 taskNumber: taskRow.number,
138 projectName: taskRow.projectName,
139 taskUrl: getTaskUrl(
140 process.env.KANEO_CLIENT_URL,
141 taskRow.workspaceId,
142 taskRow.projectId,
143 taskId,
144 ),
145 actorName: user?.name ?? null,
146 status: taskRow.status,
147 priority: taskRow.priority,
148 };
149}
150
151async function sendTelegramMessage(
152 config: TelegramConfig,
153 title: string,
154 body: string,
155 data: TelegramEventData,
156): Promise<void> {
157 const issueKey =
158 data.taskNumber !== null ? `#${data.taskNumber}` : "Task update";
159 const taskLabel = `${issueKey} ${data.taskTitle}`;
160 const escapedTaskLabel = escapeHtml(taskLabel);
161 const taskLine = data.taskUrl
162 ? `<a href="${escapeHtml(data.taskUrl)}">${escapedTaskLabel}</a>`
163 : escapedTaskLabel;
164
165 const lines = [
166 `<b>${escapeHtml(title)}</b>`,
167 escapeHtml(body),
168 "",
169 `<b>Task:</b> ${taskLine}`,
170 `<b>Project:</b> ${escapeHtml(data.projectName)}`,
171 `<b>Status:</b> ${escapeHtml(toSentenceCase(data.status))}`,
172 `<b>Priority:</b> ${escapeHtml(toSentenceCase(data.priority))}`,
173 `<b>Triggered by:</b> ${escapeHtml(data.actorName ?? "Kaneo")}`,
174 ];
175
176 try {
177 await postToTelegram(config.botToken, {
178 chat_id: config.chatId,
179 text: lines.join("\n"),
180 parse_mode: "HTML",
181 disable_web_page_preview: false,
182 message_thread_id: config.threadId,
183 });
184 } catch (error) {
185 console.error("sendTelegramMessage postToTelegram failed", {
186 error,
187 botToken: redactBotToken(config.botToken),
188 telegramTarget: getSafeTelegramTargetIdentifier(config),
189 taskUrl: data.taskUrl,
190 });
191 }
192}
193
194type TelegramMessageContent = {
195 title: string;
196 body: string;
197};
198
199async function runTelegramHandler(
200 context: PluginContext,
201 event: {
202 taskId: string;
203 projectId: string;
204 userId: string | null;
205 },
206 featureKey: TelegramEventKey,
207 buildMessage: () => TelegramMessageContent,
208): Promise<void> {
209 const validation = validateTelegramConfig(context.config);
210 if (!validation.valid) {
211 console.error("Invalid Telegram plugin config; skipping event dispatch", {
212 errors: validation.errors,
213 config: context.config,
214 featureKey,
215 projectId: event.projectId,
216 taskId: event.taskId,
217 });
218 return;
219 }
220
221 const config = normalizeTelegramConfig(context.config as TelegramConfig);
222 if (!isEnabled(config, featureKey)) return;
223
224 const data = await getTelegramEventData(
225 event.taskId,
226 event.projectId,
227 event.userId,
228 );
229 if (!data) return;
230
231 const { title, body } = buildMessage();
232 await sendTelegramMessage(config, title, body, data);
233}
234
235export async function handleTaskCreated(
236 event: TaskCreatedEvent,
237 context: PluginContext,
238): Promise<void> {
239 await runTelegramHandler(context, event, "taskCreated", () => ({
240 title: "New task created",
241 body: `A new task was added: ${event.title}`,
242 }));
243}
244
245export async function handleTaskStatusChanged(
246 event: TaskStatusChangedEvent,
247 context: PluginContext,
248): Promise<void> {
249 await runTelegramHandler(context, event, "taskStatusChanged", () => ({
250 title: "Task status changed",
251 body: `${event.title} moved from ${toSentenceCase(event.oldStatus)} to ${toSentenceCase(event.newStatus)}.`,
252 }));
253}
254
255export async function handleTaskPriorityChanged(
256 event: TaskPriorityChangedEvent,
257 context: PluginContext,
258): Promise<void> {
259 await runTelegramHandler(context, event, "taskPriorityChanged", () => ({
260 title: "Task priority changed",
261 body: `${event.title} changed from ${toSentenceCase(event.oldPriority)} to ${toSentenceCase(event.newPriority)}.`,
262 }));
263}
264
265export async function handleTaskTitleChanged(
266 event: TaskTitleChangedEvent,
267 context: PluginContext,
268): Promise<void> {
269 await runTelegramHandler(context, event, "taskTitleChanged", () => ({
270 title: "Task title changed",
271 body: `Task renamed from ${truncate(event.oldTitle, 120)} to ${truncate(event.newTitle, 120)}.`,
272 }));
273}
274
275export async function handleTaskDescriptionChanged(
276 event: TaskDescriptionChangedEvent,
277 context: PluginContext,
278): Promise<void> {
279 await runTelegramHandler(context, event, "taskDescriptionChanged", () => ({
280 title: "Task description changed",
281 body: `The task description was updated${event.newDescription ? `: ${truncate(event.newDescription.replace(/\s+/g, " "), 160)}` : "."}`,
282 }));
283}
284
285export async function handleTaskCommentCreated(
286 event: TaskCommentCreatedEvent,
287 context: PluginContext,
288): Promise<void> {
289 await runTelegramHandler(context, event, "taskCommentCreated", () => ({
290 title: "New task comment",
291 body: truncate(event.comment.replace(/\s+/g, " "), 200),
292 }));
293}