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 39e2dfae265f26c8d6d888a560f50ab2d5d58b3f 261 lines 7.4 kB view raw
1import { and, eq } from "drizzle-orm"; 2import db from "../../database"; 3import { 4 projectTable, 5 taskTable, 6 userTable, 7 workspaceTable, 8} from "../../database/schema"; 9import type { 10 PluginContext, 11 TaskCommentCreatedEvent, 12 TaskCreatedEvent, 13 TaskDescriptionChangedEvent, 14 TaskPriorityChangedEvent, 15 TaskStatusChangedEvent, 16 TaskTitleChangedEvent, 17} from "../types"; 18import { postToDiscord } from "./client"; 19import type { DiscordConfig, DiscordEventKey } from "./config"; 20import { normalizeDiscordConfig } from "./config"; 21 22type DiscordEventData = { 23 taskTitle: string; 24 taskNumber: number | null; 25 projectName: string; 26 taskUrl: string | null; 27 actorName: string | null; 28 status: string | null; 29 priority: string | null; 30}; 31 32function isEnabled(config: DiscordConfig, key: DiscordEventKey): boolean { 33 return config.events?.[key] ?? false; 34} 35 36function toSentenceCase(value: string | null): string { 37 if (!value) return "Unknown"; 38 return value 39 .replace(/[-_]+/g, " ") 40 .replace(/\b\w/g, (char) => char.toUpperCase()); 41} 42 43function truncate(value: string, maxLength: number): string { 44 if (value.length <= maxLength) { 45 return value; 46 } 47 48 return `${value.slice(0, maxLength - 1)}`; 49} 50 51function redactWebhookUrl(value: string): string { 52 try { 53 const url = new URL(value); 54 const parts = url.pathname.split("/").filter(Boolean); 55 const token = parts.at(-1) ?? ""; 56 const maskedToken = 57 token.length > 6 ? `${token.slice(0, 2)}${token.slice(-4)}` : "redacted"; 58 return `${url.origin}/${parts.slice(0, -1).join("/")}/${maskedToken}`; 59 } catch { 60 return "redacted"; 61 } 62} 63 64async function getDiscordEventData( 65 taskId: string, 66 projectId: string, 67 userId: string | null, 68): Promise<DiscordEventData | null> { 69 const taskPromise = db 70 .select({ 71 title: taskTable.title, 72 number: taskTable.number, 73 status: taskTable.status, 74 priority: taskTable.priority, 75 projectName: projectTable.name, 76 projectId: projectTable.id, 77 workspaceId: workspaceTable.id, 78 }) 79 .from(taskTable) 80 .innerJoin(projectTable, eq(taskTable.projectId, projectTable.id)) 81 .innerJoin(workspaceTable, eq(projectTable.workspaceId, workspaceTable.id)) 82 .where(and(eq(taskTable.id, taskId), eq(projectTable.id, projectId))) 83 .limit(1); 84 85 const userPromise = userId 86 ? db 87 .select({ name: userTable.name }) 88 .from(userTable) 89 .where(eq(userTable.id, userId)) 90 .limit(1) 91 : Promise.resolve([]); 92 93 const [[taskRow], [user]] = await Promise.all([taskPromise, userPromise]); 94 95 if (!taskRow) { 96 return null; 97 } 98 99 const clientUrl = process.env.KANEO_CLIENT_URL || "http://localhost:5173"; 100 const taskUrl = `${clientUrl}/dashboard/workspace/${taskRow.workspaceId}/project/${taskRow.projectId}/task/${taskId}`; 101 102 return { 103 taskTitle: taskRow.title, 104 taskNumber: taskRow.number, 105 projectName: taskRow.projectName, 106 taskUrl, 107 actorName: user?.name ?? null, 108 status: taskRow.status, 109 priority: taskRow.priority, 110 }; 111} 112 113async function sendDiscordMessage( 114 config: DiscordConfig, 115 title: string, 116 body: string, 117 data: DiscordEventData, 118): Promise<void> { 119 const issueKey = 120 data.taskNumber !== null ? `#${data.taskNumber}` : "Task update"; 121 const taskLabel = `${issueKey} ${data.taskTitle}`; 122 123 try { 124 await postToDiscord(config.webhookUrl, { 125 content: `${title}: ${data.taskTitle}`, 126 embeds: [ 127 { 128 title, 129 description: body, 130 url: data.taskUrl ?? undefined, 131 color: 0x5865f2, 132 fields: [ 133 { 134 name: "Task", 135 value: data.taskUrl 136 ? `[${taskLabel}](${data.taskUrl})` 137 : taskLabel, 138 inline: true, 139 }, 140 { 141 name: "Project", 142 value: data.projectName, 143 inline: true, 144 }, 145 { 146 name: "Status", 147 value: toSentenceCase(data.status), 148 inline: true, 149 }, 150 { 151 name: "Priority", 152 value: toSentenceCase(data.priority), 153 inline: true, 154 }, 155 ], 156 footer: { 157 text: data.actorName 158 ? `Triggered by ${data.actorName}` 159 : "Triggered by Kaneo", 160 }, 161 }, 162 ], 163 }); 164 } catch (error) { 165 console.error("sendDiscordMessage postToDiscord failed", { 166 error, 167 webhookUrl: redactWebhookUrl(config.webhookUrl), 168 channelName: config.channelName ?? null, 169 taskUrl: data.taskUrl, 170 }); 171 } 172} 173 174type DiscordMessageContent = { 175 title: string; 176 body: string; 177}; 178 179async function runDiscordHandler( 180 context: PluginContext, 181 event: { 182 taskId: string; 183 projectId: string; 184 userId: string | null; 185 }, 186 featureKey: DiscordEventKey, 187 buildMessage: () => DiscordMessageContent, 188): Promise<void> { 189 const config = normalizeDiscordConfig(context.config as DiscordConfig); 190 if (!isEnabled(config, featureKey)) return; 191 192 const data = await getDiscordEventData( 193 event.taskId, 194 event.projectId, 195 event.userId, 196 ); 197 if (!data) return; 198 199 const { title, body } = buildMessage(); 200 await sendDiscordMessage(config, title, body, data); 201} 202 203export async function handleTaskCreated( 204 event: TaskCreatedEvent, 205 context: PluginContext, 206): Promise<void> { 207 await runDiscordHandler(context, event, "taskCreated", () => ({ 208 title: "New task created", 209 body: `A new task was added: **${event.title}**`, 210 })); 211} 212 213export async function handleTaskStatusChanged( 214 event: TaskStatusChangedEvent, 215 context: PluginContext, 216): Promise<void> { 217 await runDiscordHandler(context, event, "taskStatusChanged", () => ({ 218 title: "Task status changed", 219 body: `**${event.title}** moved from **${toSentenceCase(event.oldStatus)}** to **${toSentenceCase(event.newStatus)}**.`, 220 })); 221} 222 223export async function handleTaskPriorityChanged( 224 event: TaskPriorityChangedEvent, 225 context: PluginContext, 226): Promise<void> { 227 await runDiscordHandler(context, event, "taskPriorityChanged", () => ({ 228 title: "Task priority changed", 229 body: `**${event.title}** changed from **${toSentenceCase(event.oldPriority)}** to **${toSentenceCase(event.newPriority)}**.`, 230 })); 231} 232 233export async function handleTaskTitleChanged( 234 event: TaskTitleChangedEvent, 235 context: PluginContext, 236): Promise<void> { 237 await runDiscordHandler(context, event, "taskTitleChanged", () => ({ 238 title: "Task title changed", 239 body: `Task renamed from **${truncate(event.oldTitle, 120)}** to **${truncate(event.newTitle, 120)}**.`, 240 })); 241} 242 243export async function handleTaskDescriptionChanged( 244 event: TaskDescriptionChangedEvent, 245 context: PluginContext, 246): Promise<void> { 247 await runDiscordHandler(context, event, "taskDescriptionChanged", () => ({ 248 title: "Task description changed", 249 body: `The task description was updated${event.newDescription ? `: ${truncate(event.newDescription.replace(/\s+/g, " "), 160)}` : "."}`, 250 })); 251} 252 253export async function handleTaskCommentCreated( 254 event: TaskCommentCreatedEvent, 255 context: PluginContext, 256): Promise<void> { 257 await runDiscordHandler(context, event, "taskCommentCreated", () => ({ 258 title: "New task comment", 259 body: truncate(event.comment.replace(/\s+/g, " "), 200), 260 })); 261}