kaneo (minimalist kanban) fork to experiment adding a tangled integration github.com/usekaneo/kaneo
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at cd7cada2f86b4e866a15b4323bb8d6d7ab5bba8b 293 lines 8.1 kB view raw
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, "&amp;") 55 .replace(/</g, "&lt;") 56 .replace(/>/g, "&gt;"); 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}