diff --git a/docs/specs/hooks-notifications/plan.md b/docs/specs/hooks-notifications/plan.md new file mode 100644 index 000000000..5c006e9a1 --- /dev/null +++ b/docs/specs/hooks-notifications/plan.md @@ -0,0 +1,72 @@ +# Plan: Hooks 与 Webhook 通知(DeepChat) + +## 范围与原则 + +- **仅通知**:不会阻断/中止 DeepChat 的生成、工具与权限流程(hook 失败也不影响主链路)。 +- **仅 Settings 配置**:所有配置都由 Settings 管理,不读取/合并任何外部配置文件。 +- **Webhook-only**:Telegram/Discord 只做向外发送消息(HTTP 请求),不做双向交互/按钮/回调;Confirmo 走本地 hook。 + +## 交付拆分(建议) + +为降低回归与 UI 复杂度,分两步交付: + +- **Step 1(可用)**:Settings 页面 + 配置模型 + Test(Telegram/Discord/Confirmo/每个事件 command test)+ 基础日志 +- **Step 2(完整)**:生命周期事件注入 + 真实触发 + 队列/限流/截断/脱敏 + 单元/集成测试 + +## Step 1:Settings + Test 能力(不接入真实生命周期) + +1. 定义数据模型与校验 + - 新增 shared types:`HookEventName`、settings config、event payload、执行/发送结果 + - 用 `zod` 做 settings schema 校验(容错:未知字段忽略,但记录 warning) +2. 配置存储与读取(main) + - 在现有 config store 中新增 `hooksNotifications` 配置树(默认全关闭) + - 提供 getter/setter + IPC 通道(renderer 仅通过 IPC 读写,避免在 renderer 暴露 secret) +3. Settings UI(renderer) + - 新增设置页面(或新增一个 section),布局要求: + - 顶部:Telegram 卡片(Enable + 参数 + Test + 事件勾选) + - 其次:Discord 卡片(Enable + 参数 + Test + 事件勾选) + - 其次:Confirmo 卡片(Enable + Test;默认全部事件;需检测 hook 文件存在) + - 下方:Hooks Commands 卡片(Enable + 每个生命周期:Switch + 单个 command 输入框 + 右侧 Test) + - UI 风格参考知识库配置:卡片/折叠 + Switch 控制启用 +4. Test 逻辑(main) + - `testTelegram()`:发送一条 `type="test"` 的通知文本到配置目标 + - `testDiscord()`:同上 + - `testConfirmo()`:执行本地 Confirmo hook(stdin JSON) + - `testHookCommand(eventName)`:构造一个最小模拟 payload,通过 stdin JSON 执行对应 command + - Test 结果返回 renderer:success/错误信息 + 状态码 + 用时 + stdout/stderr 摘要 +5. i18n + - 新增 settings 文案 key(zh-CN/en-US),不硬编码中文 + +## Step 2:接入生命周期 + 可靠性 + +1. 生命周期事件注入(main) + - `SessionStart`/`SessionEnd`:每次一次完整生成链路开始/结束 + - `UserPromptSubmit`:用户提交消息后、调用 LLM 前 + - `PreToolUse`:工具调用实际执行前(含 tool name/id/params) + - `PostToolUse`:工具调用成功返回后 + - `PostToolUseFailure`:工具调用失败(error) + - `PermissionRequest`:触发权限请求时(含 tool/permission meta) + - `Stop`:生成停止(含 stop_reason、userStop) +2. Dispatcher(非阻塞) + - 根据配置把事件分发到: + - command hook runner(按 event 的 switch+command) + - Telegram notifier(按 channel enabled + event 勾选) + - Discord notifier(同上) + - Confirmo hook runner(同上) + - 所有分发均异步执行、不可阻断主流程;失败仅记录日志与 diagnostics +3. 队列/限流/截断/脱敏 + - per-channel 串行队列,保持顺序并降低触发限流概率 + - 自动截断:Telegram `sendMessage` 文本 4096;Discord webhook `content` 2000 + - 处理 429:按 `Retry-After`/`retry_after` 等信息退避重试(上限次数) + - 脱敏:复用 main 侧 `redact.ts`(token、webhook URL、Authorization、apiKey 等) +4. Tests + - payload builder、截断、脱敏、队列顺序、429 退避、配置 schema + - 可选:本地 mock server 验证 Telegram/Discord 200/429/500 行为 + +## 里程碑验收(Definition of Done) + +- Settings 可配置 Telegram/Discord(启用/禁用 + 参数 + 事件勾选)并能 Test 成功/失败可见 +- Settings 可配置 Confirmo(检测 hook 可用性 + 事件勾选)并能 Test 成功/失败可见 +- 每个生命周期事件均可配置单个 command(启用/禁用)并能 Test 执行(展示 exit code/stdout/stderr 摘要) +- 生命周期触发后可按配置向 Telegram/Discord/Confirmo 发送消息(失败不影响主流程,日志可追踪) +- 不读取任何外部配置文件;默认关闭;不影响现有系统通知与聊天主流程 diff --git a/docs/specs/hooks-notifications/spec.md b/docs/specs/hooks-notifications/spec.md new file mode 100644 index 000000000..40ddacc48 --- /dev/null +++ b/docs/specs/hooks-notifications/spec.md @@ -0,0 +1,257 @@ +# Hooks 与 Webhook 通知(DeepChat) + +## 背景 + +DeepChat 目前已有系统通知(OS Notification)与 UI 内提示,但缺少一套“可配置、可复用、可路由”的通知能力: + +- 用户希望在关键生命周期点触发通知(例如:开始/结束、工具调用前后、权限请求等)。 +- 用户希望用 **一个命令输入框** 快速接入任意 webhook(例如 `curl`/`node` 脚本),并在 Settings 里一键测试。 +- 同时提供 **内置 Telegram / Discord / Confirmo** 常用通道,简单配置参数后勾选要推送的事件即可。 + +本功能只做“通知/观测”,不改变 DeepChat 的执行语义。 + +## 目标 + +- 在 Settings 中提供三类能力(均可启用/禁用,默认关闭): + - **Telegram 通知**:全局配置(token/chatId/threadId)+ 事件勾选 + Test + - **Discord 通知**:全局配置(webhookUrl)+ 事件勾选 + Test + - **Confirmo 通知**:检测本地 hook 文件存在后可启用 + Test(默认全部事件) + - **Hooks Commands**:每个生命周期事件一个 command 输入框(右侧 Test)+ 每事件启用/禁用 +- Hooks command 执行契约: + - 每次触发将事件 payload 以 **stdin JSON** 传入命令 + - 捕获 stdout/stderr/exit code,仅用于诊断与日志(不阻断主流程) +- Telegram/Discord 采用 outbound HTTP 请求(webhook/API),不做交互式组件、不接收回调。 +- Confirmo 采用本地 hook 执行(stdin payload),不做交互式组件。 +- 不读取/合并任何外部配置文件;所有配置仅由 DeepChat Settings 管理。 + +## 非目标(v1) + +- 不提供双向 bot 交互(按钮、指令、回调、鉴权登录)。 +- 不提供复杂模板系统(仅提供固定内置消息格式;高级自定义由 command hooks 覆盖)。 +- 不提供“阻止/中止/改写”的 hooks 能力(exit code/输出不会影响工具与权限流程)。 +- 不提供按事件分别配置 Telegram/Discord 参数(仅全局配置 + 事件勾选)。 + +## 用户体验(Settings) + +### 入口 + +在 Settings 增加一个页面或 section:`Notifications & Hooks`(建议独立页面,避免塞进现有 DisplaySettings)。 + +### 页面布局(从上到下) + +1. Telegram 卡片(顶部) +2. Discord 卡片 +3. Confirmo 卡片 +4. Hooks Commands 卡片(生命周期列表) + +卡片交互参考知识库配置的模式:外层卡片 + Switch 启用/禁用 + 可折叠内容区域。 + +### Telegram 卡片 + +- Enable(Switch) +- Bot Token(password input,可 reveal) +- Chat ID(text input) +- Thread ID(可选,text/number input,对应 `message_thread_id`) +- Events(多选勾选要推送的生命周期事件;默认建议勾选“重要事件”) +- Test(按钮):发送一条测试消息,不依赖真实会话 + +### Discord 卡片 + +- Enable(Switch) +- Webhook URL(password input,可 reveal) +- Events(多选勾选要推送的生命周期事件) +- Test(按钮):发送一条测试消息 + +### Confirmo 卡片 + +- Enable(Switch;仅在检测到 hook 文件后可用) +- 默认发送全部事件,无需配置事件类型 +- Test(按钮):触发一次测试通知 +- 若未检测到 `~/.confirmo/hooks/confirmo-hook.js`,整卡片不可用并提示路径 + +### Hooks Commands 卡片 + +- Enable Hooks Commands(Switch) +- 生命周期事件列表(每行): + - 事件名(label) + - Enable(Switch,便于保留 command 但临时停用) + - Command(单行 input;留空视为未配置) + - Test(按钮,位于输入框右侧):触发一次“模拟事件”,执行该 command + +Test 结果展示(每行/每通道均需要): + +- success/failed +- 耗时(ms) +- exit code(command) +- stdout/stderr 摘要(最多 N 字符,避免 UI 卡顿) +- 错误信息(HTTP status、429 退避信息、网络错误等) + +## 生命周期事件(Hook Events) + +> 事件名为 DeepChat 内部稳定 API(建议保持 PascalCase 以便脚本易读)。 + +| Event | 触发时机(主链路) | 关键字段(payload) | +| --- | --- | --- | +| `SessionStart` | 一次生成链路开始(准备调用 LLM 前) | `conversationId`、`workdir`、`providerId`、`modelId` | +| `UserPromptSubmit` | 用户提交消息后、调用 LLM 前 | `promptPreview`、`messageId` | +| `PreToolUse` | 工具调用执行前 | `tool.name`、`tool.callId`、`tool.paramsPreview` | +| `PostToolUse` | 工具调用成功后 | `tool.name`、`tool.callId`、`tool.responsePreview` | +| `PostToolUseFailure` | 工具调用失败后 | `tool.name`、`tool.callId`、`tool.error` | +| `PermissionRequest` | 出现权限请求时 | `permission.*`(tool/permissionType/description/options) | +| `Stop` | 生成停止(用户停止/完成/错误) | `stop.reason`、`stop.userStop` | +| `SessionEnd` | 一次生成链路结束(finalize) | `usage?`、`error?`、`stop?` | + +说明: + +- 以上事件是 v1 必须落地的最小集合;后续可增量增加更多事件,但不能修改既有事件语义与字段含义。 +- Telegram/Discord 的 “Events 多选” 与 Hooks Commands 的事件列表保持同一集合,便于用户理解;Confirmo 默认全部事件。 + +## Hook Command 执行契约 + +### 输入(stdin) + +触发时将 payload JSON 写入 stdin,一次性写入并关闭 stdin。 + +建议 payload 结构(v1): + +```jsonc +{ + "payloadVersion": 1, + "event": "PreToolUse", + "time": "2026-02-09T18:00:00.000Z", + "isTest": false, + "app": { + "version": "0.5.7", + "platform": "win32" + }, + "session": { + "conversationId": "conv_xxx", + "agentId": "agent_xxx", + "workdir": "C:\\repo\\project" + }, + "user": { + "messageId": "msg_xxx", + "promptPreview": "Summarize the diff..." + }, + "tool": { + "callId": "toolcall_xxx", + "name": "execute_command", + "paramsPreview": "{\"command\":\"pnpm test\"}" + }, + "permission": null, + "stop": null, + "error": null +} +``` + +字段策略: + +- `*Preview` 字段默认应为“截断后的摘要”,避免把完整敏感内容外发;如需完整内容,推荐用户用 command hooks 自己读取上下文(或未来新增显式开关)。 +- Telegram/Discord 使用简洁卡片文本,不发送原始 payload;原始 payload 仅用于 Hooks Commands / Confirmo。 +- `isTest=true` 用于区分 Settings 的 Test 触发,脚本可据此避免产生副作用。 + +### 进程与环境 + +- 使用 `child_process.spawn` 执行 `command`(建议 `shell: true` 以支持用户常见的 `&&`/管道)。 +- `cwd`:优先使用当前会话的 `workdir`;若不可得,则回落到应用记录的最近 workdir;再回落到 `process.cwd()`。 +- `timeout`:v1 可用固定默认(例如 30s),后续再支持可配置。 +- env:可附加少量只读变量,便于脚本快速取用(可选): + - `DEEPCHAT_HOOK_EVENT` + - `DEEPCHAT_CONVERSATION_ID` + - `DEEPCHAT_WORKDIR` + +### 输出(stdout/stderr/exit code) + +- stdout/stderr:仅记录与展示摘要(Diagnostics),不做结构化解析要求。 +- exit code:仅用于标记成功/失败(Diagnostics),不影响 DeepChat 主链路。 + +## 内置通道:Telegram + +### 配置 + +- `enabled: boolean` +- `botToken: string`(secret) +- `chatId: string` +- `threadId?: string`(可选,映射到 `message_thread_id`) +- `events: HookEventName[]` + +### 发送 + +- Endpoint:`POST https://api.telegram.org/bot{token}/sendMessage` +- Body(JSON):`chat_id`、`text`、可选 `message_thread_id` 等 +- 文本长度限制:`text` 1-4096 字符(超出需截断) + +### 建议默认消息格式 + +卡片式文本,字段简洁,突出事件与时间即可。 + +## 内置通道:Discord(Incoming Webhook) + +### 配置 + +- `enabled: boolean` +- `webhookUrl: string`(secret) +- `events: HookEventName[]` + +### 发送 + +- `POST webhookUrl` +- Body(JSON):`embeds`(卡片)+ `allowed_mentions: { parse: [] }`(避免误 @),`content` 可选 +- 采用 embeds 形成卡片式消息(符合 Discord message object 结构) + +## 内置通道:Confirmo(Local Hook) + +### 配置 + +- `enabled: boolean` +- `events: HookEventName[]`(固定为全部事件,Settings 不提供选择) +- 可用性:仅当 `~/.confirmo/hooks/confirmo-hook.js` 存在时可启用 + +### 执行 + +- 使用内置 Node(如存在)执行 `confirmo-hook.js`,否则调用系统 `node` +- 通过 stdin 写入 payload JSON(与 Hooks Commands 相同) + +## 触发与分发策略(运行时) + +当事件发生时,异步分发到以下目标(互不影响): + +1. Hooks Commands:若该事件启用且 command 非空,则执行 +2. Telegram:若 enabled 且该事件在 events 列表中,则发送 +3. Discord:同上 +4. Confirmo:若 enabled 且该事件在 events 列表中,则执行 + +要求:所有分发均为 **best-effort**,不得阻塞 LLM/工具调用主流程。 + +## 可靠性与保护 + +- **串行队列**:Telegram/Discord 分别串行发送,保持顺序并降低限流概率。 +- **429 退避**:遇到限流按平台返回的等待时间进行重试(上限次数),最终失败写日志即可。 +- **截断**:按平台长度限制截断并在末尾加 `…(truncated)`。 +- **脱敏**:对日志与 diagnostics 做脱敏(token、webhookUrl、Authorization 等)。 + +## 依赖与实现建议 + +尽量复用现有依赖,避免引入新包: + +- Schema:`zod`(已在 dependencies) +- HTTP:优先用 Node `fetch`(Node 20)或复用现有 `axios` +- Command:`child_process`(必要时复用 `cross-spawn`) +- 日志:`electron-log`(已存在) + +## 外部参考(实现用) + +```text +Telegram Bot API sendMessage: +https://core.telegram.org/bots/api#sendmessage + +Discord webhook rate limit note (Safety Center, for reference): +https://discord.com/safety/using-webhooks-and-embeds +``` + +## 已确认决策(来自需求) + +1. 仅仅通知(不阻断流程)。 +2. 按 DeepChat 设计,不要求完全照抄任何外部实现。 +3. webhook 就够(Telegram/Discord 仅 outbound;无交互),Confirmo 走本地 hook。 +4. 所有配置都在 Settings 完成;Telegram/Discord/Confirmo 置顶且全局配置;生命周期事件每个只提供一个 command 输入框 + 右侧 Test。 diff --git a/src/main/presenter/agentPresenter/index.ts b/src/main/presenter/agentPresenter/index.ts index 2833dc5ad..2038a046c 100644 --- a/src/main/presenter/agentPresenter/index.ts +++ b/src/main/presenter/agentPresenter/index.ts @@ -160,6 +160,18 @@ export class AgentPresenter implements IAgentPresenter { false, this.buildMessageMetadata(conversation) ) + try { + const promptPreview = this.extractUserMessageText(content) + presenter.hooksNotifications.dispatchEvent('UserPromptSubmit', { + conversationId: agentId, + messageId: userMessage.id, + promptPreview, + providerId: conversation.settings.providerId, + modelId: conversation.settings.modelId + }) + } catch (error) { + console.warn('[AgentPresenter] Failed to dispatch UserPromptSubmit hook:', error) + } try { await this.resolvePendingQuestionIfNeeded(agentId, userMessage.id, content) diff --git a/src/main/presenter/agentPresenter/loop/agentLoopHandler.ts b/src/main/presenter/agentPresenter/loop/agentLoopHandler.ts index a9d55b413..65dcc51c0 100644 --- a/src/main/presenter/agentPresenter/loop/agentLoopHandler.ts +++ b/src/main/presenter/agentPresenter/loop/agentLoopHandler.ts @@ -613,6 +613,7 @@ export class AgentLoopHandler { enabledMcpTools, conversationMessages, modelConfig, + providerId, abortSignal: abortController.signal, currentToolCallCount: toolCallCount, maxToolCalls: MAX_TOOL_CALLS, diff --git a/src/main/presenter/agentPresenter/loop/toolCallProcessor.ts b/src/main/presenter/agentPresenter/loop/toolCallProcessor.ts index 1d703e224..c47117399 100644 --- a/src/main/presenter/agentPresenter/loop/toolCallProcessor.ts +++ b/src/main/presenter/agentPresenter/loop/toolCallProcessor.ts @@ -11,6 +11,7 @@ import path from 'path' import { isNonRetryableError } from './errorClassification' import { resolveToolOffloadPath } from '../../sessionPresenter/sessionPaths' import { parseQuestionToolArgs, QUESTION_TOOL_NAME } from '../tools/questionTool' +import { presenter } from '@/presenter' interface ToolCallProcessorOptions { getAllToolDefinitions: (context: ToolCallExecutionContext) => Promise @@ -30,6 +31,7 @@ interface ToolCallExecutionContext { enabledMcpTools?: string[] conversationMessages: ChatMessage[] modelConfig: ModelConfig + providerId?: string abortSignal: AbortSignal currentToolCallCount: number maxToolCalls: number @@ -65,6 +67,7 @@ export class ToolCallProcessor { ): AsyncGenerator { let toolCallCount = context.currentToolCallCount let needContinueConversation = context.toolCalls.length > 0 + const shouldDispatchToolHooks = context.providerId === 'acp' let toolDefinitions = await this.options.getAllToolDefinitions(context) @@ -226,6 +229,21 @@ export class ToolCallProcessor { } try { + if (shouldDispatchToolHooks) { + try { + presenter.hooksNotifications.dispatchEvent('PreToolUse', { + conversationId: context.conversationId, + tool: { + callId: toolCall.id, + name: toolCall.name, + params: toolCall.arguments + } + }) + } catch (error) { + console.warn('[ToolCallProcessor] Failed to dispatch PreToolUse hook:', error) + } + } + const toolResponse = await this.options.callTool(mcpToolInput) const requiresPermission = Boolean(toolResponse.rawData?.requiresPermission) @@ -269,6 +287,22 @@ export class ToolCallProcessor { toolCall.name ) + if (shouldDispatchToolHooks) { + try { + presenter.hooksNotifications.dispatchEvent('PostToolUse', { + conversationId: context.conversationId, + tool: { + callId: toolCall.id, + name: toolCall.name, + params: toolCall.arguments, + response: toolContent + } + }) + } catch (error) { + console.warn('[ToolCallProcessor] Failed to dispatch PostToolUse hook:', error) + } + } + if (supportsFunctionCall) { this.appendNativeFunctionCallMessages(context.conversationMessages, toolCall, { content: toolContentForModel @@ -320,6 +354,22 @@ export class ToolCallProcessor { ) const errorMessage = toolError instanceof Error ? toolError.message : String(toolError) + if (shouldDispatchToolHooks) { + try { + presenter.hooksNotifications.dispatchEvent('PostToolUseFailure', { + conversationId: context.conversationId, + tool: { + callId: toolCall.id, + name: toolCall.name, + params: toolCall.arguments, + error: errorMessage + } + }) + } catch (error) { + console.warn('[ToolCallProcessor] Failed to dispatch PostToolUseFailure hook:', error) + } + } + // Check if error is non-retryable (should stop the loop) const errorForClassification: Error | string = toolError instanceof Error ? toolError : String(toolError) diff --git a/src/main/presenter/agentPresenter/streaming/llmEventHandler.ts b/src/main/presenter/agentPresenter/streaming/llmEventHandler.ts index 9eb76ded9..c7b76821e 100644 --- a/src/main/presenter/agentPresenter/streaming/llmEventHandler.ts +++ b/src/main/presenter/agentPresenter/streaming/llmEventHandler.ts @@ -13,6 +13,14 @@ import type { StreamUpdateScheduler } from './streamUpdateScheduler' type ConversationUpdateHandler = (state: GeneratingMessageState) => Promise +type HookErrorSnapshot = { + error: { message: string; stack?: string } + usage?: Record | null + conversationId?: string + providerId?: string + modelId?: string +} + export class LLMEventHandler { private readonly generatingMessages: Map private readonly searchingMessages: Set @@ -21,6 +29,7 @@ export class LLMEventHandler { private readonly toolCallHandler: ToolCallHandler private readonly streamUpdateScheduler: StreamUpdateScheduler private readonly onConversationUpdated?: ConversationUpdateHandler + private readonly errorByEventId: Map = new Map() constructor(options: { generatingMessages: Map @@ -141,8 +150,57 @@ export class LLMEventHandler { const shouldSkipToolCall = tool_call && tool_call_name === 'question' && tool_call !== 'question-required' + const isAcpProvider = state.message.model_provider === 'acp' if (tool_call && !shouldSkipToolCall) { + if (isAcpProvider) { + try { + if (tool_call === 'start') { + presenter.hooksNotifications.dispatchEvent('PreToolUse', { + conversationId: state.conversationId, + messageId: eventId, + providerId: state.message.model_provider, + modelId: state.message.model_id, + tool: { + callId: tool_call_id, + name: tool_call_name, + params: tool_call_params + } + }) + } + if (tool_call === 'end') { + presenter.hooksNotifications.dispatchEvent('PostToolUse', { + conversationId: state.conversationId, + messageId: eventId, + providerId: state.message.model_provider, + modelId: state.message.model_id, + tool: { + callId: tool_call_id, + name: tool_call_name, + params: tool_call_params, + response: msg.tool_call_response ? String(msg.tool_call_response) : undefined + } + }) + } + if (tool_call === 'error') { + presenter.hooksNotifications.dispatchEvent('PostToolUseFailure', { + conversationId: state.conversationId, + messageId: eventId, + providerId: state.message.model_provider, + modelId: state.message.model_id, + tool: { + callId: tool_call_id, + name: tool_call_name, + params: tool_call_params, + error: msg.tool_call_response ? String(msg.tool_call_response) : undefined + } + }) + } + } catch (error) { + console.warn('[LLMEventHandler] Failed to dispatch ACP tool hooks:', error) + } + } + switch (tool_call) { case 'start': presenter.sessionManager.incrementToolCallCount(state.conversationId) @@ -163,6 +221,22 @@ export class LLMEventHandler { } }) presenter.sessionManager.setStatus(state.conversationId, 'waiting_permission') + try { + presenter.hooksNotifications.dispatchEvent('PermissionRequest', { + conversationId: state.conversationId, + messageId: eventId, + providerId: state.message.model_provider, + modelId: state.message.model_id, + tool: { + callId: tool_call_id, + name: tool_call_name, + params: tool_call_params + }, + permission: msg.permission_request ?? null + }) + } catch (error) { + console.warn('[LLMEventHandler] Failed to dispatch PermissionRequest hook:', error) + } await this.toolCallHandler.processToolCallPermission(state, msg, currentTime) break case 'question-required': @@ -318,7 +392,29 @@ export class LLMEventHandler { async handleLLMAgentError(msg: LLMAgentEventData): Promise { const { eventId, error } = msg + const errorMessage = error instanceof Error ? error.message : String(error) + const errorStack = error instanceof Error ? error.stack : undefined const state = this.generatingMessages.get(eventId) + const errorSnapshot: HookErrorSnapshot = { + error: errorStack ? { message: errorMessage, stack: errorStack } : { message: errorMessage }, + usage: state?.totalUsage, + conversationId: state?.conversationId, + providerId: state?.message.model_provider, + modelId: state?.message.model_id + } + + if (!errorSnapshot.conversationId) { + try { + const message = await this.messageManager.getMessage(eventId) + errorSnapshot.conversationId = message.conversationId + errorSnapshot.providerId = errorSnapshot.providerId ?? message.model_provider + errorSnapshot.modelId = errorSnapshot.modelId ?? message.model_id + } catch { + // ignore + } + } + + this.errorByEventId.set(eventId, errorSnapshot) if (state) { if (state.adaptiveBuffer) { @@ -331,7 +427,7 @@ export class LLMEventHandler { // Flush stream buffers before persisting error to avoid stale snapshot overwrites. await this.streamUpdateScheduler.flushAll(eventId, 'final') - await this.messageManager.handleMessageError(eventId, String(error)) + await this.messageManager.handleMessageError(eventId, errorMessage) if (state) { this.generatingMessages.delete(eventId) @@ -354,6 +450,7 @@ export class LLMEventHandler { async handleLLMAgentEnd(msg: LLMAgentEventData): Promise { const { eventId, userStop } = msg const state = this.generatingMessages.get(eventId) + const errorSnapshot = this.errorByEventId.get(eventId) if (state) { if (state.adaptiveBuffer) { @@ -411,6 +508,7 @@ export class LLMEventHandler { await this.streamUpdateScheduler.flushAll(eventId, 'final') this.generatingMessages.delete(eventId) eventBus.sendToRenderer(STREAM_EVENTS.END, SendTarget.ALL_WINDOWS, msg) + this.errorByEventId.delete(eventId) return } @@ -420,6 +518,55 @@ export class LLMEventHandler { presenter.sessionManager.clearPendingQuestion(state.conversationId) } + const stopReason = errorSnapshot ? 'error' : userStop ? 'user_stop' : 'complete' + const stopPayload = { + reason: stopReason, + userStop: Boolean(userStop) + } + const usage = state?.totalUsage ?? errorSnapshot?.usage ?? null + const errorInfo = errorSnapshot?.error ?? null + let conversationId = state?.conversationId ?? errorSnapshot?.conversationId + let providerId = state?.message.model_provider ?? errorSnapshot?.providerId + let modelId = state?.message.model_id ?? errorSnapshot?.modelId + + if (!conversationId) { + try { + const message = await this.messageManager.getMessage(eventId) + conversationId = message.conversationId + providerId = providerId ?? message.model_provider + modelId = modelId ?? message.model_id + } catch { + // ignore + } + } + + try { + try { + presenter.hooksNotifications.dispatchEvent('Stop', { + conversationId, + providerId, + modelId, + stop: stopPayload + }) + } catch (error) { + console.warn('[LLMEventHandler] Failed to dispatch Stop hook:', error) + } + try { + presenter.hooksNotifications.dispatchEvent('SessionEnd', { + conversationId, + providerId, + modelId, + stop: stopPayload, + usage, + error: errorInfo + }) + } catch (error) { + console.warn('[LLMEventHandler] Failed to dispatch SessionEnd hook:', error) + } + } finally { + this.errorByEventId.delete(eventId) + } + await this.streamUpdateScheduler.flushAll(eventId, 'final') this.searchingMessages.delete(eventId) eventBus.sendToRenderer(STREAM_EVENTS.END, SendTarget.ALL_WINDOWS, msg) diff --git a/src/main/presenter/agentPresenter/streaming/streamGenerationHandler.ts b/src/main/presenter/agentPresenter/streaming/streamGenerationHandler.ts index 193a7cf59..39d8accb2 100644 --- a/src/main/presenter/agentPresenter/streaming/streamGenerationHandler.ts +++ b/src/main/presenter/agentPresenter/streaming/streamGenerationHandler.ts @@ -142,6 +142,19 @@ export class StreamGenerationHandler extends BaseHandler { searchStrategy: currentSearchStrategy } = currentConversation.settings + try { + presenter.hooksNotifications.dispatchEvent('SessionStart', { + conversationId, + messageId: userMessage.id, + promptPreview: userContent, + providerId: currentProviderId, + modelId: currentModelId, + workdir: agentWorkspacePath ?? null + }) + } catch (error) { + console.warn('[StreamGenerationHandler] Failed to dispatch SessionStart hook:', error) + } + const stream = this.ctx.llmProviderPresenter.startStreamCompletion( currentProviderId, finalContent, @@ -272,6 +285,18 @@ export class StreamGenerationHandler extends BaseHandler { await this.updateGenerationState(state, promptTokens) + try { + presenter.hooksNotifications.dispatchEvent('SessionStart', { + conversationId, + messageId: userMessage.id, + promptPreview: 'continue', + providerId, + modelId + }) + } catch (error) { + console.warn('[StreamGenerationHandler] Failed to dispatch SessionStart hook:', error) + } + if (toolCallResponse && toolCall) { eventBus.sendToRenderer(STREAM_EVENTS.RESPONSE, SendTarget.ALL_WINDOWS, { eventId: state.message.id, diff --git a/src/main/presenter/configPresenter/index.ts b/src/main/presenter/configPresenter/index.ts index 01ead3eef..65f329dcc 100644 --- a/src/main/presenter/configPresenter/index.ts +++ b/src/main/presenter/configPresenter/index.ts @@ -49,6 +49,15 @@ import { killTerminal } from './acpInitHelper' import { clearShellEnvironmentCache } from '../agentPresenter/acp' +import type { + HookEventName, + HookTestResult, + HooksNotificationsSettings +} from '@shared/hooksNotifications' +import { + createDefaultHooksNotificationsConfig, + normalizeHooksNotificationsConfig +} from '../hooksNotifications/config' // Define application settings interface interface IAppSettings { @@ -78,6 +87,7 @@ interface IAppSettings { codeFontFamily?: string // Custom code font skillsPath?: string // Skills directory path enableSkills?: boolean // Skills system global toggle + hooksNotifications?: HooksNotificationsSettings // Hooks & notifications settings [key: string]: unknown // Allow arbitrary keys, using unknown type instead of any } @@ -146,7 +156,8 @@ export class ConfigPresenter implements IConfigPresenter { skillsPath: path.join(app.getPath('home'), '.deepchat', 'skills'), enableSkills: true, updateChannel: 'stable', // Default to stable version - appVersion: this.currentAppVersion + appVersion: this.currentAppVersion, + hooksNotifications: createDefaultHooksNotificationsConfig() } }) @@ -1740,6 +1751,49 @@ export class ConfigPresenter implements IConfigPresenter { throw error } } + + getHooksNotificationsConfig(): HooksNotificationsSettings { + const raw = this.store.get('hooksNotifications') + const normalized = normalizeHooksNotificationsConfig(raw) + const confirmoStatus = presenter?.hooksNotifications?.getConfirmoHookStatus?.() + if (confirmoStatus && !confirmoStatus.available) { + normalized.confirmo.enabled = false + } + if (!raw || JSON.stringify(raw) !== JSON.stringify(normalized)) { + this.store.set('hooksNotifications', normalized) + } + return normalized + } + + setHooksNotificationsConfig(config: HooksNotificationsSettings): HooksNotificationsSettings { + const normalized = normalizeHooksNotificationsConfig(config) + const confirmoStatus = presenter?.hooksNotifications?.getConfirmoHookStatus?.() + if (confirmoStatus && !confirmoStatus.available) { + normalized.confirmo.enabled = false + } + this.store.set('hooksNotifications', normalized) + return normalized + } + + async testTelegramNotification(): Promise { + return await presenter.hooksNotifications.testTelegram() + } + + async testDiscordNotification(): Promise { + return await presenter.hooksNotifications.testDiscord() + } + + async testConfirmoNotification(): Promise { + return await presenter.hooksNotifications.testConfirmo() + } + + async testHookCommand(eventName: HookEventName): Promise { + return await presenter.hooksNotifications.testHookCommand(eventName) + } + + getConfirmoHookStatus(): { available: boolean; path: string } { + return presenter.hooksNotifications.getConfirmoHookStatus() + } } export { defaultShortcutKey } from './shortcutKeySettings' diff --git a/src/main/presenter/hooksNotifications/config.ts b/src/main/presenter/hooksNotifications/config.ts new file mode 100644 index 000000000..4bfa3d4d5 --- /dev/null +++ b/src/main/presenter/hooksNotifications/config.ts @@ -0,0 +1,203 @@ +import log from 'electron-log' +import { z } from 'zod' +import { + DEFAULT_IMPORTANT_HOOK_EVENTS, + HOOK_EVENT_NAMES, + HookEventName, + HooksNotificationsSettings +} from '@shared/hooksNotifications' + +const HookCommandConfigSchema = z + .object({ + enabled: z.boolean().optional(), + command: z.string().optional() + }) + .strip() + +const HookCommandsSchema = z + .object({ + enabled: z.boolean().optional(), + events: z.record(z.string(), HookCommandConfigSchema).optional() + }) + .strip() + +const TelegramSchema = z + .object({ + enabled: z.boolean().optional(), + botToken: z.string().optional(), + chatId: z.string().optional(), + threadId: z.union([z.string(), z.number()]).optional(), + events: z.array(z.string()).optional() + }) + .strip() + +const DiscordSchema = z + .object({ + enabled: z.boolean().optional(), + webhookUrl: z.string().optional(), + events: z.array(z.string()).optional() + }) + .strip() + +const ConfirmoSchema = z + .object({ + enabled: z.boolean().optional(), + events: z.array(z.string()).optional() + }) + .strip() + +const HooksNotificationsSchema = z + .object({ + telegram: TelegramSchema.optional(), + discord: DiscordSchema.optional(), + confirmo: ConfirmoSchema.optional(), + commands: HookCommandsSchema.optional() + }) + .strip() + +const normalizeOptionalString = (value?: string | number | null): string | undefined => { + if (value === null || value === undefined) return undefined + const text = String(value).trim() + return text.length > 0 ? text : undefined +} + +const sanitizeEvents = (events?: string[] | null): HookEventName[] => { + if (!Array.isArray(events)) return [] + const set = new Set() + for (const item of events) { + if (HOOK_EVENT_NAMES.includes(item as HookEventName)) { + set.add(item as HookEventName) + } + } + return Array.from(set) +} + +const createDefaultCommandEvents = () => { + const events: HooksNotificationsSettings['commands']['events'] = + {} as HooksNotificationsSettings['commands']['events'] + for (const name of HOOK_EVENT_NAMES) { + events[name] = { enabled: false, command: '' } + } + return events +} + +export const createDefaultHooksNotificationsConfig = (): HooksNotificationsSettings => ({ + telegram: { + enabled: false, + botToken: '', + chatId: '', + threadId: undefined, + events: [...DEFAULT_IMPORTANT_HOOK_EVENTS] + }, + discord: { + enabled: false, + webhookUrl: '', + events: [...DEFAULT_IMPORTANT_HOOK_EVENTS] + }, + confirmo: { + enabled: false, + events: [...HOOK_EVENT_NAMES] + }, + commands: { + enabled: false, + events: createDefaultCommandEvents() + } +}) + +const isRecord = (value: unknown): value is Record => + Boolean(value && typeof value === 'object' && !Array.isArray(value)) + +const warnUnknownKeys = (label: string, value: unknown, allowed: string[]) => { + if (!isRecord(value)) return + const unknown = Object.keys(value).filter((key) => !allowed.includes(key)) + if (unknown.length) { + log.warn(`[HooksNotifications] Unknown keys at ${label}: ${unknown.join(', ')}`) + } +} + +export const normalizeHooksNotificationsConfig = (input: unknown): HooksNotificationsSettings => { + warnUnknownKeys('hooksNotifications', input, ['telegram', 'discord', 'confirmo', 'commands']) + if (isRecord(input)) { + warnUnknownKeys('hooksNotifications.telegram', input.telegram, [ + 'enabled', + 'botToken', + 'chatId', + 'threadId', + 'events' + ]) + warnUnknownKeys('hooksNotifications.discord', input.discord, [ + 'enabled', + 'webhookUrl', + 'events' + ]) + warnUnknownKeys('hooksNotifications.confirmo', input.confirmo, ['enabled', 'events']) + warnUnknownKeys('hooksNotifications.commands', input.commands, ['enabled', 'events']) + const commandInput = isRecord(input.commands) ? input.commands : null + if (commandInput && isRecord(commandInput.events)) { + const unknownEvents = Object.keys(commandInput.events).filter( + (name) => !HOOK_EVENT_NAMES.includes(name as HookEventName) + ) + if (unknownEvents.length) { + log.warn(`[HooksNotifications] Unknown command events: ${unknownEvents.join(', ')}`) + } + } + } + + const defaults = createDefaultHooksNotificationsConfig() + const parsed = HooksNotificationsSchema.safeParse(input) + if (!parsed.success) { + log.warn('[HooksNotifications] Invalid config, using defaults:', parsed.error?.message) + return defaults + } + + const telegram = (parsed.data.telegram ?? {}) as Partial + const discord = (parsed.data.discord ?? {}) as Partial + const confirmo = (parsed.data.confirmo ?? {}) as Partial + const commands = (parsed.data.commands ?? {}) as Partial + + const normalizedCommandEvents: HooksNotificationsSettings['commands']['events'] = + createDefaultCommandEvents() + const inputEvents = commands.events ?? {} + for (const name of HOOK_EVENT_NAMES) { + const item = inputEvents[name] + if (item && typeof item === 'object') { + normalizedCommandEvents[name] = { + enabled: Boolean((item as { enabled?: boolean }).enabled), + command: + typeof (item as { command?: string }).command === 'string' + ? (item as { command: string }).command + : '' + } + } + } + + const telegramEvents = sanitizeEvents(telegram.events) + const discordEvents = sanitizeEvents(discord.events) + const confirmoEvents = [...HOOK_EVENT_NAMES] + + return { + telegram: { + ...defaults.telegram, + enabled: Boolean(telegram.enabled), + botToken: telegram.botToken ?? '', + chatId: telegram.chatId ?? '', + threadId: normalizeOptionalString(telegram.threadId), + events: telegramEvents.length ? telegramEvents : [...defaults.telegram.events] + }, + discord: { + ...defaults.discord, + enabled: Boolean(discord.enabled), + webhookUrl: discord.webhookUrl ?? '', + events: discordEvents.length ? discordEvents : [...defaults.discord.events] + }, + confirmo: { + ...defaults.confirmo, + enabled: Boolean(confirmo.enabled), + events: confirmoEvents + }, + commands: { + enabled: Boolean(commands.enabled), + events: normalizedCommandEvents + } + } +} diff --git a/src/main/presenter/hooksNotifications/index.ts b/src/main/presenter/hooksNotifications/index.ts new file mode 100644 index 000000000..7df94037c --- /dev/null +++ b/src/main/presenter/hooksNotifications/index.ts @@ -0,0 +1,733 @@ +import { app } from 'electron' +import log from 'electron-log' +import { spawn } from 'child_process' +import fs from 'fs' +import path from 'path' +import type { IConfigPresenter, ISessionPresenter } from '@shared/presenter' +import { RuntimeHelper } from '@/lib/runtimeHelper' +import { + HookCommandResult, + HookEventName, + HookEventPayload, + HookTestResult, + HooksNotificationsSettings +} from '@shared/hooksNotifications' + +const HOOK_PAYLOAD_VERSION = 1 as const +const COMMAND_TIMEOUT_MS = 30_000 +const PREVIEW_TEXT_LIMIT = 1200 +const DIAGNOSTIC_TEXT_LIMIT = 2000 +const TRUNCATION_SUFFIX = ' ...(truncated)' +const MAX_RETRIES = 2 +const CONFIRMO_HOOK_RELATIVE_PATH = path.join('.confirmo', 'hooks', 'confirmo-hook.js') + +type HookDispatchContext = { + conversationId?: string + messageId?: string + promptPreview?: string + providerId?: string + modelId?: string + agentId?: string | null + workdir?: string | null + tool?: { + callId?: string + name?: string + params?: string + response?: string + error?: string + } + permission?: Record | null + stop?: { + reason?: string + userStop?: boolean + } | null + usage?: Record | null + error?: { + message?: string + stack?: string + } | null + isTest?: boolean +} + +class SerialQueue { + private chain: Promise = Promise.resolve() + + enqueue(task: () => Promise): Promise { + const next = this.chain.then(task, task) + this.chain = next.catch(() => {}) + return next + } +} + +export const truncateText = (value: string, limit: number): string => { + if (!value || limit <= 0) return '' + if (value.length <= limit) return value + const suffix = TRUNCATION_SUFFIX + const sliceLength = Math.max(0, limit - suffix.length) + return value.slice(0, sliceLength) + suffix +} + +export const parseRetryAfterMs = (response: Response, body?: unknown): number | undefined => { + const header = response.headers.get('retry-after') + if (header) { + const parsed = Number(header) + if (!Number.isNaN(parsed) && parsed > 0) { + return parsed < 1000 ? Math.ceil(parsed * 1000) : Math.ceil(parsed) + } + } + + if (body && typeof body === 'object') { + const retryAfter = + typeof (body as { retry_after?: number }).retry_after === 'number' + ? (body as { retry_after: number }).retry_after + : typeof (body as { parameters?: { retry_after?: number } }).parameters?.retry_after === + 'number' + ? (body as { parameters: { retry_after: number } }).parameters.retry_after + : undefined + if (retryAfter && retryAfter > 0) { + return retryAfter < 1000 ? Math.ceil(retryAfter * 1000) : Math.ceil(retryAfter) + } + } + + return undefined +} + +const extractPromptPreview = (content: unknown): string => { + if (typeof content === 'string') return content + if (!content || typeof content !== 'object') return '' + const candidate = content as { + text?: string + content?: Array<{ content?: string }> + } + if (typeof candidate.text === 'string') return candidate.text + if (Array.isArray(candidate.content)) { + return candidate.content.map((block) => block.content || '').join('') + } + return '' +} + +const redactSensitiveText = (text: string, secrets: string[]): string => { + if (!text) return '' + let output = text + for (const secret of secrets) { + if (!secret) continue + output = output.split(secret).join('***REDACTED***') + } + output = output.replace( + /https?:\/\/(discord(?:app)?\.com)\/api\/webhooks\/\S+/gi, + '***REDACTED***' + ) + output = output.replace(/https?:\/\/api\.telegram\.org\/bot\S+/gi, '***REDACTED***') + output = output.replace(/Authorization:\s*Bearer\s+\S+/gi, 'Authorization: ***REDACTED***') + return output +} + +export class HooksNotificationsService { + private readonly runtimeHelper = RuntimeHelper.getInstance() + private readonly telegramQueue = new SerialQueue() + private readonly discordQueue = new SerialQueue() + + constructor( + private readonly configPresenter: IConfigPresenter, + private readonly deps: { + sessionPresenter: ISessionPresenter + resolveWorkspaceContext: ( + conversationId?: string, + modelId?: string + ) => Promise<{ agentWorkspacePath: string | null }> + } + ) {} + + getConfigSnapshot(): HooksNotificationsSettings { + return this.configPresenter.getHooksNotificationsConfig() + } + + dispatchEvent(event: HookEventName, context: HookDispatchContext): void { + queueMicrotask(() => { + this.dispatchEventAsync(event, context).catch((error) => { + log.warn('[HooksNotifications] Dispatch failed:', error) + }) + }) + } + + async testTelegram(): Promise { + const payload = await this.buildPayload('SessionStart', { + isTest: true, + promptPreview: 'Test message' + }) + return await this.sendTelegram(payload, true) + } + + async testDiscord(): Promise { + const payload = await this.buildPayload('SessionStart', { + isTest: true, + promptPreview: 'Test message' + }) + return await this.sendDiscord(payload, true) + } + + async testConfirmo(): Promise { + const payload = await this.buildPayload('SessionStart', { + isTest: true, + promptPreview: 'Test message' + }) + return await this.sendConfirmo(payload) + } + + async testHookCommand(event: HookEventName): Promise { + const payload = await this.buildPayload(event, { + isTest: true, + promptPreview: 'Test message' + }) + const config = this.getConfigSnapshot() + const commandConfig = config.commands.events[event] + if (!commandConfig?.command?.trim()) { + return { + success: false, + durationMs: 0, + error: 'Command is not configured' + } + } + const result = await this.executeHookCommand(commandConfig.command, payload) + return { + success: result.success, + durationMs: result.durationMs, + exitCode: result.exitCode ?? undefined, + stdout: result.stdout, + stderr: result.stderr, + error: result.error + } + } + + private async dispatchEventAsync( + event: HookEventName, + context: HookDispatchContext + ): Promise { + const config = this.getConfigSnapshot() + const payload = await this.buildPayload(event, context) + + if (config.commands.enabled) { + const commandConfig = config.commands.events[event] + if (commandConfig?.enabled && commandConfig.command?.trim()) { + void this.executeHookCommand(commandConfig.command, payload).catch((error) => { + log.warn('[HooksNotifications] Command hook failed:', error) + }) + } + } + + if (config.telegram.enabled && config.telegram.events.includes(event)) { + void this.sendTelegram(payload, false).catch((error) => { + log.warn('[HooksNotifications] Telegram hook failed:', error) + }) + } + + if (config.discord.enabled && config.discord.events.includes(event)) { + void this.sendDiscord(payload, false).catch((error) => { + log.warn('[HooksNotifications] Discord hook failed:', error) + }) + } + + if (config.confirmo.enabled && config.confirmo.events.includes(event)) { + void this.sendConfirmo(payload).catch((error) => { + log.warn('[HooksNotifications] Confirmo hook failed:', error) + }) + } + } + + getConfirmoHookStatus(): { available: boolean; path: string } { + const hookPath = this.getConfirmoHookPath() + return { available: this.isConfirmoHookAvailable(hookPath), path: hookPath } + } + + private async buildPayload( + event: HookEventName, + context: HookDispatchContext + ): Promise { + const now = new Date().toISOString() + let conversationId = context.conversationId + let providerId = context.providerId + let modelId = context.modelId + let agentId = context.agentId + let workdir = context.workdir + + if (conversationId && (!providerId || !modelId)) { + try { + const conversation = await this.deps.sessionPresenter.getConversation(conversationId) + providerId = providerId ?? conversation.settings.providerId + modelId = modelId ?? conversation.settings.modelId + if (!agentId && conversation.settings.providerId === 'acp') { + agentId = conversation.settings.modelId + } + } catch (error) { + log.warn('[HooksNotifications] Failed to load conversation info:', error) + } + } + + if (conversationId && !workdir) { + try { + const resolved = await this.deps.resolveWorkspaceContext(conversationId, modelId) + workdir = resolved.agentWorkspacePath ?? null + } catch (error) { + log.warn('[HooksNotifications] Failed to resolve workdir:', error) + } + } + + let promptPreview = context.promptPreview + if (!promptPreview && context.messageId) { + try { + const message = await this.deps.sessionPresenter.getMessage(context.messageId) + promptPreview = extractPromptPreview(message.content) + } catch (error) { + log.warn('[HooksNotifications] Failed to read message for preview:', error) + } + } + + const hasUser = Boolean(promptPreview || context.messageId) + const payload: HookEventPayload = { + payloadVersion: HOOK_PAYLOAD_VERSION, + event, + time: now, + isTest: Boolean(context.isTest), + app: { + version: app.getVersion(), + platform: process.platform + }, + session: { + conversationId, + agentId: agentId ?? null, + workdir: workdir ?? null, + providerId, + modelId + }, + user: hasUser + ? { + messageId: context.messageId, + promptPreview: truncateText(promptPreview || '', PREVIEW_TEXT_LIMIT) + } + : null, + tool: context.tool + ? { + callId: context.tool.callId, + name: context.tool.name, + paramsPreview: context.tool.params + ? truncateText(context.tool.params, PREVIEW_TEXT_LIMIT) + : undefined, + responsePreview: context.tool.response + ? truncateText(context.tool.response, PREVIEW_TEXT_LIMIT) + : undefined, + error: context.tool.error + ? truncateText(context.tool.error, PREVIEW_TEXT_LIMIT) + : undefined + } + : null, + permission: context.permission ?? null, + stop: context.stop ?? null, + usage: context.usage ?? null, + error: context.error ?? null + } + + return payload + } + + private escapeTelegramHtml(value: string): string { + return value.replace(/&/g, '&').replace(//g, '>') + } + + private formatNotificationText(payload: HookEventPayload): string { + const lines: string[] = [] + const title = `DeepChat${payload.isTest ? ' Test' : ''}` + const pushLine = (label: string, value: string) => { + lines.push(`${this.escapeTelegramHtml(label)}: ${this.escapeTelegramHtml(value)}`) + } + + lines.push(`${this.escapeTelegramHtml(title)}`) + pushLine('Event', payload.event) + if (payload.tool?.name) { + pushLine('Tool', payload.tool.name) + } + if (payload.stop?.reason) { + pushLine('Stop', payload.stop.reason) + } + if (payload.error?.message) { + pushLine('Error', truncateText(payload.error.message, 160)) + } + pushLine('Time', payload.time) + + return lines.join('\n') + } + + private resolveCommandCwd(workdir?: string | null): string { + if (workdir && fs.existsSync(workdir)) { + try { + if (fs.statSync(workdir).isDirectory()) return workdir + } catch { + return process.cwd() + } + } + return process.cwd() + } + + private async executeHookCommand( + command: string, + payload: HookEventPayload, + options?: { args?: string[]; shell?: boolean; stdinPayload?: unknown } + ): Promise { + const start = Date.now() + const cwd = this.resolveCommandCwd(payload.session.workdir) + const env: Record = { + ...process.env, + DEEPCHAT_HOOK_EVENT: payload.event, + ...(payload.session.conversationId + ? { DEEPCHAT_CONVERSATION_ID: payload.session.conversationId } + : {}), + ...(payload.session.workdir ? { DEEPCHAT_WORKDIR: payload.session.workdir } : {}) + } + + return await new Promise((resolve) => { + let stdout = '' + let stderr = '' + let finished = false + let timedOut = false + + const child = spawn(command, options?.args ?? [], { + shell: options?.shell ?? true, + cwd, + env, + windowsHide: true + }) + + const finalize = (result: HookCommandResult) => { + if (finished) return + finished = true + resolve(result) + } + + const timeout = setTimeout(() => { + timedOut = true + try { + child.kill('SIGKILL') + } catch { + // ignore + } + }, COMMAND_TIMEOUT_MS) + + child.on('error', (error) => { + clearTimeout(timeout) + finalize({ + success: false, + durationMs: Date.now() - start, + exitCode: null, + stdout: truncateText(stdout, DIAGNOSTIC_TEXT_LIMIT), + stderr: truncateText(stderr, DIAGNOSTIC_TEXT_LIMIT), + error: error instanceof Error ? error.message : String(error) + }) + }) + + child.stdout?.on('data', (chunk) => { + stdout += String(chunk) + }) + child.stderr?.on('data', (chunk) => { + stderr += String(chunk) + }) + + child.on('close', (code) => { + clearTimeout(timeout) + const secrets = [payload.session.conversationId ?? '', payload.session.workdir ?? ''] + const redactedStdout = redactSensitiveText( + truncateText(stdout, DIAGNOSTIC_TEXT_LIMIT), + secrets + ) + const redactedStderr = redactSensitiveText( + truncateText(stderr, DIAGNOSTIC_TEXT_LIMIT), + secrets + ) + finalize({ + success: !timedOut && code === 0, + durationMs: Date.now() - start, + exitCode: code ?? null, + stdout: redactedStdout, + stderr: redactedStderr, + error: timedOut ? 'Command timed out' : code === 0 ? undefined : 'Command failed' + }) + }) + + try { + const inputPayload = options?.stdinPayload ?? payload + child.stdin?.write(JSON.stringify(inputPayload)) + child.stdin?.end() + } catch (error) { + try { + child.stdin?.end() + } catch { + // ignore + } + try { + child.kill('SIGKILL') + } catch { + // ignore + } + clearTimeout(timeout) + finalize({ + success: false, + durationMs: Date.now() - start, + exitCode: null, + stdout: truncateText(stdout, DIAGNOSTIC_TEXT_LIMIT), + stderr: truncateText(stderr, DIAGNOSTIC_TEXT_LIMIT), + error: error instanceof Error ? error.message : String(error) + }) + } + }) + } + + private async sendTelegram(payload: HookEventPayload, isTest: boolean): Promise { + const config = this.getConfigSnapshot().telegram + const start = Date.now() + if (!config.botToken || !config.chatId) { + return { success: false, durationMs: 0, error: 'Missing Telegram config' } + } + + const text = this.formatNotificationText(payload) + const url = `https://api.telegram.org/bot${config.botToken}/sendMessage` + const body: Record = { + chat_id: config.chatId, + text, + parse_mode: 'HTML' + } + if (config.threadId) { + const threadValue = Number(config.threadId) + if (!Number.isNaN(threadValue)) { + body.message_thread_id = threadValue + } + } + + const task = async (): Promise => { + let lastError: string | undefined + let statusCode: number | undefined + let retryAfterMs: number | undefined + + for (let attempt = 0; attempt <= MAX_RETRIES; attempt += 1) { + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(15_000) + }) + statusCode = response.status + const json = (await response.json().catch(() => ({}))) as Record + if (response.ok) { + return { success: true, durationMs: Date.now() - start, statusCode } + } + if (response.status === 429) { + retryAfterMs = parseRetryAfterMs(response, json) + if (retryAfterMs && attempt < MAX_RETRIES) { + await new Promise((r) => setTimeout(r, retryAfterMs)) + continue + } + } + lastError = JSON.stringify(json) + break + } catch (error) { + lastError = error instanceof Error ? error.message : String(error) + break + } + } + + return { + success: false, + durationMs: Date.now() - start, + statusCode, + retryAfterMs, + error: lastError || 'Telegram request failed' + } + } + + const result = isTest ? await task() : await this.telegramQueue.enqueue(task) + return result + } + + private async sendDiscord(payload: HookEventPayload, isTest: boolean): Promise { + const config = this.getConfigSnapshot().discord + const start = Date.now() + if (!config.webhookUrl) { + return { success: false, durationMs: 0, error: 'Missing Discord webhookUrl' } + } + + const embed = this.buildDiscordEmbed(payload) + let url: URL + try { + url = new URL(config.webhookUrl) + } catch { + throw new Error('Invalid Discord webhook URL') + } + const body = { + embeds: [embed], + allowed_mentions: { parse: [] as string[] } + } + + const task = async (): Promise => { + let lastError: string | undefined + let statusCode: number | undefined + let retryAfterMs: number | undefined + + for (let attempt = 0; attempt <= MAX_RETRIES; attempt += 1) { + try { + const response = await fetch(url.toString(), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(15_000) + }) + statusCode = response.status + const json = (await response.json().catch(() => ({}))) as Record + if (response.ok) { + return { success: true, durationMs: Date.now() - start, statusCode } + } + if (response.status === 429) { + retryAfterMs = parseRetryAfterMs(response, json) + if (retryAfterMs && attempt < MAX_RETRIES) { + await new Promise((r) => setTimeout(r, retryAfterMs)) + continue + } + } + lastError = JSON.stringify(json) + break + } catch (error) { + lastError = error instanceof Error ? error.message : String(error) + break + } + } + + return { + success: false, + durationMs: Date.now() - start, + statusCode, + retryAfterMs, + error: lastError || 'Discord request failed' + } + } + + const result = isTest ? await task() : await this.discordQueue.enqueue(task) + return result + } + + private buildDiscordEmbed(payload: HookEventPayload): { + title: string + fields: Array<{ name: string; value: string; inline?: boolean }> + timestamp: string + } { + const fields: Array<{ name: string; value: string; inline?: boolean }> = [ + { name: 'Event', value: payload.event, inline: true } + ] + + if (payload.session.conversationId) { + fields.push({ + name: 'Conversation', + value: truncateText(payload.session.conversationId, 256) + }) + } + if (payload.tool?.name) { + fields.push({ + name: 'Tool', + value: truncateText(payload.tool.name, 128), + inline: true + }) + } + if (payload.stop?.reason) { + fields.push({ + name: 'Stop', + value: truncateText(payload.stop.reason, 128), + inline: true + }) + } + if (payload.error?.message) { + fields.push({ + name: 'Error', + value: truncateText(payload.error.message, 512) + }) + } + + return { + title: `DeepChat${payload.isTest ? ' Test' : ''}`, + fields, + timestamp: payload.time + } + } + + private getConfirmoHookPath(): string { + return path.join(app.getPath('home'), CONFIRMO_HOOK_RELATIVE_PATH) + } + + private isConfirmoHookAvailable(hookPath?: string): boolean { + const targetPath = hookPath ?? this.getConfirmoHookPath() + try { + return fs.existsSync(targetPath) && fs.statSync(targetPath).isFile() + } catch { + return false + } + } + + private resolveConfirmoNodeCommand(): string { + this.runtimeHelper.initializeRuntimes() + return this.runtimeHelper.replaceWithRuntimeCommand('node', true) + } + + private async sendConfirmo(payload: HookEventPayload): Promise { + const { available, path: hookPath } = this.getConfirmoHookStatus() + if (!available) { + return { success: false, durationMs: 0, error: 'Confirmo hook not found' } + } + + const nodeCommand = this.resolveConfirmoNodeCommand() + const result = await this.executeHookCommand(nodeCommand, payload, { + args: [hookPath], + shell: false, + stdinPayload: this.buildConfirmoInput(payload) + }) + + return { + success: result.success, + durationMs: result.durationMs, + exitCode: result.exitCode ?? undefined, + stdout: result.stdout, + stderr: result.stderr, + error: result.error + } + } + + private buildConfirmoInput(payload: HookEventPayload): Record { + const sessionId = + payload.session.conversationId ?? + (payload.isTest ? 'test' : undefined) ?? + payload.user?.messageId ?? + payload.tool?.callId ?? + 'unknown' + + const toolInput = this.resolveConfirmoToolInput(payload.tool?.paramsPreview) + const reason = payload.stop?.reason ?? payload.error?.message + + return { + session_id: sessionId, + cwd: payload.session.workdir ?? undefined, + hook_event_name: payload.event, + tool_name: payload.tool?.name, + tool_input: toolInput, + prompt: payload.user?.promptPreview, + source: 'deepchat', + reason + } + } + + private resolveConfirmoToolInput(paramsPreview?: string): Record | undefined { + if (!paramsPreview) return undefined + try { + const parsed = JSON.parse(paramsPreview) as Record + if (parsed && typeof parsed === 'object') { + return parsed + } + } catch { + // ignore + } + return { preview: truncateText(paramsPreview, 400) } + } +} diff --git a/src/main/presenter/index.ts b/src/main/presenter/index.ts index a44432eca..477d0dc45 100644 --- a/src/main/presenter/index.ts +++ b/src/main/presenter/index.ts @@ -61,6 +61,7 @@ import { SearchPresenter } from './searchPresenter' import { ConversationExporterService } from './exporter' import { SkillPresenter } from './skillPresenter' import { SkillSyncPresenter } from './skillSyncPresenter' +import { HooksNotificationsService } from './hooksNotifications' // IPC调用上下文接口 interface IPCCallContext { @@ -109,6 +110,7 @@ export class Presenter implements IPresenter { lifecycleManager: ILifecycleManager skillPresenter: ISkillPresenter skillSyncPresenter: ISkillSyncPresenter + hooksNotifications: HooksNotificationsService filePermissionService: FilePermissionService settingsPermissionService: SettingsPermissionService @@ -198,6 +200,12 @@ export class Presenter implements IPresenter { // Initialize Skill Sync presenter this.skillSyncPresenter = new SkillSyncPresenter(this.skillPresenter, this.configPresenter) + // Initialize Hooks & Notifications service + this.hooksNotifications = new HooksNotificationsService(this.configPresenter, { + sessionPresenter: this.sessionPresenter, + resolveWorkspaceContext: this.sessionManager.resolveWorkspaceContext.bind(this.sessionManager) + }) + this.setupEventBus() // 设置事件总线监听 } diff --git a/src/renderer/settings/components/NotificationsHooksSettings.vue b/src/renderer/settings/components/NotificationsHooksSettings.vue new file mode 100644 index 000000000..7e835c1a7 --- /dev/null +++ b/src/renderer/settings/components/NotificationsHooksSettings.vue @@ -0,0 +1,841 @@ + + + diff --git a/src/renderer/settings/main.ts b/src/renderer/settings/main.ts index 0b232988c..679f19971 100644 --- a/src/renderer/settings/main.ts +++ b/src/renderer/settings/main.ts @@ -82,6 +82,16 @@ const router = createRouter({ position: 6 } }, + { + path: '/notifications-hooks', + name: 'settings-notifications-hooks', + component: () => import('./components/NotificationsHooksSettings.vue'), + meta: { + titleKey: 'routes.settings-notifications-hooks', + icon: 'lucide:bell', + position: 6.5 + } + }, { path: '/skills', name: 'settings-skills', diff --git a/src/renderer/src/i18n/da-DK/routes.json b/src/renderer/src/i18n/da-DK/routes.json index e895b716a..4627e8d05 100644 --- a/src/renderer/src/i18n/da-DK/routes.json +++ b/src/renderer/src/i18n/da-DK/routes.json @@ -14,5 +14,6 @@ "settings-prompt": "Prompter", "settings-mcp-market": "MCP-marked", "settings-acp": "ACP-agenter", - "settings-skills": "Skills" + "settings-skills": "Skills", + "settings-notifications-hooks": "Notifikationer og Hooks" } diff --git a/src/renderer/src/i18n/da-DK/settings.json b/src/renderer/src/i18n/da-DK/settings.json index 52d3ea6e8..35e5ca56c 100644 --- a/src/renderer/src/i18n/da-DK/settings.json +++ b/src/renderer/src/i18n/da-DK/settings.json @@ -1224,5 +1224,59 @@ "title": "Eksterne værktøjer" }, "title": "Færdighedsstyring" + }, + "notificationsHooks": { + "commands": { + "commandPlaceholder": "Kommandoen der skal udføres", + "description": "Udfør kommandoen for hver begivenhed.", + "hint": "Skriv payload som JSON til stdin.", + "title": "Hooks kommandoer" + }, + "confirmo": { + "description": "Send notifikationer via Confirmo Hook (standard sender alle begivenheder).", + "title": "Confirmo", + "unavailable": "Confirmo Hook ikke fundet: {path}" + }, + "description": "Konfigurer webhook-notifikationer og livscyklus-hooks.", + "discord": { + "description": "Send notifikationer via Discord webhook.", + "title": "Discord", + "webhookUrl": "Webhook-URL", + "webhookUrlPlaceholder": "https://discord.com/api/webhooks/..." + }, + "events": { + "PermissionRequest": "Tilladelsesforespørgsel", + "PostToolUse": "Efter værktøjskald", + "PostToolUseFailure": "Værktøjsopkald mislykkedes", + "PreToolUse": "Før værktøjskald", + "SessionEnd": "Samtalen er slut", + "SessionStart": "Samtalen starter", + "Stop": "stop", + "UserPromptSubmit": "Brugerindsendelse", + "title": "begivenhed" + }, + "telegram": { + "botToken": "Bot-token", + "botTokenPlaceholder": "Telegram Bot Token", + "chatId": "Chat-ID", + "chatIdPlaceholder": "For eksempel 123456789", + "description": "Send notifikationer via Telegram Bot.", + "threadId": "Trådid (valgfri)", + "threadIdPlaceholder": "Valgfri tråd-ID", + "title": "Telegram" + }, + "test": { + "button": "Test", + "duration": "{ms} ms", + "exitCode": "Afslutningskode {code}", + "failed": "mislykkelse", + "retryAfter": "Prøv igen om {ms} ms", + "statusCode": "HTTP {code}", + "stderr": "stderr", + "stdout": "stdout", + "success": "succes", + "testing": "Tester..." + }, + "title": "Notifikationer og Hooks" } } diff --git a/src/renderer/src/i18n/en-US/routes.json b/src/renderer/src/i18n/en-US/routes.json index 0b9c13798..22c2af75b 100644 --- a/src/renderer/src/i18n/en-US/routes.json +++ b/src/renderer/src/i18n/en-US/routes.json @@ -14,5 +14,6 @@ "settings-prompt": "Prompts", "settings-mcp-market": "MCP Market", "settings-acp": "ACP Agents", - "settings-skills": "Skills" + "settings-skills": "Skills", + "settings-notifications-hooks": "Notifications & Hooks" } diff --git a/src/renderer/src/i18n/en-US/settings.json b/src/renderer/src/i18n/en-US/settings.json index 6d32af99d..d42bd3939 100644 --- a/src/renderer/src/i18n/en-US/settings.json +++ b/src/renderer/src/i18n/en-US/settings.json @@ -64,6 +64,60 @@ "fileMaxSize": "File Maximum Size", "fileMaxSizeHint": "Limits the maximum size of a single uploaded file" }, + "notificationsHooks": { + "title": "Notifications & Hooks", + "description": "Configure webhook notifications and lifecycle hooks.", + "events": { + "title": "Events", + "SessionStart": "Session Start", + "UserPromptSubmit": "User Prompt Submit", + "PreToolUse": "Pre Tool Use", + "PostToolUse": "Post Tool Use", + "PostToolUseFailure": "Tool Use Failure", + "PermissionRequest": "Permission Request", + "Stop": "Stop", + "SessionEnd": "Session End" + }, + "telegram": { + "title": "Telegram", + "description": "Send notifications via Telegram bot.", + "botToken": "Bot Token", + "botTokenPlaceholder": "Telegram bot token", + "chatId": "Chat ID", + "chatIdPlaceholder": "e.g. 123456789", + "threadId": "Thread ID (optional)", + "threadIdPlaceholder": "Optional thread ID" + }, + "discord": { + "title": "Discord", + "description": "Send notifications via Discord webhook.", + "webhookUrl": "Webhook URL", + "webhookUrlPlaceholder": "https://discord.com/api/webhooks/..." + }, + "confirmo": { + "title": "Confirmo", + "description": "Send notifications via Confirmo hook (all events).", + "unavailable": "Confirmo hook not found: {path}" + }, + "commands": { + "title": "Hooks Commands", + "description": "Run a command for each event.", + "hint": "The payload is sent to stdin as JSON.", + "commandPlaceholder": "Command to run" + }, + "test": { + "button": "Test", + "testing": "Testing...", + "success": "Success", + "failed": "Failed", + "duration": "{ms} ms", + "statusCode": "HTTP {code}", + "exitCode": "Exit {code}", + "retryAfter": "Retry after {ms} ms", + "stdout": "stdout", + "stderr": "stderr" + } + }, "data": { "title": "Data Settings", "syncEnable": "Enable Data Sync", diff --git a/src/renderer/src/i18n/fa-IR/routes.json b/src/renderer/src/i18n/fa-IR/routes.json index c1dff647d..7a357ab11 100644 --- a/src/renderer/src/i18n/fa-IR/routes.json +++ b/src/renderer/src/i18n/fa-IR/routes.json @@ -14,5 +14,6 @@ "settings-prompt": "مدیریت پرامپت‌ها", "settings-mcp-market": "بازار MCP", "settings-acp": "نماینده ACP", - "settings-skills": "Skills" + "settings-skills": "Skills", + "settings-notifications-hooks": "اعلان‌ها و هوک‌ها" } diff --git a/src/renderer/src/i18n/fa-IR/settings.json b/src/renderer/src/i18n/fa-IR/settings.json index 8ba22376b..6e239307f 100644 --- a/src/renderer/src/i18n/fa-IR/settings.json +++ b/src/renderer/src/i18n/fa-IR/settings.json @@ -64,6 +64,60 @@ "fileMaxSize": "حداکثر اندازه فایل", "fileMaxSizeHint": "حداکثر اندازه یک فایل قابل آپلود را محدود می‌کند" }, + "notificationsHooks": { + "title": "اعلان‌ها و هوک‌ها", + "description": "پیکربندی اعلان‌های وب‌هوک و هوک‌های چرخهٔ عمر.", + "events": { + "title": "رویدادها", + "SessionStart": "شروع نشست", + "UserPromptSubmit": "ارسال پرامپت کاربر", + "PreToolUse": "پیش از استفاده از ابزار", + "PostToolUse": "پس از استفاده از ابزار", + "PostToolUseFailure": "شکست استفاده از ابزار", + "PermissionRequest": "درخواست اجازه", + "Stop": "توقف", + "SessionEnd": "پایان نشست" + }, + "telegram": { + "title": "Telegram", + "description": "ارسال اعلان‌ها از طریق ربات تلگرام.", + "botToken": "توکن ربات", + "botTokenPlaceholder": "توکن ربات تلگرام", + "chatId": "شناسهٔ گفتگو", + "chatIdPlaceholder": "مثلاً 123456789", + "threadId": "شناسهٔ رشته (اختیاری)", + "threadIdPlaceholder": "شناسهٔ رشتهٔ اختیاری" + }, + "discord": { + "title": "Discord", + "description": "ارسال اعلان‌ها از طریق وب‌هوک دیسکورد.", + "webhookUrl": "نشانی وب‌هوک", + "webhookUrlPlaceholder": "https://discord.com/api/webhooks/..." + }, + "confirmo": { + "title": "Confirmo", + "description": "ارسال اعلان‌ها از طریق هوک Confirmo (همهٔ رویدادها).", + "unavailable": "هوک Confirmo یافت نشد: {path}" + }, + "commands": { + "title": "فرمان‌های هوک", + "description": "برای هر رویداد یک فرمان اجرا شود.", + "hint": "محتوا به‌صورت JSON به stdin فرستاده می‌شود.", + "commandPlaceholder": "فرمان برای اجرا" + }, + "test": { + "button": "آزمون", + "testing": "در حال آزمون...", + "success": "موفق", + "failed": "ناموفق", + "duration": "{ms} ms", + "statusCode": "HTTP {code}", + "exitCode": "کد خروج {code}", + "retryAfter": "تلاش دوباره پس از {ms} ms", + "stdout": "stdout", + "stderr": "stderr" + } + }, "data": { "title": "تنظیمات داده", "syncEnable": "روشن کردن همگام‌سازی داده", diff --git a/src/renderer/src/i18n/fr-FR/routes.json b/src/renderer/src/i18n/fr-FR/routes.json index 44db0798e..bb8d802a1 100644 --- a/src/renderer/src/i18n/fr-FR/routes.json +++ b/src/renderer/src/i18n/fr-FR/routes.json @@ -14,5 +14,6 @@ "settings-prompt": "Gestion des Prompts", "settings-mcp-market": "Marché MCP", "settings-acp": "Agent ACP", - "settings-skills": "Skills" + "settings-skills": "Skills", + "settings-notifications-hooks": "Notifications et hooks" } diff --git a/src/renderer/src/i18n/fr-FR/settings.json b/src/renderer/src/i18n/fr-FR/settings.json index 6f6c00d91..ea3af3635 100644 --- a/src/renderer/src/i18n/fr-FR/settings.json +++ b/src/renderer/src/i18n/fr-FR/settings.json @@ -64,6 +64,60 @@ "fileMaxSize": "Taille maximale du fichier", "fileMaxSizeHint": "Limite la taille maximale d'un fichier à télécharger" }, + "notificationsHooks": { + "title": "Notifications et hooks", + "description": "Configurer les notifications webhook et les hooks du cycle de vie.", + "events": { + "title": "Événements", + "SessionStart": "Début de session", + "UserPromptSubmit": "Envoi du prompt utilisateur", + "PreToolUse": "Avant l’utilisation d’un outil", + "PostToolUse": "Après l’utilisation d’un outil", + "PostToolUseFailure": "Échec d’utilisation d’un outil", + "PermissionRequest": "Demande d’autorisation", + "Stop": "Arrêt", + "SessionEnd": "Fin de session" + }, + "telegram": { + "title": "Telegram", + "description": "Envoyer des notifications via un bot Telegram.", + "botToken": "Jeton du bot", + "botTokenPlaceholder": "Jeton du bot Telegram", + "chatId": "ID du chat", + "chatIdPlaceholder": "ex. 123456789", + "threadId": "ID du fil (optionnel)", + "threadIdPlaceholder": "ID du fil optionnel" + }, + "discord": { + "title": "Discord", + "description": "Envoyer des notifications via un webhook Discord.", + "webhookUrl": "URL du webhook", + "webhookUrlPlaceholder": "https://discord.com/api/webhooks/..." + }, + "confirmo": { + "title": "Confirmo", + "description": "Envoyer des notifications via le hook Confirmo (tous les événements).", + "unavailable": "Hook Confirmo introuvable : {path}" + }, + "commands": { + "title": "Commandes de hooks", + "description": "Exécuter une commande pour chaque événement.", + "hint": "La charge utile est envoyée sur stdin au format JSON.", + "commandPlaceholder": "Commande à exécuter" + }, + "test": { + "button": "Tester", + "testing": "Test en cours...", + "success": "Réussi", + "failed": "Échoué", + "duration": "{ms} ms", + "statusCode": "HTTP {code}", + "exitCode": "Code de sortie {code}", + "retryAfter": "Réessayer dans {ms} ms", + "stdout": "stdout", + "stderr": "stderr" + } + }, "data": { "title": "Paramètres des données", "syncEnable": "Activer la synchronisation des données", diff --git a/src/renderer/src/i18n/he-IL/routes.json b/src/renderer/src/i18n/he-IL/routes.json index 167fc7c6a..9e6190e39 100644 --- a/src/renderer/src/i18n/he-IL/routes.json +++ b/src/renderer/src/i18n/he-IL/routes.json @@ -14,5 +14,6 @@ "settings-prompt": "הנחיות (Prompts)", "settings-mcp-market": "חנות MCP", "settings-acp": "סוכני ACP", - "settings-skills": "Skills" + "settings-skills": "Skills", + "settings-notifications-hooks": "התראות ו‑Hooks" } diff --git a/src/renderer/src/i18n/he-IL/settings.json b/src/renderer/src/i18n/he-IL/settings.json index b1238cdac..a6fc24c3c 100644 --- a/src/renderer/src/i18n/he-IL/settings.json +++ b/src/renderer/src/i18n/he-IL/settings.json @@ -64,6 +64,60 @@ "fileMaxSize": "גודל קובץ מקסימלי", "fileMaxSizeHint": "מגביל את הגודל המקסימלי של קובץ בודד המועלה" }, + "notificationsHooks": { + "title": "התראות ו‑Hooks", + "description": "הגדרת התראות webhook ו‑hooks של מחזור החיים.", + "events": { + "title": "אירועים", + "SessionStart": "תחילת סשן", + "UserPromptSubmit": "שליחת פרומפט משתמש", + "PreToolUse": "לפני שימוש בכלי", + "PostToolUse": "אחרי שימוש בכלי", + "PostToolUseFailure": "כשל בשימוש בכלי", + "PermissionRequest": "בקשת הרשאה", + "Stop": "עצירה", + "SessionEnd": "סיום סשן" + }, + "telegram": { + "title": "Telegram", + "description": "שליחת התראות דרך בוט טלגרם.", + "botToken": "טוקן בוט", + "botTokenPlaceholder": "טוקן בוט טלגרם", + "chatId": "מזהה צ׳אט", + "chatIdPlaceholder": "למשל 123456789", + "threadId": "מזהה שרשור (אופציונלי)", + "threadIdPlaceholder": "מזהה שרשור אופציונלי" + }, + "discord": { + "title": "Discord", + "description": "שליחת התראות דרך webhook של Discord.", + "webhookUrl": "URL של Webhook", + "webhookUrlPlaceholder": "https://discord.com/api/webhooks/..." + }, + "confirmo": { + "title": "Confirmo", + "description": "שליחת התראות דרך hook של Confirmo (כל האירועים).", + "unavailable": "ה‑hook של Confirmo לא נמצא: {path}" + }, + "commands": { + "title": "פקודות Hooks", + "description": "הרצת פקודה עבור כל אירוע.", + "hint": "ה‑payload נשלח ל‑stdin כ‑JSON.", + "commandPlaceholder": "פקודה להרצה" + }, + "test": { + "button": "בדיקה", + "testing": "בבדיקה...", + "success": "הצלחה", + "failed": "נכשל", + "duration": "{ms} ms", + "statusCode": "HTTP {code}", + "exitCode": "קוד יציאה {code}", + "retryAfter": "נסה שוב בעוד {ms} ms", + "stdout": "stdout", + "stderr": "stderr" + } + }, "data": { "title": "הגדרות נתונים", "syncEnable": "הפעל סנכרון נתונים", diff --git a/src/renderer/src/i18n/ja-JP/routes.json b/src/renderer/src/i18n/ja-JP/routes.json index d538ffc04..5828db2be 100644 --- a/src/renderer/src/i18n/ja-JP/routes.json +++ b/src/renderer/src/i18n/ja-JP/routes.json @@ -14,5 +14,6 @@ "settings-prompt": "プロンプト管理", "settings-mcp-market": "MCP市場", "settings-acp": "ACPエージェント", - "settings-skills": "Skills" + "settings-skills": "Skills", + "settings-notifications-hooks": "通知とフック" } diff --git a/src/renderer/src/i18n/ja-JP/settings.json b/src/renderer/src/i18n/ja-JP/settings.json index 0647d996c..b023e8a00 100644 --- a/src/renderer/src/i18n/ja-JP/settings.json +++ b/src/renderer/src/i18n/ja-JP/settings.json @@ -64,6 +64,60 @@ "fileMaxSize": "ファイルの最大サイズ", "fileMaxSizeHint": "アップロードできるファイルの最大サイズを制限します" }, + "notificationsHooks": { + "title": "通知とフック", + "description": "Webhook 通知とライフサイクルフックを設定します。", + "events": { + "title": "イベント", + "SessionStart": "セッション開始", + "UserPromptSubmit": "ユーザープロンプト送信", + "PreToolUse": "ツール使用前", + "PostToolUse": "ツール使用後", + "PostToolUseFailure": "ツール使用失敗", + "PermissionRequest": "権限リクエスト", + "Stop": "停止", + "SessionEnd": "セッション終了" + }, + "telegram": { + "title": "Telegram", + "description": "Telegram ボットで通知を送信します。", + "botToken": "ボットトークン", + "botTokenPlaceholder": "Telegram ボットトークン", + "chatId": "チャット ID", + "chatIdPlaceholder": "例: 123456789", + "threadId": "スレッド ID(任意)", + "threadIdPlaceholder": "任意のスレッド ID" + }, + "discord": { + "title": "Discord", + "description": "Discord の Webhook で通知を送信します。", + "webhookUrl": "Webhook URL", + "webhookUrlPlaceholder": "https://discord.com/api/webhooks/..." + }, + "confirmo": { + "title": "Confirmo", + "description": "Confirmo フックで通知を送信します(全イベント)。", + "unavailable": "Confirmo フックが見つかりません: {path}" + }, + "commands": { + "title": "フックコマンド", + "description": "各イベントごとにコマンドを実行します。", + "hint": "ペイロードは JSON として stdin に送信されます。", + "commandPlaceholder": "実行するコマンド" + }, + "test": { + "button": "テスト", + "testing": "テスト中...", + "success": "成功", + "failed": "失敗", + "duration": "{ms} ms", + "statusCode": "HTTP {code}", + "exitCode": "終了コード {code}", + "retryAfter": "{ms} ms 後に再試行", + "stdout": "stdout", + "stderr": "stderr" + } + }, "data": { "title": "データ設定", "syncEnable": "データ同期を有効にする", diff --git a/src/renderer/src/i18n/ko-KR/routes.json b/src/renderer/src/i18n/ko-KR/routes.json index d295549d4..6a5477c96 100644 --- a/src/renderer/src/i18n/ko-KR/routes.json +++ b/src/renderer/src/i18n/ko-KR/routes.json @@ -14,5 +14,6 @@ "settings-prompt": "프롬프트 관리", "settings-mcp-market": "MCP 시장", "settings-acp": "ACP 프록시", - "settings-skills": "Skills" + "settings-skills": "Skills", + "settings-notifications-hooks": "알림 및 훅" } diff --git a/src/renderer/src/i18n/ko-KR/settings.json b/src/renderer/src/i18n/ko-KR/settings.json index abaa101e9..1360bfbb0 100644 --- a/src/renderer/src/i18n/ko-KR/settings.json +++ b/src/renderer/src/i18n/ko-KR/settings.json @@ -64,6 +64,60 @@ "fileMaxSize": "파일 최대 크기", "fileMaxSizeHint": "단일 파일 업로드의 최대 크기를 제한합니다" }, + "notificationsHooks": { + "title": "알림 및 훅", + "description": "웹훅 알림과 라이프사이클 훅을 설정합니다.", + "events": { + "title": "이벤트", + "SessionStart": "세션 시작", + "UserPromptSubmit": "사용자 프롬프트 전송", + "PreToolUse": "도구 사용 전", + "PostToolUse": "도구 사용 후", + "PostToolUseFailure": "도구 사용 실패", + "PermissionRequest": "권한 요청", + "Stop": "중지", + "SessionEnd": "세션 종료" + }, + "telegram": { + "title": "Telegram", + "description": "Telegram 봇으로 알림을 전송합니다.", + "botToken": "봇 토큰", + "botTokenPlaceholder": "Telegram 봇 토큰", + "chatId": "채팅 ID", + "chatIdPlaceholder": "예: 123456789", + "threadId": "스레드 ID(선택 사항)", + "threadIdPlaceholder": "선택 사항 스레드 ID" + }, + "discord": { + "title": "Discord", + "description": "Discord 웹훅으로 알림을 전송합니다.", + "webhookUrl": "웹훅 URL", + "webhookUrlPlaceholder": "https://discord.com/api/webhooks/..." + }, + "confirmo": { + "title": "Confirmo", + "description": "Confirmo 훅으로 알림을 전송합니다(모든 이벤트).", + "unavailable": "Confirmo 훅을 찾을 수 없습니다: {path}" + }, + "commands": { + "title": "훅 명령", + "description": "각 이벤트마다 명령을 실행합니다.", + "hint": "페이로드가 JSON으로 stdin에 전송됩니다.", + "commandPlaceholder": "실행할 명령" + }, + "test": { + "button": "테스트", + "testing": "테스트 중...", + "success": "성공", + "failed": "실패", + "duration": "{ms} ms", + "statusCode": "HTTP {code}", + "exitCode": "종료 코드 {code}", + "retryAfter": "{ms} ms 후 재시도", + "stdout": "stdout", + "stderr": "stderr" + } + }, "data": { "title": "데이터 설정", "syncEnable": "데이터 동기화 활성화", diff --git a/src/renderer/src/i18n/pt-BR/routes.json b/src/renderer/src/i18n/pt-BR/routes.json index 8c0fbb7e8..762f4c572 100644 --- a/src/renderer/src/i18n/pt-BR/routes.json +++ b/src/renderer/src/i18n/pt-BR/routes.json @@ -14,5 +14,6 @@ "settings-prompt": "Prompts", "settings-mcp-market": "Mercado MCP", "settings-acp": "Proxy ACP", - "settings-skills": "Skills" + "settings-skills": "Skills", + "settings-notifications-hooks": "Notificações e hooks" } diff --git a/src/renderer/src/i18n/pt-BR/settings.json b/src/renderer/src/i18n/pt-BR/settings.json index 136fe94b4..6a6a58fcf 100644 --- a/src/renderer/src/i18n/pt-BR/settings.json +++ b/src/renderer/src/i18n/pt-BR/settings.json @@ -64,6 +64,60 @@ "fileMaxSize": "Tamanho máximo do arquivo", "fileMaxSizeHint": "Limita o tamanho máximo de um arquivo enviado" }, + "notificationsHooks": { + "title": "Notificações e hooks", + "description": "Configure notificações de webhook e hooks de ciclo de vida.", + "events": { + "title": "Eventos", + "SessionStart": "Início da sessão", + "UserPromptSubmit": "Envio do prompt do usuário", + "PreToolUse": "Antes do uso da ferramenta", + "PostToolUse": "Após o uso da ferramenta", + "PostToolUseFailure": "Falha no uso da ferramenta", + "PermissionRequest": "Solicitação de permissão", + "Stop": "Parada", + "SessionEnd": "Fim da sessão" + }, + "telegram": { + "title": "Telegram", + "description": "Enviar notificações via bot do Telegram.", + "botToken": "Token do bot", + "botTokenPlaceholder": "Token do bot do Telegram", + "chatId": "ID do chat", + "chatIdPlaceholder": "ex.: 123456789", + "threadId": "ID da thread (opcional)", + "threadIdPlaceholder": "ID de thread opcional" + }, + "discord": { + "title": "Discord", + "description": "Enviar notificações via webhook do Discord.", + "webhookUrl": "URL do webhook", + "webhookUrlPlaceholder": "https://discord.com/api/webhooks/..." + }, + "confirmo": { + "title": "Confirmo", + "description": "Enviar notificações via hook Confirmo (todos os eventos).", + "unavailable": "Hook do Confirmo não encontrado: {path}" + }, + "commands": { + "title": "Comandos de hook", + "description": "Executar um comando para cada evento.", + "hint": "O payload é enviado para o stdin em JSON.", + "commandPlaceholder": "Comando a executar" + }, + "test": { + "button": "Testar", + "testing": "Testando...", + "success": "Sucesso", + "failed": "Falhou", + "duration": "{ms} ms", + "statusCode": "HTTP {code}", + "exitCode": "Código de saída {code}", + "retryAfter": "Tentar novamente em {ms} ms", + "stdout": "stdout", + "stderr": "stderr" + } + }, "data": { "title": "Configurações de Dados", "syncEnable": "Habilitar Sincronização de Dados", diff --git a/src/renderer/src/i18n/ru-RU/routes.json b/src/renderer/src/i18n/ru-RU/routes.json index a38507ed9..b10e02fd9 100644 --- a/src/renderer/src/i18n/ru-RU/routes.json +++ b/src/renderer/src/i18n/ru-RU/routes.json @@ -14,5 +14,6 @@ "settings-prompt": "Управление промптами", "settings-mcp-market": "MCP Market", "settings-acp": "ACP-агент", - "settings-skills": "Skills" + "settings-skills": "Skills", + "settings-notifications-hooks": "Уведомления и хуки" } diff --git a/src/renderer/src/i18n/ru-RU/settings.json b/src/renderer/src/i18n/ru-RU/settings.json index 544141a93..3c6149f47 100644 --- a/src/renderer/src/i18n/ru-RU/settings.json +++ b/src/renderer/src/i18n/ru-RU/settings.json @@ -64,6 +64,60 @@ "fileMaxSize": "Максимальный размер файла", "fileMaxSizeHint": "Ограничивает максимальный размер загружаемого файла" }, + "notificationsHooks": { + "title": "Уведомления и хуки", + "description": "Настройте webhook-уведомления и хуки жизненного цикла.", + "events": { + "title": "События", + "SessionStart": "Начало сессии", + "UserPromptSubmit": "Отправка пользовательского промпта", + "PreToolUse": "Перед использованием инструмента", + "PostToolUse": "После использования инструмента", + "PostToolUseFailure": "Сбой использования инструмента", + "PermissionRequest": "Запрос разрешения", + "Stop": "Остановка", + "SessionEnd": "Конец сессии" + }, + "telegram": { + "title": "Telegram", + "description": "Отправлять уведомления через бота Telegram.", + "botToken": "Токен бота", + "botTokenPlaceholder": "Токен бота Telegram", + "chatId": "ID чата", + "chatIdPlaceholder": "например 123456789", + "threadId": "ID треда (необязательно)", + "threadIdPlaceholder": "Необязательный ID треда" + }, + "discord": { + "title": "Discord", + "description": "Отправлять уведомления через webhook Discord.", + "webhookUrl": "URL вебхука", + "webhookUrlPlaceholder": "https://discord.com/api/webhooks/..." + }, + "confirmo": { + "title": "Confirmo", + "description": "Отправлять уведомления через хук Confirmo (все события).", + "unavailable": "Хук Confirmo не найден: {path}" + }, + "commands": { + "title": "Команды хуков", + "description": "Выполнять команду для каждого события.", + "hint": "Пейлоад отправляется в stdin в формате JSON.", + "commandPlaceholder": "Команда для запуска" + }, + "test": { + "button": "Тест", + "testing": "Тестирование...", + "success": "Успех", + "failed": "Ошибка", + "duration": "{ms} мс", + "statusCode": "HTTP {code}", + "exitCode": "Код выхода {code}", + "retryAfter": "Повторить через {ms} мс", + "stdout": "stdout", + "stderr": "stderr" + } + }, "data": { "title": "Настройки данных", "syncEnable": "Включить синхронизацию данных", diff --git a/src/renderer/src/i18n/zh-CN/routes.json b/src/renderer/src/i18n/zh-CN/routes.json index f16824bec..b7fe8c6b9 100644 --- a/src/renderer/src/i18n/zh-CN/routes.json +++ b/src/renderer/src/i18n/zh-CN/routes.json @@ -14,5 +14,6 @@ "settings-prompt": "Prompt管理", "settings-mcp-market": "MCP市场", "settings-acp": "ACP Agent", - "settings-skills": "skills设置" + "settings-skills": "skills设置", + "settings-notifications-hooks": "通知与Hooks" } diff --git a/src/renderer/src/i18n/zh-CN/settings.json b/src/renderer/src/i18n/zh-CN/settings.json index 9eaff895e..80910be7c 100644 --- a/src/renderer/src/i18n/zh-CN/settings.json +++ b/src/renderer/src/i18n/zh-CN/settings.json @@ -64,6 +64,60 @@ "fileMaxSize": "文件最大大小", "fileMaxSizeHint": "限制单个文件的最大上传大小" }, + "notificationsHooks": { + "title": "通知与Hooks", + "description": "配置 webhook 通知与生命周期 hooks。", + "events": { + "title": "事件", + "SessionStart": "会话开始", + "UserPromptSubmit": "用户提交", + "PreToolUse": "工具调用前", + "PostToolUse": "工具调用后", + "PostToolUseFailure": "工具调用失败", + "PermissionRequest": "权限请求", + "Stop": "停止", + "SessionEnd": "会话结束" + }, + "telegram": { + "title": "Telegram", + "description": "通过 Telegram Bot 发送通知。", + "botToken": "Bot Token", + "botTokenPlaceholder": "Telegram Bot Token", + "chatId": "Chat ID", + "chatIdPlaceholder": "例如 123456789", + "threadId": "Thread ID(可选)", + "threadIdPlaceholder": "可选 Thread ID" + }, + "discord": { + "title": "Discord", + "description": "通过 Discord webhook 发送通知。", + "webhookUrl": "Webhook URL", + "webhookUrlPlaceholder": "https://discord.com/api/webhooks/..." + }, + "confirmo": { + "title": "Confirmo", + "description": "通过 Confirmo Hook 发送通知(默认发送全部事件)。", + "unavailable": "未找到 Confirmo Hook:{path}" + }, + "commands": { + "title": "Hooks Commands", + "description": "为每个事件执行命令。", + "hint": "payload 以 JSON 写入 stdin。", + "commandPlaceholder": "要执行的命令" + }, + "test": { + "button": "测试", + "testing": "测试中...", + "success": "成功", + "failed": "失败", + "duration": "{ms} ms", + "statusCode": "HTTP {code}", + "exitCode": "退出码 {code}", + "retryAfter": "重试等待 {ms} ms", + "stdout": "stdout", + "stderr": "stderr" + } + }, "data": { "title": "数据设置", "syncEnable": "启用数据同步", diff --git a/src/renderer/src/i18n/zh-HK/routes.json b/src/renderer/src/i18n/zh-HK/routes.json index f6536be63..1281f5f92 100644 --- a/src/renderer/src/i18n/zh-HK/routes.json +++ b/src/renderer/src/i18n/zh-HK/routes.json @@ -14,5 +14,6 @@ "settings-mcp-market": "MCP市場", "playground": "Playground 實驗室", "settings-acp": "ACP Agent", - "settings-skills": "skills設置" + "settings-skills": "skills設置", + "settings-notifications-hooks": "通知與 Hooks" } diff --git a/src/renderer/src/i18n/zh-HK/settings.json b/src/renderer/src/i18n/zh-HK/settings.json index 37ff45148..70def39d6 100644 --- a/src/renderer/src/i18n/zh-HK/settings.json +++ b/src/renderer/src/i18n/zh-HK/settings.json @@ -64,6 +64,60 @@ "fileMaxSize": "檔案最大大小", "fileMaxSizeHint": "限制單個檔案的最大上傳大小" }, + "notificationsHooks": { + "title": "通知與 Hooks", + "description": "配置 webhook 通知與生命週期 Hooks。", + "events": { + "title": "事件", + "SessionStart": "會話開始", + "UserPromptSubmit": "使用者提示送出", + "PreToolUse": "使用工具前", + "PostToolUse": "使用工具後", + "PostToolUseFailure": "工具使用失敗", + "PermissionRequest": "權限請求", + "Stop": "停止", + "SessionEnd": "會話結束" + }, + "telegram": { + "title": "Telegram", + "description": "透過 Telegram 機器人發送通知。", + "botToken": "機器人 Token", + "botTokenPlaceholder": "Telegram 機器人 Token", + "chatId": "聊天 ID", + "chatIdPlaceholder": "例如 123456789", + "threadId": "主題 ID(可選)", + "threadIdPlaceholder": "可選的主題 ID" + }, + "discord": { + "title": "Discord", + "description": "透過 Discord webhook 發送通知。", + "webhookUrl": "Webhook URL", + "webhookUrlPlaceholder": "https://discord.com/api/webhooks/..." + }, + "confirmo": { + "title": "Confirmo", + "description": "透過 Confirmo hook 發送通知(所有事件)。", + "unavailable": "找不到 Confirmo hook:{path}" + }, + "commands": { + "title": "Hooks 指令", + "description": "每個事件執行一個指令。", + "hint": "Payload 會以 JSON 傳送到 stdin。", + "commandPlaceholder": "要執行的指令" + }, + "test": { + "button": "測試", + "testing": "測試中...", + "success": "成功", + "failed": "失敗", + "duration": "{ms} ms", + "statusCode": "HTTP {code}", + "exitCode": "退出碼 {code}", + "retryAfter": "在 {ms} ms 後重試", + "stdout": "stdout", + "stderr": "stderr" + } + }, "data": { "title": "數據設置", "syncEnable": "啟用數據同步", diff --git a/src/renderer/src/i18n/zh-TW/routes.json b/src/renderer/src/i18n/zh-TW/routes.json index b623b87b1..54762fc6d 100644 --- a/src/renderer/src/i18n/zh-TW/routes.json +++ b/src/renderer/src/i18n/zh-TW/routes.json @@ -14,5 +14,6 @@ "settings-mcp-market": "MCP市場", "playground": "Playground 實驗室", "settings-acp": "ACP Agent", - "settings-skills": "skills管理" + "settings-skills": "skills管理", + "settings-notifications-hooks": "通知與 Hooks" } diff --git a/src/renderer/src/i18n/zh-TW/settings.json b/src/renderer/src/i18n/zh-TW/settings.json index 3cd7e6cf0..bbd91ce09 100644 --- a/src/renderer/src/i18n/zh-TW/settings.json +++ b/src/renderer/src/i18n/zh-TW/settings.json @@ -64,6 +64,60 @@ "fileMaxSize": "檔案最大大小", "fileMaxSizeHint": "限制單個檔案的最大上傳大小" }, + "notificationsHooks": { + "title": "通知與 Hooks", + "description": "設定 webhook 通知與生命週期 Hooks。", + "events": { + "title": "事件", + "SessionStart": "會話開始", + "UserPromptSubmit": "使用者提示送出", + "PreToolUse": "使用工具前", + "PostToolUse": "使用工具後", + "PostToolUseFailure": "工具使用失敗", + "PermissionRequest": "權限請求", + "Stop": "停止", + "SessionEnd": "會話結束" + }, + "telegram": { + "title": "Telegram", + "description": "透過 Telegram 機器人發送通知。", + "botToken": "機器人 Token", + "botTokenPlaceholder": "Telegram 機器人 Token", + "chatId": "聊天 ID", + "chatIdPlaceholder": "例如 123456789", + "threadId": "主題 ID(選填)", + "threadIdPlaceholder": "選填主題 ID" + }, + "discord": { + "title": "Discord", + "description": "透過 Discord webhook 發送通知。", + "webhookUrl": "Webhook URL", + "webhookUrlPlaceholder": "https://discord.com/api/webhooks/..." + }, + "confirmo": { + "title": "Confirmo", + "description": "透過 Confirmo hook 發送通知(所有事件)。", + "unavailable": "找不到 Confirmo hook:{path}" + }, + "commands": { + "title": "Hooks 指令", + "description": "每個事件執行一個指令。", + "hint": "Payload 會以 JSON 傳送到 stdin。", + "commandPlaceholder": "要執行的指令" + }, + "test": { + "button": "測試", + "testing": "測試中...", + "success": "成功", + "failed": "失敗", + "duration": "{ms} ms", + "statusCode": "HTTP {code}", + "exitCode": "退出碼 {code}", + "retryAfter": "在 {ms} ms 後重試", + "stdout": "stdout", + "stderr": "stderr" + } + }, "data": { "title": "資料設定", "syncEnable": "啟用資料同步", diff --git a/src/shared/hooksNotifications.ts b/src/shared/hooksNotifications.ts new file mode 100644 index 000000000..6e1ccb2e6 --- /dev/null +++ b/src/shared/hooksNotifications.ts @@ -0,0 +1,111 @@ +export const HOOK_EVENT_NAMES = [ + 'SessionStart', + 'UserPromptSubmit', + 'PreToolUse', + 'PostToolUse', + 'PostToolUseFailure', + 'PermissionRequest', + 'Stop', + 'SessionEnd' +] as const + +export type HookEventName = (typeof HOOK_EVENT_NAMES)[number] + +export const DEFAULT_IMPORTANT_HOOK_EVENTS: HookEventName[] = [ + 'SessionStart', + 'SessionEnd', + 'PostToolUseFailure', + 'PermissionRequest', + 'Stop' +] + +export type HookChannel = 'telegram' | 'discord' | 'confirmo' | 'command' + +export interface HookCommandConfig { + enabled: boolean + command: string +} + +export interface HookCommandsConfig { + enabled: boolean + events: Record +} + +export interface TelegramNotificationsConfig { + enabled: boolean + botToken: string + chatId: string + threadId?: string + events: HookEventName[] +} + +export interface DiscordNotificationsConfig { + enabled: boolean + webhookUrl: string + events: HookEventName[] +} + +export interface ConfirmoNotificationsConfig { + enabled: boolean + events: HookEventName[] +} + +export interface HooksNotificationsSettings { + telegram: TelegramNotificationsConfig + discord: DiscordNotificationsConfig + confirmo: ConfirmoNotificationsConfig + commands: HookCommandsConfig +} + +export interface HookEventPayload { + payloadVersion: 1 + event: HookEventName + time: string + isTest: boolean + app: { + version: string + platform: string + } + session: { + conversationId?: string + agentId?: string | null + workdir?: string | null + providerId?: string + modelId?: string + } + user?: { + messageId?: string + promptPreview?: string + } | null + tool?: { + callId?: string + name?: string + paramsPreview?: string + responsePreview?: string + error?: string + } | null + permission?: Record | null + stop?: { + reason?: string + userStop?: boolean + } | null + usage?: Record | null + error?: { + message?: string + stack?: string + } | null +} + +export interface HookCommandResult { + success: boolean + durationMs: number + exitCode?: number | null + stdout?: string + stderr?: string + error?: string +} + +export interface HookTestResult extends HookCommandResult { + statusCode?: number + retryAfterMs?: number +} diff --git a/src/shared/types/index.d.ts b/src/shared/types/index.d.ts index 60c03707e..6753f62ae 100644 --- a/src/shared/types/index.d.ts +++ b/src/shared/types/index.d.ts @@ -4,6 +4,7 @@ export type * from './presenters/legacy.presenters' export type * from './presenters/agent-provider' export type * from './presenters/workspace' export type * from './presenters/tool.presenter' +export type * from '../hooksNotifications' export * from './browser' export * from './chatSettings' export * from './skill' diff --git a/src/shared/types/presenters/legacy.presenters.d.ts b/src/shared/types/presenters/legacy.presenters.d.ts index 0ee90004e..a3e0940c9 100644 --- a/src/shared/types/presenters/legacy.presenters.d.ts +++ b/src/shared/types/presenters/legacy.presenters.d.ts @@ -4,6 +4,11 @@ import { MessageFile } from './chat' import { ShowResponse } from 'ollama' import { ShortcutKeySetting } from '@/presenter/configPresenter/shortcutKeySettings' import { ApiEndpointType, ModelType } from '@shared/model' +import type { + HookEventName, + HookTestResult, + HooksNotificationsSettings +} from '../../hooksNotifications' import type { NowledgeMemThread, NowledgeMemExportSummary } from '../nowledgeMem' import { ProviderChange, ProviderBatchUpdate } from './provider-operations' import type { AgentSessionLifecycleStatus } from './agent-provider' @@ -560,6 +565,14 @@ export interface IConfigPresenter { setSyncFolderPath(folderPath: string): void getLastSyncTime(): number setLastSyncTime(time: number): void + // Hooks & notifications settings + getHooksNotificationsConfig(): HooksNotificationsSettings + setHooksNotificationsConfig(config: HooksNotificationsSettings): HooksNotificationsSettings + getConfirmoHookStatus(): { available: boolean; path: string } + testTelegramNotification(): Promise + testDiscordNotification(): Promise + testConfirmoNotification(): Promise + testHookCommand(eventName: HookEventName): Promise // Skills settings getSkillsEnabled(): boolean setSkillsEnabled(enabled: boolean): void diff --git a/test/main/presenter/hooksNotifications.test.ts b/test/main/presenter/hooksNotifications.test.ts new file mode 100644 index 000000000..59b34c53a --- /dev/null +++ b/test/main/presenter/hooksNotifications.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, vi } from 'vitest' +import { truncateText, parseRetryAfterMs } from '../../../src/main/presenter/hooksNotifications' +import { + normalizeHooksNotificationsConfig, + createDefaultHooksNotificationsConfig +} from '../../../src/main/presenter/hooksNotifications/config' +import { + DEFAULT_IMPORTANT_HOOK_EVENTS, + HOOK_EVENT_NAMES +} from '../../../src/shared/hooksNotifications' + +vi.mock('electron-log', () => ({ + default: { + warn: vi.fn(), + info: vi.fn(), + error: vi.fn() + } +})) + +describe('hooksNotifications', () => { + it('truncateText keeps short strings intact', () => { + expect(truncateText('hello', 10)).toBe('hello') + }) + + it('truncateText truncates with suffix', () => { + const result = truncateText('abcdefghijklmnopqrstuvwxyz', 20) + expect(result.endsWith(' ...(truncated)')).toBe(true) + expect(result.length).toBe(20) + }) + + it('parseRetryAfterMs reads seconds header', () => { + const response = new Response(null, { + status: 429, + headers: { 'retry-after': '2' } + }) + expect(parseRetryAfterMs(response)).toBe(2000) + }) + + it('parseRetryAfterMs reads ms header', () => { + const response = new Response(null, { + status: 429, + headers: { 'retry-after': '1200' } + }) + expect(parseRetryAfterMs(response)).toBe(1200) + }) + + it('parseRetryAfterMs reads retry_after from body', () => { + const response = new Response(null, { status: 429 }) + expect(parseRetryAfterMs(response, { retry_after: 3 })).toBe(3000) + }) + + it('normalizeHooksNotificationsConfig sanitizes events and commands', () => { + const input = { + telegram: { + enabled: true, + botToken: 'token', + chatId: 'chat', + events: ['SessionStart', 'UnknownEvent'] + }, + discord: { + enabled: true, + events: [] + }, + confirmo: { + enabled: true, + events: ['Stop', 'UnknownEvent'] + }, + commands: { + enabled: true, + events: { + SessionStart: { enabled: true, command: 'echo ok' }, + UnknownEvent: { enabled: true, command: 'bad' } + } + }, + extra: 'ignored' + } + + const normalized = normalizeHooksNotificationsConfig(input) + + expect(normalized.telegram.enabled).toBe(true) + expect(normalized.telegram.botToken).toBe('token') + expect(normalized.telegram.chatId).toBe('chat') + expect(normalized.telegram.events).toEqual(['SessionStart']) + + expect(normalized.discord.enabled).toBe(true) + expect(normalized.discord.events).toEqual(DEFAULT_IMPORTANT_HOOK_EVENTS) + + expect(normalized.confirmo.enabled).toBe(true) + expect(normalized.confirmo.events).toEqual([...HOOK_EVENT_NAMES]) + + expect(Object.keys(normalized.commands.events)).toEqual([...HOOK_EVENT_NAMES]) + expect(normalized.commands.events.SessionStart.enabled).toBe(true) + expect(normalized.commands.events.SessionStart.command).toBe('echo ok') + }) + + it('normalizeHooksNotificationsConfig falls back to defaults', () => { + const defaults = createDefaultHooksNotificationsConfig() + const normalized = normalizeHooksNotificationsConfig(null) + + expect(normalized).toEqual(defaults) + }) +})