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.

feat(integrations): add project telegram integration

Tin 63ff1a92 17de4b3c

+1751 -6
+6
apps/api/src/index.ts
··· 37 37 import { getPrivateObject } from "./storage/s3"; 38 38 import task from "./task"; 39 39 import taskRelation from "./task-relation"; 40 + import telegramIntegration from "./telegram-integration"; 40 41 import timeEntry from "./time-entry"; 41 42 import { getInvitationDetails } from "./utils/check-registration-allowed"; 42 43 import { migrateApiKeyReferenceId } from "./utils/migrate-apikey-reference-id"; ··· 407 408 discordIntegration, 408 409 ); 409 410 const slackIntegrationApi = api.route("/slack-integration", slackIntegration); 411 + const telegramIntegrationApi = api.route( 412 + "/telegram-integration", 413 + telegramIntegration, 414 + ); 410 415 const taskRelationApi = api.route("/task-relation", taskRelation); 411 416 const externalLinkApi = api.route("/external-link", externalLink); 412 417 const workflowRuleApi = api.route("/workflow-rule", workflowRule); ··· 464 469 | typeof genericWebhookIntegrationApi 465 470 | typeof discordIntegrationApi 466 471 | typeof slackIntegrationApi 472 + | typeof telegramIntegrationApi 467 473 | typeof taskRelationApi 468 474 | typeof externalLinkApi 469 475 | typeof workflowRuleApi
+2
apps/api/src/plugins/index.ts
··· 3 3 import { githubPlugin, initializeGitHubPlugin } from "./github"; 4 4 import { initializeEventSubscriptions, registerPlugin } from "./registry"; 5 5 import { slackPlugin } from "./slack"; 6 + import { telegramPlugin } from "./telegram"; 6 7 7 8 export function initializePlugins() { 8 9 console.log("Initializing plugins..."); ··· 11 12 registerPlugin(slackPlugin); 12 13 registerPlugin(discordPlugin); 13 14 registerPlugin(genericWebhookPlugin); 15 + registerPlugin(telegramPlugin); 14 16 initializeGitHubPlugin(); 15 17 initializeEventSubscriptions(); 16 18
+57
apps/api/src/plugins/telegram/client.ts
··· 1 + type TelegramMessage = { 2 + chat_id: string; 3 + text: string; 4 + parse_mode?: "HTML"; 5 + disable_web_page_preview?: boolean; 6 + message_thread_id?: number; 7 + }; 8 + 9 + const TELEGRAM_TIMEOUT_MS = 10_000; 10 + 11 + export async function postToTelegram( 12 + botToken: string, 13 + message: TelegramMessage, 14 + ): Promise<void> { 15 + const controller = new AbortController(); 16 + const timeoutId = setTimeout(() => controller.abort(), TELEGRAM_TIMEOUT_MS); 17 + 18 + try { 19 + const response = await fetch( 20 + `https://api.telegram.org/bot${botToken}/sendMessage`, 21 + { 22 + method: "POST", 23 + headers: { 24 + "Content-Type": "application/json", 25 + }, 26 + body: JSON.stringify(message), 27 + signal: controller.signal, 28 + }, 29 + ); 30 + 31 + if (!response.ok) { 32 + const errorText = await response.text(); 33 + throw new Error( 34 + `Telegram request failed (${response.status}): ${errorText}`, 35 + ); 36 + } 37 + 38 + const result = (await response.json()) as { 39 + ok?: boolean; 40 + description?: string; 41 + }; 42 + 43 + if (!result.ok) { 44 + throw new Error(result.description || "Telegram API request failed"); 45 + } 46 + } catch (error) { 47 + if (error instanceof Error && error.name === "AbortError") { 48 + throw new Error( 49 + `Telegram request timed out after ${TELEGRAM_TIMEOUT_MS}ms`, 50 + ); 51 + } 52 + 53 + throw error; 54 + } finally { 55 + clearTimeout(timeoutId); 56 + } 57 + }
+90
apps/api/src/plugins/telegram/config.ts
··· 1 + import * as v from "valibot"; 2 + 3 + export const telegramEventKeys = [ 4 + "taskCreated", 5 + "taskStatusChanged", 6 + "taskPriorityChanged", 7 + "taskTitleChanged", 8 + "taskDescriptionChanged", 9 + "taskCommentCreated", 10 + ] as const; 11 + 12 + export type TelegramEventKey = (typeof telegramEventKeys)[number]; 13 + 14 + const telegramBotTokenSchema = v.pipe( 15 + v.string(), 16 + v.regex(/^\d+:[A-Za-z0-9_-]{20,}$/, "Enter a valid Telegram bot token"), 17 + ); 18 + 19 + const telegramChatIdSchema = v.pipe( 20 + v.string(), 21 + v.minLength(1, "Chat ID is required"), 22 + ); 23 + 24 + export const telegramConfigSchema = v.object({ 25 + botToken: telegramBotTokenSchema, 26 + chatId: telegramChatIdSchema, 27 + threadId: v.optional(v.number()), 28 + chatLabel: v.optional(v.string()), 29 + events: v.optional( 30 + v.object({ 31 + taskCreated: v.optional(v.boolean()), 32 + taskStatusChanged: v.optional(v.boolean()), 33 + taskPriorityChanged: v.optional(v.boolean()), 34 + taskTitleChanged: v.optional(v.boolean()), 35 + taskDescriptionChanged: v.optional(v.boolean()), 36 + taskCommentCreated: v.optional(v.boolean()), 37 + }), 38 + ), 39 + }); 40 + 41 + export type TelegramConfig = v.InferOutput<typeof telegramConfigSchema>; 42 + 43 + export const defaultTelegramEvents: Record<TelegramEventKey, boolean> = { 44 + taskCreated: true, 45 + taskStatusChanged: true, 46 + taskPriorityChanged: false, 47 + taskTitleChanged: false, 48 + taskDescriptionChanged: false, 49 + taskCommentCreated: true, 50 + }; 51 + 52 + export function normalizeTelegramConfig( 53 + config: TelegramConfig, 54 + ): TelegramConfig { 55 + return { 56 + ...config, 57 + chatId: config.chatId.trim(), 58 + threadId: 59 + typeof config.threadId === "number" && Number.isFinite(config.threadId) 60 + ? config.threadId 61 + : undefined, 62 + chatLabel: config.chatLabel?.trim() || undefined, 63 + events: { 64 + ...defaultTelegramEvents, 65 + ...(config.events ?? {}), 66 + }, 67 + }; 68 + } 69 + 70 + export async function validateTelegramConfig( 71 + config: unknown, 72 + ): Promise<{ valid: boolean; errors?: string[] }> { 73 + try { 74 + const parsed = v.parse(telegramConfigSchema, config); 75 + normalizeTelegramConfig(parsed); 76 + return { valid: true }; 77 + } catch (error) { 78 + if (error instanceof v.ValiError) { 79 + return { 80 + valid: false, 81 + errors: error.issues.map((issue) => issue.message), 82 + }; 83 + } 84 + 85 + return { 86 + valid: false, 87 + errors: [error instanceof Error ? error.message : "Invalid config"], 88 + }; 89 + } 90 + }
+249
apps/api/src/plugins/telegram/events.ts
··· 1 + import { and, eq } from "drizzle-orm"; 2 + import db from "../../database"; 3 + import { 4 + projectTable, 5 + taskTable, 6 + userTable, 7 + workspaceTable, 8 + } from "../../database/schema"; 9 + import type { 10 + PluginContext, 11 + TaskCommentCreatedEvent, 12 + TaskCreatedEvent, 13 + TaskDescriptionChangedEvent, 14 + TaskPriorityChangedEvent, 15 + TaskStatusChangedEvent, 16 + TaskTitleChangedEvent, 17 + } from "../types"; 18 + import { postToTelegram } from "./client"; 19 + import type { TelegramConfig, TelegramEventKey } from "./config"; 20 + import { normalizeTelegramConfig } from "./config"; 21 + 22 + type TelegramEventData = { 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 + 32 + function isEnabled(config: TelegramConfig, key: TelegramEventKey): boolean { 33 + return config.events?.[key] ?? false; 34 + } 35 + 36 + function 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 + 43 + function 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 + 51 + function escapeHtml(value: string): string { 52 + return value 53 + .replace(/&/g, "&amp;") 54 + .replace(/</g, "&lt;") 55 + .replace(/>/g, "&gt;"); 56 + } 57 + 58 + function redactBotToken(botToken: string): string { 59 + const [prefix, suffix = ""] = botToken.split(":", 2); 60 + if (!suffix) { 61 + return "redacted"; 62 + } 63 + 64 + return `${prefix}:${ 65 + suffix.length > 8 ? `${suffix.slice(0, 4)}…${suffix.slice(-4)}` : "••••" 66 + }`; 67 + } 68 + 69 + async function getTelegramEventData( 70 + taskId: string, 71 + projectId: string, 72 + userId: string | null, 73 + ): Promise<TelegramEventData | null> { 74 + const taskPromise = db 75 + .select({ 76 + title: taskTable.title, 77 + number: taskTable.number, 78 + status: taskTable.status, 79 + priority: taskTable.priority, 80 + projectName: projectTable.name, 81 + projectId: projectTable.id, 82 + workspaceId: workspaceTable.id, 83 + }) 84 + .from(taskTable) 85 + .innerJoin(projectTable, eq(taskTable.projectId, projectTable.id)) 86 + .innerJoin(workspaceTable, eq(projectTable.workspaceId, workspaceTable.id)) 87 + .where(and(eq(taskTable.id, taskId), eq(projectTable.id, projectId))) 88 + .limit(1); 89 + 90 + const userPromise = userId 91 + ? db 92 + .select({ name: userTable.name }) 93 + .from(userTable) 94 + .where(eq(userTable.id, userId)) 95 + .limit(1) 96 + : Promise.resolve([]); 97 + 98 + const [[taskRow], [user]] = await Promise.all([taskPromise, userPromise]); 99 + 100 + if (!taskRow) { 101 + return null; 102 + } 103 + 104 + const clientUrl = process.env.KANEO_CLIENT_URL || "http://localhost:5173"; 105 + const taskUrl = `${clientUrl}/dashboard/workspace/${taskRow.workspaceId}/project/${taskRow.projectId}/task/${taskId}`; 106 + 107 + return { 108 + taskTitle: taskRow.title, 109 + taskNumber: taskRow.number, 110 + projectName: taskRow.projectName, 111 + taskUrl, 112 + actorName: user?.name ?? null, 113 + status: taskRow.status, 114 + priority: taskRow.priority, 115 + }; 116 + } 117 + 118 + async function sendTelegramMessage( 119 + config: TelegramConfig, 120 + title: string, 121 + body: string, 122 + data: TelegramEventData, 123 + ): Promise<void> { 124 + const issueKey = 125 + data.taskNumber !== null ? `#${data.taskNumber}` : "Task update"; 126 + const taskLabel = `${issueKey} ${data.taskTitle}`; 127 + const escapedTaskLabel = escapeHtml(taskLabel); 128 + const taskLine = data.taskUrl 129 + ? `<a href="${escapeHtml(data.taskUrl)}">${escapedTaskLabel}</a>` 130 + : escapedTaskLabel; 131 + 132 + const lines = [ 133 + `<b>${escapeHtml(title)}</b>`, 134 + escapeHtml(body), 135 + "", 136 + `<b>Task:</b> ${taskLine}`, 137 + `<b>Project:</b> ${escapeHtml(data.projectName)}`, 138 + `<b>Status:</b> ${escapeHtml(toSentenceCase(data.status))}`, 139 + `<b>Priority:</b> ${escapeHtml(toSentenceCase(data.priority))}`, 140 + `<b>Triggered by:</b> ${escapeHtml(data.actorName ?? "Kaneo")}`, 141 + ]; 142 + 143 + try { 144 + await postToTelegram(config.botToken, { 145 + chat_id: config.chatId, 146 + text: lines.join("\n"), 147 + parse_mode: "HTML", 148 + disable_web_page_preview: false, 149 + message_thread_id: config.threadId, 150 + }); 151 + } catch (error) { 152 + console.error("sendTelegramMessage postToTelegram failed", { 153 + error, 154 + botToken: redactBotToken(config.botToken), 155 + chatId: config.chatId, 156 + threadId: config.threadId ?? null, 157 + taskUrl: data.taskUrl, 158 + }); 159 + } 160 + } 161 + 162 + type TelegramMessageContent = { 163 + title: string; 164 + body: string; 165 + }; 166 + 167 + async function runTelegramHandler( 168 + context: PluginContext, 169 + event: { 170 + taskId: string; 171 + projectId: string; 172 + userId: string | null; 173 + }, 174 + featureKey: TelegramEventKey, 175 + buildMessage: () => TelegramMessageContent, 176 + ): Promise<void> { 177 + const config = normalizeTelegramConfig(context.config as TelegramConfig); 178 + if (!isEnabled(config, featureKey)) return; 179 + 180 + const data = await getTelegramEventData( 181 + event.taskId, 182 + event.projectId, 183 + event.userId, 184 + ); 185 + if (!data) return; 186 + 187 + const { title, body } = buildMessage(); 188 + await sendTelegramMessage(config, title, body, data); 189 + } 190 + 191 + export async function handleTaskCreated( 192 + event: TaskCreatedEvent, 193 + context: PluginContext, 194 + ): Promise<void> { 195 + await runTelegramHandler(context, event, "taskCreated", () => ({ 196 + title: "New task created", 197 + body: `A new task was added: ${event.title}`, 198 + })); 199 + } 200 + 201 + export async function handleTaskStatusChanged( 202 + event: TaskStatusChangedEvent, 203 + context: PluginContext, 204 + ): Promise<void> { 205 + await runTelegramHandler(context, event, "taskStatusChanged", () => ({ 206 + title: "Task status changed", 207 + body: `${event.title} moved from ${toSentenceCase(event.oldStatus)} to ${toSentenceCase(event.newStatus)}.`, 208 + })); 209 + } 210 + 211 + export async function handleTaskPriorityChanged( 212 + event: TaskPriorityChangedEvent, 213 + context: PluginContext, 214 + ): Promise<void> { 215 + await runTelegramHandler(context, event, "taskPriorityChanged", () => ({ 216 + title: "Task priority changed", 217 + body: `${event.title} changed from ${toSentenceCase(event.oldPriority)} to ${toSentenceCase(event.newPriority)}.`, 218 + })); 219 + } 220 + 221 + export async function handleTaskTitleChanged( 222 + event: TaskTitleChangedEvent, 223 + context: PluginContext, 224 + ): Promise<void> { 225 + await runTelegramHandler(context, event, "taskTitleChanged", () => ({ 226 + title: "Task title changed", 227 + body: `Task renamed from ${truncate(event.oldTitle, 120)} to ${truncate(event.newTitle, 120)}.`, 228 + })); 229 + } 230 + 231 + export async function handleTaskDescriptionChanged( 232 + event: TaskDescriptionChangedEvent, 233 + context: PluginContext, 234 + ): Promise<void> { 235 + await runTelegramHandler(context, event, "taskDescriptionChanged", () => ({ 236 + title: "Task description changed", 237 + body: `The task description was updated${event.newDescription ? `: ${truncate(event.newDescription.replace(/\s+/g, " "), 160)}` : "."}`, 238 + })); 239 + } 240 + 241 + export async function handleTaskCommentCreated( 242 + event: TaskCommentCreatedEvent, 243 + context: PluginContext, 244 + ): Promise<void> { 245 + await runTelegramHandler(context, event, "taskCommentCreated", () => ({ 246 + title: "New task comment", 247 + body: truncate(event.comment.replace(/\s+/g, " "), 200), 248 + })); 249 + }
+22
apps/api/src/plugins/telegram/index.ts
··· 1 + import type { IntegrationPlugin } from "../types"; 2 + import { validateTelegramConfig } from "./config"; 3 + import { 4 + handleTaskCommentCreated, 5 + handleTaskCreated, 6 + handleTaskDescriptionChanged, 7 + handleTaskPriorityChanged, 8 + handleTaskStatusChanged, 9 + handleTaskTitleChanged, 10 + } from "./events"; 11 + 12 + export const telegramPlugin: IntegrationPlugin = { 13 + type: "telegram", 14 + name: "Telegram", 15 + onTaskCreated: handleTaskCreated, 16 + onTaskStatusChanged: handleTaskStatusChanged, 17 + onTaskPriorityChanged: handleTaskPriorityChanged, 18 + onTaskTitleChanged: handleTaskTitleChanged, 19 + onTaskDescriptionChanged: handleTaskDescriptionChanged, 20 + onTaskCommentCreated: handleTaskCommentCreated, 21 + validateConfig: validateTelegramConfig, 22 + };
+14
apps/api/src/schemas.ts
··· 157 157 updatedAt: v.date(), 158 158 }); 159 159 160 + export const telegramIntegrationSchema = v.object({ 161 + id: v.string(), 162 + projectId: v.string(), 163 + chatId: v.string(), 164 + threadId: v.nullable(v.number()), 165 + chatLabel: v.nullable(v.string()), 166 + botTokenConfigured: v.boolean(), 167 + maskedBotToken: v.string(), 168 + events: integrationEventsSchema, 169 + isActive: v.nullable(v.boolean()), 170 + createdAt: v.date(), 171 + updatedAt: v.date(), 172 + }); 173 + 160 174 export const commentSchema = v.object({ 161 175 id: v.string(), 162 176 taskId: v.string(),
+315
apps/api/src/telegram-integration/index.ts
··· 1 + import { and, eq } from "drizzle-orm"; 2 + import { Hono } from "hono"; 3 + import { HTTPException } from "hono/http-exception"; 4 + import { describeRoute, resolver, validator } from "hono-openapi"; 5 + import * as v from "valibot"; 6 + import db from "../database"; 7 + import { integrationTable } from "../database/schema"; 8 + import { 9 + defaultTelegramEvents, 10 + normalizeTelegramConfig, 11 + type TelegramConfig, 12 + validateTelegramConfig, 13 + } from "../plugins/telegram/config"; 14 + import { telegramIntegrationSchema } from "../schemas"; 15 + import { workspaceAccess } from "../utils/workspace-access-middleware"; 16 + 17 + const telegramIntegration = new Hono<{ 18 + Variables: { 19 + userId: string; 20 + workspaceId: string; 21 + apiKey?: { 22 + id: string; 23 + userId: string; 24 + enabled: boolean; 25 + }; 26 + }; 27 + }>(); 28 + 29 + function maskBotToken(value: string): string { 30 + const [prefix, suffix = ""] = value.split(":", 2); 31 + if (!suffix) { 32 + return "Configured"; 33 + } 34 + 35 + const maskedSuffix = 36 + suffix.length > 8 ? `${suffix.slice(0, 4)}…${suffix.slice(-4)}` : "••••"; 37 + return `${prefix}:${maskedSuffix}`; 38 + } 39 + 40 + function toResponse(integration: { 41 + id: string; 42 + projectId: string; 43 + config: string; 44 + isActive: boolean | null; 45 + createdAt: Date; 46 + updatedAt: Date; 47 + }) { 48 + const config = normalizeTelegramConfig( 49 + JSON.parse(integration.config) as TelegramConfig, 50 + ); 51 + 52 + return { 53 + id: integration.id, 54 + projectId: integration.projectId, 55 + chatId: config.chatId, 56 + threadId: config.threadId ?? null, 57 + chatLabel: config.chatLabel ?? null, 58 + botTokenConfigured: Boolean(config.botToken), 59 + maskedBotToken: config.botToken ? maskBotToken(config.botToken) : "", 60 + events: { 61 + ...defaultTelegramEvents, 62 + ...(config.events ?? {}), 63 + }, 64 + isActive: integration.isActive, 65 + createdAt: integration.createdAt, 66 + updatedAt: integration.updatedAt, 67 + }; 68 + } 69 + 70 + async function getTelegramIntegration(projectId: string) { 71 + const integration = await db.query.integrationTable.findFirst({ 72 + where: and( 73 + eq(integrationTable.projectId, projectId), 74 + eq(integrationTable.type, "telegram"), 75 + ), 76 + }); 77 + 78 + if (!integration) { 79 + return null; 80 + } 81 + 82 + return toResponse(integration); 83 + } 84 + 85 + const telegramEventsSchema = v.object({ 86 + taskCreated: v.optional(v.boolean()), 87 + taskStatusChanged: v.optional(v.boolean()), 88 + taskPriorityChanged: v.optional(v.boolean()), 89 + taskTitleChanged: v.optional(v.boolean()), 90 + taskDescriptionChanged: v.optional(v.boolean()), 91 + taskCommentCreated: v.optional(v.boolean()), 92 + }); 93 + 94 + telegramIntegration 95 + .get( 96 + "/project/:projectId", 97 + describeRoute({ 98 + operationId: "getTelegramIntegration", 99 + tags: ["Telegram"], 100 + description: "Get Telegram integration for a project", 101 + responses: { 102 + 200: { 103 + description: "Telegram integration details", 104 + content: { 105 + "application/json": { schema: resolver(telegramIntegrationSchema) }, 106 + }, 107 + }, 108 + }, 109 + }), 110 + validator("param", v.object({ projectId: v.string() })), 111 + workspaceAccess.fromProject("projectId"), 112 + async (c) => { 113 + const { projectId } = c.req.valid("param"); 114 + const integration = await getTelegramIntegration(projectId); 115 + return c.json(integration); 116 + }, 117 + ) 118 + .post( 119 + "/project/:projectId", 120 + describeRoute({ 121 + operationId: "createTelegramIntegration", 122 + tags: ["Telegram"], 123 + description: "Create or replace a Telegram integration for a project", 124 + responses: { 125 + 200: { 126 + description: "Telegram integration created successfully", 127 + content: { 128 + "application/json": { schema: resolver(telegramIntegrationSchema) }, 129 + }, 130 + }, 131 + }, 132 + }), 133 + validator("param", v.object({ projectId: v.string() })), 134 + validator( 135 + "json", 136 + v.object({ 137 + botToken: v.pipe(v.string(), v.minLength(1)), 138 + chatId: v.pipe(v.string(), v.minLength(1)), 139 + threadId: v.optional(v.number()), 140 + chatLabel: v.optional(v.string()), 141 + events: v.optional(telegramEventsSchema), 142 + }), 143 + ), 144 + workspaceAccess.fromProject("projectId"), 145 + async (c) => { 146 + const { projectId } = c.req.valid("param"); 147 + const body = c.req.valid("json"); 148 + 149 + const config = normalizeTelegramConfig({ 150 + botToken: body.botToken, 151 + chatId: body.chatId, 152 + threadId: body.threadId, 153 + chatLabel: body.chatLabel, 154 + events: body.events, 155 + }); 156 + 157 + const validation = await validateTelegramConfig(config); 158 + if (!validation.valid) { 159 + throw new HTTPException(400, { 160 + message: validation.errors?.join(", ") ?? "Invalid config", 161 + }); 162 + } 163 + 164 + await db 165 + .insert(integrationTable) 166 + .values({ 167 + projectId, 168 + type: "telegram", 169 + config: JSON.stringify(config), 170 + isActive: true, 171 + }) 172 + .onConflictDoUpdate({ 173 + target: [integrationTable.projectId, integrationTable.type], 174 + set: { 175 + config: JSON.stringify(config), 176 + isActive: true, 177 + updatedAt: new Date(), 178 + }, 179 + }); 180 + 181 + const integration = await getTelegramIntegration(projectId); 182 + return c.json(integration); 183 + }, 184 + ) 185 + .patch( 186 + "/project/:projectId", 187 + describeRoute({ 188 + operationId: "updateTelegramIntegration", 189 + tags: ["Telegram"], 190 + description: "Update Telegram integration settings", 191 + responses: { 192 + 200: { 193 + description: "Telegram integration updated successfully", 194 + content: { 195 + "application/json": { schema: resolver(telegramIntegrationSchema) }, 196 + }, 197 + }, 198 + }, 199 + }), 200 + validator("param", v.object({ projectId: v.string() })), 201 + validator( 202 + "json", 203 + v.object({ 204 + botToken: v.optional(v.string()), 205 + chatId: v.optional(v.string()), 206 + threadId: v.optional(v.nullable(v.number())), 207 + chatLabel: v.optional(v.nullable(v.string())), 208 + isActive: v.optional(v.boolean()), 209 + events: v.optional(telegramEventsSchema), 210 + }), 211 + ), 212 + workspaceAccess.fromProject("projectId"), 213 + async (c) => { 214 + const { projectId } = c.req.valid("param"); 215 + const body = c.req.valid("json"); 216 + 217 + const existing = await db.query.integrationTable.findFirst({ 218 + where: and( 219 + eq(integrationTable.projectId, projectId), 220 + eq(integrationTable.type, "telegram"), 221 + ), 222 + }); 223 + 224 + if (!existing) { 225 + throw new HTTPException(404, { 226 + message: "Telegram integration not found", 227 + }); 228 + } 229 + 230 + const currentConfig = normalizeTelegramConfig( 231 + JSON.parse(existing.config) as TelegramConfig, 232 + ); 233 + const nextConfig = normalizeTelegramConfig({ 234 + botToken: body.botToken?.trim() || currentConfig.botToken, 235 + chatId: body.chatId?.trim() || currentConfig.chatId, 236 + threadId: 237 + body.threadId === undefined 238 + ? currentConfig.threadId 239 + : (body.threadId ?? undefined), 240 + chatLabel: 241 + body.chatLabel === undefined 242 + ? currentConfig.chatLabel 243 + : (body.chatLabel ?? undefined), 244 + events: { 245 + ...(currentConfig.events ?? {}), 246 + ...(body.events ?? {}), 247 + }, 248 + }); 249 + 250 + const validation = await validateTelegramConfig(nextConfig); 251 + if (!validation.valid) { 252 + throw new HTTPException(400, { 253 + message: validation.errors?.join(", ") ?? "Invalid config", 254 + }); 255 + } 256 + 257 + await db 258 + .update(integrationTable) 259 + .set({ 260 + config: JSON.stringify(nextConfig), 261 + isActive: 262 + body.isActive !== undefined 263 + ? body.isActive 264 + : (existing.isActive ?? true), 265 + updatedAt: new Date(), 266 + }) 267 + .where(eq(integrationTable.id, existing.id)); 268 + 269 + const integration = await getTelegramIntegration(projectId); 270 + return c.json(integration); 271 + }, 272 + ) 273 + .delete( 274 + "/project/:projectId", 275 + describeRoute({ 276 + operationId: "deleteTelegramIntegration", 277 + tags: ["Telegram"], 278 + description: "Delete Telegram integration for a project", 279 + responses: { 280 + 200: { 281 + description: "Telegram integration deleted successfully", 282 + content: { 283 + "application/json": { 284 + schema: resolver(v.object({ success: v.boolean() })), 285 + }, 286 + }, 287 + }, 288 + }, 289 + }), 290 + validator("param", v.object({ projectId: v.string() })), 291 + workspaceAccess.fromProject("projectId"), 292 + async (c) => { 293 + const { projectId } = c.req.valid("param"); 294 + 295 + const existing = await db.query.integrationTable.findFirst({ 296 + where: and( 297 + eq(integrationTable.projectId, projectId), 298 + eq(integrationTable.type, "telegram"), 299 + ), 300 + }); 301 + 302 + if (!existing) { 303 + throw new HTTPException(404, { 304 + message: "Telegram integration not found", 305 + }); 306 + } 307 + 308 + await db 309 + .delete(integrationTable) 310 + .where(eq(integrationTable.id, existing.id)); 311 + return c.json({ success: true }); 312 + }, 313 + ); 314 + 315 + export default telegramIntegration;
+523
apps/web/src/components/project/telegram-integration-settings.tsx
··· 1 + import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; 2 + import { CheckCircle, Trash2 } from "lucide-react"; 3 + import React from "react"; 4 + import { useForm } from "react-hook-form"; 5 + import { useTranslation } from "react-i18next"; 6 + import { z } from "zod/v4"; 7 + import { Button } from "@/components/ui/button"; 8 + import { 9 + Form, 10 + FormControl, 11 + FormField, 12 + FormItem, 13 + FormLabel, 14 + FormMessage, 15 + } from "@/components/ui/form"; 16 + import { Input } from "@/components/ui/input"; 17 + import { Switch } from "@/components/ui/switch"; 18 + import { 19 + useCreateTelegramIntegration, 20 + useDeleteTelegramIntegration, 21 + useUpdateTelegramIntegration, 22 + } from "@/hooks/mutations/telegram-integration/use-telegram-integration"; 23 + import useGetTelegramIntegration from "@/hooks/queries/telegram-integration/use-get-telegram-integration"; 24 + import { toast } from "@/lib/toast"; 25 + 26 + type TelegramIntegrationFormValues = { 27 + botToken: string; 28 + chatId: string; 29 + threadId: string; 30 + chatLabel: string; 31 + taskCreated: boolean; 32 + taskStatusChanged: boolean; 33 + taskPriorityChanged: boolean; 34 + taskTitleChanged: boolean; 35 + taskDescriptionChanged: boolean; 36 + taskCommentCreated: boolean; 37 + }; 38 + 39 + function EventToggle({ 40 + control, 41 + name, 42 + label, 43 + }: { 44 + control: ReturnType<typeof useForm<TelegramIntegrationFormValues>>["control"]; 45 + name: keyof Pick< 46 + TelegramIntegrationFormValues, 47 + | "taskCreated" 48 + | "taskStatusChanged" 49 + | "taskPriorityChanged" 50 + | "taskTitleChanged" 51 + | "taskDescriptionChanged" 52 + | "taskCommentCreated" 53 + >; 54 + label: string; 55 + }) { 56 + return ( 57 + <FormField 58 + control={control} 59 + name={name} 60 + render={({ field }) => ( 61 + <FormItem className="flex items-center justify-between rounded-md border px-3 py-2"> 62 + <FormLabel className="text-sm font-medium">{label}</FormLabel> 63 + <FormControl> 64 + <Switch 65 + checked={Boolean(field.value)} 66 + onCheckedChange={field.onChange} 67 + /> 68 + </FormControl> 69 + </FormItem> 70 + )} 71 + /> 72 + ); 73 + } 74 + 75 + function isValidTelegramBotToken(value: string): boolean { 76 + return /^\d+:[A-Za-z0-9_-]{20,}$/.test(value); 77 + } 78 + 79 + function isValidTelegramThreadId(value: string): boolean { 80 + return /^\d+$/.test(value) && Number(value) > 0; 81 + } 82 + 83 + export function TelegramIntegrationSettings({ 84 + projectId, 85 + }: { 86 + projectId: string; 87 + }) { 88 + const { t } = useTranslation(); 89 + const schema = React.useMemo( 90 + () => 91 + z.object({ 92 + botToken: z.string(), 93 + chatId: z.string(), 94 + threadId: z.string(), 95 + chatLabel: z.string(), 96 + taskCreated: z.boolean(), 97 + taskStatusChanged: z.boolean(), 98 + taskPriorityChanged: z.boolean(), 99 + taskTitleChanged: z.boolean(), 100 + taskDescriptionChanged: z.boolean(), 101 + taskCommentCreated: z.boolean(), 102 + }), 103 + [], 104 + ); 105 + 106 + const { data: integration, isLoading } = useGetTelegramIntegration(projectId); 107 + const { mutateAsync: createIntegration, isPending: isCreating } = 108 + useCreateTelegramIntegration(); 109 + const { mutateAsync: updateIntegration, isPending: isUpdating } = 110 + useUpdateTelegramIntegration(); 111 + const { mutateAsync: deleteIntegration, isPending: isDeleting } = 112 + useDeleteTelegramIntegration(); 113 + const normalizedValues = React.useMemo<TelegramIntegrationFormValues>( 114 + () => ({ 115 + botToken: "", 116 + chatId: integration?.chatId ?? "", 117 + threadId: integration?.threadId ? String(integration.threadId) : "", 118 + chatLabel: integration?.chatLabel ?? "", 119 + taskCreated: integration?.events?.taskCreated ?? true, 120 + taskStatusChanged: integration?.events?.taskStatusChanged ?? true, 121 + taskPriorityChanged: integration?.events?.taskPriorityChanged ?? false, 122 + taskTitleChanged: integration?.events?.taskTitleChanged ?? false, 123 + taskDescriptionChanged: 124 + integration?.events?.taskDescriptionChanged ?? false, 125 + taskCommentCreated: integration?.events?.taskCommentCreated ?? true, 126 + }), 127 + [integration], 128 + ); 129 + 130 + const form = useForm<TelegramIntegrationFormValues>({ 131 + resolver: standardSchemaResolver(schema), 132 + defaultValues: { 133 + botToken: "", 134 + chatId: "", 135 + threadId: "", 136 + chatLabel: "", 137 + taskCreated: true, 138 + taskStatusChanged: true, 139 + taskPriorityChanged: false, 140 + taskTitleChanged: false, 141 + taskDescriptionChanged: false, 142 + taskCommentCreated: true, 143 + }, 144 + }); 145 + const { reset } = form; 146 + const lastResetKeyRef = React.useRef<string | null>(null); 147 + const resetKey = `${projectId}:${integration?.id ?? "none"}`; 148 + 149 + React.useEffect(() => { 150 + if (form.formState.isDirty && lastResetKeyRef.current === resetKey) { 151 + return; 152 + } 153 + 154 + reset(normalizedValues); 155 + lastResetKeyRef.current = resetKey; 156 + }, [form.formState.isDirty, normalizedValues, reset, resetKey]); 157 + 158 + const isConnected = Boolean(integration?.botTokenConfigured); 159 + const isBusy = isCreating || isUpdating || isDeleting; 160 + 161 + const onSubmit = async (values: TelegramIntegrationFormValues) => { 162 + try { 163 + const trimmedBotToken = values.botToken.trim(); 164 + const trimmedChatId = values.chatId.trim(); 165 + const trimmedThreadId = values.threadId.trim(); 166 + const parsedThreadId = trimmedThreadId 167 + ? Number(trimmedThreadId) 168 + : undefined; 169 + const events = { 170 + taskCreated: values.taskCreated, 171 + taskStatusChanged: values.taskStatusChanged, 172 + taskPriorityChanged: values.taskPriorityChanged, 173 + taskTitleChanged: values.taskTitleChanged, 174 + taskDescriptionChanged: values.taskDescriptionChanged, 175 + taskCommentCreated: values.taskCommentCreated, 176 + }; 177 + 178 + if (!trimmedChatId) { 179 + form.setError("chatId", { 180 + message: t("settings:telegramIntegration.validation.chatIdRequired"), 181 + }); 182 + return; 183 + } 184 + 185 + if (trimmedThreadId && !isValidTelegramThreadId(trimmedThreadId)) { 186 + form.setError("threadId", { 187 + message: t("settings:telegramIntegration.validation.threadIdInvalid"), 188 + }); 189 + return; 190 + } 191 + 192 + if (!isConnected) { 193 + if (!trimmedBotToken || !isValidTelegramBotToken(trimmedBotToken)) { 194 + form.setError("botToken", { 195 + message: t( 196 + "settings:telegramIntegration.validation.botTokenInvalid", 197 + ), 198 + }); 199 + return; 200 + } 201 + 202 + await createIntegration({ 203 + projectId, 204 + data: { 205 + botToken: trimmedBotToken, 206 + chatId: trimmedChatId, 207 + threadId: parsedThreadId, 208 + chatLabel: values.chatLabel || undefined, 209 + events, 210 + }, 211 + }); 212 + } else { 213 + if (trimmedBotToken && !isValidTelegramBotToken(trimmedBotToken)) { 214 + form.setError("botToken", { 215 + message: t( 216 + "settings:telegramIntegration.validation.botTokenInvalid", 217 + ), 218 + }); 219 + return; 220 + } 221 + 222 + await updateIntegration({ 223 + projectId, 224 + json: { 225 + botToken: trimmedBotToken || undefined, 226 + chatId: trimmedChatId, 227 + threadId: parsedThreadId ?? null, 228 + chatLabel: values.chatLabel || null, 229 + events, 230 + }, 231 + }); 232 + } 233 + 234 + form.reset({ 235 + ...values, 236 + botToken: "", 237 + chatId: trimmedChatId, 238 + threadId: trimmedThreadId, 239 + }); 240 + toast.success(t("settings:telegramIntegration.toast.saved")); 241 + } catch (error) { 242 + toast.error( 243 + error instanceof Error 244 + ? error.message 245 + : t("settings:telegramIntegration.toast.saveError"), 246 + ); 247 + } 248 + }; 249 + 250 + const handleToggleActive = async (checked: boolean) => { 251 + try { 252 + await updateIntegration({ 253 + projectId, 254 + json: { isActive: checked }, 255 + }); 256 + toast.success( 257 + checked 258 + ? t("settings:telegramIntegration.toast.enabled") 259 + : t("settings:telegramIntegration.toast.disabled"), 260 + ); 261 + } catch (error) { 262 + toast.error( 263 + error instanceof Error 264 + ? error.message 265 + : t("settings:telegramIntegration.toast.updateError"), 266 + ); 267 + } 268 + }; 269 + 270 + const handleDelete = async () => { 271 + try { 272 + await deleteIntegration(projectId); 273 + form.reset({ 274 + botToken: "", 275 + chatId: "", 276 + threadId: "", 277 + chatLabel: "", 278 + taskCreated: true, 279 + taskStatusChanged: true, 280 + taskPriorityChanged: false, 281 + taskTitleChanged: false, 282 + taskDescriptionChanged: false, 283 + taskCommentCreated: true, 284 + }); 285 + toast.success(t("settings:telegramIntegration.toast.removed")); 286 + } catch (error) { 287 + toast.error( 288 + error instanceof Error 289 + ? error.message 290 + : t("settings:telegramIntegration.toast.removeError"), 291 + ); 292 + } 293 + }; 294 + 295 + if (isLoading) { 296 + return ( 297 + <div className="space-y-4"> 298 + <div className="space-y-4 rounded-md border border-border bg-sidebar p-4"> 299 + <div className="h-4 w-40 animate-pulse rounded bg-muted" /> 300 + <div className="h-10 w-full animate-pulse rounded bg-muted" /> 301 + <div className="h-10 w-full animate-pulse rounded bg-muted" /> 302 + </div> 303 + <div className="space-y-4 rounded-md border border-border bg-sidebar p-4"> 304 + <div className="h-4 w-40 animate-pulse rounded bg-muted" /> 305 + <div className="h-10 w-full animate-pulse rounded bg-muted" /> 306 + <div className="h-10 w-full animate-pulse rounded bg-muted" /> 307 + </div> 308 + </div> 309 + ); 310 + } 311 + 312 + return ( 313 + <div className="space-y-4"> 314 + <Form {...form}> 315 + <form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}> 316 + <div className="space-y-4 rounded-md border border-border bg-sidebar p-4"> 317 + <div className="flex items-start justify-between gap-4"> 318 + <div className="space-y-1"> 319 + <div className="flex items-center gap-2"> 320 + <h3 className="font-medium"> 321 + {t("settings:telegramIntegration.connectionTitle")} 322 + </h3> 323 + </div> 324 + <p className="text-sm text-muted-foreground"> 325 + {t("settings:telegramIntegration.connectionHint")} 326 + </p> 327 + </div> 328 + 329 + <div className="flex items-center gap-3"> 330 + {isConnected && ( 331 + <div className="flex items-center gap-2 text-sm text-muted-foreground"> 332 + <CheckCircle className="size-4 text-green-600" /> 333 + <span> 334 + {integration?.isActive 335 + ? t("settings:telegramIntegration.connected") 336 + : t("settings:telegramIntegration.paused")} 337 + </span> 338 + </div> 339 + )} 340 + <Switch 341 + checked={integration?.isActive ?? false} 342 + disabled={!isConnected || isBusy} 343 + onCheckedChange={handleToggleActive} 344 + /> 345 + </div> 346 + </div> 347 + 348 + <FormField 349 + control={form.control} 350 + name="botToken" 351 + render={({ field }) => ( 352 + <FormItem> 353 + <FormLabel> 354 + {t("settings:telegramIntegration.botTokenLabel")} 355 + </FormLabel> 356 + <FormControl> 357 + <Input 358 + {...field} 359 + autoComplete="off" 360 + placeholder={t( 361 + "settings:telegramIntegration.botTokenPlaceholder", 362 + )} 363 + type="password" 364 + /> 365 + </FormControl> 366 + <p className="text-xs text-muted-foreground"> 367 + {integration?.botTokenConfigured 368 + ? t( 369 + "settings:telegramIntegration.botTokenHintConfigured", 370 + { token: integration.maskedBotToken }, 371 + ) 372 + : t("settings:telegramIntegration.botTokenHint")} 373 + </p> 374 + <FormMessage /> 375 + </FormItem> 376 + )} 377 + /> 378 + 379 + <FormField 380 + control={form.control} 381 + name="chatId" 382 + render={({ field }) => ( 383 + <FormItem> 384 + <FormLabel> 385 + {t("settings:telegramIntegration.chatIdLabel")} 386 + </FormLabel> 387 + <FormControl> 388 + <Input 389 + {...field} 390 + placeholder={t( 391 + "settings:telegramIntegration.chatIdPlaceholder", 392 + )} 393 + /> 394 + </FormControl> 395 + <p className="text-xs text-muted-foreground"> 396 + {t("settings:telegramIntegration.chatIdHint")} 397 + </p> 398 + <FormMessage /> 399 + </FormItem> 400 + )} 401 + /> 402 + 403 + <FormField 404 + control={form.control} 405 + name="threadId" 406 + render={({ field }) => ( 407 + <FormItem> 408 + <FormLabel> 409 + {t("settings:telegramIntegration.threadIdLabel")} 410 + </FormLabel> 411 + <FormControl> 412 + <Input 413 + {...field} 414 + inputMode="numeric" 415 + placeholder={t( 416 + "settings:telegramIntegration.threadIdPlaceholder", 417 + )} 418 + /> 419 + </FormControl> 420 + <p className="text-xs text-muted-foreground"> 421 + {t("settings:telegramIntegration.threadIdHint")} 422 + </p> 423 + <FormMessage /> 424 + </FormItem> 425 + )} 426 + /> 427 + 428 + <FormField 429 + control={form.control} 430 + name="chatLabel" 431 + render={({ field }) => ( 432 + <FormItem> 433 + <FormLabel> 434 + {t("settings:telegramIntegration.chatLabelLabel")} 435 + </FormLabel> 436 + <FormControl> 437 + <Input 438 + {...field} 439 + placeholder={t( 440 + "settings:telegramIntegration.chatLabelPlaceholder", 441 + )} 442 + /> 443 + </FormControl> 444 + <p className="text-xs text-muted-foreground"> 445 + {t("settings:telegramIntegration.chatLabelHint")} 446 + </p> 447 + <FormMessage /> 448 + </FormItem> 449 + )} 450 + /> 451 + </div> 452 + 453 + <div className="space-y-3 rounded-md border border-border bg-sidebar p-4"> 454 + <div> 455 + <h3 className="font-medium"> 456 + {t("settings:telegramIntegration.eventsTitle")} 457 + </h3> 458 + <p className="text-sm text-muted-foreground"> 459 + {t("settings:telegramIntegration.eventsHint")} 460 + </p> 461 + </div> 462 + 463 + <EventToggle 464 + control={form.control} 465 + label={t("settings:telegramIntegration.events.taskCreated")} 466 + name="taskCreated" 467 + /> 468 + <EventToggle 469 + control={form.control} 470 + label={t("settings:telegramIntegration.events.taskStatusChanged")} 471 + name="taskStatusChanged" 472 + /> 473 + <EventToggle 474 + control={form.control} 475 + label={t( 476 + "settings:telegramIntegration.events.taskPriorityChanged", 477 + )} 478 + name="taskPriorityChanged" 479 + /> 480 + <EventToggle 481 + control={form.control} 482 + label={t("settings:telegramIntegration.events.taskTitleChanged")} 483 + name="taskTitleChanged" 484 + /> 485 + <EventToggle 486 + control={form.control} 487 + label={t( 488 + "settings:telegramIntegration.events.taskDescriptionChanged", 489 + )} 490 + name="taskDescriptionChanged" 491 + /> 492 + <EventToggle 493 + control={form.control} 494 + label={t( 495 + "settings:telegramIntegration.events.taskCommentCreated", 496 + )} 497 + name="taskCommentCreated" 498 + /> 499 + </div> 500 + 501 + <div className="flex flex-wrap gap-2"> 502 + <Button disabled={isBusy} type="submit"> 503 + {isConnected 504 + ? t("settings:telegramIntegration.saveChanges") 505 + : t("settings:telegramIntegration.connect")} 506 + </Button> 507 + {isConnected && ( 508 + <Button 509 + disabled={isBusy} 510 + onClick={handleDelete} 511 + type="button" 512 + variant="outline" 513 + > 514 + <Trash2 className="size-4" /> 515 + {t("settings:telegramIntegration.disconnect")} 516 + </Button> 517 + )} 518 + </div> 519 + </form> 520 + </Form> 521 + </div> 522 + ); 523 + }
+43
apps/web/src/fetchers/telegram-integration/create-telegram-integration.ts
··· 1 + import { getApiUrl } from "@/fetchers/get-api-url"; 2 + import type { TelegramIntegration } from "./get-telegram-integration"; 3 + 4 + export type CreateTelegramIntegrationRequest = { 5 + botToken: string; 6 + chatId: string; 7 + threadId?: number; 8 + chatLabel?: string; 9 + events?: { 10 + taskCreated?: boolean; 11 + taskStatusChanged?: boolean; 12 + taskPriorityChanged?: boolean; 13 + taskTitleChanged?: boolean; 14 + taskDescriptionChanged?: boolean; 15 + taskCommentCreated?: boolean; 16 + }; 17 + }; 18 + 19 + async function createTelegramIntegration( 20 + projectId: string, 21 + json: CreateTelegramIntegrationRequest, 22 + ) { 23 + const response = await fetch( 24 + getApiUrl(`/telegram-integration/project/${projectId}`), 25 + { 26 + method: "POST", 27 + credentials: "include", 28 + headers: { 29 + "Content-Type": "application/json", 30 + }, 31 + body: JSON.stringify(json), 32 + }, 33 + ); 34 + 35 + if (!response.ok) { 36 + const error = await response.text(); 37 + throw new Error(error); 38 + } 39 + 40 + return (await response.json()) as TelegramIntegration; 41 + } 42 + 43 + export default createTelegramIntegration;
+20
apps/web/src/fetchers/telegram-integration/delete-telegram-integration.ts
··· 1 + import { getApiUrl } from "@/fetchers/get-api-url"; 2 + 3 + async function deleteTelegramIntegration(projectId: string) { 4 + const response = await fetch( 5 + getApiUrl(`/telegram-integration/project/${projectId}`), 6 + { 7 + method: "DELETE", 8 + credentials: "include", 9 + }, 10 + ); 11 + 12 + if (!response.ok) { 13 + const error = await response.text(); 14 + throw new Error(error); 15 + } 16 + 17 + return (await response.json()) as { success: boolean }; 18 + } 19 + 20 + export default deleteTelegramIntegration;
+40
apps/web/src/fetchers/telegram-integration/get-telegram-integration.ts
··· 1 + import { getApiUrl } from "@/fetchers/get-api-url"; 2 + 3 + export type TelegramIntegration = { 4 + id: string; 5 + projectId: string; 6 + chatId: string; 7 + threadId: number | null; 8 + chatLabel: string | null; 9 + botTokenConfigured: boolean; 10 + maskedBotToken: string; 11 + events: { 12 + taskCreated: boolean; 13 + taskStatusChanged: boolean; 14 + taskPriorityChanged: boolean; 15 + taskTitleChanged: boolean; 16 + taskDescriptionChanged: boolean; 17 + taskCommentCreated: boolean; 18 + }; 19 + isActive: boolean | null; 20 + createdAt: string; 21 + updatedAt: string; 22 + } | null; 23 + 24 + async function getTelegramIntegration(projectId: string) { 25 + const response = await fetch( 26 + getApiUrl(`/telegram-integration/project/${projectId}`), 27 + { 28 + credentials: "include", 29 + }, 30 + ); 31 + 32 + if (!response.ok) { 33 + const error = await response.text(); 34 + throw new Error(error); 35 + } 36 + 37 + return (await response.json()) as TelegramIntegration; 38 + } 39 + 40 + export default getTelegramIntegration;
+44
apps/web/src/fetchers/telegram-integration/update-telegram-integration.ts
··· 1 + import { getApiUrl } from "@/fetchers/get-api-url"; 2 + import type { TelegramIntegration } from "./get-telegram-integration"; 3 + 4 + export type UpdateTelegramIntegrationRequest = { 5 + botToken?: string; 6 + chatId?: string; 7 + threadId?: number | null; 8 + chatLabel?: string | null; 9 + isActive?: boolean; 10 + events?: { 11 + taskCreated?: boolean; 12 + taskStatusChanged?: boolean; 13 + taskPriorityChanged?: boolean; 14 + taskTitleChanged?: boolean; 15 + taskDescriptionChanged?: boolean; 16 + taskCommentCreated?: boolean; 17 + }; 18 + }; 19 + 20 + async function updateTelegramIntegration( 21 + projectId: string, 22 + json: UpdateTelegramIntegrationRequest, 23 + ) { 24 + const response = await fetch( 25 + getApiUrl(`/telegram-integration/project/${projectId}`), 26 + { 27 + method: "PATCH", 28 + credentials: "include", 29 + headers: { 30 + "Content-Type": "application/json", 31 + }, 32 + body: JSON.stringify(json), 33 + }, 34 + ); 35 + 36 + if (!response.ok) { 37 + const error = await response.text(); 38 + throw new Error(error); 39 + } 40 + 41 + return (await response.json()) as TelegramIntegration; 42 + } 43 + 44 + export default updateTelegramIntegration;
+59
apps/web/src/hooks/mutations/telegram-integration/use-telegram-integration.ts
··· 1 + import { useMutation, useQueryClient } from "@tanstack/react-query"; 2 + import createTelegramIntegration, { 3 + type CreateTelegramIntegrationRequest, 4 + } from "@/fetchers/telegram-integration/create-telegram-integration"; 5 + import deleteTelegramIntegration from "@/fetchers/telegram-integration/delete-telegram-integration"; 6 + import updateTelegramIntegration, { 7 + type UpdateTelegramIntegrationRequest, 8 + } from "@/fetchers/telegram-integration/update-telegram-integration"; 9 + 10 + export function useCreateTelegramIntegration() { 11 + const queryClient = useQueryClient(); 12 + 13 + return useMutation({ 14 + mutationFn: ({ 15 + projectId, 16 + data, 17 + }: { 18 + projectId: string; 19 + data: CreateTelegramIntegrationRequest; 20 + }) => createTelegramIntegration(projectId, data), 21 + onSuccess: (_, { projectId }) => { 22 + void queryClient.invalidateQueries({ 23 + queryKey: ["telegram-integration", projectId], 24 + }); 25 + }, 26 + }); 27 + } 28 + 29 + export function useUpdateTelegramIntegration() { 30 + const queryClient = useQueryClient(); 31 + 32 + return useMutation({ 33 + mutationFn: ({ 34 + projectId, 35 + json, 36 + }: { 37 + projectId: string; 38 + json: UpdateTelegramIntegrationRequest; 39 + }) => updateTelegramIntegration(projectId, json), 40 + onSuccess: (_, { projectId }) => { 41 + void queryClient.invalidateQueries({ 42 + queryKey: ["telegram-integration", projectId], 43 + }); 44 + }, 45 + }); 46 + } 47 + 48 + export function useDeleteTelegramIntegration() { 49 + const queryClient = useQueryClient(); 50 + 51 + return useMutation({ 52 + mutationFn: (projectId: string) => deleteTelegramIntegration(projectId), 53 + onSuccess: (_, projectId) => { 54 + void queryClient.invalidateQueries({ 55 + queryKey: ["telegram-integration", projectId], 56 + }); 57 + }, 58 + }); 59 + }
+12
apps/web/src/hooks/queries/telegram-integration/use-get-telegram-integration.ts
··· 1 + import { useQuery } from "@tanstack/react-query"; 2 + import getTelegramIntegration from "@/fetchers/telegram-integration/get-telegram-integration"; 3 + 4 + function useGetTelegramIntegration(projectId: string) { 5 + return useQuery({ 6 + queryKey: ["telegram-integration", projectId], 7 + queryFn: () => getTelegramIntegration(projectId), 8 + enabled: Boolean(projectId), 9 + }); 10 + } 11 + 12 + export default useGetTelegramIntegration;
+10 -1
apps/web/src/routes/_layout/_authenticated/dashboard/settings/projects/$projectId/integrations.tsx
··· 4 4 Github, 5 5 MessageCircle, 6 6 Radio, 7 + Send, 7 8 Webhook, 8 9 } from "lucide-react"; 9 10 import type { ReactNode } from "react"; ··· 13 14 import { GenericWebhookIntegrationSettings } from "@/components/project/generic-webhook-integration-settings"; 14 15 import { GitHubIntegrationSettings } from "@/components/project/github-integration-settings"; 15 16 import { SlackIntegrationSettings } from "@/components/project/slack-integration-settings"; 17 + import { TelegramIntegrationSettings } from "@/components/project/telegram-integration-settings"; 16 18 import { 17 19 Collapsible, 18 20 CollapsibleContent, ··· 44 46 45 47 <div className="space-y-6"> 46 48 <IntegrationSection 47 - defaultOpen 48 49 icon={<Github className="size-4" />} 49 50 subtitle={t("settings:projectIntegrations.githubSectionSubtitle")} 50 51 title={t("settings:projectIntegrations.githubSectionTitle")} ··· 76 77 title={t("settings:projectIntegrations.slackSectionTitle")} 77 78 > 78 79 <SlackIntegrationSettings projectId={projectId} /> 80 + </IntegrationSection> 81 + 82 + <IntegrationSection 83 + icon={<Send className="size-4" />} 84 + subtitle={t("settings:projectIntegrations.telegramSectionSubtitle")} 85 + title={t("settings:projectIntegrations.telegramSectionTitle")} 86 + > 87 + <TelegramIntegrationSettings projectId={projectId} /> 79 88 </IntegrationSection> 80 89 </div> 81 90 </div>
+49 -1
i18n/de-DE.json
··· 498 498 "genericWebhookSectionTitle": "Generische Webhooks", 499 499 "genericWebhookSectionSubtitle": "Sende Projektaufgabenereignisse als JSON an einen beliebigen HTTP-Endpunkt.", 500 500 "slackSectionTitle": "Slack-Integration", 501 - "slackSectionSubtitle": "Sende Projektaktualisierungen per Incoming Webhook in einen Slack-Kanal." 501 + "slackSectionSubtitle": "Sende Projektaktualisierungen per Incoming Webhook in einen Slack-Kanal.", 502 + "telegramSectionTitle": "Telegram-Integration", 503 + "telegramSectionSubtitle": "Sende Projektaktualisierungen mit einem Bot in einen Telegram-Chat oder ein Thema." 502 504 }, 503 505 "projectVisibility": { 504 506 "pageTitle": "Projektsichtbarkeit", ··· 719 721 "taskCommentCreated": "Neue Kommentare" 720 722 }, 721 723 "connect": "Webhook verbinden", 724 + "saveChanges": "Änderungen speichern", 725 + "disconnect": "Trennen" 726 + }, 727 + "telegramIntegration": { 728 + "validation": { 729 + "botTokenInvalid": "Gib einen gültigen Telegram-Bot-Token ein", 730 + "chatIdRequired": "Chat-ID ist erforderlich", 731 + "threadIdInvalid": "Gib eine gültige Telegram-Themen-ID ein" 732 + }, 733 + "toast": { 734 + "saved": "Telegram-Integration erfolgreich gespeichert", 735 + "saveError": "Telegram-Integration konnte nicht gespeichert werden", 736 + "enabled": "Telegram-Benachrichtigungen aktiviert", 737 + "disabled": "Telegram-Benachrichtigungen pausiert", 738 + "updateError": "Telegram-Integration konnte nicht aktualisiert werden", 739 + "removed": "Telegram-Integration erfolgreich entfernt", 740 + "removeError": "Telegram-Integration konnte nicht entfernt werden" 741 + }, 742 + "connectionTitle": "Telegram-Bot-Verbindung", 743 + "connectionHint": "Verwende einen Telegram-Bot-Token und eine Chat-ID, um Projektaktualisierungen in einen Chat oder ein Thema zu senden.", 744 + "connected": "Verbunden", 745 + "paused": "Pausiert", 746 + "botTokenLabel": "Bot-Token", 747 + "botTokenPlaceholder": "123456789:AAExampleBotToken", 748 + "botTokenHint": "Erstelle mit BotFather einen Bot und füge seinen Token hier ein.", 749 + "botTokenHintConfigured": "Ein Bot-Token ist bereits konfiguriert ({{token}}). Gib einen neuen ein, um ihn zu ersetzen.", 750 + "chatIdLabel": "Chat-ID", 751 + "chatIdPlaceholder": "-1001234567890 oder @team_updates", 752 + "chatIdHint": "Gib die Telegram-Chat-ID oder den Kanalnamen ein, in dem Updates gepostet werden sollen.", 753 + "threadIdLabel": "Themen-ID", 754 + "threadIdPlaceholder": "Optionale Themen-ID", 755 + "threadIdHint": "Optional. Verwende dies für Forenthemen in Telegram-Gruppen.", 756 + "chatLabelLabel": "Chat-Label", 757 + "chatLabelPlaceholder": "Engineering Updates", 758 + "chatLabelHint": "Optionales Label für deine Referenz in Kaneo.", 759 + "eventsTitle": "Ereignisbenachrichtigungen", 760 + "eventsHint": "Wähle aus, welche Projektänderungen an Telegram gesendet werden sollen.", 761 + "events": { 762 + "taskCreated": "Neue Aufgaben", 763 + "taskStatusChanged": "Statusänderungen", 764 + "taskPriorityChanged": "Prioritätsänderungen", 765 + "taskTitleChanged": "Titeländerungen", 766 + "taskDescriptionChanged": "Beschreibungsänderungen", 767 + "taskCommentCreated": "Neue Kommentare" 768 + }, 769 + "connect": "Telegram verbinden", 722 770 "saveChanges": "Änderungen speichern", 723 771 "disconnect": "Trennen" 724 772 },
+49 -1
i18n/el-GR.json
··· 492 492 "title": "Ενσωματώσεις έργου", 493 493 "subtitle": "Συνδέστε το έργο σας με εξωτερικά εργαλεία και υπηρεσίες για πιο ομαλή ροή εργασίας.", 494 494 "githubSectionTitle": "Ενσωμάτωση GitHub", 495 - "githubSectionSubtitle": "Συγχρονίστε εργασίες με GitHub issues και ενεργοποιήστε αμφίδρομο συγχρονισμό." 495 + "githubSectionSubtitle": "Συγχρονίστε εργασίες με GitHub issues και ενεργοποιήστε αμφίδρομο συγχρονισμό.", 496 + "telegramSectionTitle": "Ενσωμάτωση Telegram", 497 + "telegramSectionSubtitle": "Στείλτε ενημερώσεις εργασιών έργου σε συνομιλία ή θέμα Telegram με bot." 496 498 }, 497 499 "projectVisibility": { 498 500 "pageTitle": "Ορατότητα έργου", ··· 601 603 "importing": "Γίνεται εισαγωγή...", 602 604 "importIssues": "Εισαγωγή issues", 603 605 "importDisabledHint": "Ολοκληρώστε τη ρύθμιση του αποθετηρίου παραπάνω για να ενεργοποιήσετε την εισαγωγή" 606 + }, 607 + "telegramIntegration": { 608 + "validation": { 609 + "botTokenInvalid": "Εισαγάγετε έγκυρο token bot Telegram", 610 + "chatIdRequired": "Το αναγνωριστικό συνομιλίας είναι υποχρεωτικό", 611 + "threadIdInvalid": "Εισαγάγετε έγκυρο αναγνωριστικό θέματος Telegram" 612 + }, 613 + "toast": { 614 + "saved": "Η ενσωμάτωση Telegram αποθηκεύτηκε με επιτυχία", 615 + "saveError": "Αποτυχία αποθήκευσης της ενσωμάτωσης Telegram", 616 + "enabled": "Οι ειδοποιήσεις Telegram ενεργοποιήθηκαν", 617 + "disabled": "Οι ειδοποιήσεις Telegram μπήκαν σε παύση", 618 + "updateError": "Αποτυχία ενημέρωσης της ενσωμάτωσης Telegram", 619 + "removed": "Η ενσωμάτωση Telegram αφαιρέθηκε με επιτυχία", 620 + "removeError": "Αποτυχία αφαίρεσης της ενσωμάτωσης Telegram" 621 + }, 622 + "connectionTitle": "Σύνδεση bot Telegram", 623 + "connectionHint": "Χρησιμοποιήστε token bot Telegram και αναγνωριστικό συνομιλίας για να στέλνετε ενημερώσεις έργου σε συνομιλία ή θέμα.", 624 + "connected": "Συνδεδεμένο", 625 + "paused": "Σε παύση", 626 + "botTokenLabel": "Token bot", 627 + "botTokenPlaceholder": "123456789:AAExampleBotToken", 628 + "botTokenHint": "Δημιουργήστε bot με το BotFather και επικολλήστε εδώ το token του.", 629 + "botTokenHintConfigured": "Ένα token bot έχει ήδη ρυθμιστεί ({{token}}). Εισαγάγετε νέο για να το αντικαταστήσετε.", 630 + "chatIdLabel": "Αναγνωριστικό συνομιλίας", 631 + "chatIdPlaceholder": "-1001234567890 ή @team_updates", 632 + "chatIdHint": "Εισαγάγετε το αναγνωριστικό συνομιλίας Telegram ή το όνομα καναλιού όπου θα δημοσιεύονται οι ενημερώσεις.", 633 + "threadIdLabel": "Αναγνωριστικό θέματος", 634 + "threadIdPlaceholder": "Προαιρετικό αναγνωριστικό θέματος", 635 + "threadIdHint": "Προαιρετικό. Χρησιμοποιήστε το για θέματα forum μέσα σε ομάδες Telegram.", 636 + "chatLabelLabel": "Ετικέτα συνομιλίας", 637 + "chatLabelPlaceholder": "Engineering Updates", 638 + "chatLabelHint": "Προαιρετική ετικέτα για δική σας αναφορά μέσα στο Kaneo.", 639 + "eventsTitle": "Ειδοποιήσεις συμβάντων", 640 + "eventsHint": "Επιλέξτε ποιες αλλαγές έργου θα δημοσιεύονται στο Telegram.", 641 + "events": { 642 + "taskCreated": "Νέες εργασίες", 643 + "taskStatusChanged": "Αλλαγές κατάστασης", 644 + "taskPriorityChanged": "Αλλαγές προτεραιότητας", 645 + "taskTitleChanged": "Αλλαγές τίτλου", 646 + "taskDescriptionChanged": "Αλλαγές περιγραφής", 647 + "taskCommentCreated": "Νέα σχόλια" 648 + }, 649 + "connect": "Σύνδεση Telegram", 650 + "saveChanges": "Αποθήκευση αλλαγών", 651 + "disconnect": "Αποσύνδεση" 604 652 }, 605 653 "repositoryBrowser": { 606 654 "title": "Επιλογή αποθετηρίου",
+49 -1
i18n/en-US.json
··· 498 498 "genericWebhookSectionTitle": "Generic Webhooks", 499 499 "genericWebhookSectionSubtitle": "Send project task events to any HTTP endpoint as JSON.", 500 500 "slackSectionTitle": "Slack Integration", 501 - "slackSectionSubtitle": "Send project task updates into a Slack channel with an incoming webhook." 501 + "slackSectionSubtitle": "Send project task updates into a Slack channel with an incoming webhook.", 502 + "telegramSectionTitle": "Telegram Integration", 503 + "telegramSectionSubtitle": "Send project task updates into a Telegram chat or topic with a bot." 502 504 }, 503 505 "projectVisibility": { 504 506 "pageTitle": "Project Visibility", ··· 719 721 "taskCommentCreated": "New comments" 720 722 }, 721 723 "connect": "Connect webhook", 724 + "saveChanges": "Save changes", 725 + "disconnect": "Disconnect" 726 + }, 727 + "telegramIntegration": { 728 + "validation": { 729 + "botTokenInvalid": "Enter a valid Telegram bot token", 730 + "chatIdRequired": "Chat ID is required", 731 + "threadIdInvalid": "Enter a valid Telegram topic thread ID" 732 + }, 733 + "toast": { 734 + "saved": "Telegram integration saved successfully", 735 + "saveError": "Failed to save Telegram integration", 736 + "enabled": "Telegram notifications enabled", 737 + "disabled": "Telegram notifications paused", 738 + "updateError": "Failed to update Telegram integration", 739 + "removed": "Telegram integration removed successfully", 740 + "removeError": "Failed to remove Telegram integration" 741 + }, 742 + "connectionTitle": "Telegram bot connection", 743 + "connectionHint": "Use a Telegram bot token and chat ID to send project task updates into a chat or topic.", 744 + "connected": "Connected", 745 + "paused": "Paused", 746 + "botTokenLabel": "Bot token", 747 + "botTokenPlaceholder": "123456789:AAExampleBotToken", 748 + "botTokenHint": "Create a bot with BotFather and paste its token here.", 749 + "botTokenHintConfigured": "A bot token is already configured ({{token}}). Enter a new one to replace it.", 750 + "chatIdLabel": "Chat ID", 751 + "chatIdPlaceholder": "-1001234567890 or @team_updates", 752 + "chatIdHint": "Enter the Telegram chat ID or channel username where updates should be posted.", 753 + "threadIdLabel": "Topic thread ID", 754 + "threadIdPlaceholder": "Optional topic ID", 755 + "threadIdHint": "Optional. Use this for forum topics inside Telegram groups.", 756 + "chatLabelLabel": "Chat label", 757 + "chatLabelPlaceholder": "Engineering Updates", 758 + "chatLabelHint": "Optional label for your reference inside Kaneo.", 759 + "eventsTitle": "Event notifications", 760 + "eventsHint": "Choose which project changes should post to Telegram.", 761 + "events": { 762 + "taskCreated": "New tasks", 763 + "taskStatusChanged": "Status changes", 764 + "taskPriorityChanged": "Priority changes", 765 + "taskTitleChanged": "Title changes", 766 + "taskDescriptionChanged": "Description changes", 767 + "taskCommentCreated": "New comments" 768 + }, 769 + "connect": "Connect Telegram", 722 770 "saveChanges": "Save changes", 723 771 "disconnect": "Disconnect" 724 772 },
+49 -1
i18n/fr-FR.json
··· 492 492 "title": "Intégrations du projet", 493 493 "subtitle": "Connectez votre projet avec des outils et services externes pour rationaliser votre flux de travail.", 494 494 "githubSectionTitle": "Intégration GitHub", 495 - "githubSectionSubtitle": "Synchronisez les tâches avec les problèmes GitHub et activez la synchronisation bidirectionnelle." 495 + "githubSectionSubtitle": "Synchronisez les tâches avec les problèmes GitHub et activez la synchronisation bidirectionnelle.", 496 + "telegramSectionTitle": "Intégration Telegram", 497 + "telegramSectionSubtitle": "Envoyez les mises à jour des tâches du projet dans un chat ou un sujet Telegram avec un bot." 496 498 }, 497 499 "projectVisibility": { 498 500 "pageTitle": "Visibilité du projet", ··· 601 603 "importing": "Importation...", 602 604 "importIssues": "Importer les problèmes", 603 605 "importDisabledHint": "Complétez la configuration du dépôt ci-dessus pour activer l'importation" 606 + }, 607 + "telegramIntegration": { 608 + "validation": { 609 + "botTokenInvalid": "Entrez un jeton de bot Telegram valide", 610 + "chatIdRequired": "L'identifiant du chat est requis", 611 + "threadIdInvalid": "Entrez un identifiant de sujet Telegram valide" 612 + }, 613 + "toast": { 614 + "saved": "Intégration Telegram enregistrée avec succès", 615 + "saveError": "Échec de l'enregistrement de l'intégration Telegram", 616 + "enabled": "Notifications Telegram activées", 617 + "disabled": "Notifications Telegram mises en pause", 618 + "updateError": "Échec de la mise à jour de l'intégration Telegram", 619 + "removed": "Intégration Telegram supprimée avec succès", 620 + "removeError": "Échec de la suppression de l'intégration Telegram" 621 + }, 622 + "connectionTitle": "Connexion du bot Telegram", 623 + "connectionHint": "Utilisez un jeton de bot Telegram et un identifiant de chat pour envoyer les mises à jour du projet dans un chat ou un sujet.", 624 + "connected": "Connecté", 625 + "paused": "En pause", 626 + "botTokenLabel": "Jeton du bot", 627 + "botTokenPlaceholder": "123456789:AAExampleBotToken", 628 + "botTokenHint": "Créez un bot avec BotFather et collez son jeton ici.", 629 + "botTokenHintConfigured": "Un jeton de bot est déjà configuré ({{token}}). Entrez-en un nouveau pour le remplacer.", 630 + "chatIdLabel": "Identifiant du chat", 631 + "chatIdPlaceholder": "-1001234567890 ou @team_updates", 632 + "chatIdHint": "Entrez l'identifiant du chat Telegram ou le nom d'utilisateur du canal où publier les mises à jour.", 633 + "threadIdLabel": "Identifiant du sujet", 634 + "threadIdPlaceholder": "Identifiant de sujet facultatif", 635 + "threadIdHint": "Facultatif. Utilisez-le pour les sujets de forum dans les groupes Telegram.", 636 + "chatLabelLabel": "Libellé du chat", 637 + "chatLabelPlaceholder": "Mises à jour engineering", 638 + "chatLabelHint": "Libellé facultatif pour votre référence dans Kaneo.", 639 + "eventsTitle": "Notifications d'événements", 640 + "eventsHint": "Choisissez les changements du projet à publier dans Telegram.", 641 + "events": { 642 + "taskCreated": "Nouvelles tâches", 643 + "taskStatusChanged": "Changements de statut", 644 + "taskPriorityChanged": "Changements de priorité", 645 + "taskTitleChanged": "Changements de titre", 646 + "taskDescriptionChanged": "Changements de description", 647 + "taskCommentCreated": "Nouveaux commentaires" 648 + }, 649 + "connect": "Connecter Telegram", 650 + "saveChanges": "Enregistrer les modifications", 651 + "disconnect": "Déconnecter" 604 652 }, 605 653 "repositoryBrowser": { 606 654 "title": "Sélectionner un dépôt",
+49 -1
i18n/mk-MK.json
··· 492 492 "title": "Интеграции на проект", 493 493 "subtitle": "Поврзи го твојот проект со надворешни алатки и сервиси за да го подобриш работниот тек.", 494 494 "githubSectionTitle": "GitHub интеграција", 495 - "githubSectionSubtitle": "Синхронизирај задачи со GitHub issues и овозможи двонасочна синхронизација." 495 + "githubSectionSubtitle": "Синхронизирај задачи со GitHub issues и овозможи двонасочна синхронизација.", 496 + "telegramSectionTitle": "Telegram интеграција", 497 + "telegramSectionSubtitle": "Испраќај ажурирања за задачи од проектот во Telegram разговор или тема со бот." 496 498 }, 497 499 "projectVisibility": { 498 500 "pageTitle": "Видливост на проект", ··· 601 603 "importing": "Се увезува...", 602 604 "importIssues": "Увези Issues", 603 605 "importDisabledHint": "Заврши ја конфигурацијата на репозиториумот погоре за да овозможиш увоз" 606 + }, 607 + "telegramIntegration": { 608 + "validation": { 609 + "botTokenInvalid": "Внеси валиден Telegram bot token", 610 + "chatIdRequired": "Chat ID е задолжително", 611 + "threadIdInvalid": "Внеси валиден Telegram ID за тема" 612 + }, 613 + "toast": { 614 + "saved": "Telegram интеграцијата е успешно зачувана", 615 + "saveError": "Неуспешно зачувување на Telegram интеграцијата", 616 + "enabled": "Telegram известувањата се вклучени", 617 + "disabled": "Telegram известувањата се паузирани", 618 + "updateError": "Неуспешно ажурирање на Telegram интеграцијата", 619 + "removed": "Telegram интеграцијата е успешно отстранета", 620 + "removeError": "Неуспешно отстранување на Telegram интеграцијата" 621 + }, 622 + "connectionTitle": "Конекција со Telegram бот", 623 + "connectionHint": "Користи Telegram bot token и chat ID за да испраќаш ажурирања за проектот во разговор или тема.", 624 + "connected": "Поврзан", 625 + "paused": "Паузирано", 626 + "botTokenLabel": "Bot token", 627 + "botTokenPlaceholder": "123456789:AAExampleBotToken", 628 + "botTokenHint": "Креирај бот со BotFather и залепи го неговиот token тука.", 629 + "botTokenHintConfigured": "Bot token веќе е конфигуриран ({{token}}). Внеси нов за да го замениш.", 630 + "chatIdLabel": "Chat ID", 631 + "chatIdPlaceholder": "-1001234567890 или @team_updates", 632 + "chatIdHint": "Внеси Telegram chat ID или корисничко име на канал каде што треба да се објавуваат ажурирањата.", 633 + "threadIdLabel": "ID на тема", 634 + "threadIdPlaceholder": "Опционален ID на тема", 635 + "threadIdHint": "Опционално. Користи го ова за forum теми во Telegram групи.", 636 + "chatLabelLabel": "Ознака за разговор", 637 + "chatLabelPlaceholder": "Engineering Updates", 638 + "chatLabelHint": "Опционална ознака за твоја референца во Kaneo.", 639 + "eventsTitle": "Известувања за настани", 640 + "eventsHint": "Избери кои промени во проектот треба да се објавуваат во Telegram.", 641 + "events": { 642 + "taskCreated": "Нови задачи", 643 + "taskStatusChanged": "Промени на статус", 644 + "taskPriorityChanged": "Промени на приоритет", 645 + "taskTitleChanged": "Промени на наслов", 646 + "taskDescriptionChanged": "Промени на опис", 647 + "taskCommentCreated": "Нови коментари" 648 + }, 649 + "connect": "Поврзи Telegram", 650 + "saveChanges": "Зачувај промени", 651 + "disconnect": "Откачи" 604 652 }, 605 653 "repositoryBrowser": { 606 654 "title": "Избери репозиториум",