kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
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}