diff --git a/.env.example b/.env.example index 77623625..12afb717 100644 --- a/.env.example +++ b/.env.example @@ -43,3 +43,10 @@ QDRANT_GRPC_PORT=6334 # Webhook & Organization Sync # ORG_SYNC_SECRET=your-webhook-hmac-secret + +# Discord Bot Channel +# Deployment-wide bot token, used when a Discord channel does not carry its own. +# DISCORD_BOT_TOKEN=your-discord-bot-token +# DISCORD_CLIENT_ID=your-discord-application-id +# DISCORD_CLIENT_SECRET=your-discord-client-secret +# DISCORD_PUBLIC_KEY=your-discord-application-public-key diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e18ca722..355b99b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: - name: Run Go Tests run: | - go test -v -tags dev ./internal/services/... ./internal/repositories/... ./internal/pkg/... ./internal/oidcclient/... + go test -v -tags dev ./internal/services/... ./internal/repositories/... ./internal/pkg/... ./internal/oidcclient/... ./internal/migration/... frontend-typecheck: name: Frontend Typecheck diff --git a/.gitignore b/.gitignore index 6cea5dbe..49af5db4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,10 @@ .worktrees .idea .pnpm-store +.trae/ +doc/ +logs/ data/ config/config.yaml .env @@ -24,3 +27,5 @@ agent-desk test-reports web/public/flowgram-editor/ + +*.tar \ No newline at end of file diff --git a/.gitmodules b/.gitmodules index 450ea966..1210ecfd 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,4 @@ [submodule "qdrant"] path = qdrant url = git@github.com:huabeitech/agent-desk-qdrant.git -[submodule "docs"] - path = docs - url = git@github.com:huabeitech/agent-desk-docs.git + diff --git a/AGENTS.md b/AGENTS.md index 2c9e318c..6c436d37 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,275 +1,275 @@ # AGENTS.md -This file defines the mandatory working agreement for AI agents in this repository. It is intentionally based on the current codebase rather than historical conventions. - -## 1. Scope and Priorities - -- These rules apply to the repository root and every subdirectory. -- Explicit user instructions take precedence over this file. Mention any deliberate deviation in the final summary. -- Inspect the relevant implementation before editing. Reuse current helpers, component APIs, generated-code workflows, and neighboring patterns instead of relying on memory. -- Keep changes narrowly scoped. Preserve unrelated and user-owned worktree changes, including staged changes. -- A review, investigation, or diagnosis request is read-only unless the user also asks for implementation. - -## 2. Current Architecture - -The repository contains three application areas: - -- Go server: `cmd/server` and `internal/*` -- Next.js application: `web/*` -- Embedded workflow editor: `flowgram-editor/*`, built into `web/public/flowgram-editor` - -The main stack is: - -- Go 1.26, Gin, GORM, `github.com/mlogclub/simple` -- SQLite and MySQL -- Next.js 16 App Router, React 19, TypeScript, Tailwind CSS, shadcn/Base UI -- `pnpm` for both frontend projects -- Optional LanceDB builds through CGO; Qdrant is also supported by the application - -Important entry points: - -- Server assembly and middleware: `internal/bootstrap/server.go` -- Explicit API routes: `internal/bootstrap/routes.go` -- Model registration: `internal/models/models.go` -- Schema/data migration startup: `internal/bootstrap/migration.go` -- CRUD generator: `cmd/generator/generator.go` -- Frontend enum generator: `cmd/enums/generator.go` -- Frontend API client: `web/lib/api/client.ts` -- Frontend i18n: `web/i18n/*` and `web/messages/*` -- Dashboard shared components: `web/components/dashboard/*` -- Project commands: `Taskfile.yml` - -## 3. General Change Rules - -- Read the actual type, function, or component signature before using it. -- Prefer the highest-level existing abstraction that fits the requirement. Do not duplicate query state, pagination, auth refresh, localization, or dashboard CRUD behavior. -- Do not edit generated artifacts by hand. Change their source and run the corresponding generator/build command. -- Do not add a second implementation style when the repository already has a shared path for the same concern. -- Use `log/slog` for new Go logging and structured key-value fields for relevant context. -- New Go code uses `any`, not `interface{}`. Existing generated or legacy code does not need unrelated cleanup. -- Secrets, tokens, credentials, and private customer data must never be committed or printed in logs/tests. - -## 4. Go Backend - -### 4.1 Layer Ownership - -The normal dependency and data flow is: - -`models -> repositories -> services -> handlers -> builders/response DTOs` - -- `internal/models`: entity fields, GORM mappings, associations, and schema metadata only. -- `internal/repositories`: GORM/SQL access, conditions, ordering, pagination, locks, and persistence details. -- `internal/services`: business validation, state changes, authorization-independent domain rules, aggregation, transactions, and event orchestration. -- `internal/handlers/{api,dashboard,third}`: HTTP parameter parsing, authentication/permission checks, service calls, and response writing. -- `internal/builders`: pure model/aggregate-to-response mapping. Builders must not query the database. -- `internal/pkg/dto/request` and `internal/pkg/dto/response`: external request/response contracts. - -Mandatory boundaries: - -- Handlers must not call repositories or issue GORM queries directly. -- Models and repositories must not contain HTTP, permission, or cross-resource workflow logic. -- GORM models must not be returned directly from an API. Map them to response DTOs/builders. -- A service may return models internally, but public response shape remains owned by builders/DTOs. -- Put reusable SQL in repositories. A genuinely one-off aggregate query may stay near its domain service only when extraction would make ownership less clear. - -### 4.2 Database and Transactions - -- Repository methods that participate in transactions accept `db *gorm.DB`; call them with `sqls.DB()` outside a transaction and `ctx.Tx` inside one. -- Services own transaction boundaries through `sqls.WithTransaction`. -- Use a transaction for atomic multi-write workflows and consistency-sensitive read-modify-write operations. -- Do not add a transaction around a single independent SQL write. -- Every database operation inside a transaction must use the same `ctx.Tx`; never escape to `sqls.DB()` mid-transaction. -- Use `sqls.Cnd`/`sqls.NewCnd()` and repository methods for ordinary filtering and pagination. -- Preserve SQLite and MySQL compatibility. Avoid dialect-specific SQL unless both dialects are explicitly implemented and tested. -- Use portable column types and `int64` primary/foreign identifiers. Keep time handling compatible with MySQL `parseTime=True`. - -### 4.3 Models, Generation, and Migrations - -- Register persistent models in `internal/models/models.go` so startup `AutoMigrate(models.Models...)` includes them. -- New tables, columns, indexes, and compatible constraints are normally applied by GORM `AutoMigrate` in `internal/bootstrap/migration.go`. -- Use `internal/migration/*` only for versioned, idempotent data migration/backfill/repair work. Its version must increase monotonically. -- When a model uses the standard generated repository/service surface, register it in `cmd/generator/generator.go` and run `task generator`. -- Treat generator output as mechanical infrastructure. Put business-specific methods in handwritten files and do not manually patch generated CRUD output. -- Backend/frontend shared enums are defined in `internal/pkg/enums`, annotated using the existing enum pattern, and generated with `task enums` into `web/lib/generated/enums.ts`. -- Never create a handwritten frontend duplicate of a generated backend enum. - -### 4.4 HTTP APIs - -- All routes are explicit in `internal/bootstrap/routes.go`; handler names do not create endpoints. -- Public/product APIs live under `/api/*`, authenticated management APIs under `/api/dashboard/*`, callbacks under `/api/third/*`, and WebSockets under `/api/ws/*`. -- Add a resource-specific `register...Routes` function or extend the existing one, then mount it from `addRouter` under the correct group. -- Follow the existing resource contract: detail commonly uses `GET /:id`, list uses `/list`, writes use explicit POST actions such as `/create`, `/update`, `/delete`, and domain actions retain their established snake_case path names. -- Do not introduce `/api/v1`, automatic routing assumptions, or unnecessary deeply nested resource paths. -- Handler names mirror the registered method and path, for example `XxxGetBy`, `XxxAnyList`, and `XxxPostCreate`. -- Parse JSON/form/query/path values with `internal/pkg/httpx/params` and `internal/pkg/httpx` helpers. -- Dashboard permission checks use `services.AuthService.RequirePermission` or the established permission helper before domain work. -- Write responses through `httpx.WriteJSON`; preserve the shared `JsonResult` contract. -- Paginated responses use `web.PageResult` with `data.results` and `data.page`. -- Convert not-found, validation, permission, and persistence failures into stable application errors. Never expose raw SQL errors to clients. - -### 4.5 Backend Internationalization - -- Any new or changed user-visible backend error must support every backend locale, currently `zh-CN` and `en-US`. -- Add matching keys to both `internal/pkg/i18nx/locales/zh-CN.yml` and `internal/pkg/i18nx/locales/en-US.yml` in the same change. -- Services should return localized application errors through the `errorsx.*I18n` helpers. -- Handlers that need an immediate localized response use `httpx.JsonErrorMsg(ctx, key, args...)`; other request-context translation uses `i18nx.T`. -- Do not hard-code Chinese or English error sentences in handlers/services when the text can reach a user. -- Keep format arguments equivalent across locales and cover new reusable/error-format behavior with focused tests. - -## 5. Frontend - -### 5.1 Component and Data Boundaries - -- Application routes live under `web/app`; reusable business components live under `web/components`; route-private components live in the route's `_components` directory. -- Reuse `web/components/ui/*` primitives. Do not edit these shadcn base components for a feature-specific requirement. -- Use the current Base UI/shadcn component API as implemented in the repository; do not assume APIs such as Radix `asChild` exist. -- Use `@/*` imports for code within `web`. -- Client components must declare `"use client"` when they use state, effects, browser APIs, or client-only hooks. -- Keep resource APIs in `web/lib/api/*` and route all normal requests through `web/lib/api/client.ts`. -- Pages, business components, and stores must not implement their own `JsonResult` parsing, auth header handling, token refresh, or login-expiry cleanup. -- Direct `fetch` is reserved for unsupported transports such as third-party calls, binary transfers, SSE, and WebSocket handshakes; explain the exception in code. -- Prefer `OptionCombobox` for standard dropdowns rather than adding shadcn Select-based business controls. -- Format displayed timestamps with `formatDateTime` from `web/lib/utils.ts` unless the product explicitly requires a different representation. - -### 5.2 Dashboard Pages - -Before building a dashboard page, inspect `web/components/dashboard/crud`, `web/components/dashboard/list`, and `web/components/dashboard-page.tsx`. - -Use this order of preference: - -1. `DashboardCrudPage` for standard create/read/update/delete resources. -2. `DashboardListPage` for read-only or custom-content paginated resources. -3. `useDashboardPagedList` when layout is bespoke but list query/filter/pagination lifecycle is standard. -4. `DashboardPage`, `DashboardToolbar`, `DashboardTableShell`, and related low-level primitives only for interactions that cannot fit the higher-level components. - -Rules: - -- Do not copy a resource page to recreate standard filters, query/reset/refresh actions, pagination, loading/empty states, confirmations, row actions, or dialogs. -- Configure `DashboardCrudPage` through its filters, columns, labels, service callbacks, row actions, sorting, and form/dialog extension points before adding page-local infrastructure. -- Use its schema-driven `DashboardCrudFormDialog` when supported. A genuinely custom form may live in `_components` and should normally use `react-hook-form`, `zod`, and `Field`. -- Configure `DashboardListPage` with columns or `renderContent`, and use `renderToolbarActions` for resource-specific actions. -- Business API knowledge stays in the page/service module; generic dashboard components must not import a resource-specific API. -- A change to `web/components/dashboard/*` must be generic, backward-compatible, and useful beyond one page. Otherwise keep it local to the feature. - -### 5.3 Frontend Internationalization - -- Every frontend feature and modification must work in all `SUPPORTED_LOCALES`, currently `zh-CN` and `en-US`. -- Add every new key to both `web/messages/zh-CN.json` and `web/messages/en-US.json` in the same change, preserving matching structure. -- React pages/components use `useI18n()`; locale-aware formatting/mapping may also use `useAppLocale()`. -- Non-React code uses `translateCurrentMessage()` or `translateMessage()` from `web/i18n/messages.ts`. -- Do not hard-code user-visible copy in JSX/TSX, toast messages, dialogs, confirmations, placeholders, validation, empty/loading/error states, tooltips, accessibility labels, or client-side fallback errors. -- Product names, protocol literals, user content, and raw business data do not need translation unless the UI already provides a display-name mapping. -- Dashboard labels supplied to shared CRUD/list components must come from translation keys. -- Centralize localized display names for backend identifiers/enums in a reusable `web/lib/*-i18n.ts` helper instead of duplicating locale switches across pages. -- Preserve the same placeholders in every locale and interpolate with `t(key, values)`; do not assemble sentences by concatenating translated fragments. -- Locale configuration belongs to `AppI18nProvider` and `web/i18n/config.ts`; features must not introduce separate locale detection or state. - -### 5.4 Commercial-Grade UI and Interaction - -This is a commercial product, not a prototype or component demo. UI work is complete only when it is visually coherent, interaction-complete, responsive, localized, and credible with real production data. - -#### Visual Quality - -- Follow the existing design language, spacing scale, typography, radius, color tokens, and component variants. A new feature must look native to the product rather than like a pasted template. -- In support platform UI, if rounded corners are needed, use `rounded-md` consistently. -- Support platform components should not add `shadow`. -- Establish a clear hierarchy: page title/primary action, filters or context, main content, and secondary information. Do not make every region a card or every action visually prominent. -- Prefer restrained, purposeful styling. Avoid decorative gradients, oversized hero text, excessive shadows, glass effects, emoji icons, random accent colors, and ornamental copy unless the product context explicitly calls for them. -- Use Lucide icons consistently. Choose icons by meaning, keep icon size and stroke weight aligned with neighboring controls, and never use icons as decoration without communicative value. -- Keep spacing and alignment deliberate at every breakpoint. Labels, inputs, table columns, action groups, dialog footers, and empty states must align cleanly without ad hoc offsets. -- Design for realistic content, not ideal sample text. Verify long names, multiline content, large counts, missing optional fields, and mixed Chinese/English values. Use wrapping, truncation, tooltips, or scroll containers intentionally. -- Preserve information density appropriate to an operations dashboard. Do not waste large areas on decoration, but do not compress controls until scanning and clicking become difficult. - -#### Interaction Completeness - -- Every asynchronous action must have an immediate and unambiguous state: pending/loading, success, failure, and retry or recovery when appropriate. -- Prevent duplicate submissions. Disable or lock the initiating control while a mutation is pending and show action-specific progress text or a spinner without causing layout shift. -- Keep feedback close to the action. Use inline validation for field problems, contextual error states for failed content, and toast notifications for completed background or page-level actions. -- Never silently discard user input. Warn before closing, navigating away, resetting, or switching context when there are meaningful unsaved changes. -- Destructive, irreversible, security-sensitive, or broad-impact operations require confirmation that clearly names the object and consequence. Do not use a generic “Are you sure?” message. -- Confirmation is not a substitute for good defaults: routine reversible actions should remain efficient and should not be interrupted by unnecessary modal prompts. -- After create/update/delete operations, keep list state coherent: refresh affected data, preserve useful filters/page position when possible, close dialogs only on success, and prevent stale selections or detail panels. -- Buttons and menu items must use precise verbs describing the result. Avoid vague labels such as “OK”, “Submit”, or “Process” when a specific action name is available. -- Preserve keyboard behavior and focus flow: Enter submits only where expected, Escape closes dismissible overlays, focus moves into dialogs and returns to the trigger, and destructive actions are not the accidental default. -- Interactive rows, icons, badges, and text links must look interactive only when they are interactive. Do not rely on hover-only discoverability for essential actions. - -#### Forms and Dialogs - -- Use the smallest suitable interaction container: inline editing for simple local changes, a dialog for focused tasks, and a full page/workbench for complex or multi-step workflows. -- Dialogs need a clear title, concise context when necessary, stable body layout, and a consistent footer with secondary action before primary action. Long content must scroll inside the dialog without pushing actions off-screen. -- Forms must have visible labels, appropriate controls, useful defaults, required/optional semantics, and examples or help text only where they reduce ambiguity. -- Validate at the right time: do not show errors before the user has interacted, clear stale errors after correction, and map backend validation failures to the relevant field when possible. -- Preserve entered values after failed submission. Do not reset or close a form until the server confirms success. -- Dependent fields must clearly reflect disabled/loading/empty states. When one field invalidates another, update it predictably and explain the dependency when it is not obvious. -- Configuration and high-impact forms should not expose a permanently editable surface when a deliberate edit mode improves safety. Saving sensitive configuration requires explicit user intent and appropriate confirmation. - -#### Lists, Tables, and Operational Screens - -- Use the shared Dashboard CRUD/list system and maintain consistent toolbar, filter, pagination, loading, empty, and action placement across resources. -- Filters must distinguish draft values from applied query state. Query, reset, refresh, pagination, and URL/state behavior should be predictable and must not unexpectedly erase one another. -- Tables must remain scannable: align comparable values, keep action columns stable, use badges sparingly for status, avoid dense multiline cells when a detail view is more appropriate, and provide horizontal overflow on narrow screens. -- Empty states must distinguish “no data exists” from “no results match the current filters” and offer the most relevant next action when the user can resolve the state. -- Loading states should preserve layout. Prefer skeletons for content whose shape is known and compact spinners for localized actions; avoid replacing an entire stable page with a centered spinner. -- Error states must explain what failed in user terms and provide retry when retry is meaningful. Never leave a blank table or empty panel after a request failure. -- Bulk actions must show selection count, affected scope, eligibility, and partial-failure results. Clear selection when it is no longer valid. - -#### Responsive and Accessibility Requirements - -- Every changed screen must work at desktop and narrow/mobile widths. Do not treat horizontal clipping, overlapping controls, wrapped action chaos, or off-screen dialog buttons as acceptable. -- Responsive behavior must preserve task priority: primary actions remain reachable, secondary actions may move into menus, filters may stack or collapse, and tables may scroll without hiding row identity/actions. -- Use semantic controls and accessible names. Icon-only buttons require localized accessible labels and tooltips where the meaning is not universally obvious. -- Maintain visible focus states, logical tab order, sufficient target sizes, and adequate text/background contrast. Do not encode status or errors using color alone. -- Respect reduced-motion preferences. Animations should explain state or continuity, remain subtle, and never delay work. - -#### Product Copy and Data Credibility - -- Copy must sound like a finished product: concise, specific, consistent, and action-oriented. Do not expose implementation jargon, placeholder prose, “TODO”, mock labels, debug wording, or developer instructions to users. -- Do not ship fake statistics, sample records, disabled-looking placeholder buttons, decorative charts without meaning, or interactions that only log to the console. -- Distinguish unavailable features from empty data. If a capability is not implemented, do not render a control that pretends it is functional. -- User-visible names, statuses, permissions, dates, and errors must use the established formatting and i18n mappings rather than raw backend identifiers. - -#### UI Verification - -- For meaningful UI changes, inspect the finished screen in a real browser at representative desktop and narrow widths. Source review and typecheck alone are not sufficient visual validation. -- Exercise the complete interaction, including initial load, populated state, empty/filtered state, validation failure, server failure when practical, pending/disabled behavior, success, cancel/close, and refresh. -- Check both `zh-CN` and `en-US`; verify that translated copy does not overflow, truncate critical meaning, or break control alignment. -- Before handoff, remove temporary data, debug UI, console output, test-only shortcuts, and visual artifacts introduced during verification. -- When browser verification cannot be performed, state that limitation explicitly; do not describe the UI as visually verified. - -### 5.5 Generated and Embedded Frontend Assets - -- `web/lib/generated/enums.ts` is generated by `task enums`; do not edit it manually. -- `web/public/sdk/agent-desk-sdk.min.js` is generated from the SDK source. When SDK source changes, run `cd web && pnpm build:sdk` and its focused SDK tests. -- `web/public/flowgram-editor` is produced from `flowgram-editor`; edit the source project, not the generated public output. -- When changing `flowgram-editor`, use its own `pnpm` scripts and ensure the embedding build still succeeds. -- The Next.js application is statically exported/embedded into the Go binary. Avoid runtime-only Next.js features that conflict with the current export and embedding model. - -## 6. Testing and Validation - -Validation must match the changed surface. Do not claim checks that were not run. - -- Go formatting: run `gofmt` on every changed `.go` file. -- Go behavior: add focused tests for changed business rules, transactions, security boundaries, parsing, and reusable helpers; run the narrow package tests first, then `go test ./...` when practical. -- Frontend TypeScript: run `cd web && pnpm typecheck` for frontend changes. -- Frontend lint: run `cd web && pnpm lint` for broader component/page changes or when lint-sensitive code changed. -- Frontend logic: run relevant `node --test ...` files when changing utilities, i18n mappings, generated SDK behavior, or other modules with focused tests. -- Workflow editor: run the relevant `flowgram-editor` lint/build commands for changes in that project. -- Generation: after model CRUD or shared enum changes, run the corresponding `task generator` or `task enums` and review generated diffs. -- Build: use `task build` when changes affect frontend embedding, build configuration, generated public assets, or release assembly. -- Browser verification is expected for meaningful visual or interaction changes when a runnable environment is available; state explicitly when it was not performed. -- Documentation-only changes require at least `git diff --check` and verification that every referenced path/command exists. - -## 7. Completion Checklist - -Before handing off a change, confirm the applicable items: - -- The implementation follows current layer/component ownership and does not create reverse dependencies. -- Transactions cover exactly the operations that must be atomic, and all transactional DB calls use the same `ctx.Tx`. -- API routes are explicitly registered and responses preserve `JsonResult`/`PageResult` contracts. -- Models, migrations, queries, and tests remain compatible with SQLite and MySQL. -- Backend and frontend user-visible text is complete in both Chinese and English. -- Dashboard pages reuse the highest-level suitable component under `web/components/dashboard/*`. -- UI changes meet the commercial-grade standard: complete states, precise feedback, safe mutations, responsive layout, accessible controls, credible copy/data, and no demo-only behavior. -- Generated files were regenerated from their source and were not manually edited. -- Relevant tests/typechecks/lint/build/browser checks were run, and any validation limitation is reported. -- `git diff --check` passes and unrelated worktree changes remain untouched. +本文件定义了本仓库中 AI 智能体必须遵守的工作约定。它刻意以当前代码库的实际情况为准,而非历史惯例。 + +## 1. 适用范围与优先级 + +- 本规则适用于仓库根目录及所有子目录。 +- 用户的明确指令优先于本文件。如有刻意偏离,须在最终总结中说明。 +- 修改前先查看相关实现。复用现有的辅助函数、组件 API、代码生成工作流和邻近代码的模式,不要依赖记忆行事。 +- 保持改动范围聚焦。保留与本次任务无关的、用户自有的工作区改动,包括已暂存的改动。 +- 审查、调研或诊断类请求默认为只读,除非用户同时要求实现。 + +## 2. 当前架构 + +仓库包含三个应用区域: + +- Go 服务端:`cmd/server` 和 `internal/*` +- Next.js 应用:`web/*` +- 内嵌的工作流编辑器:`flowgram-editor/*`,构建产物位于 `web/public/flowgram-editor` + +主要技术栈: + +- Go 1.26、Gin、GORM、`github.com/mlogclub/simple` +- SQLite 和 MySQL +- Next.js 16 App Router、React 19、TypeScript、Tailwind CSS、shadcn/Base UI +- 两个前端项目均使用 `pnpm` +- 可选通过 CGO 构建 LanceDB 支持;应用同时支持 Qdrant + +重要入口: + +- 服务装配与中间件:`internal/bootstrap/server.go` +- 显式 API 路由:`internal/bootstrap/routes.go` +- 模型注册:`internal/models/models.go` +- 启动时结构/数据迁移:`internal/bootstrap/migration.go` +- CRUD 生成器:`cmd/generator/generator.go` +- 前端枚举生成器:`cmd/enums/generator.go` +- 前端 API 客户端:`web/lib/api/client.ts` +- 前端国际化:`web/i18n/*` 和 `web/messages/*` +- 控制台共享组件:`web/components/dashboard/*` +- 项目命令:`Taskfile.yml` + +## 3. 通用改动规则 + +- 使用某个类型、函数或组件前,先阅读其真实签名。 +- 优先使用满足需求的最高层级现有抽象。不要重复实现查询状态、分页、鉴权刷新、本地化或控制台 CRUD 行为。 +- 不要手工编辑生成产物。应修改其源头并运行对应的生成器/构建命令。 +- 当仓库对同一关注点已有共享实现路径时,不要引入第二种实现风格。 +- 新的 Go 日志使用 `log/slog`,并以结构化键值字段记录相关上下文。 +- 新 Go 代码使用 `any`,不使用 `interface{}`。已有的生成代码或遗留代码无需做无关清理。 +- 密钥、令牌、凭据及客户隐私数据绝不能提交到仓库,也不能打印到日志/测试中。 + +## 4. Go 后端 + +### 4.1 分层职责 + +正常的依赖与数据流方向为: + +`models -> repositories -> services -> handlers -> builders/响应 DTO` + +- `internal/models`:仅包含实体字段、GORM 映射、关联关系和 schema 元数据。 +- `internal/repositories`:GORM/SQL 访问、条件、排序、分页、锁及持久化细节。 +- `internal/services`:业务校验、状态变更、与授权无关的领域规则、聚合、事务及事件编排。 +- `internal/handlers/{api,dashboard,third}`:HTTP 参数解析、认证/权限检查、调用服务、写入响应。 +- `internal/builders`:纯粹的模型/聚合到响应的映射。Builder 不得查询数据库。 +- `internal/pkg/dto/request` 和 `internal/pkg/dto/response`:对外请求/响应契约。 + +强制边界: + +- Handler 不得直接调用 repository 或发起 GORM 查询。 +- Model 和 repository 不得包含 HTTP、权限或跨资源工作流逻辑。 +- GORM model 不得直接从 API 返回。必须映射为响应 DTO/builder。 +- Service 内部可以返回 model,但对外响应结构仍由 builder/DTO 负责。 +- 可复用的 SQL 放在 repository 中。只有在抽取出来反而会使职责归属更不清晰时,真正一次性的聚合查询才可以留在其领域 service 附近。 + +### 4.2 数据库与事务 + +- 参与事务的 repository 方法接收 `db *gorm.DB`;事务外用 `sqls.DB()` 调用,事务内用 `ctx.Tx` 调用。 +- Service 通过 `sqls.WithTransaction` 拥有事务边界。 +- 原子性的多次写入工作流和对一致性敏感的读-改-写操作必须使用事务。 +- 不要为单个独立的 SQL 写操作添加事务。 +- 事务内的每个数据库操作都必须使用同一个 `ctx.Tx`;绝不能在事务中途逃逸到 `sqls.DB()`。 +- 普通的过滤和分页使用 `sqls.Cnd`/`sqls.NewCnd()` 及 repository 方法。 +- 保持 SQLite 和 MySQL 兼容。避免方言专属 SQL,除非两种方言都已显式实现并测试。 +- 使用可移植的列类型和 `int64` 主键/外键标识。时间处理需兼容 MySQL 的 `parseTime=True`。 + +### 4.3 模型、代码生成与迁移 + +- 持久化模型须注册到 `internal/models/models.go`,以便启动时的 `AutoMigrate(models.Models...)` 包含它们。 +- 新表、新列、索引及兼容性约束通常由 `internal/bootstrap/migration.go` 中的 GORM `AutoMigrate` 应用。 +- `internal/migration/*` 仅用于有版本的、幂等的数据迁移/回填/修复工作。其版本号必须单调递增。 +- 当模型使用标准的生成 repository/service 接口时,将其注册到 `cmd/generator/generator.go` 并运行 `task generator`。 +- 把生成器输出视为机械基础设施。业务专属方法放在手写文件中,不要手工修补生成的 CRUD 代码。 +- 后端/前端共享枚举定义在 `internal/pkg/enums`,按现有枚举模式添加注解,通过 `task enums` 生成到 `web/lib/generated/enums.ts`。 +- 绝不要在前端手写一份与已生成后端枚举重复的枚举。 + +### 4.4 HTTP API + +- 所有路由都在 `internal/bootstrap/routes.go` 中显式声明;handler 名称不会自动创建端点。 +- 公开/产品 API 位于 `/api/*` 下,需要认证的管理 API 位于 `/api/dashboard/*` 下,回调位于 `/api/third/*` 下,WebSocket 位于 `/api/ws/*` 下。 +- 新增资源专属的 `register...Routes` 函数或扩展现有的函数,然后在 `addRouter` 中挂载到正确的分组。 +- 遵循现有资源约定:详情通常用 `GET /:id`,列表用 `/list`,写操作使用显式 POST 动作如 `/create`、`/update`、`/delete`,领域动作保留其既定的 snake_case 路径名。 +- 不要引入 `/api/v1`、自动路由假设或不必要的深层嵌套资源路径。 +- Handler 名称与注册的方法和路径对应,例如 `XxxGetBy`、`XxxAnyList`、`XxxPostCreate`。 +- 使用 `internal/pkg/httpx/params` 和 `internal/pkg/httpx` 辅助函数解析 JSON/表单/query/path 参数。 +- 控制台权限检查在领域工作之前使用 `services.AuthService.RequirePermission` 或既定的权限辅助函数。 +- 通过 `httpx.WriteJSON` 写入响应;保持共享的 `JsonResult` 契约。 +- 分页响应使用 `web.PageResult`,包含 `data.results` 和 `data.page`。 +- 将未找到、校验失败、权限不足和持久化失败转换为稳定的应用错误。绝不要把原始 SQL 错误暴露给客户端。 + +### 4.5 后端国际化 + +- 任何新增或变更的、用户可见的后端错误都必须支持全部后端语言,目前为 `zh-CN` 和 `en-US`。 +- 在同一次改动中,向 `internal/pkg/i18nx/locales/zh-CN.yml` 和 `internal/pkg/i18nx/locales/en-US.yml` 添加对应的键。 +- Service 应通过 `errorsx.*I18n` 辅助函数返回本地化的应用错误。 +- 需要立即返回本地化响应的 handler 使用 `httpx.JsonErrorMsg(ctx, key, args...)`;其他请求上下文内的翻译使用 `i18nx.T`。 +- 当中英文案可能触达用户时,不要在 handler/service 中硬编码中英文错误语句。 +- 各语言之间保持格式化参数一致,并为新的可复用/错误格式化行为补充聚焦测试。 + +## 5. 前端 + +### 5.1 组件与数据边界 + +- 应用路由位于 `web/app`;可复用业务组件位于 `web/components`;路由私有组件位于该路由的 `_components` 目录。 +- 复用 `web/components/ui/*` 基础组件。不要为了某个功能的特定需求修改这些 shadcn 基础组件。 +- 使用仓库中实际实现的 Base UI/shadcn 组件 API;不要假设存在 Radix 的 `asChild` 等 API。 +- `web` 内的代码使用 `@/*` 导入。 +- 客户端组件在使用状态、effect、浏览器 API 或仅客户端可用的 hook 时,必须声明 `"use client"`。 +- 资源 API 放在 `web/lib/api/*`,所有常规请求都通过 `web/lib/api/client.ts` 发起。 +- 页面、业务组件和 store 不得自行实现 `JsonResult` 解析、鉴权头处理、令牌刷新或登录过期清理。 +- 直接使用 `fetch` 仅限于不支持的传输场景,例如第三方调用、二进制传输、SSE 和 WebSocket 握手;须在代码中说明例外原因。 +- 标准下拉选择优先使用 `OptionCombobox`,而不是新增基于 shadcn Select 的业务控件。 +- 展示的时间戳使用 `web/lib/utils.ts` 中的 `formatDateTime` 格式化,除非产品明确要求其他表现形式。 + +### 5.2 控制台页面 + +构建控制台页面之前,先查看 `web/components/dashboard/crud`、`web/components/dashboard/list` 和 `web/components/dashboard-page.tsx`。 + +按以下优先级顺序选择: + +1. 标准增删改查资源使用 `DashboardCrudPage`。 +2. 只读或自定义内容的分页资源使用 `DashboardListPage`。 +3. 布局是定制的、但列表查询/筛选/分页生命周期是标准的,使用 `useDashboardPagedList`。 +4. 只有当高层级组件无法满足交互需求时,才使用 `DashboardPage`、`DashboardToolbar`、`DashboardTableShell` 及相关底层原语。 + +规则: + +- 不要复制某个资源页面来重建标准筛选、查询/重置/刷新动作、分页、加载/空状态、确认框、行操作或对话框。 +- 在添加页面本地基础设施之前,先通过 `DashboardCrudPage` 的筛选器、列、标签、服务回调、行操作、排序以及表单/对话框扩展点进行配置。 +- 在支持的情况下使用其 schema 驱动的 `DashboardCrudFormDialog`。真正自定义的表单可以放在 `_components` 中,通常应使用 `react-hook-form`、`zod` 和 `Field`。 +- 通过列或 `renderContent` 配置 `DashboardListPage`,资源专属动作使用 `renderToolbarActions`。 +- 业务 API 知识留在页面/服务模块中;通用控制台组件不得导入资源专属 API。 +- 对 `web/components/dashboard/*` 的改动必须是通用的、向后兼容的、且对不止一个页面有用。否则应将其局限在功能模块内。 + +### 5.3 前端国际化 + +- 每一个前端功能和改动都必须在所有 `SUPPORTED_LOCALES`(目前为 `zh-CN` 和 `en-US`)下正常工作。 +- 每个新键都要在同一次改动中同时添加到 `web/messages/zh-CN.json` 和 `web/messages/en-US.json`,保持结构一致。 +- React 页面/组件使用 `useI18n()`;语言相关的格式化/映射也可以使用 `useAppLocale()`。 +- 非 React 代码使用 `web/i18n/messages.ts` 中的 `translateCurrentMessage()` 或 `translateMessage()`。 +- 不要在 JSX/TSX、toast 消息、对话框、确认框、占位符、校验提示、空/加载/错误状态、工具提示、无障碍标签或客户端兜底错误中硬编码用户可见文案。 +- 产品名称、协议字面量、用户内容和原始业务数据无需翻译,除非 UI 已提供显示名映射。 +- 提供给共享 CRUD/list 组件的控制台标签必须来自翻译键。 +- 将后端标识/枚举的本地化显示名集中到可复用的 `web/lib/*-i18n.ts` 辅助文件中,而不是在各页面重复编写语言分支。 +- 每种语言保留相同的占位符并用 `t(key, values)` 插值;不要通过拼接翻译片段来组装句子。 +- 语言配置归属于 `AppI18nProvider` 和 `web/i18n/config.ts`;各功能不得引入独立的语言检测或状态。 + +### 5.4 商业级 UI 与交互 + +这是一个商业产品,不是原型或组件演示。UI 工作只有在视觉统一、交互完整、响应式、已本地化、并能以真实生产数据可信呈现时才算完成。 + +#### 视觉质量 + +- 遵循现有设计语言、间距尺度、字体排版、圆角、色彩令牌和组件变体。新功能必须看起来像产品原生的一部分,而不是粘贴进来的模板。 +- 在控制台(support platform)UI 中,如需圆角,统一使用 `rounded-md`。 +- 控制台组件不应添加 `shadow`。 +- 建立清晰的层次:页面标题/主要操作、筛选或上下文、主要内容、次要信息。不要把每个区域都做成卡片,也不要让每个操作都在视觉上突出。 +- 优先采用克制、有目的性的样式。避免装饰性渐变、超大标题文字、过度阴影、玻璃拟态、emoji 图标、随意的强调色和装饰性文案,除非产品场景明确需要。 +- 一致地使用 Lucide 图标。按含义选择图标,保持图标尺寸和描边粗细与邻近控件一致,绝不使用没有传达意义的纯装饰性图标。 +- 在每个断点下保持间距和对齐的严谨。标签、输入框、表格列、操作组、对话框页脚和空状态必须整齐对齐,不得有临时拼凑的偏移。 +- 为真实内容而非理想示例文本而设计。验证长名称、多行内容、大数量、缺失可选字段以及中英文混排的情况。有意识地使用换行、截断、工具提示或滚动容器。 +- 保持适合运营控制台的信息密度。不要把大面积空间浪费在装饰上,但也不要把控件压缩到难以浏览和点击。 + +#### 交互完整性 + +- 每个异步操作都必须有即时且明确的状态:进行中/加载中、成功、失败,以及在适当时提供重试或恢复。 +- 防止重复提交。变更进行中时禁用或锁定触发控件,并显示针对该操作的进度文案或加载图标,且不引起布局跳动。 +- 反馈要贴近操作本身。字段问题使用内联校验,内容加载失败使用情境化错误状态,已完成的后台或页面级操作使用 toast 通知。 +- 绝不静默丢弃用户输入。当存在有意义的未保存改动时,在关闭、离开页面、重置或切换上下文之前发出警告。 +- 破坏性、不可逆、安全敏感或影响广泛的操作需要确认,确认文案须明确指出操作对象和后果。不要使用泛化的“你确定吗?”消息。 +- 确认不能替代良好的默认值:常规可逆操作应保持高效,不应被不必要的弹窗打断。 +- 创建/更新/删除操作后,保持列表状态一致:刷新受影响的数据,尽可能保留有用的筛选条件/页码位置,仅在成功时关闭对话框,防止出现过期的选中项或详情面板。 +- 按钮和菜单项必须使用准确描述结果的动词。当有具体的操作名可用时,避免“确定”“提交”“处理”这类模糊标签。 +- 保持键盘行为和焦点流转:Enter 仅在预期场景提交,Escape 关闭可 dismiss 的浮层,焦点进入对话框并在关闭后返回触发元素,破坏性操作不能是意外的默认项。 +- 可交互的行、图标、徽标和文字链接必须仅在确实可交互时才呈现可交互的外观。不要依赖仅悬停可见来发现关键操作。 + +#### 表单与对话框 + +- 使用最小且合适的交互容器:简单的局部改动用内联编辑,聚焦的任务用对话框,复杂或多步骤工作流用整页/工作台。 +- 对话框需要清晰的标题、必要时简洁的上下文说明、稳定的正文布局,以及一致的页脚(次要操作在前、主要操作在后)。长内容必须在对话框内部滚动,而不能把按钮挤出屏幕。 +- 表单必须有可见标签、合适的控件、有用的默认值、必填/选填语义,示例或帮助文本仅在能减少歧义时提供。 +- 在正确的时机校验:不要在用户交互前显示错误,修正后清除过期错误,并尽可能把后端校验失败映射到对应字段。 +- 提交失败后保留已输入的值。在服务器确认成功之前不要重置或关闭表单。 +- 依赖字段必须清晰反映禁用/加载/空状态。当一个字段使另一个字段失效时,应以可预测的方式更新后者,并在依赖关系不明显时加以说明。 +- 配置类和高影响表单不应在刻意的编辑模式能提升安全性时,仍暴露永久可编辑的界面。保存敏感配置需要明确的用户意图和恰当的确认。 + +#### 列表、表格与运营界面 + +- 使用共享的控制台 CRUD/list 体系,在各资源之间保持一致的工具栏、筛选、分页、加载、空状态和操作位置。 +- 筛选必须区分草稿值与已应用的查询状态。查询、重置、刷新、分页和 URL/状态行为应当可预测,不得意外地相互清除。 +- 表格必须保持可扫描性:对齐可比较的值,保持操作列稳定,状态徽标少用,当详情视图更合适时避免拥挤的多行单元格,并在窄屏上提供横向滚动。 +- 空状态必须区分“数据不存在”与“当前筛选无匹配结果”,并在用户可以自行解决时提供最相关的下一步操作。 +- 加载状态应保持布局。形状已知的内容优先使用骨架屏,局部操作使用紧凑的加载图标;避免用居中的加载图标替换整个稳定页面。 +- 错误状态必须用用户能理解的措辞说明失败原因,并在重试有意义时提供重试。请求失败后绝不要留下空白表格或空面板。 +- 批量操作必须显示选中数量、影响范围、适用条件及部分失败的结果。当选中项不再有效时清除选择。 + +#### 响应式与无障碍要求 + +- 每个改动的界面都必须在桌面宽度和窄屏/移动宽度下可用。不得把横向裁切、控件重叠、操作换行混乱或对话框按钮超出屏幕视为可接受。 +- 响应式行为必须保持任务优先级:主要操作始终可触达,次要操作可以收进菜单,筛选项可以堆叠或折叠,表格可以滚动但不得隐藏行标识/操作。 +- 使用语义化控件和无障碍名称。仅图标的按钮在含义并非普遍显而易见时,需要本地化的无障碍标签和工具提示。 +- 保持可见的焦点状态、合理的 Tab 顺序、足够的点击目标尺寸和充分的文字/背景对比度。不要仅用颜色表达状态或错误。 +- 尊重“减少动态效果”偏好。动画应有助于表达状态或连续性,保持克制,且绝不耽误操作。 + +#### 产品文案与数据可信度 + +- 文案必须像成品:简洁、具体、一致、以行动为导向。不要向用户暴露实现术语、占位符式文字、“TODO”、模拟标签、调试措辞或开发者说明。 +- 不要交付虚假统计、示例记录、看似禁用的占位按钮、无意义的装饰图表,或只会向控制台打印日志的交互。 +- 区分功能不可用与数据为空。如果某项能力尚未实现,不要渲染一个假装可用的控件。 +- 用户可见的名称、状态、权限、日期和错误必须使用既定的格式化和 i18n 映射,而不是原始的后端标识。 + +#### UI 验证 + +- 对于有意义的 UI 改动,在真实浏览器中以有代表性的桌面宽度和窄屏宽度检查完成后的界面。仅靠源码审查和类型检查不足以完成视觉验证。 +- 演练完整交互,包括初始加载、有数据状态、空/筛选状态、校验失败、在可行时模拟服务器失败、进行中/禁用行为、成功、取消/关闭以及刷新。 +- 同时检查 `zh-CN` 和 `en-US`;验证翻译文案不会溢出、截断关键含义或破坏控件对齐。 +- 交付前移除验证期间引入的临时数据、调试 UI、控制台输出、仅用于测试的快捷方式和视觉残留。 +- 当无法进行浏览器验证时,须明确说明这一限制;不要将 UI 描述为已经过视觉验证。 + +### 5.5 生成的与内嵌的前端资产 + +- `web/lib/generated/enums.ts` 由 `task enums` 生成;不要手工编辑。 +- `web/public/sdk/agent-desk-sdk.min.js` 由 SDK 源码生成。SDK 源码变更时,运行 `cd web && pnpm build:sdk` 及其聚焦的 SDK 测试。 +- `web/public/flowgram-editor` 由 `flowgram-editor` 产出;编辑源项目,而不是生成的 public 产物。 +- 修改 `flowgram-editor` 时,使用它自己的 `pnpm` 脚本,并确保内嵌构建仍然成功。 +- Next.js 应用以静态导出方式内嵌进 Go 二进制文件。避免与当前导出和内嵌模式冲突的、仅运行时可用的 Next.js 特性。 + +## 6. 测试与验证 + +验证必须与改动的范围相匹配。不要声称运行过并未执行的检查。 + +- Go 格式化:对每个改动的 `.go` 文件运行 `gofmt`。 +- Go 行为:为改动的业务规则、事务、安全边界、参数解析和可复用辅助函数添加聚焦测试;先运行小范围的包测试,条件允许时再运行 `go test ./...`。 +- 前端 TypeScript:前端改动运行 `cd web && pnpm typecheck`。 +- 前端 lint:较广泛的组件/页面改动或改动了 lint 敏感代码时,运行 `cd web && pnpm lint`。 +- 前端逻辑:改动工具函数、i18n 映射、生成的 SDK 行为或其他有聚焦测试的模块时,运行相关的 `node --test ...` 文件。 +- 工作流编辑器:改动该项目时运行 `flowgram-editor` 相关的 lint/build 命令。 +- 代码生成:模型 CRUD 或共享枚举变更后,运行对应的 `task generator` 或 `task enums` 并审查生成的差异。 +- 构建:当改动影响前端内嵌、构建配置、生成的公共资产或发布装配时,使用 `task build`。 +- 当有可运行环境时,有意义的视觉或交互改动应进行浏览器验证;未执行时须明确说明。 +- 仅文档类改动至少需要 `git diff --check`,并验证每个引用的路径/命令确实存在。 + +## 7. 完成检查清单 + +交付改动之前,确认适用的各项: + +- 实现遵循当前的分层/组件职责划分,没有产生反向依赖。 +- 事务恰好覆盖必须原子的操作,且所有事务内数据库调用使用同一个 `ctx.Tx`。 +- API 路由已显式注册,响应保持 `JsonResult`/`PageResult` 契约。 +- 模型、迁移、查询和测试保持 SQLite 和 MySQL 兼容。 +- 后端和前端用户可见文本均中英文完整。 +- 控制台页面复用了 `web/components/dashboard/*` 下最高层级的合适组件。 +- UI 改动达到商业级标准:状态完整、反馈精准、变更安全、响应式布局、控件无障碍、文案/数据可信,无仅用于演示的行为。 +- 生成文件由源头重新生成,未被手工编辑。 +- 已运行相关的测试/类型检查/lint/构建/浏览器检查,并报告了任何验证限制。 +- `git diff --check` 通过,且无关的工作区改动保持原样未动。 diff --git a/Dockerfile b/Dockerfile index 77572bcd..8d159647 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,6 +25,7 @@ RUN pnpm build:sdk && pnpm build FROM golang:1.26-alpine AS server-builder WORKDIR /src +ENV GOPROXY=https://goproxy.cn,direct RUN apk add --no-cache git COPY go.mod go.sum ./ @@ -43,6 +44,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \ FROM golang:1.26-trixie AS server-builder-lancedb WORKDIR /src +ENV GOPROXY=https://goproxy.cn,direct ARG TARGETOS=linux ARG TARGETARCH diff --git a/README_ZH.md b/README_ZH.md index 6a59a116..2ad527f7 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -257,15 +257,21 @@ flowchart LR 如果只需要构建应用镜像,可以自行准备 MySQL 和 Qdrant,并挂载配置文件: ```bash -docker build -t mlogclub/agent-desk . +docker build -t yekay/agent-desk:1.6.3.3 . docker run --rm -p 8083:8083 \ -v $(pwd)/docker/agent-desk.yaml:/app/config/config.yaml:ro \ -v agent-desk-data:/app/data \ - mlogclub/agent-desk + yekay/agent-desk:1.6.3.3 ``` Compose 使用 [docker/agent-desk.yaml](docker/agent-desk.yaml) 作为容器内配置,应用会通过 Docker 内部服务名访问 `mysql` 和 `qdrant`。 +导出镜像: + +```bash +docker save -o agent-desk.tar yekay/agent-desk:1.6.3.3 +``` + ## 开源定位 `AgentDesk` 适合作为以下方向的开源基础项目: diff --git a/cmd/import_jsh_knowledge/main.go b/cmd/import_jsh_knowledge/main.go new file mode 100644 index 00000000..ac90f820 --- /dev/null +++ b/cmd/import_jsh_knowledge/main.go @@ -0,0 +1,314 @@ +// Package main 是一次性数据导入工具:将本地 jsh-knowledge 目录按层级 +// 导入 t_knowledge_directory / t_knowledge_document。 +// +// 用法: +// +// go run ./cmd/import_jsh_knowledge -kb 3 -root ./jsh-knowledge +// +// 工具可重复执行(幂等):已存在的同名目录会复用,同目录下同名文档会跳过。 +package main + +import ( + "crypto/sha256" + "encoding/hex" + "flag" + "fmt" + "log" + "log/slog" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "agent-desk/internal/ai/rag" + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" + "gorm.io/gorm/schema" +) + +// frontmatterRe 匹配文件开头的 YAML frontmatter(---\n ... \n---\n)。 +var frontmatterRe = regexp.MustCompile(`(?s)\A---\r?\n.*?\r?\n---\r?\n?`) + +// leadingNumberRe 提取目录名开头的数字前缀(如 "02-合作平台" -> 2)。 +var leadingNumberRe = regexp.MustCompile(`^\d+`) + +type options struct { + dsn string + root string + kbID int64 + stripFrontmatter bool + dryRun bool +} + +func main() { + opt := options{} + flag.StringVar(&opt.dsn, "dsn", "root:123456abcde@tcp(127.0.0.1:3306)/cs_ai_agent?charset=utf8mb4&parseTime=True&loc=Local", "MySQL DSN") + flag.StringVar(&opt.root, "root", "jsh-knowledge", "待导入的知识目录根路径") + flag.Int64Var(&opt.kbID, "kb", 3, "目标知识库 ID(t_knowledge_base.id)") + flag.BoolVar(&opt.stripFrontmatter, "strip-frontmatter", true, "是否剥离 Markdown 开头的 YAML frontmatter") + flag.BoolVar(&opt.dryRun, "dry-run", false, "只演练不写库") + flag.Parse() + + if err := run(opt); err != nil { + slog.Error("import failed", "error", err) + os.Exit(1) + } +} + +func run(opt options) error { + entries, err := scanRoot(opt.root) + if err != nil { + return err + } + if len(entries) == 0 { + return fmt.Errorf("目录 %s 下没有可导入的子目录", opt.root) + } + + db, err := gorm.Open(mysql.Open(opt.dsn), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}, + Logger: logger.New( + log.New(os.Stdout, "\r\n", log.LstdFlags), + logger.Config{ + SlowThreshold: time.Second, + LogLevel: logger.Warn, + IgnoreRecordNotFoundError: true, + }, + ), + }) + if err != nil { + return fmt.Errorf("连接数据库失败: %w", err) + } + + // 校验目标知识库存在。 + var kb models.KnowledgeBase + if err := db.Select("id", "name", "knowledge_type", "status"). + First(&kb, opt.kbID).Error; err != nil { + return fmt.Errorf("知识库 id=%d 不存在或不可读: %w", opt.kbID, err) + } + slog.Info("目标知识库", "id", kb.ID, "name", kb.Name, "type", kb.KnowledgeType, "status", kb.Status) + + var dirCreated, dirReused, docCreated, docSkipped int + now := time.Now() + + for idx, entry := range entries { + sortNo := parseLeadingNumber(entry.name) + if sortNo == 0 { + sortNo = idx + 1 + } + + // 每个分类目录一个事务:目录行与其下文档要么全部落库要么整体回滚。 + var dirID int64 + var dirNew bool + var n importCounters + err := db.Transaction(func(tx *gorm.DB) error { + var terr error + dirID, dirNew, terr = findOrCreateDirectory(tx, opt, entry.name, sortNo, now) + if terr != nil { + return terr + } + n, terr = importDocuments(tx, opt, dirID, entry.files, now) + return terr + }) + if err != nil { + return fmt.Errorf("目录 %s 处理失败: %w", entry.name, err) + } + if dirNew { + dirCreated++ + } else { + dirReused++ + } + docCreated += n.created + docSkipped += n.skipped + slog.Info("目录完成", + "name", entry.name, "dir_id", dirID, "new_dir", dirNew, + "new_docs", n.created, "skipped_docs", n.skipped) + } + + slog.Info("导入完成", + "dry_run", opt.dryRun, + "directories_created", dirCreated, + "directories_reused", dirReused, + "documents_created", docCreated, + "documents_skipped", docSkipped) + return nil +} + +type dirEntry struct { + name string + files []string +} + +func scanRoot(root string) ([]dirEntry, error) { + top, err := os.ReadDir(root) + if err != nil { + return nil, fmt.Errorf("读取根目录失败: %w", err) + } + var out []dirEntry + for _, d := range top { + if !d.IsDir() { + continue + } + full := filepath.Join(root, d.Name()) + files, err := collectMarkdownFiles(full) + if err != nil { + return nil, err + } + if len(files) == 0 { + slog.Warn("目录下没有 md 文件,已跳过", "dir", full) + continue + } + out = append(out, dirEntry{name: d.Name(), files: files}) + } + sort.Slice(out, func(i, j int) bool { return out[i].name < out[j].name }) + return out, nil +} + +// collectMarkdownFiles 递归收集 .md 文件(当前数据只有一层,递归以兼容未来嵌套)。 +func collectMarkdownFiles(dir string) ([]string, error) { + var files []string + + err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if path != dir { + // 当前导入器只支持一层分类目录;出现嵌套时显式告警, + // 避免文件被静默平铺到错误的目录下。 + slog.Warn("发现嵌套子目录,其内文件将被平铺到一级目录(如需多层请扩展导入器)", "subdir", path) + } + return nil + } + if strings.EqualFold(filepath.Ext(d.Name()), ".md") { + files = append(files, path) + } + return nil + }) + if err != nil { + return nil, err + } + sort.Strings(files) + return files, nil +} + +func parseLeadingNumber(name string) int { + m := leadingNumberRe.FindString(name) + if m == "" { + return 0 + } + n, err := strconv.Atoi(m) + if err != nil { + return 0 + } + return n +} + +// findOrCreateDirectory 在同一知识库/同一父级(0)下按名称查找目录,没有则创建。 +func findOrCreateDirectory(db *gorm.DB, opt options, name string, sortNo int, now time.Time) (int64, bool, error) { + var existing models.KnowledgeDirectory + err := db.Where("knowledge_base_id = ? AND parent_id = 0 AND name = ?", opt.kbID, name). + First(&existing).Error + if err == nil { + return existing.ID, false, nil + } + if err != gorm.ErrRecordNotFound { + return 0, false, err + } + + dir := &models.KnowledgeDirectory{ + KnowledgeBaseID: opt.kbID, + ParentID: 0, + Name: name, + SortNo: sortNo, + Status: enums.StatusOk, + AuditFields: buildAuditFields(now), + } + if opt.dryRun { + slog.Info("[dry-run] 将创建目录", "name", name, "sort_no", sortNo) + return int64(sortNo), true, nil + } + if err := db.Create(dir).Error; err != nil { + return 0, false, err + } + return dir.ID, true, nil +} + +type importCounters struct { + created int + skipped int +} + +func importDocuments(db *gorm.DB, opt options, dirID int64, files []string, now time.Time) (importCounters, error) { + var c importCounters + for _, path := range files { + title := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + + var existing models.KnowledgeDocument + err := db.Where("knowledge_base_id = ? AND directory_id = ? AND title = ?", opt.kbID, dirID, title). + First(&existing).Error + if err == nil { + c.skipped++ + continue + } + if err != gorm.ErrRecordNotFound { + return c, err + } + + raw, err := os.ReadFile(path) + if err != nil { + return c, fmt.Errorf("读取文件 %s 失败: %w", path, err) + } + content := string(raw) + if opt.stripFrontmatter { + content = frontmatterRe.ReplaceAllString(content, "") + } + content = strings.TrimSpace(content) + if content == "" { + slog.Warn("文档内容为空,仍将导入", "file", path) + } + + doc := &models.KnowledgeDocument{ + KnowledgeBaseID: opt.kbID, + DirectoryID: dirID, + Title: title, + ContentType: enums.KnowledgeDocumentContentTypeMarkdown, + Content: content, + Status: enums.StatusOk, + IndexStatus: enums.KnowledgeDocumentIndexStatusPending, + AuditFields: buildAuditFields(now), + } + // 与正常建文档流程保持一致:hash 取正文纯文本的 SHA-256。 + if plain := rag.ExtractPlainText(content, enums.KnowledgeDocumentContentTypeMarkdown); plain != "" { + sum := sha256.Sum256([]byte(plain)) + doc.ContentHash = hex.EncodeToString(sum[:]) + } + + if opt.dryRun { + c.created++ + continue + } + if err := db.Create(doc).Error; err != nil { + return c, fmt.Errorf("写入文档 %s 失败: %w", path, err) + } + c.created++ + } + return c, nil +} + +func buildAuditFields(now time.Time) models.AuditFields { + return models.AuditFields{ + CreatedAt: now, + CreateUserID: 0, + CreateUserName: "system", + UpdatedAt: now, + UpdateUserID: 0, + UpdateUserName: "system", + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go index 1e478faf..500abcf3 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -3,9 +3,13 @@ package main import ( "flag" "log/slog" + "os" + "os/signal" + "syscall" "agent-desk/internal/bootstrap" "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/logx" ) func main() { @@ -25,6 +29,15 @@ func main() { return } + // 监听退出信号,确保 db sink 中缓冲的日志在进程退出前 flush 到数据库。 + go func() { + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + <-stop + _ = logx.Close() + os.Exit(0) + }() + if err := app.Run(cfg.Server.Address()); err != nil { slog.Error("start server failed", "error", err) return diff --git a/docker-compose.qdrant.yml b/docker-compose.qdrant.yml new file mode 100644 index 00000000..84e192d2 --- /dev/null +++ b/docker-compose.qdrant.yml @@ -0,0 +1,30 @@ +services: + qdrant: + image: qdrant/qdrant:latest + restart: unless-stopped + volumes: + - ./data/qdrant-data:/qdrant/storage + ports: + - "6333:6333" + - "6334:6334" + + agent-desk: + image: yekay/agent-desk:1.6.3 + restart: unless-stopped + depends_on: + qdrant: + condition: service_started + ports: + - "8083:8083" + volumes: + - ./data/agent-desk-data:/app/data + - ./docker/agent-desk.yaml:/app/config/config.yaml:ro + environment: + TZ: Asia/Shanghai + # 兼容 Linux 宿主机访问;Docker Desktop (Windows/Mac) 默认已提供 host.docker.internal + extra_hosts: + - "host.docker.internal:host-gateway" + +volumes: + qdrant-data: + agent-desk-data: diff --git a/docs b/docs deleted file mode 160000 index 1c433f70..00000000 --- a/docs +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 1c433f70deb674b3f083fc510b31bb5bedbead81 diff --git a/flowgram-editor/package.json b/flowgram-editor/package.json index e5ced5da..e4eb1846 100644 --- a/flowgram-editor/package.json +++ b/flowgram-editor/package.json @@ -49,7 +49,7 @@ "@types/react": "^18", "@types/react-dom": "^18", "@types/styled-components": "^5", - "typescript": "^5.8.3", + "typescript": "^6.0.3", "eslint": "^9.0.0", "cross-env": "~7.0.3", "@flowgram.ai/eslint-config": "1.0.12", diff --git a/flowgram-editor/pnpm-lock.yaml b/flowgram-editor/pnpm-lock.yaml index f647c230..c4301f17 100644 --- a/flowgram-editor/pnpm-lock.yaml +++ b/flowgram-editor/pnpm-lock.yaml @@ -19,7 +19,7 @@ importers: version: 1.0.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@flowgram.ai/form-materials': specifier: 1.0.12 - version: 1.0.12(@babel/core@8.0.1)(@babel/template@8.0.0)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/merge@6.12.2)(@coze-editor/code-language-shared@0.1.0-alpha.868621(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.6)(@lezer/common@1.5.2))(@floating-ui/dom@1.8.0)(@lezer/common@1.5.2)(@tiptap/suggestion@3.29.0(@floating-ui/dom@1.8.0)(@tiptap/core@3.29.0(@tiptap/pm@3.29.0))(@tiptap/pm@3.29.0))(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@5.3.11(@babel/core@8.0.1)(react-dom@18.3.1(react@18.3.1))(react-is@16.13.1)(react@18.3.1))(vue@3.5.40(typescript@5.9.3)) + version: 1.0.12(@babel/core@8.0.1)(@babel/template@8.0.0)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/merge@6.12.2)(@coze-editor/code-language-shared@0.1.0-alpha.868621(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.6)(@lezer/common@1.5.2))(@floating-ui/dom@1.8.0)(@lezer/common@1.5.2)(@tiptap/suggestion@3.29.0(@floating-ui/dom@1.8.0)(@tiptap/core@3.29.0(@tiptap/pm@3.29.0))(@tiptap/pm@3.29.0))(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@5.3.11(@babel/core@8.0.1)(react-dom@18.3.1(react@18.3.1))(react-is@16.13.1)(react@18.3.1))(vue@3.5.40(typescript@6.0.3)) '@flowgram.ai/free-container-plugin': specifier: 1.0.12 version: 1.0.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@5.3.11(@babel/core@8.0.1)(react-dom@18.3.1(react@18.3.1))(react-is@16.13.1)(react@18.3.1)) @@ -74,7 +74,7 @@ importers: devDependencies: '@flowgram.ai/eslint-config': specifier: 1.0.12 - version: 1.0.12(@types/node@18.19.130)(jiti@2.7.0)(typescript@5.9.3) + version: 1.0.12(@types/node@18.19.130)(jiti@2.7.0)(supports-color@5.5.0)(typescript@6.0.3) '@flowgram.ai/ts-config': specifier: 1.0.12 version: 1.0.12 @@ -107,10 +107,10 @@ importers: version: 7.0.3 eslint: specifier: ^9.0.0 - version: 9.39.5(jiti@2.7.0) + version: 9.39.5(jiti@2.7.0)(supports-color@5.5.0) typescript: - specifier: ^5.8.3 - version: 5.9.3 + specifier: ^6.0.3 + version: 6.0.3 packages: @@ -2803,6 +2803,7 @@ packages: eslint@9.39.5: resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -4213,6 +4214,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -4280,10 +4286,12 @@ packages: uuid@10.0.0: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true v8-compile-cache-lib@3.0.1: @@ -4416,18 +4424,18 @@ snapshots: obug: 2.1.4 semver: 7.8.5 - '@babel/eslint-parser@7.19.1(@babel/core@8.0.1)(eslint@9.39.5(jiti@2.7.0))': + '@babel/eslint-parser@7.19.1(@babel/core@8.0.1)(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))': dependencies: '@babel/core': 8.0.1 '@nicolo-ribaudo/eslint-scope-5-internals': 5.1.1-v1 - eslint: 9.39.5(jiti@2.7.0) + eslint: 9.39.5(jiti@2.7.0)(supports-color@5.5.0) eslint-visitor-keys: 2.1.0 semver: 6.3.1 - '@babel/eslint-plugin@7.29.7(@babel/eslint-parser@7.19.1(@babel/core@8.0.1)(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0))': + '@babel/eslint-plugin@7.29.7(@babel/eslint-parser@7.19.1(@babel/core@8.0.1)(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0)))(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))': dependencies: - '@babel/eslint-parser': 7.19.1(@babel/core@8.0.1)(eslint@9.39.5(jiti@2.7.0)) - eslint: 9.39.5(jiti@2.7.0) + '@babel/eslint-parser': 7.19.1(@babel/core@8.0.1)(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0)) + eslint: 9.39.5(jiti@2.7.0)(supports-color@5.5.0) eslint-rule-composer: 0.3.0 '@babel/generator@7.29.7': @@ -4487,7 +4495,7 @@ snapshots: regexpu-core: 6.4.0 semver: 6.3.1 - '@babel/helper-define-polyfill-provider@0.3.3(@babel/core@8.0.1)': + '@babel/helper-define-polyfill-provider@0.3.3(@babel/core@8.0.1)(supports-color@5.5.0)': dependencies: '@babel/core': 8.0.1 '@babel/helper-compilation-targets': 7.29.7 @@ -5041,7 +5049,7 @@ snapshots: '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@8.0.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/preset-env@7.20.2(@babel/core@8.0.1)': + '@babel/preset-env@7.20.2(@babel/core@8.0.1)(supports-color@5.5.0)': dependencies: '@babel/compat-data': 7.29.7 '@babel/core': 8.0.1 @@ -5114,7 +5122,7 @@ snapshots: '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@8.0.1) '@babel/preset-modules': 0.1.6(@babel/core@8.0.1) '@babel/types': 7.29.7 - babel-plugin-polyfill-corejs2: 0.3.3(@babel/core@8.0.1) + babel-plugin-polyfill-corejs2: 0.3.3(@babel/core@8.0.1)(supports-color@5.5.0) babel-plugin-polyfill-corejs3: 0.6.0(@babel/core@8.0.1) babel-plugin-polyfill-regenerator: 0.4.1(@babel/core@8.0.1) core-js-compat: 3.49.0 @@ -5359,7 +5367,7 @@ snapshots: '@codemirror/view': 6.43.6 mitt: 3.0.1 - '@coze-editor/editor@0.1.0-alpha.868621(@babel/core@8.0.1)(@babel/template@8.0.0)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/merge@6.12.2)(@codemirror/state@6.7.1)(@codemirror/view@6.43.6)(@lezer/common@1.5.2)(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)(vue@3.5.40(typescript@5.9.3))': + '@coze-editor/editor@0.1.0-alpha.868621(@babel/core@8.0.1)(@babel/template@8.0.0)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/merge@6.12.2)(@codemirror/state@6.7.1)(@codemirror/view@6.43.6)(@lezer/common@1.5.2)(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)(vue@3.5.40(typescript@6.0.3))': dependencies: '@codemirror/commands': 6.10.4 '@codemirror/state': 6.7.1 @@ -5389,8 +5397,8 @@ snapshots: '@coze-editor/react-merge': 0.1.0-alpha.868621(@codemirror/merge@6.12.2)(@codemirror/state@6.7.1)(@codemirror/view@6.43.6)(@coze-editor/react@0.1.0-alpha.868621(@codemirror/state@6.7.1)(@codemirror/view@6.43.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@coze-editor/utils': 0.1.0-alpha.868621(@codemirror/state@6.7.1)(@codemirror/view@6.43.6)(@lezer/common@1.5.2) '@coze-editor/vscode': 0.1.0-alpha.868621(@codemirror/state@6.7.1)(@codemirror/view@6.43.6) - '@coze-editor/vue': 0.1.0-alpha.868621(@codemirror/commands@6.10.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.6)(@lezer/common@1.5.2)(vue@3.5.40(typescript@5.9.3)) - '@coze-editor/vue-components': 0.1.0-alpha.868621(@codemirror/language@6.12.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.6)(@coze-editor/vue@0.1.0-alpha.868621(@codemirror/commands@6.10.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.6)(@lezer/common@1.5.2)(vue@3.5.40(typescript@5.9.3)))(@lezer/common@1.5.2)(vue@3.5.40(typescript@5.9.3)) + '@coze-editor/vue': 0.1.0-alpha.868621(@codemirror/commands@6.10.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.6)(@lezer/common@1.5.2)(vue@3.5.40(typescript@6.0.3)) + '@coze-editor/vue-components': 0.1.0-alpha.868621(@codemirror/language@6.12.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.6)(@coze-editor/vue@0.1.0-alpha.868621(@codemirror/commands@6.10.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.6)(@lezer/common@1.5.2)(vue@3.5.40(typescript@6.0.3)))(@lezer/common@1.5.2)(vue@3.5.40(typescript@6.0.3)) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) transitivePeerDependencies: @@ -5673,28 +5681,28 @@ snapshots: '@lezer/highlight': 1.2.3 crelt: 1.0.7 - '@coze-editor/vue-components@0.1.0-alpha.868621(@codemirror/language@6.12.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.6)(@coze-editor/vue@0.1.0-alpha.868621(@codemirror/commands@6.10.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.6)(@lezer/common@1.5.2)(vue@3.5.40(typescript@5.9.3)))(@lezer/common@1.5.2)(vue@3.5.40(typescript@5.9.3))': + '@coze-editor/vue-components@0.1.0-alpha.868621(@codemirror/language@6.12.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.6)(@coze-editor/vue@0.1.0-alpha.868621(@codemirror/commands@6.10.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.6)(@lezer/common@1.5.2)(vue@3.5.40(typescript@6.0.3)))(@lezer/common@1.5.2)(vue@3.5.40(typescript@6.0.3))': dependencies: '@codemirror/state': 6.7.1 '@codemirror/view': 6.43.6 '@coze-editor/extension-placeholder': 0.1.0-alpha.868621(@codemirror/state@6.7.1)(@codemirror/view@6.43.6)(@lezer/common@1.5.2) '@coze-editor/extensions': 0.1.0-alpha.868621(@codemirror/language@6.12.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.6) '@coze-editor/utils': 0.1.0-alpha.868621(@codemirror/state@6.7.1)(@codemirror/view@6.43.6)(@lezer/common@1.5.2) - '@coze-editor/vue': 0.1.0-alpha.868621(@codemirror/commands@6.10.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.6)(@lezer/common@1.5.2)(vue@3.5.40(typescript@5.9.3)) + '@coze-editor/vue': 0.1.0-alpha.868621(@codemirror/commands@6.10.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.6)(@lezer/common@1.5.2)(vue@3.5.40(typescript@6.0.3)) '@floating-ui/dom': 1.8.0 - vue: 3.5.40(typescript@5.9.3) + vue: 3.5.40(typescript@6.0.3) transitivePeerDependencies: - '@codemirror/language' - '@lezer/common' - '@coze-editor/vue@0.1.0-alpha.868621(@codemirror/commands@6.10.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.6)(@lezer/common@1.5.2)(vue@3.5.40(typescript@5.9.3))': + '@coze-editor/vue@0.1.0-alpha.868621(@codemirror/commands@6.10.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.6)(@lezer/common@1.5.2)(vue@3.5.40(typescript@6.0.3))': dependencies: '@codemirror/state': 6.7.1 '@codemirror/view': 6.43.6 '@coze-editor/core': 0.1.0-alpha.868621(@codemirror/state@6.7.1)(@codemirror/view@6.43.6) '@coze-editor/core-plugins': 0.1.0-alpha.868621(@codemirror/commands@6.10.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.6)(@lezer/common@1.5.2) '@coze-editor/extension-placeholder': 0.1.0-alpha.868621(@codemirror/state@6.7.1)(@codemirror/view@6.43.6)(@lezer/common@1.5.2) - vue: 3.5.40(typescript@5.9.3) + vue: 3.5.40(typescript@6.0.3) transitivePeerDependencies: - '@codemirror/commands' - '@lezer/common' @@ -5865,14 +5873,14 @@ snapshots: '@emotion/unitless@0.7.5': {} - '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(jiti@2.7.0))': + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))': dependencies: - eslint: 9.39.5(jiti@2.7.0) + eslint: 9.39.5(jiti@2.7.0)(supports-color@5.5.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.2': + '@eslint/config-array@0.21.2(supports-color@5.5.0)': dependencies: '@eslint/object-schema': 2.1.7 debug: 4.4.3(supports-color@5.5.0) @@ -5888,7 +5896,7 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.3': + '@eslint/eslintrc@3.3.3(supports-color@5.5.0)': dependencies: ajv: 6.15.0 debug: 4.4.3(supports-color@5.5.0) @@ -5902,7 +5910,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/eslintrc@3.3.6': + '@eslint/eslintrc@3.3.6(supports-color@5.5.0)': dependencies: ajv: 6.15.0 debug: 4.4.3(supports-color@5.5.0) @@ -5966,10 +5974,10 @@ snapshots: react-dom: 18.3.1(react@18.3.1) reflect-metadata: 0.2.2 - '@flowgram.ai/coze-editor@1.0.12(@babel/core@8.0.1)(@babel/template@8.0.0)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/merge@6.12.2)(@codemirror/state@6.7.1)(@codemirror/view@6.43.6)(@coze-editor/code-language-shared@0.1.0-alpha.868621(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.6)(@lezer/common@1.5.2))(@lezer/common@1.5.2)(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@5.3.11(@babel/core@8.0.1)(react-dom@18.3.1(react@18.3.1))(react-is@16.13.1)(react@18.3.1))(vue@3.5.40(typescript@5.9.3))': + '@flowgram.ai/coze-editor@1.0.12(@babel/core@8.0.1)(@babel/template@8.0.0)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/merge@6.12.2)(@codemirror/state@6.7.1)(@codemirror/view@6.43.6)(@coze-editor/code-language-shared@0.1.0-alpha.868621(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.6)(@lezer/common@1.5.2))(@lezer/common@1.5.2)(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@5.3.11(@babel/core@8.0.1)(react-dom@18.3.1(react@18.3.1))(react-is@16.13.1)(react@18.3.1))(vue@3.5.40(typescript@6.0.3))': dependencies: '@coze-editor/code-language-typescript': 0.1.0-alpha.868621(@codemirror/language@6.12.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.6)(@coze-editor/code-language-shared@0.1.0-alpha.868621(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.6)(@lezer/common@1.5.2))(typescript@5.9.3) - '@coze-editor/editor': 0.1.0-alpha.868621(@babel/core@8.0.1)(@babel/template@8.0.0)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/merge@6.12.2)(@codemirror/state@6.7.1)(@codemirror/view@6.43.6)(@lezer/common@1.5.2)(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)(vue@3.5.40(typescript@5.9.3)) + '@coze-editor/editor': 0.1.0-alpha.868621(@babel/core@8.0.1)(@babel/template@8.0.0)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/merge@6.12.2)(@codemirror/state@6.7.1)(@codemirror/view@6.43.6)(@lezer/common@1.5.2)(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)(vue@3.5.40(typescript@6.0.3)) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) styled-components: 5.3.11(@babel/core@8.0.1)(react-dom@18.3.1(react@18.3.1))(react-is@16.13.1)(react@18.3.1) @@ -6025,29 +6033,29 @@ snapshots: react-dom: 18.3.1(react@18.3.1) reflect-metadata: 0.2.2 - '@flowgram.ai/eslint-config@1.0.12(@types/node@18.19.130)(jiti@2.7.0)(typescript@5.9.3)': + '@flowgram.ai/eslint-config@1.0.12(@types/node@18.19.130)(jiti@2.7.0)(supports-color@5.5.0)(typescript@6.0.3)': dependencies: '@babel/core': 8.0.1 - '@babel/eslint-parser': 7.19.1(@babel/core@8.0.1)(eslint@9.39.5(jiti@2.7.0)) - '@babel/eslint-plugin': 7.29.7(@babel/eslint-parser@7.19.1(@babel/core@8.0.1)(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) - '@babel/preset-env': 7.20.2(@babel/core@8.0.1) + '@babel/eslint-parser': 7.19.1(@babel/core@8.0.1)(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0)) + '@babel/eslint-plugin': 7.29.7(@babel/eslint-parser@7.19.1(@babel/core@8.0.1)(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0)))(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0)) + '@babel/preset-env': 7.20.2(@babel/core@8.0.1)(supports-color@5.5.0) '@babel/preset-react': 7.13.13(@babel/core@8.0.1) - '@eslint/eslintrc': 3.3.3 - '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/parser': 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) - eslint: 9.39.5(jiti@2.7.0) - eslint-config-prettier: 8.10.2(eslint@9.39.5(jiti@2.7.0)) + '@eslint/eslintrc': 3.3.3(supports-color@5.5.0) + '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@6.0.3) + '@typescript-eslint/parser': 8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@6.0.3) + eslint: 9.39.5(jiti@2.7.0)(supports-color@5.5.0) + eslint-config-prettier: 8.10.2(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0)) eslint-define-config: 1.12.0 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)) - eslint-plugin-babel: 5.3.1(eslint@9.39.5(jiti@2.7.0)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) - eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.5(jiti@2.7.0)) - eslint-plugin-prettier: 4.2.5(eslint-config-prettier@8.10.2(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0))(prettier@2.8.8) - eslint-plugin-react: 7.37.5(eslint@9.39.5(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0)))(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0) + eslint-plugin-babel: 5.3.1(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0)) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0)) + eslint-plugin-prettier: 4.2.5(eslint-config-prettier@8.10.2(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0)))(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(prettier@2.8.8) + eslint-plugin-react: 7.37.5(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0)) eslint-plugin-tsdoc: 0.2.17 prettier: 2.8.8 prettier-plugin-packagejson: 2.5.22(prettier@2.8.8) - ts-node: 10.9.2(@types/node@18.19.130)(typescript@5.9.3) + ts-node: 10.9.2(@types/node@18.19.130)(typescript@6.0.3) transitivePeerDependencies: - '@swc/core' - '@swc/wasm' @@ -6083,13 +6091,13 @@ snapshots: react-dom: 18.3.1(react@18.3.1) reflect-metadata: 0.2.2 - '@flowgram.ai/form-materials@1.0.12(@babel/core@8.0.1)(@babel/template@8.0.0)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/merge@6.12.2)(@coze-editor/code-language-shared@0.1.0-alpha.868621(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.6)(@lezer/common@1.5.2))(@floating-ui/dom@1.8.0)(@lezer/common@1.5.2)(@tiptap/suggestion@3.29.0(@floating-ui/dom@1.8.0)(@tiptap/core@3.29.0(@tiptap/pm@3.29.0))(@tiptap/pm@3.29.0))(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@5.3.11(@babel/core@8.0.1)(react-dom@18.3.1(react@18.3.1))(react-is@16.13.1)(react@18.3.1))(vue@3.5.40(typescript@5.9.3))': + '@flowgram.ai/form-materials@1.0.12(@babel/core@8.0.1)(@babel/template@8.0.0)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/merge@6.12.2)(@coze-editor/code-language-shared@0.1.0-alpha.868621(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.6)(@lezer/common@1.5.2))(@floating-ui/dom@1.8.0)(@lezer/common@1.5.2)(@tiptap/suggestion@3.29.0(@floating-ui/dom@1.8.0)(@tiptap/core@3.29.0(@tiptap/pm@3.29.0))(@tiptap/pm@3.29.0))(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@5.3.11(@babel/core@8.0.1)(react-dom@18.3.1(react@18.3.1))(react-is@16.13.1)(react@18.3.1))(vue@3.5.40(typescript@6.0.3))': dependencies: '@codemirror/state': 6.7.1 '@codemirror/view': 6.43.6 '@douyinfe/semi-icons': 2.101.1(react@18.3.1) '@douyinfe/semi-ui': 2.101.1(@floating-ui/dom@1.8.0)(@tiptap/suggestion@3.29.0(@floating-ui/dom@1.8.0)(@tiptap/core@3.29.0(@tiptap/pm@3.29.0))(@tiptap/pm@3.29.0))(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@flowgram.ai/coze-editor': 1.0.12(@babel/core@8.0.1)(@babel/template@8.0.0)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/merge@6.12.2)(@codemirror/state@6.7.1)(@codemirror/view@6.43.6)(@coze-editor/code-language-shared@0.1.0-alpha.868621(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.6)(@lezer/common@1.5.2))(@lezer/common@1.5.2)(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@5.3.11(@babel/core@8.0.1)(react-dom@18.3.1(react@18.3.1))(react-is@16.13.1)(react@18.3.1))(vue@3.5.40(typescript@5.9.3)) + '@flowgram.ai/coze-editor': 1.0.12(@babel/core@8.0.1)(@babel/template@8.0.0)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/merge@6.12.2)(@codemirror/state@6.7.1)(@codemirror/view@6.43.6)(@coze-editor/code-language-shared@0.1.0-alpha.868621(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.6)(@lezer/common@1.5.2))(@lezer/common@1.5.2)(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@5.3.11(@babel/core@8.0.1)(react-dom@18.3.1(react@18.3.1))(react-is@16.13.1)(react@18.3.1))(vue@3.5.40(typescript@6.0.3)) '@flowgram.ai/editor': 1.0.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@flowgram.ai/json-schema': 1.0.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1) immer: 10.1.3 @@ -7205,40 +7213,40 @@ snapshots: '@types/uuid@10.0.0': {} - '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.65.0 - '@typescript-eslint/type-utils': 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/utils': 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.65.0 - eslint: 9.39.5(jiti@2.7.0) + eslint: 9.39.5(jiti@2.7.0)(supports-color@5.5.0) ignore: 7.0.6 natural-compare: 1.4.0 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@6.0.3)': dependencies: '@typescript-eslint/scope-manager': 8.65.0 '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.65.0(supports-color@5.5.0)(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.65.0 debug: 4.4.3(supports-color@5.5.0) - eslint: 9.39.5(jiti@2.7.0) - typescript: 5.9.3 + eslint: 9.39.5(jiti@2.7.0)(supports-color@5.5.0) + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.65.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.65.0(supports-color@5.5.0)(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@6.0.3) '@typescript-eslint/types': 8.65.0 debug: 4.4.3(supports-color@5.5.0) - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -7247,47 +7255,47 @@ snapshots: '@typescript-eslint/types': 8.65.0 '@typescript-eslint/visitor-keys': 8.65.0 - '@typescript-eslint/tsconfig-utils@8.65.0(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.65.0(typescript@6.0.3)': dependencies: - typescript: 5.9.3 + typescript: 6.0.3 - '@typescript-eslint/type-utils@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@6.0.3)': dependencies: '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.65.0(supports-color@5.5.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3) debug: 4.4.3(supports-color@5.5.0) - eslint: 9.39.5(jiti@2.7.0) - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 + eslint: 9.39.5(jiti@2.7.0)(supports-color@5.5.0) + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color '@typescript-eslint/types@8.65.0': {} - '@typescript-eslint/typescript-estree@8.65.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.65.0(supports-color@5.5.0)(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.65.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.9.3) + '@typescript-eslint/project-service': 8.65.0(supports-color@5.5.0)(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@6.0.3) '@typescript-eslint/types': 8.65.0 '@typescript-eslint/visitor-keys': 8.65.0 debug: 4.4.3(supports-color@5.5.0) minimatch: 10.2.5 semver: 7.8.5 tinyglobby: 0.2.17 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/utils@8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)': dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0)) '@typescript-eslint/scope-manager': 8.65.0 '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) - eslint: 9.39.5(jiti@2.7.0) - typescript: 5.9.3 + '@typescript-eslint/typescript-estree': 8.65.0(supports-color@5.5.0)(typescript@6.0.3) + eslint: 9.39.5(jiti@2.7.0)(supports-color@5.5.0) + typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -7550,11 +7558,11 @@ snapshots: axobject-query@4.1.0: {} - babel-plugin-polyfill-corejs2@0.3.3(@babel/core@8.0.1): + babel-plugin-polyfill-corejs2@0.3.3(@babel/core@8.0.1)(supports-color@5.5.0): dependencies: '@babel/compat-data': 7.29.7 '@babel/core': 8.0.1 - '@babel/helper-define-polyfill-provider': 0.3.3(@babel/core@8.0.1) + '@babel/helper-define-polyfill-provider': 0.3.3(@babel/core@8.0.1)(supports-color@5.5.0) semver: 6.3.1 transitivePeerDependencies: - supports-color @@ -7562,7 +7570,7 @@ snapshots: babel-plugin-polyfill-corejs3@0.6.0(@babel/core@8.0.1): dependencies: '@babel/core': 8.0.1 - '@babel/helper-define-polyfill-provider': 0.3.3(@babel/core@8.0.1) + '@babel/helper-define-polyfill-provider': 0.3.3(@babel/core@8.0.1)(supports-color@5.5.0) core-js-compat: 3.49.0 transitivePeerDependencies: - supports-color @@ -7570,7 +7578,7 @@ snapshots: babel-plugin-polyfill-regenerator@0.4.1(@babel/core@8.0.1): dependencies: '@babel/core': 8.0.1 - '@babel/helper-define-polyfill-provider': 0.3.3(@babel/core@8.0.1) + '@babel/helper-define-polyfill-provider': 0.3.3(@babel/core@8.0.1)(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -7965,9 +7973,9 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-prettier@8.10.2(eslint@9.39.5(jiti@2.7.0)): + eslint-config-prettier@8.10.2(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0)): dependencies: - eslint: 9.39.5(jiti@2.7.0) + eslint: 9.39.5(jiti@2.7.0)(supports-color@5.5.0) eslint-define-config@1.12.0: {} @@ -7979,38 +7987,38 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0)))(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3(supports-color@5.5.0) - eslint: 9.39.5(jiti@2.7.0) + eslint: 9.39.5(jiti@2.7.0)(supports-color@5.5.0) get-tsconfig: 4.14.0 is-bun-module: 2.0.0 stable-hash: 0.0.5 tinyglobby: 0.2.17 unrs-resolver: 1.12.2 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.14.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)): + eslint-module-utils@2.14.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0)))(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0))(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) - eslint: 9.39.5(jiti@2.7.0) + '@typescript-eslint/parser': 8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@6.0.3) + eslint: 9.39.5(jiti@2.7.0)(supports-color@5.5.0) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0)))(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0) transitivePeerDependencies: - supports-color - eslint-plugin-babel@5.3.1(eslint@9.39.5(jiti@2.7.0)): + eslint-plugin-babel@5.3.1(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0)): dependencies: - eslint: 9.39.5(jiti@2.7.0) + eslint: 9.39.5(jiti@2.7.0)(supports-color@5.5.0) eslint-rule-composer: 0.3.0 - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -8019,9 +8027,9 @@ snapshots: array.prototype.flatmap: 1.3.3 debug: 3.2.7 doctrine: 2.1.0 - eslint: 9.39.5(jiti@2.7.0) + eslint: 9.39.5(jiti@2.7.0)(supports-color@5.5.0) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) + eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0)))(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0))(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0)) hasown: 2.0.4 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -8033,13 +8041,13 @@ snapshots: string.prototype.trimend: 1.0.10 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@6.0.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.5(jiti@2.7.0)): + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0)): dependencies: aria-query: 5.3.2 array-includes: 3.1.9 @@ -8049,7 +8057,7 @@ snapshots: axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 - eslint: 9.39.5(jiti@2.7.0) + eslint: 9.39.5(jiti@2.7.0)(supports-color@5.5.0) hasown: 2.0.4 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 @@ -8058,15 +8066,15 @@ snapshots: safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 - eslint-plugin-prettier@4.2.5(eslint-config-prettier@8.10.2(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0))(prettier@2.8.8): + eslint-plugin-prettier@4.2.5(eslint-config-prettier@8.10.2(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0)))(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(prettier@2.8.8): dependencies: - eslint: 9.39.5(jiti@2.7.0) + eslint: 9.39.5(jiti@2.7.0)(supports-color@5.5.0) prettier: 2.8.8 prettier-linter-helpers: 1.0.1 optionalDependencies: - eslint-config-prettier: 8.10.2(eslint@9.39.5(jiti@2.7.0)) + eslint-config-prettier: 8.10.2(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0)) - eslint-plugin-react@7.37.5(eslint@9.39.5(jiti@2.7.0)): + eslint-plugin-react@7.37.5(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0)): dependencies: array-includes: 3.1.9 array.prototype.findlast: 1.2.5 @@ -8074,7 +8082,7 @@ snapshots: array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 es-iterator-helpers: 1.4.0 - eslint: 9.39.5(jiti@2.7.0) + eslint: 9.39.5(jiti@2.7.0)(supports-color@5.5.0) estraverse: 5.3.0 hasown: 2.0.4 jsx-ast-utils: 3.3.5 @@ -8113,14 +8121,14 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@9.39.5(jiti@2.7.0): + eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0): dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.2 + '@eslint/config-array': 0.21.2(supports-color@5.5.0) '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.6 + '@eslint/eslintrc': 3.3.6(supports-color@5.5.0) '@eslint/js': 9.39.5 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.8 @@ -9942,11 +9950,11 @@ snapshots: trough@2.2.0: {} - ts-api-utils@2.5.0(typescript@5.9.3): + ts-api-utils@2.5.0(typescript@6.0.3): dependencies: - typescript: 5.9.3 + typescript: 6.0.3 - ts-node@10.9.2(@types/node@18.19.130)(typescript@5.9.3): + ts-node@10.9.2(@types/node@18.19.130)(typescript@6.0.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.12 @@ -9960,7 +9968,7 @@ snapshots: create-require: 1.1.1 diff: 4.0.4 make-error: 1.3.6 - typescript: 5.9.3 + typescript: 6.0.3 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 @@ -10012,6 +10020,8 @@ snapshots: typescript@5.9.3: {} + typescript@6.0.3: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -10142,7 +10152,7 @@ snapshots: vscode-uri@3.1.0: {} - vue@3.5.40(typescript@5.9.3): + vue@3.5.40(typescript@6.0.3): dependencies: '@vue/compiler-dom': 3.5.40 '@vue/compiler-sfc': 3.5.40 @@ -10150,7 +10160,7 @@ snapshots: '@vue/server-renderer': 3.5.40 '@vue/shared': 3.5.40 optionalDependencies: - typescript: 5.9.3 + typescript: 6.0.3 w3c-keyname@2.2.8: {} diff --git a/go.mod b/go.mod index e62257c2..34db975d 100644 --- a/go.mod +++ b/go.mod @@ -12,10 +12,10 @@ require ( github.com/glebarez/sqlite v1.11.0 github.com/go-playground/validator/v10 v10.30.1 github.com/golang-jwt/jwt/v5 v5.3.1 - github.com/gomarkdown/markdown v0.0.0-20240328165702-4d01890c35c0 + github.com/gomarkdown/markdown v0.0.0-20260411013819-759bbc3e3207 github.com/google/uuid v1.6.0 github.com/gorilla/schema v1.4.1 - github.com/gorilla/websocket v1.5.1 + github.com/gorilla/websocket v1.5.3 github.com/microcosm-cc/bluemonday v1.0.26 github.com/mlogclub/codegen v1.0.3 github.com/mlogclub/simple v1.2.40 @@ -27,8 +27,9 @@ require ( github.com/silenceper/wechat/v2 v2.1.12 github.com/spf13/cast v1.10.0 github.com/spf13/viper v1.21.0 + github.com/subosito/gotenv v1.6.0 github.com/wk8/go-ordered-map/v2 v2.1.8 - github.com/xuri/excelize/v2 v2.10.1 + github.com/xuri/excelize/v2 v2.11.0 github.com/yuin/goldmark v1.4.13 golang.org/x/crypto v0.53.0 golang.org/x/net v0.56.0 @@ -50,7 +51,6 @@ require ( github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/pflag v1.0.10 // indirect - github.com/subosito/gotenv v1.6.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/text v0.38.0 // indirect ) @@ -105,9 +105,9 @@ require ( github.com/pierrec/lz4/v4 v4.1.21 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/quic-go/qpack v0.6.0 // indirect - github.com/quic-go/quic-go v0.59.0 // indirect + github.com/quic-go/quic-go v0.59.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/richardlehane/mscfb v1.0.6 // indirect + github.com/richardlehane/mscfb v1.0.7 // indirect github.com/richardlehane/msoleps v1.0.6 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/segmentio/encoding v0.5.4 // indirect @@ -126,7 +126,6 @@ require ( github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect - go.opentelemetry.io/otel v1.42.0 // indirect golang.org/x/arch v0.28.0 // indirect golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect golang.org/x/mod v0.37.0 // indirect @@ -135,8 +134,8 @@ require ( golang.org/x/telemetry v0.0.0-20260610154732-fb80ec83bdd9 // indirect golang.org/x/time v0.14.0 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect - google.golang.org/grpc v1.78.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/grpc v1.83.1 // indirect google.golang.org/protobuf v1.36.11 // indirect modernc.org/libc v1.41.0 // indirect modernc.org/mathutil v1.6.0 // indirect diff --git a/go.sum b/go.sum index cdf24e50..216e751f 100644 --- a/go.sum +++ b/go.sum @@ -115,8 +115,8 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/gomarkdown/markdown v0.0.0-20240328165702-4d01890c35c0 h1:4gjrh/PN2MuWCCElk8/I4OCKRKWCCo2zEct3VKCbibU= -github.com/gomarkdown/markdown v0.0.0-20240328165702-4d01890c35c0/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= +github.com/gomarkdown/markdown v0.0.0-20260411013819-759bbc3e3207 h1:p7t34F7K4OCRQblcDhNJnP46Uaarz3z2cLcvOZYxWn8= +github.com/gomarkdown/markdown v0.0.0-20260411013819-759bbc3e3207/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= github.com/google/flatbuffers v24.3.25+incompatible h1:CX395cjN9Kke9mmalRoL3d81AtFUxJM+yDthflgJGkI= github.com/google/flatbuffers v24.3.25+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -141,8 +141,8 @@ github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E= github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM= -github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= -github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= @@ -248,12 +248,12 @@ github.com/qdrant/go-client v1.17.1 h1:7QmPwDddrHL3hC4NfycwtQlraVKRLcRi++BX6TTm+ github.com/qdrant/go-client v1.17.1/go.mod h1:n1h6GhkdAzcohoXt/5Z19I2yxbCkMA6Jejob3S6NZT8= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= -github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= -github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic= +github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8= -github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= +github.com/richardlehane/mscfb v1.0.7 h1:oeoiM0WE79vHwE8RpIYYvIAc8ajTH2mb6UZm55/+EB0= +github.com/richardlehane/mscfb v1.0.7/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg= github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= @@ -330,8 +330,8 @@ github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJ github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE= github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= -github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0= -github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc= +github.com/xuri/excelize/v2 v2.11.0 h1:HxaEFl6sRN2+8J5a8HaKq+0M4FsjBGMnWWtjOCPSG88= +github.com/xuri/excelize/v2 v2.11.0/go.mod h1:jxFLbzaIwGQ5ufFNvYfUOHqXhfPaNmP14KWfmNz2Uak= github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc= @@ -351,16 +351,16 @@ go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= -go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= -go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= -go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= -go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= -go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= -go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= -go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= -go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= -go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= @@ -376,8 +376,8 @@ golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M= golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY= -golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= -golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= +golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE= +golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= @@ -441,12 +441,12 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= -google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= +google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= diff --git a/internal/ai/rag/vectordb/qdrant.go b/internal/ai/rag/vectordb/qdrant.go index 5c005c75..144917fb 100644 --- a/internal/ai/rag/vectordb/qdrant.go +++ b/internal/ai/rag/vectordb/qdrant.go @@ -29,10 +29,11 @@ func NewQdrantProvider(cfg *config.QdrantVectorDBConfig) (*QdrantProvider, error } client, err := qdrant.NewClient(&qdrant.Config{ - Host: host, - Port: port, - APIKey: cfg.APIKey, - UseTLS: cfg.UseTLS, + Host: host, + Port: port, + APIKey: cfg.APIKey, + UseTLS: cfg.UseTLS, + SkipCompatibilityCheck: true, }) if err != nil { return nil, fmt.Errorf("failed to create qdrant client: %w", err) diff --git a/internal/ai/runtime/reply_commit_service.go b/internal/ai/runtime/reply_commit_service.go index 56b7110b..d0067389 100644 --- a/internal/ai/runtime/reply_commit_service.go +++ b/internal/ai/runtime/reply_commit_service.go @@ -37,24 +37,37 @@ func (s *replyCommitService) SendAIReply(input replyCommitInput) (*models.Messag if err != nil { return nil, err } - replyMessage, err := svc.MessageService.SendAIMessageWithRequestIDAndWorkflowRunID( - input.Conversation.ID, - input.AIAgent.ID, - fmt.Sprintf("%s_%d", strings.TrimSpace(input.ClientPrefix), input.Message.ID), - enums.IMMessageTypeText, + // 优先原地替换 web 渠道的占位消息;不支持替换时回退为新消息 + replyMessage := svc.MessageService.TryReplaceAIReplyPlaceholder( + &input.Conversation, + input.Message.ID, replyText, - "", - s.buildAIPrincipal(input.AIAgent), - input.Message.RequestID, input.WorkflowRunID, + input.AIAgent, ) - if err != nil || !input.IncrementRound { - return replyMessage, err + if replyMessage == nil { + replyMessage, err = svc.MessageService.SendAIMessageWithRequestIDAndWorkflowRunID( + input.Conversation.ID, + input.AIAgent.ID, + fmt.Sprintf("%s_%d", strings.TrimSpace(input.ClientPrefix), input.Message.ID), + enums.IMMessageTypeText, + replyText, + "", + s.buildAIPrincipal(input.AIAgent), + input.Message.RequestID, + input.WorkflowRunID, + ) + if err != nil { + return nil, err + } + } + if !input.IncrementRound { + return replyMessage, nil } if err := s.IncrementAIReplyRounds(input.Conversation.ID, input.Conversation.AIReplyRounds+1, input.AIAgent.Name); err != nil { return nil, err } - return replyMessage, err + return replyMessage, nil } func (s *replyCommitService) CommitAIReply(input replyCommitInput) (*models.Message, error) { diff --git a/internal/ai/runtime/reply_service_test.go b/internal/ai/runtime/reply_service_test.go index 9f44e9d9..6ac41055 100644 --- a/internal/ai/runtime/reply_service_test.go +++ b/internal/ai/runtime/reply_service_test.go @@ -70,19 +70,29 @@ func TestResolveReplyTimeout(t *testing.T) { service := newAIReplyService() aiAgent := newAIAgentFixture() - if got := service.resolveReplyTimeout(aiAgent); got != 180*time.Second { + if got := service.resolveReplyTimeout(nil, aiAgent); got != 120*time.Second { t.Fatalf("expected default timeout, got %v", got) } aiAgent.ReplyTimeoutSeconds = 30 - if got := service.resolveReplyTimeout(aiAgent); got != 30*time.Second { - t.Fatalf("expected exact timeout, got %v", got) + if got := service.resolveReplyTimeout(nil, aiAgent); got != 30*time.Second { + t.Fatalf("expected agent timeout, got %v", got) } aiAgent.ReplyTimeoutSeconds = 999 - if got := service.resolveReplyTimeout(aiAgent); got != 600*time.Second { + if got := service.resolveReplyTimeout(nil, aiAgent); got != 600*time.Second { t.Fatalf("expected clamped timeout, got %v", got) } + + // 渠道配置优先于智能体配置 + channel := &models.Channel{AIReplyTimeoutSeconds: 45} + if got := service.resolveReplyTimeout(channel, aiAgent); got != 45*time.Second { + t.Fatalf("expected channel timeout to take precedence, got %v", got) + } + channel.AIReplyTimeoutSeconds = 0 + if got := service.resolveReplyTimeout(channel, models.AIAgent{}); got != 120*time.Second { + t.Fatalf("expected default timeout when channel and agent unset, got %v", got) + } } func TestResolveInterruptPrompt(t *testing.T) { diff --git a/internal/ai/runtime/reply_trigger_service.go b/internal/ai/runtime/reply_trigger_service.go index f3a18e03..b399c8c0 100644 --- a/internal/ai/runtime/reply_trigger_service.go +++ b/internal/ai/runtime/reply_trigger_service.go @@ -13,14 +13,22 @@ import ( svc "agent-desk/internal/services" ) -func (s *aiReplyService) resolveReplyTimeout(aiAgent models.AIAgent) time.Duration { - if aiAgent.ReplyTimeoutSeconds <= 0 { - return time.Duration(defaultAIReplyAsyncTimeoutSeconds) * time.Second +// resolveReplyTimeout 解析 AI 回复超时时长:接入渠道配置优先,其次智能体配置,最后使用系统默认。 +func (s *aiReplyService) resolveReplyTimeout(channel *models.Channel, aiAgent models.AIAgent) time.Duration { + seconds := 0 + if channel != nil && channel.AIReplyTimeoutSeconds > 0 { + seconds = channel.AIReplyTimeoutSeconds } - if aiAgent.ReplyTimeoutSeconds > maxAIReplyAsyncTimeoutSeconds { - return time.Duration(maxAIReplyAsyncTimeoutSeconds) * time.Second + if seconds <= 0 && aiAgent.ReplyTimeoutSeconds > 0 { + seconds = aiAgent.ReplyTimeoutSeconds } - return time.Duration(aiAgent.ReplyTimeoutSeconds) * time.Second + if seconds <= 0 { + seconds = models.DefaultAIReplyTimeoutSeconds + } + if seconds > maxAIReplyAsyncTimeoutSeconds { + seconds = maxAIReplyAsyncTimeoutSeconds + } + return time.Duration(seconds) * time.Second } func (s *aiReplyService) TriggerReplyAsync(conversation models.Conversation, message models.Message) { @@ -29,8 +37,9 @@ func (s *aiReplyService) TriggerReplyAsync(conversation models.Conversation, mes if aiAgent == nil || aiAgent.Status != enums.StatusOk { return } + channel := svc.ChannelService.Get(conversation.ChannelID) startedAt := time.Now() - timeout := s.resolveReplyTimeout(*aiAgent) + timeout := s.resolveReplyTimeout(channel, *aiAgent) ctx, cancel := context.WithTimeout(tracex.ContextWithRequestID(context.Background(), message.RequestID), timeout) defer cancel() if err := s.TriggerReply(ctx, conversation, message, *aiAgent); err != nil { @@ -40,6 +49,7 @@ func (s *aiReplyService) TriggerReplyAsync(conversation models.Conversation, mes "timeout_ms", timeout.Milliseconds(), "elapsed_ms", time.Since(startedAt).Milliseconds(), "error", err) + svc.MessageService.CompleteAIReplyPlaceholderAsFailed(&conversation, aiAgent, &message, message.RequestID) } }() } @@ -58,9 +68,12 @@ func (s *aiReplyService) TriggerReply(ctx context.Context, conversation models.C if s.eligibility != nil && !s.eligibility.CanReply(conversation, message, aiAgent) { return nil } - if !IsAIAgentRolloutEligible(conversation, aiAgent, svc.ChannelService.Get(conversation.ChannelID)) { + channel := svc.ChannelService.Get(conversation.ChannelID) + if !IsAIAgentRolloutEligible(conversation, aiAgent, channel) { return nil } + // 正式回复生成前先发送占位提示,生成后由回复提交环节按渠道能力原地替换或追加新消息 + svc.MessageService.SendAIReplyPlaceholder(&conversation, &aiAgent, &message, channel, message.RequestID) if pendingInterrupt := svc.ConversationInterruptService.FindLatestPendingByConversationID(conversation.ID); pendingInterrupt != nil { replyCtx.PendingInterrupt = pendingInterrupt return s.resumePendingInterrupt(ctx, replyCtx) @@ -108,6 +121,9 @@ func (s *aiReplyService) executeReply(ctx context.Context, replyCtx aiReplyConte if err != nil { return err } + return nil } + // 工作流执行完成但未产出回复文本,补发失败提示避免占位消息悬挂 + svc.MessageService.CompleteAIReplyPlaceholderAsFailed(&replyCtx.Conversation, &replyCtx.AIAgent, &replyCtx.Message, replyCtx.Message.RequestID) return nil } diff --git a/internal/ai/runtime/reply_types.go b/internal/ai/runtime/reply_types.go index d16afb21..730edbd8 100644 --- a/internal/ai/runtime/reply_types.go +++ b/internal/ai/runtime/reply_types.go @@ -1,6 +1,6 @@ package runtime -const ( - defaultAIReplyAsyncTimeoutSeconds = 180 - maxAIReplyAsyncTimeoutSeconds = 600 -) +import "agent-desk/internal/models" + +// maxAIReplyAsyncTimeoutSeconds 为 AI 回复超时的上限;默认时长由 models.DefaultAIReplyTimeoutSeconds 提供。 +const maxAIReplyAsyncTimeoutSeconds = models.MaxAIReplyTimeoutSeconds diff --git a/internal/bootstrap/init.go b/internal/bootstrap/init.go index b3648e12..882efc3e 100644 --- a/internal/bootstrap/init.go +++ b/internal/bootstrap/init.go @@ -11,6 +11,8 @@ import ( "context" "log/slog" + "github.com/mlogclub/simple/sqls" + _ "agent-desk/internal/services/event_handlers" ) @@ -24,9 +26,10 @@ func Init(configPath string) error { i18nx.SetDefaultLocale(cfg.LanguageOrDefault()) logx.Init(logx.Config{ - Level: cfg.Logger.Level, - Format: cfg.Logger.Format, - AddSource: cfg.Logger.AddSource, + Level: cfg.Logger.Level, + Format: cfg.Logger.Format, + AddSource: cfg.Logger.AddSource, + EnableDBSink: true, }) if _, err := InitDB(cfg.DB); err != nil { @@ -37,6 +40,7 @@ func Init(configPath string) error { slog.Error("init migrations failed", "error", err) return err } + logx.AttachDB(sqls.DB()) if err := vectordb.Init(&cfg.VectorDB); err != nil { slog.Error("init vector db failed", "error", err) return err diff --git a/internal/bootstrap/routes.go b/internal/bootstrap/routes.go index 45a4a887..b0ef65d6 100644 --- a/internal/bootstrap/routes.go +++ b/internal/bootstrap/routes.go @@ -224,6 +224,8 @@ func registerDashboardChannelRoutes(group *gin.RouterGroup) { group.POST("/update", dashboard.ChannelPostUpdate) group.POST("/update_status", dashboard.ChannelPostUpdate_status) group.Any("/wxwork/kf/accounts", dashboard.ChannelAnyWxworkKfAccounts) + group.GET("/wxwork/api_apps", dashboard.ChannelAnyWxworkApiApps) + group.POST("/wxwork/kf/test_read_messages", dashboard.ChannelPostWxworkKfTest_read_messages) group.Any("/wxwork/outbox/failed/list", dashboard.ChannelAnyWxworkOutboxFailedList) group.POST("/wxwork/outbox/retry", dashboard.ChannelPostWxworkOutboxRetry) group.POST("/wxwork/outbox/ignore", dashboard.ChannelPostWxworkOutboxIgnore) @@ -340,6 +342,7 @@ func registerDashboardKnowledgeDirectoryRoutes(group *gin.RouterGroup) { func registerDashboardKnowledgeDocumentRoutes(group *gin.RouterGroup) { group.GET("/:id", dashboard.KnowledgeDocumentGetBy) group.POST("/batch_delete", dashboard.KnowledgeDocumentPostBatch_delete) + group.POST("/batch_build", dashboard.KnowledgeDocumentPostBatch_build) group.POST("/batch_move", dashboard.KnowledgeDocumentPostBatch_move) group.POST("/create", dashboard.KnowledgeDocumentPostCreate) group.POST("/delete", dashboard.KnowledgeDocumentPostDelete) @@ -422,6 +425,11 @@ func registerDashboardMCPRoutes(group *gin.RouterGroup) { group.POST("/test_connection", dashboard.MCPPostTest_connection) } +func registerDashboardSystemLogRoutes(group *gin.RouterGroup) { + group.GET("/:id", dashboard.SystemLogGetBy) + group.Any("/list", dashboard.SystemLogAnyList) +} + func registerThirdWechatRoutes(group *gin.RouterGroup) { group.GET("/callback", third.WechatGetCallback) group.POST("/callback", third.WechatPostCallback) @@ -436,3 +444,8 @@ func registerThirdZaloRoutes(group *gin.RouterGroup) { group.POST("/webhook", third.ZaloPostWebhook) group.POST("/webhook/:channel_id", third.ZaloPostWebhook) } + +func registerThirdDiscordRoutes(group *gin.RouterGroup) { + group.POST("/webhook", third.DiscordPostWebhook) + group.POST("/webhook/:channel_id", third.DiscordPostWebhook) +} diff --git a/internal/bootstrap/server.go b/internal/bootstrap/server.go index 71184100..f255103f 100644 --- a/internal/bootstrap/server.go +++ b/internal/bootstrap/server.go @@ -3,6 +3,7 @@ package bootstrap import ( "log/slog" "net/http" + "path" "strconv" "strings" "time" @@ -17,6 +18,7 @@ import ( "agent-desk/internal/pkg/i18nx" "agent-desk/internal/pkg/tracex" "agent-desk/internal/services" + "agent-desk/internal/services/storage" webspa "agent-desk/web" "github.com/gin-gonic/gin" @@ -44,11 +46,29 @@ func NewServer() (*gin.Engine, error) { handleSpa(app) - app.StaticFS(cfg.Storage.Local.BaseURL, ginx.StaticFiles(cfg.Storage.Local.Root)) + storageGroup := app.Group(cfg.Storage.Local.BaseURL, assetResponseHeaders()) + storageGroup.StaticFS("", ginx.StaticFiles(cfg.Storage.Local.Root)) return app, nil } +// assetResponseHeaders guards the locally stored assets. +// +// Those files are user-supplied bytes served from this application's own origin, +// so a response a browser renders inline is same-origin content. nosniff stops a +// browser reinterpreting the payload, and forcing a download for anything that is +// not previewable media means a document type that slipped in before this policy +// existed still cannot run as a page. +func assetResponseHeaders() gin.HandlerFunc { + return func(ctx *gin.Context) { + ctx.Header("X-Content-Type-Options", "nosniff") + if !storage.IsPreviewableExtension(path.Ext(ctx.Request.URL.Path)) { + ctx.Header("Content-Disposition", "attachment") + } + ctx.Next() + } +} + func corsMiddleware() gin.HandlerFunc { allowedOrigins := config.Current().Server.CORS.AllowedOrigins allowHeaders := "Origin, Content-Type, Accept, Authorization, X-Requested-With, X-Guest-Id, X-Channel-Id, X-External-Id, X-External-Name, X-Customer-Session-Token, X-Customer-Session-Expires-At" @@ -62,11 +82,35 @@ func corsMiddleware() gin.HandlerFunc { } allowedOriginSet[origin] = struct{}{} } + // publicAnyOriginPaths 是可被任意来源跨域访问的公开只读接口。 + // 这些接口不携带鉴权、不返回敏感信息,且供嵌入式 SDK 挂件在任意宿主站点 + // 预拉取展示配置(之后聊天 iframe 与后端同源,不再触发跨域)。 + publicAnyOriginPaths := map[string]struct{}{ + "/api/config": {}, + "/api/channel/config": {}, + } + applyPublicCORS := func(ctx *gin.Context) { + ctx.Header("Access-Control-Allow-Origin", "*") + ctx.Header("Access-Control-Allow-Methods", allowMethods) + ctx.Header("Access-Control-Allow-Headers", allowHeaders) + ctx.Header("Access-Control-Expose-Headers", exposeHeaders) + ctx.Header("Access-Control-Max-Age", "600") + if ctx.Request.Method == http.MethodOptions { + ctx.AbortWithStatus(http.StatusNoContent) + return + } + ctx.Next() + } return func(ctx *gin.Context) { if isWebsocketUpgrade(ctx) { ctx.Next() return } + // 公开只读接口:放行任意来源,无需白名单。 + if _, isPublic := publicAnyOriginPaths[ctx.Request.URL.Path]; isPublic { + applyPublicCORS(ctx) + return + } origin := strings.TrimRight(strings.TrimSpace(ctx.GetHeader("Origin")), "/") if origin != "" { ctx.Header("Vary", "Origin") @@ -194,11 +238,13 @@ func addRouter(app *gin.Engine) { registerDashboardCommunityPostRoutes(dashboardGroup.Group("/support-community/posts")) registerDashboardSkillDefinitionRoutes(dashboardGroup.Group("/skill-definition")) registerDashboardMCPRoutes(dashboardGroup.Group("/mcp")) + registerDashboardSystemLogRoutes(dashboardGroup.Group("/system-log")) thirdGroup := app.Group("/api/third") registerThirdWechatRoutes(thirdGroup.Group("/wechat")) registerThirdTelegramRoutes(thirdGroup.Group("/telegram")) registerThirdZaloRoutes(thirdGroup.Group("/zalo")) + registerThirdDiscordRoutes(thirdGroup.Group("/discord")) } type spaShellRewrite struct { diff --git a/internal/bootstrap/server_route_test.go b/internal/bootstrap/server_route_test.go index 31bd4f76..e6365262 100644 --- a/internal/bootstrap/server_route_test.go +++ b/internal/bootstrap/server_route_test.go @@ -298,6 +298,64 @@ func TestNewServerSeparatesAPIStaticAndSPA(t *testing.T) { } } +func TestNewServerHardensStoredAssetResponses(t *testing.T) { + root := t.TempDir() + for _, name := range []string{"screenshot.png", "archive.zip", "legacy-page.html"} { + if err := os.WriteFile(filepath.Join(root, name), []byte("payload"), 0o644); err != nil { + t.Fatalf("WriteFile(%s) error = %v", name, err) + } + } + + config.SetCurrent(&config.Config{ + Storage: config.StorageConfig{ + Local: config.LocalStorageConfig{ + Root: root, + BaseURL: "/storage", + }, + }, + }) + + app, err := NewServer() + if err != nil { + t.Fatalf("NewServer() error = %v", err) + } + + tests := []struct { + path string + wantStatus int + contentType string + wantAttachment bool + }{ + {path: "/storage/screenshot.png", wantStatus: http.StatusOK, contentType: "image/png"}, + {path: "/storage/archive.zip", wantStatus: http.StatusOK, wantAttachment: true}, + // A file planted before the upload policy existed must still not render. + {path: "/storage/legacy-page.html", wantStatus: http.StatusOK, wantAttachment: true}, + {path: "/storage/missing.png", wantStatus: http.StatusNotFound}, + } + + for _, tt := range tests { + rec := httptest.NewRecorder() + app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tt.path, nil)) + + if rec.Code != tt.wantStatus { + t.Fatalf("%s status=%d want %d", tt.path, rec.Code, tt.wantStatus) + } + if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" { + t.Fatalf("%s X-Content-Type-Options=%q want nosniff", tt.path, got) + } + if tt.contentType != "" && !strings.Contains(rec.Header().Get("Content-Type"), tt.contentType) { + t.Fatalf("%s Content-Type=%q want %q", tt.path, rec.Header().Get("Content-Type"), tt.contentType) + } + got := rec.Header().Get("Content-Disposition") + if tt.wantAttachment && got != "attachment" { + t.Fatalf("%s Content-Disposition=%q want attachment", tt.path, got) + } + if !tt.wantAttachment && got != "" { + t.Fatalf("%s Content-Disposition=%q want empty so the asset renders inline", tt.path, got) + } + } +} + func TestNewServerAllowsConfiguredCORSOrigin(t *testing.T) { config.SetCurrent(&config.Config{ Server: config.ServerConfig{ @@ -372,6 +430,114 @@ func TestNewServerRejectsUnconfiguredCORSOrigin(t *testing.T) { } } +// TestNewServerPublicConfigEndpointsAllowAnyOrigin 验证嵌入式 SDK 挂件预拉取的 +// 两个公开只读接口对任意来源放行(含带 X-Channel-Id 头触发的预检请求), +// 同时非公开接口仍受白名单约束。 +func TestNewServerPublicConfigEndpointsAllowAnyOrigin(t *testing.T) { + config.SetCurrent(&config.Config{ + Language: "zh-CN", + Server: config.ServerConfig{ + CORS: config.CORSConfig{ + // 白名单为空:公开接口仍应放行,非公开接口应被拒。 + AllowedOrigins: nil, + }, + }, + Storage: config.StorageConfig{ + Local: config.LocalStorageConfig{ + Root: "storage", + BaseURL: "/storage", + }, + }, + }) + + app, err := NewServer() + if err != nil { + t.Fatalf("NewServer() error = %v", err) + } + + cases := []struct { + name string + method string + path string + origin string + extraHdrs map[string]string + wantStatus int + wantACAO string + }{ + { + name: "GET /api/config with arbitrary origin", + method: http.MethodGet, + path: "/api/config", + origin: "http://localhost:5201", + wantStatus: http.StatusOK, + wantACAO: "*", + }, + { + name: "OPTIONS /api/config preflight passes for arbitrary origin", + method: http.MethodOptions, + path: "/api/config", + origin: "http://localhost:5201", + extraHdrs: map[string]string{"Access-Control-Request-Method": http.MethodGet}, + wantStatus: http.StatusNoContent, + wantACAO: "*", + }, + { + name: "OPTIONS /api/channel/config preflight passes with X-Channel-Id request header", + method: http.MethodOptions, + path: "/api/channel/config", + origin: "http://localhost:5201", + extraHdrs: map[string]string{"Access-Control-Request-Method": http.MethodGet, "Access-Control-Request-Headers": "X-Channel-Id"}, + wantStatus: http.StatusNoContent, + wantACAO: "*", + }, + { + name: "GET /api/config without Origin header still works (same-origin)", + method: http.MethodGet, + path: "/api/config", + origin: "", + wantStatus: http.StatusOK, + wantACAO: "*", + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(tt.method, tt.path, nil) + if tt.origin != "" { + req.Header.Set("Origin", tt.origin) + } + for k, v := range tt.extraHdrs { + req.Header.Set(k, v) + } + app.ServeHTTP(rec, req) + + if rec.Code != tt.wantStatus { + t.Fatalf("status=%d want %d, body=%s", rec.Code, tt.wantStatus, rec.Body.String()) + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != tt.wantACAO { + t.Fatalf("Access-Control-Allow-Origin=%q want %q", got, tt.wantACAO) + } + if got := rec.Header().Get("Access-Control-Allow-Headers"); !strings.Contains(got, "X-Channel-Id") { + t.Fatalf("Access-Control-Allow-Headers=%q should contain X-Channel-Id", got) + } + }) + } + + // 回归:非公开接口在白名单为空时,预检应被拒。 + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodOptions, "/api/auth/login", nil) + req.Header.Set("Origin", "http://localhost:5201") + req.Header.Set("Access-Control-Request-Method", http.MethodPost) + app.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("non-public preflight status=%d want %d", rec.Code, http.StatusForbidden) + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Fatalf("non-public Access-Control-Allow-Origin=%q want empty", got) + } +} + func TestNewServerEchoesRequestID(t *testing.T) { config.SetCurrent(&config.Config{ Storage: config.StorageConfig{ diff --git a/internal/builders/conversation_builder.go b/internal/builders/conversation_builder.go index 352c66f3..0491d473 100644 --- a/internal/builders/conversation_builder.go +++ b/internal/builders/conversation_builder.go @@ -47,6 +47,10 @@ func BuildConversationWithLocale(item *models.Conversation, locale string) respo if identity := services.ConversationService.GetConversationExternalIdentity(item); identity != nil { ret.CustomerOnline = services.WsService.IsGuestOnline(identity.ExternalID) } + if channel := services.ChannelService.Get(item.ChannelID); channel != nil { + ret.ChannelType = channel.ChannelType + ret.ChannelName = channel.Name + } if item.CurrentAssigneeID > 0 { if user := services.UserService.Get(item.CurrentAssigneeID); user != nil { ret.CurrentAssigneeName = user.Nickname diff --git a/internal/builders/system_log_builder.go b/internal/builders/system_log_builder.go new file mode 100644 index 00000000..655169db --- /dev/null +++ b/internal/builders/system_log_builder.go @@ -0,0 +1,33 @@ +package builders + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto/response" +) + +// BuildSystemLog 将 SystemLog 模型映射为响应 DTO。 +func BuildSystemLog(item *models.SystemLog) response.SystemLogResponse { + return response.SystemLogResponse{ + ID: item.ID, + Level: item.Level, + LevelName: systemLogLevelName(item.Level), + Message: item.Message, + Source: item.Source, + LoggerName: item.LoggerName, + Attrs: item.Attrs, + CreatedAt: item.CreatedAt, + } +} + +func systemLogLevelName(level string) string { + switch level { + case "INFO": + return "Info" + case "WARN": + return "Warn" + case "ERROR": + return "Error" + default: + return level + } +} diff --git a/internal/discord/client.go b/internal/discord/client.go new file mode 100644 index 00000000..4045ae1c --- /dev/null +++ b/internal/discord/client.go @@ -0,0 +1,139 @@ +package discord + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +const defaultBaseURL = "https://discord.com/api/v10" + +type Client struct { + botToken string + baseURL string + httpClient *http.Client +} + +func NewClient(botToken string) *Client { + return &Client{ + botToken: strings.TrimSpace(botToken), + baseURL: defaultBaseURL, + httpClient: &http.Client{Timeout: 15 * time.Second}, + } +} + +func (c *Client) SetBaseURL(url string) { + if strings.TrimSpace(url) != "" { + c.baseURL = strings.TrimRight(strings.TrimSpace(url), "/") + } +} + +func (c *Client) GetMe(ctx context.Context) (*User, error) { + var user User + if err := c.doRequest(ctx, http.MethodGet, "/users/@me", nil, &user); err != nil { + return nil, err + } + return &user, nil +} + +func (c *Client) CreateDMChannel(ctx context.Context, recipientID string) (*Channel, error) { + if strings.TrimSpace(recipientID) == "" { + return nil, fmt.Errorf("recipient_id is required") + } + req := CreateDMRequest{RecipientID: strings.TrimSpace(recipientID)} + var channel Channel + if err := c.doRequest(ctx, http.MethodPost, "/users/@me/channels", req, &channel); err != nil { + return nil, err + } + return &channel, nil +} + +func (c *Client) SendMessage(ctx context.Context, channelID string, content string) (*Message, error) { + channelID = strings.TrimSpace(channelID) + if channelID == "" { + return nil, fmt.Errorf("channel_id is required") + } + if strings.TrimSpace(content) == "" { + return nil, fmt.Errorf("content is required") + } + + req := SendMessageRequest{Content: content} + var msg Message + endpoint := fmt.Sprintf("/channels/%s/messages", channelID) + if err := c.doRequest(ctx, http.MethodPost, endpoint, req, &msg); err != nil { + return nil, err + } + return &msg, nil +} + +func (c *Client) SendEmbedMessage(ctx context.Context, channelID string, content string, embeds []Embed) (*Message, error) { + channelID = strings.TrimSpace(channelID) + if channelID == "" { + return nil, fmt.Errorf("channel_id is required") + } + + req := SendMessageRequest{ + Content: content, + Embeds: embeds, + } + var msg Message + endpoint := fmt.Sprintf("/channels/%s/messages", channelID) + if err := c.doRequest(ctx, http.MethodPost, endpoint, req, &msg); err != nil { + return nil, err + } + return &msg, nil +} + +func (c *Client) doRequest(ctx context.Context, method, path string, payload any, result any) error { + if c.botToken == "" { + return fmt.Errorf("discord bot token is required") + } + + endpoint := fmt.Sprintf("%s%s", c.baseURL, path) + + var bodyReader io.Reader + if payload != nil { + bodyBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal discord request failed: %w", err) + } + bodyReader = bytes.NewBuffer(bodyBytes) + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint, bodyReader) + if err != nil { + return fmt.Errorf("create discord request failed: %w", err) + } + + req.Header.Set("Authorization", "Bot "+c.botToken) + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + res, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("discord http request failed: %w", err) + } + defer res.Body.Close() + + bodyBytes, err := io.ReadAll(res.Body) + if err != nil { + return fmt.Errorf("read discord response failed: %w", err) + } + + if res.StatusCode < 200 || res.StatusCode >= 300 { + return fmt.Errorf("discord api error (%d): %s", res.StatusCode, string(bodyBytes)) + } + + if result != nil { + if err := json.Unmarshal(bodyBytes, result); err != nil { + return fmt.Errorf("unmarshal discord response failed: %w (body: %s)", err, string(bodyBytes)) + } + } + return nil +} diff --git a/internal/discord/client_test.go b/internal/discord/client_test.go new file mode 100644 index 00000000..de1b7c85 --- /dev/null +++ b/internal/discord/client_test.go @@ -0,0 +1,90 @@ +package discord + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestDiscordSendMessage(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bot test_token" { + t.Errorf("expected Bot test_token, got %s", r.Header.Get("Authorization")) + } + if r.URL.Path != "/channels/789/messages" { + t.Errorf("expected path /channels/789/messages, got %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"id":"123456","channel_id":"789","content":"hello"}`)) + })) + defer server.Close() + + client := NewClient("test_token") + client.SetBaseURL(server.URL) + + resp, err := client.SendMessage(context.Background(), "789", "hello") + if err != nil { + t.Fatalf("SendMessage failed: %v", err) + } + if resp.ID != "123456" { + t.Errorf("expected ID 123456, got %s", resp.ID) + } +} + +func TestDiscordSendEmbedMessage(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bot test_token" { + t.Errorf("expected Bot test_token, got %s", r.Header.Get("Authorization")) + } + if r.URL.Path != "/channels/789/messages" { + t.Errorf("expected path /channels/789/messages, got %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"id":"embed_123","channel_id":"789","content":"Check image"}`)) + })) + defer server.Close() + + client := NewClient("test_token") + client.SetBaseURL(server.URL) + + embed := Embed{ + Title: "Screenshot", + Image: &EmbedMedia{URL: "https://example.com/img.png"}, + } + resp, err := client.SendEmbedMessage(context.Background(), "789", "Check image", []Embed{embed}) + if err != nil { + t.Fatalf("SendEmbedMessage failed: %v", err) + } + if resp.ID != "embed_123" { + t.Errorf("expected ID embed_123, got %s", resp.ID) + } +} + +func TestDiscordCreateDMChannel(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bot test_token" { + t.Errorf("expected Bot test_token, got %s", r.Header.Get("Authorization")) + } + if r.URL.Path != "/users/@me/channels" { + t.Errorf("expected path /users/@me/channels, got %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"id":"dm_chan_123","type":1}`)) + })) + defer server.Close() + + client := NewClient("test_token") + client.SetBaseURL(server.URL) + + resp, err := client.CreateDMChannel(context.Background(), "user_999") + if err != nil { + t.Fatalf("CreateDMChannel failed: %v", err) + } + if resp.ID != "dm_chan_123" { + t.Errorf("expected ID dm_chan_123, got %s", resp.ID) + } +} diff --git a/internal/discord/types.go b/internal/discord/types.go new file mode 100644 index 00000000..3363bebd --- /dev/null +++ b/internal/discord/types.go @@ -0,0 +1,80 @@ +package discord + +// User represents a Discord user. +type User struct { + ID string `json:"id"` + Username string `json:"username"` + Discriminator string `json:"discriminator,omitempty"` + GlobalName string `json:"global_name,omitempty"` + Avatar string `json:"avatar,omitempty"` + Bot bool `json:"bot,omitempty"` +} + +// Channel represents a Discord channel (Guild Text, DM, Thread, etc.). +type Channel struct { + ID string `json:"id"` + Type int `json:"type"` + GuildID string `json:"guild_id,omitempty"` + Name string `json:"name,omitempty"` +} + +// Attachment represents a file or image uploaded to Discord. +type Attachment struct { + ID string `json:"id"` + Filename string `json:"filename"` + URL string `json:"url"` + ProxyURL string `json:"proxy_url,omitempty"` + ContentType string `json:"content_type,omitempty"` + Size int64 `json:"size,omitempty"` +} + +// EmbedMedia represents an image/video/thumbnail inside an Embed. +type EmbedMedia struct { + URL string `json:"url"` +} + +// Embed represents a Discord rich embed object. +type Embed struct { + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + URL string `json:"url,omitempty"` + Color int `json:"color,omitempty"` + Image *EmbedMedia `json:"image,omitempty"` +} + +// Message represents a Discord message. +type Message struct { + ID string `json:"id"` + ChannelID string `json:"channel_id"` + GuildID string `json:"guild_id,omitempty"` + Author User `json:"author"` + Content string `json:"content"` + Timestamp string `json:"timestamp"` + Attachments []Attachment `json:"attachments,omitempty"` + Embeds []Embed `json:"embeds,omitempty"` +} + +// SendMessageRequest represents payload for Discord create message API. +type SendMessageRequest struct { + Content string `json:"content,omitempty"` + Embeds []Embed `json:"embeds,omitempty"` +} + +// CreateDMRequest represents payload for Discord create DM channel API. +type CreateDMRequest struct { + RecipientID string `json:"recipient_id"` +} + +// WebhookPayload represents an incoming message/event from Discord Gateway or Webhook. +type WebhookPayload struct { + ID string `json:"id,omitempty"` + Type int `json:"type,omitempty"` + GuildID string `json:"guild_id,omitempty"` + ChannelID string `json:"channel_id,omitempty"` + Author *User `json:"author,omitempty"` + Content string `json:"content,omitempty"` + Timestamp string `json:"timestamp,omitempty"` + Attachments []Attachment `json:"attachments,omitempty"` + Embeds []Embed `json:"embeds,omitempty"` + Message *Message `json:"message,omitempty"` +} diff --git a/internal/handlers/dashboard/channel_handler.go b/internal/handlers/dashboard/channel_handler.go index bbf79ed3..847fdbfe 100644 --- a/internal/handlers/dashboard/channel_handler.go +++ b/internal/handlers/dashboard/channel_handler.go @@ -65,6 +65,35 @@ func ChannelAnyWxworkKfAccounts(ctx *gin.Context) { httpx.WriteJSON(ctx, list) } +// ChannelAnyWxworkApiApps 返回配置文件中可用的企业微信应用(agentId),供渠道表单选择。 +func ChannelAnyWxworkApiApps(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionChannelView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, services.ChannelService.ListWxWorkApiApps()) +} + +// ChannelPostWxworkKfTest_read_messages 测试企业微信客服渠道能否读取近 3 天的消息与事件。 +// 只读操作,使用 channel.view 权限;失败原因以结构化结果返回,不抛 JsonResult 错误。 +func ChannelPostWxworkKfTest_read_messages(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionChannelView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.TestWxWorkKFReadMessagesRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + result, err := services.ChannelService.TestWxWorkKFReadMessages(req.ID) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, result) +} + func ChannelAnyWxworkOutboxFailedList(ctx *gin.Context) { if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionWxWorkOutboxView); err != nil { httpx.WriteJSON(ctx, err) diff --git a/internal/handlers/dashboard/knowledge_document_handler.go b/internal/handlers/dashboard/knowledge_document_handler.go index cc36a536..9740907e 100644 --- a/internal/handlers/dashboard/knowledge_document_handler.go +++ b/internal/handlers/dashboard/knowledge_document_handler.go @@ -163,6 +163,24 @@ func KnowledgeDocumentPostBatch_move(ctx *gin.Context) { httpx.WriteJSON(ctx, nil) } +func KnowledgeDocumentPostBatch_build(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionKnowledgeDocumentUpdate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.BatchBuildKnowledgeDocumentRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + if err := services.KnowledgeDocumentService.BatchBuildKnowledgeDocuments(req, operator); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, nil) +} + func KnowledgeDocumentPostBatch_delete(ctx *gin.Context) { if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionKnowledgeDocumentDelete); err != nil { httpx.WriteJSON(ctx, err) diff --git a/internal/handlers/dashboard/system_log_handler.go b/internal/handlers/dashboard/system_log_handler.go new file mode 100644 index 00000000..d9b71175 --- /dev/null +++ b/internal/handlers/dashboard/system_log_handler.go @@ -0,0 +1,56 @@ +package dashboard + +import ( + "agent-desk/internal/builders" + "agent-desk/internal/pkg/constants" + "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/pkg/httpx" + "agent-desk/internal/pkg/httpx/params" + "agent-desk/internal/services" + + "github.com/gin-gonic/gin" + "github.com/mlogclub/simple/web" +) + +// SystemLogAnyList 系统日志分页列表,支持按级别、关键字、时间范围筛选。 +func SystemLogAnyList(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionSystemLogView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + + cnd := params.NewPagedSqlCnd(ctx, + params.QueryFilter{ParamName: "level", Op: params.Eq}, + params.QueryFilter{ParamName: "message", Op: params.Like}, + params.QueryFilter{ParamName: "source", Op: params.Like}, + params.QueryFilter{ParamName: "startTime", Op: params.Gte, ColumnName: "created_at"}, + params.QueryFilter{ParamName: "endTime", Op: params.Lte, ColumnName: "created_at"}, + ).Desc("id") + + list, paging := services.SystemLogService.FindPageByCnd(cnd) + results := make([]response.SystemLogResponse, 0, len(list)) + for i := range list { + results = append(results, builders.BuildSystemLog(&list[i])) + } + httpx.WriteJSON(ctx, &web.PageResult{Results: results, Page: paging}) +} + +// SystemLogGetBy 系统日志详情。 +func SystemLogGetBy(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionSystemLogView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + + id, ok := httpx.GetPathInt64(ctx, "id") + if !ok { + return + } + + item := services.SystemLogService.Get(id) + if item == nil { + httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.notFound")) + return + } + httpx.WriteJSON(ctx, builders.BuildSystemLog(item)) +} diff --git a/internal/handlers/third/discord_handler.go b/internal/handlers/third/discord_handler.go new file mode 100644 index 00000000..e36ba526 --- /dev/null +++ b/internal/handlers/third/discord_handler.go @@ -0,0 +1,39 @@ +package third + +import ( + "bytes" + "io" + "net/http" + "strings" + + "agent-desk/internal/services" + + "github.com/gin-gonic/gin" +) + +// DiscordPostWebhook receives incoming Webhook events from Discord. +func DiscordPostWebhook(ctx *gin.Context) { + channelID := strings.TrimSpace(ctx.Param("channel_id")) + if channelID == "" { + channelID = strings.TrimSpace(ctx.Query("channel_id")) + } + + secretHeader := ctx.GetHeader("X-Discord-Secret-Token") + if secretHeader == "" { + secretHeader = ctx.GetHeader("X-Webhook-Secret") + } + + bodyBytes, err := io.ReadAll(ctx.Request.Body) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "failed to read body"}) + return + } + ctx.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + + if err := services.DiscordInboundService.HandleWebhook(ctx.Request.Context(), channelID, secretHeader, bodyBytes); err != nil { + ctx.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"ok": true}) +} diff --git a/internal/handlers/third/discord_handler_test.go b/internal/handlers/third/discord_handler_test.go new file mode 100644 index 00000000..e3b8e4f5 --- /dev/null +++ b/internal/handlers/third/discord_handler_test.go @@ -0,0 +1,115 @@ +package third + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + "agent-desk/internal/services" + + "github.com/gin-gonic/gin" + "github.com/mlogclub/simple/sqls" +) + +func TestDiscordPostWebhook_Handler(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupThirdHandlerTestDB(t) + + now := time.Now() + agent := &models.AIAgent{ + Name: "Discord Agent", + ServiceMode: enums.IMConversationServiceModeAIFirst, + PublishedRevisionID: 1, + WelcomeMessage: "Hello Discord!", + Status: enums.StatusOk, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + _ = db.Create(agent) + + discordConfig, _ := json.Marshal(dto.DiscordChannelConfig{ + GuildID: "guild_999", + BotToken: "test_bot_token", + WebhookSecret: "secret_discord_123", + WelcomeMessage: "Welcome!", + }) + + operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"} + channel, err := services.ChannelService.CreateChannel(request.CreateChannelRequest{ + Name: "Discord Community", + ChannelType: enums.ChannelTypeDiscord, + AIAgentID: agent.ID, + AIAgentRolloutPercent: 100, + ConfigJSON: string(discordConfig), + Status: int(enums.StatusOk), + }, operator) + if err != nil { + t.Fatalf("CreateChannel failed: %v", err) + } + + router := gin.New() + router.POST("/api/third/discord/webhook/:channel_id", DiscordPostWebhook) + router.POST("/api/third/discord/webhook", DiscordPostWebhook) + + payload := []byte(`{ + "id": "msg_001", + "channel_id": "ch_777", + "guild_id": "guild_999", + "content": "Need help with setup", + "author": { + "id": "user_456", + "username": "gamer_one", + "global_name": "Gamer One", + "bot": false + } + }`) + + // 1. Invalid secret + req, _ := http.NewRequest(http.MethodPost, "/api/third/discord/webhook/"+channel.ChannelID, bytes.NewBuffer(payload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Discord-Secret-Token", "wrong_secret") + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 OK wrapper, got: %d", rec.Code) + } + var resp map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &resp) + if resp["ok"] == true { + t.Fatalf("expected error for invalid secret token") + } + + // 2. Valid secret + req2, _ := http.NewRequest(http.MethodPost, "/api/third/discord/webhook/"+channel.ChannelID, bytes.NewBuffer(payload)) + req2.Header.Set("Content-Type", "application/json") + req2.Header.Set("X-Discord-Secret-Token", "secret_discord_123") + + rec2 := httptest.NewRecorder() + router.ServeHTTP(rec2, req2) + + if rec2.Code != http.StatusOK { + t.Fatalf("expected 200 OK, got %d", rec2.Code) + } + var resp2 map[string]any + _ = json.Unmarshal(rec2.Body.Bytes(), &resp2) + if resp2["ok"] != true { + t.Fatalf("expected ok: true, got: %+v", resp2) + } + + // Verify identity in DB + identity := repositories.CustomerIdentityRepository.FindOne(db, sqls.NewCnd(). + Eq("external_source", enums.ExternalSourceDiscord). + Eq("external_id", "user_456")) + if identity == nil { + t.Fatalf("expected customer identity for user_456") + } +} diff --git a/internal/migration/000002_init_auth_data.go b/internal/migration/000002_init_auth_data.go index 177b5379..b459cd3f 100644 --- a/internal/migration/000002_init_auth_data.go +++ b/internal/migration/000002_init_auth_data.go @@ -205,6 +205,7 @@ func ensureBootstrapAdmin(tx *gorm.DB, superAdminRole *models.Role) error { Username: username, Nickname: nickname, Password: string(hashedPassword), + UserType: enums.UserTypeEmployee, Status: enums.StatusOk, Remark: "bootstrap super admin", AuditFields: models.AuditFields{ @@ -223,6 +224,7 @@ func ensureBootstrapAdmin(tx *gorm.DB, superAdminRole *models.Role) error { } else { if err := repositories.UserRepository.Updates(tx, user.ID, map[string]any{ "nickname": nickname, + "user_type": enums.UserTypeEmployee, "status": enums.StatusOk, "update_user_id": constants.SystemAuditUserID, "update_user_name": constants.SystemAuditUserName, diff --git a/internal/migration/000010_backfill_employee_user_type.go b/internal/migration/000010_backfill_employee_user_type.go new file mode 100644 index 00000000..3c3781ba --- /dev/null +++ b/internal/migration/000010_backfill_employee_user_type.go @@ -0,0 +1,32 @@ +package migration + +import ( + "agent-desk/internal/pkg/enums" + "log/slog" + + "github.com/mlogclub/simple/sqls" +) + +func init() { + register(10, "backfill employee user_type for staff users", func() error { + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + // t_user stores both staff accounts (user_type=employee, allowed on /api/dashboard) + // and support-portal customers (user_type=user). Existing staff accounts created + // before the user_type column was introduced kept the column default "user" and + // were rejected by the dashboard auth middleware. Staff accounts are identified + // by having at least one role binding in t_user_role. + res := ctx.Tx.Exec( + "UPDATE t_user SET user_type = ? WHERE user_type = ? "+ + "AND EXISTS (SELECT 1 FROM t_user_role ur WHERE ur.user_id = t_user.id)", + enums.UserTypeEmployee, enums.UserTypeUser, + ) + if res.Error != nil { + return res.Error + } + if res.RowsAffected > 0 { + slog.Info("backfilled employee user_type for staff users", "count", res.RowsAffected) + } + return nil + }) + }) +} diff --git a/internal/migration/000011_repair_bootstrap_admin_user_type.go b/internal/migration/000011_repair_bootstrap_admin_user_type.go new file mode 100644 index 00000000..a3f03047 --- /dev/null +++ b/internal/migration/000011_repair_bootstrap_admin_user_type.go @@ -0,0 +1,34 @@ +package migration + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/constants" + "agent-desk/internal/pkg/enums" + "time" + + "github.com/mlogclub/simple/sqls" +) + +func init() { + register(11, "repair bootstrap admin user type", func() error { + db := sqls.DB() + superAdmins := db.Table("t_user_role AS ur").Select("ur.user_id"). + Joins("JOIN t_role AS r ON r.id = ur.role_id"). + Where("r.code = ? AND r.status = ?", constants.RoleCodeSuperAdmin, enums.StatusOk) + + // Migration 2 has already run on existing installations. Repair only the + // active bootstrap account that already has the super admin role. + return db.Model(&models.User{}). + Where("username = ?", constants.BootstrapAdminUsername). + Where("user_type = ?", enums.UserTypeUser). + Where("status = ?", enums.StatusOk). + Where("deleted_at IS NULL"). + Where("id IN (?)", superAdmins). + Updates(map[string]any{ + "user_type": enums.UserTypeEmployee, + "updated_at": time.Now(), + "update_user_id": constants.SystemAuditUserID, + "update_user_name": constants.SystemAuditUserName, + }).Error + }) +} diff --git a/internal/migration/000011_repair_bootstrap_admin_user_type_test.go b/internal/migration/000011_repair_bootstrap_admin_user_type_test.go new file mode 100644 index 00000000..c65b5587 --- /dev/null +++ b/internal/migration/000011_repair_bootstrap_admin_user_type_test.go @@ -0,0 +1,131 @@ +package migration + +import ( + "path/filepath" + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/constants" + "agent-desk/internal/pkg/enums" + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +func setupBootstrapAdminTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "auth.db")), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}, + }) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&models.User{}, &models.Role{}, &models.UserRole{}); err != nil { + t.Fatal(err) + } + conn, err := db.DB() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = conn.Close() }) + return db +} + +func TestBootstrapAdminCreatedAsEmployee(t *testing.T) { + db := setupBootstrapAdminTestDB(t) + role := models.Role{Code: constants.RoleCodeSuperAdmin, Status: enums.StatusOk} + if err := db.Create(&role).Error; err != nil { + t.Fatal(err) + } + if err := ensureBootstrapAdmin(db, &role); err != nil { + t.Fatal(err) + } + var user models.User + if err := db.Where("username = ?", constants.BootstrapAdminUsername).First(&user).Error; err != nil { + t.Fatal(err) + } + if user.UserType != enums.UserTypeEmployee { + t.Fatalf("bootstrap admin type = %q, want employee", user.UserType) + } + var count int64 + if err := db.Model(&models.UserRole{}).Where("user_id = ? AND role_id = ?", user.ID, role.ID).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("super admin role assignments = %d, want 1", count) + } +} + +func TestRepairBootstrapAdminUserType(t *testing.T) { + repair, ok := migrationFuncs[11] + if !ok { + t.Fatal("bootstrap admin repair migration is not registered") + } + for _, tc := range []struct { + name string + username string + userType enums.UserType + status enums.Status + roleCode string + roleStatus enums.Status + deleted bool + want enums.UserType + }{ + {"legacy admin", "admin", enums.UserTypeUser, enums.StatusOk, constants.RoleCodeSuperAdmin, enums.StatusOk, false, enums.UserTypeEmployee}, + {"already employee", "admin", enums.UserTypeEmployee, enums.StatusOk, constants.RoleCodeSuperAdmin, enums.StatusOk, false, enums.UserTypeEmployee}, + {"ordinary user", "customer", enums.UserTypeUser, enums.StatusOk, "", enums.StatusOk, false, enums.UserTypeUser}, + {"same name without role", "admin", enums.UserTypeUser, enums.StatusOk, "", enums.StatusOk, false, enums.UserTypeUser}, + {"other super admin", "other", enums.UserTypeUser, enums.StatusOk, constants.RoleCodeSuperAdmin, enums.StatusOk, false, enums.UserTypeUser}, + {"disabled admin", "admin", enums.UserTypeUser, enums.StatusDisabled, constants.RoleCodeSuperAdmin, enums.StatusOk, false, enums.UserTypeUser}, + {"disabled role", "admin", enums.UserTypeUser, enums.StatusOk, constants.RoleCodeSuperAdmin, enums.StatusDisabled, false, enums.UserTypeUser}, + {"deleted admin", "admin", enums.UserTypeUser, enums.StatusOk, constants.RoleCodeSuperAdmin, enums.StatusOk, true, enums.UserTypeUser}, + } { + t.Run(tc.name, func(t *testing.T) { + db := setupBootstrapAdminTestDB(t) + sqls.SetDB(db) + t.Cleanup(func() { sqls.SetDB(nil) }) + user := models.User{Username: tc.username, Password: "unchanged-test-value", UserType: tc.userType, Status: tc.status} + if tc.deleted { + now := time.Now() + user.DeletedAt = &now + } + if err := db.Create(&user).Error; err != nil { + t.Fatal(err) + } + if tc.roleCode != "" { + role := models.Role{Code: tc.roleCode, Status: tc.roleStatus} + if err := db.Create(&role).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&models.UserRole{UserID: user.ID, RoleID: role.ID}).Error; err != nil { + t.Fatal(err) + } + } + if err := repair.Fn(); err != nil { + t.Fatal(err) + } + var got models.User + if err := db.First(&got, user.ID).Error; err != nil { + t.Fatal(err) + } + if got.UserType != tc.want { + t.Fatalf("type = %q, want %q", got.UserType, tc.want) + } + if got.Password != user.Password || got.Status != user.Status { + t.Fatal("repair changed password or account status") + } + updatedAt := got.UpdatedAt + if err := repair.Fn(); err != nil { + t.Fatal(err) + } + if err := db.First(&got, user.ID).Error; err != nil { + t.Fatal(err) + } + if !got.UpdatedAt.Equal(updatedAt) { + t.Fatal("second repair changed updated_at") + } + }) + } +} diff --git a/internal/models/channel.go b/internal/models/channel.go new file mode 100644 index 00000000..343a332d --- /dev/null +++ b/internal/models/channel.go @@ -0,0 +1,31 @@ +package models + +import "strings" + +// AI 回复占位与超时的系统默认文案与上限;接入渠道可通过对应字段按渠道覆盖。 +const ( + DefaultAIReplyPlaceholder = "正在查看,请稍候……" + DefaultAIReplyTimeoutSeconds = 120 + MaxAIReplyTimeoutSeconds = 600 + DefaultAIReplyTimeoutNotice = "抱歉,AI 客服暂时未能处理您的问题,请重新发送问题或转人工客服。" +) + +// EffectiveAIReplyPlaceholder 返回渠道生效的 AI 接待占位提示语。 +func (c *Channel) EffectiveAIReplyPlaceholder() string { + if c != nil { + if value := strings.TrimSpace(c.AIReplyPlaceholder); value != "" { + return value + } + } + return DefaultAIReplyPlaceholder +} + +// EffectiveAIReplyTimeoutNotice 返回渠道生效的 AI 回复超时/失败提示语。 +func (c *Channel) EffectiveAIReplyTimeoutNotice() string { + if c != nil { + if value := strings.TrimSpace(c.AIReplyTimeoutNotice); value != "" { + return value + } + } + return DefaultAIReplyTimeoutNotice +} diff --git a/internal/models/models.go b/internal/models/models.go index 01b3e70d..b1ae9f97 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -77,6 +77,7 @@ var Models = []any{ &AIWorkflowNodeRun{}, &ConversationInterrupt{}, &SystemConfig{}, + &SystemLog{}, } // AgentToolInvocation persists the idempotency boundary for a business tool. @@ -753,6 +754,9 @@ type Channel struct { AIAgentID int64 `gorm:"type:bigint;not null;default:0;index"` // AIAgentID 为该渠道默认接入的 AI Agent。 当外部客户通过该渠道首次进入系统且尚未命中现有未结束会话时,系统会使用该 AI Agent 作为会话默认接待实例。 AIAgentRolloutPercent int `gorm:"type:int;not null;default:100"` // AIAgentRolloutPercent 为该渠道对 AI 自动回复施加的灰度百分比,100 表示不额外限制。 PreviousAIAgentRolloutPercent int `gorm:"type:int;not null;default:0"` // PreviousAIAgentRolloutPercent 保存渠道上一次生效的 Agent 灰度比例,0 表示尚无可回滚值。 + AIReplyPlaceholder string `gorm:"type:varchar(255);not null;default:''"` // AIReplyPlaceholder 为 AI 接待占位提示语,AI 收到客户消息后先回复该内容,正式回复生成后原地替换;空值使用系统默认文案。 + AIReplyTimeoutSeconds int `gorm:"type:int;not null;default:0"` // AIReplyTimeoutSeconds 为 AI 回复超时秒数,超时后向客户提示处理失败;0 表示使用系统默认(120 秒)。 + AIReplyTimeoutNotice string `gorm:"type:varchar(500);not null;default:''"` // AIReplyTimeoutNotice 为 AI 回复超时或处理失败时向客户提示的文案;空值使用系统默认文案。 // ConfigJSON 为渠道专属扩展配置,使用 JSON 存储。 // 例如: // 1. web 渠道可记录允许域名、品牌配置等; diff --git a/internal/models/system_log.go b/internal/models/system_log.go new file mode 100644 index 00000000..2b39f33b --- /dev/null +++ b/internal/models/system_log.go @@ -0,0 +1,18 @@ +package models + +import "time" + +// SystemLog 系统日志记录,持久化 INFO/WARN/ERROR 级别的运行日志。 +type SystemLog struct { + ID int64 `gorm:"primaryKey;autoIncrement"` + Level string `gorm:"type:varchar(20);not null;default:'';index"` // INFO / WARN / ERROR + Message string `gorm:"type:text"` // 日志消息 + Source string `gorm:"type:varchar(255);not null;default:'';index"` // file:line + LoggerName string `gorm:"type:varchar(128);not null;default:'';index"` // logger 名称 + Attrs string `gorm:"type:text"` // JSON 序列化的 attrs + CreatedAt time.Time `gorm:"not null;index"` // 日志发生时间 +} + +func (SystemLog) TableName() string { + return "t_system_log" +} diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index cbfff40e..a15d4c38 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -25,6 +25,7 @@ type Config struct { OIDC OIDCConfig `yaml:"oidc"` CustomerSession CustomerSessionConfig `yaml:"customerSession"` Webhook WebhookConfig `yaml:"webhook"` + Discord DiscordConfig `yaml:"discord"` } func (c Config) LanguageOrDefault() string { @@ -46,6 +47,15 @@ type WxWorkNotifyConfig struct { DuplicateCheckInterval int `yaml:"duplicateCheckInterval"` } +// WxWorkApiAppConfig 定义一个企业微信自建应用(agentId + corpSecret)。 +// 不同应用的 corpSecret 换取各自独立的 access_token,必须按应用分开缓存与使用。 +type WxWorkApiAppConfig struct { + // AgentID 为企业微信自建应用 AgentID。 + AgentID string `yaml:"agentId"` + // CorpSecret 为该应用的 Secret,用于换取该应用的 access_token。 + CorpSecret string `yaml:"corpSecret"` +} + type ServerConfig struct { Port int `yaml:"port"` CompanyName string `yaml:"companyName"` @@ -203,9 +213,14 @@ type WxWorkConfig struct { // CorpID 为企业微信公司 ID,例如 wwxxxxxxxxxxxxxxxx。 CorpID string `yaml:"corpId"` // CorpSecret 为企业微信应用 Secret,用于换取 access_token。 + // 仅用于单应用的旧配置;多应用场景请使用 APIApps。 CorpSecret string `yaml:"corpSecret"` // AgentID 为企业微信自建应用 AgentID。 + // 仅用于单应用的旧配置;多应用场景请使用 APIApps。 AgentID string `yaml:"agentId"` + // APIApps 为可用的企业微信自建应用列表,每项包含 agentId 与对应 corpSecret。 + // 接入渠道按 agentId 选择应用,使用该应用的 corpSecret 换取独立 access_token。 + APIApps []WxWorkApiAppConfig `yaml:"apiApps"` // OAuthRedirect 为企业微信网页授权回调地址。 // 必须填写完整 URL,且通常指向后端接口 /api/auth/wxwork/callback。 OAuthRedirect string `yaml:"oauthRedirect"` @@ -225,11 +240,55 @@ type WxWorkConfig struct { Notify WxWorkNotifyConfig `yaml:"notify"` } +// NormalizedAPIApps 返回去除空白并过滤掉不完整项后的 apiApps。 +// 当 apiApps 为空且配置了顶层 corpSecret 时,回退为单应用列表,兼容旧配置。 +func (c WxWorkConfig) NormalizedAPIApps() []WxWorkApiAppConfig { + apps := make([]WxWorkApiAppConfig, 0, len(c.APIApps)) + for _, app := range c.APIApps { + agentID := strings.TrimSpace(app.AgentID) + corpSecret := strings.TrimSpace(app.CorpSecret) + if agentID == "" || corpSecret == "" { + continue + } + apps = append(apps, WxWorkApiAppConfig{AgentID: agentID, CorpSecret: corpSecret}) + } + if len(apps) == 0 { + if corpSecret := strings.TrimSpace(c.CorpSecret); corpSecret != "" { + apps = append(apps, WxWorkApiAppConfig{ + AgentID: strings.TrimSpace(c.AgentID), + CorpSecret: corpSecret, + }) + } + } + return apps +} + +// FindAPIApp 按 agentID 查找已配置的应用。 +func (c WxWorkConfig) FindAPIApp(agentID string) (WxWorkApiAppConfig, bool) { + agentID = strings.TrimSpace(agentID) + for _, app := range c.NormalizedAPIApps() { + if app.AgentID == agentID { + return app, true + } + } + return WxWorkApiAppConfig{}, false +} + type WebhookConfig struct { OrgSyncSecret string `yaml:"orgSyncSecret"` DOSOrgSyncSecret string `yaml:"dosOrgSyncSecret"` } +// DiscordConfig holds deployment-wide Discord bot credentials. A channel may +// carry its own bot token, which takes precedence; these are the fallback for a +// single shared bot. +type DiscordConfig struct { + ClientID string `yaml:"clientId"` + ClientSecret string `yaml:"clientSecret"` + BotToken string `yaml:"botToken"` + PublicKey string `yaml:"publicKey"` +} + func Load(path string) (*Config, error) { loadDotEnv(path) @@ -312,30 +371,41 @@ func bindConfigDefaults(v *viper.Viper) { v.SetDefault("vectorDB.qdrant.host", "127.0.0.1") v.SetDefault("vectorDB.qdrant.grpcPort", 6334) v.SetDefault("mcp.enabled", true) + v.SetDefault("discord.clientId", "") + v.SetDefault("discord.clientSecret", "") + v.SetDefault("discord.botToken", "") + v.SetDefault("discord.publicKey", "") } func bindEnvironmentAliases(v *viper.Viper) { - _ = v.BindEnv("server.port", "PORT", "SERVER_PORT", "AGENT_DESK_SERVER_PORT") - _ = v.BindEnv("server.companyName", "COMPANY_NAME", "NEXT_PUBLIC_COMPANY_NAME", "BRAND_NAME", "BRAND_COMPANY_NAME", "AGENT_DESK_SERVER_COMPANYNAME") - _ = v.BindEnv("server.companyLogoUrl", "COMPANY_LOGO_URL", "NEXT_PUBLIC_COMPANY_LOGO_URL", "BRAND_LOGO_URL", "AGENT_DESK_SERVER_COMPANYLOGOURL") - _ = v.BindEnv("db.type", "DATABASE_TYPE", "DB_TYPE", "AGENT_DESK_DB_TYPE") - _ = v.BindEnv("db.dsn", "DATABASE_URL", "DB_DSN", "AGENT_DESK_DB_DSN") - _ = v.BindEnv("auth.passwordLoginEnabled", "PASSWORD_LOGIN_ENABLED", "AGENT_DESK_AUTH_PASSWORDLOGINENABLED") - _ = v.BindEnv("auth.tokenTTLHours", "AUTH_TOKEN_TTL_HOURS", "AGENT_DESK_AUTH_TOKENTTLHOURS") - _ = v.BindEnv("customerSession.secret", "CUSTOMER_SESSION_SECRET", "SESSION_SECRET", "JWT_SECRET", "AGENT_DESK_CUSTOMERSESSION_SECRET") - _ = v.BindEnv("storage.default", "STORAGE_DEFAULT", "STORAGE_TYPE", "AGENT_DESK_STORAGE_DEFAULT") - _ = v.BindEnv("storage.local.root", "STORAGE_LOCAL_ROOT", "AGENT_DESK_STORAGE_LOCAL_ROOT") - _ = v.BindEnv("storage.local.baseUrl", "STORAGE_LOCAL_BASE_URL", "AGENT_DESK_STORAGE_LOCAL_BASEURL") - _ = v.BindEnv("vectorDB.type", "VECTOR_DB_TYPE", "AGENT_DESK_VECTORDB_TYPE") - _ = v.BindEnv("vectorDB.qdrant.host", "QDRANT_HOST", "AGENT_DESK_VECTORDB_QDRANT_HOST") - _ = v.BindEnv("vectorDB.qdrant.grpcPort", "QDRANT_GRPC_PORT", "QDRANT_PORT", "AGENT_DESK_VECTORDB_QDRANT_GRPCPORT") - _ = v.BindEnv("vectorDB.qdrant.apiKey", "QDRANT_API_KEY", "AGENT_DESK_VECTORDB_QDRANT_APIKEY") - _ = v.BindEnv("oidc.enabled", "OIDC_ENABLED", "AGENT_DESK_OIDC_ENABLED") - _ = v.BindEnv("oidc.issuer", "OIDC_ISSUER", "AGENT_DESK_OIDC_ISSUER") - _ = v.BindEnv("oidc.clientId", "OIDC_CLIENT_ID", "CUSTOM_OAUTH_CLIENT_ID", "AGENT_DESK_OIDC_CLIENTID") - _ = v.BindEnv("oidc.clientSecret", "OIDC_CLIENT_SECRET", "CUSTOM_OAUTH_CLIENT_SECRET", "AGENT_DESK_OIDC_CLIENTSECRET") - _ = v.BindEnv("oidc.redirectUrl", "OIDC_REDIRECT_URL", "CUSTOM_OAUTH_REDIRECT_URI", "AGENT_DESK_OIDC_REDIRECTURL") - _ = v.BindEnv("webhook.orgSyncSecret", "ORG_SYNC_SECRET", "WEBHOOK_SECRET", "AGENT_DESK_WEBHOOK_ORGSYNCSECRET") + // Prefixed AGENT_DESK_* aliases are listed first so that ambient legacy + // variables (PORT, DATABASE_URL, ...) cannot silently override the + // documented configuration. + _ = v.BindEnv("server.port", "AGENT_DESK_SERVER_PORT", "PORT", "SERVER_PORT") + _ = v.BindEnv("server.companyName", "AGENT_DESK_SERVER_COMPANYNAME", "COMPANY_NAME", "NEXT_PUBLIC_COMPANY_NAME", "BRAND_NAME", "BRAND_COMPANY_NAME") + _ = v.BindEnv("server.companyLogoUrl", "AGENT_DESK_SERVER_COMPANYLOGOURL", "COMPANY_LOGO_URL", "NEXT_PUBLIC_COMPANY_LOGO_URL", "BRAND_LOGO_URL") + _ = v.BindEnv("db.type", "AGENT_DESK_DB_TYPE", "DATABASE_TYPE", "DB_TYPE") + _ = v.BindEnv("db.dsn", "AGENT_DESK_DB_DSN", "DATABASE_URL", "DB_DSN") + _ = v.BindEnv("auth.passwordLoginEnabled", "AGENT_DESK_AUTH_PASSWORDLOGINENABLED", "PASSWORD_LOGIN_ENABLED") + _ = v.BindEnv("auth.tokenTTLHours", "AGENT_DESK_AUTH_TOKENTTLHOURS", "AUTH_TOKEN_TTL_HOURS") + _ = v.BindEnv("customerSession.secret", "AGENT_DESK_CUSTOMERSESSION_SECRET", "CUSTOMER_SESSION_SECRET", "SESSION_SECRET", "JWT_SECRET") + _ = v.BindEnv("storage.default", "AGENT_DESK_STORAGE_DEFAULT", "STORAGE_DEFAULT", "STORAGE_TYPE") + _ = v.BindEnv("storage.local.root", "AGENT_DESK_STORAGE_LOCAL_ROOT", "STORAGE_LOCAL_ROOT") + _ = v.BindEnv("storage.local.baseUrl", "AGENT_DESK_STORAGE_LOCAL_BASEURL", "STORAGE_LOCAL_BASE_URL") + _ = v.BindEnv("vectorDB.type", "AGENT_DESK_VECTORDB_TYPE", "VECTOR_DB_TYPE") + _ = v.BindEnv("vectorDB.qdrant.host", "AGENT_DESK_VECTORDB_QDRANT_HOST", "QDRANT_HOST") + _ = v.BindEnv("vectorDB.qdrant.grpcPort", "AGENT_DESK_VECTORDB_QDRANT_GRPCPORT", "QDRANT_GRPC_PORT", "QDRANT_PORT") + _ = v.BindEnv("vectorDB.qdrant.apiKey", "AGENT_DESK_VECTORDB_QDRANT_APIKEY", "QDRANT_API_KEY") + _ = v.BindEnv("oidc.enabled", "AGENT_DESK_OIDC_ENABLED", "OIDC_ENABLED") + _ = v.BindEnv("oidc.issuer", "AGENT_DESK_OIDC_ISSUER", "OIDC_ISSUER") + _ = v.BindEnv("oidc.clientId", "AGENT_DESK_OIDC_CLIENTID", "OIDC_CLIENT_ID", "CUSTOM_OAUTH_CLIENT_ID") + _ = v.BindEnv("oidc.clientSecret", "AGENT_DESK_OIDC_CLIENTSECRET", "OIDC_CLIENT_SECRET", "CUSTOM_OAUTH_CLIENT_SECRET") + _ = v.BindEnv("oidc.redirectUrl", "AGENT_DESK_OIDC_REDIRECTURL", "OIDC_REDIRECT_URL", "CUSTOM_OAUTH_REDIRECT_URI") + _ = v.BindEnv("webhook.orgSyncSecret", "AGENT_DESK_WEBHOOK_ORGSYNCSECRET", "ORG_SYNC_SECRET", "WEBHOOK_SECRET") + _ = v.BindEnv("discord.clientId", "AGENT_DESK_DISCORD_CLIENTID", "DISCORD_CLIENT_ID") + _ = v.BindEnv("discord.clientSecret", "AGENT_DESK_DISCORD_CLIENTSECRET", "DISCORD_CLIENT_SECRET") + _ = v.BindEnv("discord.botToken", "AGENT_DESK_DISCORD_BOTTOKEN", "DISCORD_BOT_TOKEN") + _ = v.BindEnv("discord.publicKey", "AGENT_DESK_DISCORD_PUBLICKEY", "DISCORD_PUBLIC_KEY") } func normalizeLoadedConfig(cfg *Config) { diff --git a/internal/pkg/constants/auth.go b/internal/pkg/constants/auth.go index eae23100..0e095657 100644 --- a/internal/pkg/constants/auth.go +++ b/internal/pkg/constants/auth.go @@ -186,6 +186,9 @@ var ( // MCP 调试相关权限 PermissionMCPView = Permission{Name: "查看MCP调试信息", Code: "mcp.view", Type: "api", GroupName: "mcp", Method: "POST", APIPath: "/api/dashboard/mcp/list_tools", SortNo: 1710} PermissionMCPCall = Permission{Name: "调用MCP工具", Code: "mcp.call", Type: "api", GroupName: "mcp", Method: "POST", APIPath: "/api/dashboard/mcp/call_tool", SortNo: 1720} + + // 系统日志相关权限 + PermissionSystemLogView = Permission{Name: "查看系统日志", Code: "systemLog.view", Type: "api", GroupName: "systemLog", Method: "ANY", APIPath: "/api/dashboard/system-log/list", SortNo: 1810} ) // Permissions 内置权限列表 @@ -295,6 +298,7 @@ var Permissions = []Permission{ PermissionSkillDefinitionDelete, PermissionMCPView, PermissionMCPCall, + PermissionSystemLogView, } // PermissionMap 权限映射,用于通过 Code 查找 Permission @@ -392,6 +396,7 @@ var builtinPermissionResourceLabels = map[string]string{ "knowledgeFAQ": "knowledge FAQs", "skillDefinition": "Skill definitions", "mcp": "MCP tools", + "systemLog": "system logs", } var builtinPermissionNameOverrides = map[string]string{ @@ -444,6 +449,7 @@ var RolePermissions = map[string][]Permission{ PermissionAIAgentView, PermissionAIAgentCreate, PermissionAIAgentUpdate, PermissionAIAgentDelete, PermissionAIConfigView, PermissionAIConfigCreate, PermissionAIConfigUpdate, PermissionAIConfigDelete, PermissionSkillDefinitionView, PermissionSkillDefinitionCreate, PermissionSkillDefinitionUpdate, PermissionSkillDefinitionDelete, + PermissionSystemLogView, }, RoleCodeCsTeamLeader: { PermissionUserView, diff --git a/internal/pkg/dto/dto.go b/internal/pkg/dto/dto.go index 276d03a1..f61d5366 100644 --- a/internal/pkg/dto/dto.go +++ b/internal/pkg/dto/dto.go @@ -15,6 +15,9 @@ type AuthPrincipal struct { type WxWorkKFChannelConfig struct { OpenKfID string `json:"openKfId"` + // AgentID 指定该渠道使用的企业微信自建应用。 + // 系统据此找到对应的 corpSecret,换取并缓存该应用独立的 accessToken。 + AgentID string `json:"agentId"` } type WebChannelConfig struct { @@ -33,6 +36,21 @@ type WechatMPChannelConfig struct { UserTokenSecret string `json:"userTokenSecret,omitempty"` } +// WechatMiniProgramChannelConfig 微信小程序客服消息渠道配置。 +// +// 注意与 WechatMPChannelConfig(微信公众号)区分。 +type WechatMiniProgramChannelConfig struct { + Title string `json:"title"` + Subtitle string `json:"subtitle"` + ThemeColor string `json:"themeColor"` + UserTokenSecret string `json:"userTokenSecret,omitempty"` + AppID string `json:"appId"` + Token string `json:"token"` + EncodingAESKey string `json:"encodingAESKey"` + TokenServiceURL string `json:"tokenServiceUrl"` + TokenServiceSecret string `json:"tokenServiceSecret,omitempty"` +} + type TelegramChannelConfig struct { BotToken string `json:"botToken"` BotUsername string `json:"botUsername,omitempty"` @@ -49,3 +67,14 @@ type ZaloOAChannelConfig struct { WebhookSecret string `json:"webhookSecret,omitempty"` WelcomeMessage string `json:"welcomeMessage,omitempty"` } + +type DiscordChannelConfig struct { + GuildID string `json:"guildId,omitempty"` + GuildName string `json:"guildName,omitempty"` + ChannelScope string `json:"channelScope,omitempty"` // all | dm_only + BotToken string `json:"botToken,omitempty"` + ApplicationID string `json:"applicationId,omitempty"` + PublicKey string `json:"publicKey,omitempty"` + WebhookSecret string `json:"webhookSecret,omitempty"` + WelcomeMessage string `json:"welcomeMessage,omitempty"` +} diff --git a/internal/pkg/dto/request/channel_request.go b/internal/pkg/dto/request/channel_request.go index a5095cb3..0853a98a 100644 --- a/internal/pkg/dto/request/channel_request.go +++ b/internal/pkg/dto/request/channel_request.go @@ -4,6 +4,9 @@ type CreateChannelRequest struct { ChannelType string `json:"channelType"` AIAgentID int64 `json:"aiAgentId"` AIAgentRolloutPercent int `json:"aiAgentRolloutPercent"` + AIReplyPlaceholder string `json:"aiReplyPlaceholder"` + AIReplyTimeoutSeconds int `json:"aiReplyTimeoutSeconds"` + AIReplyTimeoutNotice string `json:"aiReplyTimeoutNotice"` Name string `json:"name"` ConfigJSON string `json:"configJson"` Status int `json:"status"` @@ -32,6 +35,10 @@ type ResetChannelUserTokenSecretRequest struct { ID int64 `json:"id"` } +type TestWxWorkKFReadMessagesRequest struct { + ID int64 `json:"id"` +} + type ChannelMessageOutboxActionRequest struct { ID int64 `json:"id"` } diff --git a/internal/pkg/dto/request/knowledge_request.go b/internal/pkg/dto/request/knowledge_request.go index f0d20b02..a9e5305b 100644 --- a/internal/pkg/dto/request/knowledge_request.go +++ b/internal/pkg/dto/request/knowledge_request.go @@ -65,6 +65,10 @@ type BatchDeleteKnowledgeDocumentRequest struct { IDs []int64 `json:"ids"` } +type BatchBuildKnowledgeDocumentRequest struct { + IDs []int64 `json:"ids"` +} + type CreateKnowledgeFAQRequest struct { KnowledgeBaseID int64 `json:"knowledgeBaseId"` DirectoryID int64 `json:"directoryId"` diff --git a/internal/pkg/dto/response/channel_response.go b/internal/pkg/dto/response/channel_response.go index e1c36f99..1009bfb7 100644 --- a/internal/pkg/dto/response/channel_response.go +++ b/internal/pkg/dto/response/channel_response.go @@ -13,6 +13,9 @@ type ChannelResponse struct { AIAgentID int64 `json:"aiAgentId"` AIAgentRolloutPercent int `json:"aiAgentRolloutPercent"` PreviousAIAgentRolloutPercent int `json:"previousAiAgentRolloutPercent"` + AIReplyPlaceholder string `json:"aiReplyPlaceholder"` + AIReplyTimeoutSeconds int `json:"aiReplyTimeoutSeconds"` + AIReplyTimeoutNotice string `json:"aiReplyTimeoutNotice"` AIAgentName string `json:"aiAgentName,omitempty"` Name string `json:"name"` ConfigJSON string `json:"configJson"` @@ -27,6 +30,11 @@ type WxWorkKFAccountResponse struct { ManagePrivilege bool `json:"managePrivilege"` } +// WxWorkApiAppResponse 为配置文件中可用的企业微信应用。 +type WxWorkApiAppResponse struct { + AgentID string `json:"agentId"` +} + type ChannelMessageOutboxResponse struct { ID int64 `json:"id"` ChannelType string `json:"channelType"` @@ -55,6 +63,9 @@ func BuildChannelResponse(item *models.Channel) ChannelResponse { AIAgentID: item.AIAgentID, AIAgentRolloutPercent: item.AIAgentRolloutPercent, PreviousAIAgentRolloutPercent: item.PreviousAIAgentRolloutPercent, + AIReplyPlaceholder: item.AIReplyPlaceholder, + AIReplyTimeoutSeconds: item.AIReplyTimeoutSeconds, + AIReplyTimeoutNotice: item.AIReplyTimeoutNotice, Name: item.Name, ConfigJSON: item.ConfigJSON, Status: item.Status, diff --git a/internal/pkg/dto/response/conversation_response.go b/internal/pkg/dto/response/conversation_response.go index 697dbfdf..f357b5ae 100644 --- a/internal/pkg/dto/response/conversation_response.go +++ b/internal/pkg/dto/response/conversation_response.go @@ -21,6 +21,8 @@ type ConversationResponse struct { ID int64 `json:"id"` AIAgentID int64 `json:"aiAgentId"` ChannelID int64 `json:"channelId"` + ChannelType string `json:"channelType,omitempty"` + ChannelName string `json:"channelName,omitempty"` CustomerID int64 `json:"customerId"` CustomerName string `json:"customerName"` Status enums.IMConversationStatus `json:"status"` diff --git a/internal/pkg/dto/response/system_log_response.go b/internal/pkg/dto/response/system_log_response.go new file mode 100644 index 00000000..562e23ec --- /dev/null +++ b/internal/pkg/dto/response/system_log_response.go @@ -0,0 +1,15 @@ +package response + +import "time" + +// SystemLogResponse 系统日志列表/详情响应。 +type SystemLogResponse struct { + ID int64 `json:"id"` + Level string `json:"level"` + LevelName string `json:"levelName"` + Message string `json:"message"` + Source string `json:"source"` + LoggerName string `json:"loggerName"` + Attrs string `json:"attrs"` + CreatedAt time.Time `json:"createdAt"` +} diff --git a/internal/pkg/dto/response/wxwork_kf_message_read_test_response.go b/internal/pkg/dto/response/wxwork_kf_message_read_test_response.go new file mode 100644 index 00000000..e98f0b18 --- /dev/null +++ b/internal/pkg/dto/response/wxwork_kf_message_read_test_response.go @@ -0,0 +1,32 @@ +package response + +// WxWorkKFMessageReadTestResult 为“测试读取企业微信客服消息”的结果。 +// 该接口是只读连通性测试:不消费消息、不创建会话、不更新同步游标; +// 调用失败也以 HTTP 200 返回结构化结果,由前端按错误码给出排查建议。 +type WxWorkKFMessageReadTestResult struct { + Success bool `json:"success"` // 是否成功读取 + Stage string `json:"stage,omitempty"` // 失败环节:gettoken / syncmsg + ErrorCode string `json:"errorCode,omitempty"` // 企业微信原始 errcode,或本地错误码 + ErrorMessage string `json:"errorMessage,omitempty"` // 企业微信原始 errmsg 或网络错误原文 + OpenKfID string `json:"openKfId,omitempty"` // 测试使用的客服账号 ID + + TotalScanned int `json:"totalScanned"` // 本次扫描到的消息/事件总条数(受分页上限约束) + MessageCount int `json:"messageCount"` // 普通消息条数 + EventCount int `json:"eventCount"` // 事件条数 + Truncated bool `json:"truncated"` // 是否因达到分页扫描上限而提前停止 + EarliestTime string `json:"earliestTime,omitempty"` + LatestTime string `json:"latestTime,omitempty"` + Samples []WxWorkKFMessageReadSample `json:"samples"` // 最近若干条样例(新消息在前) +} + +// WxWorkKFMessageReadSample 为测试结果中的单条消息/事件样例,仅携带展示所需的原始字段,本地化在前端完成。 +type WxWorkKFMessageReadSample struct { + MsgID string `json:"msgId,omitempty"` + SendTime string `json:"sendTime,omitempty"` + Origin int `json:"origin"` // 3-客户发送 4-系统事件 5-接待人员发送 + MsgType string `json:"msgType,omitempty"` // text/image/file/voice/video/event 等 + EventType string `json:"eventType,omitempty"` // msgtype=event 时的事件类型 + TextContent string `json:"textContent,omitempty"` // 文本消息原文(已截断) + ExternalUserID string `json:"externalUserId,omitempty"` // 客户 external_userid + ServicerUserID string `json:"servicerUserId,omitempty"` // 接待人员 userid +} diff --git a/internal/pkg/enums/external_identity.go b/internal/pkg/enums/external_identity.go index 2ace3f0a..e73ce77a 100644 --- a/internal/pkg/enums/external_identity.go +++ b/internal/pkg/enums/external_identity.go @@ -6,21 +6,25 @@ package enums type ExternalSource string const ( - ExternalSourceGuest ExternalSource = "guest" // 访客 - ExternalSourceWxWorkKF ExternalSource = "wxwork_kf" // 企业微信客服 - ExternalSourceUser ExternalSource = "user" // 站内用户 - ExternalSourceExternal ExternalSource = "external" // 外部接入方用户 - ExternalSourceTelegram ExternalSource = "telegram" // Telegram - ExternalSourceZaloOA ExternalSource = "zalo_oa" // Zalo OA + ExternalSourceGuest ExternalSource = "guest" // 访客 + ExternalSourceWxWorkKF ExternalSource = "wxwork_kf" // 企业微信客服 + ExternalSourceUser ExternalSource = "user" // 站内用户 + ExternalSourceExternal ExternalSource = "external" // 外部接入方用户 + ExternalSourceTelegram ExternalSource = "telegram" // Telegram + ExternalSourceZaloOA ExternalSource = "zalo_oa" // Zalo OA + ExternalSourceDiscord ExternalSource = "discord" // Discord + ExternalSourceWechatMiniProgram ExternalSource = "wechat_miniprogram" // 微信小程序 ) var externalSourceLabelMap = map[ExternalSource]string{ - ExternalSourceGuest: "访客", - ExternalSourceWxWorkKF: "企业微信客服", - ExternalSourceUser: "站内用户", - ExternalSourceExternal: "外部用户", - ExternalSourceTelegram: "Telegram", - ExternalSourceZaloOA: "Zalo OA", + ExternalSourceGuest: "访客", + ExternalSourceWxWorkKF: "企业微信客服", + ExternalSourceUser: "站内用户", + ExternalSourceExternal: "外部用户", + ExternalSourceTelegram: "Telegram", + ExternalSourceZaloOA: "Zalo OA", + ExternalSourceDiscord: "Discord", + ExternalSourceWechatMiniProgram: "微信小程序", } func GetExternalSourceLabel(v ExternalSource) string { diff --git a/internal/pkg/enums/im.go b/internal/pkg/enums/im.go index da23f4c3..0743b7fe 100644 --- a/internal/pkg/enums/im.go +++ b/internal/pkg/enums/im.go @@ -246,6 +246,7 @@ const ( IMRealtimeEventUnsubscribed = "unsubscribed" IMRealtimeEventResyncRequired = "resyncRequired" IMRealtimeEventMessageCreated = "message.created" + IMRealtimeEventMessageUpdated = "message.updated" IMRealtimeEventMessageRecalled = "message.recalled" IMRealtimeEventConversationCreated = "conversation.created" IMRealtimeEventConversationUpdated = "conversation.updated" diff --git a/internal/pkg/enums/wxwork_kf.go b/internal/pkg/enums/wxwork_kf.go index 825f7fa6..33d7fc9b 100644 --- a/internal/pkg/enums/wxwork_kf.go +++ b/internal/pkg/enums/wxwork_kf.go @@ -23,6 +23,7 @@ const ( ChannelTypeWxWorkKF = "wxwork_kf" ChannelTypeTelegram = "telegram" ChannelTypeZaloOA = "zalo_oa" + ChannelTypeDiscord = "discord" ) type WxWorkKFMessageSendStatus string diff --git a/internal/pkg/i18nx/locales/en-US.yml b/internal/pkg/i18nx/locales/en-US.yml index ac67c301..94f32600 100644 --- a/internal/pkg/i18nx/locales/en-US.yml +++ b/internal/pkg/i18nx/locales/en-US.yml @@ -345,6 +345,8 @@ error.e0344: "Invalid attachment message payload format." error.e0345: "Attachment message is missing assetId." error.e0346: "Attachment message is missing payload." error.e0347: "Default team queue mode requires at least one agent team." +error.e0348: "This file type cannot be uploaded for security reasons." +error.e0349: "Invalid AI reply timeout." error.profile.nicknameRequired: "Enter a nickname." error.profile.nicknameTooLong: "Nickname cannot exceed 100 characters." error.profile.avatarTooLong: "Avatar link cannot exceed 255 characters." @@ -376,6 +378,8 @@ error.wxwork.userIDTaken: "The WeCom user ID is already used as a system usernam error.wxwork.mobileTaken: "The WeCom mobile number is already used by a system user." error.wxwork.emailTaken: "The WeCom email address is already used by a system user." error.wxwork.configIncomplete: "WeCom is not enabled or its configuration is incomplete." +error.wxwork.appNotConfigured: "The WeCom app is not configured or enabled, agentId: %s" +error.wxwork.agentIdRequired: "Please select a WeCom app (agentId)." error.customerSession.secretMissing: "Customer session secret is not configured." error.conversation.createFailed: "Failed to create conversation." error.conversation.tagNotFound: "Tag not found." diff --git a/internal/pkg/i18nx/locales/zh-CN.yml b/internal/pkg/i18nx/locales/zh-CN.yml index e168083c..9e7084cb 100644 --- a/internal/pkg/i18nx/locales/zh-CN.yml +++ b/internal/pkg/i18nx/locales/zh-CN.yml @@ -345,6 +345,8 @@ error.e0344: "附件消息 payload 格式错误" error.e0345: "附件消息缺少 assetId" error.e0346: "附件消息缺少 payload" error.e0347: "默认客服组待接入池模式必须至少选择一个客服组" +error.e0348: "出于安全考虑,此文件类型不支持上传" +error.e0349: "AI 回复超时时长不合法" error.profile.nicknameRequired: "请输入昵称" error.profile.nicknameTooLong: "昵称不能超过 100 个字符" error.profile.avatarTooLong: "头像链接不能超过 255 个字符" @@ -376,6 +378,8 @@ error.wxwork.userIDTaken: "企业微信用户ID已被系统用户名占用" error.wxwork.mobileTaken: "企业微信手机号已被系统用户占用" error.wxwork.emailTaken: "企业微信邮箱已被系统用户占用" error.wxwork.configIncomplete: "企业微信未启用或配置不完整" +error.wxwork.appNotConfigured: "企业微信应用未配置或未启用,agentId: %s" +error.wxwork.agentIdRequired: "请选择企业微信应用(agentId)" error.customerSession.secretMissing: "客服会话密钥未配置" error.conversation.createFailed: "创建会话失败" error.conversation.tagNotFound: "标签不存在" diff --git a/internal/pkg/logx/db_sink.go b/internal/pkg/logx/db_sink.go new file mode 100644 index 00000000..e670cb90 --- /dev/null +++ b/internal/pkg/logx/db_sink.go @@ -0,0 +1,225 @@ +package logx + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "runtime" + "sync" + "sync/atomic" + "time" + + "agent-desk/internal/models" + + "gorm.io/gorm" +) + +const ( + dbSinkChannelCapacity = 4096 + dbSinkBatchSize = 200 + dbSinkFlushInterval = 1 * time.Second +) + +// dbSink 包装底层 stdout handler,并将 WARN/ERROR 级别日志异步写入数据库。 +type dbSink struct { + inner slog.Handler + + // DB 引用,通过 atomic 实现 lazy 注入。nil 时跳过 DB 写入。 + db atomic.Pointer[gorm.DB] + + // 日志 channel,worker goroutine 消费。 + ch chan models.SystemLog + + // 控制 worker 生命周期。 + stopOnce sync.Once + stop chan struct{} + done chan struct{} +} + +// dbSinkDropped 记录因 channel 满而丢弃的日志条数(进程级累计)。 +var dbSinkDropped atomic.Uint64 + +// dbSinkLastDropReportSec 记录上次输出丢弃告警的秒级时间戳,用于限频,避免 INFO 高频时刷屏 stderr。 +var dbSinkLastDropReportSec atomic.Int64 + +var sink *dbSink + +// AttachDB 注入 DB 并启动 worker goroutine。调用幂等。 +func AttachDB(db *gorm.DB) { + if sink == nil { + return + } + if sink.db.Load() != nil { + return + } + sink.db.Store(db) + go sink.worker() +} + +// Close 触发 worker 最终 flush 并退出。在 server shutdown 时调用。 +func Close() error { + if sink == nil { + return nil + } + sink.stopOnce.Do(func() { + close(sink.stop) + }) + select { + case <-sink.done: + case <-time.After(3 * time.Second): + } + return nil +} + +func (h *dbSink) Handle(ctx context.Context, r slog.Record) error { + // 先走 stdout + if err := h.inner.Handle(ctx, r.Clone()); err != nil { + return err + } + + // 只持久化 WARN 及以上级别(DEBUG/INFO 不写库) + if r.Level < slog.LevelWarn { + return nil + } + + // DB 未注入时跳过 + db := h.db.Load() + if db == nil { + return nil + } + + // 构造 SystemLog + logEntry := buildSystemLog(r) + + // 非阻塞写入 channel,满时丢弃(每秒最多输出一次 stderr 告警,避免高频日志刷屏) + select { + case h.ch <- logEntry: + default: + dropped := dbSinkDropped.Add(1) + nowSec := time.Now().Unix() + if dbSinkLastDropReportSec.Swap(nowSec) != nowSec { + fmt.Fprintf(os.Stderr, "logx: db sink channel full, dropped %d log entries\n", dropped) + } + } + + return nil +} + +func (h *dbSink) Enabled(ctx context.Context, level slog.Level) bool { + return h.inner.Enabled(ctx, level) +} + +func (h *dbSink) WithAttrs(attrs []slog.Attr) slog.Handler { + return &dbSink{ + inner: h.inner.WithAttrs(attrs), + db: h.db, + ch: h.ch, + stop: h.stop, + done: h.done, + } +} + +func (h *dbSink) WithGroup(name string) slog.Handler { + return &dbSink{ + inner: h.inner.WithGroup(name), + db: h.db, + ch: h.ch, + stop: h.stop, + done: h.done, + } +} + +func (h *dbSink) worker() { + defer close(h.done) + + batch := make([]models.SystemLog, 0, dbSinkBatchSize) + ticker := time.NewTicker(dbSinkFlushInterval) + defer ticker.Stop() + + flush := func() { + if len(batch) == 0 { + return + } + db := h.db.Load() + if db == nil { + batch = batch[:0] + return + } + if err := db.CreateInBatches(batch, dbSinkBatchSize).Error; err != nil { + // 写入失败不影响主流程,输出到 stderr + fmt.Fprintf(os.Stderr, "logx: batch insert system logs failed: %v\n", err) + } + batch = batch[:0] + } + + for { + select { + case <-h.stop: + // drain 剩余日志 + for { + select { + case entry := <-h.ch: + batch = append(batch, entry) + if len(batch) >= dbSinkBatchSize { + flush() + } + default: + flush() + return + } + } + case entry := <-h.ch: + batch = append(batch, entry) + if len(batch) >= dbSinkBatchSize { + flush() + } + case <-ticker.C: + flush() + } + } +} + +func buildSystemLog(r slog.Record) models.SystemLog { + // 提取 source(file:line) + source := "" + if r.PC != 0 { + frames := runtimeCallerFrames(r.PC) + if frames != nil { + source = fmt.Sprintf("%s:%d", frames.File, frames.Line) + } + } + + // 提取 attrs + attrs := map[string]any{} + r.Attrs(func(a slog.Attr) bool { + attrs[a.Key] = a.Value.Any() + return true + }) + + attrsJSON := "" + if len(attrs) > 0 { + if b, err := json.Marshal(attrs); err == nil { + attrsJSON = string(b) + } + } + + return models.SystemLog{ + Level: r.Level.String(), + Message: r.Message, + Source: source, + LoggerName: "default", + Attrs: attrsJSON, + CreatedAt: r.Time, + } +} + +func runtimeCallerFrames(pc uintptr) *runtime.Frame { + frames := runtime.CallersFrames([]uintptr{pc}) + frame, _ := frames.Next() + if frame.File == "" { + return nil + } + return &frame +} diff --git a/internal/pkg/logx/logger.go b/internal/pkg/logx/logger.go index da116583..de2b66af 100644 --- a/internal/pkg/logx/logger.go +++ b/internal/pkg/logx/logger.go @@ -4,28 +4,45 @@ import ( "log/slog" "os" "strings" + + "agent-desk/internal/models" ) // Config 定义日志初始化参数。 type Config struct { - Level string `yaml:"level"` - Format string `yaml:"format"` - AddSource bool `yaml:"addSource"` + Level string `yaml:"level"` + Format string `yaml:"format"` + AddSource bool `yaml:"addSource"` + EnableDBSink bool `yaml:"enableDBSink"` } // Init 初始化全局 slog logger,并设置为默认 logger。 +// 当 EnableDBSink 为 true 时,创建复合 handler(stdout + dbSink), +// dbSink 的 DB 引用在 AttachDB 调用前为 nil,不会写库。 func Init(cfg Config) *slog.Logger { level := parseLevel(cfg.Level) opts := &slog.HandlerOptions{ Level: level, - AddSource: cfg.AddSource, + AddSource: true, // 强制开启,以便 DB sink 记录 source } - var handler slog.Handler + var stdoutHandler slog.Handler if strings.EqualFold(cfg.Format, "json") { - handler = slog.NewJSONHandler(os.Stdout, opts) + stdoutHandler = slog.NewJSONHandler(os.Stdout, opts) } else { - handler = slog.NewTextHandler(os.Stdout, opts) + stdoutHandler = slog.NewTextHandler(os.Stdout, opts) + } + + var handler slog.Handler = stdoutHandler + + if cfg.EnableDBSink { + sink = &dbSink{ + inner: stdoutHandler, + ch: make(chan models.SystemLog, dbSinkChannelCapacity), + stop: make(chan struct{}), + done: make(chan struct{}), + } + handler = sink } logger := slog.New(handler) diff --git a/internal/repositories/system_log_repository.go b/internal/repositories/system_log_repository.go new file mode 100644 index 00000000..a42a0854 --- /dev/null +++ b/internal/repositories/system_log_repository.go @@ -0,0 +1,47 @@ +package repositories + +import ( + "time" + + "agent-desk/internal/models" + + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +var SystemLogRepository = newSystemLogRepository() + +func newSystemLogRepository() *systemLogRepository { + return &systemLogRepository{} +} + +type systemLogRepository struct { +} + +func (r *systemLogRepository) Get(db *gorm.DB, id int64) *models.SystemLog { + ret := &models.SystemLog{} + if err := db.First(ret, "id = ?", id).Error; err != nil { + return nil + } + return ret +} + +func (r *systemLogRepository) FindPageByCnd(db *gorm.DB, cnd *sqls.Cnd) (list []models.SystemLog, paging *sqls.Paging) { + cnd.Find(db, &list) + count := cnd.Count(db, &models.SystemLog{}) + + paging = &sqls.Paging{ + Page: cnd.Paging.Page, + Limit: cnd.Paging.Limit, + Total: count, + } + return +} + +func (r *systemLogRepository) DeleteOlderThan(db *gorm.DB, before time.Time) (count int64) { + res := db.Where("created_at < ?", before).Delete(&models.SystemLog{}) + if res.Error == nil { + count = res.RowsAffected + } + return +} diff --git a/internal/services/ai_reply_placeholder_service.go b/internal/services/ai_reply_placeholder_service.go new file mode 100644 index 00000000..c4e8d613 --- /dev/null +++ b/internal/services/ai_reply_placeholder_service.go @@ -0,0 +1,205 @@ +package services + +import ( + "log/slog" + "strconv" + "strings" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + + "github.com/mlogclub/simple/sqls" +) + +// AI 回复占位消息使用确定性 clientMsgID 前缀,保证同一触发消息的占位与超时提示幂等; +// aiReplyPendingPayload 为占位消息的 payload 标记,原地替换前用于守卫,避免覆盖已生成的正式回复。 +const ( + aiReplyPlaceholderClientMsgIDPrefix = "ai_placeholder_" + aiReplyTimeoutNoticeClientMsgIDPrefix = "ai_timeout_" + aiReplyPendingPayload = `{"aiReplyPending":true}` +) + +// aiReplyPrincipal 构建 AI 消息的操作者信息。 +func aiReplyPrincipal(aiAgent models.AIAgent) *dto.AuthPrincipal { + username := "AI" + if strings.TrimSpace(aiAgent.Name) != "" { + username = strings.TrimSpace(aiAgent.Name) + } + return &dto.AuthPrincipal{ + UserID: 0, + Username: username, + Nickname: username, + } +} + +// aiReplyPlaceholderClientMsgID 返回触发消息对应的占位消息 clientMsgID。 +func aiReplyPlaceholderClientMsgID(triggerMessageID int64) string { + return aiReplyPlaceholderClientMsgIDPrefix + strconv.FormatInt(triggerMessageID, 10) +} + +// SendAIReplyPlaceholder 在正式 AI 回复生成前,向客户发送一条占位提示消息。 +// 通过确定性 clientMsgID 保证幂等;发送失败仅记录日志,不阻断 AI 回复流程。 +func (s *messageService) SendAIReplyPlaceholder(conversation *models.Conversation, aiAgent *models.AIAgent, triggerMessage *models.Message, channel *models.Channel, requestID string) { + if conversation == nil || aiAgent == nil || triggerMessage == nil { + return + } + content := models.DefaultAIReplyPlaceholder + if channel != nil { + content = channel.EffectiveAIReplyPlaceholder() + } + if _, err := s.SendAIMessageWithRequestIDAndWorkflowRunID( + conversation.ID, + aiAgent.ID, + aiReplyPlaceholderClientMsgID(triggerMessage.ID), + enums.IMMessageTypeText, + content, + aiReplyPendingPayload, + aiReplyPrincipal(*aiAgent), + requestID, + 0, + ); err != nil { + slog.Error("send ai reply placeholder failed", + "conversation_id", conversation.ID, + "message_id", triggerMessage.ID, + "error", err, + ) + } +} + +// TryReplaceAIReplyPlaceholder 尝试将 web 渠道仍处于待回复状态的占位消息原地替换为正式回复。 +// 仅 web 渠道支持原地替换;占位消息不存在或已被替换时返回 nil,由调用方回退为新消息。 +func (s *messageService) TryReplaceAIReplyPlaceholder(conversation *models.Conversation, triggerMessageID int64, replyText string, workflowRunID int64, aiAgent models.AIAgent) *models.Message { + if conversation == nil { + return nil + } + channel := ChannelService.Get(conversation.ChannelID) + if channel == nil || channel.ChannelType != enums.ChannelTypeWeb { + return nil + } + placeholder := repositories.MessageRepository.GetByClientMsgID(sqls.DB(), conversation.ID, aiReplyPlaceholderClientMsgID(triggerMessageID)) + if placeholder == nil || strings.TrimSpace(placeholder.Payload) != aiReplyPendingPayload { + return nil + } + content, _, summary, err := s.normalizeMessageContent(conversation.ID, enums.IMMessageTypeText, replyText, "") + if err != nil { + slog.Error("normalize ai reply content failed", + "conversation_id", conversation.ID, + "message_id", placeholder.ID, + "error", err, + ) + return nil + } + return s.replaceAIReplyPlaceholder(conversation, placeholder, content, summary, workflowRunID, aiAgent) +} + +// CompleteAIReplyPlaceholderAsFailed 在 AI 处理超时或失败时向客户发送失败提示: +// web 渠道且占位消息仍待回复时原地替换为提示语;否则以新消息补发(确定性 clientMsgID 保证幂等)。 +// 已转人工或已关闭的会话不再补发提示。 +func (s *messageService) CompleteAIReplyPlaceholderAsFailed(conversation *models.Conversation, aiAgent *models.AIAgent, triggerMessage *models.Message, requestID string) { + if conversation == nil || aiAgent == nil || triggerMessage == nil { + return + } + if conversation.Status == enums.IMConversationStatusClosed { + return + } + if conversation.CurrentAssigneeID > 0 || conversation.HandoffAt != nil { + return + } + notice := models.DefaultAIReplyTimeoutNotice + if channel := ChannelService.Get(conversation.ChannelID); channel != nil { + notice = channel.EffectiveAIReplyTimeoutNotice() + } + content, _, summary, err := s.normalizeMessageContent(conversation.ID, enums.IMMessageTypeText, notice, "") + if err != nil { + slog.Error("normalize ai timeout notice failed", + "conversation_id", conversation.ID, + "message_id", triggerMessage.ID, + "error", err, + ) + return + } + + placeholder := repositories.MessageRepository.GetByClientMsgID(sqls.DB(), conversation.ID, aiReplyPlaceholderClientMsgID(triggerMessage.ID)) + if placeholder != nil { + if strings.TrimSpace(placeholder.Payload) != aiReplyPendingPayload { + // 正式回复已生成并替换占位消息,无需再提示失败 + return + } + if channel := ChannelService.Get(conversation.ChannelID); channel != nil && channel.ChannelType == enums.ChannelTypeWeb { + s.replaceAIReplyPlaceholder(conversation, placeholder, content, summary, 0, *aiAgent) + return + } + } + + // 外部渠道无法编辑已发送消息,或占位消息缺失时,以新消息补发失败提示 + if _, err := s.SendAIMessageWithRequestIDAndWorkflowRunID( + conversation.ID, + aiAgent.ID, + aiReplyTimeoutNoticeClientMsgIDPrefix+strconv.FormatInt(triggerMessage.ID, 10), + enums.IMMessageTypeText, + content, + "", + aiReplyPrincipal(*aiAgent), + requestID, + 0, + ); err != nil { + slog.Error("send ai timeout notice failed", + "conversation_id", conversation.ID, + "message_id", triggerMessage.ID, + "error", err, + ) + } +} + +// replaceAIReplyPlaceholder 将占位消息原地更新为指定内容,并同步会话摘要与实时事件。 +func (s *messageService) replaceAIReplyPlaceholder(conversation *models.Conversation, placeholder *models.Message, content string, summary string, workflowRunID int64, aiAgent models.AIAgent) *models.Message { + now := time.Now() + agentName := strings.TrimSpace(aiAgent.Name) + // 占位消息内容与会话摘要的更新保持原子性 + if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + if err := repositories.MessageRepository.Updates(ctx.Tx, placeholder.ID, map[string]any{ + "content": content, + "payload": "", + "workflow_run_id": workflowRunID, + "update_user_id": 0, + "update_user_name": agentName, + "updated_at": now, + }); err != nil { + return err + } + + // 占位消息仍是会话最后一条消息时,同步列表摘要 + if conversation.LastMessageID == placeholder.ID { + summaryText := limitText(summary, 255) + conversation.LastMessageSummary = summaryText + conversation.UpdatedAt = now + return repositories.ConversationRepository.Updates(ctx.Tx, conversation.ID, map[string]any{ + "last_message_summary": summaryText, + "update_user_id": 0, + "update_user_name": agentName, + "updated_at": now, + }) + } + return nil + }); err != nil { + slog.Error("replace ai reply placeholder failed", + "conversation_id", conversation.ID, + "message_id", placeholder.ID, + "error", err, + ) + return nil + } + placeholder.Content = content + placeholder.Payload = "" + placeholder.WorkflowRunID = workflowRunID + placeholder.UpdatedAt = now + placeholder.UpdateUserID = 0 + placeholder.UpdateUserName = agentName + + WsService.PublishMessageUpdated(conversation, placeholder) + WsService.PublishConversationChanged(conversation, enums.IMRealtimeEventConversationUpdated) + return placeholder +} diff --git a/internal/services/asset_service.go b/internal/services/asset_service.go index 8746994a..495ee0a1 100644 --- a/internal/services/asset_service.go +++ b/internal/services/asset_service.go @@ -61,12 +61,16 @@ func (s *assetService) OpenReader(asset *models.Asset) (io.ReadCloser, error) { } func (s *assetService) UploadBytes(data []byte, prefix, filename string, principal *dto.AuthPrincipal) (*models.Asset, error) { - src := bytes.NewReader(data) - return s.Upload(src, storage.UploadInfo{ + filename = storage.SanitizeFilename(filename) + mimeType, err := storage.ValidateUpload(filename, "", http.DetectContentType(data)) + if err != nil { + return nil, err + } + return s.Upload(bytes.NewReader(data), storage.UploadInfo{ Prefix: prefix, Filename: filename, FileSize: int64(len(data)), - MimeType: http.DetectContentType(data), + MimeType: mimeType, Principal: principal, }) } @@ -87,11 +91,22 @@ func (s *assetService) UploadFile(file *multipart.FileHeader, prefix string, pri } defer func() { _ = src.Close() }() + sniffed, err := storage.SniffContentType(src) + if err != nil { + return nil, err + } + + filename := storage.SanitizeFilename(file.Filename) + mimeType, err := storage.ValidateUpload(filename, file.Header.Get("Content-Type"), sniffed) + if err != nil { + return nil, err + } + return s.Upload(src, storage.UploadInfo{ Prefix: prefix, - Filename: file.Filename, + Filename: filename, FileSize: file.Size, - MimeType: file.Header.Get("Content-Type"), + MimeType: mimeType, Principal: principal, }) } diff --git a/internal/services/channel_message_outbox_service.go b/internal/services/channel_message_outbox_service.go index 2660a8bc..c4843a7f 100644 --- a/internal/services/channel_message_outbox_service.go +++ b/internal/services/channel_message_outbox_service.go @@ -250,16 +250,90 @@ func (s *channelMessageOutboxService) EnqueueZaloOAMessage(conversation *models. return nil } +func (s *channelMessageOutboxService) EnqueueDiscordMessage(conversation *models.Conversation, message *models.Message) error { + if conversation == nil || message == nil { + return nil + } + channel := ChannelService.Get(conversation.ChannelID) + if channel == nil || channel.ChannelType != enums.ChannelTypeDiscord { + return nil + } + if message.SenderType != enums.IMSenderTypeAgent && message.SenderType != enums.IMSenderTypeAI { + return nil + } + if message.MessageType != enums.IMMessageTypeText && message.MessageType != enums.IMMessageTypeHTML && message.MessageType != enums.IMMessageTypeImage && message.MessageType != enums.IMMessageTypeAttachment { + return nil + } + if existing := s.GetByMessageID(enums.ChannelTypeDiscord, message.ID); existing != nil { + return nil + } + + payload, err := json.Marshal(map[string]any{ + "conversationId": conversation.ID, + "messageId": message.ID, + "messageType": message.MessageType, + "content": strings.TrimSpace(message.Content), + "payload": strings.TrimSpace(message.Payload), + "senderId": message.SenderID, + }) + if err != nil { + return err + } + + now := time.Now() + err = s.Create(&models.ChannelMessageOutbox{ + ChannelType: enums.ChannelTypeDiscord, + ConversationID: conversation.ID, + MessageID: message.ID, + Payload: string(payload), + SendStatus: string(enums.ChannelMessageOutboxStatusPending), + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: message.UpdateUserID, + CreateUserName: message.UpdateUserName, + UpdatedAt: now, + UpdateUserID: message.UpdateUserID, + UpdateUserName: message.UpdateUserName, + }, + }) + if err != nil { + return err + } + + go func() { + defer func() { + if r := recover(); r != nil { + slog.Error("recovered from panic in discord outbound dispatch", "error", r) + } + }() + DiscordOutboundService.DispatchPendingOutbox() + }() + + return nil +} + func (s *channelMessageOutboxService) ListPending(channelType string, limit int) []models.ChannelMessageOutbox { if limit <= 0 { limit = 20 } + // 只取"现在就可以尝试发送"的记录,保证每批取出的记录都会被真正尝试、投递循环必然收敛: + // pending:无重试计划限制(新入队或人工重试后 next_retry_at 为空); + // failed:必须存在重试计划且已到期。达到最大重试次数(next_retry_at 为空)的记录 + // 停止自动重试,等待管理端人工处置;退避窗口内的记录不占用批次。 + now := time.Now() cnd := sqls.NewCnd(). Eq("channel_type", strings.TrimSpace(channelType)). - In("send_status", []string{ - string(enums.ChannelMessageOutboxStatusPending), - string(enums.ChannelMessageOutboxStatusFailed), - }). + Where( + "((send_status = ? AND (next_retry_at IS NULL OR next_retry_at <= ?)) "+ + "OR (send_status = ? AND next_retry_at IS NOT NULL AND next_retry_at <= ?))", + string(enums.ChannelMessageOutboxStatusPending), now, + string(enums.ChannelMessageOutboxStatusFailed), now, + ). + // Only rows whose backoff has elapsed are eligible; ordering by + // next_retry_at keeps a backlog of not-yet-due retries from starving + // newer pending sends. + Lte("next_retry_at", now). + Asc("next_retry_at"). Asc("id"). Limit(limit) return s.Find(cnd) diff --git a/internal/services/channel_message_outbox_service_test.go b/internal/services/channel_message_outbox_service_test.go new file mode 100644 index 00000000..914f583d --- /dev/null +++ b/internal/services/channel_message_outbox_service_test.go @@ -0,0 +1,201 @@ +package services + +import ( + "strings" + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + "agent-desk/internal/wxwork" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +func setupChannelMessageOutboxTestDB(t *testing.T) *gorm.DB { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{ + TablePrefix: "t_", + SingularTable: true, + }, + }) + if err != nil { + t.Fatalf("open sqlite error = %v", err) + } + t.Cleanup(func() { + sqlDB, err := db.DB() + if err == nil { + _ = sqlDB.Close() + } + }) + if err := db.AutoMigrate(&models.ChannelMessageOutbox{}, &models.Message{}); err != nil { + t.Fatalf("auto migrate error = %v", err) + } + sqls.SetDB(db) + return db +} + +func createOutboxRecordForTest(t *testing.T, db *gorm.DB, item *models.ChannelMessageOutbox) { + t.Helper() + if err := repositories.ChannelMessageOutboxRepository.Create(db, item); err != nil { + t.Fatalf("create outbox error = %v", err) + } +} + +// TestChannelMessageOutboxListPendingEligibility 校验 ListPending 只取"现在可以尝试发送"的记录: +// failed 达到最大重试次数(next_retry_at 为空)后必须停止自动重试,退避窗口内的记录不能占用批次。 +func TestChannelMessageOutboxListPendingEligibility(t *testing.T) { + db := setupChannelMessageOutboxTestDB(t) + + now := time.Now() + past := now.Add(-time.Minute) + future := now.Add(time.Hour) + + cases := []struct { + name string + channel string + status enums.ChannelMessageOutboxStatus + retryCount int + nextAt *time.Time + expected bool + }{ + {"pending 无重试时间", enums.ChannelTypeWxWorkKF, enums.ChannelMessageOutboxStatusPending, 0, nil, true}, + {"failed 已到期", enums.ChannelTypeWxWorkKF, enums.ChannelMessageOutboxStatusFailed, 1, &past, true}, + {"failed 退避窗口内", enums.ChannelTypeWxWorkKF, enums.ChannelMessageOutboxStatusFailed, 1, &future, false}, + {"failed 达上限无重试时间", enums.ChannelTypeWxWorkKF, enums.ChannelMessageOutboxStatusFailed, 6, nil, false}, + {"pending 未来重试时间", enums.ChannelTypeWxWorkKF, enums.ChannelMessageOutboxStatusPending, 0, &future, false}, + {"sending 状态", enums.ChannelTypeWxWorkKF, enums.ChannelMessageOutboxStatusSending, 0, nil, false}, + {"sent 状态", enums.ChannelTypeWxWorkKF, enums.ChannelMessageOutboxStatusSent, 0, nil, false}, + {"其他渠道", enums.ChannelTypeTelegram, enums.ChannelMessageOutboxStatusPending, 0, nil, false}, + } + for i, tc := range cases { + createOutboxRecordForTest(t, db, &models.ChannelMessageOutbox{ + ChannelType: tc.channel, + MessageID: int64(i + 1), + Payload: "{}", + SendStatus: string(tc.status), + RetryCount: tc.retryCount, + NextRetryAt: tc.nextAt, + }) + } + + items := ChannelMessageOutboxService.ListPending(enums.ChannelTypeWxWorkKF, 20) + included := make(map[int64]bool, len(items)) + for _, item := range items { + included[item.MessageID] = true + } + var mismatches []string + for i, tc := range cases { + messageID := int64(i + 1) + if included[messageID] != tc.expected { + mismatches = append(mismatches, + "case "+tc.name+": 期望 included="+boolText(tc.expected)+", 实际 included="+boolText(included[messageID])) + } + } + if len(mismatches) > 0 { + t.Fatalf("ListPending 资格判定不符合预期:\n%s", strings.Join(mismatches, "\n")) + } +} + +func boolText(v bool) string { + if v { + return "true" + } + return "false" +} + +// TestTelegramDispatchSkipsBackoffBatch 回归守卫:整批记录都处于退避窗口时投递必须零进度终止, +// 而不是把 skip 当作成功导致外层 drain 循环空转。 +func TestTelegramDispatchSkipsBackoffBatch(t *testing.T) { + db := setupChannelMessageOutboxTestDB(t) + future := time.Now().Add(time.Hour) + for i := 1; i <= 3; i++ { + nextAt := future + createOutboxRecordForTest(t, db, &models.ChannelMessageOutbox{ + ChannelType: enums.ChannelTypeTelegram, + MessageID: int64(i), + Payload: "{}", + SendStatus: string(enums.ChannelMessageOutboxStatusFailed), + RetryCount: 1, + NextRetryAt: &nextAt, + }) + } + + if got := TelegramOutboundService.doDispatchPendingOutbox(20); got != 0 { + t.Fatalf("全退避批次应零进度终止, got %d", got) + } +} + +// TestWxWorkExhaustedOutboxNotAutoRetried 回归守卫:达到最大重试次数的记录不参与自动投递, +// 状态与重试次数保持不变,等待管理端人工处置。 +func TestWxWorkExhaustedOutboxNotAutoRetried(t *testing.T) { + db := setupChannelMessageOutboxTestDB(t) + enableWxWorkForTest(t) + createOutboxRecordForTest(t, db, &models.ChannelMessageOutbox{ + ChannelType: enums.ChannelTypeWxWorkKF, + MessageID: 1, + Payload: "{}", + SendStatus: string(enums.ChannelMessageOutboxStatusFailed), + RetryCount: wxWorkKFOutboxMaxRetry, + }) + + if got := WxWorkKFOutboundService.doDispatchPendingOutbox(20); got != 0 { + t.Fatalf("达上限记录不应被自动重试, got %d", got) + } + item := repositories.ChannelMessageOutboxRepository.Get(db, 1) + if item == nil { + t.Fatal("outbox record not found") + } + if item.SendStatus != string(enums.ChannelMessageOutboxStatusFailed) || item.RetryCount != wxWorkKFOutboxMaxRetry { + t.Fatalf("达上限记录应保持 failed 且重试次数不变, got status=%s retryCount=%d", item.SendStatus, item.RetryCount) + } +} + +// enableWxWorkForTest 在测试期间启用 wxwork(doDispatchPendingOutbox 有 Enabled 门),结束后复位。 +func enableWxWorkForTest(t *testing.T) { + t.Helper() + config.SetCurrent(&config.Config{ + WxWork: config.WxWorkConfig{ + Enabled: true, + CorpID: "corp-test", + CorpSecret: "secret-test", + }, + }) + wxwork.Init() + t.Cleanup(func() { + config.SetCurrent(&config.Config{}) + wxwork.Init() + }) +} + +// TestRetryWxWorkFailureRevivesExhaustedRecord 校验人工重试可以把达上限记录复活为 pending。 +func TestRetryWxWorkFailureRevivesExhaustedRecord(t *testing.T) { + db := setupChannelMessageOutboxTestDB(t) + createOutboxRecordForTest(t, db, &models.ChannelMessageOutbox{ + ChannelType: enums.ChannelTypeWxWorkKF, + MessageID: 1, + Payload: "{}", + SendStatus: string(enums.ChannelMessageOutboxStatusFailed), + RetryCount: wxWorkKFOutboxMaxRetry, + }) + + if err := ChannelMessageOutboxService.RetryWxWorkFailure(1, &dto.AuthPrincipal{}); err != nil { + t.Fatalf("retry error = %v", err) + } + + items := ChannelMessageOutboxService.ListPending(enums.ChannelTypeWxWorkKF, 20) + if len(items) != 1 { + t.Fatalf("人工重试后应重新可投递, got %d", len(items)) + } + if items[0].SendStatus != string(enums.ChannelMessageOutboxStatusPending) { + t.Fatalf("人工重试后状态应为 pending, got %s", items[0].SendStatus) + } +} diff --git a/internal/services/channel_service.go b/internal/services/channel_service.go index 6a5399fc..c0706062 100644 --- a/internal/services/channel_service.go +++ b/internal/services/channel_service.go @@ -2,6 +2,7 @@ package services import ( "agent-desk/internal/models" + "agent-desk/internal/pkg/config" "agent-desk/internal/pkg/dto" "agent-desk/internal/pkg/dto/request" "agent-desk/internal/pkg/dto/response" @@ -22,6 +23,7 @@ import ( "github.com/gin-gonic/gin" "github.com/mlogclub/simple/common/strs" "github.com/mlogclub/simple/sqls" + "github.com/silenceper/wechat/v2/work" "github.com/silenceper/wechat/v2/work/kf" ) @@ -196,9 +198,33 @@ func (s *channelService) ParseWxWorkKFChannelConfig(raw string) (*dto.WxWorkKFCh return nil, err } cfg.OpenKfID = strings.TrimSpace(cfg.OpenKfID) + cfg.AgentID = strings.TrimSpace(cfg.AgentID) return cfg, nil } +// GetWxWorkCliByChannel 返回渠道绑定 agentId 对应的企微客户端。 +// 不同渠道可绑定不同应用,客户端与 access_token 均按应用隔离。 +func (s *channelService) GetWxWorkCliByChannel(channel *models.Channel) (*work.Work, error) { + if channel == nil { + return nil, errorsx.InvalidParamI18n("error.wxwork.appNotConfigured", "") + } + cfg, err := s.ParseWxWorkKFChannelConfig(channel.ConfigJSON) + if err != nil { + return nil, err + } + return wxwork.GetWorkCliByAgentID(cfg.AgentID) +} + +// ListWxWorkApiApps 返回配置文件中可用的企业微信应用(agentId 列表),供渠道表单选择。 +func (s *channelService) ListWxWorkApiApps() []response.WxWorkApiAppResponse { + apps := config.Current().WxWork.NormalizedAPIApps() + ret := make([]response.WxWorkApiAppResponse, 0, len(apps)) + for _, app := range apps { + ret = append(ret, response.WxWorkApiAppResponse{AgentID: app.AgentID}) + } + return ret +} + func (s *channelService) ListWxWorkKFAccounts() ([]response.WxWorkKFAccountResponse, error) { if !wxwork.Enabled() || wxwork.GetWorkCli() == nil { return nil, errorsx.BusinessErrorI18n(1, "error.wxwork.configIncomplete") @@ -333,6 +359,25 @@ func (s *channelService) ParseZaloOAChannelConfig(raw string) (*dto.ZaloOAChanne return cfg, nil } +func (s *channelService) ParseDiscordChannelConfig(raw string) (*dto.DiscordChannelConfig, error) { + raw = strings.TrimSpace(raw) + cfg := &dto.DiscordChannelConfig{} + if raw != "" { + if err := json.Unmarshal([]byte(raw), cfg); err != nil { + return nil, err + } + } + cfg.GuildID = strings.TrimSpace(cfg.GuildID) + cfg.GuildName = strings.TrimSpace(cfg.GuildName) + cfg.ChannelScope = strings.TrimSpace(cfg.ChannelScope) + cfg.BotToken = strings.TrimSpace(cfg.BotToken) + cfg.ApplicationID = strings.TrimSpace(cfg.ApplicationID) + cfg.PublicKey = strings.TrimSpace(cfg.PublicKey) + cfg.WebhookSecret = strings.TrimSpace(cfg.WebhookSecret) + cfg.WelcomeMessage = strings.TrimSpace(cfg.WelcomeMessage) + return cfg, nil +} + func (s *channelService) GetUserTokenSecret(channel *models.Channel) string { if channel == nil { return "" @@ -449,7 +494,7 @@ func (s *channelService) GetEnabledChannel(ctx *gin.Context) *models.Channel { func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRequest) (*models.Channel, error) { channelType := strings.TrimSpace(req.ChannelType) - if channelType != enums.ChannelTypeWeb && channelType != enums.ChannelTypeWechatMP && channelType != enums.ChannelTypeWxWorkKF && channelType != enums.ChannelTypeTelegram && channelType != enums.ChannelTypeZaloOA { + if channelType != enums.ChannelTypeWeb && channelType != enums.ChannelTypeWechatMP && channelType != enums.ChannelTypeWxWorkKF && channelType != enums.ChannelTypeTelegram && channelType != enums.ChannelTypeZaloOA && channelType != enums.ChannelTypeDiscord { return nil, errorsx.InvalidParamI18n("error.e0250") } name := strings.TrimSpace(req.Name) @@ -465,6 +510,9 @@ func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRe if req.AIAgentRolloutPercent < 1 || req.AIAgentRolloutPercent > 100 { return nil, errorsx.InvalidParam("channel ai agent rollout percent must be between 1 and 100") } + if req.AIReplyTimeoutSeconds < 0 || req.AIReplyTimeoutSeconds > models.MaxAIReplyTimeoutSeconds { + return nil, errorsx.InvalidParamI18n("error.e0349") + } aiAgent := AIAgentService.Get(req.AIAgentID) if aiAgent == nil || aiAgent.Status != enums.StatusOk { return nil, errorsx.InvalidParamI18n("error.e0004") @@ -550,6 +598,12 @@ func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRe if cfg == nil || cfg.OpenKfID == "" { return nil, errorsx.InvalidParamI18n("error.e0103") } + if cfg.AgentID == "" { + return nil, errorsx.InvalidParamI18n("error.wxwork.agentIdRequired") + } + if _, err := wxwork.GetWorkCliByAgentID(cfg.AgentID); err != nil { + return nil, err + } if channel := s.GetEnabledWxWorkKFChannelByOpenKfID(cfg.OpenKfID); channel != nil && channel.ID != id { return nil, errorsx.InvalidParamI18n("error.e0069") } @@ -596,6 +650,32 @@ func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRe return nil, err } configJSON = string(configBytes) + case enums.ChannelTypeDiscord: + if channelID == "" { + channelID = strs.UUID() + } + if exists := s.Take("channel_id = ? AND status <> ? AND id <> ?", channelID, enums.StatusDeleted, id); exists != nil { + return nil, errorsx.InvalidParamI18n("error.e0248") + } + cfg, err := s.ParseDiscordChannelConfig(configJSON) + if err != nil { + return nil, errorsx.InvalidParam("invalid discord configuration") + } + // A channel may rely on the deployment-wide bot token instead of carrying + // its own, so the token is not required here the way Telegram's is. + if cfg.ChannelScope != "" && cfg.ChannelScope != "all" && cfg.ChannelScope != "dm_only" { + return nil, errorsx.InvalidParam("discord channelScope must be all or dm_only") + } + if cfg.WebhookSecret == "" { + if secret, err := generateUserTokenSecret(); err == nil { + cfg.WebhookSecret = secret + } + } + configBytes, err := json.Marshal(cfg) + if err != nil { + return nil, err + } + configJSON = string(configBytes) } return &models.Channel{ @@ -603,6 +683,9 @@ func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRe ChannelID: channelID, AIAgentID: req.AIAgentID, AIAgentRolloutPercent: req.AIAgentRolloutPercent, + AIReplyPlaceholder: strings.TrimSpace(req.AIReplyPlaceholder), + AIReplyTimeoutSeconds: req.AIReplyTimeoutSeconds, + AIReplyTimeoutNotice: strings.TrimSpace(req.AIReplyTimeoutNotice), Name: name, ConfigJSON: configJSON, Status: status, diff --git a/internal/services/conversation_service.go b/internal/services/conversation_service.go index b4db8067..d866620b 100644 --- a/internal/services/conversation_service.go +++ b/internal/services/conversation_service.go @@ -85,12 +85,13 @@ func (s *conversationService) Updates(id int64, columns map[string]interface{}) return repositories.ConversationRepository.Updates(sqls.DB(), id, columns) } -func (s *conversationService) getLatestNotFinishedByCustomerID(db *gorm.DB, customerID int64) *models.Conversation { +func (s *conversationService) getLatestNotFinishedByCustomerID(db *gorm.DB, customerID, channelID int64) *models.Conversation { if customerID <= 0 { return nil } cnd := sqls.NewCnd() cnd.Eq("customer_id", customerID) + cnd.Eq("channel_id", channelID) cnd.In("status", []enums.IMConversationStatus{ enums.IMConversationStatusAIServing, enums.IMConversationStatusPending, @@ -115,15 +116,27 @@ func (s *conversationService) Create(externalUser openidentity.ExternalUser, cha return err } customerName := s.getCustomerName(ctx.Tx, customerID) - if existing := s.getLatestNotFinishedByCustomerID(ctx.Tx, customerID); existing != nil { + if existing := s.getLatestNotFinishedByCustomerID(ctx.Tx, customerID, channelID); existing != nil { conversation = existing + updates := map[string]any{"updated_at": time.Now()} if customerName != "" && existing.CustomerName != customerName { - if err := repositories.ConversationRepository.Updates(ctx.Tx, existing.ID, map[string]any{ - "customer_name": customerName, - "updated_at": time.Now(), - }); err != nil { + updates["customer_name"] = customerName + } + // 渠道绑定的 Agent 可能变更,复用会话时同步 Agent 及服务模式 + if existing.AIAgentID != aiAgentID { + updates["ai_agent_id"] = aiAgentID + updates["service_mode"] = aiAgent.ServiceMode + updates["status"] = s.resolveInitialStatus(aiAgent.ServiceMode) + } + if len(updates) > 1 { + if err := repositories.ConversationRepository.Updates(ctx.Tx, existing.ID, updates); err != nil { return err } + conversation.AIAgentID = aiAgentID + conversation.ServiceMode = aiAgent.ServiceMode + conversation.Status = s.resolveInitialStatus(aiAgent.ServiceMode) + conversation.CustomerName = customerName + } else if customerName != "" { conversation.CustomerName = customerName } return nil diff --git a/internal/services/cronx/cron.go b/internal/services/cronx/cron.go index 52ab00e2..2b422531 100644 --- a/internal/services/cronx/cron.go +++ b/internal/services/cronx/cron.go @@ -4,12 +4,18 @@ import ( "agent-desk/internal/services" "fmt" "log/slog" + "time" "github.com/robfig/cron/v3" ) func Init() { - c := cron.New() + // Recover:任务 panic 不拖垮整个 cron; + // SkipIfStillRunning:上一轮未结束前跳过本轮触发,避免任务慢于间隔时 goroutine 无限堆积。 + c := cron.New(cron.WithChain( + cron.Recover(cron.DefaultLogger), + cron.SkipIfStillRunning(cron.DefaultLogger), + )) addFunc(c, "0 4 ? * *", func() { fmt.Println("cron test") @@ -34,6 +40,19 @@ func Init() { if zaloCount > 0 { slog.Info("zalo oa outbox dispatched", "count", zaloCount) } + discordCount := services.DiscordOutboundService.DispatchPendingOutbox() + if discordCount > 0 { + slog.Info("discord outbox dispatched", "count", discordCount) + } + }) + + // 每天凌晨 3 点清理一个月以前的系统日志。 + addFunc(c, "0 3 * * *", func() { + before := time.Now().AddDate(0, -1, 0) + deleted := services.SystemLogService.DeleteOlderThan(before) + if deleted > 0 { + slog.Info("system log cleanup completed", "deleted", deleted, "before", before.Format(time.DateTime)) + } }) c.Start() diff --git a/internal/services/discord_inbound_service.go b/internal/services/discord_inbound_service.go new file mode 100644 index 00000000..93f20eba --- /dev/null +++ b/internal/services/discord_inbound_service.go @@ -0,0 +1,176 @@ +package services + +import ( + "context" + "crypto/subtle" + "encoding/json" + "fmt" + "log/slog" + "strings" + + "agent-desk/internal/discord" + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/openidentity" +) + +var DiscordInboundService = newDiscordInboundService() + +func newDiscordInboundService() *discordInboundService { + return &discordInboundService{} +} + +type discordInboundService struct{} + +// HandleWebhook processes an incoming webhook or gateway payload from Discord. +func (s *discordInboundService) HandleWebhook(ctx context.Context, channelID string, secretHeader string, rawPayload []byte) error { + channelID = strings.TrimSpace(channelID) + var channel *models.Channel + if channelID != "" { + channel = ChannelService.Take("channel_id = ? AND channel_type = ? AND status = ?", channelID, enums.ChannelTypeDiscord, enums.StatusOk) + } + if channel == nil { + channel = ChannelService.Take("channel_type = ? AND status = ?", enums.ChannelTypeDiscord, enums.StatusOk) + } + if channel == nil { + return errorsx.InvalidParam("discord channel not found or disabled") + } + + cfg, err := ChannelService.ParseDiscordChannelConfig(channel.ConfigJSON) + if err != nil || cfg == nil { + return errorsx.InvalidParam("discord channel config invalid") + } + + // Compared in constant time: a byte-wise != leaks how much of the prefix + // matched through response timing. + if cfg.WebhookSecret != "" && + subtle.ConstantTimeCompare([]byte(strings.TrimSpace(secretHeader)), []byte(cfg.WebhookSecret)) != 1 { + return errorsx.UnauthorizedI18n("error.auth.invalidSignature") + } + + var payload discord.WebhookPayload + if err := json.Unmarshal(rawPayload, &payload); err != nil { + return fmt.Errorf("unmarshal discord payload failed: %w", err) + } + + author := payload.Author + text := strings.TrimSpace(payload.Content) + msgID := payload.ID + targetChannelID := payload.ChannelID + guildID := payload.GuildID + attachments := payload.Attachments + embeds := payload.Embeds + + if payload.Message != nil { + if author == nil { + author = &payload.Message.Author + } + if text == "" { + text = strings.TrimSpace(payload.Message.Content) + } + if msgID == "" { + msgID = payload.Message.ID + } + if targetChannelID == "" { + targetChannelID = payload.Message.ChannelID + } + if guildID == "" { + guildID = payload.Message.GuildID + } + if len(attachments) == 0 && len(payload.Message.Attachments) > 0 { + attachments = payload.Message.Attachments + } + if len(embeds) == 0 && len(payload.Message.Embeds) > 0 { + embeds = payload.Message.Embeds + } + } + + if author == nil || author.Bot || strings.TrimSpace(author.ID) == "" { + return nil // Ignore bot messages or invalid authors + } + + // Honour the channel's guild scope. A bot can be invited to several servers, + // and without these checks GuildID and ChannelScope would be stored + // configuration that silently does nothing. + if cfg.GuildID != "" && guildID != cfg.GuildID { + slog.Debug("ignoring discord message from an out-of-scope guild", + "guild_id", guildID, + "channel", channel.ID, + ) + return nil + } + if cfg.ChannelScope == "dm_only" && guildID != "" { + slog.Debug("ignoring discord guild message, channel is dm_only", + "guild_id", guildID, + "channel", channel.ID, + ) + return nil + } + + if text == "" && len(attachments) > 0 { + firstAtt := attachments[0] + if firstAtt.Filename != "" { + text = fmt.Sprintf("[%s] %s", firstAtt.Filename, firstAtt.URL) + } else { + text = firstAtt.URL + } + } + + if text == "" && len(attachments) == 0 && len(embeds) == 0 { + return nil // Ignore empty messages + } + if text == "" && len(embeds) > 0 { + text = embeds[0].Description + if text == "" { + text = embeds[0].Title + } + } + + // 1. Resolve customer identity + externalID := author.ID + name := strings.TrimSpace(author.GlobalName) + if name == "" { + name = strings.TrimSpace(author.Username) + } + if name == "" { + name = fmt.Sprintf("Discord User %s", author.ID) + } + + externalUser := openidentity.ExternalUser{ + ExternalSource: enums.ExternalSourceDiscord, + ExternalID: externalID, + ExternalName: name, + } + + // 2. Create or match Conversation + conversation, err := ConversationService.Create(externalUser, channel.ID, channel.AIAgentID) + if err != nil { + return fmt.Errorf("create discord conversation failed: %w", err) + } + + // 3. Send message through MessageService + clientMsgID := fmt.Sprintf("discord_%s_%s", targetChannelID, msgID) + payloadMap := map[string]any{ + "discord_message_id": msgID, + "discord_channel_id": targetChannelID, + "discord_guild_id": guildID, + "discord_user_id": author.ID, + "discord_attachments": attachments, + } + payloadBytes, _ := json.Marshal(payloadMap) + + _, err = MessageService.SendCustomerMessage( + conversation.ID, + clientMsgID, + enums.IMMessageTypeText, + text, + string(payloadBytes), + externalUser, + ) + if err != nil { + return fmt.Errorf("send customer message failed: %w", err) + } + + return nil +} diff --git a/internal/services/discord_inbound_service_test.go b/internal/services/discord_inbound_service_test.go new file mode 100644 index 00000000..b6c31b24 --- /dev/null +++ b/internal/services/discord_inbound_service_test.go @@ -0,0 +1,294 @@ +package services + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +func setupDiscordTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{ + TablePrefix: "t_", + SingularTable: true, + }, + }) + if err != nil { + t.Fatalf("open sqlite db: %v", err) + } + if err := db.AutoMigrate( + &models.Channel{}, + &models.ChannelMessageOutbox{}, + &models.Customer{}, + &models.CustomerIdentity{}, + &models.CustomerContact{}, + &models.Conversation{}, + &models.ConversationParticipant{}, + &models.ConversationReadState{}, + &models.ConversationInterrupt{}, + &models.ConversationEventLog{}, + &models.Message{}, + &models.AIAgent{}, + &models.User{}, + &models.Role{}, + &models.UserRole{}, + &models.Permission{}, + &models.RolePermission{}, + &models.UserPermission{}, + ); err != nil { + t.Fatalf("migrate discord test tables: %v", err) + } + sqls.SetDB(db) + return db +} + +func TestDiscordInboundAndOutbound(t *testing.T) { + db := setupDiscordTestDB(t) + + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"id":"out_msg_100","channel_id":"text_chan_1","content":"Agent reply"}`)) + })) + defer mockServer.Close() + + now := time.Now() + aiAgent := &models.AIAgent{ + Name: "Support AI", + Status: enums.StatusOk, + PublishedRevisionID: 1, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + if err := db.Create(aiAgent).Error; err != nil { + t.Fatalf("create ai agent: %v", err) + } + + discordConfig := dto.DiscordChannelConfig{ + GuildID: "guild_12345", + GuildName: "Test Guild", + BotToken: "discord_bot_token", + WebhookSecret: "test_secret", + } + cfgBytes, _ := json.Marshal(discordConfig) + + channel := &models.Channel{ + ChannelType: enums.ChannelTypeDiscord, + ChannelID: "discord_ch_1", + AIAgentID: aiAgent.ID, + AIAgentRolloutPercent: 100, + Name: "Community Support", + ConfigJSON: string(cfgBytes), + Status: enums.StatusOk, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + if err := db.Create(channel).Error; err != nil { + t.Fatalf("create discord channel: %v", err) + } + + payload := `{ + "id": "msg_999", + "channel_id": "text_chan_1", + "guild_id": "guild_12345", + "content": "", + "author": { + "id": "user_888", + "username": "gamer_joy", + "global_name": "Joy Le", + "bot": false + }, + "attachments": [ + { + "id": "att_1", + "filename": "screenshot.png", + "url": "https://cdn.discordapp.com/attachments/1/screenshot.png", + "content_type": "image/png", + "size": 10240 + } + ] + }` + + ctx := context.Background() + err := DiscordInboundService.HandleWebhook(ctx, channel.ChannelID, "test_secret", []byte(payload)) + if err != nil { + t.Fatalf("HandleWebhook failed: %v", err) + } + + // Verify customer identity + identity := repositories.CustomerIdentityRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("external_source", enums.ExternalSourceDiscord). + Eq("external_id", "user_888")) + if identity == nil { + t.Fatalf("expected customer identity to be created") + } + + // Verify conversation + conv := repositories.ConversationRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("customer_id", identity.CustomerID). + Eq("channel_id", channel.ID)) + if conv == nil { + t.Fatalf("expected conversation to be created") + } + + // Verify image message created from attachment + custMsg := repositories.MessageRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("conversation_id", conv.ID). + Eq("sender_type", enums.IMSenderTypeCustomer)) + if custMsg == nil { + t.Fatalf("expected customer message to be created") + } + + operator := &dto.AuthPrincipal{UserID: 1, Nickname: "Agent Joy"} + + // Test Outbound enqueue with Message + replyMsg, err := MessageService.SendAIMessage(conv.ID, aiAgent.ID, "ai_msg_1", enums.IMMessageTypeText, "Here is your response image: https://example.com/response_img.png", "", operator) + if err != nil { + t.Fatalf("MessageService.SendAIMessage failed: %v", err) + } + + outbox := ChannelMessageOutboxService.GetByMessageID(enums.ChannelTypeDiscord, replyMsg.ID) + if outbox == nil { + t.Fatalf("expected outbox entry for discord message") + } + if outbox.SendStatus != string(enums.ChannelMessageOutboxStatusPending) && outbox.SendStatus != string(enums.ChannelMessageOutboxStatusSending) && outbox.SendStatus != string(enums.ChannelMessageOutboxStatusFailed) { + t.Fatalf("unexpected outbox status: %s", outbox.SendStatus) + } +} + +func seedDiscordScopedChannel(t *testing.T, db *gorm.DB, channelID string, cfg dto.DiscordChannelConfig) *models.Channel { + t.Helper() + now := time.Now() + agent := &models.AIAgent{ + Name: "Support AI", + Status: enums.StatusOk, + PublishedRevisionID: 1, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + if err := db.Create(agent).Error; err != nil { + t.Fatalf("create ai agent: %v", err) + } + cfg.BotToken = "discord_bot_token" + cfg.WebhookSecret = "scope_secret" + cfgBytes, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal discord config: %v", err) + } + channel := &models.Channel{ + ChannelType: enums.ChannelTypeDiscord, + ChannelID: channelID, + AIAgentID: agent.ID, + AIAgentRolloutPercent: 100, + Name: "Discord " + channelID, + ConfigJSON: string(cfgBytes), + Status: enums.StatusOk, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + if err := db.Create(channel).Error; err != nil { + t.Fatalf("create discord channel: %v", err) + } + return channel +} + +func discordScopedPayload(t *testing.T, guildID, messageID string) []byte { + t.Helper() + body := map[string]any{ + "id": messageID, + "channel_id": "discord_text_chan", + "content": "hello from discord", + "author": map[string]any{"id": "user_scope", "username": "scoped_user", "bot": false}, + } + if guildID != "" { + body["guild_id"] = guildID + } + raw, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + return raw +} + +// A bot can be invited to several servers, and GuildID and ChannelScope are +// stored on the channel. Messages outside that scope must not create a +// conversation, otherwise the stored scope is configuration that does nothing. +func TestDiscordInboundHonoursGuildScope(t *testing.T) { + db := setupDiscordTestDB(t) + + guildScoped := seedDiscordScopedChannel(t, db, "discord_guild_scoped", dto.DiscordChannelConfig{ + GuildID: "guild_in_scope", + GuildName: "In Scope", + }) + dmOnly := seedDiscordScopedChannel(t, db, "discord_dm_only", dto.DiscordChannelConfig{ + ChannelScope: "dm_only", + }) + unscoped := seedDiscordScopedChannel(t, db, "discord_unscoped", dto.DiscordChannelConfig{}) + + cases := []struct { + name string + channel *models.Channel + guildID string + wantStored bool + }{ + {"matching guild is accepted", guildScoped, "guild_in_scope", true}, + {"another guild is ignored", guildScoped, "guild_elsewhere", false}, + {"a dm is ignored by a guild scoped channel", guildScoped, "", false}, + {"a dm is accepted by a dm_only channel", dmOnly, "", true}, + {"a guild message is ignored by a dm_only channel", dmOnly, "guild_anywhere", false}, + {"an unscoped channel accepts any guild", unscoped, "guild_anywhere", true}, + {"an unscoped channel accepts a dm", unscoped, "", true}, + } + + for _, tc := range cases { + messageID := "scope_" + strings.ReplaceAll(tc.name, " ", "_") + payload := discordScopedPayload(t, tc.guildID, messageID) + + if err := DiscordInboundService.HandleWebhook(context.Background(), tc.channel.ChannelID, "scope_secret", payload); err != nil { + t.Fatalf("%s: HandleWebhook failed: %v", tc.name, err) + } + + var count int64 + if err := db.Table("t_message").Where("client_msg_id LIKE ?", "%"+messageID).Count(&count).Error; err != nil { + t.Fatalf("%s: count messages: %v", tc.name, err) + } + switch { + case tc.wantStored && count == 0: + t.Errorf("%s: expected the message to be stored", tc.name) + case !tc.wantStored && count != 0: + t.Errorf("%s: expected the message to be dropped, found %d", tc.name, count) + } + } +} + +// The webhook secret is compared in constant time, so a wrong secret of the same +// length must be rejected rather than accepted by a prefix match. +func TestDiscordInboundRejectsWrongWebhookSecret(t *testing.T) { + db := setupDiscordTestDB(t) + channel := seedDiscordScopedChannel(t, db, "discord_secret", dto.DiscordChannelConfig{}) + + payload := discordScopedPayload(t, "", "secret_msg_1") + err := DiscordInboundService.HandleWebhook(context.Background(), channel.ChannelID, "wrong_secret_value", payload) + if err == nil { + t.Fatalf("expected a wrong webhook secret to be rejected") + } + + var count int64 + if err := db.Table("t_message").Where("client_msg_id LIKE ?", "%secret_msg_1").Count(&count).Error; err != nil { + t.Fatalf("count messages: %v", err) + } + if count != 0 { + t.Fatalf("a rejected delivery stored %d messages", count) + } +} diff --git a/internal/services/discord_integration_test.go b/internal/services/discord_integration_test.go new file mode 100644 index 00000000..e470bd27 --- /dev/null +++ b/internal/services/discord_integration_test.go @@ -0,0 +1,169 @@ +package services + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +func setupDiscordIntegrationTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{ + TablePrefix: "t_", + SingularTable: true, + }, + }) + if err != nil { + t.Fatalf("open sqlite db: %v", err) + } + if err := db.AutoMigrate( + &models.Channel{}, + &models.ChannelMessageOutbox{}, + &models.Customer{}, + &models.CustomerIdentity{}, + &models.CustomerContact{}, + &models.Conversation{}, + &models.ConversationParticipant{}, + &models.ConversationReadState{}, + &models.ConversationInterrupt{}, + &models.ConversationEventLog{}, + &models.Message{}, + &models.AIAgent{}, + &models.AgentProfile{}, + &models.AgentTeam{}, + &models.User{}, + &models.Role{}, + &models.UserRole{}, + &models.Permission{}, + &models.RolePermission{}, + &models.UserPermission{}, + ); err != nil { + t.Fatalf("migrate discord integration test tables: %v", err) + } + sqls.SetDB(db) + return db +} + +func TestDiscordIntegrationFullFlow(t *testing.T) { + db := setupDiscordIntegrationTestDB(t) + + mockDiscordServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"id":"discord_msg_reply_999","channel_id":"ch_discord_general","content":"Cảm ơn bạn! Đội ngũ hỗ trợ sẽ kiểm tra ngay."}`)) + })) + defer mockDiscordServer.Close() + + now := time.Now() + // 1. Create AI Agent + agent := &models.AIAgent{ + Name: "Discord Support AI", + ServiceMode: enums.IMConversationServiceModeAIFirst, + PublishedRevisionID: 1, + WelcomeMessage: "Chào mừng đến với máy chủ Discord Crove Desk!", + Status: enums.StatusOk, + AuditFields: models.AuditFields{ + CreatedAt: now, + UpdatedAt: now, + }, + } + _ = db.Create(agent) + + // 2. Create Discord Channel + discordConfig, _ := json.Marshal(dto.DiscordChannelConfig{ + GuildID: "guild_987654321", + GuildName: "Crove Community Discord", + BotToken: "test-discord-bot-token-xyz", + WebhookSecret: "discord-secret-token-123", + WelcomeMessage: "Welcome to Discord Support!", + }) + + operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"} + channel, err := ChannelService.CreateChannel(request.CreateChannelRequest{ + Name: "Crove Discord Support", + ChannelType: enums.ChannelTypeDiscord, + AIAgentID: agent.ID, + AIAgentRolloutPercent: 100, + ConfigJSON: string(discordConfig), + Status: int(enums.StatusOk), + }, operator) + if err != nil { + t.Fatalf("CreateChannel failed: %v", err) + } + + // 3. Simulate Inbound Discord Webhook / Gateway message from user + inboundPayload := []byte(`{ + "id": "msg_discord_user_001", + "channel_id": "ch_discord_general", + "guild_id": "guild_987654321", + "content": "Tôi muốn hỏi về cách cấu hình Custom Domain cho Email Channel trên Crove Desk", + "author": { + "id": "discord_uid_555", + "username": "gamer_joy", + "global_name": "Anh Le", + "bot": false + } + }`) + + ctx := context.Background() + err = DiscordInboundService.HandleWebhook(ctx, channel.ChannelID, "discord-secret-token-123", inboundPayload) + if err != nil { + t.Fatalf("DiscordInboundService.HandleWebhook failed: %v", err) + } + + // Verify Customer Identity + identity := repositories.CustomerIdentityRepository.FindOne(db, sqls.NewCnd(). + Eq("external_source", enums.ExternalSourceDiscord). + Eq("external_id", "discord_uid_555")) + if identity == nil { + t.Fatalf("expected customer identity for discord_uid_555") + } + + customer := repositories.CustomerRepository.Get(db, identity.CustomerID) + if customer == nil || customer.Name != "Anh Le" { + t.Fatalf("unexpected customer profile: %+v", customer) + } + + // Verify Conversation created + conv := repositories.ConversationRepository.FindOne(db, sqls.NewCnd().Eq("customer_id", customer.ID)) + if conv == nil || conv.ChannelID != channel.ID { + t.Fatalf("unexpected conversation: %+v", conv) + } + + // Verify Customer Message stored + msg := repositories.MessageRepository.FindOne(db, sqls.NewCnd(). + Eq("conversation_id", conv.ID). + Eq("sender_type", enums.IMSenderTypeCustomer)) + if msg == nil || msg.Content != "Tôi muốn hỏi về cách cấu hình Custom Domain cho Email Channel trên Crove Desk" { + t.Fatalf("unexpected stored customer message: %+v", msg) + } + + // 4. Simulate Agent / AI Reply and test Outbox Enqueue & Outbound Dispatch + replyMsg, err := MessageService.SendAIMessage(conv.ID, agent.ID, "ai_reply_001", enums.IMMessageTypeText, "Cảm ơn bạn! Đội ngũ hỗ trợ sẽ kiểm tra ngay.", "", operator) + if err != nil { + t.Fatalf("MessageService.SendAIMessage failed: %v", err) + } + + outbox := ChannelMessageOutboxService.GetByMessageID(enums.ChannelTypeDiscord, replyMsg.ID) + if outbox == nil { + t.Fatalf("expected discord outbox entry for AI message") + } + if outbox.ChannelType != enums.ChannelTypeDiscord { + t.Fatalf("expected outbox channel type 'discord', got '%s'", outbox.ChannelType) + } +} diff --git a/internal/services/discord_outbound_service.go b/internal/services/discord_outbound_service.go new file mode 100644 index 00000000..2024f8af --- /dev/null +++ b/internal/services/discord_outbound_service.go @@ -0,0 +1,231 @@ +package services + +import ( + "context" + "encoding/json" + "log/slog" + "strings" + "time" + + "agent-desk/internal/discord" + "agent-desk/internal/models" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + "agent-desk/internal/services/storage" + "os" + + "github.com/mlogclub/simple/sqls" +) + +const ( + discordOutboxBatchSize = 20 + discordOutboxMaxRetry = 5 +) + +var DiscordOutboundService = newDiscordOutboundService() + +func newDiscordOutboundService() *discordOutboundService { + return &discordOutboundService{} +} + +type discordOutboundService struct{} + +func (s *discordOutboundService) DispatchPendingOutbox() int { + return s.doDispatchPendingOutbox(discordOutboxBatchSize) +} + +func (s *discordOutboundService) doDispatchPendingOutbox(limit int) int { + if limit <= 0 { + limit = discordOutboxBatchSize + } + items := ChannelMessageOutboxService.ListPending(enums.ChannelTypeDiscord, limit) + if len(items) == 0 { + return 0 + } + + successCount := 0 + for i := range items { + if err := s.processOutbox(items[i].ID); err != nil { + slog.Warn("process discord outbox failed", + "outbox_id", items[i].ID, + "error", err, + ) + continue + } + successCount++ + } + return successCount +} + +func (s *discordOutboundService) processOutbox(outboxID int64) error { + outbox := ChannelMessageOutboxService.Get(outboxID) + if outbox == nil { + return nil + } + if outbox.ChannelType != enums.ChannelTypeDiscord { + return nil + } + if outbox.SendStatus == string(enums.ChannelMessageOutboxStatusSent) { + return nil + } + if outbox.NextRetryAt != nil && outbox.NextRetryAt.After(time.Now()) { + return nil + } + + if err := ChannelMessageOutboxService.Updates(outbox.ID, map[string]any{ + "send_status": string(enums.ChannelMessageOutboxStatusSending), + "updated_at": time.Now(), + }); err != nil { + return err + } + + message := MessageService.Get(outbox.MessageID) + if message == nil { + return s.markOutboxFailed(outbox, "message not found") + } + conversation := ConversationService.Get(outbox.ConversationID) + if conversation == nil { + return s.markOutboxFailed(outbox, "conversation not found") + } + + channel := ChannelService.Get(conversation.ChannelID) + if channel == nil || channel.Status != enums.StatusOk { + return s.markOutboxFailed(outbox, "discord channel not found or disabled") + } + cfg, err := ChannelService.ParseDiscordChannelConfig(channel.ConfigJSON) + if err != nil { + return s.markOutboxFailed(outbox, "invalid discord channel config") + } + botToken := "" + if cfg != nil { + botToken = strings.TrimSpace(cfg.BotToken) + } + if botToken == "" { + botToken = strings.TrimSpace(config.Current().Discord.BotToken) + } + if botToken == "" { + botToken = strings.TrimSpace(os.Getenv("DISCORD_BOT_TOKEN")) + } + if botToken == "" { + return s.markOutboxFailed(outbox, "discord bot token not configured") + } + + // Resolve target Discord User ID and/or Channel ID + var discordUserID string + customerIdentity := repositories.CustomerIdentityRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("customer_id", conversation.CustomerID). + Eq("external_source", enums.ExternalSourceDiscord)) + if customerIdentity != nil { + discordUserID = strings.TrimSpace(customerIdentity.ExternalID) + } + + // Check if there is a discord_channel_id in last message payload + var targetChannelID string + lastCustomerMsg := repositories.MessageRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("conversation_id", conversation.ID). + Eq("sender_type", enums.IMSenderTypeCustomer). + Desc("id")) + if lastCustomerMsg != nil && lastCustomerMsg.Payload != "" { + var payloadMap map[string]any + if err := json.Unmarshal([]byte(lastCustomerMsg.Payload), &payloadMap); err == nil { + if chID, ok := payloadMap["discord_channel_id"].(string); ok && chID != "" { + targetChannelID = chID + } + } + } + + client := discord.NewClient(botToken) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + if targetChannelID == "" { + if discordUserID == "" { + return s.markOutboxFailed(outbox, "unable to resolve discord target user or channel") + } + dmChannel, err := client.CreateDMChannel(ctx, discordUserID) + if err != nil { + return s.markOutboxFailed(outbox, "create discord dm channel failed: "+err.Error()) + } + targetChannelID = dmChannel.ID + } + + var sendErr error + if message.MessageType == enums.IMMessageTypeImage { + assetPayload, err := parseIMMessageAssetPayload(message.Payload) + var imageURL string + if err == nil && assetPayload != nil { + assetPayload = hydrateIMMessageAssetPayload(assetPayload) + if assetPayload.Provider != "" && assetPayload.StorageKey != "" { + if provider, err := storage.NewProvider(assetPayload.Provider); err == nil { + imageURL = provider.GetSignedURL(assetPayload.StorageKey) + } + } + } + if imageURL == "" && strings.HasPrefix(strings.TrimSpace(message.Content), "http") { + imageURL = strings.TrimSpace(message.Content) + } + + if imageURL != "" { + embed := discord.Embed{ + Title: "Image Attachment", + Image: &discord.EmbedMedia{URL: imageURL}, + } + _, sendErr = client.SendEmbedMessage(ctx, targetChannelID, message.Content, []discord.Embed{embed}) + } else { + _, sendErr = client.SendMessage(ctx, targetChannelID, message.Content) + } + } else if message.MessageType == enums.IMMessageTypeAttachment { + assetPayload, err := parseIMMessageAssetPayload(message.Payload) + var fileURL string + if err == nil && assetPayload != nil { + assetPayload = hydrateIMMessageAssetPayload(assetPayload) + if assetPayload.Provider != "" && assetPayload.StorageKey != "" { + if provider, err := storage.NewProvider(assetPayload.Provider); err == nil { + fileURL = provider.GetSignedURL(assetPayload.StorageKey) + } + } + } + textToSend := message.Content + if fileURL != "" { + if textToSend != "" { + textToSend += "\n" + fileURL + } else { + textToSend = fileURL + } + } + _, sendErr = client.SendMessage(ctx, targetChannelID, textToSend) + } else { + _, sendErr = client.SendMessage(ctx, targetChannelID, message.Content) + } + + if sendErr != nil { + return s.markOutboxFailed(outbox, sendErr.Error()) + } + + return ChannelMessageOutboxService.Updates(outbox.ID, map[string]any{ + "send_status": string(enums.ChannelMessageOutboxStatusSent), + "sent_at": time.Now(), + "updated_at": time.Now(), + }) +} + +func (s *discordOutboundService) markOutboxFailed(outbox *models.ChannelMessageOutbox, errMsg string) error { + if outbox == nil { + return nil + } + retryCount := outbox.RetryCount + 1 + status := string(enums.ChannelMessageOutboxStatusFailed) + if retryCount >= discordOutboxMaxRetry { + status = string(enums.ChannelMessageOutboxStatusIgnored) + } + nextRetryAt := time.Now().Add(time.Duration(retryCount*30) * time.Second) + + return ChannelMessageOutboxService.Updates(outbox.ID, map[string]any{ + "send_status": status, + "retry_count": retryCount, + "next_retry_at": &nextRetryAt, + "last_error": errMsg, + "updated_at": time.Now(), + }) +} diff --git a/internal/services/knowledge_document_service.go b/internal/services/knowledge_document_service.go index 0e700473..1db89870 100644 --- a/internal/services/knowledge_document_service.go +++ b/internal/services/knowledge_document_service.go @@ -237,6 +237,31 @@ func (s *knowledgeDocumentService) BatchMoveKnowledgeDocuments(req request.Batch return nil } +// BatchBuildKnowledgeDocuments 对所选文档逐个重建索引。 +// 单篇失败不中断整体(失败状态会由 IndexDocumentByID 落库为 failed), +// 与 BatchMoveKnowledgeDocuments 移动后重建索引的处理保持一致。 +func (s *knowledgeDocumentService) BatchBuildKnowledgeDocuments(req request.BatchBuildKnowledgeDocumentRequest, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + ids := uniquePositiveIDs(req.IDs) + if len(ids) == 0 { + return errorsx.InvalidParamI18n("error.e0331") + } + for _, id := range ids { + current := s.Get(id) + if current == nil || current.Status == enums.StatusDeleted { + return errorsx.InvalidParamI18n("error.e0218") + } + } + for _, id := range ids { + if err := rag.Index.IndexDocumentByID(context.Background(), id); err != nil { + slog.Error("failed to batch rebuild knowledge document index", "document_id", id, "error", err) + } + } + return nil +} + func (s *knowledgeDocumentService) BatchDeleteKnowledgeDocuments(req request.BatchDeleteKnowledgeDocumentRequest) error { ids := uniquePositiveIDs(req.IDs) if len(ids) == 0 { diff --git a/internal/services/message_service.go b/internal/services/message_service.go index 48464472..b892b182 100644 --- a/internal/services/message_service.go +++ b/internal/services/message_service.go @@ -559,6 +559,15 @@ func (s *messageService) sendValidatedMessage(conversation *models.Conversation, ) } + // Discord 渠道消息入队,异步发送 + if enqueueErr := ChannelMessageOutboxService.EnqueueDiscordMessage(conversation, message); enqueueErr != nil { + slog.Error("enqueue discord outbox failed", + "conversation_id", conversation.ID, + "message_id", message.ID, + "error", enqueueErr, + ) + } + // 客户发送消息,触发AI回复 if senderType == enums.IMSenderTypeCustomer { if TriggerAIReplyAsyncHook != nil { diff --git a/internal/services/role_service.go b/internal/services/role_service.go index cf1046b8..6003f447 100644 --- a/internal/services/role_service.go +++ b/internal/services/role_service.go @@ -2,6 +2,7 @@ package services import ( "agent-desk/internal/models" + "agent-desk/internal/pkg/constants" "agent-desk/internal/pkg/dto" "agent-desk/internal/pkg/dto/request" "agent-desk/internal/pkg/enums" @@ -172,6 +173,9 @@ func (s *roleService) AssignPermissions(roleID int64, permissionIDs []int64, ope if role == nil { return errorsx.InvalidParamI18n("error.e0305") } + if role.IsSystem && (operator == nil || !slices.Contains(operator.Roles, string(constants.RoleCodeSuperAdmin))) { + return errorsx.ForbiddenI18n("error.e0293") + } return s.replaceRolePermissions(roleID, permissionIDs, operator) } diff --git a/internal/services/storage/safety.go b/internal/services/storage/safety.go new file mode 100644 index 00000000..7b9cc342 --- /dev/null +++ b/internal/services/storage/safety.go @@ -0,0 +1,218 @@ +package storage + +import ( + "io" + "mime" + "net/http" + "path" + "strings" + "unicode/utf8" + + "agent-desk/internal/pkg/errorsx" +) + +const ( + // sniffLimit is the number of leading bytes net/http.DetectContentType inspects. + sniffLimit = 512 + // maxFilenameLength keeps a stored filename inside the column that holds it. + maxFilenameLength = 255 +) + +// previewableExtensions are the only stored file types a browser may render +// inline. Every other extension is forced to download. +// +// Assets are served from this application's own origin, so an inline response is +// same-origin content: a browser that navigates to it runs whatever the bytes +// ask for, in the same security context as the dashboard and the public support +// widget. Forcing a download for anything we have not vetted keeps that true +// only for media, which cannot touch the DOM. +var previewableExtensions = map[string]bool{ + ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, + ".bmp": true, ".ico": true, ".avif": true, ".tif": true, ".tiff": true, + ".heic": true, ".heif": true, + ".mp4": true, ".m4v": true, ".webm": true, ".mov": true, ".ogv": true, + ".mp3": true, ".m4a": true, ".wav": true, ".ogg": true, ".oga": true, + ".aac": true, ".flac": true, + ".pdf": true, +} + +// blockedExtensions are rejected at upload time. Each one names a document a +// browser executes or lays out instead of displaying, so accepting it would let +// an unauthenticated visitor plant a script that runs against this origin the +// moment a staff member opens the attachment link. +var blockedExtensions = map[string]bool{ + ".html": true, ".htm": true, ".shtml": true, ".xhtml": true, ".xht": true, + ".svg": true, ".svgz": true, + ".xml": true, ".xsl": true, ".xslt": true, ".xsd": true, ".wsdl": true, + ".js": true, ".mjs": true, ".cjs": true, + ".swf": true, + ".php": true, ".phtml": true, ".php3": true, ".php4": true, ".php5": true, + ".asp": true, ".aspx": true, ".jsp": true, ".jspx": true, ".cfm": true, + ".hta": true, ".htc": true, ".htaccess": true, +} + +// blockedMediaTypes are the payloads a browser renders as an active document. +// They are rejected regardless of the extension the client chose, so renaming a +// page to .png does not smuggle it past the extension check. +var blockedMediaTypes = map[string]bool{ + "text/html": true, + "application/xhtml+xml": true, + "image/svg+xml": true, + "text/xml": true, + "application/xml": true, + "application/xslt+xml": true, + "text/javascript": true, + "application/javascript": true, + "application/x-javascript": true, +} + +// blockedUploadI18nKey is returned to the uploader whenever the file-safety +// policy rejects a payload. It deliberately does not name the offending type. +const blockedUploadI18nKey = "error.e0348" + +// mediaTypeExtensions maps a MIME type to the extension it should be stored +// under. mime.ExtensionsByType cannot be used here: on Windows it consults the +// registry, so the same type resolves to a different extension than in +// production, and it happily hands back an extension that describes an active +// document. Types that must never become a servable document are absent, which +// leaves the caller with the inert .bin fallback. +var mediaTypeExtensions = map[string]string{ + // images + "image/jpeg": ".jpg", "image/jfif": ".jpg", "image/pjpeg": ".jpg", + "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp", + "image/bmp": ".bmp", "image/tiff": ".tiff", "image/avif": ".avif", + "image/heic": ".heic", "image/heif": ".heif", "image/x-icon": ".ico", + "image/vnd.microsoft.icon": ".ico", + // documents + "application/pdf": ".pdf", "text/plain": ".txt", "text/csv": ".csv", + "text/markdown": ".md", "application/json": ".json", "application/rtf": ".rtf", + "application/msword": ".doc", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx", + "application/vnd.ms-excel": ".xls", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx", + "application/vnd.ms-powerpoint": ".ppt", + "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx", + "application/vnd.oasis.opendocument.text": ".odt", + "application/vnd.oasis.opendocument.spreadsheet": ".ods", + "application/vnd.oasis.opendocument.presentation": ".odp", + // archives + "application/zip": ".zip", "application/x-zip-compressed": ".zip", + "application/x-rar-compressed": ".rar", "application/vnd.rar": ".rar", + "application/x-7z-compressed": ".7z", "application/x-tar": ".tar", + "application/gzip": ".gz", "application/x-bzip2": ".bz2", "application/x-xz": ".xz", + // audio + "audio/mpeg": ".mp3", "audio/mp4": ".m4a", "audio/x-m4a": ".m4a", + "audio/wav": ".wav", "audio/x-wav": ".wav", "audio/ogg": ".ogg", + "audio/aac": ".aac", "audio/flac": ".flac", "audio/webm": ".webm", + // video + "video/mp4": ".mp4", "video/webm": ".webm", "video/quicktime": ".mov", + "video/x-msvideo": ".avi", "video/x-matroska": ".mkv", "video/ogg": ".ogv", + "video/mpeg": ".mpeg", +} + +// safeExtensionForMediaType returns the extension a payload of this type should +// be stored under, or "" when the type is not one this origin is willing to +// serve as a document. +func safeExtensionForMediaType(mediaType string) string { + return mediaTypeExtensions[strings.ToLower(strings.TrimSpace(mediaType))] +} + +// IsPreviewableExtension reports whether a stored file may be rendered inline. +func IsPreviewableExtension(ext string) bool { + return previewableExtensions[normalizeExt(ext)] +} + +// IsBlockedExtension reports whether an extension names a browser-active document. +func IsBlockedExtension(ext string) bool { + return blockedExtensions[normalizeExt(ext)] +} + +// IsBlockedMediaType reports whether a MIME type describes a browser-active document. +func IsBlockedMediaType(mediaType string) bool { + parsed, _, err := mime.ParseMediaType(strings.TrimSpace(mediaType)) + if err != nil || parsed == "" { + return false + } + return blockedMediaTypes[strings.ToLower(parsed)] +} + +// SanitizeFilename reduces a client-supplied name to a bare basename. Uploads +// arrive from browsers, mobile SDKs and channel webhooks, all of which are free +// to send a full path, control characters or nothing at all; the result is stored +// on the asset and echoed back into message payloads and download links. +func SanitizeFilename(name string) string { + name = strings.TrimSpace(name) + if name == "" { + return "" + } + name = path.Base(strings.ReplaceAll(name, "\\", "/")) + name = strings.Map(func(r rune) rune { + if r < 0x20 || r == 0x7f { + return -1 + } + return r + }, name) + name = strings.TrimSpace(strings.TrimRight(name, ". ")) + if len(name) > maxFilenameLength { + ext := path.Ext(name) + if len(ext) > maxFilenameLength { + ext = "" + } + stem := strings.TrimSuffix(name, ext) + // Cut on a rune boundary: a partial multibyte character would not survive + // the round trip through a utf8mb4 column. + limit := maxFilenameLength - len(ext) + for limit > 0 && !utf8.RuneStart(stem[limit]) { + limit-- + } + name = stem[:limit] + ext + } + return name +} + +// SniffContentType reports what the leading bytes of a seekable payload actually +// are, then rewinds so the caller can still stream the whole file to storage. +// The Content-Type a client declares is a claim, not evidence. +func SniffContentType(src io.ReadSeeker) (string, error) { + head := make([]byte, sniffLimit) + read, err := io.ReadFull(src, head) + if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF { + return "", err + } + if _, err := src.Seek(0, io.SeekStart); err != nil { + return "", err + } + return http.DetectContentType(head[:read]), nil +} + +// ValidateUpload applies the file-safety policy to an upload and returns the MIME +// type that should be recorded on the asset. +// +// sniffed comes from SniffContentType and wins whenever it identified something +// specific; declared is what the client claimed and is only trusted for the +// formats net/http cannot recognise, such as HEIC and most Office documents. +func ValidateUpload(filename, declared, sniffed string) (string, error) { + if IsBlockedExtension(path.Ext(filename)) { + return "", errorsx.InvalidParamI18n(blockedUploadI18nKey) + } + if IsBlockedMediaType(sniffed) || IsBlockedMediaType(declared) { + return "", errorsx.InvalidParamI18n(blockedUploadI18nKey) + } + + mediaType, _, _ := mime.ParseMediaType(sniffed) + if mediaType != "" && mediaType != "application/octet-stream" && !strings.HasPrefix(mediaType, "text/") { + return sniffed, nil + } + if declared != "" { + return declared, nil + } + return sniffed, nil +} + +func normalizeExt(ext string) string { + ext = strings.ToLower(strings.TrimSpace(ext)) + if ext != "" && !strings.HasPrefix(ext, ".") { + ext = "." + ext + } + return ext +} diff --git a/internal/services/storage/safety_test.go b/internal/services/storage/safety_test.go new file mode 100644 index 00000000..78fafbe6 --- /dev/null +++ b/internal/services/storage/safety_test.go @@ -0,0 +1,268 @@ +package storage + +import ( + "bytes" + "io" + "net/http" + "strings" + "testing" + "unicode/utf8" +) + +var pngSignature = []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR") + +func TestValidateUploadBlocksBrowserActiveExtensions(t *testing.T) { + names := []string{ + "session-stealer.html", "page.HTM", "vector.svg", "sheet.xml", + "transform.xsl", "bundle.min.js", "app.mjs", "shell.php", + "report.asp", "index.jsp", "legacy.swf", "widget.htc", + } + for _, name := range names { + if _, err := ValidateUpload(name, "application/octet-stream", "application/octet-stream"); err == nil { + t.Errorf("ValidateUpload(%q) accepted a browser-active extension", name) + } + } +} + +func TestValidateUploadBlocksStoredXSSPayload(t *testing.T) { + payload := []byte(``) + sniffed, err := SniffContentType(bytes.NewReader(payload)) + if err != nil { + t.Fatalf("SniffContentType() error = %v", err) + } + if !IsBlockedMediaType(sniffed) { + t.Fatalf("expected the payload to sniff as a blocked media type, got %q", sniffed) + } + + if _, err := ValidateUpload("proof.html", "text/html", sniffed); err == nil { + t.Error("expected an honestly named HTML upload to be rejected") + } + // Renaming the payload is the actual attack: the extension looks like an + // image and the declared type agrees, so only the bytes can catch it. + if _, err := ValidateUpload("profile.png", "image/png", sniffed); err == nil { + t.Error("expected an HTML payload disguised as a PNG to be rejected") + } +} + +func TestValidateUploadBlocksSVGRegardlessOfDeclaredType(t *testing.T) { + payload := []byte(``) + sniffed, err := SniffContentType(bytes.NewReader(payload)) + if err != nil { + t.Fatalf("SniffContentType() error = %v", err) + } + if _, err := ValidateUpload("logo.svg", "image/svg+xml", sniffed); err == nil { + t.Error("expected an SVG upload to be rejected") + } + // An SVG that arrives without the .svg extension is still refused, because + // the declared type alone is enough to identify it as an active document. + if _, err := ValidateUpload("logo.png", "image/svg+xml", sniffed); err == nil { + t.Error("expected an SVG declared as an image to be rejected") + } +} + +func TestValidateUploadAcceptsOrdinarySupportFiles(t *testing.T) { + cases := []struct { + filename string + declared string + sniffed string + wantMime string + }{ + {"screenshot.png", "image/png", http.DetectContentType(pngSignature), "image/png"}, + // The client declared a generic type; the payload identified itself. + {"photo.jpg", "application/octet-stream", "image/jpeg", "image/jpeg"}, + // net/http cannot recognise HEIC, so the declared type has to stand in. + {"IMG_0001.heic", "image/heic", "application/octet-stream", "image/heic"}, + {"contract.pdf", "application/pdf", "application/pdf", "application/pdf"}, + {"export.csv", "text/csv", "text/plain; charset=utf-8", "text/csv"}, + {"notes.txt", "text/plain", "text/plain; charset=utf-8", "text/plain"}, + {"logs.zip", "application/zip", "application/zip", "application/zip"}, + {"build.apk", "application/vnd.android.package-archive", "application/octet-stream", "application/vnd.android.package-archive"}, + {"data.json", "application/json", "application/json", "application/json"}, + } + for _, tc := range cases { + got, err := ValidateUpload(tc.filename, tc.declared, tc.sniffed) + if err != nil { + t.Errorf("ValidateUpload(%q) error = %v", tc.filename, err) + continue + } + if got != tc.wantMime { + t.Errorf("ValidateUpload(%q) mime = %q want %q", tc.filename, got, tc.wantMime) + } + } +} + +func TestSanitizeFilename(t *testing.T) { + cases := []struct { + in string + want string + }{ + {"report.pdf", "report.pdf"}, + {"../../etc/passwd", "passwd"}, + {`C:\Users\victim\Desktop\evil.html`, "evil.html"}, + {"/absolute/path/notes.txt", "notes.txt"}, + {"tab\tand\nnewline.log", "tabandnewline.log"}, + {"....", ""}, + {"", ""}, + {" spaced name.pdf ", "spaced name.pdf"}, + } + for _, tc := range cases { + if got := SanitizeFilename(tc.in); got != tc.want { + t.Errorf("SanitizeFilename(%q) = %q want %q", tc.in, got, tc.want) + } + } + + long := strings.Repeat("a", 300) + ".pdf" + got := SanitizeFilename(long) + if len(got) > 255 { + t.Errorf("SanitizeFilename() len = %d want <= 255", len(got)) + } + if !strings.HasSuffix(got, ".pdf") { + t.Errorf("SanitizeFilename() = %q, expected the extension to survive truncation", got) + } + + // A multibyte stem must not be cut in the middle of a rune. + wide := strings.Repeat("附件", 100) + ".pdf" + got = SanitizeFilename(wide) + if len(got) > 255 { + t.Errorf("SanitizeFilename() len = %d want <= 255", len(got)) + } + if !utf8.ValidString(got) { + t.Errorf("SanitizeFilename() = %q is not valid UTF-8", got) + } + if !strings.HasSuffix(got, ".pdf") { + t.Errorf("SanitizeFilename() = %q, expected the extension to survive truncation", got) + } + + // An extension longer than the whole budget cannot be preserved. + got = SanitizeFilename("a." + strings.Repeat("b", 300)) + if len(got) > 255 { + t.Errorf("SanitizeFilename() len = %d want <= 255", len(got)) + } + if !utf8.ValidString(got) { + t.Errorf("SanitizeFilename() = %q is not valid UTF-8", got) + } + + // A name made entirely of dots leaves nothing worth keeping. + if got = SanitizeFilename(strings.Repeat(".", 300)); got != "" { + t.Errorf("SanitizeFilename() of a dot-only name = %q want empty", got) + } +} + +func TestSniffContentTypeRewindsTheReader(t *testing.T) { + src := bytes.NewReader(pngSignature) + got, err := SniffContentType(src) + if err != nil { + t.Fatalf("SniffContentType() error = %v", err) + } + if got != "image/png" { + t.Fatalf("SniffContentType() = %q want image/png", got) + } + + // The caller still streams the whole payload to storage afterwards, so the + // reader must be back at the start. + rest, err := io.ReadAll(src) + if err != nil { + t.Fatalf("ReadAll() error = %v", err) + } + if !bytes.Equal(rest, pngSignature) { + t.Fatalf("reader was not rewound: got %d bytes want %d", len(rest), len(pngSignature)) + } +} + +func TestSniffContentTypeHandlesShortAndEmptyPayloads(t *testing.T) { + short := bytes.NewReader([]byte("hi")) + got, err := SniffContentType(short) + if err != nil { + t.Fatalf("SniffContentType() error = %v", err) + } + if !strings.HasPrefix(got, "text/plain") { + t.Fatalf("SniffContentType() = %q want a text/plain result", got) + } + rest, err := io.ReadAll(short) + if err != nil { + t.Fatalf("ReadAll() error = %v", err) + } + if string(rest) != "hi" { + t.Fatalf("reader was not rewound: got %q", rest) + } + + empty := bytes.NewReader(nil) + if _, err := SniffContentType(empty); err != nil { + t.Fatalf("SniffContentType() on an empty payload error = %v", err) + } +} + +func TestGenerateStorageKeyNeverUsesBlockedExtension(t *testing.T) { + cases := []struct { + info UploadInfo + want string + }{ + {UploadInfo{Filename: "evil.html", MimeType: "text/html"}, ".bin"}, + {UploadInfo{Filename: "evil.svg", MimeType: "image/svg+xml"}, ".bin"}, + // The extension is gone but the MIME type still names an active + // document, so the derived extension must be refused too. + {UploadInfo{Filename: "noext", MimeType: "text/html"}, ".bin"}, + {UploadInfo{Filename: "notes.txt", MimeType: "text/plain"}, ".txt"}, + {UploadInfo{Filename: "photo.png", MimeType: "image/png"}, ".png"}, + {UploadInfo{Filename: "unknown", MimeType: "application/octet-stream"}, ".bin"}, + } + for _, tc := range cases { + _, key := GenerateStorageKey(tc.info) + if !strings.HasSuffix(key, tc.want) { + t.Errorf("GenerateStorageKey(%+v) = %q, expected it to end in %q", tc.info, key, tc.want) + } + if strings.Contains(key, "..") { + t.Errorf("GenerateStorageKey(%+v) = %q contains a traversal segment", tc.info, key) + } + } +} + +func TestGetExtByMimeTypeIsPlatformIndependent(t *testing.T) { + // mime.ExtensionsByType reads the Windows registry, where text/html resolved + // to ".ehtml" and slipped past the block list. The mapping has to come from + // our own table so a storage key is the same on every platform, and so a + // media type that describes an active document maps to nothing at all. + cases := map[string]string{ + "text/html": "", + "application/xhtml+xml": "", + "image/svg+xml": "", + "text/xml": "", + "application/xml": "", + "application/javascript": "", + "text/javascript": "", + "image/jpeg": ".jpg", + "image/jfif": ".jpg", + "image/pjpeg": ".jpg", + "image/png": ".png", + "application/pdf": ".pdf", + "text/csv": ".csv", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx", + } + for mediaType, want := range cases { + if got := getExtByMimeType(mediaType); got != want { + t.Errorf("getExtByMimeType(%q) = %q want %q", mediaType, got, want) + } + } + + for mediaType := range blockedMediaTypes { + if got := getExtByMimeType(mediaType); got != "" { + t.Errorf("getExtByMimeType(%q) = %q, a blocked media type must not yield an extension", mediaType, got) + } + } +} + +func TestIsPreviewableExtension(t *testing.T) { + inline := []string{".png", ".JPG", ".jpeg", ".gif", ".webp", ".avif", ".mp4", ".webm", ".mp3", ".pdf"} + for _, ext := range inline { + if !IsPreviewableExtension(ext) { + t.Errorf("IsPreviewableExtension(%q) = false want true", ext) + } + } + + download := []string{".html", ".svg", ".xml", ".js", ".zip", ".txt", ".csv", ".docx", ".apk", ""} + for _, ext := range download { + if IsPreviewableExtension(ext) { + t.Errorf("IsPreviewableExtension(%q) = true want false", ext) + } + } +} diff --git a/internal/services/storage/utils.go b/internal/services/storage/utils.go index 0aabde2e..16b76999 100644 --- a/internal/services/storage/utils.go +++ b/internal/services/storage/utils.go @@ -30,9 +30,16 @@ func GenerateStorageKey(info UploadInfo) (assetID string, storageKey string) { } func getExt(info UploadInfo) string { - ext := strings.ToLower(filepath.Ext(strings.TrimSpace(info.Filename))) - if ext == "" { - ext = getExtByMimeType(info.MimeType) + ext := normalizeExt(filepath.Ext(strings.TrimSpace(info.Filename))) + if ext == "" || IsBlockedExtension(ext) { + ext = normalizeExt(getExtByMimeType(info.MimeType)) + } + if ext == "" || IsBlockedExtension(ext) { + // The stored extension decides the Content-Type this origin serves. A + // browser-active extension must never reach the key, and no extension at + // all would leave the server to sniff the payload and announce whatever + // it finds, so both cases settle on an inert binary type. + return ".bin" } return ext } @@ -47,21 +54,7 @@ func getExtByMimeType(mimeType string) string { return "" } - // 处理一些非标准的 MIME 类型 - switch mediaType { - case "image/jfif": - return ".jpg" - case "image/pjpeg": - return ".jpg" - case "image/jpeg": - return ".jpg" - default: - exts, _ := mime.ExtensionsByType(mediaType) - if len(exts) > 0 { - return exts[0] - } - } - return "" + return safeExtensionForMediaType(mediaType) } func normalizeAssetPrefix(prefix string) string { diff --git a/internal/services/system_log_service.go b/internal/services/system_log_service.go new file mode 100644 index 00000000..aec8c0a6 --- /dev/null +++ b/internal/services/system_log_service.go @@ -0,0 +1,32 @@ +package services + +import ( + "time" + + "agent-desk/internal/models" + "agent-desk/internal/repositories" + + "github.com/mlogclub/simple/sqls" +) + +var SystemLogService = newSystemLogService() + +func newSystemLogService() *systemLogService { + return &systemLogService{} +} + +type systemLogService struct { +} + +func (s *systemLogService) Get(id int64) *models.SystemLog { + return repositories.SystemLogRepository.Get(sqls.DB(), id) +} + +func (s *systemLogService) FindPageByCnd(cnd *sqls.Cnd) (list []models.SystemLog, paging *sqls.Paging) { + return repositories.SystemLogRepository.FindPageByCnd(sqls.DB(), cnd) +} + +// DeleteOlderThan 清理指定时间之前的日志,返回受影响行数。用于 cron 定时清理。 +func (s *systemLogService) DeleteOlderThan(before time.Time) int64 { + return repositories.SystemLogRepository.DeleteOlderThan(sqls.DB(), before) +} diff --git a/internal/services/user_service.go b/internal/services/user_service.go index c0c2f25d..67610673 100644 --- a/internal/services/user_service.go +++ b/internal/services/user_service.go @@ -2,6 +2,7 @@ package services import ( "agent-desk/internal/models" + "agent-desk/internal/pkg/constants" "agent-desk/internal/pkg/dto" "agent-desk/internal/pkg/dto/request" "agent-desk/internal/pkg/enums" @@ -262,6 +263,9 @@ func (s *userService) replaceUserRolesDB(db *gorm.DB, userID int64, roleIDs []in if role.Status != enums.StatusOk { return errorsx.InvalidParamI18n("error.e0291") } + if role.IsSystem && (operator == nil || !slices.Contains(operator.Roles, string(constants.RoleCodeSuperAdmin))) { + return errorsx.ForbiddenI18n("error.e0293") + } relation := &models.UserRole{ UserID: userID, RoleID: roleID, @@ -279,6 +283,18 @@ func (s *userService) changePassword(userID int64, password string, operator *dt if user == nil || user.DeletedAt != nil { return errorsx.InvalidParamI18n("error.e0255") } + if operator != nil && operator.UserID != userID && !slices.Contains(operator.Roles, string(constants.RoleCodeSuperAdmin)) { + var superAdminCount int64 + if err := sqls.DB().Model(&models.UserRole{}). + Joins("JOIN t_role ON t_role.id = t_user_role.role_id"). + Where("t_user_role.user_id = ? AND t_role.code = ?", userID, string(constants.RoleCodeSuperAdmin)). + Count(&superAdminCount).Error; err != nil { + return err + } + if superAdminCount > 0 { + return errorsx.ForbiddenI18n("error.e0293") + } + } if strings.TrimSpace(password) == "" { return errorsx.InvalidParamI18n("error.e0220") } diff --git a/internal/services/ws_realtime_types.go b/internal/services/ws_realtime_types.go index 14813840..7d2cd371 100644 --- a/internal/services/ws_realtime_types.go +++ b/internal/services/ws_realtime_types.go @@ -164,6 +164,19 @@ func (e RealtimeMessageCreatedEvent) EventPayload() RealtimeEventPayload { return e.Payload } +// RealtimeMessageUpdatedEvent 通知客户端某条消息内容已被更新(如 AI 占位回复被正式回复替换)。 +type RealtimeMessageUpdatedEvent struct { + Payload RealtimeMessageCreatedPayload +} + +func (e RealtimeMessageUpdatedEvent) EventType() string { + return enums.IMRealtimeEventMessageUpdated +} + +func (e RealtimeMessageUpdatedEvent) EventPayload() RealtimeEventPayload { + return e.Payload +} + type RealtimeMessageRecalledPayload struct { ConversationID int64 `json:"conversationId,omitempty"` MessageID int64 `json:"messageId,omitempty"` diff --git a/internal/services/ws_service.go b/internal/services/ws_service.go index 3bcd28e8..500d684b 100644 --- a/internal/services/ws_service.go +++ b/internal/services/ws_service.go @@ -2,6 +2,7 @@ package services import ( "agent-desk/internal/models" + "agent-desk/internal/pkg/constants" "agent-desk/internal/pkg/dto" "agent-desk/internal/pkg/dto/response" "agent-desk/internal/pkg/enums" @@ -13,6 +14,7 @@ import ( "fmt" "log/slog" "net/http" + "slices" "strconv" "strings" "sync/atomic" @@ -318,6 +320,32 @@ func (s *wsService) PublishMessageCreated(conversation *models.Conversation, mes s.PublishToTopics(s.routeConversationTopics(conversation), event) } +func (s *wsService) PublishMessageUpdated(conversation *models.Conversation, message *models.Message) { + if conversation == nil || message == nil { + return + } + content, payload := utils.BuildRenderableMessage(message) + + event := s.newEvent(s.conversationTopic(conversation.ID), RealtimeMessageUpdatedEvent{ + Payload: RealtimeMessageCreatedPayload{ + ConversationID: conversation.ID, + MessageID: message.ID, + RequestID: message.RequestID, + Message: s.buildRealtimeMessage(message), + Status: conversation.Status, + CurrentAssigneeID: conversation.CurrentAssigneeID, + SenderType: message.SenderType, + SenderID: message.SenderID, + MessageType: message.MessageType, + Content: content, + Payload: payload, + SendStatus: message.SendStatus, + SentAt: formatWsTime(message.SentAt), + }, + }) + s.PublishToTopics(s.routeConversationTopics(conversation), event) +} + func (s *wsService) buildRealtimeMessage(item *models.Message) response.MessageResponse { if item == nil { return response.MessageResponse{} @@ -626,7 +654,13 @@ func (s *wsService) canSubscribeConversation(session *ClientSession, conversatio return false } if session.Role == realtimeRoleAdmin { - return true + // Staff sessions must hold the same conversation-view permission the + // REST endpoints require; a bare admin-role websocket must not become + // a side channel around RequirePermission. + if session.Principal != nil && slices.Contains(session.Principal.Permissions, constants.PermissionConversationView.Code) { + return true + } + return false } conversation := ConversationService.Get(conversationID) if conversation == nil { diff --git a/internal/services/wxwork_kf_inbound_service.go b/internal/services/wxwork_kf_inbound_service.go index 053c2580..3978f3a2 100644 --- a/internal/services/wxwork_kf_inbound_service.go +++ b/internal/services/wxwork_kf_inbound_service.go @@ -10,7 +10,6 @@ import ( "agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/errorsx" "agent-desk/internal/pkg/openidentity" - "agent-desk/internal/wxwork" "github.com/mlogclub/simple/common/strs" "github.com/silenceper/wechat/v2/work/kf" @@ -31,8 +30,21 @@ func newWxWorkKFInboundService() *wxWorkKFInboundService { type wxWorkKFInboundService struct { } +// kfClientByOpenKfID 按 openKfID 找到启用渠道,并返回该渠道绑定应用的客服客户端。 +func (s *wxWorkKFInboundService) kfClientByOpenKfID(openKfID string) (*kf.Client, error) { + channel := ChannelService.GetEnabledWxWorkKFChannelByOpenKfID(openKfID) + if channel == nil { + return nil, errorsx.InvalidParamI18n("error.e0231") + } + workCli, err := ChannelService.GetWxWorkCliByChannel(channel) + if err != nil { + return nil, err + } + return workCli.GetKF() +} + func (s *wxWorkKFInboundService) SyncCallbackMessages(message kf.CallbackMessage) error { - cli, err := wxwork.GetWorkCli().GetKF() + cli, err := s.kfClientByOpenKfID(message.OpenKfID) if err != nil { return err } @@ -139,7 +151,7 @@ func (s *wxWorkKFInboundService) handleImageMessage(item syncmsg.Message) error if err != nil { return err } - canonicalPayload, content, err := s.buildInboundAssetPayload(conversation.ID, strings.TrimSpace(payload.Image.MediaID)) + canonicalPayload, content, err := s.buildInboundAssetPayload(conversation, strings.TrimSpace(payload.Image.MediaID)) if err != nil { return err } @@ -169,7 +181,7 @@ func (s *wxWorkKFInboundService) handleFileMessage(item syncmsg.Message) error { if err != nil { return err } - canonicalPayload, content, err := s.buildInboundAssetPayload(conversation.ID, strings.TrimSpace(payload.File.MediaID)) + canonicalPayload, content, err := s.buildInboundAssetPayload(conversation, strings.TrimSpace(payload.File.MediaID)) if err != nil { return err } @@ -514,12 +526,17 @@ func (s *wxWorkKFInboundService) appendConversationEvent(conversationID int64, c }) } -func (s *wxWorkKFInboundService) buildInboundAssetPayload(conversationID int64, mediaID string) (string, string, error) { +func (s *wxWorkKFInboundService) buildInboundAssetPayload(conversation *models.Conversation, mediaID string) (string, string, error) { mediaID = strings.TrimSpace(mediaID) if mediaID == "" { return "", "", errorsx.InvalidParamI18n("error.e0095") } - materialCli := wxwork.GetWorkCli().GetMaterial() + channel := ChannelService.Get(conversation.ChannelID) + workCli, err := ChannelService.GetWxWorkCliByChannel(channel) + if err != nil { + return "", "", err + } + materialCli := workCli.GetMaterial() data, err := materialCli.GetTempFile(mediaID) if err != nil { return "", "", err diff --git a/internal/services/wxwork_kf_message_read_test_service.go b/internal/services/wxwork_kf_message_read_test_service.go new file mode 100644 index 00000000..d2c9cd4a --- /dev/null +++ b/internal/services/wxwork_kf_message_read_test_service.go @@ -0,0 +1,337 @@ +package services + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/utils" +) + +// 企业微信客服“测试读取消息”相关常量。 +// 说明:silenceper/wechat SDK 的 SyncMsg 在 errcode!=0 时只返回 errmsg 文本、丢失 errcode, +// 无法按错误码给出排查建议,因此该测试直接调用官方 API 并自行解析 errcode。 +const ( + wxWorkGetTokenURLTemplate = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=%s&corpsecret=%s" + wxWorkSyncMsgURLTemplate = "https://qyapi.weixin.qq.com/cgi-bin/kf/sync_msg?access_token=%s" + + wxWorkReadTestStageGetToken = "gettoken" + wxWorkReadTestStageSyncMsg = "syncmsg" + + // 本地错误码(非企业微信返回),前端按这些码做本地化排查建议。 + wxWorkReadTestErrInvalidChannel = "INVALID_CHANNEL" + wxWorkReadTestErrOpenKFMissing = "OPENKFID_MISSING" + wxWorkReadTestErrAgentIDMissing = "AGENTID_MISSING" + wxWorkReadTestErrAppNotConfigured = "AGENTID_NOT_CONFIGURED" + wxWorkReadTestErrConfigJSON = "INVALID_CONFIG_JSON" + wxWorkReadTestErrNetwork = "NETWORK_ERROR" + wxWorkReadTestErrBadResponse = "BAD_RESPONSE" + + wxWorkReadTestSyncLimit uint64 = 1000 // 单次 sync_msg 拉取条数(官方最大值) + wxWorkReadTestMaxPages = 5 // 最多翻页次数,限制测试请求耗时 + wxWorkReadTestSampleSize = 20 // 返回最近样例条数 + wxWorkReadTestMaxPreviewRunes = 500 // 文本样例最大字符数 + wxWorkReadTestHTTPTimeout = 10 * time.Second +) + +var wxWorkReadTestHTTPClient = &http.Client{Timeout: wxWorkReadTestHTTPTimeout} + +type wxWorkReadTestTokenResponse struct { + ErrCode int64 `json:"errcode"` + ErrMsg string `json:"errmsg"` + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` +} + +type wxWorkReadTestSyncRequest struct { + Cursor string `json:"cursor"` + Limit uint64 `json:"limit"` + OpenKfID string `json:"open_kfid"` +} + +type wxWorkReadTestSyncResponse struct { + ErrCode int64 `json:"errcode"` + ErrMsg string `json:"errmsg"` + NextCursor string `json:"next_cursor"` + HasMore uint32 `json:"has_more"` + MsgList []map[string]any `json:"msg_list"` +} + +// TestWxWorkKFReadMessages 对指定企微客服渠道执行只读消息拉取测试: +// 使用空游标从企业微信拉取该客服账号最近保留期(3 天)内的消息与事件, +// 不消费消息、不创建会话、不更新同步游标。返回结构化结果供前端展示与错误码排查。 +func (s *channelService) TestWxWorkKFReadMessages(channelID int64) (*response.WxWorkKFMessageReadTestResult, error) { + channel := s.Get(channelID) + if channel == nil || channel.Status == enums.StatusDeleted || channel.ChannelType != enums.ChannelTypeWxWorkKF { + return s.failReadTestResult("", wxWorkReadTestErrInvalidChannel, ""), nil + } + + kfCfg, cfgErr := s.ParseWxWorkKFChannelConfig(channel.ConfigJSON) + if cfgErr != nil { + return s.failReadTestResult("", wxWorkReadTestErrConfigJSON, cfgErr.Error()), nil + } + openKfID := strings.TrimSpace(kfCfg.OpenKfID) + if openKfID == "" { + return s.failReadTestResult("", wxWorkReadTestErrOpenKFMissing, ""), nil + } + agentID := strings.TrimSpace(kfCfg.AgentID) + if agentID == "" { + return s.failReadTestResult("", wxWorkReadTestErrAgentIDMissing, ""), nil + } + + wxConfig := config.Current().WxWork + corpID := strings.TrimSpace(wxConfig.CorpID) + app, appFound := wxConfig.FindAPIApp(agentID) + corpSecret := strings.TrimSpace(app.CorpSecret) + if !wxConfig.Enabled || corpID == "" || !appFound || corpSecret == "" { + return s.failReadTestResult("", wxWorkReadTestErrAppNotConfigured, agentID), nil + } + + // 1. 获取 access_token(独立调用,不接触 SDK 令牌缓存;人工测试频率低,可接受) + accessToken, result := s.fetchReadTestAccessToken(corpID, corpSecret) + if result != nil { + return result, nil + } + + // 2. 从空游标开始分页拉取(空游标返回保留期内最早的消息,按时间升序) + scanned := make([]map[string]any, 0) + cursor := "" + truncated := false + for page := 0; page < wxWorkReadTestMaxPages; page++ { + resp, err := s.callReadTestSyncMsg(accessToken, openKfID, cursor) + if err != nil { + slog.Warn("wxwork kf read test sync_msg failed", + "channel_id", channelID, + "open_kfid", openKfID, + "error", err, + ) + return s.failReadTestResult(wxWorkReadTestStageSyncMsg, wxWorkReadTestErrNetwork, err.Error()), nil + } + if resp.ErrCode != 0 { + slog.Warn("wxwork kf read test sync_msg rejected", + "channel_id", channelID, + "open_kfid", openKfID, + "errcode", resp.ErrCode, + "errmsg", resp.ErrMsg, + ) + return s.failReadTestResult(wxWorkReadTestStageSyncMsg, strconv.FormatInt(resp.ErrCode, 10), resp.ErrMsg), nil + } + + scanned = append(scanned, resp.MsgList...) + if resp.HasMore != 1 || strings.TrimSpace(resp.NextCursor) == "" { + truncated = false + break + } + cursor = resp.NextCursor + if page == wxWorkReadTestMaxPages-1 { + truncated = true + } + } + + result2 := s.buildReadTestResult(openKfID, scanned, truncated) + slog.Info("wxwork kf read test succeeded", + "channel_id", channelID, + "open_kfid", openKfID, + "total_scanned", result2.TotalScanned, + "message_count", result2.MessageCount, + "event_count", result2.EventCount, + "truncated", result2.Truncated, + ) + return result2, nil +} + +func (s *channelService) fetchReadTestAccessToken(corpID, corpSecret string) (string, *response.WxWorkKFMessageReadTestResult) { + requestURL := fmt.Sprintf(wxWorkGetTokenURLTemplate, url.QueryEscape(corpID), url.QueryEscape(corpSecret)) + ctx, cancel := context.WithTimeout(context.Background(), wxWorkReadTestHTTPTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) + if err != nil { + return "", s.failReadTestResult(wxWorkReadTestStageGetToken, wxWorkReadTestErrNetwork, err.Error()) + } + resp, err := wxWorkReadTestHTTPClient.Do(req) + if err != nil { + return "", s.failReadTestResult(wxWorkReadTestStageGetToken, wxWorkReadTestErrNetwork, err.Error()) + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return "", s.failReadTestResult(wxWorkReadTestStageGetToken, wxWorkReadTestErrNetwork, err.Error()) + } + tokenResp := wxWorkReadTestTokenResponse{} + if err := json.Unmarshal(body, &tokenResp); err != nil { + return "", s.failReadTestResult(wxWorkReadTestStageGetToken, wxWorkReadTestErrBadResponse, truncateReadTestText(string(body), 300)) + } + if tokenResp.ErrCode != 0 || strings.TrimSpace(tokenResp.AccessToken) == "" { + code := strconv.FormatInt(tokenResp.ErrCode, 10) + if tokenResp.ErrCode == 0 { + code = wxWorkReadTestErrBadResponse + } + return "", s.failReadTestResult(wxWorkReadTestStageGetToken, code, tokenResp.ErrMsg) + } + return tokenResp.AccessToken, nil +} + +func (s *channelService) callReadTestSyncMsg(accessToken, openKfID, cursor string) (*wxWorkReadTestSyncResponse, error) { + payload, err := json.Marshal(wxWorkReadTestSyncRequest{ + Cursor: cursor, + Limit: wxWorkReadTestSyncLimit, + OpenKfID: openKfID, + }) + if err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(context.Background(), wxWorkReadTestHTTPTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + fmt.Sprintf(wxWorkSyncMsgURLTemplate, url.QueryEscape(accessToken)), + bytes.NewReader(payload), + ) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json; charset=utf-8") + resp, err := wxWorkReadTestHTTPClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + if err != nil { + return nil, err + } + syncResp := wxWorkReadTestSyncResponse{} + if err := json.Unmarshal(body, &syncResp); err != nil { + return nil, fmt.Errorf("invalid response: %s", truncateReadTestText(string(body), 300)) + } + return &syncResp, nil +} + +// buildReadTestResult 汇总扫描结果并抽取最近若干条样例(新消息在前)。 +func (s *channelService) buildReadTestResult(openKfID string, scanned []map[string]any, truncated bool) *response.WxWorkKFMessageReadTestResult { + result := &response.WxWorkKFMessageReadTestResult{ + Success: true, + OpenKfID: openKfID, + TotalScanned: len(scanned), + Samples: make([]response.WxWorkKFMessageReadSample, 0), + Truncated: truncated, + } + + var earliestUnix, latestUnix int64 + for _, raw := range scanned { + msgType := readTestAsString(raw["msgtype"]) + if msgType == "event" { + result.EventCount++ + } else { + result.MessageCount++ + } + sendTime := readTestAsInt64(raw["send_time"]) + if sendTime > 0 { + if earliestUnix == 0 || sendTime < earliestUnix { + earliestUnix = sendTime + } + if sendTime > latestUnix { + latestUnix = sendTime + } + } + } + if earliestUnix > 0 { + result.EarliestTime = utils.FormatTime(time.Unix(earliestUnix, 0)) + } + if latestUnix > 0 { + result.LatestTime = utils.FormatTime(time.Unix(latestUnix, 0)) + } + + // 接口按时间升序返回,取最后 N 条后反转为新消息在前 + start := len(scanned) - wxWorkReadTestSampleSize + if start < 0 { + start = 0 + } + for i := len(scanned) - 1; i >= start; i-- { + result.Samples = append(result.Samples, buildWxWorkReadSample(scanned[i])) + } + return result +} + +func buildWxWorkReadSample(raw map[string]any) response.WxWorkKFMessageReadSample { + sample := response.WxWorkKFMessageReadSample{ + MsgID: readTestAsString(raw["msgid"]), + Origin: int(readTestAsInt64(raw["origin"])), + MsgType: readTestAsString(raw["msgtype"]), + ExternalUserID: readTestAsString(raw["external_userid"]), + ServicerUserID: readTestAsString(raw["servicer_userid"]), + } + if sendTime := readTestAsInt64(raw["send_time"]); sendTime > 0 { + sample.SendTime = utils.FormatTime(time.Unix(sendTime, 0)) + } + if sample.MsgType == "text" { + if textMap, ok := raw["text"].(map[string]any); ok { + sample.TextContent = truncateReadTestText(strings.TrimSpace(readTestAsString(textMap["content"])), wxWorkReadTestMaxPreviewRunes) + } + } + if sample.MsgType == "event" { + if eventMap, ok := raw["event"].(map[string]any); ok { + sample.EventType = readTestAsString(eventMap["event_type"]) + // 事件消息的客户/客服账号在 event 对象内 + if sample.ExternalUserID == "" { + sample.ExternalUserID = readTestAsString(eventMap["external_userid"]) + } + if sample.ServicerUserID == "" { + sample.ServicerUserID = readTestAsString(eventMap["new_servicer_userid"]) + } + } + } + return sample +} + +func (s *channelService) failReadTestResult(stage, code, message string) *response.WxWorkKFMessageReadTestResult { + return &response.WxWorkKFMessageReadTestResult{ + Success: false, + Stage: stage, + ErrorCode: code, + ErrorMessage: truncateReadTestText(strings.TrimSpace(message), 1000), + Samples: make([]response.WxWorkKFMessageReadSample, 0), + } +} + +func readTestAsString(value any) string { + if value == nil { + return "" + } + if s, ok := value.(string); ok { + return strings.TrimSpace(s) + } + return "" +} + +func readTestAsInt64(value any) int64 { + switch v := value.(type) { + case float64: + return int64(v) + case int64: + return v + case json.Number: + n, _ := v.Int64() + return n + default: + return 0 + } +} + +func truncateReadTestText(value string, maxRunes int) string { + runes := []rune(value) + if len(runes) <= maxRunes { + return value + } + return string(runes[:maxRunes]) + "…" +} diff --git a/internal/services/wxwork_kf_outbound_service.go b/internal/services/wxwork_kf_outbound_service.go index e2f428a5..3180ee1c 100644 --- a/internal/services/wxwork_kf_outbound_service.go +++ b/internal/services/wxwork_kf_outbound_service.go @@ -168,7 +168,7 @@ func (s *wxWorkKFOutboundService) processOutbox(outboxID int64) error { wxMsgIDs := make([]string, 0, len(chunks)) for i := range chunks { - wxMsgID, sendErr := s.sendOutboundChunk(mapping, message, chunks[i], i) + wxMsgID, sendErr := s.sendOutboundChunk(channel, mapping, message, chunks[i], i) if sendErr != nil { return s.markOutboxFailed(outbox, sendErr.Error()) } @@ -229,19 +229,23 @@ func (s *wxWorkKFOutboundService) processOutbox(outboxID int64) error { }) } -func (s *wxWorkKFOutboundService) sendOutboundChunk(mapping *models.WxWorkKFConversation, message *models.Message, chunk wxWorkKFOutboundChunk, chunkIndex int) (string, error) { +func (s *wxWorkKFOutboundService) sendOutboundChunk(channel *models.Channel, mapping *models.WxWorkKFConversation, message *models.Message, chunk wxWorkKFOutboundChunk, chunkIndex int) (string, error) { switch chunk.MessageType { case enums.IMMessageTypeText: - return s.sendTextMessage(mapping, message, chunk.Content, chunkIndex) + return s.sendTextMessage(channel, mapping, message, chunk.Content, chunkIndex) case enums.IMMessageTypeImage: - return s.sendImageMessage(mapping, message, chunk, chunkIndex) + return s.sendImageMessage(channel, mapping, message, chunk, chunkIndex) default: return "", i18nx.Errorf("error.wxwork.unsupportedOutboundMessageType", chunk.MessageType) } } -func (s *wxWorkKFOutboundService) sendTextMessage(mapping *models.WxWorkKFConversation, message *models.Message, content string, chunkIndex int) (string, error) { - cli, err := wxwork.GetWorkCli().GetKF() +func (s *wxWorkKFOutboundService) sendTextMessage(channel *models.Channel, mapping *models.WxWorkKFConversation, message *models.Message, content string, chunkIndex int) (string, error) { + workCli, err := ChannelService.GetWxWorkCliByChannel(channel) + if err != nil { + return "", err + } + cli, err := workCli.GetKF() if err != nil { return "", err } @@ -282,7 +286,7 @@ func (s *wxWorkKFOutboundService) sendTextMessage(mapping *models.WxWorkKFConver return strings.TrimSpace(resp.MsgID), nil } -func (s *wxWorkKFOutboundService) sendImageMessage(mapping *models.WxWorkKFConversation, message *models.Message, chunk wxWorkKFOutboundChunk, chunkIndex int) (string, error) { +func (s *wxWorkKFOutboundService) sendImageMessage(channel *models.Channel, mapping *models.WxWorkKFConversation, message *models.Message, chunk wxWorkKFOutboundChunk, chunkIndex int) (string, error) { if strings.TrimSpace(chunk.AssetID) == "" { return "", i18nx.Errorf("error.e0145") } @@ -312,7 +316,11 @@ func (s *wxWorkKFOutboundService) sendImageMessage(mapping *models.WxWorkKFConve "external_userid", mapping.ExternalUserID, ) - materialCli := wxwork.GetWorkCli().GetMaterial() + workCli, err := ChannelService.GetWxWorkCliByChannel(channel) + if err != nil { + return "", err + } + materialCli := workCli.GetMaterial() uploadResp, err := materialCli.UploadTempFileFromReader(asset.Filename, "image", fileReader) if err != nil { return "", err @@ -321,7 +329,7 @@ func (s *wxWorkKFOutboundService) sendImageMessage(mapping *models.WxWorkKFConve return "", i18nx.Errorf("error.e0113") } - kfCli, err := wxwork.GetWorkCli().GetKF() + kfCli, err := workCli.GetKF() if err != nil { return "", err } diff --git a/internal/wxwork/login.go b/internal/wxwork/login.go index d7044f2b..afb2f222 100644 --- a/internal/wxwork/login.go +++ b/internal/wxwork/login.go @@ -48,14 +48,15 @@ func BuildLoginURL(state string) (string, error) { if strings.TrimSpace(wxCfg.OAuthRedirect) == "" { return "", i18nx.Errorf("error.e0107") } - if strings.TrimSpace(wxCfg.AgentID) == "" { + agentID := DefaultAgentID() + if strings.TrimSpace(agentID) == "" { return "", i18nx.Errorf("error.e0093") } return fmt.Sprintf( "https://open.weixin.qq.com/connect/oauth2/authorize?appid=%s&redirect_uri=%s&response_type=code&scope=snsapi_privateinfo&agentid=%s&state=%s#wechat_redirect", url.QueryEscape(strings.TrimSpace(wxCfg.CorpID)), url.QueryEscape(strings.TrimSpace(wxCfg.OAuthRedirect)), - url.QueryEscape(strings.TrimSpace(wxCfg.AgentID)), + url.QueryEscape(strings.TrimSpace(agentID)), url.QueryEscape(strings.TrimSpace(state)), ), nil } @@ -67,13 +68,14 @@ func BuildQRCodeLoginURL(state string) (string, error) { if strings.TrimSpace(wxCfg.OAuthRedirect) == "" { return "", i18nx.Errorf("error.e0107") } - if strings.TrimSpace(wxCfg.AgentID) == "" { + agentID := DefaultAgentID() + if strings.TrimSpace(agentID) == "" { return "", i18nx.Errorf("error.e0093") } return fmt.Sprintf( "https://open.work.weixin.qq.com/wwopen/sso/qrConnect?appid=%s&agentid=%s&redirect_uri=%s&state=%s", url.QueryEscape(strings.TrimSpace(wxCfg.CorpID)), - url.QueryEscape(strings.TrimSpace(wxCfg.AgentID)), + url.QueryEscape(strings.TrimSpace(agentID)), url.QueryEscape(strings.TrimSpace(wxCfg.OAuthRedirect)), url.QueryEscape(strings.TrimSpace(state)), ), nil @@ -202,7 +204,7 @@ func GetUserDetail(code string) (*LoginUser, error) { return nil, i18nx.Errorf("error.e0202") } - oauthClient := w.GetOauth() + oauthClient := defaultWork.GetOauth() userInfo, err := oauthClient.GetUserInfo(code) if err != nil { return nil, err @@ -230,7 +232,7 @@ func GetUserDetail(code string) (*LoginUser, error) { } } - if profile, profileErr := w.GetAddressList().UserGet(ret.UserID); profileErr == nil { + if profile, profileErr := defaultWork.GetAddressList().UserGet(ret.UserID); profileErr == nil { ret.UserProfile = profile if strings.TrimSpace(profile.Name) != "" { ret.Name = strings.TrimSpace(profile.Name) diff --git a/internal/wxwork/wxwork.go b/internal/wxwork/wxwork.go index 6525ad68..1364a4d9 100644 --- a/internal/wxwork/wxwork.go +++ b/internal/wxwork/wxwork.go @@ -2,6 +2,7 @@ package wxwork import ( "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/i18nx" "strings" "github.com/silenceper/wechat/v2/cache" @@ -12,8 +13,9 @@ import ( ) var ( - w *work.Work - wxCfg config.WxWorkConfig + defaultWork *work.Work + appWorks map[string]*work.Work + wxCfg config.WxWorkConfig ) type LoginUser struct { @@ -33,38 +35,82 @@ type LoginUser struct { } func Init() { - w = nil + defaultWork = nil + appWorks = nil wxCfg = config.WxWorkConfig{} cfg := config.Current() if !cfg.WxWork.Enabled { return } wxCfg = cfg.WxWork - if strings.TrimSpace(wxCfg.CorpID) == "" || strings.TrimSpace(wxCfg.CorpSecret) == "" { + if strings.TrimSpace(wxCfg.CorpID) == "" { return } - w = work.NewWork(&wxconfig.Config{ - CorpID: wxCfg.CorpID, - CorpSecret: wxCfg.CorpSecret, - AgentID: wxCfg.AgentID, - RasPrivateKey: wxCfg.RSAPrivateKey, - Token: wxCfg.Token, - EncodingAESKey: wxCfg.EncodingAESKey, - Cache: cache.NewMemory(), - }) + apps := wxCfg.NormalizedAPIApps() + if len(apps) == 0 { + return + } + + // 每个应用使用各自的 corpSecret 构建独立客户端, + // SDK 令牌缓存随客户端实例隔离,不同应用的 access_token 天然分开缓存。 + appWorks = make(map[string]*work.Work, len(apps)) + for i := range apps { + app := apps[i] + cli := work.NewWork(&wxconfig.Config{ + CorpID: wxCfg.CorpID, + CorpSecret: app.CorpSecret, + AgentID: app.AgentID, + RasPrivateKey: wxCfg.RSAPrivateKey, + Token: wxCfg.Token, + EncodingAESKey: wxCfg.EncodingAESKey, + Cache: cache.NewMemory(), + }) + appWorks[app.AgentID] = cli + if defaultWork == nil { + defaultWork = cli + } + } } func Enabled() bool { - return w != nil && wxCfg.Enabled + return defaultWork != nil && wxCfg.Enabled } func StateSecret() string { - if strings.TrimSpace(wxCfg.StateSecret) != "" { - return strings.TrimSpace(wxCfg.StateSecret) + if secret := strings.TrimSpace(wxCfg.StateSecret); secret != "" { + return secret } - return strings.TrimSpace(wxCfg.CorpSecret) + if secret := strings.TrimSpace(wxCfg.CorpSecret); secret != "" { + return secret + } + if apps := wxCfg.NormalizedAPIApps(); len(apps) > 0 { + return strings.TrimSpace(apps[0].CorpSecret) + } + return "" } func GetWorkCli() *work.Work { - return w + return defaultWork +} + +// GetWorkCliByAgentID 按 agentId 返回对应应用的企微客户端; +// 各应用客户端持有独立的 corpSecret 与 token 缓存,不能混用。 +func GetWorkCliByAgentID(agentID string) (*work.Work, error) { + agentID = strings.TrimSpace(agentID) + if cli := appWorks[agentID]; cli != nil { + return cli, nil + } + return nil, i18nx.Errorf("error.wxwork.appNotConfigured", agentID) +} + +// DefaultAgentID 返回默认(第一个已配置)应用的 agentId; +// 供 OAuth 登录等不区分具体应用的流程使用。 +func DefaultAgentID() string { + if agentID := strings.TrimSpace(wxCfg.AgentID); agentID != "" { + return agentID + } + if apps := wxCfg.NormalizedAPIApps(); len(apps) > 0 { + return strings.TrimSpace(apps[0].AgentID) + } + return "" } diff --git a/web/app/(dashboard)/dashboard/channels/_components/edit.tsx b/web/app/(dashboard)/dashboard/channels/_components/edit.tsx index c5b96168..14f9d388 100644 --- a/web/app/(dashboard)/dashboard/channels/_components/edit.tsx +++ b/web/app/(dashboard)/dashboard/channels/_components/edit.tsx @@ -23,14 +23,17 @@ import { type AIAgent, type AdminChannel, type CreateAdminChannelPayload, + type WxWorkApiApp, type WxWorkKFAccount, fetchAIAgentsAll, fetchChannel, + fetchWxWorkApiApps, fetchWxWorkKFAccounts, rollbackChannelAIAgentRollout, resetChannelUserTokenSecret, } from "@/lib/api/admin" import { useI18n } from "@/i18n/provider" +import { WxWorkReadTestButton } from "./read-test-button" type ChannelFormDialogProps = { open: boolean @@ -73,6 +76,13 @@ type ZaloOAChannelConfig = { webhookSecret?: string } +type DiscordChannelConfig = { + guildId?: string + guildName?: string + botToken?: string + webhookSecret?: string +} + function getDefaultWebChannelConfig(t: Translate): Required { return { title: t("channel.defaultTitleWeb"), @@ -87,11 +97,19 @@ function getDefaultWebChannelConfig(t: Translate): Required { function createSchema(t: Translate) { return z .object({ - channelType: z.enum(["web", "wechat_mp", "wxwork_kf", "telegram", "zalo_oa"], t("channel.typeRequired")), + channelType: z.enum(["web", "wechat_mp", "wxwork_kf", "telegram", "zalo_oa", "discord"], t("channel.typeRequired")), aiAgentId: z.string().trim().regex(/^\d+$/, t("channel.agentRequired")), aiAgentRolloutPercent: z.coerce.number().int().min(1).max(100), + aiReplyPlaceholder: z.string().trim().max(255, t("channel.aiReplyPlaceholderTooLong")), + aiReplyTimeoutSeconds: z.coerce + .number() + .int(t("channel.aiReplyTimeoutInvalid")) + .min(0, t("channel.aiReplyTimeoutInvalid")) + .max(600, t("channel.aiReplyTimeoutInvalid")), + aiReplyTimeoutNotice: z.string().trim().max(500, t("channel.aiReplyTimeoutNoticeTooLong")), name: z.string().trim().min(1, t("channel.nameRequired")), openKfId: z.string().trim(), + wxAgentId: z.string().trim(), botToken: z.string().trim(), botUsername: z.string().trim(), webhookSecret: z.string().trim(), @@ -99,6 +117,9 @@ function createSchema(t: Translate) { zaloOaId: z.string().trim(), zaloAccessToken: z.string().trim(), zaloSecretKey: z.string().trim(), + discordGuildId: z.string().trim(), + discordGuildName: z.string().trim(), + discordBotToken: z.string().trim(), widgetTitle: z.string().trim(), widgetSubtitle: z.string().trim(), widgetThemeColor: z.string().trim(), @@ -115,6 +136,13 @@ function createSchema(t: Translate) { message: t("channel.wxworkAccountRequired"), }) } + if (values.channelType === "wxwork_kf" && !values.wxAgentId.trim()) { + ctx.addIssue({ + code: "custom", + path: ["wxAgentId"], + message: t("channel.wxworkAppRequired"), + }) + } if (values.channelType === "telegram" && !values.botToken.trim()) { ctx.addIssue({ code: "custom", @@ -133,11 +161,15 @@ function createSchema(t: Translate) { } type EditForm = { - channelType: "web" | "wechat_mp" | "wxwork_kf" | "telegram" | "zalo_oa" + channelType: "web" | "wechat_mp" | "wxwork_kf" | "telegram" | "zalo_oa" | "discord" aiAgentId: string aiAgentRolloutPercent: number + aiReplyPlaceholder: string + aiReplyTimeoutSeconds: number + aiReplyTimeoutNotice: string name: string openKfId: string + wxAgentId: string botToken: string botUsername: string webhookSecret: string @@ -145,6 +177,9 @@ type EditForm = { zaloOaId: string zaloAccessToken: string zaloSecretKey: string + discordGuildId: string + discordGuildName: string + discordBotToken: string widgetTitle: string widgetSubtitle: string widgetThemeColor: string @@ -160,8 +195,12 @@ function createEmptyForm(t: Translate): EditForm { channelType: "web", aiAgentId: "", aiAgentRolloutPercent: 100, + aiReplyPlaceholder: "", + aiReplyTimeoutSeconds: 0, + aiReplyTimeoutNotice: "", name: "", openKfId: "", + wxAgentId: "", botToken: "", botUsername: "", webhookSecret: "", @@ -169,6 +208,9 @@ function createEmptyForm(t: Translate): EditForm { zaloOaId: "", zaloAccessToken: "", zaloSecretKey: "", + discordGuildId: "", + discordGuildName: "", + discordBotToken: "", widgetTitle: defaultWebChannelConfig.title, widgetSubtitle: defaultWebChannelConfig.subtitle, widgetThemeColor: defaultWebChannelConfig.themeColor, @@ -179,15 +221,18 @@ function createEmptyForm(t: Translate): EditForm { } } -function parseOpenKfId(configJson: string): string { +function parseWxWorkKfConfig(configJson: string): { openKfId: string; agentId: string } { if (!configJson.trim()) { - return "" + return { openKfId: "", agentId: "" } } try { - const parsed = JSON.parse(configJson) as { openKfId?: string } - return typeof parsed.openKfId === "string" ? parsed.openKfId.trim() : "" + const parsed = JSON.parse(configJson) as { openKfId?: string; agentId?: string } + return { + openKfId: typeof parsed.openKfId === "string" ? parsed.openKfId.trim() : "", + agentId: typeof parsed.agentId === "string" ? parsed.agentId.trim() : "", + } } catch { - return "" + return { openKfId: "", agentId: "" } } } @@ -222,6 +267,21 @@ function parseZaloOAChannelConfig(configJson: string): ZaloOAChannelConfig { } } +function parseDiscordChannelConfig(configJson: string): DiscordChannelConfig { + if (!configJson.trim()) return {} + try { + const parsed = JSON.parse(configJson) as DiscordChannelConfig + return { + guildId: parsed.guildId?.trim() || "", + guildName: parsed.guildName?.trim() || "", + botToken: parsed.botToken?.trim() || "", + webhookSecret: parsed.webhookSecret?.trim() || "", + } + } catch { + return {} + } +} + function parseWebChannelConfig(configJson: string, t: Translate): Required { const defaultWebChannelConfig = getDefaultWebChannelConfig(t) if (!configJson.trim()) { @@ -276,6 +336,7 @@ function buildForm(item: AdminChannel | null, t: Translate): EditForm { const isWechatMP = item.channelType === "wechat_mp" const isTelegram = item.channelType === "telegram" const isZaloOA = item.channelType === "zalo_oa" + const isDiscord = item.channelType === "discord" const webConfig = parseWebChannelConfig(item.configJson, t) const wechatConfig = isWechatMP ? parseWechatMPChannelConfig(item.configJson, t) @@ -286,6 +347,10 @@ function buildForm(item: AdminChannel | null, t: Translate): EditForm { const zaloConfig = isZaloOA ? parseZaloOAChannelConfig(item.configJson) : null + const discordConfig = isDiscord + ? parseDiscordChannelConfig(item.configJson) + : null + const wxWorkConfig = parseWxWorkKfConfig(item.configJson) return { channelType: item.channelType === "wxwork_kf" @@ -294,20 +359,33 @@ function buildForm(item: AdminChannel | null, t: Translate): EditForm { ? "telegram" : item.channelType === "zalo_oa" ? "zalo_oa" - : item.channelType === "wechat_mp" - ? "wechat_mp" - : "web", + : item.channelType === "discord" + ? "discord" + : item.channelType === "wechat_mp" + ? "wechat_mp" + : "web", aiAgentId: item.aiAgentId > 0 ? String(item.aiAgentId) : "", aiAgentRolloutPercent: item.aiAgentRolloutPercent || 100, + aiReplyPlaceholder: item.aiReplyPlaceholder || "", + aiReplyTimeoutSeconds: item.aiReplyTimeoutSeconds || 0, + aiReplyTimeoutNotice: item.aiReplyTimeoutNotice || "", name: item.name, - openKfId: parseOpenKfId(item.configJson), + openKfId: wxWorkConfig.openKfId, + wxAgentId: wxWorkConfig.agentId, botToken: telegramConfig?.botToken ?? "", botUsername: telegramConfig?.botUsername ?? "", - webhookSecret: telegramConfig?.webhookSecret ?? zaloConfig?.webhookSecret ?? "", + webhookSecret: + telegramConfig?.webhookSecret ?? + zaloConfig?.webhookSecret ?? + discordConfig?.webhookSecret ?? + "", zaloAppId: zaloConfig?.appId ?? "", zaloOaId: zaloConfig?.oaId ?? "", zaloAccessToken: zaloConfig?.accessToken ?? "", zaloSecretKey: zaloConfig?.secretKey ?? "", + discordGuildId: discordConfig?.guildId ?? "", + discordGuildName: discordConfig?.guildName ?? "", + discordBotToken: discordConfig?.botToken ?? "", widgetTitle: wechatConfig?.title ?? webConfig.title, widgetSubtitle: wechatConfig?.subtitle ?? webConfig.subtitle, widgetThemeColor: wechatConfig?.themeColor ?? webConfig.themeColor, @@ -332,7 +410,7 @@ function buildPayload(form: EditForm, status: number, t: Translate): CreateAdmin } const configJson = channelType === "wxwork_kf" - ? JSON.stringify({ openKfId: form.openKfId.trim() }) + ? JSON.stringify({ openKfId: form.openKfId.trim(), agentId: form.wxAgentId.trim() }) : channelType === "telegram" ? JSON.stringify({ botToken: form.botToken.trim(), @@ -347,18 +425,28 @@ function buildPayload(form: EditForm, status: number, t: Translate): CreateAdmin secretKey: form.zaloSecretKey.trim(), webhookSecret: form.webhookSecret.trim(), }) - : channelType === "wechat_mp" - ? JSON.stringify(webLikeConfig) - : JSON.stringify({ - ...webLikeConfig, - position: form.widgetPosition || defaultWebChannelConfig.position, - width: form.widgetWidth.trim() || defaultWebChannelConfig.width, - userTokenSecret: form.userTokenSecret.trim(), + : channelType === "discord" + ? JSON.stringify({ + guildId: form.discordGuildId.trim(), + guildName: form.discordGuildName.trim(), + botToken: form.discordBotToken.trim(), + webhookSecret: form.webhookSecret.trim(), }) + : channelType === "wechat_mp" + ? JSON.stringify(webLikeConfig) + : JSON.stringify({ + ...webLikeConfig, + position: form.widgetPosition || defaultWebChannelConfig.position, + width: form.widgetWidth.trim() || defaultWebChannelConfig.width, + userTokenSecret: form.userTokenSecret.trim(), + }) return { channelType, aiAgentId: Number(form.aiAgentId), aiAgentRolloutPercent: form.aiAgentRolloutPercent, + aiReplyPlaceholder: form.aiReplyPlaceholder.trim(), + aiReplyTimeoutSeconds: Number.isFinite(form.aiReplyTimeoutSeconds) ? form.aiReplyTimeoutSeconds : 0, + aiReplyTimeoutNotice: form.aiReplyTimeoutNotice.trim(), name: form.name.trim(), configJson, status, @@ -416,6 +504,8 @@ function ChannelFormBody({ const [loading, setLoading] = useState(false) const [aiAgents, setAIAgents] = useState([]) const [wxWorkKFAccounts, setWxWorkKFAccounts] = useState([]) + const [wxWorkApiApps, setWxWorkApiApps] = useState([]) + const [wxWorkApiAppsLoading, setWxWorkApiAppsLoading] = useState(false) const [wxWorkKFAccountsLoading, setWxWorkKFAccountsLoading] = useState(false) const [wxWorkKFAccountsError, setWxWorkKFAccountsError] = useState("") const [channelDetail, setChannelDetail] = useState(null) @@ -440,6 +530,7 @@ function ChannelFormBody({ const channelType = useWatch({ control, name: "channelType" }) const aiAgentId = useWatch({ control, name: "aiAgentId" }) const openKfId = useWatch({ control, name: "openKfId" }) + const wxAgentId = useWatch({ control, name: "wxAgentId" }) const userTokenSecret = useWatch({ control, name: "userTokenSecret" }) const previousRolloutPercent = channelDetail?.previousAiAgentRolloutPercent ?? 0 @@ -530,6 +621,27 @@ function ChannelFormBody({ t, ]) + useEffect(() => { + if ( + channelType !== "wxwork_kf" || + wxWorkApiApps.length > 0 || + wxWorkApiAppsLoading + ) { + return + } + async function loadWxWorkApiApps() { + setWxWorkApiAppsLoading(true) + try { + setWxWorkApiApps(await fetchWxWorkApiApps()) + } catch (error) { + console.error("Failed to load WeCom apps:", error) + } finally { + setWxWorkApiAppsLoading(false) + } + } + void loadWxWorkApiApps() + }, [channelType, wxWorkApiApps.length, wxWorkApiAppsLoading]) + const selectedAIAgent = aiAgents.find((item) => String(item.id) === aiAgentId) const aiAgentOptions = aiAgents.map((item) => ({ value: String(item.id), @@ -540,9 +652,14 @@ function ChannelFormBody({ value: item.openKfId, label: item.name ? `${item.name} (${item.openKfId})` : item.openKfId, })) + const wxWorkApiAppOptions = wxWorkApiApps.map((item) => ({ + value: item.agentId, + label: item.agentId, + })) const channelTypeOptions = [ { value: "web", label: t("channel.typeWeb") }, { value: "telegram", label: t("channel.typeTelegram") }, + { value: "discord", label: t("channel.typeDiscord") }, { value: "wechat_mp", label: t("channel.typeWechatMp") }, { value: "wxwork_kf", label: t("channel.typeWxworkKf") }, ] as const @@ -560,6 +677,16 @@ function ChannelFormBody({ label: openKfId, }) } + if ( + channelType === "wxwork_kf" && + wxAgentId && + !wxWorkApiAppOptions.some((item) => item.value === wxAgentId) + ) { + wxWorkApiAppOptions.unshift({ + value: wxAgentId, + label: wxAgentId, + }) + } async function onFormSubmit(values: EditForm) { const selected = aiAgents.find((item) => String(item.id) === values.aiAgentId) @@ -765,6 +892,57 @@ function ChannelFormBody({ ) : null} + {channelType === "discord" ? ( +
+
+ + {t("channel.discordGuildId")} + + + + + + + + {t("channel.discordGuildName")} + + + + + +
+ + + {t("channel.discordBotToken")} + + + + + + +
+
{t("channel.discordSetupTitle")}
+
{t("channel.discordSetupDescription")}
+
+ {t("channel.inboundWebhookUrl")}: /api/third/discord/webhook +
+
+
+ ) : null} + {channelType === "telegram" ? (
@@ -814,31 +992,58 @@ function ChannelFormBody({ ) : null} {channelType === "wxwork_kf" ? ( - - {t("channel.wxworkAccount")} - - ( - - )} - /> - - - +
+ + {t("channel.wxworkAccount")} + + ( + + )} + /> + + + + + + {t("channel.wxworkApp")} + + ( + + )} + /> + + + + +
) : null} {channelType === "web" || channelType === "wechat_mp" ? ( @@ -967,6 +1172,56 @@ function ChannelFormBody({ ) : null}
+
+
+
{t("channel.aiReplySectionTitle")}
+
{t("channel.aiReplySectionDescription")}
+
+ +
+ + {t("channel.aiReplyPlaceholder")} + + + + + + + + {t("channel.aiReplyTimeoutSeconds")} + + +
{t("channel.aiReplyTimeoutSecondsHint")}
+ +
+
+
+ + + {t("channel.aiReplyTimeoutNotice")} + +