From 6f854ebbf7141bc921cc777293e0ebaa00979bf5 Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Sun, 23 Aug 2026 11:35:00 +0700
Subject: [PATCH 01/53] fix(auth): log and surface oauth error parameters on
oidc callback
---
internal/handlers/api/auth_handler.go | 23 +++++++++++++++++++++--
1 file changed, 21 insertions(+), 2 deletions(-)
diff --git a/internal/handlers/api/auth_handler.go b/internal/handlers/api/auth_handler.go
index 92073151..4acd476b 100644
--- a/internal/handlers/api/auth_handler.go
+++ b/internal/handlers/api/auth_handler.go
@@ -7,6 +7,7 @@ import (
"agent-desk/internal/pkg/httpx"
"agent-desk/internal/pkg/httpx/params"
"agent-desk/internal/services"
+ "log/slog"
"net/http"
"net/url"
"strings"
@@ -97,16 +98,34 @@ func OIDCLogin(ctx *gin.Context) {
}
func OIDCCallback(ctx *gin.Context) {
+ if oauthErr := ctx.Query("error"); oauthErr != "" {
+ desc := ctx.Query("error_description")
+ slog.Warn("oidc callback returned oauth error", "error", oauthErr, "description", desc)
+ errMsg := oauthErr
+ if desc != "" {
+ errMsg += ": " + desc
+ }
+ ctx.Redirect(http.StatusFound, "/dashboard/login?oidcError="+url.QueryEscape(errMsg))
+ return
+ }
+
+ code := ctx.Query("code")
+ state := ctx.Query("state")
+ if strings.TrimSpace(code) == "" {
+ slog.Warn("oidc callback missing code", "rawQuery", ctx.Request.URL.RawQuery)
+ }
+
cfg := config.Current()
ticket, next, err := services.OIDCLoginService.LoginByOIDC(
ctx.Request.Context(),
- ctx.Query("code"),
- ctx.Query("state"),
+ code,
+ state,
cfg.Auth,
ctx.ClientIP(),
ctx.GetHeader("User-Agent"),
)
if err != nil {
+ slog.Error("oidc callback login failed", "error", err)
ctx.Redirect(http.StatusFound, "/dashboard/login?oidcError="+url.QueryEscape(loginErrorMessage(err.Error())))
return
}
From e870f2a86f4fbf07602f8d4a45e56a924c25f73c Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Sun, 23 Aug 2026 12:56:56 +0700
Subject: [PATCH 02/53] feat(auth): add PKCE S256 code challenge and verifier
support for OIDC authorization
---
internal/oidcclient/oidcclient.go | 54 ++++++++++++++++++-------
internal/services/oidc_login_service.go | 4 +-
2 files changed, 41 insertions(+), 17 deletions(-)
diff --git a/internal/oidcclient/oidcclient.go b/internal/oidcclient/oidcclient.go
index c828a920..2ba71bde 100644
--- a/internal/oidcclient/oidcclient.go
+++ b/internal/oidcclient/oidcclient.go
@@ -54,6 +54,7 @@ type Profile struct {
type statePayload struct {
Next string `json:"next"`
Nonce string `json:"nonce"`
+ Verifier string `json:"verifier,omitempty"`
ExpiredAt int64 `json:"expiredAt"`
}
@@ -132,16 +133,29 @@ func BuildAuthCodeURL(next string) (string, error) {
if !Enabled() {
return "", errorsx.BusinessErrorI18n(1, "error.oidc.loginDisabled")
}
- state, err := CreateState(next)
+
+ rawVerifier := make([]byte, 32)
+ if _, err := rand.Read(rawVerifier); err != nil {
+ return "", err
+ }
+ verifier := base64.RawURLEncoding.EncodeToString(rawVerifier)
+ h := sha256.Sum256([]byte(verifier))
+ challenge := base64.RawURLEncoding.EncodeToString(h[:])
+
+ state, err := CreateState(next, verifier)
if err != nil {
return "", err
}
oidcMu.Lock()
defer oidcMu.Unlock()
- return oauthConfig.AuthCodeURL(state), nil
+ return oauthConfig.AuthCodeURL(
+ state,
+ oauth2.SetAuthURLParam("code_challenge", challenge),
+ oauth2.SetAuthURLParam("code_challenge_method", "S256"),
+ ), nil
}
-func ExchangeCode(ctx context.Context, code string) (*Profile, error) {
+func ExchangeCode(ctx context.Context, code string, verifier string) (*Profile, error) {
ensureInitialized(ctx)
if !Enabled() {
return nil, errorsx.BusinessErrorI18n(1, "error.oidc.loginDisabled")
@@ -152,12 +166,17 @@ func ExchangeCode(ctx context.Context, code string) (*Profile, error) {
}
oidcMu.Lock()
- verifier := idTokenVerifier
+ idVerifier := idTokenVerifier
oauthCfg := oauthConfig
prov := provider
oidcMu.Unlock()
- token, err := oauthCfg.Exchange(ctx, code)
+ var opts []oauth2.AuthCodeOption
+ if strings.TrimSpace(verifier) != "" {
+ opts = append(opts, oauth2.SetAuthURLParam("code_verifier", strings.TrimSpace(verifier)))
+ }
+
+ token, err := oauthCfg.Exchange(ctx, code, opts...)
if err != nil {
return nil, err
}
@@ -165,7 +184,7 @@ func ExchangeCode(ctx context.Context, code string) (*Profile, error) {
if !ok || strings.TrimSpace(rawIDToken) == "" {
return nil, errorsx.UnauthorizedI18n("error.e0038")
}
- idToken, err := verifier.Verify(ctx, rawIDToken)
+ idToken, err := idVerifier.Verify(ctx, rawIDToken)
if err != nil {
return nil, err
}
@@ -182,7 +201,7 @@ func ExchangeCode(ctx context.Context, code string) (*Profile, error) {
return profile, nil
}
-func CreateState(next string) (string, error) {
+func CreateState(next string, verifier ...string) (string, error) {
secret := stateSecret()
if secret == "" {
return "", errorsx.BusinessErrorI18n(2, "error.oidc.stateSecretMissing")
@@ -191,9 +210,14 @@ func CreateState(next string) (string, error) {
if err != nil {
return "", err
}
+ v := ""
+ if len(verifier) > 0 {
+ v = verifier[0]
+ }
payload := statePayload{
Next: sanitizeNextPath(next),
Nonce: nonce,
+ Verifier: v,
ExpiredAt: time.Now().Add(StateTTL).Unix(),
}
body, err := json.Marshal(payload)
@@ -204,30 +228,30 @@ func CreateState(next string) (string, error) {
return encoded + "." + signState(encoded, secret), nil
}
-func ParseState(state string) (string, error) {
+func ParseState(state string) (string, string, error) {
secret := stateSecret()
if secret == "" {
- return "", errorsx.UnauthorizedI18n("error.e0046")
+ return "", "", errorsx.UnauthorizedI18n("error.e0046")
}
parts := strings.Split(strings.TrimSpace(state), ".")
if len(parts) != 2 {
- return "", errorsx.UnauthorizedI18n("error.e0046")
+ return "", "", errorsx.UnauthorizedI18n("error.e0046")
}
if !hmac.Equal([]byte(parts[1]), []byte(signState(parts[0], secret))) {
- return "", errorsx.UnauthorizedI18n("error.e0046")
+ return "", "", errorsx.UnauthorizedI18n("error.e0046")
}
body, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
- return "", errorsx.UnauthorizedI18n("error.e0046")
+ return "", "", errorsx.UnauthorizedI18n("error.e0046")
}
payload := statePayload{}
if err = json.Unmarshal(body, &payload); err != nil {
- return "", errorsx.UnauthorizedI18n("error.e0046")
+ return "", "", errorsx.UnauthorizedI18n("error.e0046")
}
if payload.ExpiredAt <= time.Now().Unix() {
- return "", errorsx.UnauthorizedI18n("error.e0046")
+ return "", "", errorsx.UnauthorizedI18n("error.e0046")
}
- return sanitizeNextPath(payload.Next), nil
+ return sanitizeNextPath(payload.Next), payload.Verifier, nil
}
func IssueLoginTicket(loginResp *response.LoginResponse) (string, error) {
diff --git a/internal/services/oidc_login_service.go b/internal/services/oidc_login_service.go
index 8aaf540e..9827c4d7 100644
--- a/internal/services/oidc_login_service.go
+++ b/internal/services/oidc_login_service.go
@@ -37,11 +37,11 @@ func (s *oidcLoginService) BuildOIDCLoginURL(next string) (string, error) {
}
func (s *oidcLoginService) LoginByOIDC(ctx context.Context, code, state string, authCfg config.AuthConfig, clientIP, userAgent string) (string, string, error) {
- next, err := oidcclient.ParseState(state)
+ next, verifier, err := oidcclient.ParseState(state)
if err != nil {
return "", "", err
}
- profile, err := oidcclient.ExchangeCode(ctx, code)
+ profile, err := oidcclient.ExchangeCode(ctx, code, verifier)
if err != nil {
return "", "", err
}
From 12bb354af8baa3483260f27355a0e44294af25ce Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Sun, 23 Aug 2026 15:06:57 +0700
Subject: [PATCH 03/53] feat(auth): add passwordLoginEnabled setting to disable
username/password login form
---
config/config.example.yaml | 3 +
docker/agent-desk.supabase.example.yaml | 1 +
internal/bootstrap/server_route_test.go | 74 ++++++++++++++-
internal/handlers/api/auth_handler.go | 12 ++-
internal/pkg/config/config.go | 14 ++-
internal/pkg/dto/response/auth_response.go | 7 +-
internal/pkg/i18nx/locales/en-US.yml | 1 +
internal/pkg/i18nx/locales/zh-CN.yml | 1 +
web/components/login-form.tsx | 100 +++++++++++----------
web/lib/api/config.ts | 1 +
10 files changed, 157 insertions(+), 57 deletions(-)
diff --git a/config/config.example.yaml b/config/config.example.yaml
index fc55d638..94bcbb1d 100644
--- a/config/config.example.yaml
+++ b/config/config.example.yaml
@@ -36,6 +36,9 @@ logger:
addSource: false
auth:
+ # Enable username and password login. When set to false, users must sign in with SSO (e.g. OIDC or WeCom).
+ # Default is true.
+ passwordLoginEnabled: true
# Login access token lifetime, in hours. Values <= 0 fall back to 12 hours.
# Applies to password login, OIDC login, and WeCom login sessions.
tokenTTLHours: 12
diff --git a/docker/agent-desk.supabase.example.yaml b/docker/agent-desk.supabase.example.yaml
index 9a4bf1f6..165a4bb6 100644
--- a/docker/agent-desk.supabase.example.yaml
+++ b/docker/agent-desk.supabase.example.yaml
@@ -24,6 +24,7 @@ logger:
addSource: false
auth:
+ passwordLoginEnabled: false
tokenTTLHours: 12
maxFailedAttempts: 5
credentialLockMinute: 15
diff --git a/internal/bootstrap/server_route_test.go b/internal/bootstrap/server_route_test.go
index af2d0c67..d64edb8c 100644
--- a/internal/bootstrap/server_route_test.go
+++ b/internal/bootstrap/server_route_test.go
@@ -4,6 +4,8 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
+ "os"
+ "path/filepath"
"strings"
"testing"
@@ -139,9 +141,10 @@ func TestNewServerExposesPublicConfig(t *testing.T) {
var body struct {
Success bool `json:"success"`
Data struct {
- Language string `json:"language"`
- WxWorkEnabled bool `json:"wxworkEnabled"`
- OIDCEnabled bool `json:"oidcEnabled"`
+ Language string `json:"language"`
+ PasswordLoginEnabled bool `json:"passwordLoginEnabled"`
+ WxWorkEnabled bool `json:"wxworkEnabled"`
+ OIDCEnabled bool `json:"oidcEnabled"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
@@ -153,6 +156,9 @@ func TestNewServerExposesPublicConfig(t *testing.T) {
if body.Data.Language != "zh-CN" {
t.Fatalf("language=%q want zh-CN", body.Data.Language)
}
+ if !body.Data.PasswordLoginEnabled {
+ t.Fatalf("passwordLoginEnabled=false want true")
+ }
if !body.Data.WxWorkEnabled {
t.Fatalf("wxworkEnabled=false want true")
}
@@ -187,7 +193,69 @@ func TestNewServerDoesNotExposeLegacyAuthOptions(t *testing.T) {
}
}
+func TestNewServerPasswordLoginDisabled(t *testing.T) {
+ disabled := false
+ config.SetCurrent(&config.Config{
+ Auth: config.AuthConfig{
+ PasswordLoginEnabled: &disabled,
+ },
+ Storage: config.StorageConfig{
+ Local: config.LocalStorageConfig{
+ Root: "storage",
+ BaseURL: "/storage",
+ },
+ },
+ })
+
+ app, err := NewServer()
+ if err != nil {
+ t.Fatalf("NewServer() error = %v", err)
+ }
+
+ // 1. /api/config should return passwordLoginEnabled=false
+ rec := httptest.NewRecorder()
+ app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/config", nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status=%d want %d", rec.Code, http.StatusOK)
+ }
+ var configBody struct {
+ Data struct {
+ PasswordLoginEnabled bool `json:"passwordLoginEnabled"`
+ } `json:"data"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &configBody); err != nil {
+ t.Fatalf("unmarshal response: %v", err)
+ }
+ if configBody.Data.PasswordLoginEnabled {
+ t.Fatalf("expected passwordLoginEnabled=false, got true")
+ }
+
+ // 2. /api/auth/login should be rejected
+ rec = httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"username":"admin","password":"secret"}`))
+ req.Header.Set("Content-Type", "application/json")
+ app.ServeHTTP(rec, req)
+
+ var loginBody struct {
+ Success bool `json:"success"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &loginBody); err != nil {
+ t.Fatalf("unmarshal response: %v", err)
+ }
+ if loginBody.Success {
+ t.Fatalf("expected login failure when password login is disabled")
+ }
+}
+
func TestNewServerSeparatesAPIStaticAndSPA(t *testing.T) {
+ for _, rootDir := range []string{"web/out", "../web/out", "../../web/out"} {
+ _ = os.MkdirAll(rootDir, 0o755)
+ _ = os.WriteFile(filepath.Join(rootDir, "index.html"), []byte("spa"), 0o644)
+ defer func(d string) {
+ _ = os.Remove(filepath.Join(d, "index.html"))
+ }(rootDir)
+ }
+
config.SetCurrent(&config.Config{
Storage: config.StorageConfig{
Local: config.LocalStorageConfig{
diff --git a/internal/handlers/api/auth_handler.go b/internal/handlers/api/auth_handler.go
index 4acd476b..9e5e7c9b 100644
--- a/internal/handlers/api/auth_handler.go
+++ b/internal/handlers/api/auth_handler.go
@@ -4,6 +4,7 @@ import (
"agent-desk/internal/pkg/config"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/dto/response"
+ "agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/httpx"
"agent-desk/internal/pkg/httpx/params"
"agent-desk/internal/services"
@@ -17,6 +18,10 @@ import (
func Login(ctx *gin.Context) {
cfg := config.Current()
+ if !cfg.Auth.IsPasswordLoginEnabled() {
+ httpx.WriteJSON(ctx, errorsx.ForbiddenI18n("error.auth.passwordLoginDisabled"))
+ return
+ }
req := request.LoginRequest{}
if err := params.ReadJSON(ctx, &req); err != nil {
httpx.WriteJSON(ctx, err)
@@ -34,9 +39,10 @@ func Login(ctx *gin.Context) {
func PublicConfig(ctx *gin.Context) {
cfg := config.Current()
httpx.WriteJSON(ctx, &response.PublicConfigResponse{
- Language: cfg.LanguageOrDefault(),
- WxWorkEnabled: cfg.WxWork.Enabled,
- OIDCEnabled: cfg.OIDC.Enabled,
+ Language: cfg.LanguageOrDefault(),
+ PasswordLoginEnabled: cfg.Auth.IsPasswordLoginEnabled(),
+ WxWorkEnabled: cfg.WxWork.Enabled,
+ OIDCEnabled: cfg.OIDC.Enabled,
})
}
diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go
index 3870ac21..259041ad 100644
--- a/internal/pkg/config/config.go
+++ b/internal/pkg/config/config.go
@@ -76,9 +76,17 @@ type LoggerConfig struct {
}
type AuthConfig struct {
- TokenTTLHours int `yaml:"tokenTTLHours"`
- MaxFailedAttempts int `yaml:"maxFailedAttempts"`
- CredentialLockMinute int `yaml:"credentialLockMinute"`
+ PasswordLoginEnabled *bool `yaml:"passwordLoginEnabled"`
+ TokenTTLHours int `yaml:"tokenTTLHours"`
+ MaxFailedAttempts int `yaml:"maxFailedAttempts"`
+ CredentialLockMinute int `yaml:"credentialLockMinute"`
+}
+
+func (a AuthConfig) IsPasswordLoginEnabled() bool {
+ if a.PasswordLoginEnabled == nil {
+ return true
+ }
+ return *a.PasswordLoginEnabled
}
type CustomerSessionConfig struct {
diff --git a/internal/pkg/dto/response/auth_response.go b/internal/pkg/dto/response/auth_response.go
index f2202aba..c4b023b4 100644
--- a/internal/pkg/dto/response/auth_response.go
+++ b/internal/pkg/dto/response/auth_response.go
@@ -21,7 +21,8 @@ type LoginResponse struct {
}
type PublicConfigResponse struct {
- Language string `json:"language"`
- WxWorkEnabled bool `json:"wxworkEnabled"`
- OIDCEnabled bool `json:"oidcEnabled"`
+ Language string `json:"language"`
+ PasswordLoginEnabled bool `json:"passwordLoginEnabled"`
+ WxWorkEnabled bool `json:"wxworkEnabled"`
+ OIDCEnabled bool `json:"oidcEnabled"`
}
diff --git a/internal/pkg/i18nx/locales/en-US.yml b/internal/pkg/i18nx/locales/en-US.yml
index eaba4824..ca0adeb7 100644
--- a/internal/pkg/i18nx/locales/en-US.yml
+++ b/internal/pkg/i18nx/locales/en-US.yml
@@ -230,6 +230,7 @@ error.e0230: "Invalid service mode."
error.e0231: "No matching WeCom channel was found."
error.e0232: "No schedules were generated."
error.auth.expired: "Your session has expired. Please sign in again."
+error.auth.passwordLoginDisabled: "Username and password login is disabled. Please use SSO to sign in."
error.e0234: "No available AI configuration is configured."
error.e0235: "No available embedding model is configured."
error.e0236: "Permission not found."
diff --git a/internal/pkg/i18nx/locales/zh-CN.yml b/internal/pkg/i18nx/locales/zh-CN.yml
index 37c3f435..e7c63410 100644
--- a/internal/pkg/i18nx/locales/zh-CN.yml
+++ b/internal/pkg/i18nx/locales/zh-CN.yml
@@ -230,6 +230,7 @@ error.e0230: "服务模式不合法"
error.e0231: "未找到匹配的企业微信接入渠道"
error.e0232: "未生成任何排班"
error.auth.expired: "未登录或登录已过期"
+error.auth.passwordLoginDisabled: "账号密码登录已禁用,请使用第三方登录。"
error.e0234: "未配置可用的 AI 配置"
error.e0235: "未配置可用的 Embedding 模型"
error.e0236: "权限不存在"
diff --git a/web/components/login-form.tsx b/web/components/login-form.tsx
index 8129e6e3..71f98b55 100644
--- a/web/components/login-form.tsx
+++ b/web/components/login-form.tsx
@@ -50,6 +50,7 @@ export function LoginForm({
nextPath && nextPath.startsWith("/") ? nextPath : "/dashboard"
const enabledProviderCount =
Number(publicConfig?.wxworkEnabled) + Number(publicConfig?.oidcEnabled)
+ const isPasswordLoginEnabled = publicConfig?.passwordLoginEnabled !== false
useEffect(() => {
if (session) {
@@ -97,6 +98,9 @@ export function LoginForm({
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
+ if (!isPasswordLoginEnabled) {
+ return
+ }
const formData = new FormData(event.currentTarget)
const username = formData.get("username")?.toString().trim() ?? ""
const password = formData.get("password")?.toString() ?? ""
@@ -162,49 +166,55 @@ export function LoginForm({
{t("auth.loginDescription", { brand: t("app.brand") })}
-
- {t("auth.username")}
-
-
-
-
-
-
-
-
-
+ {isPasswordLoginEnabled ? (
+ <>
+
+ {t("auth.username")}
+
+
+
+
+
+
+
+
+
+ >
+ ) : null}
{enabledProviderCount > 0 ? (
<>
-
- {t("auth.continueWith")}
-
+ {isPasswordLoginEnabled ? (
+
+ {t("auth.continueWith")}
+
+ ) : null}
{publicConfig.wxworkEnabled ? (
@@ -218,14 +228,14 @@ export function LoginForm({
: "/api/auth/wxwork_qr_login"
window.location.href = `${path}?next=${encodeURIComponent(redirectPath)}`
}}
- >
-
+
+ height={16}
+ className="size-4 shrink-0"
+ />
{t("auth.wxworkSignIn")}
) : null}
diff --git a/web/lib/api/config.ts b/web/lib/api/config.ts
index e96af102..2fe187e2 100644
--- a/web/lib/api/config.ts
+++ b/web/lib/api/config.ts
@@ -2,6 +2,7 @@ import { request } from "@/lib/api/client"
export type PublicConfig = {
language: string
+ passwordLoginEnabled?: boolean
wxworkEnabled: boolean
oidcEnabled: boolean
}
From 56e5393a4a78d8e6c2233daa4215f32460415c3b Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Sun, 23 Aug 2026 19:27:21 +0700
Subject: [PATCH 04/53] feat(config): add .env file support and standard
environment variable aliases
---
.env.example | 43 ++++++++++++
internal/pkg/config/config.go | 105 +++++++++++++++++++++++++++--
internal/pkg/config/config_test.go | 68 +++++++++++++++++++
3 files changed, 212 insertions(+), 4 deletions(-)
create mode 100644 .env.example
diff --git a/.env.example b/.env.example
new file mode 100644
index 00000000..d553e0ee
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,43 @@
+# ==============================================================================
+# AgentDesk Environment Variables Configuration
+# Copy this file to .env and adjust the configuration values as needed.
+# ==============================================================================
+
+# Server Configuration
+PORT=8083
+# AGENT_DESK_SERVER_CORS_ALLOWEDORIGINS="http://localhost:3000,http://127.0.0.1:8083"
+
+# Database Configuration
+# Driver options: sqlite, mysql, postgres
+DB_TYPE=sqlite
+DATABASE_URL=file:./data/app.db?_busy_timeout=5000
+# MySQL example:
+# DATABASE_URL="cs_ai_agent:cs_ai_agent_password@tcp(127.0.0.1:3306)/cs_ai_agent?charset=utf8mb4&parseTime=True&multiStatements=true&loc=Local"
+# PostgreSQL example:
+# DATABASE_URL="postgres://postgres:password@127.0.0.1:5432/cs_ai_agent?sslmode=disable"
+
+# Auth & Security
+PASSWORD_LOGIN_ENABLED=true
+# AUTH_TOKEN_TTL_HOURS=12
+# CUSTOMER_SESSION_SECRET=replace-with-a-random-secret-at-least-32-chars
+
+# Storage (local or oss)
+STORAGE_DEFAULT=local
+STORAGE_LOCAL_ROOT=data/storage
+STORAGE_LOCAL_BASE_URL=/storage
+
+# Vector Database (Qdrant)
+VECTOR_DB_TYPE=qdrant
+QDRANT_HOST=127.0.0.1
+QDRANT_GRPC_PORT=6334
+# QDRANT_API_KEY=
+
+# Single Sign-On (OIDC / OAuth 2.0)
+# OIDC_ENABLED=true
+# OIDC_ISSUER=https://auth.example.com
+# OIDC_CLIENT_ID=your-client-id
+# OIDC_CLIENT_SECRET=your-client-secret
+# OIDC_REDIRECT_URL=http://localhost:8083/api/auth/oidc_callback
+
+# Webhook & Organization Sync
+# ORG_SYNC_SECRET=your-webhook-hmac-secret
diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go
index 259041ad..236ad251 100644
--- a/internal/pkg/config/config.go
+++ b/internal/pkg/config/config.go
@@ -2,10 +2,14 @@ package config
import (
"agent-desk/internal/pkg/enums"
+ "errors"
"fmt"
+ "os"
+ "path/filepath"
"strings"
"github.com/spf13/viper"
+ "github.com/subosito/gotenv"
)
type Config struct {
@@ -225,20 +229,113 @@ type WebhookConfig struct {
}
func Load(path string) (*Config, error) {
+ loadDotEnv(path)
+
v := viper.New()
- v.SetConfigFile(path)
- v.SetConfigType("yaml")
+ bindConfigDefaults(v)
+
+ if strings.TrimSpace(path) != "" {
+ v.SetConfigFile(path)
+ v.SetConfigType("yaml")
+ }
+
v.SetEnvPrefix("AGENT_DESK")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
+ bindEnvironmentAliases(v)
- if err := v.ReadInConfig(); err != nil {
- return nil, err
+ if strings.TrimSpace(path) != "" {
+ if err := v.ReadInConfig(); err != nil {
+ var configFileNotFoundError viper.ConfigFileNotFoundError
+ if !os.IsNotExist(err) && !errors.As(err, &configFileNotFoundError) {
+ return nil, err
+ }
+ }
}
cfg := &Config{}
if err := v.Unmarshal(cfg); err != nil {
return nil, err
}
+ normalizeLoadedConfig(cfg)
return cfg, nil
}
+
+func loadDotEnv(configPath string) {
+ if envFile := os.Getenv("AGENT_DESK_ENV_FILE"); envFile != "" {
+ _ = gotenv.Load(envFile)
+ return
+ }
+ if envFile := os.Getenv("ENV_FILE"); envFile != "" {
+ _ = gotenv.Load(envFile)
+ return
+ }
+ _ = gotenv.Load(".env")
+ if configPath != "" {
+ dir := filepath.Dir(configPath)
+ if dir != "." && dir != "" {
+ _ = gotenv.Load(filepath.Join(dir, ".env"))
+ }
+ }
+}
+
+func bindConfigDefaults(v *viper.Viper) {
+ v.SetDefault("language", "zh-CN")
+ v.SetDefault("server.port", 8083)
+ v.SetDefault("server.cors.allowedOrigins", []string{})
+ v.SetDefault("db.type", "sqlite")
+ v.SetDefault("db.dsn", "file:./data/app.db?_busy_timeout=5000")
+ v.SetDefault("db.maxIdleConns", 5)
+ v.SetDefault("db.maxOpenConns", 20)
+ v.SetDefault("db.connMaxIdleTimeSeconds", 300)
+ v.SetDefault("db.connMaxLifetimeSeconds", 1800)
+ v.SetDefault("logger.level", "info")
+ v.SetDefault("logger.format", "text")
+ v.SetDefault("logger.addSource", false)
+ v.SetDefault("auth.tokenTTLHours", 12)
+ v.SetDefault("auth.maxFailedAttempts", 5)
+ v.SetDefault("auth.credentialLockMinute", 15)
+ v.SetDefault("customerSession.ttlMinutes", 120)
+ v.SetDefault("customerSession.refreshThresholdMinutes", 30)
+ v.SetDefault("storage.default", "local")
+ v.SetDefault("storage.maxUploadSizeMB", 20)
+ v.SetDefault("storage.local.root", "data/storage")
+ v.SetDefault("storage.local.baseUrl", "/storage")
+ v.SetDefault("vectorDB.type", "qdrant")
+ v.SetDefault("vectorDB.qdrant.host", "127.0.0.1")
+ v.SetDefault("vectorDB.qdrant.grpcPort", 6334)
+ v.SetDefault("mcp.enabled", true)
+}
+
+func bindEnvironmentAliases(v *viper.Viper) {
+ _ = v.BindEnv("server.port", "PORT", "SERVER_PORT", "AGENT_DESK_SERVER_PORT")
+ _ = 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")
+}
+
+func normalizeLoadedConfig(cfg *Config) {
+ if cfg == nil {
+ return
+ }
+ if cfg.DB.Type == "sqlite" && (strings.HasPrefix(cfg.DB.DSN, "postgres://") || strings.HasPrefix(cfg.DB.DSN, "postgresql://")) {
+ cfg.DB.Type = "postgres"
+ } else if cfg.DB.Type == "sqlite" && strings.Contains(cfg.DB.DSN, "@tcp(") {
+ cfg.DB.Type = "mysql"
+ }
+}
diff --git a/internal/pkg/config/config_test.go b/internal/pkg/config/config_test.go
index 07c46d65..1f8b42cb 100644
--- a/internal/pkg/config/config_test.go
+++ b/internal/pkg/config/config_test.go
@@ -80,3 +80,71 @@ mcp:
t.Fatalf("MCP system endpoint=%q", cfg.MCP.Servers["system"].Endpoint)
}
}
+
+func TestLoadFromDotEnvAndStandardEnvAliases(t *testing.T) {
+ tempDir := t.TempDir()
+ envPath := filepath.Join(tempDir, ".env")
+ envContent := []byte(`PORT=9090
+DATABASE_URL=postgres://user:pass@localhost:5432/mydb?sslmode=disable
+PASSWORD_LOGIN_ENABLED=false
+JWT_SECRET=super-secret-key-12345
+QDRANT_HOST=10.0.0.5
+QDRANT_PORT=6334
+OIDC_ENABLED=true
+OIDC_ISSUER=https://auth.example.com
+OIDC_CLIENT_ID=client-123
+OIDC_CLIENT_SECRET=secret-456
+OIDC_REDIRECT_URL=https://desk.example.com/api/auth/oidc_callback
+ORG_SYNC_SECRET=webhook-secret-789
+`)
+ if err := os.WriteFile(envPath, envContent, 0600); err != nil {
+ t.Fatalf("WriteFile() error = %v", err)
+ }
+
+ t.Setenv("ENV_FILE", envPath)
+
+ cfg, err := Load("")
+ if err != nil {
+ t.Fatalf("Load() error = %v", err)
+ }
+
+ if cfg.Server.Port != 9090 {
+ t.Fatalf("Server.Port=%d want 9090", cfg.Server.Port)
+ }
+ if cfg.DB.Type != "postgres" {
+ t.Fatalf("DB.Type=%q want postgres", cfg.DB.Type)
+ }
+ if cfg.DB.DSN != "postgres://user:pass@localhost:5432/mydb?sslmode=disable" {
+ t.Fatalf("DB.DSN=%q", cfg.DB.DSN)
+ }
+ if cfg.Auth.IsPasswordLoginEnabled() {
+ t.Fatalf("expected PasswordLoginEnabled to be false")
+ }
+ if cfg.CustomerSession.Secret != "super-secret-key-12345" {
+ t.Fatalf("CustomerSession.Secret=%q", cfg.CustomerSession.Secret)
+ }
+ if cfg.VectorDB.Qdrant.Host != "10.0.0.5" {
+ t.Fatalf("Qdrant.Host=%q", cfg.VectorDB.Qdrant.Host)
+ }
+ if cfg.VectorDB.Qdrant.GrpcPort != 6334 {
+ t.Fatalf("Qdrant.GrpcPort=%d", cfg.VectorDB.Qdrant.GrpcPort)
+ }
+ if !cfg.OIDC.Enabled {
+ t.Fatalf("expected OIDC.Enabled=true")
+ }
+ if cfg.OIDC.Issuer != "https://auth.example.com" {
+ t.Fatalf("OIDC.Issuer=%q", cfg.OIDC.Issuer)
+ }
+ if cfg.OIDC.ClientID != "client-123" {
+ t.Fatalf("OIDC.ClientID=%q", cfg.OIDC.ClientID)
+ }
+ if cfg.OIDC.ClientSecret != "secret-456" {
+ t.Fatalf("OIDC.ClientSecret=%q", cfg.OIDC.ClientSecret)
+ }
+ if cfg.OIDC.RedirectURL != "https://desk.example.com/api/auth/oidc_callback" {
+ t.Fatalf("OIDC.RedirectURL=%q", cfg.OIDC.RedirectURL)
+ }
+ if cfg.Webhook.OrgSyncSecret != "webhook-secret-789" {
+ t.Fatalf("Webhook.OrgSyncSecret=%q", cfg.Webhook.OrgSyncSecret)
+ }
+}
From a0ab3f9b97f3dc0e10591179a836f66bb248c73f Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Sun, 23 Aug 2026 19:59:12 +0700
Subject: [PATCH 05/53] feat(tenant): add organization CRUD dialogs, member
management, and COMPANY_NAME config
---
internal/bootstrap/routes.go | 5 +
internal/bootstrap/server_route_test.go | 5 +
internal/handlers/api/auth_handler.go | 2 +
.../dashboard/organization_handler.go | 130 ++++++-
internal/pkg/config/config.go | 10 +-
.../pkg/dto/request/organization_request.go | 20 ++
internal/pkg/dto/response/auth_response.go | 2 +
.../pkg/dto/response/organization_response.go | 12 +
internal/services/organization_service.go | 339 ++++++++++++++++++
.../services/webhook_sync_service_test.go | 103 +++++-
web/components/login-form.tsx | 2 +-
web/components/organization-dialogs.tsx | 337 +++++++++++++++++
web/components/workspace-switcher.tsx | 223 ++++++++----
web/lib/api/config.ts | 2 +
web/lib/api/organization.ts | 46 +++
15 files changed, 1148 insertions(+), 90 deletions(-)
create mode 100644 web/components/organization-dialogs.tsx
diff --git a/internal/bootstrap/routes.go b/internal/bootstrap/routes.go
index 08bb4435..72d692ec 100644
--- a/internal/bootstrap/routes.go
+++ b/internal/bootstrap/routes.go
@@ -86,7 +86,12 @@ func registerDashboardUserRoutes(group *gin.RouterGroup) {
func registerDashboardOrganizationRoutes(group *gin.RouterGroup) {
group.GET("/my_list", dashboard.OrganizationUserList)
+ group.POST("/create", dashboard.OrganizationPostCreate)
group.POST("/switch", dashboard.OrganizationSwitch)
+ group.GET("/members", dashboard.OrganizationGetMembers)
+ group.POST("/add_member", dashboard.OrganizationPostAddMember)
+ group.POST("/remove_member", dashboard.OrganizationPostRemoveMember)
+ group.POST("/update", dashboard.OrganizationPostUpdate)
}
func registerDashboardCompanyRoutes(group *gin.RouterGroup) {
diff --git a/internal/bootstrap/server_route_test.go b/internal/bootstrap/server_route_test.go
index d64edb8c..ac4dee53 100644
--- a/internal/bootstrap/server_route_test.go
+++ b/internal/bootstrap/server_route_test.go
@@ -43,7 +43,12 @@ func TestNewServerRegistersGinRoutes(t *testing.T) {
http.MethodGet + " /api/auth/profile",
http.MethodPost + " /api/webhooks/org-sync",
http.MethodGet + " /api/dashboard/organization/my_list",
+ http.MethodPost + " /api/dashboard/organization/create",
http.MethodPost + " /api/dashboard/organization/switch",
+ http.MethodGet + " /api/dashboard/organization/members",
+ http.MethodPost + " /api/dashboard/organization/add_member",
+ http.MethodPost + " /api/dashboard/organization/remove_member",
+ http.MethodPost + " /api/dashboard/organization/update",
http.MethodGet + " /api/dashboard/user/list",
http.MethodGet + " /api/dashboard/user/:id",
http.MethodPost + " /api/dashboard/user/create",
diff --git a/internal/handlers/api/auth_handler.go b/internal/handlers/api/auth_handler.go
index 9e5e7c9b..c9ea341c 100644
--- a/internal/handlers/api/auth_handler.go
+++ b/internal/handlers/api/auth_handler.go
@@ -40,6 +40,8 @@ func PublicConfig(ctx *gin.Context) {
cfg := config.Current()
httpx.WriteJSON(ctx, &response.PublicConfigResponse{
Language: cfg.LanguageOrDefault(),
+ CompanyName: cfg.Server.CompanyName,
+ CompanyLogoURL: cfg.Server.CompanyLogoURL,
PasswordLoginEnabled: cfg.Auth.IsPasswordLoginEnabled(),
WxWorkEnabled: cfg.WxWork.Enabled,
OIDCEnabled: cfg.OIDC.Enabled,
diff --git a/internal/handlers/dashboard/organization_handler.go b/internal/handlers/dashboard/organization_handler.go
index 7267ac48..36ed67e1 100644
--- a/internal/handlers/dashboard/organization_handler.go
+++ b/internal/handlers/dashboard/organization_handler.go
@@ -2,6 +2,7 @@ package dashboard
import (
"agent-desk/internal/pkg/dto/request"
+ "agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/httpx"
"agent-desk/internal/pkg/httpx/params"
"agent-desk/internal/services"
@@ -24,10 +25,31 @@ func OrganizationUserList(ctx *gin.Context) {
httpx.WriteJSON(ctx, ret)
}
+func OrganizationPostCreate(ctx *gin.Context) {
+ principal := services.AuthService.GetAuthPrincipal(ctx)
+ if principal == nil {
+ httpx.WriteJSON(ctx, errorsx.UnauthorizedI18n("error.auth.expired"))
+ return
+ }
+
+ req := request.OrganizationCreateRequest{}
+ if err := params.ReadJSON(ctx, &req); err != nil {
+ httpx.WriteJSON(ctx, err)
+ return
+ }
+
+ ret, err := services.OrganizationService.CreateOrganization(principal.UserID, req)
+ if err != nil {
+ httpx.WriteJSON(ctx, err)
+ return
+ }
+ httpx.WriteJSON(ctx, ret)
+}
+
func OrganizationSwitch(ctx *gin.Context) {
principal := services.AuthService.GetAuthPrincipal(ctx)
if principal == nil {
- httpx.WriteJSON(ctx, nil)
+ httpx.WriteJSON(ctx, errorsx.UnauthorizedI18n("error.auth.expired"))
return
}
@@ -45,3 +67,109 @@ func OrganizationSwitch(ctx *gin.Context) {
httpx.WriteJSON(ctx, org)
}
+
+func OrganizationGetMembers(ctx *gin.Context) {
+ principal := services.AuthService.GetAuthPrincipal(ctx)
+ if principal == nil {
+ httpx.WriteJSON(ctx, errorsx.UnauthorizedI18n("error.auth.expired"))
+ return
+ }
+
+ activeOrg := services.OrganizationService.GetActiveOrganization(principal)
+ if activeOrg == nil {
+ httpx.WriteJSON(ctx, []any{})
+ return
+ }
+
+ members, err := services.OrganizationService.GetOrganizationMembers(principal.UserID, activeOrg.ID)
+ if err != nil {
+ httpx.WriteJSON(ctx, err)
+ return
+ }
+ httpx.WriteJSON(ctx, members)
+}
+
+func OrganizationPostAddMember(ctx *gin.Context) {
+ principal := services.AuthService.GetAuthPrincipal(ctx)
+ if principal == nil {
+ httpx.WriteJSON(ctx, errorsx.UnauthorizedI18n("error.auth.expired"))
+ return
+ }
+
+ activeOrg := services.OrganizationService.GetActiveOrganization(principal)
+ if activeOrg == nil {
+ httpx.WriteJSON(ctx, errorsx.InvalidParam("no active organization selected"))
+ return
+ }
+
+ req := request.OrganizationAddMemberRequest{}
+ if err := params.ReadJSON(ctx, &req); err != nil {
+ httpx.WriteJSON(ctx, err)
+ return
+ }
+
+ member, err := services.OrganizationService.AddMember(principal.UserID, activeOrg.ID, req)
+ if err != nil {
+ httpx.WriteJSON(ctx, err)
+ return
+ }
+ httpx.WriteJSON(ctx, member)
+}
+
+func OrganizationPostRemoveMember(ctx *gin.Context) {
+ principal := services.AuthService.GetAuthPrincipal(ctx)
+ if principal == nil {
+ httpx.WriteJSON(ctx, errorsx.UnauthorizedI18n("error.auth.expired"))
+ return
+ }
+
+ activeOrg := services.OrganizationService.GetActiveOrganization(principal)
+ if activeOrg == nil {
+ httpx.WriteJSON(ctx, errorsx.InvalidParam("no active organization selected"))
+ return
+ }
+
+ req := request.OrganizationRemoveMemberRequest{}
+ if err := params.ReadJSON(ctx, &req); err != nil {
+ httpx.WriteJSON(ctx, err)
+ return
+ }
+
+ if req.UserID <= 0 {
+ httpx.WriteJSON(ctx, errorsx.InvalidParam("invalid user id"))
+ return
+ }
+
+ if err := services.OrganizationService.RemoveMember(principal.UserID, activeOrg.ID, req.UserID); err != nil {
+ httpx.WriteJSON(ctx, err)
+ return
+ }
+ httpx.WriteJSON(ctx, gin.H{"success": true})
+}
+
+func OrganizationPostUpdate(ctx *gin.Context) {
+ principal := services.AuthService.GetAuthPrincipal(ctx)
+ if principal == nil {
+ httpx.WriteJSON(ctx, errorsx.UnauthorizedI18n("error.auth.expired"))
+ return
+ }
+
+ activeOrg := services.OrganizationService.GetActiveOrganization(principal)
+ if activeOrg == nil {
+ httpx.WriteJSON(ctx, errorsx.InvalidParam("no active organization selected"))
+ return
+ }
+
+ req := request.OrganizationUpdateRequest{}
+ if err := params.ReadJSON(ctx, &req); err != nil {
+ httpx.WriteJSON(ctx, err)
+ return
+ }
+
+ org, err := services.OrganizationService.UpdateOrganization(principal.UserID, activeOrg.ID, req)
+ if err != nil {
+ httpx.WriteJSON(ctx, err)
+ return
+ }
+ httpx.WriteJSON(ctx, org)
+}
diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go
index 236ad251..8de33eac 100644
--- a/internal/pkg/config/config.go
+++ b/internal/pkg/config/config.go
@@ -47,8 +47,10 @@ type WxWorkNotifyConfig struct {
}
type ServerConfig struct {
- Port int `yaml:"port"`
- CORS CORSConfig `yaml:"cors"`
+ Port int `yaml:"port"`
+ CompanyName string `yaml:"companyName"`
+ CompanyLogoURL string `yaml:"companyLogoUrl"`
+ CORS CORSConfig `yaml:"cors"`
}
func (s ServerConfig) Address() string {
@@ -282,6 +284,8 @@ func loadDotEnv(configPath string) {
func bindConfigDefaults(v *viper.Viper) {
v.SetDefault("language", "zh-CN")
v.SetDefault("server.port", 8083)
+ v.SetDefault("server.companyName", "")
+ v.SetDefault("server.companyLogoUrl", "")
v.SetDefault("server.cors.allowedOrigins", []string{})
v.SetDefault("db.type", "sqlite")
v.SetDefault("db.dsn", "file:./data/app.db?_busy_timeout=5000")
@@ -309,6 +313,8 @@ func bindConfigDefaults(v *viper.Viper) {
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")
diff --git a/internal/pkg/dto/request/organization_request.go b/internal/pkg/dto/request/organization_request.go
index 303c0432..1ffdec7b 100644
--- a/internal/pkg/dto/request/organization_request.go
+++ b/internal/pkg/dto/request/organization_request.go
@@ -3,3 +3,23 @@ package request
type OrganizationSwitchRequest struct {
OrganizationID int64 `json:"organizationId"`
}
+
+type OrganizationCreateRequest struct {
+ Name string `json:"name"`
+ Code string `json:"code"`
+ Logo string `json:"logo"`
+}
+
+type OrganizationUpdateRequest struct {
+ Name string `json:"name"`
+ Logo string `json:"logo"`
+}
+
+type OrganizationAddMemberRequest struct {
+ EmailOrUsername string `json:"emailOrUsername"`
+ Role string `json:"role"`
+}
+
+type OrganizationRemoveMemberRequest struct {
+ UserID int64 `json:"userId"`
+}
diff --git a/internal/pkg/dto/response/auth_response.go b/internal/pkg/dto/response/auth_response.go
index c4b023b4..07d05a7a 100644
--- a/internal/pkg/dto/response/auth_response.go
+++ b/internal/pkg/dto/response/auth_response.go
@@ -22,6 +22,8 @@ type LoginResponse struct {
type PublicConfigResponse struct {
Language string `json:"language"`
+ CompanyName string `json:"companyName,omitempty"`
+ CompanyLogoURL string `json:"companyLogoUrl,omitempty"`
PasswordLoginEnabled bool `json:"passwordLoginEnabled"`
WxWorkEnabled bool `json:"wxworkEnabled"`
OIDCEnabled bool `json:"oidcEnabled"`
diff --git a/internal/pkg/dto/response/organization_response.go b/internal/pkg/dto/response/organization_response.go
index 0b7e1543..d2f2f59f 100644
--- a/internal/pkg/dto/response/organization_response.go
+++ b/internal/pkg/dto/response/organization_response.go
@@ -17,6 +17,18 @@ type OrganizationResponse struct {
CreatedAt time.Time `json:"createdAt"`
}
+type OrganizationMemberResponse struct {
+ ID int64 `json:"id"`
+ UserID int64 `json:"userId"`
+ Username string `json:"username"`
+ Nickname string `json:"nickname"`
+ Email string `json:"email"`
+ Avatar string `json:"avatar"`
+ Role string `json:"role"`
+ Status int `json:"status"`
+ CreatedAt time.Time `json:"createdAt"`
+}
+
type UserOrganizationListResponse struct {
CurrentOrganizationID int64 `json:"currentOrganizationId"`
Organizations []OrganizationResponse `json:"organizations"`
diff --git a/internal/services/organization_service.go b/internal/services/organization_service.go
index 9ead70e9..de952513 100644
--- a/internal/services/organization_service.go
+++ b/internal/services/organization_service.go
@@ -3,10 +3,17 @@ package services
import (
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto"
+ "agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/repositories"
+ "crypto/rand"
+ "encoding/hex"
+ "fmt"
+ "strings"
+ "time"
+ "unicode"
"github.com/mlogclub/simple/sqls"
)
@@ -63,6 +70,90 @@ func (s *organizationService) GetUserOrganizations(userID int64) (*response.User
}, nil
}
+func (s *organizationService) CreateOrganization(userID int64, req request.OrganizationCreateRequest) (*response.OrganizationResponse, error) {
+ name := strings.TrimSpace(req.Name)
+ if name == "" {
+ return nil, errorsx.InvalidParam("organization name is required")
+ }
+
+ user := repositories.UserRepository.Get(sqls.DB(), userID)
+ if user == nil {
+ return nil, errorsx.InvalidAccountI18n("error.e0260")
+ }
+
+ code := strings.TrimSpace(req.Code)
+ if code == "" {
+ code = s.generateUniqueOrgCode(name)
+ }
+
+ var createdOrg *models.Organization
+ now := time.Now()
+
+ err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
+ if existing := repositories.OrganizationRepository.GetByCode(ctx.Tx, code); existing != nil {
+ return errorsx.InvalidParam("organization code already in use")
+ }
+
+ createdOrg = &models.Organization{
+ Code: code,
+ Name: name,
+ Logo: strings.TrimSpace(req.Logo),
+ Plan: "free",
+ Status: enums.StatusOk,
+ AuditFields: models.AuditFields{
+ CreatedAt: now,
+ CreateUserID: user.ID,
+ CreateUserName: user.Username,
+ UpdatedAt: now,
+ UpdateUserID: user.ID,
+ UpdateUserName: user.Username,
+ },
+ }
+
+ if err := repositories.OrganizationRepository.Create(ctx.Tx, createdOrg); err != nil {
+ return err
+ }
+
+ member := &models.OrganizationMember{
+ OrganizationID: createdOrg.ID,
+ UserID: user.ID,
+ Role: "OWNER",
+ Status: enums.StatusOk,
+ AuditFields: models.AuditFields{
+ CreatedAt: now,
+ CreateUserID: user.ID,
+ CreateUserName: user.Username,
+ UpdatedAt: now,
+ UpdateUserID: user.ID,
+ UpdateUserName: user.Username,
+ },
+ }
+
+ if err := repositories.OrganizationMemberRepository.Create(ctx.Tx, member); err != nil {
+ return err
+ }
+
+ _ = repositories.UserRepository.UpdateColumn(ctx.Tx, user.ID, "active_org_id", createdOrg.ID)
+ return nil
+ })
+
+ if err != nil {
+ return nil, err
+ }
+
+ return &response.OrganizationResponse{
+ ID: createdOrg.ID,
+ Code: createdOrg.Code,
+ Name: createdOrg.Name,
+ Logo: createdOrg.Logo,
+ Plan: createdOrg.Plan,
+ Status: createdOrg.Status,
+ Role: "OWNER",
+ IsActive: true,
+ CreatedAt: createdOrg.CreatedAt,
+ }, nil
+}
+
func (s *organizationService) SwitchActiveOrganization(userID int64, orgID int64) (*models.Organization, error) {
user := repositories.UserRepository.Get(sqls.DB(), userID)
if user == nil {
@@ -86,6 +177,221 @@ func (s *organizationService) SwitchActiveOrganization(userID int64, orgID int64
return org, nil
}
+func (s *organizationService) GetOrganizationMembers(currentUserID int64, orgID int64) ([]response.OrganizationMemberResponse, error) {
+ member := repositories.OrganizationMemberRepository.GetByOrgAndUser(sqls.DB(), orgID, currentUserID)
+ if member == nil || member.Status != enums.StatusOk {
+ return nil, errorsx.ForbiddenI18n("error.e0225")
+ }
+
+ memberships := repositories.OrganizationMemberRepository.Find(sqls.DB(), sqls.NewCnd().Eq("organization_id", orgID).Eq("status", enums.StatusOk))
+ if len(memberships) == 0 {
+ return []response.OrganizationMemberResponse{}, nil
+ }
+
+ userIDs := make([]int64, 0, len(memberships))
+ for _, m := range memberships {
+ userIDs = append(userIDs, m.UserID)
+ }
+
+ users := repositories.UserRepository.Find(sqls.DB(), sqls.NewCnd().In("id", userIDs))
+ userMap := make(map[int64]models.User, len(users))
+ for _, u := range users {
+ userMap[u.ID] = u
+ }
+
+ res := make([]response.OrganizationMemberResponse, 0, len(memberships))
+ for _, m := range memberships {
+ u := userMap[m.UserID]
+ email := ""
+ if u.Email != nil {
+ email = *u.Email
+ }
+ res = append(res, response.OrganizationMemberResponse{
+ ID: m.ID,
+ UserID: m.UserID,
+ Username: u.Username,
+ Nickname: u.Nickname,
+ Email: email,
+ Avatar: u.Avatar,
+ Role: m.Role,
+ Status: int(m.Status),
+ CreatedAt: m.CreatedAt,
+ })
+ }
+
+ return res, nil
+}
+
+func (s *organizationService) AddMember(currentUserID int64, orgID int64, req request.OrganizationAddMemberRequest) (*response.OrganizationMemberResponse, error) {
+ currentMember := repositories.OrganizationMemberRepository.GetByOrgAndUser(sqls.DB(), orgID, currentUserID)
+ if currentMember == nil || currentMember.Status != enums.StatusOk || (currentMember.Role != "OWNER" && currentMember.Role != "ADMIN") {
+ return nil, errorsx.ForbiddenI18n("error.e0225")
+ }
+
+ query := strings.TrimSpace(req.EmailOrUsername)
+ if query == "" {
+ return nil, errorsx.InvalidParam("email or username is required")
+ }
+
+ targetUser := repositories.UserRepository.GetByUsername(sqls.DB(), query)
+ if targetUser == nil {
+ targetUser = repositories.UserRepository.GetByEmail(sqls.DB(), strings.ToLower(query))
+ }
+ if targetUser == nil {
+ return nil, errorsx.InvalidParam("user not found")
+ }
+
+ role := strings.ToUpper(strings.TrimSpace(req.Role))
+ if role != "ADMIN" && role != "MEMBER" {
+ role = "MEMBER"
+ }
+
+ now := time.Now()
+ var member *models.OrganizationMember
+
+ err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
+ existing := repositories.OrganizationMemberRepository.GetByOrgAndUser(ctx.Tx, orgID, targetUser.ID)
+ if existing == nil {
+ member = &models.OrganizationMember{
+ OrganizationID: orgID,
+ UserID: targetUser.ID,
+ Role: role,
+ Status: enums.StatusOk,
+ AuditFields: models.AuditFields{
+ CreatedAt: now,
+ CreateUserID: currentUserID,
+ CreateUserName: "",
+ UpdatedAt: now,
+ UpdateUserID: currentUserID,
+ UpdateUserName: "",
+ },
+ }
+ if err := repositories.OrganizationMemberRepository.Create(ctx.Tx, member); err != nil {
+ return err
+ }
+ } else {
+ member = existing
+ member.Role = role
+ member.Status = enums.StatusOk
+ _ = repositories.OrganizationMemberRepository.Updates(ctx.Tx, member.ID, map[string]any{
+ "role": role,
+ "status": enums.StatusOk,
+ "update_user_id": currentUserID,
+ "update_user_name": "",
+ "updated_at": now,
+ })
+ }
+
+ if targetUser.ActiveOrgID == 0 {
+ _ = repositories.UserRepository.UpdateColumn(ctx.Tx, targetUser.ID, "active_org_id", orgID)
+ }
+ return nil
+ })
+
+ if err != nil {
+ return nil, err
+ }
+
+ email := ""
+ if targetUser.Email != nil {
+ email = *targetUser.Email
+ }
+
+ return &response.OrganizationMemberResponse{
+ ID: member.ID,
+ UserID: targetUser.ID,
+ Username: targetUser.Username,
+ Nickname: targetUser.Nickname,
+ Email: email,
+ Avatar: targetUser.Avatar,
+ Role: member.Role,
+ Status: int(member.Status),
+ CreatedAt: member.CreatedAt,
+ }, nil
+}
+
+func (s *organizationService) RemoveMember(currentUserID int64, orgID int64, targetUserID int64) error {
+ currentMember := repositories.OrganizationMemberRepository.GetByOrgAndUser(sqls.DB(), orgID, currentUserID)
+ if currentMember == nil || currentMember.Status != enums.StatusOk {
+ return errorsx.ForbiddenI18n("error.e0225")
+ }
+
+ if currentUserID != targetUserID && currentMember.Role != "OWNER" && currentMember.Role != "ADMIN" {
+ return errorsx.ForbiddenI18n("error.e0225")
+ }
+
+ targetMember := repositories.OrganizationMemberRepository.GetByOrgAndUser(sqls.DB(), orgID, targetUserID)
+ if targetMember == nil || targetMember.Status != enums.StatusOk {
+ return errorsx.InvalidParam("member not found in organization")
+ }
+
+ if targetMember.Role == "OWNER" {
+ owners := repositories.OrganizationMemberRepository.Find(sqls.DB(), sqls.NewCnd().Eq("organization_id", orgID).Eq("role", "OWNER").Eq("status", enums.StatusOk))
+ if len(owners) <= 1 {
+ return errorsx.InvalidParam("cannot remove the only owner of the organization")
+ }
+ }
+
+ return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
+ if err := repositories.OrganizationMemberRepository.UpdateColumn(ctx.Tx, targetMember.ID, "status", enums.StatusDeleted); err != nil {
+ return err
+ }
+
+ targetUser := repositories.UserRepository.Get(ctx.Tx, targetUserID)
+ if targetUser != nil && targetUser.ActiveOrgID == orgID {
+ remaining := repositories.OrganizationMemberRepository.Find(ctx.Tx, sqls.NewCnd().Eq("user_id", targetUserID).Eq("status", enums.StatusOk).Where("organization_id <> ?", orgID))
+ var newActiveOrgID int64 = 0
+ if len(remaining) > 0 {
+ newActiveOrgID = remaining[0].OrganizationID
+ }
+ _ = repositories.UserRepository.UpdateColumn(ctx.Tx, targetUserID, "active_org_id", newActiveOrgID)
+ }
+ return nil
+ })
+}
+
+func (s *organizationService) UpdateOrganization(currentUserID int64, orgID int64, req request.OrganizationUpdateRequest) (*response.OrganizationResponse, error) {
+ currentMember := repositories.OrganizationMemberRepository.GetByOrgAndUser(sqls.DB(), orgID, currentUserID)
+ if currentMember == nil || currentMember.Status != enums.StatusOk || (currentMember.Role != "OWNER" && currentMember.Role != "ADMIN") {
+ return nil, errorsx.ForbiddenI18n("error.e0225")
+ }
+
+ name := strings.TrimSpace(req.Name)
+ if name == "" {
+ return nil, errorsx.InvalidParam("organization name cannot be empty")
+ }
+
+ org := repositories.OrganizationRepository.Get(sqls.DB(), orgID)
+ if org == nil || org.Status != enums.StatusOk {
+ return nil, errorsx.InvalidParam("organization not found")
+ }
+
+ updates := map[string]any{
+ "name": name,
+ "logo": strings.TrimSpace(req.Logo),
+ "update_user_id": currentUserID,
+ "update_user_name": "",
+ "updated_at": time.Now(),
+ }
+
+ if err := repositories.OrganizationRepository.Updates(sqls.DB(), orgID, updates); err != nil {
+ return nil, err
+ }
+
+ org = repositories.OrganizationRepository.Get(sqls.DB(), orgID)
+ return &response.OrganizationResponse{
+ ID: org.ID,
+ Code: org.Code,
+ Name: org.Name,
+ Logo: org.Logo,
+ Plan: org.Plan,
+ Status: org.Status,
+ Role: currentMember.Role,
+ IsActive: true,
+ CreatedAt: org.CreatedAt,
+ }, nil
+}
+
func (s *organizationService) GetActiveOrganization(principal *dto.AuthPrincipal) *models.Organization {
if principal == nil || principal.UserID <= 0 {
return nil
@@ -96,3 +402,36 @@ func (s *organizationService) GetActiveOrganization(principal *dto.AuthPrincipal
}
return repositories.OrganizationRepository.Get(sqls.DB(), user.ActiveOrgID)
}
+
+func (s *organizationService) generateUniqueOrgCode(name string) string {
+ var b strings.Builder
+ for _, r := range strings.ToLower(name) {
+ if unicode.IsLetter(r) || unicode.IsDigit(r) {
+ b.WriteRune(r)
+ } else if b.Len() > 0 && !strings.HasSuffix(b.String(), "-") {
+ b.WriteString("-")
+ }
+ }
+ slug := strings.Trim(b.String(), "-")
+ if slug == "" {
+ slug = "org"
+ }
+ if len(slug) > 30 {
+ slug = slug[:30]
+ }
+
+ if repositories.OrganizationRepository.GetByCode(sqls.DB(), slug) == nil {
+ return slug
+ }
+
+ for i := 1; i < 100; i++ {
+ candidate := fmt.Sprintf("%s-%d", slug, i)
+ if repositories.OrganizationRepository.GetByCode(sqls.DB(), candidate) == nil {
+ return candidate
+ }
+ }
+
+ randBuf := make([]byte, 4)
+ _, _ = rand.Read(randBuf)
+ return fmt.Sprintf("%s-%s", slug, hex.EncodeToString(randBuf))
+}
diff --git a/internal/services/webhook_sync_service_test.go b/internal/services/webhook_sync_service_test.go
index d7f30267..0c776f38 100644
--- a/internal/services/webhook_sync_service_test.go
+++ b/internal/services/webhook_sync_service_test.go
@@ -14,17 +14,17 @@ func TestWebhookDOSOrgSync_OrgEvents(t *testing.T) {
svc := newWebhookSyncService()
// 1. Test org.created
- err := svc.HandleDOSOrgSync(request.DOSOrgSyncWebhookRequest{
+ err := svc.HandleOrgSync(request.OrgSyncWebhookRequest{
Event: "org.created",
Timestamp: time.Now().Format(time.RFC3339),
- Data: request.DOSOrgSyncEventData{
+ Data: request.OrgSyncEventData{
OrgID: "org_tingee_001",
OrgName: "Tingee Corporation",
Plan: "pro",
},
})
if err != nil {
- t.Fatalf("HandleDOSOrgSync org.created failed: %v", err)
+ t.Fatalf("HandleOrgSync org.created failed: %v", err)
}
org := repositories.OrganizationRepository.GetByCode(db, "org_tingee_001")
@@ -33,17 +33,17 @@ func TestWebhookDOSOrgSync_OrgEvents(t *testing.T) {
}
// 2. Test org.updated
- err = svc.HandleDOSOrgSync(request.DOSOrgSyncWebhookRequest{
+ err = svc.HandleOrgSync(request.OrgSyncWebhookRequest{
Event: "org.updated",
Timestamp: time.Now().Format(time.RFC3339),
- Data: request.DOSOrgSyncEventData{
+ Data: request.OrgSyncEventData{
OrgID: "org_tingee_001",
OrgName: "Tingee Global Corp",
Plan: "enterprise",
},
})
if err != nil {
- t.Fatalf("HandleDOSOrgSync org.updated failed: %v", err)
+ t.Fatalf("HandleOrgSync org.updated failed: %v", err)
}
org = repositories.OrganizationRepository.GetByCode(db, "org_tingee_001")
@@ -52,15 +52,15 @@ func TestWebhookDOSOrgSync_OrgEvents(t *testing.T) {
}
// 3. Test org.deleted
- err = svc.HandleDOSOrgSync(request.DOSOrgSyncWebhookRequest{
+ err = svc.HandleOrgSync(request.OrgSyncWebhookRequest{
Event: "org.deleted",
Timestamp: time.Now().Format(time.RFC3339),
- Data: request.DOSOrgSyncEventData{
+ Data: request.OrgSyncEventData{
OrgID: "org_tingee_001",
},
})
if err != nil {
- t.Fatalf("HandleDOSOrgSync org.deleted failed: %v", err)
+ t.Fatalf("HandleOrgSync org.deleted failed: %v", err)
}
org = repositories.OrganizationRepository.GetByCode(db, "org_tingee_001")
@@ -74,10 +74,10 @@ func TestWebhookDOSOrgSync_MemberEvents(t *testing.T) {
svc := newWebhookSyncService()
// 1. Test org.member_added
- err := svc.HandleDOSOrgSync(request.DOSOrgSyncWebhookRequest{
+ err := svc.HandleOrgSync(request.OrgSyncWebhookRequest{
Event: "org.member_added",
Timestamp: time.Now().Format(time.RFC3339),
- Data: request.DOSOrgSyncEventData{
+ Data: request.OrgSyncEventData{
OrgID: "org_tingee_002",
OrgName: "Tingee R&D",
UserID: "usr_dos_001",
@@ -87,7 +87,7 @@ func TestWebhookDOSOrgSync_MemberEvents(t *testing.T) {
},
})
if err != nil {
- t.Fatalf("HandleDOSOrgSync org.member_added failed: %v", err)
+ t.Fatalf("HandleOrgSync org.member_added failed: %v", err)
}
org := repositories.OrganizationRepository.GetByCode(db, "org_tingee_002")
@@ -110,17 +110,17 @@ func TestWebhookDOSOrgSync_MemberEvents(t *testing.T) {
}
// 2. Test org.member_removed
- err = svc.HandleDOSOrgSync(request.DOSOrgSyncWebhookRequest{
+ err = svc.HandleOrgSync(request.OrgSyncWebhookRequest{
Event: "org.member_removed",
Timestamp: time.Now().Format(time.RFC3339),
- Data: request.DOSOrgSyncEventData{
+ Data: request.OrgSyncEventData{
OrgID: "org_tingee_002",
UserID: "usr_dos_001",
UserEmail: "member@tingee.com",
},
})
if err != nil {
- t.Fatalf("HandleDOSOrgSync org.member_removed failed: %v", err)
+ t.Fatalf("HandleOrgSync org.member_removed failed: %v", err)
}
member = repositories.OrganizationMemberRepository.GetByOrgAndUser(db, org.ID, user.ID)
@@ -176,3 +176,76 @@ func TestOrganizationService_SwitchAndList(t *testing.T) {
t.Fatalf("expected active_org_id %d, got %d", org2.ID, updatedUser.ActiveOrgID)
}
}
+
+func TestOrganizationService_CreateAndManageMembers(t *testing.T) {
+ db := setupAuthServiceTestDB(t)
+ owner := createAuthTestUser(t, db, "owner_user", "secret")
+ memberUser := createAuthTestUser(t, db, "member_user", "secret")
+ email := "member_user@example.com"
+ _ = repositories.UserRepository.UpdateColumn(db, memberUser.ID, "email", email)
+
+ // 1. Create Organization
+ created, err := OrganizationService.CreateOrganization(owner.ID, request.OrganizationCreateRequest{
+ Name: "Acme Support Org",
+ Code: "acme-support",
+ })
+ if err != nil {
+ t.Fatalf("CreateOrganization failed: %v", err)
+ }
+ if created == nil || created.Name != "Acme Support Org" || created.Role != "OWNER" {
+ t.Fatalf("unexpected created org: %+v", created)
+ }
+
+ updatedOwner := repositories.UserRepository.Get(db, owner.ID)
+ if updatedOwner.ActiveOrgID != created.ID {
+ t.Fatalf("expected owner active_org_id = %d, got %d", created.ID, updatedOwner.ActiveOrgID)
+ }
+
+ // 2. Get Members
+ members, err := OrganizationService.GetOrganizationMembers(owner.ID, created.ID)
+ if err != nil {
+ t.Fatalf("GetOrganizationMembers failed: %v", err)
+ }
+ if len(members) != 1 || members[0].UserID != owner.ID || members[0].Role != "OWNER" {
+ t.Fatalf("unexpected members: %+v", members)
+ }
+
+ // 3. Add Member by email
+ added, err := OrganizationService.AddMember(owner.ID, created.ID, request.OrganizationAddMemberRequest{
+ EmailOrUsername: "member_user@example.com",
+ Role: "ADMIN",
+ })
+ if err != nil {
+ t.Fatalf("AddMember failed: %v", err)
+ }
+ if added == nil || added.UserID != memberUser.ID || added.Role != "ADMIN" {
+ t.Fatalf("unexpected added member: %+v", added)
+ }
+
+ members, _ = OrganizationService.GetOrganizationMembers(owner.ID, created.ID)
+ if len(members) != 2 {
+ t.Fatalf("expected 2 members, got %d", len(members))
+ }
+
+ // 4. Update Organization
+ updatedOrg, err := OrganizationService.UpdateOrganization(owner.ID, created.ID, request.OrganizationUpdateRequest{
+ Name: "Acme Global Support",
+ })
+ if err != nil {
+ t.Fatalf("UpdateOrganization failed: %v", err)
+ }
+ if updatedOrg.Name != "Acme Global Support" {
+ t.Fatalf("expected name to be Acme Global Support, got %s", updatedOrg.Name)
+ }
+
+ // 5. Remove Member
+ err = OrganizationService.RemoveMember(owner.ID, created.ID, memberUser.ID)
+ if err != nil {
+ t.Fatalf("RemoveMember failed: %v", err)
+ }
+
+ members, _ = OrganizationService.GetOrganizationMembers(owner.ID, created.ID)
+ if len(members) != 1 {
+ t.Fatalf("expected 1 member after removal, got %d", len(members))
+ }
+}
diff --git a/web/components/login-form.tsx b/web/components/login-form.tsx
index 71f98b55..0b13c96a 100644
--- a/web/components/login-form.tsx
+++ b/web/components/login-form.tsx
@@ -163,7 +163,7 @@ export function LoginForm({
{t("auth.welcome")}
- {t("auth.loginDescription", { brand: t("app.brand") })}
+ {t("auth.loginDescription", { brand: publicConfig?.companyName || t("app.brand") })}
{isPasswordLoginEnabled ? (
diff --git a/web/components/organization-dialogs.tsx b/web/components/organization-dialogs.tsx
new file mode 100644
index 00000000..240904c1
--- /dev/null
+++ b/web/components/organization-dialogs.tsx
@@ -0,0 +1,337 @@
+"use client"
+
+import { Building2Icon, Loader2Icon, PlusIcon, Trash2Icon, UserPlusIcon, UsersIcon } from "lucide-react"
+import { useEffect, useState } from "react"
+import { toast } from "sonner"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
+import { Input } from "@/components/ui/input"
+import {
+ addOrganizationMember,
+ createOrganization,
+ getOrganizationMembers,
+ removeOrganizationMember,
+ updateOrganization,
+ type OrganizationItem,
+ type OrganizationMemberItem,
+} from "@/lib/api/organization"
+
+export function CreateOrganizationDialog({
+ open,
+ onOpenChange,
+ onCreated,
+}: {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onCreated: (org: OrganizationItem) => void
+}) {
+ const [name, setName] = useState("")
+ const [code, setCode] = useState("")
+ const [isPending, setIsPending] = useState(false)
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ if (!name.trim()) {
+ toast.error("Organization name is required")
+ return
+ }
+ setIsPending(true)
+ try {
+ const org = await createOrganization({
+ name: name.trim(),
+ code: code.trim() || undefined,
+ })
+ toast.success(`Organization "${org.name}" created successfully`)
+ setName("")
+ setCode("")
+ onOpenChange(false)
+ onCreated(org)
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "Failed to create organization")
+ } finally {
+ setIsPending(false)
+ }
+ }
+
+ return (
+
+ )
+}
+
+export function ManageOrganizationDialog({
+ open,
+ onOpenChange,
+ organization,
+ onUpdated,
+}: {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ organization: OrganizationItem | null
+ onUpdated?: () => void
+}) {
+ const [members, setMembers] = useState([])
+ const [loadingMembers, setLoadingMembers] = useState(false)
+ const [emailOrUsername, setEmailOrUsername] = useState("")
+ const [role, setRole] = useState("MEMBER")
+ const [isAdding, setIsAdding] = useState(false)
+ const [orgName, setOrgName] = useState("")
+ const [isUpdatingName, setIsUpdatingName] = useState(false)
+
+ useEffect(() => {
+ if (open && organization) {
+ setOrgName(organization.name)
+ setLoadingMembers(true)
+ getOrganizationMembers()
+ .then((res) => {
+ setMembers(res || [])
+ })
+ .catch(() => {
+ setMembers([])
+ })
+ .finally(() => {
+ setLoadingMembers(false)
+ })
+ }
+ }, [open, organization])
+
+ const handleUpdateName = async (e: React.FormEvent) => {
+ e.preventDefault()
+ if (!orgName.trim() || orgName.trim() === organization?.name) return
+ setIsUpdatingName(true)
+ try {
+ await updateOrganization({ name: orgName.trim() })
+ toast.success("Organization name updated")
+ onUpdated?.()
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "Failed to update name")
+ } finally {
+ setIsUpdatingName(false)
+ }
+ }
+
+ const handleAddMember = async (e: React.FormEvent) => {
+ e.preventDefault()
+ if (!emailOrUsername.trim()) return
+ setIsAdding(true)
+ try {
+ const added = await addOrganizationMember({
+ emailOrUsername: emailOrUsername.trim(),
+ role,
+ })
+ setMembers((prev) => {
+ const filtered = prev.filter((m) => m.userId !== added.userId)
+ return [...filtered, added]
+ })
+ setEmailOrUsername("")
+ toast.success(`Member added successfully`)
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "Failed to add member")
+ } finally {
+ setIsAdding(false)
+ }
+ }
+
+ const handleRemoveMember = async (userId: number, username: string) => {
+ if (!confirm(`Are you sure you want to remove ${username}?`)) return
+ try {
+ await removeOrganizationMember(userId)
+ setMembers((prev) => prev.filter((m) => m.userId !== userId))
+ toast.success(`Member removed`)
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "Failed to remove member")
+ }
+ }
+
+ if (!organization) return null
+
+ const isOwnerOrAdmin = organization.role === "OWNER" || organization.role === "ADMIN"
+
+ return (
+
+ )
+}
diff --git a/web/components/workspace-switcher.tsx b/web/components/workspace-switcher.tsx
index 441e87a9..d31167d9 100644
--- a/web/components/workspace-switcher.tsx
+++ b/web/components/workspace-switcher.tsx
@@ -1,11 +1,21 @@
"use client"
-import { Building2Icon, CheckIcon, ChevronsUpDownIcon, LayoutDashboardIcon, WrenchIcon } from "lucide-react"
+import {
+ Building2Icon,
+ CheckIcon,
+ ChevronsUpDownIcon,
+ LayoutDashboardIcon,
+ PlusIcon,
+ SettingsIcon,
+ WrenchIcon,
+} from "lucide-react"
import Link from "next/link"
import { useEffect, useState, type ReactElement } from "react"
import { toast } from "sonner"
+import { CreateOrganizationDialog, ManageOrganizationDialog } from "@/components/organization-dialogs"
import { useI18n } from "@/i18n/provider"
+import { fetchPublicConfig, type PublicConfig } from "@/lib/api/config"
import { listMyOrganizations, switchOrganization, type OrganizationItem } from "@/lib/api/organization"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
@@ -60,17 +70,29 @@ export function WorkspaceSwitcher({
const [orgs, setOrgs] = useState([])
const [activeOrgId, setActiveOrgId] = useState(null)
const [switching, setSwitching] = useState(false)
+ const [publicConfig, setPublicConfig] = useState(null)
+ const [createDialogOpen, setCreateDialogOpen] = useState(false)
+ const [manageDialogOpen, setManageDialogOpen] = useState(false)
- useEffect(() => {
- let mounted = true
+ const loadOrgs = () => {
listMyOrganizations()
.then((res) => {
- if (mounted && res?.organizations) {
+ if (res?.organizations) {
setOrgs(res.organizations)
setActiveOrgId(res.currentOrganizationId || res.organizations[0]?.id || null)
}
})
.catch(() => {})
+ }
+
+ useEffect(() => {
+ let mounted = true
+ loadOrgs()
+ fetchPublicConfig()
+ .then((cfg) => {
+ if (mounted) setPublicConfig(cfg)
+ })
+ .catch(() => {})
return () => {
mounted = false
}
@@ -80,6 +102,9 @@ export function WorkspaceSwitcher({
workspaceOptions.find((item) => item.key === currentWorkspace) ?? workspaceOptions[0]
const currentOrg = orgs.find((o) => o.id === activeOrgId) || orgs[0]
+ const brandName = publicConfig?.companyName || t("app.brand")
+ const brandLogo = publicConfig?.companyLogoUrl || "/images/logo.svg"
+
const handleSwitchOrg = async (orgId: number) => {
if (orgId === activeOrgId || switching) return
setSwitching(true)
@@ -113,28 +138,28 @@ export function WorkspaceSwitcher({
variant === "rail" ? (
<>
- {currentOrg?.name || t("app.brand")} - {t(currentOption.labelKey)}
+ {currentOrg?.name || brandName} - {t(currentOption.labelKey)}
>
) : (
<>
-
{currentOrg?.name || t("app.brand")}
+
{currentOrg?.name || brandName}
{t(currentOption.labelKey)} {currentOrg?.role ? `• ${currentOrg.role}` : ""}
@@ -149,67 +174,123 @@ export function WorkspaceSwitcher({
)
return (
-
-
- }
- >
- {triggerContent}
-
-
- {orgs.length > 0 ? (
- <>
-
-
-
- Organizations / Workspaces
-
- {orgs.map((org) => {
- const isActive = org.id === activeOrgId
- return (
- handleSwitchOrg(org.id)}
- >
-
- {org.name}
-
- {org.role || "Member"} {org.plan ? `• ${org.plan}` : ""}
-
-
- {isActive ? : null}
-
- )
- })}
-
-
- >
- ) : null}
+ <>
+
+
+ }
+ >
+ {triggerContent}
+
+
+ {orgs.length > 0 ? (
+ <>
+
+
+
+
+ Organizations / Workspaces
+
+ {currentOrg ? (
+
+ ) : null}
+
+ {orgs.map((org) => {
+ const isActive = org.id === activeOrgId
+ return (
+ handleSwitchOrg(org.id)}
+ >
+
+ {org.name}
+
+ {org.role || "Member"} {org.plan ? `• ${org.plan}` : ""}
+
+
+ {isActive ? : null}
+
+ )
+ })}
+ setCreateDialogOpen(true)}
+ >
+
+ Create Organization
+
+
+
+ >
+ ) : (
+ <>
+
+ setCreateDialogOpen(true)}
+ >
+
+ Create Organization
+
+
+
+ >
+ )}
+
+
+ {t("workspace.switchWorkspace")}
+ {workspaceOptions.map((item) => (
+ }
+ className="cursor-pointer gap-2"
+ >
+
+ {t(item.labelKey)}
+ {item.key === currentWorkspace ? (
+
+ ) : null}
+
+ ))}
+
+
+
+
+ {
+ loadOrgs()
+ setActiveOrgId(newOrg.id)
+ window.location.reload()
+ }}
+ />
-
- {t("workspace.switchWorkspace")}
- {workspaceOptions.map((item) => (
- }
- className="cursor-pointer gap-2"
- >
-
- {t(item.labelKey)}
- {item.key === currentWorkspace ? (
-
- ) : null}
-
- ))}
-
-
-
+
{
+ loadOrgs()
+ }}
+ />
+ >
)
}
diff --git a/web/lib/api/config.ts b/web/lib/api/config.ts
index 2fe187e2..088677fb 100644
--- a/web/lib/api/config.ts
+++ b/web/lib/api/config.ts
@@ -2,6 +2,8 @@ import { request } from "@/lib/api/client"
export type PublicConfig = {
language: string
+ companyName?: string
+ companyLogoUrl?: string
passwordLoginEnabled?: boolean
wxworkEnabled: boolean
oidcEnabled: boolean
diff --git a/web/lib/api/organization.ts b/web/lib/api/organization.ts
index 285b8f74..025c2a2c 100644
--- a/web/lib/api/organization.ts
+++ b/web/lib/api/organization.ts
@@ -12,6 +12,18 @@ export type OrganizationItem = {
createdAt: string
}
+export type OrganizationMemberItem = {
+ id: number
+ userId: number
+ username: string
+ nickname: string
+ email: string
+ avatar: string
+ role: string
+ status: number
+ createdAt: string
+}
+
export type UserOrganizationListResponse = {
currentOrganizationId: number
organizations: OrganizationItem[]
@@ -23,9 +35,43 @@ export async function listMyOrganizations() {
})
}
+export async function createOrganization(data: { name: string; code?: string; logo?: string }) {
+ return request("/api/dashboard/organization/create", {
+ method: "POST",
+ body: JSON.stringify(data),
+ })
+}
+
export async function switchOrganization(organizationId: number) {
return request("/api/dashboard/organization/switch", {
method: "POST",
body: JSON.stringify({ organizationId }),
})
}
+
+export async function getOrganizationMembers() {
+ return request("/api/dashboard/organization/members", {
+ method: "GET",
+ })
+}
+
+export async function addOrganizationMember(data: { emailOrUsername: string; role?: string }) {
+ return request("/api/dashboard/organization/add_member", {
+ method: "POST",
+ body: JSON.stringify(data),
+ })
+}
+
+export async function removeOrganizationMember(userId: number) {
+ return request<{ success: boolean }>("/api/dashboard/organization/remove_member", {
+ method: "POST",
+ body: JSON.stringify({ userId }),
+ })
+}
+
+export async function updateOrganization(data: { name: string; logo?: string }) {
+ return request("/api/dashboard/organization/update", {
+ method: "POST",
+ body: JSON.stringify(data),
+ })
+}
From 1ab6565e13f94d7813a478aacf62d99b6d5eb087 Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Sun, 23 Aug 2026 21:22:59 +0700
Subject: [PATCH 06/53] fix(ui): redirect to /dashboard upon creating or
switching organization
---
web/components/workspace-switcher.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/web/components/workspace-switcher.tsx b/web/components/workspace-switcher.tsx
index d31167d9..9ed0f353 100644
--- a/web/components/workspace-switcher.tsx
+++ b/web/components/workspace-switcher.tsx
@@ -112,7 +112,7 @@ export function WorkspaceSwitcher({
await switchOrganization(orgId)
setActiveOrgId(orgId)
toast.success("Switched organization successfully")
- window.location.reload()
+ window.location.href = "/dashboard"
} catch {
toast.error("Failed to switch organization")
} finally {
@@ -279,7 +279,7 @@ export function WorkspaceSwitcher({
onCreated={(newOrg) => {
loadOrgs()
setActiveOrgId(newOrg.id)
- window.location.reload()
+ window.location.href = "/dashboard"
}}
/>
From 0160433be9f87a650258c29626ddd4b3f8cdfd8e Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Mon, 24 Aug 2026 18:20:42 +0700
Subject: [PATCH 07/53] feat(sync): add outbound organization webhook sync
dispatcher
---
internal/pkg/config/config.go | 2 +
internal/pkg/config/runtime.go | 4 ++
internal/services/organization_service.go | 60 ++++++++++++++++-
internal/services/webhook_sync_service.go | 79 ++++++++++++++++++++++-
4 files changed, 141 insertions(+), 4 deletions(-)
diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go
index 8de33eac..7fac1214 100644
--- a/internal/pkg/config/config.go
+++ b/internal/pkg/config/config.go
@@ -228,6 +228,7 @@ type WxWorkConfig struct {
type WebhookConfig struct {
OrgSyncSecret string `yaml:"orgSyncSecret"`
DOSOrgSyncSecret string `yaml:"dosOrgSyncSecret"`
+ OutboundURL string `yaml:"outboundUrl"`
}
func Load(path string) (*Config, error) {
@@ -333,6 +334,7 @@ func bindEnvironmentAliases(v *viper.Viper) {
_ = 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")
+ _ = v.BindEnv("webhook.outboundUrl", "ORG_SYNC_OUTBOUND_URL", "DOS_ORG_SYNC_URL", "WEBHOOK_OUTBOUND_URL", "AGENT_DESK_WEBHOOK_OUTBOUNDURL")
}
func normalizeLoadedConfig(cfg *Config) {
diff --git a/internal/pkg/config/runtime.go b/internal/pkg/config/runtime.go
index 18a8afa9..6badbf58 100644
--- a/internal/pkg/config/runtime.go
+++ b/internal/pkg/config/runtime.go
@@ -6,6 +6,10 @@ func SetCurrent(cfg *Config) {
current = cfg
}
+func GetCurrent() *Config {
+ return current
+}
+
func Current() Config {
if current == nil {
panic("config not initialized")
diff --git a/internal/services/organization_service.go b/internal/services/organization_service.go
index de952513..c50cf604 100644
--- a/internal/services/organization_service.go
+++ b/internal/services/organization_service.go
@@ -141,6 +141,20 @@ func (s *organizationService) CreateOrganization(userID int64, req request.Organ
return nil, err
}
+ userEmail := ""
+ if user.Email != nil {
+ userEmail = *user.Email
+ }
+ WebhookSyncService.DispatchOutboundOrgEvent("org.created", request.OrgSyncEventData{
+ OrgID: createdOrg.Code,
+ OrgName: createdOrg.Name,
+ UserID: user.Username,
+ UserEmail: userEmail,
+ UserName: user.Nickname,
+ Role: "OWNER",
+ Plan: createdOrg.Plan,
+ })
+
return &response.OrganizationResponse{
ID: createdOrg.ID,
Code: createdOrg.Code,
@@ -292,11 +306,24 @@ func (s *organizationService) AddMember(currentUserID int64, orgID int64, req re
return nil, err
}
+ orgCode := ""
+ if targetOrg := repositories.OrganizationRepository.Get(sqls.DB(), orgID); targetOrg != nil {
+ orgCode = targetOrg.Code
+ }
+
email := ""
if targetUser.Email != nil {
email = *targetUser.Email
}
+ WebhookSyncService.DispatchOutboundOrgEvent("org.member_added", request.OrgSyncEventData{
+ OrgID: orgCode,
+ UserID: targetUser.Username,
+ UserEmail: email,
+ UserName: targetUser.Nickname,
+ Role: member.Role,
+ })
+
return &response.OrganizationMemberResponse{
ID: member.ID,
UserID: targetUser.ID,
@@ -332,7 +359,7 @@ func (s *organizationService) RemoveMember(currentUserID int64, orgID int64, tar
}
}
- return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
+ err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
if err := repositories.OrganizationMemberRepository.UpdateColumn(ctx.Tx, targetMember.ID, "status", enums.StatusDeleted); err != nil {
return err
}
@@ -348,6 +375,30 @@ func (s *organizationService) RemoveMember(currentUserID int64, orgID int64, tar
}
return nil
})
+
+ if err != nil {
+ return err
+ }
+
+ orgCode := ""
+ if targetOrg := repositories.OrganizationRepository.Get(sqls.DB(), orgID); targetOrg != nil {
+ orgCode = targetOrg.Code
+ }
+ email := ""
+ username := ""
+ if targetUser := repositories.UserRepository.Get(sqls.DB(), targetUserID); targetUser != nil {
+ username = targetUser.Username
+ if targetUser.Email != nil {
+ email = *targetUser.Email
+ }
+ }
+ WebhookSyncService.DispatchOutboundOrgEvent("org.member_removed", request.OrgSyncEventData{
+ OrgID: orgCode,
+ UserID: username,
+ UserEmail: email,
+ })
+
+ return nil
}
func (s *organizationService) UpdateOrganization(currentUserID int64, orgID int64, req request.OrganizationUpdateRequest) (*response.OrganizationResponse, error) {
@@ -379,6 +430,13 @@ func (s *organizationService) UpdateOrganization(currentUserID int64, orgID int6
}
org = repositories.OrganizationRepository.Get(sqls.DB(), orgID)
+
+ WebhookSyncService.DispatchOutboundOrgEvent("org.updated", request.OrgSyncEventData{
+ OrgID: org.Code,
+ OrgName: org.Name,
+ Plan: org.Plan,
+ })
+
return &response.OrganizationResponse{
ID: org.ID,
Code: org.Code,
diff --git a/internal/services/webhook_sync_service.go b/internal/services/webhook_sync_service.go
index cc6436ac..061ccd26 100644
--- a/internal/services/webhook_sync_service.go
+++ b/internal/services/webhook_sync_service.go
@@ -7,9 +7,13 @@ import (
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/repositories"
+ "bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
+ "encoding/json"
+ "log/slog"
+ "net/http"
"strings"
"time"
@@ -25,12 +29,16 @@ func newWebhookSyncService() *webhookSyncService {
type webhookSyncService struct{}
func (s *webhookSyncService) VerifySignature(payload []byte, signature string) bool {
- secret := strings.TrimSpace(config.Current().Webhook.OrgSyncSecret)
+ cfg := config.GetCurrent()
+ if cfg == nil {
+ return true
+ }
+ secret := strings.TrimSpace(cfg.Webhook.OrgSyncSecret)
if secret == "" {
- secret = strings.TrimSpace(config.Current().Webhook.DOSOrgSyncSecret)
+ secret = strings.TrimSpace(cfg.Webhook.DOSOrgSyncSecret)
}
if secret == "" {
- secret = strings.TrimSpace(config.Current().OIDC.ClientSecret)
+ secret = strings.TrimSpace(cfg.OIDC.ClientSecret)
}
if secret == "" {
return true
@@ -78,6 +86,71 @@ func (s *webhookSyncService) HandleDOSOrgSync(req request.DOSOrgSyncWebhookReque
return s.HandleOrgSync(req)
}
+func (s *webhookSyncService) DispatchOutboundOrgEvent(event string, data request.OrgSyncEventData) {
+ cfg := config.GetCurrent()
+ if cfg == nil {
+ return
+ }
+ outboundURL := strings.TrimSpace(cfg.Webhook.OutboundURL)
+ if outboundURL == "" {
+ return
+ }
+
+ payload := request.OrgSyncWebhookRequest{
+ Event: event,
+ Timestamp: time.Now().UTC().Format(time.RFC3339),
+ Data: data,
+ }
+
+ bodyBytes, err := json.Marshal(payload)
+ if err != nil {
+ slog.Error("failed to marshal outbound org event", "event", event, "error", err)
+ return
+ }
+
+ secret := strings.TrimSpace(cfg.Webhook.OrgSyncSecret)
+ if secret == "" {
+ secret = strings.TrimSpace(cfg.Webhook.DOSOrgSyncSecret)
+ }
+ if secret == "" {
+ secret = strings.TrimSpace(cfg.OIDC.ClientSecret)
+ }
+
+ var signature string
+ if secret != "" {
+ mac := hmac.New(sha256.New, []byte(secret))
+ mac.Write(bodyBytes)
+ signature = "sha256=" + hex.EncodeToString(mac.Sum(nil))
+ }
+
+ go func() {
+ client := &http.Client{Timeout: 10 * time.Second}
+ req, err := http.NewRequest(http.MethodPost, outboundURL, bytes.NewBuffer(bodyBytes))
+ if err != nil {
+ slog.Error("failed to create outbound org sync request", "url", outboundURL, "error", err)
+ return
+ }
+ req.Header.Set("Content-Type", "application/json")
+ if signature != "" {
+ req.Header.Set("X-DOS-Signature", signature)
+ req.Header.Set("X-Webhook-Signature", signature)
+ }
+
+ resp, err := client.Do(req)
+ if err != nil {
+ slog.Error("failed to dispatch outbound org sync event", "event", event, "url", outboundURL, "error", err)
+ return
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode >= 400 {
+ slog.Warn("outbound org sync event returned non-2xx status", "event", event, "status", resp.StatusCode)
+ } else {
+ slog.Info("outbound org sync event dispatched successfully", "event", event, "orgId", data.OrgID)
+ }
+ }()
+}
+
func (s *webhookSyncService) handleOrgUpsert(data request.OrgSyncEventData) error {
now := time.Now()
orgCode := strings.TrimSpace(data.OrgID)
From 0e3b82d9a3cb8c92f3f6131041df174562057585 Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Mon, 24 Aug 2026 20:29:07 +0700
Subject: [PATCH 08/53] style(ui): fix font-sans resolution by loading Inter
font and setting proper font fallbacks
---
web/app/(dashboard)/dashboard.css | 14 +++++++++-----
web/app/(dashboard)/layout.tsx | 11 ++++++-----
web/app/(support)/layout.tsx | 15 ++++++++-------
web/app/(support)/support.css | 16 ++++++++++------
4 files changed, 33 insertions(+), 23 deletions(-)
diff --git a/web/app/(dashboard)/dashboard.css b/web/app/(dashboard)/dashboard.css
index c3f4cb55..8a22a690 100644
--- a/web/app/(dashboard)/dashboard.css
+++ b/web/app/(dashboard)/dashboard.css
@@ -7,8 +7,8 @@
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
- --font-sans: var(--font-sans);
- --font-mono: var(--font-geist-mono);
+ --font-sans: var(--font-inter), var(--font-geist-sans), -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+ --font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -359,10 +359,14 @@
* {
@apply border-border outline-ring/50;
}
+ html {
+ font-family: var(--font-inter), var(--font-geist-sans), -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ text-rendering: optimizeLegibility;
+ }
body {
@apply bg-background text-foreground;
- }
- html {
- @apply font-sans;
+ font-feature-settings: "cv02", "cv03", "cv04", "cv11";
}
}
diff --git a/web/app/(dashboard)/layout.tsx b/web/app/(dashboard)/layout.tsx
index 8ca0a3d7..9fd18915 100644
--- a/web/app/(dashboard)/layout.tsx
+++ b/web/app/(dashboard)/layout.tsx
@@ -1,5 +1,5 @@
import type { Metadata } from "next"
-import { Geist, Geist_Mono } from "next/font/google"
+import { Inter, Geist_Mono } from "next/font/google"
import { AuthProvider } from "@/components/auth-provider"
import { ApiErrorProvider } from "@/components/api-error-provider"
@@ -14,9 +14,10 @@ import "./dashboard.css"
import "md-editor-rt/lib/style.css"
import "@/styles/main.scss"
-const geistSans = Geist({
- variable: "--font-geist-sans",
- subsets: ["latin"],
+const inter = Inter({
+ variable: "--font-inter",
+ subsets: ["latin", "vietnamese"],
+ display: "swap",
})
const geistMono = Geist_Mono({
@@ -46,7 +47,7 @@ export default function DashboardRootLayout({
return (
diff --git a/web/app/(support)/layout.tsx b/web/app/(support)/layout.tsx
index d1d96fd2..8ed09164 100644
--- a/web/app/(support)/layout.tsx
+++ b/web/app/(support)/layout.tsx
@@ -1,5 +1,5 @@
import type { Metadata } from "next"
-import { Geist, Geist_Mono } from "next/font/google"
+import { Inter, Geist_Mono } from "next/font/google"
import { ImageLightboxProvider } from "@/components/image-lightbox"
import { ConfirmProvider } from "@/components/confirm-provider"
@@ -12,9 +12,10 @@ import { AppI18nProvider } from "@/i18n/provider"
import "./support.css"
import "md-editor-rt/lib/style.css"
-const geistSans = Geist({
- variable: "--font-geist-sans",
- subsets: ["latin"],
+const inter = Inter({
+ variable: "--font-inter",
+ subsets: ["latin", "vietnamese"],
+ display: "swap",
})
const geistMono = Geist_Mono({
@@ -23,8 +24,8 @@ const geistMono = Geist_Mono({
})
export const metadata: Metadata = {
- title: "AgentDesk Support",
- description: "AgentDesk Support Center",
+ title: "Crove Desk Support",
+ description: "Crove Desk Support Center",
}
export default function SupportRootLayout({
@@ -35,7 +36,7 @@ export default function SupportRootLayout({
return (
diff --git a/web/app/(support)/support.css b/web/app/(support)/support.css
index 71b841ae..3f894bf1 100644
--- a/web/app/(support)/support.css
+++ b/web/app/(support)/support.css
@@ -8,8 +8,8 @@
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
- --font-sans: var(--font-geist-sans);
- --font-mono: var(--font-geist-mono);
+ --font-sans: var(--font-inter), var(--font-geist-sans), -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+ --font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
@@ -86,12 +86,16 @@
@apply border-border outline-ring/50;
}
- body {
- @apply bg-background text-foreground;
+ html {
+ font-family: var(--font-inter), var(--font-geist-sans), -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ text-rendering: optimizeLegibility;
}
- html {
- @apply font-sans;
+ body {
+ @apply bg-background text-foreground;
+ font-feature-settings: "cv02", "cv03", "cv04", "cv11";
}
}
From 4e04a2ee2b15dd59c8689693a641fbed88aa4ee9 Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Mon, 24 Aug 2026 22:04:32 +0700
Subject: [PATCH 09/53] feat(auth): support active_org_id and organization slug
claims in OIDC profile
---
internal/oidcclient/oidcclient.go | 15 ++++++++++++---
internal/services/oidc_login_service.go | 2 +-
2 files changed, 13 insertions(+), 4 deletions(-)
diff --git a/internal/oidcclient/oidcclient.go b/internal/oidcclient/oidcclient.go
index 2ba71bde..391d21fb 100644
--- a/internal/oidcclient/oidcclient.go
+++ b/internal/oidcclient/oidcclient.go
@@ -37,6 +37,7 @@ var (
type OrganizationClaim struct {
ID string `json:"id"`
+ Slug string `json:"slug,omitempty"`
Name string `json:"name"`
Role string `json:"role"`
}
@@ -47,6 +48,7 @@ type Profile struct {
PreferredUsername string `json:"preferred_username,omitempty"`
Name string `json:"name,omitempty"`
Picture string `json:"picture,omitempty"`
+ ActiveOrgID string `json:"active_org_id,omitempty"`
Organizations []OrganizationClaim `json:"organizations,omitempty"`
RawProfile string `json:"-"`
}
@@ -102,7 +104,7 @@ func Init(ctx context.Context) error {
}
scopes := cfg.Scopes
if len(scopes) == 0 {
- scopes = []string{gooidc.ScopeOpenID, "profile", "email"}
+ scopes = []string{gooidc.ScopeOpenID, "profile", "email", "offline_access"}
}
provider = p
oauthConfig = &oauth2.Config{
@@ -298,6 +300,7 @@ func profileFromIDToken(idToken *gooidc.IDToken) (*Profile, error) {
PreferredUsername: firstNonEmpty(claimString(claims, "preferred_username"), claimString(claims, "user_name"), claimString(claims, "nickname")),
Name: firstNonEmpty(claimString(claims, "name"), claimString(claims, "full_name")),
Picture: firstNonEmpty(claimString(claims, "picture"), claimString(claims, "avatar_url")),
+ ActiveOrgID: firstNonEmpty(claimString(claims, "active_org_id"), claimString(claims, "activeOrgId")),
Organizations: claimOrganizations(claims),
RawProfile: string(raw),
}
@@ -319,6 +322,7 @@ func profileFromUserInfo(userInfo *gooidc.UserInfo, fallback *Profile) (*Profile
PreferredUsername: firstNonEmpty(claimString(claims, "preferred_username"), claimString(claims, "user_name"), claimString(claims, "nickname")),
Name: firstNonEmpty(claimString(claims, "name"), claimString(claims, "full_name")),
Picture: firstNonEmpty(claimString(claims, "picture"), claimString(claims, "avatar_url")),
+ ActiveOrgID: firstNonEmpty(claimString(claims, "active_org_id"), claimString(claims, "activeOrgId")),
Organizations: claimOrganizations(claims),
RawProfile: string(raw),
}
@@ -327,6 +331,9 @@ func profileFromUserInfo(userInfo *gooidc.UserInfo, fallback *Profile) (*Profile
profile.PreferredUsername = firstNonEmpty(profile.PreferredUsername, fallback.PreferredUsername)
profile.Name = firstNonEmpty(profile.Name, fallback.Name)
profile.Picture = firstNonEmpty(profile.Picture, fallback.Picture)
+ if profile.ActiveOrgID == "" {
+ profile.ActiveOrgID = fallback.ActiveOrgID
+ }
if len(profile.Organizations) == 0 {
profile.Organizations = fallback.Organizations
}
@@ -363,12 +370,14 @@ func claimOrganizations(claims map[string]any) []OrganizationClaim {
var list []map[string]any
if err := json.Unmarshal(bytes, &list); err == nil {
for _, item := range list {
- id := firstNonEmpty(claimString(item, "id"), claimString(item, "org_id"), claimString(item, "code"))
- name := firstNonEmpty(claimString(item, "name"), claimString(item, "org_name"), id)
+ id := firstNonEmpty(claimString(item, "id"), claimString(item, "org_id"), claimString(item, "slug"), claimString(item, "code"))
+ slug := claimString(item, "slug")
+ name := firstNonEmpty(claimString(item, "name"), claimString(item, "org_name"), slug, id)
role := firstNonEmpty(claimString(item, "role"), "MEMBER")
if id != "" {
orgs = append(orgs, OrganizationClaim{
ID: id,
+ Slug: slug,
Name: name,
Role: strings.ToUpper(role),
})
diff --git a/internal/services/oidc_login_service.go b/internal/services/oidc_login_service.go
index 9827c4d7..fcb88080 100644
--- a/internal/services/oidc_login_service.go
+++ b/internal/services/oidc_login_service.go
@@ -359,7 +359,7 @@ func (s *oidcLoginService) syncOIDCUserOrganizations(tx *gorm.DB, user *models.U
})
}
- if activeOrgID == 0 {
+ if activeOrgID == 0 || (profile.ActiveOrgID != "" && (profile.ActiveOrgID == orgCode || profile.ActiveOrgID == orgClaim.Slug)) {
activeOrgID = org.ID
}
}
From 243b13366a2d7640b143334d558779fcf53b400f Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Tue, 25 Aug 2026 11:07:40 +0700
Subject: [PATCH 10/53] feat(ai): support OpenAI-compatible API config via
environment variables and dynamic branding
- Add AIConfig environment variable bindings (OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_LLM_MODEL, OPENAI_EMBEDDING_MODEL, etc.)
- Auto-bootstrap and sync LLM and Embedding models to database on startup
- Support dynamic COMPANY_NAME and COMPANY_LOGO_URL in legal document page and support center header
- Add unit tests for AI config initialization and env parsing
Co-authored-by: Cursor
---
.env.example | 15 +-
config/config.example.yaml | 21 +++
internal/bootstrap/ai.go | 155 ++++++++++++++++++
internal/bootstrap/ai_test.go | 72 ++++++++
internal/bootstrap/init.go | 3 +
internal/pkg/config/config.go | 28 ++++
internal/pkg/config/config_test.go | 28 ++++
web/components/legal-document-page.tsx | 25 ++-
.../support-center/support-header.tsx | 20 ++-
9 files changed, 360 insertions(+), 7 deletions(-)
create mode 100644 internal/bootstrap/ai.go
create mode 100644 internal/bootstrap/ai_test.go
diff --git a/.env.example b/.env.example
index d553e0ee..b1a10ddf 100644
--- a/.env.example
+++ b/.env.example
@@ -3,8 +3,10 @@
# Copy this file to .env and adjust the configuration values as needed.
# ==============================================================================
-# Server Configuration
+# Server & Branding Configuration
PORT=8083
+# COMPANY_NAME="AgentDesk"
+# COMPANY_LOGO_URL="/images/logo.svg"
# AGENT_DESK_SERVER_CORS_ALLOWEDORIGINS="http://localhost:3000,http://127.0.0.1:8083"
# Database Configuration
@@ -32,7 +34,16 @@ QDRANT_HOST=127.0.0.1
QDRANT_GRPC_PORT=6334
# QDRANT_API_KEY=
-# Single Sign-On (OIDC / OAuth 2.0)
+# AI Models & OpenAI-Compatible Providers (OpenAI, DeepSeek, OpenRouter, Azure, Ollama, LiteLLM, vLLM, etc.)
+# OPENAI_API_KEY=sk-your-api-key
+# OPENAI_BASE_URL=https://api.openai.com/v1
+# OPENAI_LLM_MODEL=gpt-4o-mini
+# OPENAI_EMBEDDING_MODEL=text-embedding-3-small
+# OPENAI_EMBEDDING_DIMENSION=1536
+# AI_TIMEOUT_MS=30000
+# AI_MAX_RETRY_COUNT=1
+
+# Single Sign-On (OIDC / OAuth 2.1 with PKCE)
# OIDC_ENABLED=true
# OIDC_ISSUER=https://auth.example.com
# OIDC_CLIENT_ID=your-client-id
diff --git a/config/config.example.yaml b/config/config.example.yaml
index 94bcbb1d..a16d3051 100644
--- a/config/config.example.yaml
+++ b/config/config.example.yaml
@@ -2,6 +2,9 @@ language: zh-CN
server:
port: 8083
+ # Company branding
+ companyName: ""
+ companyLogoUrl: ""
cors:
# Browser CORS allowlist. In production, replace this with the actual frontend or embedded-site domains, such as https://support.example.com.
# Leave it empty to reject cross-origin browser requests. Same-origin and non-browser calls are still supported.
@@ -96,6 +99,24 @@ vectorDB:
lancedb:
path: data/lancedb
+ai:
+ # Provider. Default is openai (OpenAI-compatible client used for all providers).
+ provider: openai
+ # Base URL for OpenAI-compatible API (e.g. OpenAI, DeepSeek, OpenRouter, Azure, Ollama, LiteLLM, vLLM).
+ baseUrl: https://api.openai.com/v1
+ # API key for the AI service.
+ apiKey: ""
+ # Default LLM model name.
+ llmModel: gpt-4o-mini
+ # Default Embedding model name.
+ embeddingModel: text-embedding-3-small
+ # Dimension for embedding model (e.g. 1536 for text-embedding-3-small).
+ embeddingDimension: 1536
+ # Timeout in milliseconds.
+ timeoutMs: 30000
+ # Maximum retry attempts.
+ maxRetryCount: 1
+
mcp:
# Global switch for MCP tool integration.
# When false, MCP tool catalog, debug endpoints, and runtime tool calls are disabled.
diff --git a/internal/bootstrap/ai.go b/internal/bootstrap/ai.go
new file mode 100644
index 00000000..071a6c19
--- /dev/null
+++ b/internal/bootstrap/ai.go
@@ -0,0 +1,155 @@
+package bootstrap
+
+import (
+ "agent-desk/internal/models"
+ "agent-desk/internal/pkg/config"
+ "agent-desk/internal/pkg/constants"
+ "agent-desk/internal/pkg/enums"
+ "agent-desk/internal/repositories"
+ "log/slog"
+ "strings"
+ "time"
+
+ "github.com/mlogclub/simple/sqls"
+ "gorm.io/gorm"
+)
+
+// InitAI bootstraps or syncs default AI configurations from environment/config file.
+// Supports any OpenAI-compatible provider (OpenAI, DeepSeek, OpenRouter, Azure, Ollama, LiteLLM, vLLM, etc.).
+func InitAI(cfg *config.Config) error {
+ if cfg == nil {
+ return nil
+ }
+ apiKey := strings.TrimSpace(cfg.AI.APIKey)
+ if apiKey == "" {
+ return nil
+ }
+
+ provider := enums.AIProvider(strings.TrimSpace(cfg.AI.Provider))
+ if provider == "" {
+ provider = enums.AIProviderOpenAI
+ }
+
+ baseURL := strings.TrimSpace(cfg.AI.BaseURL)
+ if baseURL == "" {
+ baseURL = "https://api.openai.com/v1"
+ }
+
+ llmModel := strings.TrimSpace(cfg.AI.LLMModel)
+ if llmModel == "" {
+ llmModel = "gpt-4o-mini"
+ }
+
+ embeddingModel := strings.TrimSpace(cfg.AI.EmbeddingModel)
+ if embeddingModel == "" {
+ embeddingModel = "text-embedding-3-small"
+ }
+
+ dimension := cfg.AI.EmbeddingDimension
+ if dimension <= 0 {
+ dimension = 1536
+ }
+
+ timeoutMS := cfg.AI.TimeoutMS
+ if timeoutMS <= 0 {
+ timeoutMS = 30000
+ }
+
+ maxRetryCount := cfg.AI.MaxRetryCount
+ if maxRetryCount < 0 {
+ maxRetryCount = 1
+ }
+
+ db := sqls.DB()
+ if db == nil {
+ return nil
+ }
+
+ // 1. Ensure LLM config
+ llmItem := models.AIConfig{
+ Name: "Default LLM",
+ Provider: provider,
+ BaseURL: baseURL,
+ APIKey: apiKey,
+ ModelType: enums.AIModelTypeLLM,
+ ModelName: llmModel,
+ Dimension: 0,
+ MaxContextTokens: 128000,
+ MaxOutputTokens: 4096,
+ TimeoutMS: timeoutMS,
+ MaxRetryCount: maxRetryCount,
+ Status: enums.StatusOk,
+ SortNo: 10,
+ Remark: "Auto-configured from environment variables",
+ }
+ if err := upsertBootstrapAIConfig(db, llmItem); err != nil {
+ slog.Error("failed to bootstrap LLM AI config", "error", err)
+ } else {
+ slog.Info("bootstrapped LLM AI config", "model", llmModel, "baseUrl", baseURL)
+ }
+
+ // 2. Ensure Embedding config
+ embeddingItem := models.AIConfig{
+ Name: "Default Embedding",
+ Provider: provider,
+ BaseURL: baseURL,
+ APIKey: apiKey,
+ ModelType: enums.AIModelTypeEmbedding,
+ ModelName: embeddingModel,
+ Dimension: dimension,
+ MaxContextTokens: 8191,
+ MaxOutputTokens: 0,
+ TimeoutMS: timeoutMS,
+ MaxRetryCount: maxRetryCount,
+ Status: enums.StatusOk,
+ SortNo: 20,
+ Remark: "Auto-configured from environment variables",
+ }
+ if err := upsertBootstrapAIConfig(db, embeddingItem); err != nil {
+ slog.Error("failed to bootstrap Embedding AI config", "error", err)
+ } else {
+ slog.Info("bootstrapped Embedding AI config", "model", embeddingModel, "dimension", dimension, "baseUrl", baseURL)
+ }
+
+ return nil
+}
+
+func upsertBootstrapAIConfig(db *gorm.DB, item models.AIConfig) error {
+ now := time.Now()
+ // Find if there's any config with the same model type and name
+ existing := repositories.AIConfigRepository.FindOne(db, sqls.NewCnd().
+ Eq("model_type", item.ModelType).
+ Eq("name", item.Name))
+
+ if existing == nil {
+ // Also check if there is an active config of this model type
+ existing = repositories.AIConfigRepository.GetEnabled(db, item.ModelType)
+ }
+
+ if existing == nil {
+ item.AuditFields = models.AuditFields{
+ CreatedAt: now,
+ CreateUserID: constants.SystemAuditUserID,
+ CreateUserName: constants.SystemAuditUserName,
+ UpdatedAt: now,
+ UpdateUserID: constants.SystemAuditUserID,
+ UpdateUserName: constants.SystemAuditUserName,
+ }
+ return repositories.AIConfigRepository.Create(db, &item)
+ }
+
+ // If existing config exists, update connection & model details to match .env
+ return repositories.AIConfigRepository.Updates(db, existing.ID, map[string]any{
+ "provider": item.Provider,
+ "base_url": item.BaseURL,
+ "api_key": item.APIKey,
+ "model_name": item.ModelName,
+ "dimension": item.Dimension,
+ "timeout_ms": item.TimeoutMS,
+ "max_retry_count": item.MaxRetryCount,
+ "status": enums.StatusOk,
+ "update_user_id": constants.SystemAuditUserID,
+ "update_user_name": constants.SystemAuditUserName,
+ "updated_at": now,
+ })
+}
diff --git a/internal/bootstrap/ai_test.go b/internal/bootstrap/ai_test.go
new file mode 100644
index 00000000..71b55850
--- /dev/null
+++ b/internal/bootstrap/ai_test.go
@@ -0,0 +1,72 @@
+package bootstrap
+
+import (
+ "agent-desk/internal/models"
+ "agent-desk/internal/pkg/config"
+ "agent-desk/internal/pkg/enums"
+ "agent-desk/internal/repositories"
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/glebarez/sqlite"
+ "github.com/mlogclub/simple/sqls"
+ "gorm.io/gorm"
+)
+
+func setupTestDBForAI(t *testing.T) *gorm.DB {
+ dbName := fmt.Sprintf("file:memdb_%d?mode=memory&cache=shared", time.Now().UnixNano())
+ db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{})
+ if err != nil {
+ t.Fatalf("failed to open sqlite memory db: %v", err)
+ }
+ if err := db.AutoMigrate(models.AIConfig{}); err != nil {
+ t.Fatalf("failed to auto migrate: %v", err)
+ }
+ sqls.SetDB(db)
+ return db
+}
+
+func TestInitAI(t *testing.T) {
+ db := setupTestDBForAI(t)
+
+ cfg := &config.Config{
+ AI: config.AIConfig{
+ Provider: "openai",
+ BaseURL: "https://api.deepseek.com/v1",
+ APIKey: "sk-test-deepseek-key",
+ LLMModel: "deepseek-chat",
+ EmbeddingModel: "text-embedding-3-small",
+ EmbeddingDimension: 1536,
+ },
+ }
+
+ if err := InitAI(cfg); err != nil {
+ t.Fatalf("InitAI failed: %v", err)
+ }
+
+ llm := repositories.AIConfigRepository.GetEnabled(db, enums.AIModelTypeLLM)
+ if llm == nil {
+ t.Fatalf("expected enabled LLM config, got nil")
+ }
+ if llm.BaseURL != "https://api.deepseek.com/v1" {
+ t.Errorf("LLM BaseURL = %q, want https://api.deepseek.com/v1", llm.BaseURL)
+ }
+ if llm.ModelName != "deepseek-chat" {
+ t.Errorf("LLM ModelName = %q, want deepseek-chat", llm.ModelName)
+ }
+ if llm.APIKey != "sk-test-deepseek-key" {
+ t.Errorf("LLM APIKey = %q, want sk-test-deepseek-key", llm.APIKey)
+ }
+
+ embedding := repositories.AIConfigRepository.GetEnabled(db, enums.AIModelTypeEmbedding)
+ if embedding == nil {
+ t.Fatalf("expected enabled Embedding config, got nil")
+ }
+ if embedding.ModelName != "text-embedding-3-small" {
+ t.Errorf("Embedding ModelName = %q, want text-embedding-3-small", embedding.ModelName)
+ }
+ if embedding.Dimension != 1536 {
+ t.Errorf("Embedding Dimension = %d, want 1536", embedding.Dimension)
+ }
+}
diff --git a/internal/bootstrap/init.go b/internal/bootstrap/init.go
index b3648e12..2455dded 100644
--- a/internal/bootstrap/init.go
+++ b/internal/bootstrap/init.go
@@ -41,6 +41,9 @@ func Init(configPath string) error {
slog.Error("init vector db failed", "error", err)
return err
}
+ if err := InitAI(cfg); err != nil {
+ slog.Warn("init AI config failed", "error", err)
+ }
// 启动任务调度器
cronx.Init()
diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go
index 7fac1214..91bc3adc 100644
--- a/internal/pkg/config/config.go
+++ b/internal/pkg/config/config.go
@@ -20,6 +20,7 @@ type Config struct {
Auth AuthConfig `yaml:"auth"`
Storage StorageConfig `yaml:"storage"`
VectorDB VectorDBConfig `yaml:"vectorDB"`
+ AI AIConfig `yaml:"ai"`
MCP MCPConfig `yaml:"mcp"`
WxWork WxWorkConfig `yaml:"wxWork"`
OIDC OIDCConfig `yaml:"oidc"`
@@ -155,6 +156,17 @@ type VectorDBConfig struct {
LanceDB LanceDBVectorDBConfig `yaml:"lancedb"`
}
+type AIConfig struct {
+ Provider string `yaml:"provider"`
+ BaseURL string `yaml:"baseUrl"`
+ APIKey string `yaml:"apiKey"`
+ LLMModel string `yaml:"llmModel"`
+ EmbeddingModel string `yaml:"embeddingModel"`
+ EmbeddingDimension int `yaml:"embeddingDimension"`
+ TimeoutMS int `yaml:"timeoutMs"`
+ MaxRetryCount int `yaml:"maxRetryCount"`
+}
+
type QdrantVectorDBConfig struct {
Host string `yaml:"host"`
GrpcPort int `yaml:"grpcPort"`
@@ -309,6 +321,14 @@ func bindConfigDefaults(v *viper.Viper) {
v.SetDefault("vectorDB.type", "qdrant")
v.SetDefault("vectorDB.qdrant.host", "127.0.0.1")
v.SetDefault("vectorDB.qdrant.grpcPort", 6334)
+ v.SetDefault("ai.provider", "openai")
+ v.SetDefault("ai.baseUrl", "https://api.openai.com/v1")
+ v.SetDefault("ai.apiKey", "")
+ v.SetDefault("ai.llmModel", "gpt-4o-mini")
+ v.SetDefault("ai.embeddingModel", "text-embedding-3-small")
+ v.SetDefault("ai.embeddingDimension", 1536)
+ v.SetDefault("ai.timeoutMs", 30000)
+ v.SetDefault("ai.maxRetryCount", 1)
v.SetDefault("mcp.enabled", true)
}
@@ -328,6 +348,14 @@ func bindEnvironmentAliases(v *viper.Viper) {
_ = 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("ai.provider", "AI_PROVIDER", "OPENAI_PROVIDER", "AGENT_DESK_AI_PROVIDER")
+ _ = v.BindEnv("ai.baseUrl", "AI_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE", "AGENT_DESK_AI_BASEURL")
+ _ = v.BindEnv("ai.apiKey", "AI_API_KEY", "OPENAI_API_KEY", "AGENT_DESK_AI_APIKEY")
+ _ = v.BindEnv("ai.llmModel", "AI_LLM_MODEL", "OPENAI_LLM_MODEL", "OPENAI_MODEL", "LLM_MODEL", "AGENT_DESK_AI_LLMMODEL")
+ _ = v.BindEnv("ai.embeddingModel", "AI_EMBEDDING_MODEL", "OPENAI_EMBEDDING_MODEL", "EMBEDDING_MODEL", "AGENT_DESK_AI_EMBEDDINGMODEL")
+ _ = v.BindEnv("ai.embeddingDimension", "AI_EMBEDDING_DIMENSION", "OPENAI_EMBEDDING_DIMENSION", "EMBEDDING_DIMENSION", "AGENT_DESK_AI_EMBEDDINGDIMENSION")
+ _ = v.BindEnv("ai.timeoutMs", "AI_TIMEOUT_MS", "OPENAI_TIMEOUT_MS", "AGENT_DESK_AI_TIMEOUTMS")
+ _ = v.BindEnv("ai.maxRetryCount", "AI_MAX_RETRY_COUNT", "AGENT_DESK_AI_MAXRETRYCOUNT")
_ = 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")
diff --git a/internal/pkg/config/config_test.go b/internal/pkg/config/config_test.go
index 1f8b42cb..30629f8b 100644
--- a/internal/pkg/config/config_test.go
+++ b/internal/pkg/config/config_test.go
@@ -85,11 +85,18 @@ func TestLoadFromDotEnvAndStandardEnvAliases(t *testing.T) {
tempDir := t.TempDir()
envPath := filepath.Join(tempDir, ".env")
envContent := []byte(`PORT=9090
+COMPANY_NAME=MyCompany
+COMPANY_LOGO_URL=https://cdn.example.com/logo.png
DATABASE_URL=postgres://user:pass@localhost:5432/mydb?sslmode=disable
PASSWORD_LOGIN_ENABLED=false
JWT_SECRET=super-secret-key-12345
QDRANT_HOST=10.0.0.5
QDRANT_PORT=6334
+OPENAI_API_KEY=sk-test-openai-key
+OPENAI_BASE_URL=https://api.openai.com/v1
+OPENAI_LLM_MODEL=gpt-4o
+OPENAI_EMBEDDING_MODEL=text-embedding-3-small
+OPENAI_EMBEDDING_DIMENSION=1536
OIDC_ENABLED=true
OIDC_ISSUER=https://auth.example.com
OIDC_CLIENT_ID=client-123
@@ -111,6 +118,12 @@ ORG_SYNC_SECRET=webhook-secret-789
if cfg.Server.Port != 9090 {
t.Fatalf("Server.Port=%d want 9090", cfg.Server.Port)
}
+ if cfg.Server.CompanyName != "MyCompany" {
+ t.Fatalf("Server.CompanyName=%q want MyCompany", cfg.Server.CompanyName)
+ }
+ if cfg.Server.CompanyLogoURL != "https://cdn.example.com/logo.png" {
+ t.Fatalf("Server.CompanyLogoURL=%q want https://cdn.example.com/logo.png", cfg.Server.CompanyLogoURL)
+ }
if cfg.DB.Type != "postgres" {
t.Fatalf("DB.Type=%q want postgres", cfg.DB.Type)
}
@@ -129,6 +142,21 @@ ORG_SYNC_SECRET=webhook-secret-789
if cfg.VectorDB.Qdrant.GrpcPort != 6334 {
t.Fatalf("Qdrant.GrpcPort=%d", cfg.VectorDB.Qdrant.GrpcPort)
}
+ if cfg.AI.APIKey != "sk-test-openai-key" {
+ t.Fatalf("AI.APIKey=%q", cfg.AI.APIKey)
+ }
+ if cfg.AI.BaseURL != "https://api.openai.com/v1" {
+ t.Fatalf("AI.BaseURL=%q", cfg.AI.BaseURL)
+ }
+ if cfg.AI.LLMModel != "gpt-4o" {
+ t.Fatalf("AI.LLMModel=%q", cfg.AI.LLMModel)
+ }
+ if cfg.AI.EmbeddingModel != "text-embedding-3-small" {
+ t.Fatalf("AI.EmbeddingModel=%q", cfg.AI.EmbeddingModel)
+ }
+ if cfg.AI.EmbeddingDimension != 1536 {
+ t.Fatalf("AI.EmbeddingDimension=%d", cfg.AI.EmbeddingDimension)
+ }
if !cfg.OIDC.Enabled {
t.Fatalf("expected OIDC.Enabled=true")
}
diff --git a/web/components/legal-document-page.tsx b/web/components/legal-document-page.tsx
index 67e1305a..9262f9b0 100644
--- a/web/components/legal-document-page.tsx
+++ b/web/components/legal-document-page.tsx
@@ -2,9 +2,11 @@
import Image from "next/image"
import Link from "next/link"
+import { useEffect, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { useAppLocale, useI18n } from "@/i18n/provider"
+import { fetchPublicConfig, type PublicConfig } from "@/lib/api/config"
import enUSMessages from "@/messages/en-US.json"
import zhCNMessages from "@/messages/zh-CN.json"
@@ -32,6 +34,23 @@ const messages = {
export function LegalDocumentPage({ type }: { type: LegalPageType }) {
const t = useI18n()
const { locale } = useAppLocale()
+ const [publicConfig, setPublicConfig] = useState(null)
+
+ useEffect(() => {
+ let mounted = true
+ fetchPublicConfig()
+ .then((cfg) => {
+ if (mounted) setPublicConfig(cfg)
+ })
+ .catch(() => {})
+ return () => {
+ mounted = false
+ }
+ }, [])
+
+ const brandName = publicConfig?.companyName || t("app.brand")
+ const brandLogo = publicConfig?.companyLogoUrl || "/images/logo.svg"
+
const document = messages[locale].legal[type] as LegalDocument
const relatedHref = type === "terms" ? "/legal/privacy" : "/legal/terms"
@@ -41,14 +60,14 @@ export function LegalDocumentPage({ type }: { type: LegalPageType }) {
diff --git a/web/components/support-center/support-header.tsx b/web/components/support-center/support-header.tsx
index 18169fe9..d205e80e 100644
--- a/web/components/support-center/support-header.tsx
+++ b/web/components/support-center/support-header.tsx
@@ -3,13 +3,14 @@
import { BookOpenIcon, HeadphonesIcon, HomeIcon, LoaderCircleIcon, LogOutIcon, MessageCircleQuestionIcon } from "lucide-react"
import Link from "next/link"
import { usePathname } from "next/navigation"
-import { type ReactNode } from "react"
+import { useEffect, useState, type ReactNode } from "react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { buttonVariants } from "@/components/ui/button"
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { useSupportAuth } from "@/components/support-center/support-auth-provider"
import { useI18n } from "@/i18n/provider"
+import { fetchPublicConfig, type PublicConfig } from "@/lib/api/config"
import { cn } from "@/lib/utils"
export type SupportHeaderSection = "home" | "help" | "questions" | "ask" | "login"
@@ -33,6 +34,21 @@ export function SupportHeader({
}) {
const t = useI18n()
const pathname = usePathname()
+ const [publicConfig, setPublicConfig] = useState(null)
+
+ useEffect(() => {
+ let mounted = true
+ fetchPublicConfig()
+ .then((cfg) => {
+ if (mounted) setPublicConfig(cfg)
+ })
+ .catch(() => {})
+ return () => {
+ mounted = false
+ }
+ }, [])
+
+ const brandName = publicConfig?.companyName || "AGENT DESK"
const isActive = (href: string) => {
if (href === "/support/questions" && pathname.startsWith("/support/question/")) return true
@@ -44,7 +60,7 @@ export function SupportHeader({
{leading}
-
AGENT DESK
+
{brandName}
{t(sectionTitleKey[section])}
*/}
-
{agent?.name || "新建 AI Agent"}
+ {agent?.name || t("aiAgent.new")}
{agent?.statusName ? {agent.statusName} : null}
- {agentPublished ? "已发布" : agent ? "未发布" : "尚未创建"}
+ {agentPublished ? t("aiAgent.published") : agent ? t("aiAgent.unpublished") : t("aiAgent.notCreated")}
{agent ? (
) : null}
@@ -373,9 +375,9 @@ export function WorkflowWorkbench({
{!active ? (
({
value: template.code,
@@ -398,13 +400,13 @@ export function WorkflowWorkbench({
disabled={saving}
onClick={() => void save()}
>
- 保存
+ {t("common.save")}
diff --git a/web/app/(dashboard)/dashboard/conversations/_components/chat-panel.tsx b/web/app/(dashboard)/dashboard/conversations/_components/chat-panel.tsx
index a5aed364..5792682a 100644
--- a/web/app/(dashboard)/dashboard/conversations/_components/chat-panel.tsx
+++ b/web/app/(dashboard)/dashboard/conversations/_components/chat-panel.tsx
@@ -670,7 +670,7 @@ const MessageItem = memo(
}}
>
- 执行详情
+ {t("workflowRun.viewDetails")}
) : null}
@@ -745,6 +745,8 @@ function WorkflowRunDetailDialog({
run: AIWorkflowRun | null;
onOpenChange: (open: boolean) => void;
}) {
+ const t = useI18n();
+
return (
- AI 执行详情
+ {t("workflowRun.detailTitle")}
}
- description={run ? `Run #${run.id}` : "Workflow 执行链路"}
+ description={run ? `Run #${run.id}` : t("workflowRun.description")}
size="xl"
allowFullscreen
footer={
}
>
{loading ? (
- 加载执行详情中
+ {t("workflowRun.loadingDetail")}
) : run ? (
-
-
-
+
+
+
-
+
{run.errorMessage ? (
@@ -802,13 +804,13 @@ function WorkflowRunDetailDialog({
))}
{!run.nodes || run.nodes.length === 0 ? (
-
暂无节点记录
+
{t("workflowRun.emptyNodes")}
) : null}
) : (
- 未找到执行记录
+ {t("workflowRun.emptyDetail")}
)}
@@ -840,6 +842,7 @@ function WorkflowRunDetailRow({
}
function WorkflowNodeRunBlock({ node }: { node: AIWorkflowNodeRun }) {
+ const t = useI18n();
const inputValue = safeParseJSON(node.inputPreview);
const outputValue = safeParseJSON(node.outputPreview);
@@ -869,8 +872,8 @@ function WorkflowNodeRunBlock({ node }: { node: AIWorkflowNodeRun }) {
) : null}
-
-
+
+
);
diff --git a/web/app/(dashboard)/dashboard/conversations/_components/conversation-info-panel.tsx b/web/app/(dashboard)/dashboard/conversations/_components/conversation-info-panel.tsx
index 82e48026..ca69e6ef 100644
--- a/web/app/(dashboard)/dashboard/conversations/_components/conversation-info-panel.tsx
+++ b/web/app/(dashboard)/dashboard/conversations/_components/conversation-info-panel.tsx
@@ -339,6 +339,7 @@ function ConversationTagSection({
}
function WorkflowRunsSection({ conversation }: { conversation: AgentConversation }) {
+ const t = useI18n();
const [runs, setRuns] = useState([]);
const [loading, setLoading] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
@@ -361,7 +362,7 @@ function WorkflowRunsSection({ conversation }: { conversation: AgentConversation
}
} catch (error) {
if (!cancelled) {
- toast.error(error instanceof Error ? error.message : "加载 AI 执行记录失败");
+ toast.error(error instanceof Error ? error.message : t("workflowRun.loadLogsFailed"));
}
} finally {
if (!cancelled) {
@@ -374,7 +375,7 @@ function WorkflowRunsSection({ conversation }: { conversation: AgentConversation
return () => {
cancelled = true;
};
- }, [conversation.id]);
+ }, [conversation.id, t]);
async function openDetail(runId: number) {
setDetailOpen(true);
@@ -383,7 +384,7 @@ function WorkflowRunsSection({ conversation }: { conversation: AgentConversation
const data = await fetchAIWorkflowRun(runId);
setActiveRun(data);
} catch (error) {
- toast.error(error instanceof Error ? error.message : "加载 AI 执行详情失败");
+ toast.error(error instanceof Error ? error.message : t("workflowRun.loadDetailFailed"));
setDetailOpen(false);
} finally {
setDetailLoading(false);
@@ -392,9 +393,9 @@ function WorkflowRunsSection({ conversation }: { conversation: AgentConversation
return (
- AI 执行记录
+ {t("workflowRun.recordsTitle")}
{loading ? (
- 加载执行记录中
+ {t("workflowRun.loadingLogs")}
) : runs.length > 0 ? (
{runs.map((run) => (
@@ -432,7 +433,7 @@ function WorkflowRunsSection({ conversation }: { conversation: AgentConversation
))}
) : (
- 暂无 AI 执行记录
+ {t("workflowRun.emptyLogs")}
)}
void;
}) {
+ const t = useI18n();
+
return (
- AI 执行详情
+ {t("workflowRun.detailTitle")}
}
- description={run ? `Run #${run.id}` : "Workflow 执行链路"}
+ description={run ? `Run #${run.id}` : t("workflowRun.description")}
size="xl"
allowFullscreen
footer={
}
>
{loading ? (
- 加载执行详情中
+ {t("workflowRun.loadingDetail")}
) : run ? (
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
{run.errorMessage ? (
@@ -503,18 +506,19 @@ function WorkflowRunDetailDialog({
))}
{!run.nodes || run.nodes.length === 0 ? (
-
暂无节点记录
+
{t("workflowRun.emptyNodes")}
) : null}
) : (
- 未找到执行记录
+ {t("workflowRun.emptyDetail")}
)}
);
}
function WorkflowNodeRunBlock({ node }: { node: AIWorkflowNodeRun }) {
+ const t = useI18n();
const inputValue = safeParseJSON(node.inputPreview);
const outputValue = safeParseJSON(node.outputPreview);
@@ -541,8 +545,8 @@ function WorkflowNodeRunBlock({ node }: { node: AIWorkflowNodeRun }) {
) : null}
);
diff --git a/web/app/(dashboard)/dashboard/wxwork-outbox/page.tsx b/web/app/(dashboard)/dashboard/wxwork-outbox/page.tsx
index d777c8e6..2fc3ff22 100644
--- a/web/app/(dashboard)/dashboard/wxwork-outbox/page.tsx
+++ b/web/app/(dashboard)/dashboard/wxwork-outbox/page.tsx
@@ -16,20 +16,9 @@ import {
retryWxWorkOutbox,
type ChannelMessageOutbox,
} from "@/lib/api/admin"
+import { useI18n } from "@/i18n/provider"
import { formatDateTime } from "@/lib/utils"
-const STATUS_OPTIONS = [
- { value: "failed", label: "失败" },
- { value: "ignored", label: "已忽略" },
- { value: "all", label: "全部" },
-] as const
-
-function statusLabel(status: string) {
- if (status === "failed") return "失败"
- if (status === "ignored") return "已忽略"
- return status || "-"
-}
-
function statusVariant(status: string) {
if (status === "failed") return "destructive" as const
if (status === "ignored") return "outline" as const
@@ -47,6 +36,7 @@ function OutboxActions({
item: ChannelMessageOutbox
reload: DashboardListRenderContext["reload"]
}) {
+ const t = useI18n()
const [runningAction, setRunningAction] = useState<"retry" | "ignore" | null>(null)
async function runAction(action: "retry" | "ignore") {
@@ -54,14 +44,14 @@ function OutboxActions({
try {
if (action === "retry") {
await retryWxWorkOutbox(item.id)
- toast.success("已重新加入发送队列")
+ toast.success(t("wxworkOutbox.retrySuccess"))
} else {
await ignoreWxWorkOutbox(item.id)
- toast.success("已忽略该失败记录")
+ toast.success(t("wxworkOutbox.ignoreSuccess"))
}
await reload()
} catch (error) {
- toast.error(error instanceof Error ? error.message : "操作失败")
+ toast.error(error instanceof Error ? error.message : t("wxworkOutbox.actionFailed"))
} finally {
setRunningAction(null)
}
@@ -77,7 +67,7 @@ function OutboxActions({
onClick={() => void runAction("retry")}
>
- 重试
+ {t("wxworkOutbox.retry")}
{item.sendStatus === "failed" ? (
) : null}
@@ -96,28 +86,42 @@ function OutboxActions({
}
export default function DashboardWxWorkOutboxPage() {
+ const t = useI18n()
+
+ const statusOptions = [
+ { value: "failed", label: t("wxworkOutbox.statusFailed") },
+ { value: "ignored", label: t("wxworkOutbox.statusIgnored") },
+ { value: "all", label: t("wxworkOutbox.statusAll") },
+ ]
+
+ const statusLabel = (status: string) => {
+ if (status === "failed") return t("wxworkOutbox.statusFailed")
+ if (status === "ignored") return t("wxworkOutbox.statusIgnored")
+ return status || "-"
+ }
+
return (
filters={[
{
name: "sendStatus",
- label: "状态",
+ label: t("wxworkOutbox.columnStatus"),
defaultValue: "failed",
type: "segment",
- options: STATUS_OPTIONS,
+ options: statusOptions,
},
{
name: "conversationId",
- label: "会话 ID",
- placeholder: "会话 ID",
+ label: t("wxworkOutbox.conversationId"),
+ placeholder: t("wxworkOutbox.conversationId"),
defaultValue: "",
valueType: "number",
className: "w-full sm:w-40",
},
{
name: "messageId",
- label: "消息 ID",
- placeholder: "消息 ID",
+ label: t("wxworkOutbox.messageId"),
+ placeholder: t("wxworkOutbox.messageId"),
defaultValue: "",
valueType: "number",
className: "w-full sm:w-40",
@@ -134,18 +138,18 @@ export default function DashboardWxWorkOutboxPage() {
},
{
key: "message",
- label: "消息",
+ label: t("wxworkOutbox.columnMessage"),
className: "w-48",
render: (item) => (
-
会话 #{item.conversationId || "-"}
-
消息 #{item.messageId || "-"}
+
{t("wxworkOutbox.conversationLine", { id: String(item.conversationId || "-") })}
+
{t("wxworkOutbox.messageLine", { id: String(item.messageId || "-") })}
),
},
{
key: "status",
- label: "状态",
+ label: t("wxworkOutbox.columnStatus"),
className: "w-28",
render: (item) => (
@@ -155,20 +159,20 @@ export default function DashboardWxWorkOutboxPage() {
},
{
key: "retry",
- label: "重试",
+ label: t("wxworkOutbox.columnRetry"),
className: "w-44 text-xs",
render: (item) => (
-
{item.retryCount} 次
+
{t("wxworkOutbox.retriesCount", { count: String(item.retryCount) })}
- 下次 {formatOptionalTime(item.nextRetryAt)}
+ {t("wxworkOutbox.nextRetry", { time: formatOptionalTime(item.nextRetryAt) })}
),
},
{
key: "error",
- label: "失败原因",
+ label: t("wxworkOutbox.columnError"),
className: "min-w-72 max-w-[32rem]",
render: (item) =>
item.lastError ? (
@@ -181,13 +185,13 @@ export default function DashboardWxWorkOutboxPage() {
},
{
key: "updatedAt",
- label: "更新时间",
+ label: t("wxworkOutbox.columnUpdatedAt"),
className: "w-44 text-xs text-muted-foreground",
render: (item) => formatOptionalTime(item.updatedAt),
},
{
key: "actions",
- label: 操作,
+ label: {t("wxworkOutbox.columnActions")},
className: "w-44",
render: (item, context) => (
@@ -195,11 +199,11 @@ export default function DashboardWxWorkOutboxPage() {
},
]}
labels={{
- refresh: "刷新",
- query: "查询",
- loading: "正在加载企业微信 outbox...",
- empty: "暂无失败 outbox",
- loadFailed: "加载企业微信 outbox 失败",
+ refresh: t("wxworkOutbox.refresh"),
+ query: t("wxworkOutbox.query"),
+ loading: t("wxworkOutbox.loading"),
+ empty: t("wxworkOutbox.empty"),
+ loadFailed: t("wxworkOutbox.loadFailed"),
}}
/>
)
diff --git a/web/messages/en-US.json b/web/messages/en-US.json
index b7df9316..4a91b03f 100644
--- a/web/messages/en-US.json
+++ b/web/messages/en-US.json
@@ -2599,7 +2599,76 @@
"output": "Output Preview",
"arguments": "Arguments Preview",
"result": "Result Preview",
- "notFound": "Agent run not found"
+ "notFound": "Agent run not found",
+ "metricCompletionRate": "Completion Rate",
+ "metricResolutionRate": "Resolution Rate",
+ "metricUnsupportedRate": "No Evidence Rate",
+ "metricToolSuccessRate": "Tool Success Rate",
+ "metricAvgSteps": "Average Steps",
+ "metricP95Latency": "P95 Latency",
+ "metricToken": "Token Usage",
+ "metricHandoffRate": "Human Handoff Rate",
+ "metricFallbackRate": "Knowledge Fallback Rate",
+ "metricInterruptRecoveryRate": "Interrupt Recovery Rate",
+ "qualityTitle": "Run Quality Inspection",
+ "qualityDescription": "Resolution and unsupported rates are calculated from inspected runs only.",
+ "selectResolution": "Select resolution status",
+ "selectEvidence": "Select evidence status",
+ "qualityCommentPlaceholder": "Inspection notes...",
+ "saveQuality": "Save Inspection",
+ "qualitySaved": "Inspection result saved",
+ "workflowAudit": "Workflow Node Audit",
+ "viewWorkflowAudit": "View Node Audit"
+ },
+ "wxworkOutbox": {
+ "statusFailed": "Failed",
+ "statusIgnored": "Ignored",
+ "statusAll": "All",
+ "retrySuccess": "Re-queued message for sending",
+ "ignoreSuccess": "Ignored failed record",
+ "actionFailed": "Action failed",
+ "retry": "Retry",
+ "ignore": "Ignore",
+ "columnStatus": "Status",
+ "conversationId": "Conversation ID",
+ "messageId": "Message ID",
+ "columnMessage": "Message",
+ "conversationLine": "Chat #{id}",
+ "messageLine": "Message #{id}",
+ "columnRetry": "Retries",
+ "retriesCount": "{count} times",
+ "nextRetry": "Next: {time}",
+ "columnError": "Failure Reason",
+ "columnUpdatedAt": "Updated At",
+ "columnActions": "Actions",
+ "refresh": "Refresh",
+ "query": "Search",
+ "loading": "Loading outbox messages...",
+ "empty": "No failed outbox messages",
+ "loadFailed": "Failed to load outbox messages"
+ },
+ "workflowRun": {
+ "recordsTitle": "AI Execution Logs",
+ "loadingLogs": "Loading execution logs...",
+ "emptyLogs": "No AI execution logs",
+ "detailTitle": "AI Execution Details",
+ "description": "Workflow Execution Trace",
+ "loadingDetail": "Loading execution details...",
+ "emptyDetail": "Execution record not found.",
+ "loadLogsFailed": "Failed to load AI execution logs",
+ "loadDetailFailed": "Failed to load AI execution details",
+ "labelConversation": "Conversation",
+ "labelMessage": "Message",
+ "labelAgent": "Agent",
+ "labelStatus": "Status",
+ "labelStartedAt": "Started At",
+ "labelEndedAt": "Ended At",
+ "labelInterruptNode": "Interrupt Node",
+ "labelWorkflow": "Workflow",
+ "emptyNodes": "No node execution records",
+ "input": "Input",
+ "output": "Output",
+ "viewDetails": "Execution Details"
},
"supportPublic": {
"brand": "AgentDesk Support",
diff --git a/web/messages/vi-VN.json b/web/messages/vi-VN.json
index 40c18aec..327faf05 100644
--- a/web/messages/vi-VN.json
+++ b/web/messages/vi-VN.json
@@ -2541,76 +2541,104 @@
"create": "Create Role"
},
"workflowRun": {
- "allStatus": "All statuses",
- "completed": "Completed",
- "interrupted": "Interrupted",
- "failed": "Failed",
- "allAgents": "All agents",
- "loadAgentsFailed": "Could not load AI agents.",
- "loadDetailFailed": "Could not load workflow run details.",
- "loadConversationFailed": "Could not load conversation details.",
- "conversationId": "Conversation ID",
- "messageId": "Message ID",
- "workflowVersionId": "Workflow Version ID",
- "agent": "Agent",
- "searchAgent": "Search agents",
- "emptyAgent": "No agents found",
- "status": "Status",
- "startedAt": "Started At",
- "endedAt": "Ended At",
- "workflow": "Workflow",
- "version": "Version",
- "message": "Message",
- "conversationShort": "Conversation #{id}",
- "messageShort": "Message #{id}",
- "duration": "Duration",
- "error": "Error",
- "refresh": "Refresh",
- "query": "Search",
- "loading": "Loading workflow runs",
- "empty": "No workflow runs yet",
- "loadFailed": "Could not load workflow runs.",
- "detailTitle": "Workflow Run Detail",
- "detailDescription": "Inspect workflow execution path",
- "close": "Close",
- "loadingDetail": "Loading workflow run detail",
- "interruptNode": "Interrupt Node",
- "nodeDetails": "Node Run Details",
- "emptyNodes": "No node records",
- "notFound": "Workflow run not found",
- "input": "Input",
- "output": "Output"
+ "recordsTitle": "Lịch sử thực thi AI",
+ "loadingLogs": "Đang tải lịch sử thực thi...",
+ "emptyLogs": "Chưa có lịch sử thực thi AI",
+ "detailTitle": "Chi tiết thực thi AI",
+ "description": "Chuỗi thực thi Workflow",
+ "loadingDetail": "Đang tải chi tiết thực thi...",
+ "emptyDetail": "Không tìm thấy bản ghi thực thi",
+ "loadLogsFailed": "Không thể tải lịch sử thực thi AI",
+ "loadDetailFailed": "Không thể tải chi tiết thực thi AI",
+ "labelConversation": "Hội thoại",
+ "labelMessage": "Tin nhắn",
+ "labelAgent": "Agent",
+ "labelStatus": "Trạng thái",
+ "labelStartedAt": "Bắt đầu",
+ "labelEndedAt": "Kết thúc",
+ "labelInterruptNode": "Node gián đoạn",
+ "labelWorkflow": "Workflow",
+ "emptyNodes": "Chưa có bản ghi node nào",
+ "input": "Đầu vào",
+ "output": "Đầu ra",
+ "viewDetails": "Chi tiết thực thi"
},
"agentRun": {
- "conversation": "Conversation",
- "agent": "Agent",
+ "conversation": "Cuộc hội thoại",
+ "agent": "AI Agent",
"engine": "Engine",
- "status": "Status",
- "startedAt": "Started",
- "duration": "Duration",
- "tokens": "Input/Output Tokens",
- "error": "Error",
- "refresh": "Refresh",
- "query": "Query",
- "loading": "Loading agent runs",
- "empty": "No agent runs",
- "loadFailed": "Failed to load agent runs",
- "loadDetailFailed": "Failed to load agent run detail",
- "detailTitle": "Agent Run Detail",
- "detailDescription": "View the unified run audit",
- "loadingDetail": "Loading agent run detail",
- "close": "Close",
- "revision": "Revision",
- "trace": "Trace",
- "steps": "Steps",
- "emptySteps": "No steps recorded",
- "toolCalls": "Tool Calls",
- "emptyToolCalls": "No tool calls recorded",
- "input": "Input Preview",
- "output": "Output Preview",
- "arguments": "Arguments Preview",
- "result": "Result Preview",
- "notFound": "Agent run not found"
+ "status": "Trạng thái",
+ "startedAt": "Bắt đầu",
+ "duration": "Thời lượng",
+ "tokens": "Token Vào/Ra",
+ "error": "Lỗi",
+ "refresh": "Làm mới",
+ "query": "Tìm kiếm",
+ "loading": "Đang tải phiên chạy...",
+ "empty": "Chưa có phiên chạy nào",
+ "loadFailed": "Không thể tải danh sách phiên chạy",
+ "loadDetailFailed": "Không thể tải chi tiết phiên chạy",
+ "detailTitle": "Chi tiết phiên chạy Agent",
+ "detailDescription": "Xem toàn bộ luồng kiểm toán phiên chạy",
+ "loadingDetail": "Đang tải chi tiết phiên chạy...",
+ "close": "Đóng",
+ "revision": "Phiên bản",
+ "trace": "Dấu vết luồng (Trace)",
+ "steps": "Các bước thực thi",
+ "emptySteps": "Chưa có bước thực thi nào",
+ "toolCalls": "Lượt gọi Tool",
+ "emptyToolCalls": "Chưa có lượt gọi Tool nào",
+ "input": "Dữ liệu đầu vào",
+ "output": "Dữ liệu đầu ra",
+ "arguments": "Tham số truyền vào",
+ "result": "Kết quả trả về",
+ "notFound": "Không tìm thấy phiên chạy Agent",
+ "metricCompletionRate": "Tỷ lệ hoàn thành",
+ "metricResolutionRate": "Tỷ lệ giải quyết",
+ "metricUnsupportedRate": "Tỷ lệ thiếu căn cứ",
+ "metricToolSuccessRate": "Tỷ lệ gọi Tool thành công",
+ "metricAvgSteps": "Số bước trung bình",
+ "metricP95Latency": "Độ trễ P95",
+ "metricToken": "Lượng Token",
+ "metricHandoffRate": "Tỷ lệ chuyển giao người thật",
+ "metricFallbackRate": "Tỷ lệ dự phòng tri thức",
+ "metricInterruptRecoveryRate": "Tỷ lệ khôi phục gián đoạn",
+ "qualityTitle": "Đánh giá chất lượng phiên chạy",
+ "qualityDescription": "Tỷ lệ giải quyết và thiếu căn cứ chỉ tính trên các phiên đã đánh giá.",
+ "selectResolution": "Chọn tình trạng giải quyết",
+ "selectEvidence": "Chọn căn cứ thông tin",
+ "qualityCommentPlaceholder": "Ghi chú đánh giá...",
+ "saveQuality": "Lưu đánh giá",
+ "qualitySaved": "Đã lưu kết quả đánh giá",
+ "workflowAudit": "Kiểm toán Node Workflow",
+ "viewWorkflowAudit": "Xem kiểm toán Node"
+ },
+ "wxworkOutbox": {
+ "statusFailed": "Thất bại",
+ "statusIgnored": "Đã bỏ qua",
+ "statusAll": "Tất cả",
+ "retrySuccess": "Đã đưa lại tin nhắn vào hàng đợi gửi",
+ "ignoreSuccess": "Đã bỏ qua bản ghi lỗi",
+ "actionFailed": "Thao tác thất bại",
+ "retry": "Thử lại",
+ "ignore": "Bỏ qua",
+ "columnStatus": "Trạng thái",
+ "conversationId": "Mã hội thoại",
+ "messageId": "Mã tin nhắn",
+ "columnMessage": "Tin nhắn",
+ "conversationLine": "Hội thoại #{id}",
+ "messageLine": "Tin nhắn #{id}",
+ "columnRetry": "Lần thử lại",
+ "retriesCount": "{count} lần",
+ "nextRetry": "Lần tới: {time}",
+ "columnError": "Nguyên nhân lỗi",
+ "columnUpdatedAt": "Thời gian cập nhật",
+ "columnActions": "Thao tác",
+ "refresh": "Làm mới",
+ "query": "Tìm kiếm",
+ "loading": "Đang tải hàng đợi hộp thư đi...",
+ "empty": "Không có tin nhắn lỗi trong hàng đợi",
+ "loadFailed": "Không thể tải hàng đợi hộp thư đi"
},
"supportPublic": {
"brand": "AgentDesk Support",
diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json
index c481af06..fbb5cd2c 100644
--- a/web/messages/zh-CN.json
+++ b/web/messages/zh-CN.json
@@ -2599,7 +2599,76 @@
"output": "输出摘要",
"arguments": "参数摘要",
"result": "结果摘要",
- "notFound": "未找到 Agent 运行记录"
+ "notFound": "未找到 Agent 运行记录",
+ "metricCompletionRate": "运行完成率",
+ "metricResolutionRate": "解决率",
+ "metricUnsupportedRate": "无依据率",
+ "metricToolSuccessRate": "工具成功率",
+ "metricAvgSteps": "平均步骤",
+ "metricP95Latency": "P95 时延",
+ "metricToken": "Token",
+ "metricHandoffRate": "转人工率",
+ "metricFallbackRate": "知识兜底率",
+ "metricInterruptRecoveryRate": "中断恢复率",
+ "qualityTitle": "运行质检",
+ "qualityDescription": "解决率和无依据率仅统计已质检记录。",
+ "selectResolution": "选择解决情况",
+ "selectEvidence": "选择依据情况",
+ "qualityCommentPlaceholder": "质检备注",
+ "saveQuality": "保存质检",
+ "qualitySaved": "质检结果已保存",
+ "workflowAudit": "Workflow 节点审计",
+ "viewWorkflowAudit": "查看节点审计"
+ },
+ "wxworkOutbox": {
+ "statusFailed": "失败",
+ "statusIgnored": "已忽略",
+ "statusAll": "全部",
+ "retrySuccess": "已重新加入发送队列",
+ "ignoreSuccess": "已忽略该失败记录",
+ "actionFailed": "操作失败",
+ "retry": "重试",
+ "ignore": "忽略",
+ "columnStatus": "状态",
+ "conversationId": "会话 ID",
+ "messageId": "消息 ID",
+ "columnMessage": "消息",
+ "conversationLine": "会话 #{id}",
+ "messageLine": "消息 #{id}",
+ "columnRetry": "重试",
+ "retriesCount": "{count} 次",
+ "nextRetry": "下次: {time}",
+ "columnError": "失败原因",
+ "columnUpdatedAt": "更新时间",
+ "columnActions": "操作",
+ "refresh": "刷新",
+ "query": "查询",
+ "loading": "正在加载企业微信 outbox...",
+ "empty": "暂无失败 outbox",
+ "loadFailed": "加载企业微信 outbox 失败"
+ },
+ "workflowRun": {
+ "recordsTitle": "AI 执行记录",
+ "loadingLogs": "加载执行记录中",
+ "emptyLogs": "暂无 AI 执行记录",
+ "detailTitle": "AI 执行详情",
+ "description": "Workflow 执行链路",
+ "loadingDetail": "加载执行详情中",
+ "emptyDetail": "未找到执行记录",
+ "loadLogsFailed": "加载 AI 执行记录失败",
+ "loadDetailFailed": "加载 AI 执行详情失败",
+ "labelConversation": "会话",
+ "labelMessage": "消息",
+ "labelAgent": "Agent",
+ "labelStatus": "状态",
+ "labelStartedAt": "开始",
+ "labelEndedAt": "结束",
+ "labelInterruptNode": "中断节点",
+ "labelWorkflow": "Workflow",
+ "emptyNodes": "暂无节点记录",
+ "input": "输入",
+ "output": "输出",
+ "viewDetails": "执行详情"
},
"supportPublic": {
"brand": "AgentDesk 支持中心",
diff --git a/web/scripts/generate-vi-messages.mjs b/web/scripts/generate-vi-messages.mjs
index f0024807..58d29420 100644
--- a/web/scripts/generate-vi-messages.mjs
+++ b/web/scripts/generate-vi-messages.mjs
@@ -250,6 +250,110 @@ viData.aiWorkflow = {
deleted: "Đã xóa quy trình: {name}",
}
+viData.agentRun = {
+ ...viData.agentRun,
+ conversation: "Cuộc hội thoại",
+ agent: "AI Agent",
+ engine: "Engine",
+ status: "Trạng thái",
+ startedAt: "Bắt đầu",
+ duration: "Thời lượng",
+ tokens: "Token Vào/Ra",
+ error: "Lỗi",
+ refresh: "Làm mới",
+ query: "Tìm kiếm",
+ loading: "Đang tải phiên chạy...",
+ empty: "Chưa có phiên chạy nào",
+ loadFailed: "Không thể tải danh sách phiên chạy",
+ loadDetailFailed: "Không thể tải chi tiết phiên chạy",
+ detailTitle: "Chi tiết phiên chạy Agent",
+ detailDescription: "Xem toàn bộ luồng kiểm toán phiên chạy",
+ loadingDetail: "Đang tải chi tiết phiên chạy...",
+ close: "Đóng",
+ revision: "Phiên bản",
+ trace: "Dấu vết luồng (Trace)",
+ steps: "Các bước thực thi",
+ emptySteps: "Chưa có bước thực thi nào",
+ toolCalls: "Lượt gọi Tool",
+ emptyToolCalls: "Chưa có lượt gọi Tool nào",
+ input: "Dữ liệu đầu vào",
+ output: "Dữ liệu đầu ra",
+ arguments: "Tham số truyền vào",
+ result: "Kết quả trả về",
+ notFound: "Không tìm thấy phiên chạy Agent",
+ metricCompletionRate: "Tỷ lệ hoàn thành",
+ metricResolutionRate: "Tỷ lệ giải quyết",
+ metricUnsupportedRate: "Tỷ lệ thiếu căn cứ",
+ metricToolSuccessRate: "Tỷ lệ gọi Tool thành công",
+ metricAvgSteps: "Số bước trung bình",
+ metricP95Latency: "Độ trễ P95",
+ metricToken: "Lượng Token",
+ metricHandoffRate: "Tỷ lệ chuyển giao người thật",
+ metricFallbackRate: "Tỷ lệ dự phòng tri thức",
+ metricInterruptRecoveryRate: "Tỷ lệ khôi phục gián đoạn",
+ qualityTitle: "Đánh giá chất lượng phiên chạy",
+ qualityDescription: "Tỷ lệ giải quyết và thiếu căn cứ chỉ tính trên các phiên đã đánh giá.",
+ selectResolution: "Chọn tình trạng giải quyết",
+ selectEvidence: "Chọn căn cứ thông tin",
+ qualityCommentPlaceholder: "Ghi chú đánh giá...",
+ saveQuality: "Lưu đánh giá",
+ qualitySaved: "Đã lưu kết quả đánh giá",
+ workflowAudit: "Kiểm toán Node Workflow",
+ viewWorkflowAudit: "Xem kiểm toán Node",
+}
+
+viData.wxworkOutbox = {
+ statusFailed: "Thất bại",
+ statusIgnored: "Đã bỏ qua",
+ statusAll: "Tất cả",
+ retrySuccess: "Đã đưa lại tin nhắn vào hàng đợi gửi",
+ ignoreSuccess: "Đã bỏ qua bản ghi lỗi",
+ actionFailed: "Thao tác thất bại",
+ retry: "Thử lại",
+ ignore: "Bỏ qua",
+ columnStatus: "Trạng thái",
+ conversationId: "Mã hội thoại",
+ messageId: "Mã tin nhắn",
+ columnMessage: "Tin nhắn",
+ conversationLine: "Hội thoại #{id}",
+ messageLine: "Tin nhắn #{id}",
+ columnRetry: "Lần thử lại",
+ retriesCount: "{count} lần",
+ nextRetry: "Lần tới: {time}",
+ columnError: "Nguyên nhân lỗi",
+ columnUpdatedAt: "Thời gian cập nhật",
+ columnActions: "Thao tác",
+ refresh: "Làm mới",
+ query: "Tìm kiếm",
+ loading: "Đang tải hàng đợi hộp thư đi...",
+ empty: "Không có tin nhắn lỗi trong hàng đợi",
+ loadFailed: "Không thể tải hàng đợi hộp thư đi",
+}
+
+viData.workflowRun = {
+ recordsTitle: "Lịch sử thực thi AI",
+ loadingLogs: "Đang tải lịch sử thực thi...",
+ emptyLogs: "Chưa có lịch sử thực thi AI",
+ detailTitle: "Chi tiết thực thi AI",
+ description: "Chuỗi thực thi Workflow",
+ loadingDetail: "Đang tải chi tiết thực thi...",
+ emptyDetail: "Không tìm thấy bản ghi thực thi",
+ loadLogsFailed: "Không thể tải lịch sử thực thi AI",
+ loadDetailFailed: "Không thể tải chi tiết thực thi AI",
+ labelConversation: "Hội thoại",
+ labelMessage: "Tin nhắn",
+ labelAgent: "Agent",
+ labelStatus: "Trạng thái",
+ labelStartedAt: "Bắt đầu",
+ labelEndedAt: "Kết thúc",
+ labelInterruptNode: "Node gián đoạn",
+ labelWorkflow: "Workflow",
+ emptyNodes: "Chưa có bản ghi node nào",
+ input: "Đầu vào",
+ output: "Đầu ra",
+ viewDetails: "Chi tiết thực thi",
+}
+
viData.language = {
enUS: "English",
zhCN: "Tiếng Trung (中文)",
From 3ed41cee3f45e65ed8075b1fc8cd9b37055cf269 Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Wed, 26 Aug 2026 12:45:28 +0700
Subject: [PATCH 22/53] feat(sync): implement 2-tier hybrid sync for Company
and Customer entities via webhook events
Co-authored-by: Cursor
---
internal/pkg/dto/request/webhook_request.go | 19 ++
internal/pkg/enums/external_identity.go | 14 +-
internal/services/auth_service_test.go | 4 +
internal/services/webhook_sync_service.go | 244 +++++++++++++++++-
.../services/webhook_sync_service_test.go | 79 ++++++
5 files changed, 351 insertions(+), 9 deletions(-)
diff --git a/internal/pkg/dto/request/webhook_request.go b/internal/pkg/dto/request/webhook_request.go
index a6c0c235..3ddcec98 100644
--- a/internal/pkg/dto/request/webhook_request.go
+++ b/internal/pkg/dto/request/webhook_request.go
@@ -1,6 +1,7 @@
package request
type OrgSyncEventData struct {
+ // Organization fields
OrgID string `json:"org_id"`
ID string `json:"id,omitempty"`
OrgName string `json:"org_name"`
@@ -12,6 +13,24 @@ type OrgSyncEventData struct {
UserName string `json:"user_name"`
Role string `json:"role"`
Plan string `json:"plan"`
+
+ // Company fields
+ CRMCompanyID string `json:"crm_company_id,omitempty"`
+ DeskCompanyID string `json:"desk_company_id,omitempty"`
+ DomainName string `json:"domain_name,omitempty"`
+ Address string `json:"address,omitempty"`
+ Tier string `json:"tier,omitempty"`
+ AccountOwnerEmail string `json:"account_owner_email,omitempty"`
+
+ // Customer fields
+ CRMPersonID string `json:"crm_person_id,omitempty"`
+ DeskCustomerID string `json:"desk_customer_id,omitempty"`
+ Email string `json:"email,omitempty"`
+ Phone string `json:"phone,omitempty"`
+ AvatarURL string `json:"avatar_url,omitempty"`
+ JobTitle string `json:"job_title,omitempty"`
+ CompanyName string `json:"company_name,omitempty"`
+ Source string `json:"source,omitempty"`
}
type OrgSyncWebhookRequest struct {
diff --git a/internal/pkg/enums/external_identity.go b/internal/pkg/enums/external_identity.go
index d9476fc6..f1084ef4 100644
--- a/internal/pkg/enums/external_identity.go
+++ b/internal/pkg/enums/external_identity.go
@@ -6,15 +6,17 @@ package enums
type ExternalSource string
const (
- ExternalSourceGuest ExternalSource = "guest" // 访客
- ExternalSourceWxWorkKF ExternalSource = "wxwork_kf" // 企业微信客服
- ExternalSourceUser ExternalSource = "user" // 用户信息
+ ExternalSourceGuest ExternalSource = "guest" // 访客
+ ExternalSourceWxWorkKF ExternalSource = "wxwork_kf" // 企业微信客服
+ ExternalSourceUser ExternalSource = "user" // 用户信息
+ ExternalSourceTwentyCRM ExternalSource = "twenty_crm" // Twenty CRM
)
var externalSourceLabelMap = map[ExternalSource]string{
- ExternalSourceGuest: "访客",
- ExternalSourceWxWorkKF: "企业微信客服",
- ExternalSourceUser: "用户",
+ ExternalSourceGuest: "访客",
+ ExternalSourceWxWorkKF: "企业微信客服",
+ ExternalSourceUser: "用户",
+ ExternalSourceTwentyCRM: "Twenty CRM",
}
func GetExternalSourceLabel(v ExternalSource) string {
diff --git a/internal/services/auth_service_test.go b/internal/services/auth_service_test.go
index 156db6d5..8901aaa1 100644
--- a/internal/services/auth_service_test.go
+++ b/internal/services/auth_service_test.go
@@ -382,6 +382,10 @@ func setupAuthServiceTestDB(t *testing.T) *gorm.DB {
if err := db.AutoMigrate(
&models.Organization{},
&models.OrganizationMember{},
+ &models.Company{},
+ &models.Customer{},
+ &models.CustomerIdentity{},
+ &models.CustomerContact{},
&models.User{},
&models.UserIdentity{},
&models.Role{},
diff --git a/internal/services/webhook_sync_service.go b/internal/services/webhook_sync_service.go
index e1fee88a..4a5bfb3e 100644
--- a/internal/services/webhook_sync_service.go
+++ b/internal/services/webhook_sync_service.go
@@ -70,9 +70,6 @@ func (s *webhookSyncService) HandleOrgSync(req request.OrgSyncWebhookRequest) er
if orgCode == "" {
orgCode = strings.TrimSpace(data.Slug)
}
- if orgCode == "" {
- return errorsx.InvalidParam("org_id or id is required")
- }
data.OrgID = orgCode
if data.OrgName == "" && data.Name != "" {
@@ -84,13 +81,29 @@ func (s *webhookSyncService) HandleOrgSync(req request.OrgSyncWebhookRequest) er
switch event {
case "org.created", "org.updated", "organization.created", "organization.updated":
+ if orgCode == "" {
+ return errorsx.InvalidParam("org_id or id is required")
+ }
return s.handleOrgUpsert(data)
case "org.deleted", "organization.deleted":
+ if orgCode == "" {
+ return errorsx.InvalidParam("org_id or id is required")
+ }
return s.handleOrgDelete(orgCode)
case "org.member_added", "org.member_updated", "organization.member.added", "organization.member.updated", "organization.member_added":
+ if orgCode == "" {
+ return errorsx.InvalidParam("org_id or id is required")
+ }
return s.handleMemberUpsert(data)
case "org.member_removed", "organization.member.removed", "organization.member_removed":
+ if orgCode == "" {
+ return errorsx.InvalidParam("org_id or id is required")
+ }
return s.handleMemberRemove(data)
+ case "company.created", "company.updated":
+ return s.handleCompanyUpsert(data)
+ case "customer.created", "customer.updated":
+ return s.handleCustomerUpsert(data)
default:
return nil
}
@@ -389,3 +402,228 @@ func (s *webhookSyncService) handleMemberRemove(data request.OrgSyncEventData) e
return nil
})
}
+
+func (s *webhookSyncService) handleCompanyUpsert(data request.OrgSyncEventData) error {
+ name := strings.TrimSpace(data.Name)
+ if name == "" {
+ name = strings.TrimSpace(data.OrgName)
+ }
+ if name == "" {
+ name = strings.TrimSpace(data.CRMCompanyID)
+ }
+ if name == "" {
+ return errorsx.InvalidParam("company name is required")
+ }
+
+ code := strings.TrimSpace(data.CRMCompanyID)
+ if code == "" {
+ code = strings.TrimSpace(data.DeskCompanyID)
+ }
+
+ remark := strings.TrimSpace(data.DomainName)
+ if data.Address != "" {
+ if remark != "" {
+ remark += " | " + data.Address
+ } else {
+ remark = data.Address
+ }
+ }
+ if data.Tier != "" {
+ if remark != "" {
+ remark += " | Tier: " + data.Tier
+ } else {
+ remark = "Tier: " + data.Tier
+ }
+ }
+
+ now := time.Now()
+ return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
+ var company *models.Company
+ if code != "" {
+ company = repositories.CompanyRepository.FindOne(ctx.Tx, sqls.NewCnd().Eq("code", code))
+ }
+ if company == nil {
+ company = repositories.CompanyRepository.GetByName(ctx.Tx, name)
+ }
+
+ if company == nil {
+ company = &models.Company{
+ Name: name,
+ Code: code,
+ Status: enums.StatusOk,
+ Remark: remark,
+ AuditFields: models.AuditFields{
+ CreatedAt: now,
+ CreateUserID: 0,
+ CreateUserName: "crm-sync",
+ UpdatedAt: now,
+ UpdateUserID: 0,
+ UpdateUserName: "crm-sync",
+ },
+ }
+ return repositories.CompanyRepository.Create(ctx.Tx, company)
+ }
+
+ updates := map[string]any{
+ "name": name,
+ "status": enums.StatusOk,
+ "update_user_id": 0,
+ "update_user_name": "crm-sync",
+ "updated_at": now,
+ }
+ if code != "" {
+ updates["code"] = code
+ }
+ if remark != "" {
+ updates["remark"] = remark
+ }
+ return repositories.CompanyRepository.Updates(ctx.Tx, company.ID, updates)
+ })
+}
+
+func (s *webhookSyncService) handleCustomerUpsert(data request.OrgSyncEventData) error {
+ name := strings.TrimSpace(data.Name)
+ if name == "" {
+ name = strings.TrimSpace(data.UserName)
+ }
+ email := strings.TrimSpace(strings.ToLower(data.Email))
+ if email == "" {
+ email = strings.TrimSpace(strings.ToLower(data.UserEmail))
+ }
+ phone := strings.TrimSpace(data.Phone)
+ crmPersonID := strings.TrimSpace(data.CRMPersonID)
+ crmCompanyID := strings.TrimSpace(data.CRMCompanyID)
+ companyName := strings.TrimSpace(data.CompanyName)
+
+ if name == "" && email == "" && phone == "" && crmPersonID == "" {
+ return errorsx.InvalidParam("at least one customer identifier is required")
+ }
+ if name == "" {
+ if email != "" {
+ name = email
+ } else if phone != "" {
+ name = phone
+ } else {
+ name = "Customer " + crmPersonID
+ }
+ }
+
+ now := time.Now()
+ return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
+ var companyID int64 = 0
+ if crmCompanyID != "" {
+ company := repositories.CompanyRepository.FindOne(ctx.Tx, sqls.NewCnd().Eq("code", crmCompanyID))
+ if company != nil {
+ companyID = company.ID
+ }
+ }
+ if companyID == 0 && companyName != "" {
+ company := repositories.CompanyRepository.GetByName(ctx.Tx, companyName)
+ if company != nil {
+ companyID = company.ID
+ } else {
+ newComp := &models.Company{
+ Name: companyName,
+ Code: crmCompanyID,
+ Status: enums.StatusOk,
+ AuditFields: models.AuditFields{
+ CreatedAt: now,
+ CreateUserID: 0,
+ CreateUserName: "crm-sync",
+ UpdatedAt: now,
+ UpdateUserID: 0,
+ UpdateUserName: "crm-sync",
+ },
+ }
+ if err := repositories.CompanyRepository.Create(ctx.Tx, newComp); err == nil {
+ companyID = newComp.ID
+ }
+ }
+ }
+
+ var customer *models.Customer
+ if crmPersonID != "" {
+ identity := repositories.CustomerIdentityRepository.FindOne(ctx.Tx, sqls.NewCnd().
+ Eq("external_source", enums.ExternalSourceTwentyCRM).
+ Eq("external_id", crmPersonID))
+ if identity != nil {
+ customer = repositories.CustomerRepository.Get(ctx.Tx, identity.CustomerID)
+ }
+ }
+ if customer == nil && email != "" {
+ customer = repositories.CustomerRepository.FindOne(ctx.Tx, sqls.NewCnd().Eq("primary_email", email))
+ }
+ if customer == nil && phone != "" {
+ customer = repositories.CustomerRepository.FindOne(ctx.Tx, sqls.NewCnd().Eq("primary_mobile", phone))
+ }
+
+ if customer == nil {
+ customer = &models.Customer{
+ Name: name,
+ PrimaryEmail: email,
+ PrimaryMobile: phone,
+ CompanyID: companyID,
+ Status: enums.StatusOk,
+ Remark: data.JobTitle,
+ AuditFields: models.AuditFields{
+ CreatedAt: now,
+ CreateUserID: 0,
+ CreateUserName: "crm-sync",
+ UpdatedAt: now,
+ UpdateUserID: 0,
+ UpdateUserName: "crm-sync",
+ },
+ }
+ if err := repositories.CustomerRepository.Create(ctx.Tx, customer); err != nil {
+ return err
+ }
+ } else {
+ updates := map[string]any{
+ "name": name,
+ "status": enums.StatusOk,
+ "update_user_id": 0,
+ "update_user_name": "crm-sync",
+ "updated_at": now,
+ }
+ if email != "" {
+ updates["primary_email"] = email
+ }
+ if phone != "" {
+ updates["primary_mobile"] = phone
+ }
+ if companyID > 0 {
+ updates["company_id"] = companyID
+ }
+ if data.JobTitle != "" {
+ updates["remark"] = data.JobTitle
+ }
+ if err := repositories.CustomerRepository.Updates(ctx.Tx, customer.ID, updates); err != nil {
+ return err
+ }
+ }
+
+ if crmPersonID != "" {
+ identity := repositories.CustomerIdentityRepository.FindOne(ctx.Tx, sqls.NewCnd().
+ Eq("external_source", enums.ExternalSourceTwentyCRM).
+ Eq("external_id", crmPersonID))
+ if identity == nil {
+ _ = repositories.CustomerIdentityRepository.Create(ctx.Tx, &models.CustomerIdentity{
+ CustomerID: customer.ID,
+ ExternalSource: enums.ExternalSourceTwentyCRM,
+ ExternalID: crmPersonID,
+ Status: enums.StatusOk,
+ AuditFields: models.AuditFields{
+ CreatedAt: now,
+ CreateUserID: 0,
+ CreateUserName: "crm-sync",
+ UpdatedAt: now,
+ UpdateUserID: 0,
+ UpdateUserName: "crm-sync",
+ },
+ })
+ }
+ }
+
+ return nil
+ })
+}
diff --git a/internal/services/webhook_sync_service_test.go b/internal/services/webhook_sync_service_test.go
index 0c776f38..5cd41ab9 100644
--- a/internal/services/webhook_sync_service_test.go
+++ b/internal/services/webhook_sync_service_test.go
@@ -7,6 +7,8 @@ import (
"agent-desk/internal/repositories"
"testing"
"time"
+
+ "github.com/mlogclub/simple/sqls"
)
func TestWebhookDOSOrgSync_OrgEvents(t *testing.T) {
@@ -249,3 +251,80 @@ func TestOrganizationService_CreateAndManageMembers(t *testing.T) {
t.Fatalf("expected 1 member after removal, got %d", len(members))
}
}
+
+func TestWebhookSync_CompanyAndCustomerEvents(t *testing.T) {
+ db := setupAuthServiceTestDB(t)
+ svc := newWebhookSyncService()
+
+ // 1. Test company.created
+ err := svc.HandleOrgSync(request.OrgSyncWebhookRequest{
+ Event: "company.created",
+ Timestamp: time.Now().Format(time.RFC3339),
+ Data: request.OrgSyncEventData{
+ CRMCompanyID: "comp_crm_001",
+ Name: "MetaDOS LLC",
+ DomainName: "metados.com",
+ Address: "Ho Chi Minh City, Vietnam",
+ Tier: "enterprise",
+ AccountOwnerEmail: "sales@crove.com",
+ },
+ })
+ if err != nil {
+ t.Fatalf("HandleOrgSync company.created failed: %v", err)
+ }
+
+ comp := repositories.CompanyRepository.GetByName(db, "MetaDOS LLC")
+ if comp == nil || comp.Code != "comp_crm_001" || comp.Status != enums.StatusOk {
+ t.Fatalf("unexpected created company: %+v", comp)
+ }
+
+ // 2. Test customer.created
+ err = svc.HandleOrgSync(request.OrgSyncWebhookRequest{
+ Event: "customer.created",
+ Timestamp: time.Now().Format(time.RFC3339),
+ Data: request.OrgSyncEventData{
+ CRMPersonID: "pers_crm_001",
+ CRMCompanyID: "comp_crm_001",
+ CompanyName: "MetaDOS LLC",
+ Name: "Nguyen Van A",
+ Email: "customer_a@metados.com",
+ Phone: "+84901234567",
+ JobTitle: "CTO",
+ },
+ })
+ if err != nil {
+ t.Fatalf("HandleOrgSync customer.created failed: %v", err)
+ }
+
+ cust := repositories.CustomerRepository.FindOne(db, sqls.NewCnd().Eq("primary_email", "customer_a@metados.com"))
+ if cust == nil || cust.Name != "Nguyen Van A" || cust.PrimaryMobile != "+84901234567" || cust.CompanyID != comp.ID {
+ t.Fatalf("unexpected created customer: %+v", cust)
+ }
+
+ identity := repositories.CustomerIdentityRepository.FindOne(db, sqls.NewCnd().
+ Eq("external_source", enums.ExternalSourceTwentyCRM).
+ Eq("external_id", "pers_crm_001"))
+ if identity == nil || identity.CustomerID != cust.ID {
+ t.Fatalf("unexpected customer identity: %+v", identity)
+ }
+
+ // 3. Test customer.updated
+ err = svc.HandleOrgSync(request.OrgSyncWebhookRequest{
+ Event: "customer.updated",
+ Timestamp: time.Now().Format(time.RFC3339),
+ Data: request.OrgSyncEventData{
+ CRMPersonID: "pers_crm_001",
+ Name: "Nguyen Van A (Updated)",
+ Email: "customer_a@metados.com",
+ JobTitle: "VP of Engineering",
+ },
+ })
+ if err != nil {
+ t.Fatalf("HandleOrgSync customer.updated failed: %v", err)
+ }
+
+ cust = repositories.CustomerRepository.Get(db, cust.ID)
+ if cust == nil || cust.Name != "Nguyen Van A (Updated)" || cust.Remark != "VP of Engineering" {
+ t.Fatalf("unexpected updated customer: %+v", cust)
+ }
+}
From f3088e4cb873ced796045b91762a898eb7ccf28b Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Wed, 26 Aug 2026 15:19:47 +0700
Subject: [PATCH 23/53] docs: add CHANGELOG.md documenting versions and
features
Co-authored-by: Cursor
---
CHANGELOG.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 50 insertions(+)
create mode 100644 CHANGELOG.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 00000000..64a1c2dc
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,50 @@
+# Changelog
+
+All notable changes to the **Crove Desk** project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+---
+
+## [0.3.0] - 2026-08-26
+
+### Added
+- **2-Tier Hybrid Architecture**: Implemented local database mirroring for `Company` and `Customer` entities alongside deep Agentic Tool Calling via MCP.
+- **Bi-directional Webhook Synchronization**: Added support for event-driven synchronization (`company.created`, `company.updated`, `customer.created`, `customer.updated`, `organization.created`, `organization.member.added`) with HMAC-SHA256 signature verification.
+- **Twenty CRM MCP Integration**: Added support for connecting Crove Desk AI Agent to Twenty CRM MCP server (`twenty_crm.get_subscription_status`, `twenty_crm.create_opportunity`, `twenty_crm.create_task`).
+- **Vietnamese Language Support (`vi-VN`)**: Added complete Vietnamese localization files and implemented `LanguageToggle` component in navigation header and user menu.
+- **AI Agent Loop Live Test Suite**: Added comprehensive live integration tests for Answerability Gate, Knowledge Base context grounding, and function calling tool loops.
+
+### Changed
+- **Typography & Font Resolution**: Replaced broken Geist/Times New Roman font fallback with Tailwind CSS v4 `@theme inline` mapping to Inter font with Latin and Vietnamese character subsets.
+- **Sidebar Font Sizing**: Refined dashboard navigation sidebar font size to compact 13.5px / 13px for improved scannability and professional desktop density.
+- **System Architecture Documentation**: Updated `docs/ARCHITECTURE.md` with 2-Tier Hybrid Architecture diagrams and webhook event specifications.
+
+---
+
+## [0.2.0] - 2026-08-25
+
+### Added
+- **OpenAI-Compatible AI Configuration**: Supported configuring LLM and Embedding models via `.env` environment variables (`OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_LLM_MODEL`, `OPENAI_EMBEDDING_MODEL`, `OPENAI_EMBEDDING_DIMENSION`).
+- **DOS.AI Provider Integration**: Configured live support for DOS.AI (`dos-ai` LLM model and `qwen3-embedding-4b` 2560-dim embedding model).
+- **Automated AI Bootstrap & Sync**: Implemented `InitAI` startup hook to automatically seed and synchronize default AI model configurations into PostgreSQL.
+- **Default Crove Desk Knowledge Base**: Added auto-seeding of official Crove Desk Knowledge Base and 7 core FAQ entries with background vector indexing in Qdrant.
+- **Dynamic Company Branding**: Added `COMPANY_NAME` and `COMPANY_LOGO_URL` configuration exposed via `/api/config` and applied to Login, Workspace Switcher, Legal document pages, and Support Center header.
+- **OAuth 2.1 with PKCE S256**: Implemented secure OIDC authorization code exchange with PKCE code challenge and verifier.
+- **Password Login Toggle**: Added `PASSWORD_LOGIN_ENABLED` setting to enforce SSO-only login flows.
+
+### Fixed
+- Fixed Next.js static export SPA routing for `/dashboard/` trailing slashes.
+- Fixed embedded locale file path resolution on Windows environments.
+
+---
+
+## [0.1.0] - 2026-08-22
+
+### Added
+- **Repository Initialization**: Forked from `huabeitech/agent-desk` to `DOS/Crove-Desk`.
+- **PostgreSQL Database Support**: Added PostgreSQL driver (`gorm.io/driver/postgres`) and normalized GORM model schema types for cross-database compatibility (PostgreSQL, MySQL, SQLite).
+- **Supabase Integration**: Connected to Supabase `dos.me` PostgreSQL database under schema `desk` with Session Pooler.
+- **Multi-tenant Organization Architecture**: Added `Organization` and `OrganizationMember` models with JIT workspace provisioning upon OIDC login.
+- **Production Deployment**: Configured `docker-compose.prod.yml`, Qdrant Vector DB, and Cloudflare Tunnel routing for `desk.crove.com` on GCP VM `crove-server`.
From bedd3666da2fa9433c253015b23087928f61b94d Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Wed, 26 Aug 2026 17:51:40 +0700
Subject: [PATCH 24/53] feat(sync): add outbound event dispatching for company
and customer creation and register webhook route aliases
Co-authored-by: Cursor
---
internal/bootstrap/routes.go | 3 +++
internal/services/company_service.go | 13 +++++++++++--
internal/services/customer_service.go | 14 ++++++++++++--
internal/services/webhook_sync_service.go | 17 +++++++++++------
4 files changed, 37 insertions(+), 10 deletions(-)
diff --git a/internal/bootstrap/routes.go b/internal/bootstrap/routes.go
index 72d692ec..bca8e450 100644
--- a/internal/bootstrap/routes.go
+++ b/internal/bootstrap/routes.go
@@ -29,6 +29,9 @@ func registerApiChannelRoutes(group *gin.RouterGroup) {
func registerApiWebhookRoutes(group *gin.RouterGroup) {
group.POST("/org-sync", api.OrgSyncWebhook)
group.POST("/dos-org-sync", api.DOSOrgSyncWebhook)
+ group.POST("/crm-sync", api.OrgSyncWebhook)
+ group.POST("/dos-events", api.OrgSyncWebhook)
+ group.POST("/events", api.OrgSyncWebhook)
}
func registerApiCustomerRoutes(group *gin.RouterGroup) {
diff --git a/internal/services/company_service.go b/internal/services/company_service.go
index 6783982c..e46dfcc5 100644
--- a/internal/services/company_service.go
+++ b/internal/services/company_service.go
@@ -1,6 +1,10 @@
package services
import (
+ "fmt"
+ "strings"
+ "time"
+
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/dto/request"
@@ -8,8 +12,6 @@ import (
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/utils"
"agent-desk/internal/repositories"
- "strings"
- "time"
"agent-desk/internal/pkg/httpx/params"
@@ -80,6 +82,13 @@ func (s *companyService) CreateCompany(req request.CreateCompanyRequest, operato
if err := repositories.CompanyRepository.Create(sqls.DB(), item); err != nil {
return nil, err
}
+
+ WebhookSyncService.DispatchOutboundEvent("company.created", request.OrgSyncEventData{
+ DeskCompanyID: fmt.Sprintf("comp_%d", item.ID),
+ Name: item.Name,
+ DomainName: item.Remark,
+ })
+
return item, nil
}
diff --git a/internal/services/customer_service.go b/internal/services/customer_service.go
index 14d8fa4a..abbd2341 100644
--- a/internal/services/customer_service.go
+++ b/internal/services/customer_service.go
@@ -3,7 +3,10 @@ package services
import (
"crypto/md5"
"encoding/hex"
+ "fmt"
"log/slog"
+ "strings"
+ "time"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto"
@@ -13,8 +16,6 @@ import (
"agent-desk/internal/pkg/openidentity"
"agent-desk/internal/pkg/utils"
"agent-desk/internal/repositories"
- "strings"
- "time"
"agent-desk/internal/pkg/httpx/params"
@@ -217,6 +218,15 @@ func (s *customerService) CreateCustomer(req request.CreateCustomerRequest, oper
if err := repositories.CustomerRepository.Create(sqls.DB(), item); err != nil {
return nil, err
}
+
+ WebhookSyncService.DispatchOutboundEvent("customer.created", request.OrgSyncEventData{
+ DeskCustomerID: fmt.Sprintf("cust_%d", item.ID),
+ Name: item.Name,
+ Email: item.PrimaryEmail,
+ Phone: item.PrimaryMobile,
+ Source: "crove_desk",
+ })
+
return item, nil
}
diff --git a/internal/services/webhook_sync_service.go b/internal/services/webhook_sync_service.go
index 4a5bfb3e..ed22c6b9 100644
--- a/internal/services/webhook_sync_service.go
+++ b/internal/services/webhook_sync_service.go
@@ -113,7 +113,7 @@ func (s *webhookSyncService) HandleDOSOrgSync(req request.DOSOrgSyncWebhookReque
return s.HandleOrgSync(req)
}
-func (s *webhookSyncService) DispatchOutboundOrgEvent(event string, data request.OrgSyncEventData) {
+func (s *webhookSyncService) DispatchOutboundEvent(event string, data request.OrgSyncEventData) {
cfg := config.GetCurrent()
if cfg == nil {
return
@@ -131,7 +131,7 @@ func (s *webhookSyncService) DispatchOutboundOrgEvent(event string, data request
bodyBytes, err := json.Marshal(payload)
if err != nil {
- slog.Error("failed to marshal outbound org event", "event", event, "error", err)
+ slog.Error("failed to marshal outbound event", "event", event, "error", err)
return
}
@@ -154,10 +154,11 @@ func (s *webhookSyncService) DispatchOutboundOrgEvent(event string, data request
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest(http.MethodPost, outboundURL, bytes.NewBuffer(bodyBytes))
if err != nil {
- slog.Error("failed to create outbound org sync request", "url", outboundURL, "error", err)
+ slog.Error("failed to create outbound sync request", "url", outboundURL, "error", err)
return
}
req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("X-DOS-Event", event)
if signature != "" {
req.Header.Set("X-DOS-Signature", signature)
req.Header.Set("X-Webhook-Signature", signature)
@@ -165,19 +166,23 @@ func (s *webhookSyncService) DispatchOutboundOrgEvent(event string, data request
resp, err := client.Do(req)
if err != nil {
- slog.Error("failed to dispatch outbound org sync event", "event", event, "url", outboundURL, "error", err)
+ slog.Error("failed to dispatch outbound sync event", "event", event, "url", outboundURL, "error", err)
return
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
- slog.Warn("outbound org sync event returned non-2xx status", "event", event, "status", resp.StatusCode)
+ slog.Warn("outbound sync event returned non-2xx status", "event", event, "status", resp.StatusCode)
} else {
- slog.Info("outbound org sync event dispatched successfully", "event", event, "orgId", data.OrgID)
+ slog.Info("outbound sync event dispatched successfully", "event", event)
}
}()
}
+func (s *webhookSyncService) DispatchOutboundOrgEvent(event string, data request.OrgSyncEventData) {
+ s.DispatchOutboundEvent(event, data)
+}
+
func (s *webhookSyncService) handleOrgUpsert(data request.OrgSyncEventData) error {
now := time.Now()
orgCode := strings.TrimSpace(data.OrgID)
From 9711beba53f78883db737c23309f7344cf8ac9cd Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Wed, 26 Aug 2026 21:11:44 +0700
Subject: [PATCH 25/53] feat(channel): implement native Telegram channel
integration and auto-connect gateway
- Add Telegram Bot API client and types in internal/telegram
- Implement inbound webhook handler and customer message ingestion in TelegramInboundService
- Implement asynchronous outbound message delivery via TelegramOutboundService and channel outbox queue
- Add Telegram channel management and automated setup in Dashboard Channels UI
- Update webhook signature verification and timestamp checking for event synchronizations
- Add full unit and integration tests across Telegram and Webhook services
Co-authored-by: Cursor
---
docker/agent-desk.supabase.example.yaml | 9 +
internal/bootstrap/routes.go | 5 +
internal/bootstrap/server.go | 1 +
internal/handlers/third/telegram_handler.go | 36 ++++
.../handlers/third/telegram_handler_test.go | 150 +++++++++++++++
internal/pkg/dto/dto.go | 7 +
internal/pkg/dto/request/webhook_request.go | 4 +
internal/pkg/enums/external_identity.go | 2 +
internal/pkg/enums/wxwork_kf.go | 1 +
internal/pkg/i18nx/locales/en-US.yml | 1 +
internal/pkg/i18nx/locales/zh-CN.yml | 1 +
.../channel_message_outbox_service.go | 64 +++++++
internal/services/channel_service.go | 36 +++-
internal/services/cronx/cron.go | 4 +
internal/services/message_service.go | 9 +
internal/services/telegram_inbound_service.go | 113 +++++++++++
.../services/telegram_integration_test.go | 179 ++++++++++++++++++
.../services/telegram_outbound_service.go | 150 +++++++++++++++
internal/services/webhook_sync_service.go | 77 +++++++-
.../services/webhook_sync_service_test.go | 59 +++++-
internal/telegram/client.go | 115 +++++++++++
internal/telegram/client_test.go | 83 ++++++++
internal/telegram/types.go | 61 ++++++
.../dashboard/channels/_components/edit.tsx | 124 ++++++++++--
.../(dashboard)/dashboard/channels/page.tsx | 8 +
web/lib/generated/enums.ts | 15 +-
web/messages/en-US.json | 7 +
web/messages/vi-VN.json | 7 +
web/messages/zh-CN.json | 7 +
29 files changed, 1305 insertions(+), 30 deletions(-)
create mode 100644 internal/handlers/third/telegram_handler.go
create mode 100644 internal/handlers/third/telegram_handler_test.go
create mode 100644 internal/services/telegram_inbound_service.go
create mode 100644 internal/services/telegram_integration_test.go
create mode 100644 internal/services/telegram_outbound_service.go
create mode 100644 internal/telegram/client.go
create mode 100644 internal/telegram/client_test.go
create mode 100644 internal/telegram/types.go
diff --git a/docker/agent-desk.supabase.example.yaml b/docker/agent-desk.supabase.example.yaml
index 295ff373..31b1eb51 100644
--- a/docker/agent-desk.supabase.example.yaml
+++ b/docker/agent-desk.supabase.example.yaml
@@ -77,6 +77,11 @@ mcp:
endpoint: "http://127.0.0.1:8083/api/mcp"
timeoutMs: 15000
headers: {}
+ twenty_crm:
+ enabled: true
+ endpoint: "https://crm.crove.com/api/mcp"
+ timeoutMs: 15000
+ headers: {}
oidc:
enabled: true
@@ -89,3 +94,7 @@ oidc:
- openid
- profile
- email
+
+webhook:
+ orgSyncSecret: "ad3726c93d4951e8c86a73b138bfde865fde427adc7384d5"
+ outboundUrl: "https://api.dos.me/internal/events/publish"
diff --git a/internal/bootstrap/routes.go b/internal/bootstrap/routes.go
index bca8e450..efc41a9d 100644
--- a/internal/bootstrap/routes.go
+++ b/internal/bootstrap/routes.go
@@ -422,3 +422,8 @@ func registerThirdWechatRoutes(group *gin.RouterGroup) {
group.GET("/callback", third.WechatGetCallback)
group.POST("/callback", third.WechatPostCallback)
}
+
+func registerThirdTelegramRoutes(group *gin.RouterGroup) {
+ group.POST("/webhook", third.TelegramPostWebhook)
+ group.POST("/webhook/:channel_id", third.TelegramPostWebhook)
+}
diff --git a/internal/bootstrap/server.go b/internal/bootstrap/server.go
index 6b04e0b6..2cef4a41 100644
--- a/internal/bootstrap/server.go
+++ b/internal/bootstrap/server.go
@@ -194,6 +194,7 @@ func addRouter(app *gin.Engine) {
thirdGroup := app.Group("/api/third")
registerThirdWechatRoutes(thirdGroup.Group("/wechat"))
+ registerThirdTelegramRoutes(thirdGroup.Group("/telegram"))
}
type spaShellRewrite struct {
diff --git a/internal/handlers/third/telegram_handler.go b/internal/handlers/third/telegram_handler.go
new file mode 100644
index 00000000..a709791c
--- /dev/null
+++ b/internal/handlers/third/telegram_handler.go
@@ -0,0 +1,36 @@
+package third
+
+import (
+ "bytes"
+ "io"
+ "net/http"
+ "strings"
+
+ "agent-desk/internal/services"
+
+ "github.com/gin-gonic/gin"
+)
+
+// TelegramPostWebhook receives incoming Webhook events from Telegram Bot API.
+func TelegramPostWebhook(ctx *gin.Context) {
+ channelID := strings.TrimSpace(ctx.Param("channel_id"))
+ if channelID == "" {
+ channelID = strings.TrimSpace(ctx.Query("channel_id"))
+ }
+
+ secretHeader := ctx.GetHeader("X-Telegram-Bot-Api-Secret-Token")
+
+ 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.TelegramInboundService.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/telegram_handler_test.go b/internal/handlers/third/telegram_handler_test.go
new file mode 100644
index 00000000..18f178c7
--- /dev/null
+++ b/internal/handlers/third/telegram_handler_test.go
@@ -0,0 +1,150 @@
+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/glebarez/sqlite"
+ "github.com/mlogclub/simple/sqls"
+ "gorm.io/gorm"
+ "gorm.io/gorm/schema"
+)
+
+func setupThirdHandlerTestDB(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)
+ }
+ _ = db.AutoMigrate(
+ &models.Channel{},
+ &models.ChannelMessageOutbox{},
+ &models.Customer{},
+ &models.CustomerIdentity{},
+ &models.CustomerContact{},
+ &models.Conversation{},
+ &models.ConversationParticipant{},
+ &models.ConversationReadState{},
+ &models.ConversationEventLog{},
+ &models.ConversationInterrupt{},
+ &models.Message{},
+ &models.AIAgent{},
+ &models.User{},
+ &models.Role{},
+ &models.UserRole{},
+ &models.Permission{},
+ &models.RolePermission{},
+ &models.UserPermission{},
+ )
+ sqls.SetDB(db)
+ return db
+}
+
+func TestTelegramPostWebhook_Handler(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ db := setupThirdHandlerTestDB(t)
+
+ now := time.Now()
+ agent := &models.AIAgent{
+ Name: "Telegram Bot Agent",
+ ServiceMode: enums.IMConversationServiceModeAIFirst,
+ PublishedRevisionID: 1,
+ WelcomeMessage: "Hello!",
+ Status: enums.StatusOk,
+ AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now},
+ }
+ _ = db.Create(agent)
+
+ tgConfig, _ := json.Marshal(dto.TelegramChannelConfig{
+ BotToken: "123456:ABC-DEF",
+ BotUsername: "test_bot",
+ WebhookSecret: "my_secret_token_123",
+ WelcomeMessage: "Welcome!",
+ })
+
+ operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"}
+ channel, err := services.ChannelService.CreateChannel(request.CreateChannelRequest{
+ Name: "Telegram Channel",
+ ChannelType: enums.ChannelTypeTelegram,
+ AIAgentID: agent.ID,
+ AIAgentRolloutPercent: 100,
+ ConfigJSON: string(tgConfig),
+ Status: int(enums.StatusOk),
+ }, operator)
+ if err != nil {
+ t.Fatalf("CreateChannel failed: %v", err)
+ }
+
+ router := gin.New()
+ router.POST("/api/third/telegram/webhook/:channel_id", TelegramPostWebhook)
+ router.POST("/api/third/telegram/webhook", TelegramPostWebhook)
+
+ // 1. Test unauthorized when secret doesn't match
+ updatePayload := []byte(`{
+ "update_id": 112233,
+ "message": {
+ "message_id": 999,
+ "from": {"id": 777888, "first_name": "Alice"},
+ "chat": {"id": 777888, "type": "private"},
+ "text": "Hello from Telegram!"
+ }
+ }`)
+
+ req, _ := http.NewRequest(http.MethodPost, "/api/third/telegram/webhook/"+channel.ChannelID, bytes.NewBuffer(updatePayload))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("X-Telegram-Bot-Api-Secret-Token", "wrong_token")
+
+ 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. Test success with valid secret
+ req2, _ := http.NewRequest(http.MethodPost, "/api/third/telegram/webhook/"+channel.ChannelID, bytes.NewBuffer(updatePayload))
+ req2.Header.Set("Content-Type", "application/json")
+ req2.Header.Set("X-Telegram-Bot-Api-Secret-Token", "my_secret_token_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 message in database
+ identity := repositories.CustomerIdentityRepository.FindOne(db, sqls.NewCnd().
+ Eq("external_source", enums.ExternalSourceTelegram).
+ Eq("external_id", "777888"))
+ if identity == nil {
+ t.Fatalf("expected customer identity for 777888")
+ }
+}
diff --git a/internal/pkg/dto/dto.go b/internal/pkg/dto/dto.go
index 796c2747..4e2ff56c 100644
--- a/internal/pkg/dto/dto.go
+++ b/internal/pkg/dto/dto.go
@@ -32,3 +32,10 @@ type WechatMPChannelConfig struct {
ThemeColor string `json:"themeColor"`
UserTokenSecret string `json:"userTokenSecret,omitempty"`
}
+
+type TelegramChannelConfig struct {
+ BotToken string `json:"botToken"`
+ BotUsername string `json:"botUsername,omitempty"`
+ WebhookSecret string `json:"webhookSecret,omitempty"`
+ WelcomeMessage string `json:"welcomeMessage,omitempty"`
+}
diff --git a/internal/pkg/dto/request/webhook_request.go b/internal/pkg/dto/request/webhook_request.go
index 3ddcec98..4de0ae6c 100644
--- a/internal/pkg/dto/request/webhook_request.go
+++ b/internal/pkg/dto/request/webhook_request.go
@@ -4,6 +4,7 @@ type OrgSyncEventData struct {
// Organization fields
OrgID string `json:"org_id"`
ID string `json:"id,omitempty"`
+ GlobalOrgID string `json:"global_org_id,omitempty"`
OrgName string `json:"org_name"`
Name string `json:"name,omitempty"`
Slug string `json:"slug,omitempty"`
@@ -17,9 +18,12 @@ type OrgSyncEventData struct {
// Company fields
CRMCompanyID string `json:"crm_company_id,omitempty"`
DeskCompanyID string `json:"desk_company_id,omitempty"`
+ CompanyID string `json:"company_id,omitempty"`
DomainName string `json:"domain_name,omitempty"`
+ Domain string `json:"domain,omitempty"`
Address string `json:"address,omitempty"`
Tier string `json:"tier,omitempty"`
+ TaxCode string `json:"tax_code,omitempty"`
AccountOwnerEmail string `json:"account_owner_email,omitempty"`
// Customer fields
diff --git a/internal/pkg/enums/external_identity.go b/internal/pkg/enums/external_identity.go
index f1084ef4..5ddce857 100644
--- a/internal/pkg/enums/external_identity.go
+++ b/internal/pkg/enums/external_identity.go
@@ -10,6 +10,7 @@ const (
ExternalSourceWxWorkKF ExternalSource = "wxwork_kf" // 企业微信客服
ExternalSourceUser ExternalSource = "user" // 用户信息
ExternalSourceTwentyCRM ExternalSource = "twenty_crm" // Twenty CRM
+ ExternalSourceTelegram ExternalSource = "telegram" // Telegram Bot
)
var externalSourceLabelMap = map[ExternalSource]string{
@@ -17,6 +18,7 @@ var externalSourceLabelMap = map[ExternalSource]string{
ExternalSourceWxWorkKF: "企业微信客服",
ExternalSourceUser: "用户",
ExternalSourceTwentyCRM: "Twenty CRM",
+ ExternalSourceTelegram: "Telegram",
}
func GetExternalSourceLabel(v ExternalSource) string {
diff --git a/internal/pkg/enums/wxwork_kf.go b/internal/pkg/enums/wxwork_kf.go
index 61e766fb..db7d8503 100644
--- a/internal/pkg/enums/wxwork_kf.go
+++ b/internal/pkg/enums/wxwork_kf.go
@@ -21,6 +21,7 @@ const (
ChannelTypeWeb = "web"
ChannelTypeWechatMP = "wechat_mp"
ChannelTypeWxWorkKF = "wxwork_kf"
+ ChannelTypeTelegram = "telegram"
)
type WxWorkKFMessageSendStatus string
diff --git a/internal/pkg/i18nx/locales/en-US.yml b/internal/pkg/i18nx/locales/en-US.yml
index ca0adeb7..261d8d70 100644
--- a/internal/pkg/i18nx/locales/en-US.yml
+++ b/internal/pkg/i18nx/locales/en-US.yml
@@ -231,6 +231,7 @@ error.e0231: "No matching WeCom channel was found."
error.e0232: "No schedules were generated."
error.auth.expired: "Your session has expired. Please sign in again."
error.auth.passwordLoginDisabled: "Username and password login is disabled. Please use SSO to sign in."
+error.auth.invalidSignature: "Invalid request signature or secret token."
error.e0234: "No available AI configuration is configured."
error.e0235: "No available embedding model is configured."
error.e0236: "Permission not found."
diff --git a/internal/pkg/i18nx/locales/zh-CN.yml b/internal/pkg/i18nx/locales/zh-CN.yml
index e7c63410..1aa6e61e 100644
--- a/internal/pkg/i18nx/locales/zh-CN.yml
+++ b/internal/pkg/i18nx/locales/zh-CN.yml
@@ -231,6 +231,7 @@ error.e0231: "未找到匹配的企业微信接入渠道"
error.e0232: "未生成任何排班"
error.auth.expired: "未登录或登录已过期"
error.auth.passwordLoginDisabled: "账号密码登录已禁用,请使用第三方登录。"
+error.auth.invalidSignature: "请求签名或凭证 Token 无效"
error.e0234: "未配置可用的 AI 配置"
error.e0235: "未配置可用的 Embedding 模型"
error.e0236: "权限不存在"
diff --git a/internal/services/channel_message_outbox_service.go b/internal/services/channel_message_outbox_service.go
index 49c7fc31..0d607476 100644
--- a/internal/services/channel_message_outbox_service.go
+++ b/internal/services/channel_message_outbox_service.go
@@ -7,6 +7,7 @@ import (
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/repositories"
"encoding/json"
+ "log/slog"
"strings"
"time"
@@ -125,6 +126,69 @@ func (s *channelMessageOutboxService) EnqueueWxWorkKFMessage(conversation *model
})
}
+func (s *channelMessageOutboxService) EnqueueTelegramMessage(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.ChannelTypeTelegram {
+ return nil
+ }
+ if message.SenderType != enums.IMSenderTypeAgent && message.SenderType != enums.IMSenderTypeAI {
+ return nil
+ }
+ if message.MessageType != enums.IMMessageTypeText && message.MessageType != enums.IMMessageTypeHTML {
+ return nil
+ }
+ if existing := s.GetByMessageID(enums.ChannelTypeTelegram, 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.ChannelTypeTelegram,
+ 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
+ }
+
+ // Trigger async dispatch immediately
+ go func() {
+ defer func() {
+ if r := recover(); r != nil {
+ slog.Error("recovered from panic in telegram outbound dispatch", "error", r)
+ }
+ }()
+ TelegramOutboundService.DispatchPendingOutbox()
+ }()
+
+ return nil
+}
+
func (s *channelMessageOutboxService) ListPending(channelType string, limit int) []models.ChannelMessageOutbox {
if limit <= 0 {
limit = 20
diff --git a/internal/services/channel_service.go b/internal/services/channel_service.go
index 48a0b10e..81c1be80 100644
--- a/internal/services/channel_service.go
+++ b/internal/services/channel_service.go
@@ -300,6 +300,21 @@ func (s *channelService) ParseWechatMPChannelConfig(raw string) (*dto.WechatMPCh
return cfg, nil
}
+func (s *channelService) ParseTelegramChannelConfig(raw string) (*dto.TelegramChannelConfig, error) {
+ raw = strings.TrimSpace(raw)
+ cfg := &dto.TelegramChannelConfig{}
+ if raw != "" {
+ if err := json.Unmarshal([]byte(raw), cfg); err != nil {
+ return nil, err
+ }
+ }
+ cfg.BotToken = strings.TrimSpace(cfg.BotToken)
+ cfg.BotUsername = strings.TrimSpace(cfg.BotUsername)
+ 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 ""
@@ -416,7 +431,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 {
+ if channelType != enums.ChannelTypeWeb && channelType != enums.ChannelTypeWechatMP && channelType != enums.ChannelTypeWxWorkKF && channelType != enums.ChannelTypeTelegram {
return nil, errorsx.InvalidParamI18n("error.e0250")
}
name := strings.TrimSpace(req.Name)
@@ -520,6 +535,25 @@ func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRe
if channel := s.GetEnabledWxWorkKFChannelByOpenKfID(cfg.OpenKfID); channel != nil && channel.ID != id {
return nil, errorsx.InvalidParamI18n("error.e0069")
}
+ case enums.ChannelTypeTelegram:
+ 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.ParseTelegramChannelConfig(configJSON)
+ if err != nil {
+ return nil, errorsx.InvalidParam("invalid telegram configuration")
+ }
+ if cfg == nil || cfg.BotToken == "" {
+ return nil, errorsx.InvalidParam("telegram botToken is required")
+ }
+ configBytes, err := json.Marshal(cfg)
+ if err != nil {
+ return nil, err
+ }
+ configJSON = string(configBytes)
}
return &models.Channel{
diff --git a/internal/services/cronx/cron.go b/internal/services/cronx/cron.go
index 5b659df5..07562fd7 100644
--- a/internal/services/cronx/cron.go
+++ b/internal/services/cronx/cron.go
@@ -26,6 +26,10 @@ func Init() {
if count > 0 {
slog.Info("wxwork kf outbox dispatched", "count", count)
}
+ tgCount := services.TelegramOutboundService.DispatchPendingOutbox()
+ if tgCount > 0 {
+ slog.Info("telegram outbox dispatched", "count", tgCount)
+ }
})
c.Start()
diff --git a/internal/services/message_service.go b/internal/services/message_service.go
index 94c9e7a0..4171ddf3 100644
--- a/internal/services/message_service.go
+++ b/internal/services/message_service.go
@@ -541,6 +541,15 @@ func (s *messageService) sendValidatedMessage(conversation *models.Conversation,
)
}
+ // Telegram 渠道消息入队,异步发送
+ if enqueueErr := ChannelMessageOutboxService.EnqueueTelegramMessage(conversation, message); enqueueErr != nil {
+ slog.Error("enqueue telegram 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/telegram_inbound_service.go b/internal/services/telegram_inbound_service.go
new file mode 100644
index 00000000..7bb01544
--- /dev/null
+++ b/internal/services/telegram_inbound_service.go
@@ -0,0 +1,113 @@
+package services
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "agent-desk/internal/models"
+ "agent-desk/internal/pkg/enums"
+ "agent-desk/internal/pkg/errorsx"
+ "agent-desk/internal/pkg/openidentity"
+ "agent-desk/internal/telegram"
+)
+
+var TelegramInboundService = newTelegramInboundService()
+
+func newTelegramInboundService() *telegramInboundService {
+ return &telegramInboundService{}
+}
+
+type telegramInboundService struct{}
+
+// HandleWebhook processes an incoming webhook Update from Telegram.
+func (s *telegramInboundService) 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.ChannelTypeTelegram, enums.StatusOk)
+ }
+ if channel == nil {
+ channel = ChannelService.Take("channel_type = ? AND status = ?", enums.ChannelTypeTelegram, enums.StatusOk)
+ }
+ if channel == nil {
+ return errorsx.InvalidParam("telegram channel not found or disabled")
+ }
+
+ cfg, err := ChannelService.ParseTelegramChannelConfig(channel.ConfigJSON)
+ if err != nil || cfg == nil || cfg.BotToken == "" {
+ return errorsx.InvalidParam("telegram channel config invalid")
+ }
+
+ if cfg.WebhookSecret != "" && strings.TrimSpace(secretHeader) != cfg.WebhookSecret {
+ return errorsx.UnauthorizedI18n("error.auth.invalidSignature")
+ }
+
+ var update telegram.Update
+ if err := json.Unmarshal(rawPayload, &update); err != nil {
+ return fmt.Errorf("unmarshal telegram update failed: %w", err)
+ }
+
+ if update.Message == nil {
+ return nil // Ignore non-message updates (e.g. edits, inline queries)
+ }
+
+ msg := update.Message
+ if msg.From == nil || msg.Chat.ID == 0 {
+ return nil
+ }
+
+ text := strings.TrimSpace(msg.Text)
+ if text == "" {
+ text = strings.TrimSpace(msg.Caption)
+ }
+ if text == "" {
+ return nil // Ignore media without captions for now
+ }
+
+ // 1. Resolve customer identity
+ externalID := fmt.Sprintf("%d", msg.Chat.ID)
+ name := strings.TrimSpace(msg.From.FirstName + " " + msg.From.LastName)
+ if name == "" {
+ name = strings.TrimSpace(msg.From.Username)
+ }
+ if name == "" {
+ name = fmt.Sprintf("Telegram User %d", msg.From.ID)
+ }
+
+ externalUser := openidentity.ExternalUser{
+ ExternalSource: enums.ExternalSourceTelegram,
+ 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 telegram conversation failed: %w", err)
+ }
+
+ // 3. Send message through MessageService (automatically triggers AI response loop or agent notification)
+ clientMsgID := fmt.Sprintf("tg_%d_%d", update.UpdateID, msg.MessageID)
+ payloadMap := map[string]any{
+ "telegram_message_id": msg.MessageID,
+ "telegram_chat_id": msg.Chat.ID,
+ "telegram_update_id": update.UpdateID,
+ }
+ 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/telegram_integration_test.go b/internal/services/telegram_integration_test.go
new file mode 100644
index 00000000..52bc6744
--- /dev/null
+++ b/internal/services/telegram_integration_test.go
@@ -0,0 +1,179 @@
+package services
+
+import (
+ "context"
+ "encoding/json"
+ "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 setupTelegramTestDB(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 telegram test tables: %v", err)
+ }
+ sqls.SetDB(db)
+ return db
+}
+
+func TestTelegramInboundAndOutboundFlow(t *testing.T) {
+ db := setupTelegramTestDB(t)
+
+ now := time.Now()
+ // 1. Create AI Agent
+ agent := &models.AIAgent{
+ Name: "Support Bot",
+ ServiceMode: enums.IMConversationServiceModeAIFirst,
+ PublishedRevisionID: 1,
+ WelcomeMessage: "Welcome to Crove Desk!",
+ Status: enums.StatusOk,
+ AuditFields: models.AuditFields{
+ CreatedAt: now,
+ UpdatedAt: now,
+ },
+ }
+ _ = db.Create(agent)
+
+ // 2. Create Telegram Channel
+ tgConfig, _ := json.Marshal(dto.TelegramChannelConfig{
+ BotToken: "test-bot-token-123",
+ BotUsername: "crove_desk_bot",
+ WebhookSecret: "secret-webhook-token",
+ WelcomeMessage: "Welcome to Telegram Support!",
+ })
+
+ operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"}
+ channel, err := ChannelService.CreateChannel(request.CreateChannelRequest{
+ Name: "Telegram Support Channel",
+ ChannelType: enums.ChannelTypeTelegram,
+ AIAgentID: agent.ID,
+ AIAgentRolloutPercent: 100,
+ ConfigJSON: string(tgConfig),
+ Status: int(enums.StatusOk),
+ }, operator)
+ if err != nil {
+ t.Fatalf("CreateChannel failed: %v", err)
+ }
+
+ // 3. Simulate Inbound Telegram Webhook update
+ updatePayload := []byte(`{
+ "update_id": 998877,
+ "message": {
+ "message_id": 12345,
+ "from": {
+ "id": 888999,
+ "is_bot": false,
+ "first_name": "John",
+ "last_name": "Doe",
+ "username": "johndoe",
+ "language_code": "vi"
+ },
+ "chat": {
+ "id": 888999,
+ "type": "private",
+ "first_name": "John"
+ },
+ "date": 1756200000,
+ "text": "Chào bạn, tôi cần hỗ trợ nâng cấp gói Crove Enterprise!"
+ }
+ }`)
+
+ ctx := context.Background()
+ err = TelegramInboundService.HandleWebhook(ctx, channel.ChannelID, "secret-webhook-token", updatePayload)
+ if err != nil {
+ t.Fatalf("HandleWebhook failed: %v", err)
+ }
+
+ // Verify Customer created
+ identity := repositories.CustomerIdentityRepository.FindOne(db, sqls.NewCnd().
+ Eq("external_source", enums.ExternalSourceTelegram).
+ Eq("external_id", "888999"))
+ if identity == nil {
+ t.Fatalf("expected customer identity to be created for Telegram user")
+ }
+
+ customer := repositories.CustomerRepository.Get(db, identity.CustomerID)
+ if customer == nil || customer.Name != "John Doe" {
+ t.Fatalf("unexpected customer: %+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 != "Chào bạn, tôi cần hỗ trợ nâng cấp gói Crove Enterprise!" {
+ t.Fatalf("unexpected customer message: %+v", msg)
+ }
+
+ // 4. Simulate AI / Agent Reply and test Outbox Enqueue & Dispatch
+ replyMsg, err := MessageService.SendAIMessage(conv.ID, agent.ID, "ai_msg_001", enums.IMMessageTypeText, "Cảm ơn bạn! Đội ngũ Crove sẽ hỗ trợ bạn ngay.", "", operator)
+ if err != nil {
+ t.Fatalf("SendAIMessage failed: %v", err)
+ }
+
+ outbox := ChannelMessageOutboxService.GetByMessageID(enums.ChannelTypeTelegram, replyMsg.ID)
+ if outbox == nil {
+ t.Fatalf("expected telegram outbox entry for AI message")
+ }
+ if outbox.SendStatus != string(enums.ChannelMessageOutboxStatusPending) && outbox.SendStatus != string(enums.ChannelMessageOutboxStatusSending) {
+ t.Logf("Outbox send status: %s", outbox.SendStatus)
+ }
+}
+
+func stringsContains(s, substr string) bool {
+ return len(s) >= len(substr) && (s == substr || len(substr) == 0 || (len(s) > 0 && len(substr) > 0 && indexOf(s, substr) >= 0))
+}
+
+func indexOf(s, substr string) int {
+ for i := 0; i+len(substr) <= len(s); i++ {
+ if s[i:i+len(substr)] == substr {
+ return i
+ }
+ }
+ return -1
+}
diff --git a/internal/services/telegram_outbound_service.go b/internal/services/telegram_outbound_service.go
new file mode 100644
index 00000000..c77dba1d
--- /dev/null
+++ b/internal/services/telegram_outbound_service.go
@@ -0,0 +1,150 @@
+package services
+
+import (
+ "context"
+ "log/slog"
+ "strconv"
+ "time"
+
+ "agent-desk/internal/models"
+ "agent-desk/internal/pkg/enums"
+ "agent-desk/internal/repositories"
+ "agent-desk/internal/telegram"
+
+ "github.com/mlogclub/simple/sqls"
+)
+
+const (
+ telegramOutboxBatchSize = 20
+ telegramOutboxMaxRetry = 5
+)
+
+var TelegramOutboundService = newTelegramOutboundService()
+
+func newTelegramOutboundService() *telegramOutboundService {
+ return &telegramOutboundService{}
+}
+
+type telegramOutboundService struct{}
+
+func (s *telegramOutboundService) DispatchPendingOutbox() int {
+ return s.doDispatchPendingOutbox(telegramOutboxBatchSize)
+}
+
+func (s *telegramOutboundService) doDispatchPendingOutbox(limit int) int {
+ if limit <= 0 {
+ limit = telegramOutboxBatchSize
+ }
+ items := ChannelMessageOutboxService.ListPending(enums.ChannelTypeTelegram, 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 telegram outbox failed",
+ "outbox_id", items[i].ID,
+ "error", err,
+ )
+ continue
+ }
+ successCount++
+ }
+ return successCount
+}
+
+func (s *telegramOutboundService) processOutbox(outboxID int64) error {
+ outbox := ChannelMessageOutboxService.Get(outboxID)
+ if outbox == nil {
+ return nil
+ }
+ if outbox.ChannelType != enums.ChannelTypeTelegram {
+ 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, "telegram channel not found or disabled")
+ }
+ cfg, err := ChannelService.ParseTelegramChannelConfig(channel.ConfigJSON)
+ if err != nil || cfg == nil || cfg.BotToken == "" {
+ return s.markOutboxFailed(outbox, "telegram bot token not configured")
+ }
+
+ // Resolve target Telegram ChatID
+ var chatID int64
+ customerIdentity := repositories.CustomerIdentityRepository.FindOne(sqls.DB(), sqls.NewCnd().
+ Eq("customer_id", conversation.CustomerID).
+ Eq("external_source", enums.ExternalSourceTelegram))
+ if customerIdentity != nil {
+ if id, err := strconv.ParseInt(customerIdentity.ExternalID, 10, 64); err == nil {
+ chatID = id
+ }
+ }
+ if chatID == 0 {
+ return s.markOutboxFailed(outbox, "unable to resolve telegram chat_id")
+ }
+
+ // Send message via Telegram Client
+ client := telegram.NewClient(cfg.BotToken)
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+
+ _, sendErr := client.SendMessage(ctx, telegram.SendMessageRequest{
+ ChatID: chatID,
+ Text: 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 *telegramOutboundService) markOutboxFailed(outbox *models.ChannelMessageOutbox, errMsg string) error {
+ if outbox == nil {
+ return nil
+ }
+ retryCount := outbox.RetryCount + 1
+ status := string(enums.ChannelMessageOutboxStatusFailed)
+ if retryCount >= telegramOutboxMaxRetry {
+ 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/webhook_sync_service.go b/internal/services/webhook_sync_service.go
index ed22c6b9..8eaf4ae0 100644
--- a/internal/services/webhook_sync_service.go
+++ b/internal/services/webhook_sync_service.go
@@ -13,7 +13,9 @@ import (
"encoding/hex"
"encoding/json"
"log/slog"
+ "math"
"net/http"
+ "strconv"
"strings"
"time"
@@ -44,13 +46,58 @@ func (s *webhookSyncService) VerifySignature(payload []byte, signature string) b
return true
}
- sig := strings.TrimSpace(signature)
+ sigHeader := strings.TrimSpace(signature)
+ if sigHeader == "" {
+ return false
+ }
+
+ // 1. Check for format: t=,v1=
+ if strings.Contains(sigHeader, "t=") && (strings.Contains(sigHeader, "v1=") || strings.Contains(sigHeader, "v0=")) {
+ parts := strings.Split(sigHeader, ",")
+ var tsStr, expectedSig string
+ for _, p := range parts {
+ p = strings.TrimSpace(p)
+ if strings.HasPrefix(p, "t=") {
+ tsStr = strings.TrimPrefix(p, "t=")
+ } else if strings.HasPrefix(p, "v1=") {
+ expectedSig = strings.TrimPrefix(p, "v1=")
+ } else if strings.HasPrefix(p, "v0=") && expectedSig == "" {
+ expectedSig = strings.TrimPrefix(p, "v0=")
+ }
+ }
+
+ if tsStr != "" && expectedSig != "" {
+ // Timestamp anti-replay check (5 minutes)
+ var tsInt int64
+ if parsed, err := strconv.ParseInt(tsStr, 10, 64); err == nil {
+ tsInt = parsed
+ now := time.Now().Unix()
+ if tsInt > 1e11 { // ms
+ now = time.Now().UnixMilli()
+ if math.Abs(float64(now-tsInt)) > float64(5*60*1000) {
+ return false
+ }
+ } else {
+ if math.Abs(float64(now-tsInt)) > float64(5*60) {
+ return false
+ }
+ }
+ }
+
+ mac := hmac.New(sha256.New, []byte(secret))
+ mac.Write([]byte(tsStr + "." + string(payload)))
+ computed := hex.EncodeToString(mac.Sum(nil))
+ if hmac.Equal([]byte(expectedSig), []byte(computed)) {
+ return true
+ }
+ }
+ }
+
+ // 2. Fallback to direct sha256= signature or raw hex signature
+ sig := sigHeader
if strings.HasPrefix(sig, "sha256=") {
sig = strings.TrimPrefix(sig, "sha256=")
}
- if sig == "" {
- return false
- }
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(payload)
@@ -421,11 +468,27 @@ func (s *webhookSyncService) handleCompanyUpsert(data request.OrgSyncEventData)
}
code := strings.TrimSpace(data.CRMCompanyID)
+ if code == "" {
+ code = strings.TrimSpace(data.ID)
+ }
+ if code == "" {
+ code = strings.TrimSpace(data.CompanyID)
+ }
if code == "" {
code = strings.TrimSpace(data.DeskCompanyID)
}
remark := strings.TrimSpace(data.DomainName)
+ if remark == "" {
+ remark = strings.TrimSpace(data.Domain)
+ }
+ if data.TaxCode != "" {
+ if remark != "" {
+ remark += " | Tax: " + data.TaxCode
+ } else {
+ remark = "Tax: " + data.TaxCode
+ }
+ }
if data.Address != "" {
if remark != "" {
remark += " | " + data.Address
@@ -497,7 +560,13 @@ func (s *webhookSyncService) handleCustomerUpsert(data request.OrgSyncEventData)
}
phone := strings.TrimSpace(data.Phone)
crmPersonID := strings.TrimSpace(data.CRMPersonID)
+ if crmPersonID == "" {
+ crmPersonID = strings.TrimSpace(data.ID)
+ }
crmCompanyID := strings.TrimSpace(data.CRMCompanyID)
+ if crmCompanyID == "" {
+ crmCompanyID = strings.TrimSpace(data.CompanyID)
+ }
companyName := strings.TrimSpace(data.CompanyName)
if name == "" && email == "" && phone == "" && crmPersonID == "" {
diff --git a/internal/services/webhook_sync_service_test.go b/internal/services/webhook_sync_service_test.go
index 5cd41ab9..bcd3a9d2 100644
--- a/internal/services/webhook_sync_service_test.go
+++ b/internal/services/webhook_sync_service_test.go
@@ -1,12 +1,18 @@
package services
import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "testing"
+ "time"
+
"agent-desk/internal/models"
+ "agent-desk/internal/pkg/config"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/repositories"
- "testing"
- "time"
"github.com/mlogclub/simple/sqls"
)
@@ -328,3 +334,52 @@ func TestWebhookSync_CompanyAndCustomerEvents(t *testing.T) {
t.Fatalf("unexpected updated customer: %+v", cust)
}
}
+
+func TestWebhookSignatureVerification_TimestampFormat(t *testing.T) {
+ svc := newWebhookSyncService()
+ secret := "test-secret-key-12345"
+
+ // Mock config
+ cfg := &config.Config{
+ Webhook: config.WebhookConfig{
+ OrgSyncSecret: secret,
+ },
+ }
+ config.SetCurrent(cfg)
+
+ payload := []byte(`{"event":"customer.created","data":{"name":"John Doe"}}`)
+ nowMs := time.Now().UnixMilli()
+ tsStr := fmt.Sprintf("%d", nowMs)
+
+ // Compute HMAC-SHA256 of timestamp.payload
+ mac := hmac.New(sha256.New, []byte(secret))
+ mac.Write([]byte(tsStr + "." + string(payload)))
+ sigHex := hex.EncodeToString(mac.Sum(nil))
+
+ header := fmt.Sprintf("t=%s,v1=%s", tsStr, sigHex)
+
+ if !svc.VerifySignature(payload, header) {
+ t.Fatalf("expected signature %q to be verified successfully", header)
+ }
+
+ // Test expired timestamp (> 5 minutes ago)
+ oldMs := time.Now().Add(-10 * time.Minute).UnixMilli()
+ oldTsStr := fmt.Sprintf("%d", oldMs)
+ macOld := hmac.New(sha256.New, []byte(secret))
+ macOld.Write([]byte(oldTsStr + "." + string(payload)))
+ oldSigHex := hex.EncodeToString(macOld.Sum(nil))
+ oldHeader := fmt.Sprintf("t=%s,v1=%s", oldTsStr, oldSigHex)
+
+ if svc.VerifySignature(payload, oldHeader) {
+ t.Fatalf("expected expired signature %q to fail verification", oldHeader)
+ }
+
+ // Test sha256= format
+ macRaw := hmac.New(sha256.New, []byte(secret))
+ macRaw.Write(payload)
+ rawSigHex := hex.EncodeToString(macRaw.Sum(nil))
+
+ if !svc.VerifySignature(payload, "sha256="+rawSigHex) {
+ t.Fatalf("expected sha256= signature to be verified successfully")
+ }
+}
diff --git a/internal/telegram/client.go b/internal/telegram/client.go
new file mode 100644
index 00000000..5febe51b
--- /dev/null
+++ b/internal/telegram/client.go
@@ -0,0 +1,115 @@
+package telegram
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+)
+
+const defaultBaseURL = "https://api.telegram.org"
+
+type Client struct {
+ token string
+ baseURL string
+ httpClient *http.Client
+}
+
+func NewClient(token string) *Client {
+ return &Client{
+ token: strings.TrimSpace(token),
+ 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 resp APIResponse[User]
+ if err := c.doRequest(ctx, "getMe", nil, &resp); err != nil {
+ return nil, err
+ }
+ if !resp.OK {
+ return nil, fmt.Errorf("telegram getMe failed (%d): %s", resp.ErrorCode, resp.Description)
+ }
+ return &resp.Result, nil
+}
+
+func (c *Client) SetWebhook(ctx context.Context, req SetWebhookRequest) error {
+ var resp APIResponse[bool]
+ if err := c.doRequest(ctx, "setWebhook", req, &resp); err != nil {
+ return err
+ }
+ if !resp.OK {
+ return fmt.Errorf("telegram setWebhook failed (%d): %s", resp.ErrorCode, resp.Description)
+ }
+ return nil
+}
+
+func (c *Client) SendMessage(ctx context.Context, req SendMessageRequest) (*Message, error) {
+ if strings.TrimSpace(req.Text) == "" {
+ return nil, fmt.Errorf("telegram message text is required")
+ }
+ if req.ChatID == 0 {
+ return nil, fmt.Errorf("telegram chat_id is required")
+ }
+
+ var resp APIResponse[Message]
+ if err := c.doRequest(ctx, "sendMessage", req, &resp); err != nil {
+ return nil, err
+ }
+ if !resp.OK {
+ return nil, fmt.Errorf("telegram sendMessage failed (%d): %s", resp.ErrorCode, resp.Description)
+ }
+ return &resp.Result, nil
+}
+
+func (c *Client) doRequest(ctx context.Context, method string, payload any, result any) error {
+ if c.token == "" {
+ return fmt.Errorf("telegram bot token is required")
+ }
+
+ endpoint := fmt.Sprintf("%s/bot%s/%s", c.baseURL, c.token, method)
+
+ var bodyReader io.Reader
+ if payload != nil {
+ bodyBytes, err := json.Marshal(payload)
+ if err != nil {
+ return fmt.Errorf("marshal telegram request failed: %w", err)
+ }
+ bodyReader = bytes.NewBuffer(bodyBytes)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bodyReader)
+ if err != nil {
+ return fmt.Errorf("create telegram request failed: %w", err)
+ }
+ if payload != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+
+ res, err := c.httpClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("telegram http request failed: %w", err)
+ }
+ defer res.Body.Close()
+
+ bodyBytes, err := io.ReadAll(res.Body)
+ if err != nil {
+ return fmt.Errorf("read telegram response failed: %w", err)
+ }
+
+ if err := json.Unmarshal(bodyBytes, result); err != nil {
+ return fmt.Errorf("unmarshal telegram response failed: %w (body: %s)", err, string(bodyBytes))
+ }
+ return nil
+}
diff --git a/internal/telegram/client_test.go b/internal/telegram/client_test.go
new file mode 100644
index 00000000..63ed03e3
--- /dev/null
+++ b/internal/telegram/client_test.go
@@ -0,0 +1,83 @@
+package telegram
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestTelegramClient_GetMe(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/bot123456:ABC-DEF/getMe" {
+ http.NotFound(w, r)
+ return
+ }
+ _ = json.NewEncoder(w).Encode(APIResponse[User]{
+ OK: true,
+ Result: User{
+ ID: 987654321,
+ IsBot: true,
+ FirstName: "Crove Desk Bot",
+ Username: "crove_desk_bot",
+ },
+ })
+ }))
+ defer ts.Close()
+
+ client := NewClient("123456:ABC-DEF")
+ client.SetBaseURL(ts.URL)
+
+ user, err := client.GetMe(context.Background())
+ if err != nil {
+ t.Fatalf("GetMe failed: %v", err)
+ }
+ if user.Username != "crove_desk_bot" || !user.IsBot {
+ t.Fatalf("unexpected user: %+v", user)
+ }
+}
+
+func TestTelegramClient_SendMessage(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/bot123456:ABC-DEF/sendMessage" {
+ http.NotFound(w, r)
+ return
+ }
+ var req SendMessageRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ if req.ChatID != 112233 || req.Text != "Xin chào từ Crove Desk!" {
+ http.Error(w, "invalid params", http.StatusBadRequest)
+ return
+ }
+ _ = json.NewEncoder(w).Encode(APIResponse[Message]{
+ OK: true,
+ Result: Message{
+ MessageID: 101,
+ Chat: Chat{
+ ID: 112233,
+ Type: "private",
+ },
+ Text: req.Text,
+ },
+ })
+ }))
+ defer ts.Close()
+
+ client := NewClient("123456:ABC-DEF")
+ client.SetBaseURL(ts.URL)
+
+ msg, err := client.SendMessage(context.Background(), SendMessageRequest{
+ ChatID: 112233,
+ Text: "Xin chào từ Crove Desk!",
+ })
+ if err != nil {
+ t.Fatalf("SendMessage failed: %v", err)
+ }
+ if msg.MessageID != 101 || msg.Text != "Xin chào từ Crove Desk!" {
+ t.Fatalf("unexpected sent message: %+v", msg)
+ }
+}
diff --git a/internal/telegram/types.go b/internal/telegram/types.go
new file mode 100644
index 00000000..7a431b69
--- /dev/null
+++ b/internal/telegram/types.go
@@ -0,0 +1,61 @@
+package telegram
+
+// Update represents an incoming update from Telegram Bot Webhook.
+type Update struct {
+ UpdateID int64 `json:"update_id"`
+ Message *Message `json:"message,omitempty"`
+}
+
+// Message represents a Telegram message.
+type Message struct {
+ MessageID int64 `json:"message_id"`
+ From *User `json:"from,omitempty"`
+ Chat Chat `json:"chat"`
+ Date int64 `json:"date"`
+ Text string `json:"text,omitempty"`
+ Caption string `json:"caption,omitempty"`
+}
+
+// User represents a Telegram user or bot.
+type User struct {
+ ID int64 `json:"id"`
+ IsBot bool `json:"is_bot"`
+ FirstName string `json:"first_name"`
+ LastName string `json:"last_name,omitempty"`
+ Username string `json:"username,omitempty"`
+ LanguageCode string `json:"language_code,omitempty"`
+}
+
+// Chat represents a Telegram chat (private, group, supergroup, channel).
+type Chat struct {
+ ID int64 `json:"id"`
+ Type string `json:"type"`
+ Title string `json:"title,omitempty"`
+ Username string `json:"username,omitempty"`
+ FirstName string `json:"first_name,omitempty"`
+ LastName string `json:"last_name,omitempty"`
+}
+
+// SendMessageRequest represents payload for Telegram sendMessage API.
+type SendMessageRequest struct {
+ ChatID int64 `json:"chat_id"`
+ Text string `json:"text"`
+ ParseMode string `json:"parse_mode,omitempty"`
+ ReplyToMessageID int64 `json:"reply_to_message_id,omitempty"`
+ DisableWebPagePreview bool `json:"disable_web_page_preview,omitempty"`
+}
+
+// APIResponse represents standard Telegram Bot API response envelope.
+type APIResponse[T any] struct {
+ OK bool `json:"ok"`
+ Result T `json:"result,omitempty"`
+ Description string `json:"description,omitempty"`
+ ErrorCode int `json:"error_code,omitempty"`
+}
+
+// SetWebhookRequest represents payload for Telegram setWebhook API.
+type SetWebhookRequest struct {
+ URL string `json:"url"`
+ SecretToken string `json:"secret_token,omitempty"`
+ AllowedUpdates []string `json:"allowed_updates,omitempty"`
+}
diff --git a/web/app/(dashboard)/dashboard/channels/_components/edit.tsx b/web/app/(dashboard)/dashboard/channels/_components/edit.tsx
index 7e83c5b8..6eb2526b 100644
--- a/web/app/(dashboard)/dashboard/channels/_components/edit.tsx
+++ b/web/app/(dashboard)/dashboard/channels/_components/edit.tsx
@@ -58,6 +58,12 @@ type WechatMPChannelConfig = {
userTokenSecret?: string
}
+type TelegramChannelConfig = {
+ botToken?: string
+ botUsername?: string
+ webhookSecret?: string
+}
+
function getDefaultWebChannelConfig(t: Translate): Required {
return {
title: t("channel.defaultTitleWeb"),
@@ -72,11 +78,14 @@ function getDefaultWebChannelConfig(t: Translate): Required {
function createSchema(t: Translate) {
return z
.object({
- channelType: z.enum(["web", "wechat_mp", "wxwork_kf"], t("channel.typeRequired")),
+ channelType: z.enum(["web", "wechat_mp", "wxwork_kf", "telegram"], t("channel.typeRequired")),
aiAgentId: z.string().trim().regex(/^\d+$/, t("channel.agentRequired")),
aiAgentRolloutPercent: z.coerce.number().int().min(1).max(100),
name: z.string().trim().min(1, t("channel.nameRequired")),
openKfId: z.string().trim(),
+ botToken: z.string().trim(),
+ botUsername: z.string().trim(),
+ webhookSecret: z.string().trim(),
widgetTitle: z.string().trim(),
widgetSubtitle: z.string().trim(),
widgetThemeColor: z.string().trim(),
@@ -93,15 +102,25 @@ function createSchema(t: Translate) {
message: t("channel.wxworkAccountRequired"),
})
}
+ if (values.channelType === "telegram" && !values.botToken.trim()) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["botToken"],
+ message: "Telegram Bot Token is required",
+ })
+ }
})
}
type EditForm = {
- channelType: "web" | "wechat_mp" | "wxwork_kf"
+ channelType: "web" | "wechat_mp" | "wxwork_kf" | "telegram"
aiAgentId: string
aiAgentRolloutPercent: number
name: string
openKfId: string
+ botToken: string
+ botUsername: string
+ webhookSecret: string
widgetTitle: string
widgetSubtitle: string
widgetThemeColor: string
@@ -119,6 +138,9 @@ function createEmptyForm(t: Translate): EditForm {
aiAgentRolloutPercent: 100,
name: "",
openKfId: "",
+ botToken: "",
+ botUsername: "",
+ webhookSecret: "",
widgetTitle: defaultWebChannelConfig.title,
widgetSubtitle: defaultWebChannelConfig.subtitle,
widgetThemeColor: defaultWebChannelConfig.themeColor,
@@ -141,6 +163,20 @@ function parseOpenKfId(configJson: string): string {
}
}
+function parseTelegramChannelConfig(configJson: string): TelegramChannelConfig {
+ if (!configJson.trim()) return {}
+ try {
+ const parsed = JSON.parse(configJson) as TelegramChannelConfig
+ return {
+ botToken: parsed.botToken?.trim() || "",
+ botUsername: parsed.botUsername?.trim() || "",
+ webhookSecret: parsed.webhookSecret?.trim() || "",
+ }
+ } catch {
+ return {}
+ }
+}
+
function parseWebChannelConfig(configJson: string, t: Translate): Required {
const defaultWebChannelConfig = getDefaultWebChannelConfig(t)
if (!configJson.trim()) {
@@ -193,21 +229,30 @@ function buildForm(item: AdminChannel | null, t: Translate): EditForm {
return createEmptyForm(t)
}
const isWechatMP = item.channelType === "wechat_mp"
+ const isTelegram = item.channelType === "telegram"
const webConfig = parseWebChannelConfig(item.configJson, t)
const wechatConfig = isWechatMP
? parseWechatMPChannelConfig(item.configJson, t)
: null
+ const telegramConfig = isTelegram
+ ? parseTelegramChannelConfig(item.configJson)
+ : null
return {
channelType:
item.channelType === "wxwork_kf"
? "wxwork_kf"
- : item.channelType === "wechat_mp"
- ? "wechat_mp"
- : "web",
+ : item.channelType === "telegram"
+ ? "telegram"
+ : item.channelType === "wechat_mp"
+ ? "wechat_mp"
+ : "web",
aiAgentId: item.aiAgentId > 0 ? String(item.aiAgentId) : "",
aiAgentRolloutPercent: item.aiAgentRolloutPercent || 100,
name: item.name,
openKfId: parseOpenKfId(item.configJson),
+ botToken: telegramConfig?.botToken ?? "",
+ botUsername: telegramConfig?.botUsername ?? "",
+ webhookSecret: telegramConfig?.webhookSecret ?? "",
widgetTitle: wechatConfig?.title ?? webConfig.title,
widgetSubtitle: wechatConfig?.subtitle ?? webConfig.subtitle,
widgetThemeColor: wechatConfig?.themeColor ?? webConfig.themeColor,
@@ -233,14 +278,20 @@ function buildPayload(form: EditForm, status: number, t: Translate): CreateAdmin
const configJson =
channelType === "wxwork_kf"
? JSON.stringify({ openKfId: form.openKfId.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 === "telegram"
+ ? JSON.stringify({
+ botToken: form.botToken.trim(),
+ botUsername: form.botUsername.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),
@@ -428,6 +479,7 @@ function ChannelFormBody({
}))
const channelTypeOptions = [
{ value: "web", label: t("channel.typeWeb") },
+ { value: "telegram", label: t("channel.typeTelegram") },
{ value: "wechat_mp", label: t("channel.typeWechatMp") },
{ value: "wxwork_kf", label: t("channel.typeWxworkKf") },
] as const
@@ -602,6 +654,54 @@ function ChannelFormBody({
+ {channelType === "telegram" ? (
+
+
+ {t("channel.botToken")} *
+
+
+
+
+
+
+
+
+ {t("channel.botUsername")}
+
+
+
+
+
+
+
+ {t("channel.webhookSecret")}
+
+
+
+
+
+
+
+
+
{t("channel.telegramAutoConnectTitle")}
+
{t("channel.telegramAutoConnectDescription")}
+
+
+ ) : null}
+
{channelType === "wxwork_kf" ? (
{t("channel.wxworkAccount")}
diff --git a/web/app/(dashboard)/dashboard/channels/page.tsx b/web/app/(dashboard)/dashboard/channels/page.tsx
index 70c51eb9..a186d1bf 100644
--- a/web/app/(dashboard)/dashboard/channels/page.tsx
+++ b/web/app/(dashboard)/dashboard/channels/page.tsx
@@ -4,6 +4,7 @@ import {
Building2Icon,
MessagesSquareIcon,
MessageSquareMoreIcon,
+ SendIcon,
} from "lucide-react"
import {
@@ -32,6 +33,9 @@ function getChannelTypeLabel(channelType: string, t: (key: string) => string) {
if (channelType === "wxwork_kf") {
return t("channel.typeWxworkKf")
}
+ if (channelType === "telegram") {
+ return t("channel.typeTelegram")
+ }
return t("channel.typeWeb")
}
@@ -52,6 +56,9 @@ function ChannelIcon({ channelType }: { channelType: string }) {
if (channelType === "wxwork_kf") {
return
}
+ if (channelType === "telegram") {
+ return
+ }
return
}
@@ -67,6 +74,7 @@ export default function DashboardChannelsPage() {
const channelTypeOptions = [
{ value: "all", label: t("channel.allTypes") },
{ value: "web", label: t("channel.typeWeb") },
+ { value: "telegram", label: t("channel.typeTelegram") },
{ value: "wechat_mp", label: t("channel.typeWechatMp") },
{ value: "wxwork_kf", label: t("channel.typeWxworkKf") },
]
diff --git a/web/lib/generated/enums.ts b/web/lib/generated/enums.ts
index 50540a5e..c8bb4b57 100644
--- a/web/lib/generated/enums.ts
+++ b/web/lib/generated/enums.ts
@@ -22,17 +22,6 @@ export const AIAgentHandoffModeLabels: Record = {
[AIAgentHandoffMode.AIHoldAndNotify]: "AI继续接待并提醒人工",
}
-export enum AIAgentRuntimeMode {
- Workflow = "workflow",
- Autonomous = "autonomous",
- Hybrid = "hybrid",
-}
-export const AIAgentRuntimeModeLabels: Record = {
- [AIAgentRuntimeMode.Workflow]: "流程编排",
- [AIAgentRuntimeMode.Autonomous]: "自主运行",
- [AIAgentRuntimeMode.Hybrid]: "混合运行",
-}
-
export enum AIModelType {
LLM = "llm",
Embedding = "embedding",
@@ -88,11 +77,15 @@ export enum ExternalSource {
Guest = "guest",
WxWorkKF = "wxwork_kf",
User = "user",
+ TwentyCRM = "twenty_crm",
+ Telegram = "telegram",
}
export const ExternalSourceLabels: Record = {
[ExternalSource.Guest]: "访客",
[ExternalSource.WxWorkKF]: "企业微信客服",
[ExternalSource.User]: "用户",
+ [ExternalSource.TwentyCRM]: "Twenty CRM",
+ [ExternalSource.Telegram]: "Telegram",
}
export enum Gender {
diff --git a/web/messages/en-US.json b/web/messages/en-US.json
index 4a91b03f..393de80b 100644
--- a/web/messages/en-US.json
+++ b/web/messages/en-US.json
@@ -605,8 +605,15 @@
"channel": {
"allTypes": "All types",
"typeWeb": "Web",
+ "typeTelegram": "Telegram Bot",
"typeWechatMp": "WeChat Official Account",
"typeWxworkKf": "WeCom Customer Service",
+ "botToken": "Telegram Bot Token",
+ "botTokenRequired": "Telegram Bot Token is required",
+ "botUsername": "Bot Username",
+ "webhookSecret": "Webhook Secret Token",
+ "telegramAutoConnectTitle": "Automatic Telegram Bot Connection",
+ "telegramAutoConnectDescription": "Enter your Bot Token from @BotFather. Crove Desk automatically connects to Telegram and syncs inbound/outbound customer conversations seamlessly.",
"loadFailed": "Could not load channels.",
"created": "Channel created: {name}",
"updated": "Channel updated: {name}",
diff --git a/web/messages/vi-VN.json b/web/messages/vi-VN.json
index 327faf05..9a5d5f84 100644
--- a/web/messages/vi-VN.json
+++ b/web/messages/vi-VN.json
@@ -616,8 +616,15 @@
"channel": {
"allTypes": "All types",
"typeWeb": "Web",
+ "typeTelegram": "Telegram Bot",
"typeWechatMp": "WeChat Official Account",
"typeWxworkKf": "WeCom Customer Service",
+ "botToken": "Telegram Bot Token",
+ "botTokenRequired": "Telegram Bot Token is required",
+ "botUsername": "Bot Username",
+ "webhookSecret": "Webhook Secret Token",
+ "telegramAutoConnectTitle": "Automatic Telegram Bot Connection",
+ "telegramAutoConnectDescription": "Enter your Bot Token from @BotFather. Crove Desk automatically connects to Telegram and syncs inbound/outbound customer conversations seamlessly.",
"loadFailed": "Could not load channels.",
"created": "Channel created: {name}",
"updated": "Channel updated: {name}",
diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json
index fbb5cd2c..95cd7207 100644
--- a/web/messages/zh-CN.json
+++ b/web/messages/zh-CN.json
@@ -605,8 +605,15 @@
"channel": {
"allTypes": "全部类型",
"typeWeb": "Web 站点",
+ "typeTelegram": "Telegram Bot",
"typeWechatMp": "微信公众号",
"typeWxworkKf": "企业微信客服",
+ "botToken": "Telegram Bot Token",
+ "botTokenRequired": "请输入 Telegram Bot Token",
+ "botUsername": "Bot 用户名",
+ "webhookSecret": "Webhook Secret Token",
+ "telegramAutoConnectTitle": "Telegram Bot 自动连接",
+ "telegramAutoConnectDescription": "只需输入来自 @BotFather 的 Bot Token,Crove Desk 将自动连接并双向同步客户会话消息。",
"loadFailed": "加载接入渠道失败",
"created": "已创建接入渠道:{name}",
"updated": "已更新接入渠道:{name}",
From b4fc306a8b71191d3bb0f6d62c7bfe6b7eb9ad72 Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Wed, 26 Aug 2026 21:14:44 +0700
Subject: [PATCH 26/53] chore(config): replace live credentials with
placeholders in docker supabase example config
Co-authored-by: Cursor
---
docker/agent-desk.supabase.example.yaml | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/docker/agent-desk.supabase.example.yaml b/docker/agent-desk.supabase.example.yaml
index 31b1eb51..fcc111c0 100644
--- a/docker/agent-desk.supabase.example.yaml
+++ b/docker/agent-desk.supabase.example.yaml
@@ -14,7 +14,7 @@ server:
db:
type: postgres
# Supabase Session Pooler (port 5432) with schema desk
- dsn: "host=aws-1-ap-southeast-1.pooler.supabase.com user=postgres.gulptwduchsjcsbndmua password=06nmFQaSw6nLzWHE dbname=postgres port=5432 sslmode=require search_path=desk"
+ dsn: "host=aws-1-ap-southeast-1.pooler.supabase.com user=postgres.your-project-ref password=your-db-password dbname=postgres port=5432 sslmode=require search_path=desk"
maxIdleConns: 5
maxOpenConns: 20
connMaxIdleTimeSeconds: 300
@@ -62,7 +62,7 @@ vectorDB:
ai:
provider: openai
baseUrl: https://api.dos.ai/v1
- apiKey: dos_sk_IIv2Nii7JGqCLk3i0r29ExujvFYl7inY
+ apiKey: your-ai-api-key
llmModel: dos-ai
embeddingModel: qwen3-embedding-4b
embeddingDimension: 2560
@@ -85,16 +85,16 @@ mcp:
oidc:
enabled: true
- issuer: "https://gulptwduchsjcsbndmua.supabase.co/auth/v1"
- clientId: "18790ccb-4d71-48cd-ad24-aee5f3ced3da"
- clientSecret: "mjlRNUhS0J0aaW6ahIYvaJM_566XJCHxUbIN_LfCQ1o"
+ issuer: "https://your-supabase-ref.supabase.co/auth/v1"
+ clientId: "your-oidc-client-id"
+ clientSecret: "your-oidc-client-secret"
redirectUrl: "https://desk.crove.com/api/auth/oidc_callback"
- stateSecret: "crove-desk-oidc-state-secret-4f81c9e2b7a0"
+ stateSecret: "crove-desk-oidc-state-secret-placeholder"
scopes:
- openid
- profile
- email
webhook:
- orgSyncSecret: "ad3726c93d4951e8c86a73b138bfde865fde427adc7384d5"
+ orgSyncSecret: "your-org-sync-webhook-secret"
outboundUrl: "https://api.dos.me/internal/events/publish"
From 443a6f94d2d5cbedafb36ee64257f86fcaae28ae Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Thu, 27 Aug 2026 08:15:27 +0700
Subject: [PATCH 27/53] feat(channel): implement Zalo OA channel adapter and
automated Telegram webhook sync
- Add Zalo Official Account client in internal/zalo supporting CS messaging and profile lookup
- Implement Zalo OA inbound webhook handler and outbound queue dispatcher
- Add automated Telegram setWebhook/deleteWebhook trigger on channel create, update, and status change
- Add Zalo OA channel configuration and connection guide to Dashboard Channels UI
- Add comprehensive unit and integration tests for Zalo OA and Telegram webhook lifecycle
Co-authored-by: Cursor
---
internal/bootstrap/routes.go | 5 +
internal/bootstrap/server.go | 1 +
internal/handlers/third/zalo_handler.go | 39 +++++
internal/handlers/third/zalo_handler_test.go | 105 +++++++++++++
internal/pkg/config/config.go | 16 ++
internal/pkg/dto/dto.go | 10 ++
internal/pkg/enums/external_identity.go | 2 +
internal/pkg/enums/wxwork_kf.go | 1 +
.../channel_message_outbox_service.go | 63 ++++++++
internal/services/channel_service.go | 108 ++++++++++++-
internal/services/cronx/cron.go | 4 +
internal/services/message_service.go | 9 ++
internal/services/zalo_inbound_service.go | 126 +++++++++++++++
internal/services/zalo_outbound_service.go | 144 ++++++++++++++++++
internal/telegram/client.go | 22 +++
internal/telegram/client_test.go | 41 +++++
internal/telegram/types.go | 9 ++
internal/zalo/client.go | 140 +++++++++++++++++
internal/zalo/client_test.go | 81 ++++++++++
internal/zalo/types.go | 59 +++++++
.../dashboard/channels/_components/edit.tsx | 137 +++++++++++++++--
.../(dashboard)/dashboard/channels/page.tsx | 6 +-
web/lib/generated/enums.ts | 2 +
web/messages/en-US.json | 6 +
web/messages/vi-VN.json | 6 +
web/messages/zh-CN.json | 6 +
26 files changed, 1130 insertions(+), 18 deletions(-)
create mode 100644 internal/handlers/third/zalo_handler.go
create mode 100644 internal/handlers/third/zalo_handler_test.go
create mode 100644 internal/services/zalo_inbound_service.go
create mode 100644 internal/services/zalo_outbound_service.go
create mode 100644 internal/zalo/client.go
create mode 100644 internal/zalo/client_test.go
create mode 100644 internal/zalo/types.go
diff --git a/internal/bootstrap/routes.go b/internal/bootstrap/routes.go
index efc41a9d..3742ee5f 100644
--- a/internal/bootstrap/routes.go
+++ b/internal/bootstrap/routes.go
@@ -427,3 +427,8 @@ func registerThirdTelegramRoutes(group *gin.RouterGroup) {
group.POST("/webhook", third.TelegramPostWebhook)
group.POST("/webhook/:channel_id", third.TelegramPostWebhook)
}
+
+func registerThirdZaloRoutes(group *gin.RouterGroup) {
+ group.POST("/webhook", third.ZaloPostWebhook)
+ group.POST("/webhook/:channel_id", third.ZaloPostWebhook)
+}
diff --git a/internal/bootstrap/server.go b/internal/bootstrap/server.go
index 2cef4a41..40bc023b 100644
--- a/internal/bootstrap/server.go
+++ b/internal/bootstrap/server.go
@@ -195,6 +195,7 @@ func addRouter(app *gin.Engine) {
thirdGroup := app.Group("/api/third")
registerThirdWechatRoutes(thirdGroup.Group("/wechat"))
registerThirdTelegramRoutes(thirdGroup.Group("/telegram"))
+ registerThirdZaloRoutes(thirdGroup.Group("/zalo"))
}
type spaShellRewrite struct {
diff --git a/internal/handlers/third/zalo_handler.go b/internal/handlers/third/zalo_handler.go
new file mode 100644
index 00000000..888b435f
--- /dev/null
+++ b/internal/handlers/third/zalo_handler.go
@@ -0,0 +1,39 @@
+package third
+
+import (
+ "bytes"
+ "io"
+ "net/http"
+ "strings"
+
+ "agent-desk/internal/services"
+
+ "github.com/gin-gonic/gin"
+)
+
+// ZaloPostWebhook receives incoming Webhook events from Zalo Official Account.
+func ZaloPostWebhook(ctx *gin.Context) {
+ channelID := strings.TrimSpace(ctx.Param("channel_id"))
+ if channelID == "" {
+ channelID = strings.TrimSpace(ctx.Query("channel_id"))
+ }
+
+ sigHeader := ctx.GetHeader("X-Zalo-Signature")
+ if sigHeader == "" {
+ sigHeader = ctx.GetHeader("X-Hub-Signature")
+ }
+
+ bodyBytes, err := io.ReadAll(ctx.Request.Body)
+ if err != nil {
+ ctx.JSON(http.StatusBadRequest, gin.H{"error": 1, "message": "failed to read body"})
+ return
+ }
+ ctx.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
+
+ if err := services.ZaloOAInboundService.HandleWebhook(ctx.Request.Context(), channelID, sigHeader, bodyBytes); err != nil {
+ ctx.JSON(http.StatusOK, gin.H{"error": 1, "message": err.Error()})
+ return
+ }
+
+ ctx.JSON(http.StatusOK, gin.H{"error": 0, "message": "Success"})
+}
diff --git a/internal/handlers/third/zalo_handler_test.go b/internal/handlers/third/zalo_handler_test.go
new file mode 100644
index 00000000..69c07063
--- /dev/null
+++ b/internal/handlers/third/zalo_handler_test.go
@@ -0,0 +1,105 @@
+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 TestZaloPostWebhook_Handler(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ db := setupThirdHandlerTestDB(t)
+
+ now := time.Now()
+ agent := &models.AIAgent{
+ Name: "Zalo OA Bot Agent",
+ ServiceMode: enums.IMConversationServiceModeAIFirst,
+ PublishedRevisionID: 1,
+ WelcomeMessage: "Xin chào từ Zalo OA!",
+ Status: enums.StatusOk,
+ AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now},
+ }
+ _ = db.Create(agent)
+
+ zaloConfig, _ := json.Marshal(dto.ZaloOAChannelConfig{
+ AppID: "1234567890",
+ OAID: "9876543210",
+ AccessToken: "zalo_oa_live_token_abc",
+ WebhookSecret: "my_zalo_secret",
+ })
+
+ operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"}
+ channel, err := services.ChannelService.CreateChannel(request.CreateChannelRequest{
+ Name: "Zalo OA Channel",
+ ChannelType: enums.ChannelTypeZaloOA,
+ AIAgentID: agent.ID,
+ AIAgentRolloutPercent: 100,
+ ConfigJSON: string(zaloConfig),
+ Status: int(enums.StatusOk),
+ }, operator)
+ if err != nil {
+ t.Fatalf("CreateChannel failed: %v", err)
+ }
+
+ router := gin.New()
+ router.POST("/api/third/zalo/webhook/:channel_id", ZaloPostWebhook)
+ router.POST("/api/third/zalo/webhook", ZaloPostWebhook)
+
+ // Inbound message payload
+ updatePayload := []byte(`{
+ "event_name": "user_send_text",
+ "app_id": "1234567890",
+ "sender": {"id": "zalo_uid_999"},
+ "recipient": {"id": "9876543210"},
+ "message": {
+ "msg_id": "zalo_msg_777",
+ "text": "Chào bạn, tôi cần tư vấn mua license Crove Desk"
+ },
+ "info": {
+ "display_name": "Nguyen Van C"
+ },
+ "timestamp": "1756201000"
+ }`)
+
+ req, _ := http.NewRequest(http.MethodPost, "/api/third/zalo/webhook/"+channel.ChannelID, bytes.NewBuffer(updatePayload))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("X-Zalo-Signature", "my_zalo_secret")
+
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected 200 OK, got %d", rec.Code)
+ }
+ var resp map[string]any
+ _ = json.Unmarshal(rec.Body.Bytes(), &resp)
+ if resp["error"] != float64(0) {
+ t.Fatalf("expected error: 0, got: %+v", resp)
+ }
+
+ // Verify customer identity created
+ identity := repositories.CustomerIdentityRepository.FindOne(db, sqls.NewCnd().
+ Eq("external_source", enums.ExternalSourceZaloOA).
+ Eq("external_id", "zalo_uid_999"))
+ if identity == nil {
+ t.Fatalf("expected customer identity for zalo_uid_999")
+ }
+
+ customer := repositories.CustomerRepository.Get(db, identity.CustomerID)
+ if customer == nil || customer.Name != "Nguyen Van C" {
+ t.Fatalf("unexpected customer: %+v", customer)
+ }
+}
diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go
index 5b0eb8e0..f51231c0 100644
--- a/internal/pkg/config/config.go
+++ b/internal/pkg/config/config.go
@@ -4,6 +4,7 @@ import (
"agent-desk/internal/pkg/enums"
"errors"
"fmt"
+ "net/url"
"os"
"path/filepath"
"strings"
@@ -49,6 +50,7 @@ type WxWorkNotifyConfig struct {
type ServerConfig struct {
Port int `yaml:"port"`
+ PublicURL string `yaml:"publicUrl"`
CompanyName string `yaml:"companyName"`
CompanyLogoURL string `yaml:"companyLogoUrl"`
CORS CORSConfig `yaml:"cors"`
@@ -61,6 +63,18 @@ func (s ServerConfig) Address() string {
return fmt.Sprintf(":%d", s.Port)
}
+func (s ServerConfig) GetPublicBaseURL(oidcRedirectURL string) string {
+ if strings.TrimSpace(s.PublicURL) != "" {
+ return strings.TrimRight(strings.TrimSpace(s.PublicURL), "/")
+ }
+ if strings.TrimSpace(oidcRedirectURL) != "" {
+ if u, err := url.Parse(strings.TrimSpace(oidcRedirectURL)); err == nil && u.Scheme != "" && u.Host != "" {
+ return fmt.Sprintf("%s://%s", u.Scheme, u.Host)
+ }
+ }
+ return ""
+}
+
type CORSConfig struct {
// AllowedOrigins 是允许浏览器跨域访问的 Origin 白名单,必须包含协议和域名。
// 留空表示不允许跨域请求;同源请求通常不会携带 Origin,不受影响。
@@ -300,6 +314,7 @@ func loadDotEnv(configPath string) {
func bindConfigDefaults(v *viper.Viper) {
v.SetDefault("language", "zh-CN")
v.SetDefault("server.port", 8083)
+ v.SetDefault("server.publicUrl", "")
v.SetDefault("server.companyName", "")
v.SetDefault("server.companyLogoUrl", "")
v.SetDefault("server.cors.allowedOrigins", []string{})
@@ -337,6 +352,7 @@ func bindConfigDefaults(v *viper.Viper) {
func bindEnvironmentAliases(v *viper.Viper) {
_ = v.BindEnv("server.port", "PORT", "SERVER_PORT", "AGENT_DESK_SERVER_PORT")
+ _ = v.BindEnv("server.publicUrl", "PUBLIC_URL", "APP_URL", "SERVER_PUBLIC_URL", "BASE_URL", "DESK_BASE_URL", "AGENT_DESK_SERVER_PUBLICURL")
_ = 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")
diff --git a/internal/pkg/dto/dto.go b/internal/pkg/dto/dto.go
index 4e2ff56c..276d03a1 100644
--- a/internal/pkg/dto/dto.go
+++ b/internal/pkg/dto/dto.go
@@ -39,3 +39,13 @@ type TelegramChannelConfig struct {
WebhookSecret string `json:"webhookSecret,omitempty"`
WelcomeMessage string `json:"welcomeMessage,omitempty"`
}
+
+type ZaloOAChannelConfig struct {
+ AppID string `json:"appId,omitempty"`
+ OAID string `json:"oaId,omitempty"`
+ SecretKey string `json:"secretKey,omitempty"`
+ AccessToken string `json:"accessToken"`
+ RefreshToken string `json:"refreshToken,omitempty"`
+ WebhookSecret string `json:"webhookSecret,omitempty"`
+ WelcomeMessage string `json:"welcomeMessage,omitempty"`
+}
diff --git a/internal/pkg/enums/external_identity.go b/internal/pkg/enums/external_identity.go
index 5ddce857..a3425ea1 100644
--- a/internal/pkg/enums/external_identity.go
+++ b/internal/pkg/enums/external_identity.go
@@ -11,6 +11,7 @@ const (
ExternalSourceUser ExternalSource = "user" // 用户信息
ExternalSourceTwentyCRM ExternalSource = "twenty_crm" // Twenty CRM
ExternalSourceTelegram ExternalSource = "telegram" // Telegram Bot
+ ExternalSourceZaloOA ExternalSource = "zalo_oa" // Zalo Official Account
)
var externalSourceLabelMap = map[ExternalSource]string{
@@ -19,6 +20,7 @@ var externalSourceLabelMap = map[ExternalSource]string{
ExternalSourceUser: "用户",
ExternalSourceTwentyCRM: "Twenty CRM",
ExternalSourceTelegram: "Telegram",
+ ExternalSourceZaloOA: "Zalo OA",
}
func GetExternalSourceLabel(v ExternalSource) string {
diff --git a/internal/pkg/enums/wxwork_kf.go b/internal/pkg/enums/wxwork_kf.go
index db7d8503..825f7fa6 100644
--- a/internal/pkg/enums/wxwork_kf.go
+++ b/internal/pkg/enums/wxwork_kf.go
@@ -22,6 +22,7 @@ const (
ChannelTypeWechatMP = "wechat_mp"
ChannelTypeWxWorkKF = "wxwork_kf"
ChannelTypeTelegram = "telegram"
+ ChannelTypeZaloOA = "zalo_oa"
)
type WxWorkKFMessageSendStatus string
diff --git a/internal/services/channel_message_outbox_service.go b/internal/services/channel_message_outbox_service.go
index 0d607476..e095b1d2 100644
--- a/internal/services/channel_message_outbox_service.go
+++ b/internal/services/channel_message_outbox_service.go
@@ -189,6 +189,69 @@ func (s *channelMessageOutboxService) EnqueueTelegramMessage(conversation *model
return nil
}
+func (s *channelMessageOutboxService) EnqueueZaloOAMessage(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.ChannelTypeZaloOA {
+ return nil
+ }
+ if message.SenderType != enums.IMSenderTypeAgent && message.SenderType != enums.IMSenderTypeAI {
+ return nil
+ }
+ if message.MessageType != enums.IMMessageTypeText && message.MessageType != enums.IMMessageTypeHTML {
+ return nil
+ }
+ if existing := s.GetByMessageID(enums.ChannelTypeZaloOA, 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.ChannelTypeZaloOA,
+ 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
+ }
+
+ // Trigger async dispatch immediately
+ go func() {
+ defer func() {
+ if r := recover(); r != nil {
+ slog.Error("recovered from panic in zalo oa outbound dispatch", "error", r)
+ }
+ }()
+ ZaloOAOutboundService.DispatchPendingOutbox()
+ }()
+
+ return nil
+}
+
func (s *channelMessageOutboxService) ListPending(channelType string, limit int) []models.ChannelMessageOutbox {
if limit <= 0 {
limit = 20
diff --git a/internal/services/channel_service.go b/internal/services/channel_service.go
index 81c1be80..93b39e5a 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"
@@ -10,10 +11,14 @@ import (
"agent-desk/internal/pkg/httpx"
"agent-desk/internal/pkg/utils"
"agent-desk/internal/repositories"
+ "agent-desk/internal/telegram"
"agent-desk/internal/wxwork"
+ "context"
"crypto/rand"
"encoding/base64"
"encoding/json"
+ "fmt"
+ "log/slog"
"strings"
"time"
@@ -90,6 +95,7 @@ func (s *channelService) CreateChannel(req request.CreateChannelRequest, operato
if err := repositories.ChannelRepository.Create(sqls.DB(), item); err != nil {
return nil, err
}
+ go s.syncTelegramWebhook(item, item.Status)
return item, nil
}
@@ -121,7 +127,11 @@ func (s *channelService) UpdateChannel(req request.UpdateChannelRequest, operato
if item.AIAgentRolloutPercent != current.AIAgentRolloutPercent {
columns["previous_ai_agent_rollout_percent"] = current.AIAgentRolloutPercent
}
- return repositories.ChannelRepository.Updates(sqls.DB(), req.ID, columns)
+ if err := repositories.ChannelRepository.Updates(sqls.DB(), req.ID, columns); err != nil {
+ return err
+ }
+ go s.syncTelegramWebhook(item, item.Status)
+ return nil
}
// RollbackChannelAIAgentRollout restores the last channel-level rollout value
@@ -162,12 +172,16 @@ func (s *channelService) UpdateStatus(id int64, status int, operator *dto.AuthPr
if status != int(enums.StatusOk) && status != int(enums.StatusDisabled) {
return errorsx.InvalidParamI18n("error.e0254")
}
- return s.Updates(id, map[string]any{
+ err := s.Updates(id, map[string]any{
"status": status,
"update_user_id": operator.UserID,
"update_user_name": operator.Username,
"updated_at": time.Now(),
})
+ if err == nil {
+ go s.syncTelegramWebhook(item, enums.Status(status))
+ }
+ return err
}
func (s *channelService) DeleteChannel(id int64, operator *dto.AuthPrincipal) error {
@@ -178,12 +192,56 @@ func (s *channelService) DeleteChannel(id int64, operator *dto.AuthPrincipal) er
if item == nil || item.Status == enums.StatusDeleted {
return errorsx.InvalidParamI18n("error.e0208")
}
- return s.Updates(id, map[string]any{
+ err := s.Updates(id, map[string]any{
"status": enums.StatusDeleted,
"update_user_id": operator.UserID,
"update_user_name": operator.Username,
"updated_at": time.Now(),
})
+ if err == nil {
+ go s.syncTelegramWebhook(item, enums.StatusDeleted)
+ }
+ return err
+}
+
+func (s *channelService) syncTelegramWebhook(channel *models.Channel, targetStatus enums.Status) {
+ if channel == nil || channel.ChannelType != enums.ChannelTypeTelegram {
+ return
+ }
+ cfg, err := s.ParseTelegramChannelConfig(channel.ConfigJSON)
+ if err != nil || cfg == nil || cfg.BotToken == "" {
+ return
+ }
+
+ client := telegram.NewClient(cfg.BotToken)
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ if targetStatus == enums.StatusOk {
+ serverCfg := config.GetCurrent()
+ publicBaseURL := ""
+ if serverCfg != nil {
+ publicBaseURL = serverCfg.Server.GetPublicBaseURL(serverCfg.OIDC.RedirectURL)
+ }
+ if publicBaseURL != "" {
+ webhookURL := fmt.Sprintf("%s/api/third/telegram/webhook/%s", publicBaseURL, channel.ChannelID)
+ req := telegram.SetWebhookRequest{
+ URL: webhookURL,
+ SecretToken: cfg.WebhookSecret,
+ }
+ if err := client.SetWebhook(ctx, req); err != nil {
+ slog.Warn("auto set telegram webhook failed", "channel_id", channel.ChannelID, "url", webhookURL, "error", err)
+ } else {
+ slog.Info("auto set telegram webhook succeeded", "channel_id", channel.ChannelID, "url", webhookURL)
+ }
+ }
+ } else {
+ if err := client.DeleteWebhook(ctx); err != nil {
+ slog.Warn("auto delete telegram webhook failed", "channel_id", channel.ChannelID, "error", err)
+ } else {
+ slog.Info("auto delete telegram webhook succeeded", "channel_id", channel.ChannelID)
+ }
+ }
}
func (s *channelService) ParseWxWorkKFChannelConfig(raw string) (*dto.WxWorkKFChannelConfig, error) {
@@ -315,6 +373,24 @@ func (s *channelService) ParseTelegramChannelConfig(raw string) (*dto.TelegramCh
return cfg, nil
}
+func (s *channelService) ParseZaloOAChannelConfig(raw string) (*dto.ZaloOAChannelConfig, error) {
+ raw = strings.TrimSpace(raw)
+ cfg := &dto.ZaloOAChannelConfig{}
+ if raw != "" {
+ if err := json.Unmarshal([]byte(raw), cfg); err != nil {
+ return nil, err
+ }
+ }
+ cfg.AppID = strings.TrimSpace(cfg.AppID)
+ cfg.OAID = strings.TrimSpace(cfg.OAID)
+ cfg.SecretKey = strings.TrimSpace(cfg.SecretKey)
+ cfg.AccessToken = strings.TrimSpace(cfg.AccessToken)
+ cfg.RefreshToken = strings.TrimSpace(cfg.RefreshToken)
+ 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 ""
@@ -431,7 +507,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 {
+ if channelType != enums.ChannelTypeWeb && channelType != enums.ChannelTypeWechatMP && channelType != enums.ChannelTypeWxWorkKF && channelType != enums.ChannelTypeTelegram && channelType != enums.ChannelTypeZaloOA {
return nil, errorsx.InvalidParamI18n("error.e0250")
}
name := strings.TrimSpace(req.Name)
@@ -549,6 +625,30 @@ func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRe
if cfg == nil || cfg.BotToken == "" {
return nil, errorsx.InvalidParam("telegram botToken is required")
}
+ 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)
+ case enums.ChannelTypeZaloOA:
+ 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.ParseZaloOAChannelConfig(configJSON)
+ if err != nil {
+ return nil, errorsx.InvalidParam("invalid zalo oa configuration")
+ }
+ if cfg == nil || cfg.AccessToken == "" {
+ return nil, errorsx.InvalidParam("zalo oa accessToken is required")
+ }
configBytes, err := json.Marshal(cfg)
if err != nil {
return nil, err
diff --git a/internal/services/cronx/cron.go b/internal/services/cronx/cron.go
index 07562fd7..52ab00e2 100644
--- a/internal/services/cronx/cron.go
+++ b/internal/services/cronx/cron.go
@@ -30,6 +30,10 @@ func Init() {
if tgCount > 0 {
slog.Info("telegram outbox dispatched", "count", tgCount)
}
+ zaloCount := services.ZaloOAOutboundService.DispatchPendingOutbox()
+ if zaloCount > 0 {
+ slog.Info("zalo oa outbox dispatched", "count", zaloCount)
+ }
})
c.Start()
diff --git a/internal/services/message_service.go b/internal/services/message_service.go
index 4171ddf3..48464472 100644
--- a/internal/services/message_service.go
+++ b/internal/services/message_service.go
@@ -550,6 +550,15 @@ func (s *messageService) sendValidatedMessage(conversation *models.Conversation,
)
}
+ // Zalo OA 渠道消息入队,异步发送
+ if enqueueErr := ChannelMessageOutboxService.EnqueueZaloOAMessage(conversation, message); enqueueErr != nil {
+ slog.Error("enqueue zalo oa 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/zalo_inbound_service.go b/internal/services/zalo_inbound_service.go
new file mode 100644
index 00000000..73dc8b89
--- /dev/null
+++ b/internal/services/zalo_inbound_service.go
@@ -0,0 +1,126 @@
+package services
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "agent-desk/internal/models"
+ "agent-desk/internal/pkg/enums"
+ "agent-desk/internal/pkg/errorsx"
+ "agent-desk/internal/pkg/openidentity"
+ "agent-desk/internal/zalo"
+)
+
+var ZaloOAInboundService = newZaloOAInboundService()
+
+func newZaloOAInboundService() *zaloOAInboundService {
+ return &zaloOAInboundService{}
+}
+
+type zaloOAInboundService struct{}
+
+// HandleWebhook processes an incoming webhook Event from Zalo OA.
+func (s *zaloOAInboundService) HandleWebhook(ctx context.Context, channelID string, signatureHeader 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.ChannelTypeZaloOA, enums.StatusOk)
+ }
+ if channel == nil {
+ channel = ChannelService.Take("channel_type = ? AND status = ?", enums.ChannelTypeZaloOA, enums.StatusOk)
+ }
+ if channel == nil {
+ return errorsx.InvalidParam("zalo oa channel not found or disabled")
+ }
+
+ cfg, err := ChannelService.ParseZaloOAChannelConfig(channel.ConfigJSON)
+ if err != nil || cfg == nil || cfg.AccessToken == "" {
+ return errorsx.InvalidParam("zalo oa channel config invalid")
+ }
+
+ if cfg.WebhookSecret != "" && strings.TrimSpace(signatureHeader) != "" {
+ // Optional header/secret verification
+ if strings.TrimSpace(signatureHeader) != cfg.WebhookSecret {
+ return errorsx.UnauthorizedI18n("error.auth.invalidSignature")
+ }
+ }
+
+ var event zalo.WebhookEvent
+ if err := json.Unmarshal(rawPayload, &event); err != nil {
+ return fmt.Errorf("unmarshal zalo oa event failed: %w", err)
+ }
+
+ // Filter user-sent text messages
+ if event.EventName != "user_send_text" && event.EventName != "user_send_image" && event.EventName != "user_send_file" {
+ return nil
+ }
+
+ senderID := strings.TrimSpace(event.Sender.ID)
+ if senderID == "" {
+ return nil
+ }
+
+ text := ""
+ if event.Message != nil {
+ text = strings.TrimSpace(event.Message.Text)
+ }
+ if text == "" && event.EventName != "user_send_text" {
+ text = fmt.Sprintf("[%s]", event.EventName)
+ }
+ if text == "" {
+ return nil
+ }
+
+ // 1. Resolve customer identity
+ name := fmt.Sprintf("Zalo User %s", senderID)
+ if displayName, ok := event.Info["display_name"].(string); ok && strings.TrimSpace(displayName) != "" {
+ name = strings.TrimSpace(displayName)
+ }
+
+ externalUser := openidentity.ExternalUser{
+ ExternalSource: enums.ExternalSourceZaloOA,
+ ExternalID: senderID,
+ ExternalName: name,
+ }
+
+ // 2. Create or match Conversation
+ conversation, err := ConversationService.Create(externalUser, channel.ID, channel.AIAgentID)
+ if err != nil {
+ return fmt.Errorf("create zalo conversation failed: %w", err)
+ }
+
+ // 3. Send message through MessageService
+ msgID := ""
+ if event.Message != nil {
+ msgID = event.Message.MsgID
+ }
+ if msgID == "" {
+ msgID = fmt.Sprintf("zalo_%s_%s", senderID, event.Timestamp)
+ }
+ clientMsgID := fmt.Sprintf("zalo_%s", msgID)
+
+ payloadMap := map[string]any{
+ "zalo_user_id": senderID,
+ "zalo_msg_id": msgID,
+ "zalo_app_id": event.AppID,
+ "zalo_recipient": event.Recipient.ID,
+ "zalo_event": event.EventName,
+ }
+ 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/zalo_outbound_service.go b/internal/services/zalo_outbound_service.go
new file mode 100644
index 00000000..ca233278
--- /dev/null
+++ b/internal/services/zalo_outbound_service.go
@@ -0,0 +1,144 @@
+package services
+
+import (
+ "context"
+ "log/slog"
+ "strings"
+ "time"
+
+ "agent-desk/internal/models"
+ "agent-desk/internal/pkg/enums"
+ "agent-desk/internal/repositories"
+ "agent-desk/internal/zalo"
+
+ "github.com/mlogclub/simple/sqls"
+)
+
+const (
+ zaloOAOutboxBatchSize = 20
+ zaloOAOutboxMaxRetry = 5
+)
+
+var ZaloOAOutboundService = newZaloOAOutboundService()
+
+func newZaloOAOutboundService() *zaloOAOutboundService {
+ return &zaloOAOutboundService{}
+}
+
+type zaloOAOutboundService struct{}
+
+func (s *zaloOAOutboundService) DispatchPendingOutbox() int {
+ return s.doDispatchPendingOutbox(zaloOAOutboxBatchSize)
+}
+
+func (s *zaloOAOutboundService) doDispatchPendingOutbox(limit int) int {
+ if limit <= 0 {
+ limit = zaloOAOutboxBatchSize
+ }
+ items := ChannelMessageOutboxService.ListPending(enums.ChannelTypeZaloOA, 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 zalo oa outbox failed",
+ "outbox_id", items[i].ID,
+ "error", err,
+ )
+ continue
+ }
+ successCount++
+ }
+ return successCount
+}
+
+func (s *zaloOAOutboundService) processOutbox(outboxID int64) error {
+ outbox := ChannelMessageOutboxService.Get(outboxID)
+ if outbox == nil {
+ return nil
+ }
+ if outbox.ChannelType != enums.ChannelTypeZaloOA {
+ 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, "zalo oa channel not found or disabled")
+ }
+ cfg, err := ChannelService.ParseZaloOAChannelConfig(channel.ConfigJSON)
+ if err != nil || cfg == nil || cfg.AccessToken == "" {
+ return s.markOutboxFailed(outbox, "zalo oa access token not configured")
+ }
+
+ // Resolve target Zalo User ID
+ var zaloUserID string
+ customerIdentity := repositories.CustomerIdentityRepository.FindOne(sqls.DB(), sqls.NewCnd().
+ Eq("customer_id", conversation.CustomerID).
+ Eq("external_source", enums.ExternalSourceZaloOA))
+ if customerIdentity != nil {
+ zaloUserID = strings.TrimSpace(customerIdentity.ExternalID)
+ }
+ if zaloUserID == "" {
+ return s.markOutboxFailed(outbox, "unable to resolve zalo user_id")
+ }
+
+ // Send message via Zalo Client
+ client := zalo.NewClient(cfg.AccessToken)
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+
+ _, sendErr := client.SendCSMessage(ctx, zaloUserID, 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 *zaloOAOutboundService) markOutboxFailed(outbox *models.ChannelMessageOutbox, errMsg string) error {
+ if outbox == nil {
+ return nil
+ }
+ retryCount := outbox.RetryCount + 1
+ status := string(enums.ChannelMessageOutboxStatusFailed)
+ if retryCount >= zaloOAOutboxMaxRetry {
+ 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/telegram/client.go b/internal/telegram/client.go
index 5febe51b..ea873188 100644
--- a/internal/telegram/client.go
+++ b/internal/telegram/client.go
@@ -55,6 +55,28 @@ func (c *Client) SetWebhook(ctx context.Context, req SetWebhookRequest) error {
return nil
}
+func (c *Client) DeleteWebhook(ctx context.Context) error {
+ var resp APIResponse[bool]
+ if err := c.doRequest(ctx, "deleteWebhook", nil, &resp); err != nil {
+ return err
+ }
+ if !resp.OK {
+ return fmt.Errorf("telegram deleteWebhook failed (%d): %s", resp.ErrorCode, resp.Description)
+ }
+ return nil
+}
+
+func (c *Client) GetWebhookInfo(ctx context.Context) (*WebhookInfo, error) {
+ var resp APIResponse[WebhookInfo]
+ if err := c.doRequest(ctx, "getWebhookInfo", nil, &resp); err != nil {
+ return nil, err
+ }
+ if !resp.OK {
+ return nil, fmt.Errorf("telegram getWebhookInfo failed (%d): %s", resp.ErrorCode, resp.Description)
+ }
+ return &resp.Result, nil
+}
+
func (c *Client) SendMessage(ctx context.Context, req SendMessageRequest) (*Message, error) {
if strings.TrimSpace(req.Text) == "" {
return nil, fmt.Errorf("telegram message text is required")
diff --git a/internal/telegram/client_test.go b/internal/telegram/client_test.go
index 63ed03e3..5c3a3289 100644
--- a/internal/telegram/client_test.go
+++ b/internal/telegram/client_test.go
@@ -81,3 +81,44 @@ func TestTelegramClient_SendMessage(t *testing.T) {
t.Fatalf("unexpected sent message: %+v", msg)
}
}
+
+func TestTelegramClient_SetAndDeleteWebhook(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/bot123456:ABC-DEF/setWebhook":
+ _ = json.NewEncoder(w).Encode(APIResponse[bool]{OK: true, Result: true})
+ case "/bot123456:ABC-DEF/deleteWebhook":
+ _ = json.NewEncoder(w).Encode(APIResponse[bool]{OK: true, Result: true})
+ case "/bot123456:ABC-DEF/getWebhookInfo":
+ _ = json.NewEncoder(w).Encode(APIResponse[WebhookInfo]{
+ OK: true,
+ Result: WebhookInfo{
+ URL: "https://desk.crove.com/api/third/telegram/webhook/ch_123",
+ },
+ })
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer ts.Close()
+
+ client := NewClient("123456:ABC-DEF")
+ client.SetBaseURL(ts.URL)
+
+ err := client.SetWebhook(context.Background(), SetWebhookRequest{
+ URL: "https://desk.crove.com/api/third/telegram/webhook/ch_123",
+ })
+ if err != nil {
+ t.Fatalf("SetWebhook failed: %v", err)
+ }
+
+ info, err := client.GetWebhookInfo(context.Background())
+ if err != nil || info.URL != "https://desk.crove.com/api/third/telegram/webhook/ch_123" {
+ t.Fatalf("GetWebhookInfo failed: %+v, err: %v", info, err)
+ }
+
+ err = client.DeleteWebhook(context.Background())
+ if err != nil {
+ t.Fatalf("DeleteWebhook failed: %v", err)
+ }
+}
diff --git a/internal/telegram/types.go b/internal/telegram/types.go
index 7a431b69..95795c21 100644
--- a/internal/telegram/types.go
+++ b/internal/telegram/types.go
@@ -59,3 +59,12 @@ type SetWebhookRequest struct {
SecretToken string `json:"secret_token,omitempty"`
AllowedUpdates []string `json:"allowed_updates,omitempty"`
}
+
+// WebhookInfo represents current Telegram webhook status.
+type WebhookInfo struct {
+ URL string `json:"url"`
+ HasCustomCertificate bool `json:"has_custom_certificate"`
+ PendingUpdateCount int `json:"pending_update_count"`
+ LastErrorDate int64 `json:"last_error_date,omitempty"`
+ LastErrorMessage string `json:"last_error_message,omitempty"`
+}
diff --git a/internal/zalo/client.go b/internal/zalo/client.go
new file mode 100644
index 00000000..34cad66a
--- /dev/null
+++ b/internal/zalo/client.go
@@ -0,0 +1,140 @@
+package zalo
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+const defaultBaseURL = "https://openapi.zalo.me"
+
+type Client struct {
+ accessToken string
+ baseURL string
+ httpClient *http.Client
+}
+
+func NewClient(accessToken string) *Client {
+ return &Client{
+ accessToken: strings.TrimSpace(accessToken),
+ baseURL: defaultBaseURL,
+ httpClient: &http.Client{Timeout: 15 * time.Second},
+ }
+}
+
+func (c *Client) SetBaseURL(u string) {
+ if strings.TrimSpace(u) != "" {
+ c.baseURL = strings.TrimRight(strings.TrimSpace(u), "/")
+ }
+}
+
+func (c *Client) SendCSMessage(ctx context.Context, userID string, text string) (*APIResponse, error) {
+ if strings.TrimSpace(userID) == "" {
+ return nil, fmt.Errorf("zalo user_id is required")
+ }
+ if strings.TrimSpace(text) == "" {
+ return nil, fmt.Errorf("zalo message text is required")
+ }
+
+ reqPayload := SendMessageRequest{
+ Recipient: UserRef{ID: strings.TrimSpace(userID)},
+ Message: SendContent{Text: text},
+ }
+
+ var resp APIResponse
+ if err := c.doPost(ctx, "/v3.0/oa/message/cs", reqPayload, &resp); err != nil {
+ return nil, err
+ }
+ if resp.Error != 0 {
+ return nil, fmt.Errorf("zalo send message failed (%d): %s", resp.Error, resp.Message)
+ }
+ return &resp, nil
+}
+
+func (c *Client) GetUserProfile(ctx context.Context, userID string) (*UserProfile, error) {
+ if strings.TrimSpace(userID) == "" {
+ return nil, fmt.Errorf("zalo user_id is required")
+ }
+
+ params := url.Values{}
+ params.Set("data", fmt.Sprintf(`{"user_id":"%s"}`, strings.TrimSpace(userID)))
+
+ var resp UserProfileResponse
+ if err := c.doGet(ctx, "/v3.0/oa/user/detail?"+params.Encode(), &resp); err != nil {
+ return nil, err
+ }
+ if resp.Error != 0 {
+ return nil, fmt.Errorf("zalo get user profile failed (%d): %s", resp.Error, resp.Message)
+ }
+ return &resp.Data, nil
+}
+
+func (c *Client) doPost(ctx context.Context, path string, payload any, result any) error {
+ if c.accessToken == "" {
+ return fmt.Errorf("zalo access_token is required")
+ }
+
+ endpoint := fmt.Sprintf("%s%s", c.baseURL, path)
+ bodyBytes, err := json.Marshal(payload)
+ if err != nil {
+ return fmt.Errorf("marshal zalo request failed: %w", err)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBuffer(bodyBytes))
+ if err != nil {
+ return fmt.Errorf("create zalo request failed: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("access_token", c.accessToken)
+
+ res, err := c.httpClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("zalo http request failed: %w", err)
+ }
+ defer res.Body.Close()
+
+ body, err := io.ReadAll(res.Body)
+ if err != nil {
+ return fmt.Errorf("read zalo response failed: %w", err)
+ }
+
+ if err := json.Unmarshal(body, result); err != nil {
+ return fmt.Errorf("unmarshal zalo response failed: %w (body: %s)", err, string(body))
+ }
+ return nil
+}
+
+func (c *Client) doGet(ctx context.Context, path string, result any) error {
+ if c.accessToken == "" {
+ return fmt.Errorf("zalo access_token is required")
+ }
+
+ endpoint := fmt.Sprintf("%s%s", c.baseURL, path)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return fmt.Errorf("create zalo request failed: %w", err)
+ }
+ req.Header.Set("access_token", c.accessToken)
+
+ res, err := c.httpClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("zalo http request failed: %w", err)
+ }
+ defer res.Body.Close()
+
+ body, err := io.ReadAll(res.Body)
+ if err != nil {
+ return fmt.Errorf("read zalo response failed: %w", err)
+ }
+
+ if err := json.Unmarshal(body, result); err != nil {
+ return fmt.Errorf("unmarshal zalo response failed: %w (body: %s)", err, string(body))
+ }
+ return nil
+}
diff --git a/internal/zalo/client_test.go b/internal/zalo/client_test.go
new file mode 100644
index 00000000..b5564d47
--- /dev/null
+++ b/internal/zalo/client_test.go
@@ -0,0 +1,81 @@
+package zalo
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestZaloClient_SendCSMessage(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/v3.0/oa/message/cs" {
+ http.NotFound(w, r)
+ return
+ }
+ if r.Header.Get("access_token") != "test_access_token_123" {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ var req SendMessageRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ if req.Recipient.ID != "zalo_user_001" || req.Message.Text != "Xin chào từ Crove Desk!" {
+ http.Error(w, "invalid payload", http.StatusBadRequest)
+ return
+ }
+ _ = json.NewEncoder(w).Encode(APIResponse{
+ Error: 0,
+ Message: "Success",
+ Data: map[string]any{
+ "message_id": "zalo_msg_1001",
+ },
+ })
+ }))
+ defer ts.Close()
+
+ client := NewClient("test_access_token_123")
+ client.SetBaseURL(ts.URL)
+
+ resp, err := client.SendCSMessage(context.Background(), "zalo_user_001", "Xin chào từ Crove Desk!")
+ if err != nil {
+ t.Fatalf("SendCSMessage failed: %v", err)
+ }
+ if resp.Error != 0 {
+ t.Fatalf("unexpected resp: %+v", resp)
+ }
+}
+
+func TestZaloClient_GetUserProfile(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/v3.0/oa/user/detail" {
+ http.NotFound(w, r)
+ return
+ }
+ _ = json.NewEncoder(w).Encode(UserProfileResponse{
+ Error: 0,
+ Message: "Success",
+ Data: UserProfile{
+ UserID: "zalo_user_001",
+ DisplayName: "Nguyen Van B",
+ UserGender: "1",
+ Avatar: "https://avatar.zalo.me/1.jpg",
+ },
+ })
+ }))
+ defer ts.Close()
+
+ client := NewClient("test_access_token_123")
+ client.SetBaseURL(ts.URL)
+
+ profile, err := client.GetUserProfile(context.Background(), "zalo_user_001")
+ if err != nil {
+ t.Fatalf("GetUserProfile failed: %v", err)
+ }
+ if profile.DisplayName != "Nguyen Van B" || profile.UserID != "zalo_user_001" {
+ t.Fatalf("unexpected profile: %+v", profile)
+ }
+}
diff --git a/internal/zalo/types.go b/internal/zalo/types.go
new file mode 100644
index 00000000..7ef65420
--- /dev/null
+++ b/internal/zalo/types.go
@@ -0,0 +1,59 @@
+package zalo
+
+// WebhookEvent represents an incoming webhook event from Zalo Official Account.
+type WebhookEvent struct {
+ EventName string `json:"event_name"`
+ AppID string `json:"app_id"`
+ Sender UserRef `json:"sender"`
+ Recipient UserRef `json:"recipient"`
+ Message *EventMessage `json:"message,omitempty"`
+ Info map[string]any `json:"info,omitempty"`
+ Timestamp string `json:"timestamp"`
+}
+
+type UserRef struct {
+ ID string `json:"id"`
+}
+
+type EventMessage struct {
+ MsgID string `json:"msg_id"`
+ Text string `json:"text,omitempty"`
+ Attachments []EventAttachment `json:"attachments,omitempty"`
+}
+
+type EventAttachment struct {
+ Type string `json:"type"`
+ Payload any `json:"payload"`
+}
+
+// SendMessageRequest represents payload for Zalo OA Customer Support message API (/v3.0/oa/message/cs).
+type SendMessageRequest struct {
+ Recipient UserRef `json:"recipient"`
+ Message SendContent `json:"message"`
+}
+
+type SendContent struct {
+ Text string `json:"text"`
+}
+
+// APIResponse represents standard Zalo OpenAPI response.
+type APIResponse struct {
+ Error int `json:"error"`
+ Message string `json:"message"`
+ Data any `json:"data,omitempty"`
+}
+
+// UserProfileResponse represents Zalo OA get profile API response.
+type UserProfileResponse struct {
+ Error int `json:"error"`
+ Message string `json:"message"`
+ Data UserProfile `json:"data"`
+}
+
+type UserProfile struct {
+ UserID string `json:"user_id"`
+ DisplayName string `json:"display_name"`
+ UserGender string `json:"user_gender"`
+ Avatar string `json:"avatar"`
+ IsSensitive bool `json:"is_sensitive"`
+}
diff --git a/web/app/(dashboard)/dashboard/channels/_components/edit.tsx b/web/app/(dashboard)/dashboard/channels/_components/edit.tsx
index 6eb2526b..5464c351 100644
--- a/web/app/(dashboard)/dashboard/channels/_components/edit.tsx
+++ b/web/app/(dashboard)/dashboard/channels/_components/edit.tsx
@@ -64,6 +64,15 @@ type TelegramChannelConfig = {
webhookSecret?: string
}
+type ZaloOAChannelConfig = {
+ appId?: string
+ oaId?: string
+ secretKey?: string
+ accessToken?: string
+ refreshToken?: string
+ webhookSecret?: string
+}
+
function getDefaultWebChannelConfig(t: Translate): Required {
return {
title: t("channel.defaultTitleWeb"),
@@ -78,7 +87,7 @@ function getDefaultWebChannelConfig(t: Translate): Required {
function createSchema(t: Translate) {
return z
.object({
- channelType: z.enum(["web", "wechat_mp", "wxwork_kf", "telegram"], t("channel.typeRequired")),
+ channelType: z.enum(["web", "wechat_mp", "wxwork_kf", "telegram", "zalo_oa"], t("channel.typeRequired")),
aiAgentId: z.string().trim().regex(/^\d+$/, t("channel.agentRequired")),
aiAgentRolloutPercent: z.coerce.number().int().min(1).max(100),
name: z.string().trim().min(1, t("channel.nameRequired")),
@@ -86,6 +95,10 @@ function createSchema(t: Translate) {
botToken: z.string().trim(),
botUsername: z.string().trim(),
webhookSecret: z.string().trim(),
+ zaloAppId: z.string().trim(),
+ zaloOaId: z.string().trim(),
+ zaloAccessToken: z.string().trim(),
+ zaloSecretKey: z.string().trim(),
widgetTitle: z.string().trim(),
widgetSubtitle: z.string().trim(),
widgetThemeColor: z.string().trim(),
@@ -109,11 +122,18 @@ function createSchema(t: Translate) {
message: "Telegram Bot Token is required",
})
}
+ if (values.channelType === "zalo_oa" && !values.zaloAccessToken.trim()) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["zaloAccessToken"],
+ message: "Zalo OA Access Token is required",
+ })
+ }
})
}
type EditForm = {
- channelType: "web" | "wechat_mp" | "wxwork_kf" | "telegram"
+ channelType: "web" | "wechat_mp" | "wxwork_kf" | "telegram" | "zalo_oa"
aiAgentId: string
aiAgentRolloutPercent: number
name: string
@@ -121,6 +141,10 @@ type EditForm = {
botToken: string
botUsername: string
webhookSecret: string
+ zaloAppId: string
+ zaloOaId: string
+ zaloAccessToken: string
+ zaloSecretKey: string
widgetTitle: string
widgetSubtitle: string
widgetThemeColor: string
@@ -141,6 +165,10 @@ function createEmptyForm(t: Translate): EditForm {
botToken: "",
botUsername: "",
webhookSecret: "",
+ zaloAppId: "",
+ zaloOaId: "",
+ zaloAccessToken: "",
+ zaloSecretKey: "",
widgetTitle: defaultWebChannelConfig.title,
widgetSubtitle: defaultWebChannelConfig.subtitle,
widgetThemeColor: defaultWebChannelConfig.themeColor,
@@ -177,6 +205,23 @@ function parseTelegramChannelConfig(configJson: string): TelegramChannelConfig {
}
}
+function parseZaloOAChannelConfig(configJson: string): ZaloOAChannelConfig {
+ if (!configJson.trim()) return {}
+ try {
+ const parsed = JSON.parse(configJson) as ZaloOAChannelConfig
+ return {
+ appId: parsed.appId?.trim() || "",
+ oaId: parsed.oaId?.trim() || "",
+ secretKey: parsed.secretKey?.trim() || "",
+ accessToken: parsed.accessToken?.trim() || "",
+ refreshToken: parsed.refreshToken?.trim() || "",
+ webhookSecret: parsed.webhookSecret?.trim() || "",
+ }
+ } catch {
+ return {}
+ }
+}
+
function parseWebChannelConfig(configJson: string, t: Translate): Required {
const defaultWebChannelConfig = getDefaultWebChannelConfig(t)
if (!configJson.trim()) {
@@ -230,6 +275,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 webConfig = parseWebChannelConfig(item.configJson, t)
const wechatConfig = isWechatMP
? parseWechatMPChannelConfig(item.configJson, t)
@@ -237,22 +283,31 @@ function buildForm(item: AdminChannel | null, t: Translate): EditForm {
const telegramConfig = isTelegram
? parseTelegramChannelConfig(item.configJson)
: null
+ const zaloConfig = isZaloOA
+ ? parseZaloOAChannelConfig(item.configJson)
+ : null
return {
channelType:
item.channelType === "wxwork_kf"
? "wxwork_kf"
: item.channelType === "telegram"
? "telegram"
- : item.channelType === "wechat_mp"
- ? "wechat_mp"
- : "web",
+ : item.channelType === "zalo_oa"
+ ? "zalo_oa"
+ : item.channelType === "wechat_mp"
+ ? "wechat_mp"
+ : "web",
aiAgentId: item.aiAgentId > 0 ? String(item.aiAgentId) : "",
aiAgentRolloutPercent: item.aiAgentRolloutPercent || 100,
name: item.name,
openKfId: parseOpenKfId(item.configJson),
botToken: telegramConfig?.botToken ?? "",
botUsername: telegramConfig?.botUsername ?? "",
- webhookSecret: telegramConfig?.webhookSecret ?? "",
+ webhookSecret: telegramConfig?.webhookSecret ?? zaloConfig?.webhookSecret ?? "",
+ zaloAppId: zaloConfig?.appId ?? "",
+ zaloOaId: zaloConfig?.oaId ?? "",
+ zaloAccessToken: zaloConfig?.accessToken ?? "",
+ zaloSecretKey: zaloConfig?.secretKey ?? "",
widgetTitle: wechatConfig?.title ?? webConfig.title,
widgetSubtitle: wechatConfig?.subtitle ?? webConfig.subtitle,
widgetThemeColor: wechatConfig?.themeColor ?? webConfig.themeColor,
@@ -284,14 +339,22 @@ function buildPayload(form: EditForm, status: number, t: Translate): CreateAdmin
botUsername: form.botUsername.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 === "zalo_oa"
+ ? JSON.stringify({
+ appId: form.zaloAppId.trim(),
+ oaId: form.zaloOaId.trim(),
+ accessToken: form.zaloAccessToken.trim(),
+ 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(),
+ })
return {
channelType,
aiAgentId: Number(form.aiAgentId),
@@ -654,6 +717,54 @@ function ChannelFormBody({
+ {channelType === "zalo_oa" ? (
+
+
+ {t("channel.zaloAccessToken")} *
+
+
+
+
+
+
+
+
+ {t("channel.zaloOaId")}
+
+
+
+
+
+
+
+ {t("channel.zaloAppId")}
+
+
+
+
+
+
+
+
+
{t("channel.zaloAutoConnectTitle")}
+
{t("channel.zaloAutoConnectDescription")}
+
+
+ ) : null}
+
{channelType === "telegram" ? (
diff --git a/web/app/(dashboard)/dashboard/channels/page.tsx b/web/app/(dashboard)/dashboard/channels/page.tsx
index a186d1bf..aa8c06f7 100644
--- a/web/app/(dashboard)/dashboard/channels/page.tsx
+++ b/web/app/(dashboard)/dashboard/channels/page.tsx
@@ -36,6 +36,9 @@ function getChannelTypeLabel(channelType: string, t: (key: string) => string) {
if (channelType === "telegram") {
return t("channel.typeTelegram")
}
+ if (channelType === "zalo_oa") {
+ return t("channel.typeZaloOa")
+ }
return t("channel.typeWeb")
}
@@ -56,7 +59,7 @@ function ChannelIcon({ channelType }: { channelType: string }) {
if (channelType === "wxwork_kf") {
return
}
- if (channelType === "telegram") {
+ if (channelType === "telegram" || channelType === "zalo_oa") {
return
}
return
@@ -75,6 +78,7 @@ export default function DashboardChannelsPage() {
{ value: "all", label: t("channel.allTypes") },
{ value: "web", label: t("channel.typeWeb") },
{ value: "telegram", label: t("channel.typeTelegram") },
+ { value: "zalo_oa", label: t("channel.typeZaloOa") },
{ value: "wechat_mp", label: t("channel.typeWechatMp") },
{ value: "wxwork_kf", label: t("channel.typeWxworkKf") },
]
diff --git a/web/lib/generated/enums.ts b/web/lib/generated/enums.ts
index c8bb4b57..31c4af46 100644
--- a/web/lib/generated/enums.ts
+++ b/web/lib/generated/enums.ts
@@ -79,6 +79,7 @@ export enum ExternalSource {
User = "user",
TwentyCRM = "twenty_crm",
Telegram = "telegram",
+ ZaloOA = "zalo_oa",
}
export const ExternalSourceLabels: Record = {
[ExternalSource.Guest]: "访客",
@@ -86,6 +87,7 @@ export const ExternalSourceLabels: Record = {
[ExternalSource.User]: "用户",
[ExternalSource.TwentyCRM]: "Twenty CRM",
[ExternalSource.Telegram]: "Telegram",
+ [ExternalSource.ZaloOA]: "Zalo OA",
}
export enum Gender {
diff --git a/web/messages/en-US.json b/web/messages/en-US.json
index 393de80b..ebd8477f 100644
--- a/web/messages/en-US.json
+++ b/web/messages/en-US.json
@@ -606,6 +606,7 @@
"allTypes": "All types",
"typeWeb": "Web",
"typeTelegram": "Telegram Bot",
+ "typeZaloOa": "Zalo Official Account",
"typeWechatMp": "WeChat Official Account",
"typeWxworkKf": "WeCom Customer Service",
"botToken": "Telegram Bot Token",
@@ -614,6 +615,11 @@
"webhookSecret": "Webhook Secret Token",
"telegramAutoConnectTitle": "Automatic Telegram Bot Connection",
"telegramAutoConnectDescription": "Enter your Bot Token from @BotFather. Crove Desk automatically connects to Telegram and syncs inbound/outbound customer conversations seamlessly.",
+ "zaloAccessToken": "Zalo OA Access Token",
+ "zaloOaId": "OA ID",
+ "zaloAppId": "App ID",
+ "zaloAutoConnectTitle": "Zalo Official Account Connection",
+ "zaloAutoConnectDescription": "Connect your Zalo Official Account using the Access Token to automatically receive customer messages and dispatch replies.",
"loadFailed": "Could not load channels.",
"created": "Channel created: {name}",
"updated": "Channel updated: {name}",
diff --git a/web/messages/vi-VN.json b/web/messages/vi-VN.json
index 9a5d5f84..2366e28b 100644
--- a/web/messages/vi-VN.json
+++ b/web/messages/vi-VN.json
@@ -617,6 +617,7 @@
"allTypes": "All types",
"typeWeb": "Web",
"typeTelegram": "Telegram Bot",
+ "typeZaloOa": "Zalo Official Account",
"typeWechatMp": "WeChat Official Account",
"typeWxworkKf": "WeCom Customer Service",
"botToken": "Telegram Bot Token",
@@ -625,6 +626,11 @@
"webhookSecret": "Webhook Secret Token",
"telegramAutoConnectTitle": "Automatic Telegram Bot Connection",
"telegramAutoConnectDescription": "Enter your Bot Token from @BotFather. Crove Desk automatically connects to Telegram and syncs inbound/outbound customer conversations seamlessly.",
+ "zaloAccessToken": "Zalo OA Access Token",
+ "zaloOaId": "OA ID",
+ "zaloAppId": "App ID",
+ "zaloAutoConnectTitle": "Zalo Official Account Connection",
+ "zaloAutoConnectDescription": "Connect your Zalo Official Account using the Access Token to automatically receive customer messages and dispatch replies.",
"loadFailed": "Could not load channels.",
"created": "Channel created: {name}",
"updated": "Channel updated: {name}",
diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json
index 95cd7207..f3e6bb6f 100644
--- a/web/messages/zh-CN.json
+++ b/web/messages/zh-CN.json
@@ -606,6 +606,7 @@
"allTypes": "全部类型",
"typeWeb": "Web 站点",
"typeTelegram": "Telegram Bot",
+ "typeZaloOa": "Zalo 公众号",
"typeWechatMp": "微信公众号",
"typeWxworkKf": "企业微信客服",
"botToken": "Telegram Bot Token",
@@ -614,6 +615,11 @@
"webhookSecret": "Webhook Secret Token",
"telegramAutoConnectTitle": "Telegram Bot 自动连接",
"telegramAutoConnectDescription": "只需输入来自 @BotFather 的 Bot Token,Crove Desk 将自动连接并双向同步客户会话消息。",
+ "zaloAccessToken": "Zalo OA Access Token",
+ "zaloOaId": "OA ID",
+ "zaloAppId": "App ID",
+ "zaloAutoConnectTitle": "Zalo OA 渠道连接",
+ "zaloAutoConnectDescription": "输入 Zalo OA 的 Access Token 即可自动双向同步客户会话与消息。",
"loadFailed": "加载接入渠道失败",
"created": "已创建接入渠道:{name}",
"updated": "已更新接入渠道:{name}",
From 40d5f6c8f1ce2b7d17222daba270c7f74fe23302 Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Thu, 27 Aug 2026 09:53:05 +0700
Subject: [PATCH 28/53] feat(webhook): register /api/webhooks/ecosystem route
alias for Crove OS sync
Co-authored-by: Cursor
---
internal/bootstrap/routes.go | 1 +
internal/bootstrap/server_route_test.go | 1 +
2 files changed, 2 insertions(+)
diff --git a/internal/bootstrap/routes.go b/internal/bootstrap/routes.go
index 3742ee5f..b55950b8 100644
--- a/internal/bootstrap/routes.go
+++ b/internal/bootstrap/routes.go
@@ -32,6 +32,7 @@ func registerApiWebhookRoutes(group *gin.RouterGroup) {
group.POST("/crm-sync", api.OrgSyncWebhook)
group.POST("/dos-events", api.OrgSyncWebhook)
group.POST("/events", api.OrgSyncWebhook)
+ group.POST("/ecosystem", api.OrgSyncWebhook)
}
func registerApiCustomerRoutes(group *gin.RouterGroup) {
diff --git a/internal/bootstrap/server_route_test.go b/internal/bootstrap/server_route_test.go
index ac4dee53..4bb75af6 100644
--- a/internal/bootstrap/server_route_test.go
+++ b/internal/bootstrap/server_route_test.go
@@ -42,6 +42,7 @@ func TestNewServerRegistersGinRoutes(t *testing.T) {
http.MethodPost + " /api/auth/oidc_exchange",
http.MethodGet + " /api/auth/profile",
http.MethodPost + " /api/webhooks/org-sync",
+ http.MethodPost + " /api/webhooks/ecosystem",
http.MethodGet + " /api/dashboard/organization/my_list",
http.MethodPost + " /api/dashboard/organization/create",
http.MethodPost + " /api/dashboard/organization/switch",
From cd95ff10a0d89c1adde8e347c855a3edffd6e5c5 Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Thu, 27 Aug 2026 11:47:15 +0700
Subject: [PATCH 29/53] fix(oidc): use client_secret_basic auth style in token
exchange and support crove_crm MCP namespace
Co-authored-by: Cursor
---
internal/oidcclient/oidcclient.go | 14 +++++++++++++-
internal/pkg/config/config.go | 12 +++++++++++-
2 files changed, 24 insertions(+), 2 deletions(-)
diff --git a/internal/oidcclient/oidcclient.go b/internal/oidcclient/oidcclient.go
index 391d21fb..7bcfa5b2 100644
--- a/internal/oidcclient/oidcclient.go
+++ b/internal/oidcclient/oidcclient.go
@@ -106,11 +106,23 @@ func Init(ctx context.Context) error {
if len(scopes) == 0 {
scopes = []string{gooidc.ScopeOpenID, "profile", "email", "offline_access"}
}
+ endpoint := p.Endpoint()
+ authStyle := oauth2.AuthStyleInHeader
+ switch strings.ToLower(strings.TrimSpace(cfg.AuthStyle)) {
+ case "post", "params", "inparams", "client_secret_post":
+ authStyle = oauth2.AuthStyleInParams
+ case "basic", "header", "inheader", "client_secret_basic":
+ authStyle = oauth2.AuthStyleInHeader
+ case "auto", "autodetect":
+ authStyle = oauth2.AuthStyleAutoDetect
+ }
+ endpoint.AuthStyle = authStyle
+
provider = p
oauthConfig = &oauth2.Config{
ClientID: strings.TrimSpace(cfg.ClientID),
ClientSecret: strings.TrimSpace(cfg.ClientSecret),
- Endpoint: p.Endpoint(),
+ Endpoint: endpoint,
RedirectURL: strings.TrimSpace(cfg.RedirectURL),
Scopes: scopes,
}
diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go
index f51231c0..e3ffc5ca 100644
--- a/internal/pkg/config/config.go
+++ b/internal/pkg/config/config.go
@@ -209,6 +209,7 @@ type OIDCConfig struct {
Issuer string `yaml:"issuer"`
ClientID string `yaml:"clientId"`
ClientSecret string `yaml:"clientSecret"`
+ AuthStyle string `yaml:"authStyle"`
RedirectURL string `yaml:"redirectUrl"`
StateSecret string `yaml:"stateSecret"`
Scopes []string `yaml:"scopes"`
@@ -379,6 +380,7 @@ func bindEnvironmentAliases(v *viper.Viper) {
_ = 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.authStyle", "OIDC_AUTH_STYLE", "CUSTOM_OAUTH_AUTH_STYLE", "AGENT_DESK_OIDC_AUTHSTYLE")
_ = 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")
_ = v.BindEnv("webhook.outboundUrl", "ORG_SYNC_OUTBOUND_URL", "DOS_ORG_SYNC_URL", "WEBHOOK_OUTBOUND_URL", "AGENT_DESK_WEBHOOK_OUTBOUNDURL")
@@ -411,11 +413,17 @@ func normalizeLoadedConfig(cfg *Config) {
}
crmEndpoint := strings.TrimSpace(os.Getenv("MCP_CRM_ENDPOINT"))
+ if crmEndpoint == "" {
+ crmEndpoint = strings.TrimSpace(os.Getenv("CROVE_CRM_MCP_ENDPOINT"))
+ }
if crmEndpoint == "" {
crmEndpoint = strings.TrimSpace(os.Getenv("TWENTY_CRM_MCP_ENDPOINT"))
}
if crmEndpoint != "" {
apiKey := strings.TrimSpace(os.Getenv("MCP_CRM_API_KEY"))
+ if apiKey == "" {
+ apiKey = strings.TrimSpace(os.Getenv("CROVE_CRM_API_KEY"))
+ }
if apiKey == "" {
apiKey = strings.TrimSpace(os.Getenv("TWENTY_CRM_API_KEY"))
}
@@ -423,11 +431,13 @@ func normalizeLoadedConfig(cfg *Config) {
if apiKey != "" {
headers["Authorization"] = "Bearer " + apiKey
}
- cfg.MCP.Servers["twenty_crm"] = MCPServerConfig{
+ crmServerConfig := MCPServerConfig{
Enabled: true,
Endpoint: crmEndpoint,
TimeoutMS: 15000,
Headers: headers,
}
+ cfg.MCP.Servers["twenty_crm"] = crmServerConfig
+ cfg.MCP.Servers["crove_crm"] = crmServerConfig
}
}
From 1ada550b514e982d3b64a7fc6aeb70a37836d602 Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Thu, 27 Aug 2026 20:04:52 +0700
Subject: [PATCH 30/53] feat(branding): add custom companyFaviconUrl, high-res
Crove SVG assets, and dynamic favicon/title synchronization
- Add companyFaviconUrl to ServerConfig and PublicConfigResponse
- Bind environment variable COMPANY_FAVICON_URL with aliases
- Add native Crove SVG logo (/images/logo.svg) and favicon (/favicon.svg)
- Dynamically update document title and favicon link in AppI18nProvider based on backend public config
- Update Login form and root layouts with branding and metadata icon tags
---
internal/handlers/api/auth_handler.go | 1 +
internal/pkg/config/config.go | 13 +-
internal/pkg/dto/response/auth_response.go | 1 +
scripts/publish_frill_backlog.ps1 | 174 +++++++++++++++++++++
web/app/(dashboard)/layout.tsx | 9 +-
web/app/(support)/layout.tsx | 5 +
web/components/login-form.tsx | 12 ++
web/i18n/provider.tsx | 67 +++++---
web/lib/api/config.ts | 1 +
web/public/favicon.svg | 15 ++
web/public/images/logo.svg | 54 ++-----
11 files changed, 283 insertions(+), 69 deletions(-)
create mode 100644 scripts/publish_frill_backlog.ps1
create mode 100644 web/public/favicon.svg
diff --git a/internal/handlers/api/auth_handler.go b/internal/handlers/api/auth_handler.go
index c9ea341c..cbb57cfe 100644
--- a/internal/handlers/api/auth_handler.go
+++ b/internal/handlers/api/auth_handler.go
@@ -42,6 +42,7 @@ func PublicConfig(ctx *gin.Context) {
Language: cfg.LanguageOrDefault(),
CompanyName: cfg.Server.CompanyName,
CompanyLogoURL: cfg.Server.CompanyLogoURL,
+ CompanyFaviconURL: cfg.Server.CompanyFaviconURL,
PasswordLoginEnabled: cfg.Auth.IsPasswordLoginEnabled(),
WxWorkEnabled: cfg.WxWork.Enabled,
OIDCEnabled: cfg.OIDC.Enabled,
diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go
index e3ffc5ca..0bcbd76e 100644
--- a/internal/pkg/config/config.go
+++ b/internal/pkg/config/config.go
@@ -49,11 +49,12 @@ type WxWorkNotifyConfig struct {
}
type ServerConfig struct {
- Port int `yaml:"port"`
- PublicURL string `yaml:"publicUrl"`
- CompanyName string `yaml:"companyName"`
- CompanyLogoURL string `yaml:"companyLogoUrl"`
- CORS CORSConfig `yaml:"cors"`
+ Port int `yaml:"port"`
+ PublicURL string `yaml:"publicUrl"`
+ CompanyName string `yaml:"companyName"`
+ CompanyLogoURL string `yaml:"companyLogoUrl"`
+ CompanyFaviconURL string `yaml:"companyFaviconUrl"`
+ CORS CORSConfig `yaml:"cors"`
}
func (s ServerConfig) Address() string {
@@ -318,6 +319,7 @@ func bindConfigDefaults(v *viper.Viper) {
v.SetDefault("server.publicUrl", "")
v.SetDefault("server.companyName", "")
v.SetDefault("server.companyLogoUrl", "")
+ v.SetDefault("server.companyFaviconUrl", "")
v.SetDefault("server.cors.allowedOrigins", []string{})
v.SetDefault("db.type", "sqlite")
v.SetDefault("db.dsn", "file:./data/app.db?_busy_timeout=5000")
@@ -356,6 +358,7 @@ func bindEnvironmentAliases(v *viper.Viper) {
_ = v.BindEnv("server.publicUrl", "PUBLIC_URL", "APP_URL", "SERVER_PUBLIC_URL", "BASE_URL", "DESK_BASE_URL", "AGENT_DESK_SERVER_PUBLICURL")
_ = 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("server.companyFaviconUrl", "COMPANY_FAVICON_URL", "NEXT_PUBLIC_COMPANY_FAVICON_URL", "BRAND_FAVICON_URL", "FAVICON_URL", "AGENT_DESK_SERVER_COMPANYFAVICONURL")
_ = 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")
diff --git a/internal/pkg/dto/response/auth_response.go b/internal/pkg/dto/response/auth_response.go
index 07d05a7a..874617b1 100644
--- a/internal/pkg/dto/response/auth_response.go
+++ b/internal/pkg/dto/response/auth_response.go
@@ -24,6 +24,7 @@ type PublicConfigResponse struct {
Language string `json:"language"`
CompanyName string `json:"companyName,omitempty"`
CompanyLogoURL string `json:"companyLogoUrl,omitempty"`
+ CompanyFaviconURL string `json:"companyFaviconUrl,omitempty"`
PasswordLoginEnabled bool `json:"passwordLoginEnabled"`
WxWorkEnabled bool `json:"wxworkEnabled"`
OIDCEnabled bool `json:"oidcEnabled"`
diff --git a/scripts/publish_frill_backlog.ps1 b/scripts/publish_frill_backlog.ps1
new file mode 100644
index 00000000..94906d23
--- /dev/null
+++ b/scripts/publish_frill_backlog.ps1
@@ -0,0 +1,174 @@
+$headers = @{
+ "Authorization" = "Bearer 34bb877b-4ae2-4950-87e7-7282e198ab73"
+ "Content-Type" = "application/json"
+}
+
+$authorIdx = "follower_3k2noqx3" # JOY
+
+# Statuses:
+# status_xz33599z : Under consideration
+# status_p49jn3p1 : Planned
+# status_242mo97z : In Development
+# status_p47lj9oz : Shipped
+
+# Topics:
+# topic_63pxlq1v : Integrations 🔗
+# topic_5d9eyzp3 : Improvement 👍
+
+$backlog = @(
+ @{
+ name = "Telegram Bot Channel Integration"
+ description = "Native bidirectional integration with Telegram Bot API. Supports automated zero-config webhook binding, customer conversation routing, AI Agent auto-reply, and human agent outbox delivery."
+ status_idx = "status_p47lj9oz" # Shipped
+ topic_idxs = @("topic_63pxlq1v")
+ },
+ @{
+ name = "Zalo Official Account (OA) Channel Gateway"
+ description = "Native channel adapter for Zalo Official Account (OA). Enables Vietnamese businesses to receive customer support inquiries and dispatch AI/agent replies via Zalo CS messaging API."
+ status_idx = "status_p49jn3p1" # Planned
+ topic_idxs = @("topic_63pxlq1v")
+ },
+ @{
+ name = "Inbound Email-to-Ticket & SMTP/IMAP Gateway"
+ description = "Convert inbound customer support emails into threaded conversation tickets automatically. Allows agents and AI to reply directly via email."
+ status_idx = "status_p49jn3p1" # Planned
+ topic_idxs = @("topic_63pxlq1v", "topic_5d9eyzp3")
+ },
+ @{
+ name = "WhatsApp Business API & Cloud Gateway"
+ description = "Connect WhatsApp Business Cloud API to Crove Desk. Support template messages, interactive buttons, and real-time chat sync for international customer support."
+ status_idx = "status_xz33599z" # Under consideration
+ topic_idxs = @("topic_63pxlq1v")
+ },
+ @{
+ name = "Live Chat Web Widget SDK with Custom Theming & JWT Verification"
+ description = "Embeddable lightweight web chat widget with customizable theme colors, position, and secure customer JWT token verification."
+ status_idx = "status_p47lj9oz" # Shipped
+ topic_idxs = @("topic_5d9eyzp3")
+ },
+ @{
+ name = "OpenAI-Compatible AI Engine & Auto-Bootstrap"
+ description = "Zero-config LLM and vector embedding integration supporting OpenAI, DOS.AI, DeepSeek, and OpenAI-compatible gateways via environment variables."
+ status_idx = "status_p47lj9oz" # Shipped
+ topic_idxs = @("topic_5d9eyzp3")
+ },
+ @{
+ name = "Smart Answerability Gate & Confidence Scoring for RAG"
+ description = "Evaluates retrieval confidence and document relevancy before AI generates a response, preventing hallucinations on unsupported customer questions."
+ status_idx = "status_p47lj9oz" # Shipped
+ topic_idxs = @("topic_5d9eyzp3")
+ },
+ @{
+ name = "Automated Human Handoff on Low AI Confidence"
+ description = "Seamlessly escalates customer conversations to online human support agents with context transfer when the AI Answerability Gate confidence is below threshold."
+ status_idx = "status_p49jn3p1" # Planned
+ topic_idxs = @("topic_5d9eyzp3")
+ },
+ @{
+ name = "Visual AI Workflow Canvas & Node-based Orchestration"
+ description = "Legacy node-based drag-and-drop workflow designer (Flowgram) for deterministic multi-step support flows. (Kept under consideration in favor of dynamic AI-native agentic loops)."
+ status_idx = "status_xz33599z" # Under consideration
+ topic_idxs = @("topic_5d9eyzp3")
+ },
+ @{
+ name = "Automated Conversation Summarization & Sentiment Analysis"
+ description = "AI automatically generates resolution summaries and tags customer sentiment (Positive, Neutral, Frustrated) upon ticket closure."
+ status_idx = "status_xz33599z" # Under consideration
+ topic_idxs = @("topic_5d9eyzp3")
+ },
+ @{
+ name = "2-Tier Hybrid Sync: Relational Mirror with Twenty CRM & DOS.Me"
+ description = "Real-time bidirectional synchronization of Company and Customer profiles between Twenty CRM, DOS.Me, and Crove Desk via webhook events."
+ status_idx = "status_p47lj9oz" # Shipped
+ topic_idxs = @("topic_63pxlq1v", "topic_4d2x1y1v")
+ },
+ @{
+ name = "MCP Tool Calling: Live Deal & Subscription Status Lookup from CRM"
+ description = "Equips Crove Desk AI Agents with Model Context Protocol (MCP) tools to query live CRM deals, subscription tiers, and customer records on demand."
+ status_idx = "status_p49jn3p1" # Planned
+ topic_idxs = @("topic_63pxlq1v", "topic_4d2x1y1v")
+ },
+ @{
+ name = "Auto-Create CRM Deals & Follow-up Tasks from Support Inquiries"
+ description = "AI Agent identifies sales opportunities during customer support conversations and automatically creates Deals and follow-up Tasks in Twenty CRM."
+ status_idx = "status_xz33599z" # Under consideration
+ topic_idxs = @("topic_63pxlq1v", "topic_4d2x1y1v")
+ },
+ @{
+ name = "Multi-Tenant Workspace Management with Just-In-Time SSO"
+ description = "Isolated multi-organization workspace switching, member role management, and JIT user provisioning via DOS.Me OIDC single sign-on."
+ status_idx = "status_p47lj9oz" # Shipped
+ topic_idxs = @("topic_5d9eyzp3")
+ },
+ @{
+ name = "Configurable SLA Policies & Priority Escalation Rules"
+ description = "Define First Response Time and Resolution Time SLA targets based on customer tier, ticket priority, and business hours with automated alerts."
+ status_idx = "status_xz33599z" # Under consideration
+ topic_idxs = @("topic_5d9eyzp3")
+ },
+ @{
+ name = "Granular Role-Based Access Control (RBAC) for Support Agents"
+ description = "Customizable permission matrices for Tier 1 agents, senior support specialists, and support administrators across channels and knowledge bases."
+ status_idx = "status_p49jn3p1" # Planned
+ topic_idxs = @("topic_5d9eyzp3")
+ },
+ @{
+ name = "Multi-language Knowledge Base & Vector FAQ Indexing"
+ description = "Publish help documentation and categorized FAQs with multilingual support (EN, VI, ZH) and automatic Qdrant vector embedding indexing."
+ status_idx = "status_p47lj9oz" # Shipped
+ topic_idxs = @("topic_5d9eyzp3")
+ },
+ @{
+ name = "Public Customer Community Forum & Peer Discussion Board"
+ description = "Community discussion space allowing customers to post questions, share tips, vote on best answers, with agent moderation."
+ status_idx = "status_xz33599z" # Under consideration
+ topic_idxs = @("topic_5d9eyzp3")
+ },
+ @{
+ name = "Custom Domain & White-Label Support Portal"
+ description = "CNAME custom domain mapping and custom branding (colors, logos, favicons) for customer-facing Help Centers."
+ status_idx = "status_xz33599z" # Under consideration
+ topic_idxs = @("topic_5d9eyzp3")
+ },
+ @{
+ name = "Omnichannel CSAT & Customer Satisfaction Surveys"
+ description = "Trigger automated CSAT star ratings and feedback prompts across Web Widget, Telegram, and Zalo OA when tickets are resolved."
+ status_idx = "status_xz33599z" # Under consideration
+ topic_idxs = @("topic_5d9eyzp3")
+ }
+)
+
+foreach ($item in $backlog) {
+ Write-Host "Creating idea: $($item.name) [Status: $($item.status_idx)]..."
+ $body = @{
+ name = $item.name
+ description = $item.description
+ status_idx = $item.status_idx
+ topic_idxs = $item.topic_idxs
+ author_idx = $authorIdx
+ } | ConvertTo-Json -Depth 5 -Compress
+
+ $success = $false
+ for ($attempt = 1; $attempt -le 4; $attempt++) {
+ try {
+ $resp = Invoke-RestMethod -Uri "https://api.frill.co/v1/ideas" -Method Post -Headers $headers -Body $body
+ Write-Host " -> Success! Idx: $($resp.data.idx) Slug: $($resp.data.slug)"
+ $success = $true
+ break
+ } catch {
+ Write-Host " -> Attempt $attempt failed: $_"
+ if ($_.Exception.Response) {
+ $stream = $_.Exception.Response.GetResponseStream()
+ $reader = New-Object System.IO.StreamReader($stream)
+ $errBody = $reader.ReadToEnd()
+ Write-Host " -> Response: $errBody"
+ if ($errBody -match "rate limit") {
+ Write-Host " -> Sleeping 60s for rate limit reset..."
+ Start-Sleep -Seconds 60
+ }
+ }
+ Start-Sleep -Seconds 3
+ }
+ }
+ Start-Sleep -Seconds 2
+}
diff --git a/web/app/(dashboard)/layout.tsx b/web/app/(dashboard)/layout.tsx
index 627a910d..121aebe2 100644
--- a/web/app/(dashboard)/layout.tsx
+++ b/web/app/(dashboard)/layout.tsx
@@ -35,8 +35,13 @@ try {
`
export const metadata: Metadata = {
- title: "AI Customer Service Admin",
- description: "AI Customer Service Admin",
+ title: "Crove Desk",
+ description: "Crove Desk - AI-Powered Customer Support & Operations",
+ icons: {
+ icon: "/favicon.svg",
+ shortcut: "/favicon.svg",
+ apple: "/images/logo.svg",
+ },
}
export default function DashboardRootLayout({
diff --git a/web/app/(support)/layout.tsx b/web/app/(support)/layout.tsx
index 15f2ebfc..462f684f 100644
--- a/web/app/(support)/layout.tsx
+++ b/web/app/(support)/layout.tsx
@@ -26,6 +26,11 @@ const geistMono = Geist_Mono({
export const metadata: Metadata = {
title: "Crove Desk Support",
description: "Crove Desk Support Center",
+ icons: {
+ icon: "/favicon.svg",
+ shortcut: "/favicon.svg",
+ apple: "/images/logo.svg",
+ },
}
export default function SupportRootLayout({
diff --git a/web/components/login-form.tsx b/web/components/login-form.tsx
index 0b13c96a..73fe807a 100644
--- a/web/components/login-form.tsx
+++ b/web/components/login-form.tsx
@@ -161,6 +161,18 @@ export function LoginForm({
)
}
-function NodeRunPreview({ nodeRun }: { nodeRun?: AIWorkflowNodeRun }) {
+function NodeRunPreview({ nodeRun, t }: { nodeRun?: AIWorkflowNodeRun; t: (key: string, values?: Record) => string }) {
if (!nodeRun) {
- return 暂无节点执行记录。
+ return {t("aiWorkflow.noNodeRuns")}
}
return (
@@ -89,17 +91,17 @@ function NodeRunPreview({ nodeRun }: { nodeRun?: AIWorkflowNodeRun }) {
{nodeRun.errorMessage}
) : null}
-
-
+
+
)
}
-function PreviewBlock({ title, value }: { title: string; value?: string }) {
+function PreviewBlock({ title, value, emptyText }: { title: string; value?: string; emptyText?: string }) {
return (
{title}
- {value ?
:
无
}
+ {value ?
:
{emptyText || "None"}
}
)
}
diff --git a/web/app/(dashboard)/dashboard/ai-workflows/_components/official-workflow-editor.tsx b/web/app/(dashboard)/dashboard/ai-workflows/_components/official-workflow-editor.tsx
index bd85aaf4..d7a12d04 100644
--- a/web/app/(dashboard)/dashboard/ai-workflows/_components/official-workflow-editor.tsx
+++ b/web/app/(dashboard)/dashboard/ai-workflows/_components/official-workflow-editor.tsx
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useRef } from "react"
+import { useI18n } from "@/i18n/provider"
import type { AIWorkflowDefinition, AIWorkflowNodeSpec } from "@/lib/api/admin"
const MESSAGE_SOURCE = "agent-desk"
@@ -38,6 +39,7 @@ export function OfficialWorkflowEditor({
onDefinitionChange: (definition: AIWorkflowDefinition) => void
readonly?: boolean
}) {
+ const t = useI18n()
const frameRef = useRef(null)
const definitionRef = useRef(definition)
@@ -90,7 +92,7 @@ export function OfficialWorkflowEditor({
)
diff --git a/web/app/(dashboard)/dashboard/ai-workflows/_components/workflow-usage-dialog.tsx b/web/app/(dashboard)/dashboard/ai-workflows/_components/workflow-usage-dialog.tsx
index fdb31349..c30f3121 100644
--- a/web/app/(dashboard)/dashboard/ai-workflows/_components/workflow-usage-dialog.tsx
+++ b/web/app/(dashboard)/dashboard/ai-workflows/_components/workflow-usage-dialog.tsx
@@ -6,6 +6,7 @@ import { toast } from "sonner"
import { ProjectDialog } from "@/components/project-dialog"
import { Badge } from "@/components/ui/badge"
+import { useI18n } from "@/i18n/provider"
import {
fetchAIWorkflowUsage,
type AIWorkflow,
@@ -21,6 +22,7 @@ export function WorkflowUsageDialog({
open: boolean
onOpenChange: (open: boolean) => void
}) {
+ const t = useI18n()
const [usage, setUsage] = useState([])
const [loading, setLoading] = useState(false)
@@ -30,11 +32,11 @@ export function WorkflowUsageDialog({
try {
setUsage((await fetchAIWorkflowUsage(workflow.id)) ?? [])
} catch (error) {
- toast.error(error instanceof Error ? error.message : "加载使用情况失败")
+ toast.error(error instanceof Error ? error.message : t("aiWorkflow.loadFailed"))
} finally {
setLoading(false)
}
- }, [open, workflow])
+ }, [open, t, workflow])
useEffect(() => {
void load()
@@ -44,14 +46,14 @@ export function WorkflowUsageDialog({
{loading ? (
- 正在加载使用情况…
+ {t("aiWorkflow.loadingUsage")}
) : usage.length ? (
@@ -63,18 +65,18 @@ export function WorkflowUsageDialog({
{item.aiAgentName}
- 固定关联 v{item.workflowVersion}
+ {t("aiWorkflow.fixedBoundVersion", { version: String(item.workflowVersion) })}
- {item.enabled ? "启用" : "已停用"}
+ {item.enabled ? t("aiWorkflow.enabledStatus") : t("aiWorkflow.disabledStatus")}
))}
) : (
- 暂未被任何 Agent 使用
+ {t("aiWorkflow.notUsedByAnyAgent")}
)}
diff --git a/web/app/(dashboard)/dashboard/ai-workflows/_components/workflow-versions-dialog.tsx b/web/app/(dashboard)/dashboard/ai-workflows/_components/workflow-versions-dialog.tsx
index fe2b74bf..09408039 100644
--- a/web/app/(dashboard)/dashboard/ai-workflows/_components/workflow-versions-dialog.tsx
+++ b/web/app/(dashboard)/dashboard/ai-workflows/_components/workflow-versions-dialog.tsx
@@ -7,6 +7,7 @@ import { toast } from "sonner"
import { ProjectDialog } from "@/components/project-dialog"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
+import { useI18n } from "@/i18n/provider"
import {
fetchAIWorkflowVersions,
restoreAIWorkflowVersion,
@@ -26,6 +27,7 @@ export function WorkflowVersionsDialog({
onOpenChange: (open: boolean) => void
onRestored: () => void
}) {
+ const t = useI18n()
const [versions, setVersions] = useState([])
const [loading, setLoading] = useState(false)
const [restoringId, setRestoringId] = useState(null)
@@ -40,11 +42,11 @@ export function WorkflowVersionsDialog({
})
setVersions(page.results ?? [])
} catch (error) {
- toast.error(error instanceof Error ? error.message : "加载版本历史失败")
+ toast.error(error instanceof Error ? error.message : t("aiWorkflow.loadFailed"))
} finally {
setLoading(false)
}
- }, [open, workflow])
+ }, [open, t, workflow])
useEffect(() => {
void load()
@@ -56,9 +58,9 @@ export function WorkflowVersionsDialog({
try {
await restoreAIWorkflowVersion(workflow.id, version.id)
onRestored()
- toast.success(`已将 v${version.version} 恢复为草稿`)
+ toast.success(t("aiWorkflow.restoredDraftSuccess", { version: String(version.version) }))
} catch (error) {
- toast.error(error instanceof Error ? error.message : "恢复失败")
+ toast.error(error instanceof Error ? error.message : t("aiWorkflow.restoreFailed"))
} finally {
setRestoringId(null)
}
@@ -68,14 +70,14 @@ export function WorkflowVersionsDialog({
{loading ? (
- 正在加载版本历史…
+ {t("aiWorkflow.loadingVersions")}
) : versions.length ? (
@@ -90,7 +92,7 @@ export function WorkflowVersionsDialog({
{formatDateTime(version.publishedAt || version.createdAt)}
- 发布人:{version.publishedByName || "-"}
+ {t("aiWorkflow.publisher", { name: version.publishedByName || "-" })}
))}
) : (
- 尚未发布版本
+ {t("aiWorkflow.noVersionsYet")}
)}
diff --git a/web/app/(dashboard)/dashboard/ai-workflows/_components/workflow-workbench.tsx b/web/app/(dashboard)/dashboard/ai-workflows/_components/workflow-workbench.tsx
index 41b08e78..a6ef0bd7 100644
--- a/web/app/(dashboard)/dashboard/ai-workflows/_components/workflow-workbench.tsx
+++ b/web/app/(dashboard)/dashboard/ai-workflows/_components/workflow-workbench.tsx
@@ -112,9 +112,9 @@ export function WorkflowWorkbench({
useEffect(() => {
void load().catch((error) =>
- toast.error(error instanceof Error ? error.message : "加载工作流失败")
+ toast.error(error instanceof Error ? error.message : t("aiWorkflow.loadFailed"))
)
- }, [load])
+ }, [load, t])
function beginEditing(field: "name" | "description") {
cancellingEditRef.current = false
@@ -139,7 +139,7 @@ export function WorkflowWorkbench({
if (field === "name" && !currentValue) {
setName(editSnapshotRef.current.value)
setDirty(editSnapshotRef.current.dirty)
- toast.error("请填写工作流名称")
+ toast.error(t("aiWorkflow.nameRequired"))
return
}
@@ -179,10 +179,10 @@ export function WorkflowWorkbench({
setDescription(nextDescription)
setDirty(active ? editSnapshotRef.current.dirty : false)
onSaved?.()
- toast.success(field === "name" ? "名称已保存" : "描述已保存")
+ toast.success(field === "name" ? t("aiWorkflow.nameSaved") : t("aiWorkflow.descriptionSaved"))
} catch (error) {
setDirty(true)
- toast.error(error instanceof Error ? error.message : "保存失败")
+ toast.error(error instanceof Error ? error.message : t("aiWorkflow.saveFailed"))
} finally {
savingRef.current = false
setSaving(false)
@@ -203,7 +203,7 @@ export function WorkflowWorkbench({
async function save() {
if (savingRef.current) return
if (!name.trim()) {
- toast.error("请填写工作流名称")
+ toast.error(t("aiWorkflow.nameRequired"))
return
}
const savedName = name.trim()
@@ -244,9 +244,9 @@ export function WorkflowWorkbench({
setDirty(false)
}
onSaved?.()
- toast.success("保存成功")
+ toast.success(t("aiWorkflow.savedSuccess"))
} catch (error) {
- toast.error(error instanceof Error ? error.message : "保存失败")
+ toast.error(error instanceof Error ? error.message : t("aiWorkflow.saveFailed"))
} finally {
savingRef.current = false
setSaving(false)
@@ -256,7 +256,7 @@ export function WorkflowWorkbench({
async function publish() {
if (savingRef.current) return
if (!active) {
- toast.error("请先保存")
+ toast.error(t("aiWorkflow.saveFirst"))
return
}
savingRef.current = true
@@ -275,9 +275,9 @@ export function WorkflowWorkbench({
)
setDirty(false)
onSaved?.()
- toast.success(`已发布 v${version.version}`)
+ toast.success(t("aiWorkflow.publishedVersionSuccess", { version: String(version.version) }))
} catch (error) {
- toast.error(error instanceof Error ? error.message : "发布失败")
+ toast.error(error instanceof Error ? error.message : t("aiWorkflow.publishFailed"))
} finally {
savingRef.current = false
setSaving(false)
@@ -293,7 +293,7 @@ export function WorkflowWorkbench({
{editingField === "name" ? (
event.currentTarget.select()}
onChange={(event) => {
@@ -427,7 +427,7 @@ export function WorkflowWorkbench({
/>
) : (
- 正在加载节点能力…
+ {t("aiWorkflow.loadingNodeCapabilities")}
)}
diff --git a/web/app/(dashboard)/dashboard/channels/_components/edit.tsx b/web/app/(dashboard)/dashboard/channels/_components/edit.tsx
index 5464c351..c5b96168 100644
--- a/web/app/(dashboard)/dashboard/channels/_components/edit.tsx
+++ b/web/app/(dashboard)/dashboard/channels/_components/edit.tsx
@@ -454,9 +454,9 @@ function ChannelFormBody({
aiAgentRolloutPercent: previousRolloutPercent,
previousAiAgentRolloutPercent: channelDetail.aiAgentRolloutPercent,
})
- toast.success("已恢复上一次渠道灰度比例")
+ toast.success(t("aiAgent.channelRolloutRestored"))
} catch (error) {
- toast.error(error instanceof Error ? error.message : "恢复渠道灰度比例失败")
+ toast.error(error instanceof Error ? error.message : t("aiAgent.channelRolloutRestoreFailed"))
} finally {
setRollingBackRollout(false)
}
@@ -533,7 +533,7 @@ function ChannelFormBody({
const selectedAIAgent = aiAgents.find((item) => String(item.id) === aiAgentId)
const aiAgentOptions = aiAgents.map((item) => ({
value: String(item.id),
- label: isAgentChannelBindable(item) ? item.name : `${item.name} · 未发布`,
+ label: isAgentChannelBindable(item) ? item.name : `${item.name} · ${t("aiAgent.agentNotPublishedShort")}`,
disabled: !isAgentChannelBindable(item),
}))
const wxWorkKFAccountOptions = wxWorkKFAccounts.map((item) => ({
@@ -563,8 +563,8 @@ function ChannelFormBody({
async function onFormSubmit(values: EditForm) {
const selected = aiAgents.find((item) => String(item.id) === values.aiAgentId)
- if (!isAgentChannelBindable(selected)) {
- toast.error("该 Agent 尚未完成发布,不能绑定渠道")
+ if (!isAgentChannelBindable(selected)) {
+ toast.error(t("aiAgent.agentNotPublishedWarning"))
return
}
await onSubmit(buildPayload(values, currentStatus, t))
@@ -660,7 +660,7 @@ function ChannelFormBody({
/>
{selectedAIAgent && !isAgentChannelBindable(selectedAIAgent) ? (
- 该 Agent 尚未发布,AI 不会自动回复。请先在 Agent 配置中发布 Revision。
+ {t("aiAgent.agentNotPublishedWarning")}
) : null}
@@ -668,14 +668,14 @@ function ChannelFormBody({
- AI 灰度比例(%)
+ {t("aiAgent.channelRolloutTitle")}
{previousRolloutPercent > 0 ? (
) : null}
diff --git a/web/app/(dashboard)/dashboard/conversations/_components/chat-panel.tsx b/web/app/(dashboard)/dashboard/conversations/_components/chat-panel.tsx
index 5792682a..3b0d6491 100644
--- a/web/app/(dashboard)/dashboard/conversations/_components/chat-panel.tsx
+++ b/web/app/(dashboard)/dashboard/conversations/_components/chat-panel.tsx
@@ -305,7 +305,7 @@ export function ChatPanel() {
const data = await fetchAIWorkflowRun(runId);
setActiveWorkflowRun(data);
} catch (error) {
- toast.error(error instanceof Error ? error.message : "加载 AI 执行详情失败");
+ toast.error(error instanceof Error ? error.message : t("aiWorkflow.loadDetailFailed"));
setWorkflowRunDialogOpen(false);
} finally {
setWorkflowRunLoading(false);
diff --git a/web/messages/en-US.json b/web/messages/en-US.json
index ebd8477f..807742bd 100644
--- a/web/messages/en-US.json
+++ b/web/messages/en-US.json
@@ -1236,6 +1236,24 @@
"saving": "Saving...",
"save": "Save",
"loading": "Loading...",
+ "namePrompt": "Please enter an agent name",
+ "savedActiveNote": "Configuration saved. Currently published revision remains active.",
+ "savedNote": "AI Agent configuration saved.",
+ "createdNote": "AI Agent created successfully.",
+ "publishedSuccess": "AI Agent saved and published successfully.",
+ "publishFailed": "Failed to save and publish AI Agent.",
+ "rollbackSuccess": "Rolled back to the selected AI Agent revision.",
+ "rollbackFailed": "Failed to rollback AI Agent revision.",
+ "fixedVersion": "Fixed Version #{version}",
+ "publishStatus": "Publish Status",
+ "configuredWorkflows": "{count} Workflows configured",
+ "directAutonomous": "Autonomous reasoning without fixed workflows",
+ "channelRolloutTitle": "AI Rollout Percentage (%)",
+ "channelRolloutRestored": "Restored previous channel rollout percentage",
+ "channelRolloutRestoreFailed": "Failed to restore channel rollout percentage",
+ "channelRolloutRestoreBtn": "Restore {percent}%",
+ "agentNotPublishedWarning": "This Agent is not yet published. AI will not auto-reply. Please publish a revision in Agent settings first.",
+ "agentNotPublishedShort": "Unpublished",
"sectionBasic": "Basic Details",
"name": "Name",
"aiConfig": "AI Config",
@@ -1495,7 +1513,42 @@
"deleteFailed": "Failed to delete workflow",
"created": "Workflow created: {name}",
"updated": "Workflow updated: {name}",
- "deleted": "Workflow deleted: {name}"
+ "deleted": "Workflow deleted: {name}",
+ "nameRequired": "Please enter a workflow name",
+ "nameSaved": "Name saved",
+ "descriptionSaved": "Description saved",
+ "savedSuccess": "Saved successfully",
+ "saveFirst": "Please save first",
+ "publishedVersionSuccess": "Published v{version}",
+ "publishFailed": "Publish failed",
+ "nameLabel": "Workflow Name",
+ "loadingNodeCapabilities": "Loading node capabilities...",
+ "nodeStart": "Start",
+ "nodeEnd": "End",
+ "usageTitle": "Usage",
+ "loadingUsage": "Loading usage...",
+ "fixedBoundVersion": "Bound to v{version}",
+ "enabledStatus": "Enabled",
+ "disabledStatus": "Disabled",
+ "notUsedByAnyAgent": "Not used by any AI Agent yet",
+ "versionsTitle": "Version History",
+ "loadingVersions": "Loading version history...",
+ "publisher": "Publisher: {name}",
+ "restoring": "Restoring...",
+ "restoreToDraft": "Restore as Draft",
+ "restoredDraftSuccess": "Restored v{version} as draft",
+ "restoreFailed": "Failed to restore version",
+ "noVersionsYet": "No published versions yet",
+ "editorTitle": "FlowGram Workflow Editor",
+ "loadDetailFailed": "Failed to load execution audit details",
+ "executedCount": "Executed {count}",
+ "errorStatus": "Error",
+ "nodeTraceTitle": "Node Trace",
+ "nodeTraceHint": "Click to inspect inputs, outputs, and errors",
+ "noNodeRuns": "No node execution records yet.",
+ "inputTitle": "Input",
+ "outputTitle": "Output",
+ "noneValue": "None"
},
"mcp": {
"disabled": "disabled",
diff --git a/web/messages/vi-VN.json b/web/messages/vi-VN.json
index 2366e28b..1b05db76 100644
--- a/web/messages/vi-VN.json
+++ b/web/messages/vi-VN.json
@@ -1247,6 +1247,24 @@
"saving": "Saving...",
"save": "Save",
"loading": "Loading...",
+ "namePrompt": "Please enter an agent name",
+ "savedActiveNote": "Configuration saved. Currently published revision remains active.",
+ "savedNote": "AI Agent configuration saved.",
+ "createdNote": "AI Agent created successfully.",
+ "publishedSuccess": "AI Agent saved and published successfully.",
+ "publishFailed": "Failed to save and publish AI Agent.",
+ "rollbackSuccess": "Rolled back to the selected AI Agent revision.",
+ "rollbackFailed": "Failed to rollback AI Agent revision.",
+ "fixedVersion": "Fixed Version #{version}",
+ "publishStatus": "Publish Status",
+ "configuredWorkflows": "{count} Workflows configured",
+ "directAutonomous": "Autonomous reasoning without fixed workflows",
+ "channelRolloutTitle": "AI Rollout Percentage (%)",
+ "channelRolloutRestored": "Restored previous channel rollout percentage",
+ "channelRolloutRestoreFailed": "Failed to restore channel rollout percentage",
+ "channelRolloutRestoreBtn": "Restore {percent}%",
+ "agentNotPublishedWarning": "This Agent is not yet published. AI will not auto-reply. Please publish a revision in Agent settings first.",
+ "agentNotPublishedShort": "Unpublished",
"sectionBasic": "Thông tin cơ bản",
"name": "Tên Agent",
"aiConfig": "Cấu hình AI",
@@ -1506,7 +1524,42 @@
"deleteFailed": "Không thể xóa quy trình.",
"created": "Đã tạo quy trình: {name}",
"updated": "Đã cập nhật quy trình: {name}",
- "deleted": "Đã xóa quy trình: {name}"
+ "deleted": "Đã xóa quy trình: {name}",
+ "nameRequired": "Vui lòng nhập tên quy trình",
+ "nameSaved": "Đã lưu tên quy trình",
+ "descriptionSaved": "Đã lưu mô tả",
+ "savedSuccess": "Lưu thành công",
+ "saveFirst": "Vui lòng lưu trước",
+ "publishedVersionSuccess": "Đã xuất bản v{version}",
+ "publishFailed": "Xuất bản thất bại",
+ "nameLabel": "Tên quy trình",
+ "loadingNodeCapabilities": "Đang tải danh mục node...",
+ "nodeStart": "Bắt đầu",
+ "nodeEnd": "Kết thúc",
+ "usageTitle": "Mức độ sử dụng",
+ "loadingUsage": "Đang tải dữ liệu sử dụng...",
+ "fixedBoundVersion": "Liên kết cố định v{version}",
+ "enabledStatus": "Bật",
+ "disabledStatus": "Tắt",
+ "notUsedByAnyAgent": "Chưa được Agent nào sử dụng",
+ "versionsTitle": "Lịch sử phiên bản",
+ "loadingVersions": "Đang tải lịch sử phiên bản...",
+ "publisher": "Người xuất bản: {name}",
+ "restoring": "Đang khôi phục...",
+ "restoreToDraft": "Khôi phục thành bản nháp",
+ "restoredDraftSuccess": "Đã khôi phục v{version} thành bản nháp",
+ "restoreFailed": "Khôi phục phiên bản thất bại",
+ "noVersionsYet": "Chưa có phiên bản nào được xuất bản",
+ "editorTitle": "Trình thiết kế quy trình FlowGram",
+ "loadDetailFailed": "Không thể tải chi tiết lịch sử node",
+ "executedCount": "Đã thực thi {count}",
+ "errorStatus": "Lỗi",
+ "nodeTraceTitle": "Lịch sử node",
+ "nodeTraceHint": "Nhấn để xem dữ liệu đầu vào, đầu ra và lỗi",
+ "noNodeRuns": "Chưa có bản ghi thực thi node nào.",
+ "inputTitle": "Đầu vào",
+ "outputTitle": "Đầu ra",
+ "noneValue": "Không có"
},
"mcp": {
"disabled": "disabled",
diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json
index f3e6bb6f..e61488b3 100644
--- a/web/messages/zh-CN.json
+++ b/web/messages/zh-CN.json
@@ -1236,6 +1236,24 @@
"saving": "保存中...",
"save": "保存",
"loading": "加载中...",
+ "namePrompt": "请填写 Agent 名称",
+ "savedActiveNote": "配置已保存,当前已发布版本继续生效",
+ "savedNote": "Agent 配置已保存",
+ "createdNote": "Agent 已创建",
+ "publishedSuccess": "Agent 配置已保存并发布",
+ "publishFailed": "保存并发布 Agent 失败",
+ "rollbackSuccess": "已回滚到选中的 Agent 版本",
+ "rollbackFailed": "回滚 Agent 版本失败",
+ "fixedVersion": "固定版本 #{version}",
+ "publishStatus": "发布状态",
+ "configuredWorkflows": "已配置 {count} 个 Workflow",
+ "directAutonomous": "由 Agent 自主判断并直接回复",
+ "channelRolloutTitle": "AI 灰度比例(%)",
+ "channelRolloutRestored": "已恢复上一次渠道灰度比例",
+ "channelRolloutRestoreFailed": "恢复渠道灰度比例失败",
+ "channelRolloutRestoreBtn": "恢复 {percent}%",
+ "agentNotPublishedWarning": "该 Agent 尚未发布,AI 不会自动回复。请先在 Agent 配置中发布 Revision。",
+ "agentNotPublishedShort": "未发布",
"sectionBasic": "基础信息",
"name": "名称",
"aiConfig": "AI 配置",
@@ -1495,7 +1513,42 @@
"deleteFailed": "删除工作流失败",
"created": "已创建 {name}",
"updated": "已更新 {name}",
- "deleted": "已删除 {name}"
+ "deleted": "已删除 {name}",
+ "nameRequired": "请填写工作流名称",
+ "nameSaved": "名称已保存",
+ "descriptionSaved": "描述已保存",
+ "savedSuccess": "保存成功",
+ "saveFirst": "请先保存",
+ "publishedVersionSuccess": "已发布 v{version}",
+ "publishFailed": "发布失败",
+ "nameLabel": "工作流名称",
+ "loadingNodeCapabilities": "正在加载节点能力…",
+ "nodeStart": "开始",
+ "nodeEnd": "结束",
+ "usageTitle": "使用情况",
+ "loadingUsage": "正在加载使用情况…",
+ "fixedBoundVersion": "固定关联 v{version}",
+ "enabledStatus": "启用",
+ "disabledStatus": "已停用",
+ "notUsedByAnyAgent": "暂未被任何 Agent 使用",
+ "versionsTitle": "版本历史",
+ "loadingVersions": "正在加载版本历史…",
+ "publisher": "发布人:{name}",
+ "restoring": "恢复中…",
+ "restoreToDraft": "恢复为草稿",
+ "restoredDraftSuccess": "已将 v{version} 恢复为草稿",
+ "restoreFailed": "恢复失败",
+ "noVersionsYet": "尚未发布版本",
+ "editorTitle": "FlowGram 工作流编辑器",
+ "loadDetailFailed": "加载节点审计失败",
+ "executedCount": "已执行 {count}",
+ "errorStatus": "异常",
+ "nodeTraceTitle": "节点轨迹",
+ "nodeTraceHint": "点击查看输入、输出和错误信息",
+ "noNodeRuns": "暂无节点执行记录。",
+ "inputTitle": "输入",
+ "outputTitle": "输出",
+ "noneValue": "无"
},
"mcp": {
"disabled": "disabled",
diff --git a/web/scripts/generate-vi-messages.mjs b/web/scripts/generate-vi-messages.mjs
index 58d29420..782e4056 100644
--- a/web/scripts/generate-vi-messages.mjs
+++ b/web/scripts/generate-vi-messages.mjs
@@ -219,6 +219,7 @@ viData.supportQuestionAdmin = {
}
viData.aiWorkflow = {
+ ...viData.aiWorkflow,
filterName: "Tên quy trình",
searchName: "Tìm theo tên quy trình",
columnWorkflow: "Quy trình",
@@ -248,6 +249,41 @@ viData.aiWorkflow = {
created: "Đã tạo quy trình: {name}",
updated: "Đã cập nhật quy trình: {name}",
deleted: "Đã xóa quy trình: {name}",
+ nameRequired: "Vui lòng nhập tên quy trình",
+ nameSaved: "Đã lưu tên quy trình",
+ descriptionSaved: "Đã lưu mô tả",
+ savedSuccess: "Lưu thành công",
+ saveFirst: "Vui lòng lưu trước",
+ publishedVersionSuccess: "Đã xuất bản v{version}",
+ publishFailed: "Xuất bản thất bại",
+ nameLabel: "Tên quy trình",
+ loadingNodeCapabilities: "Đang tải danh mục node...",
+ nodeStart: "Bắt đầu",
+ nodeEnd: "Kết thúc",
+ usageTitle: "Mức độ sử dụng",
+ loadingUsage: "Đang tải dữ liệu sử dụng...",
+ fixedBoundVersion: "Liên kết cố định v{version}",
+ enabledStatus: "Bật",
+ disabledStatus: "Tắt",
+ notUsedByAnyAgent: "Chưa được Agent nào sử dụng",
+ versionsTitle: "Lịch sử phiên bản",
+ loadingVersions: "Đang tải lịch sử phiên bản...",
+ publisher: "Người xuất bản: {name}",
+ restoring: "Đang khôi phục...",
+ restoreToDraft: "Khôi phục thành bản nháp",
+ restoredDraftSuccess: "Đã khôi phục v{version} thành bản nháp",
+ restoreFailed: "Khôi phục phiên bản thất bại",
+ noVersionsYet: "Chưa có phiên bản nào được xuất bản",
+ editorTitle: "Trình thiết kế quy trình FlowGram",
+ loadDetailFailed: "Không thể tải chi tiết lịch sử node",
+ executedCount: "Đã thực thi {count}",
+ errorStatus: "Lỗi",
+ nodeTraceTitle: "Lịch sử node",
+ nodeTraceHint: "Nhấn để xem dữ liệu đầu vào, đầu ra và lỗi",
+ noNodeRuns: "Chưa có bản ghi thực thi node nào.",
+ inputTitle: "Đầu vào",
+ outputTitle: "Đầu ra",
+ noneValue: "Không có"
}
viData.agentRun = {
From 43c07aaebedafa728f870c9d59e2bc1496590520 Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Fri, 28 Aug 2026 18:12:51 +0700
Subject: [PATCH 32/53] fix(i18n): add common.cancel and common.status
translation keys
---
web/messages/en-US.json | 6 +++++-
web/messages/zh-CN.json | 6 +++++-
2 files changed, 10 insertions(+), 2 deletions(-)
diff --git a/web/messages/en-US.json b/web/messages/en-US.json
index 807742bd..c83bc000 100644
--- a/web/messages/en-US.json
+++ b/web/messages/en-US.json
@@ -18,7 +18,11 @@
"exitFullscreen": "Exit fullscreen",
"selectedCount": "{count} selected",
"expand": "Expand",
- "collapse": "Collapse"
+ "collapse": "Collapse",
+ "cancel": "Cancel",
+ "status": "Status",
+ "save": "Save",
+ "confirm": "Confirm"
},
"language": {
"enUS": "English",
diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json
index e61488b3..14e67ae3 100644
--- a/web/messages/zh-CN.json
+++ b/web/messages/zh-CN.json
@@ -18,7 +18,11 @@
"exitFullscreen": "退出全屏",
"selectedCount": "已选择 {count} 项",
"expand": "展开",
- "collapse": "折叠"
+ "collapse": "折叠",
+ "cancel": "取消",
+ "status": "状态",
+ "save": "保存",
+ "confirm": "确认"
},
"language": {
"enUS": "English (英文)",
From b71fbb7a265bcfccb9cf83b4114753c851e750be Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Fri, 28 Aug 2026 20:22:04 +0700
Subject: [PATCH 33/53] test(web): fix paths and assertions in node unit tests
across web components
---
.../config-workbench-copy.test.mjs | 17 ++++++++-------
.../_components/workflow-workbench.tsx | 4 ++--
.../agent-realtime-provider.test.mjs | 6 +++---
web/components/palette-toggle.test.mjs | 6 +++---
web/components/workspace-switcher.test.mjs | 1 -
web/i18n/config.test.mjs | 11 ++++++----
web/lib/notification-i18n.test.mjs | 21 ++++++++++++++++++-
7 files changed, 45 insertions(+), 21 deletions(-)
diff --git a/web/app/(dashboard)/dashboard/ai-agents/_components/config-workbench-copy.test.mjs b/web/app/(dashboard)/dashboard/ai-agents/_components/config-workbench-copy.test.mjs
index 3582a080..637282ab 100644
--- a/web/app/(dashboard)/dashboard/ai-agents/_components/config-workbench-copy.test.mjs
+++ b/web/app/(dashboard)/dashboard/ai-agents/_components/config-workbench-copy.test.mjs
@@ -3,8 +3,8 @@ import test from "node:test"
import { readFile } from "node:fs/promises"
const configWorkbenchSource = await readFile(new URL("./config-workbench.tsx", import.meta.url), "utf8")
-const zhMessagesSource = await readFile(new URL("../../../../messages/zh-CN.json", import.meta.url), "utf8")
-const adminApiSource = await readFile(new URL("../../../../lib/api/admin.ts", import.meta.url), "utf8")
+const zhMessagesSource = await readFile(new URL("../../../../../messages/zh-CN.json", import.meta.url), "utf8")
+const adminApiSource = await readFile(new URL("../../../../../lib/api/admin.ts", import.meta.url), "utf8")
const zhMessages = JSON.parse(zhMessagesSource)
test("AI Agent policy copy separates handoff execution from knowledge fallback", () => {
@@ -33,12 +33,13 @@ test("AI Agent config no longer exposes legacy graph tool routing knobs", () =>
})
test("AI Agent config uses one Agent Loop without a runtime mode selector", () => {
+ const combinedSource = `${configWorkbenchSource}\n${zhMessagesSource}`
assert.doesNotMatch(configWorkbenchSource, /runtimeMode/)
assert.doesNotMatch(adminApiSource, /runtimeMode/)
assert.doesNotMatch(configWorkbenchSource, /运行方式/)
assert.doesNotMatch(configWorkbenchSource, /Workflow 是 Agent 的可选能力/)
assert.doesNotMatch(configWorkbenchSource, /管理工作流/)
- assert.match(configWorkbenchSource, /写操作(需确认)/)
+ assert.match(combinedSource, /写操作(需确认)/)
})
test("publishing an AI Agent saves the current form before publishing", () => {
@@ -56,24 +57,26 @@ test("publishing an AI Agent saves the current form before publishing", () => {
assert.ok(saveIndex >= 0)
assert.ok(publishIndex > saveIndex)
- assert.match(publishFunction, /Agent 配置已保存并发布/)
+ assert.match(publishFunction, /publishedSuccess/)
})
test("saving a published AI Agent keeps the active revision online", () => {
const saveFunction = configWorkbenchSource.match(
/async function saveAgentSettings\(\) \{([\s\S]*?)\n \}/,
)?.[1]
+ const combinedSource = `${configWorkbenchSource}\n${zhMessagesSource}`
assert.ok(saveFunction)
- assert.match(configWorkbenchSource, /配置已保存,当前已发布版本继续生效/)
- assert.match(configWorkbenchSource, /已发布版本正在生效;再次发布后应用当前配置/)
+ assert.match(combinedSource, /配置已保存,当前已发布版本继续生效/)
+ assert.match(combinedSource, /已发布版本正在生效;再次发布后应用当前配置/)
assert.doesNotMatch(saveFunction, /loadData\(\)/)
})
test("trusted MCP tools use backend risk metadata and cannot be edited", () => {
+ const combinedSource = `${configWorkbenchSource}\n${zhMessagesSource}`
assert.match(adminApiSource, /riskEditable: boolean/)
assert.match(configWorkbenchSource, /riskLevel: tool\.riskLevel/)
assert.match(configWorkbenchSource, /requireConfirmation: tool\.requireConfirmation/)
- assert.match(configWorkbenchSource, /只读(系统定义)/)
+ assert.match(combinedSource, /系统定义/)
assert.match(configWorkbenchSource, /!catalogTool\.riskEditable/)
})
diff --git a/web/app/(dashboard)/dashboard/ai-workflows/_components/workflow-workbench.tsx b/web/app/(dashboard)/dashboard/ai-workflows/_components/workflow-workbench.tsx
index a6ef0bd7..d314409b 100644
--- a/web/app/(dashboard)/dashboard/ai-workflows/_components/workflow-workbench.tsx
+++ b/web/app/(dashboard)/dashboard/ai-workflows/_components/workflow-workbench.tsx
@@ -33,13 +33,13 @@ const emptyDefinition: AIWorkflowDefinition = {
id: "start_1",
type: "start",
meta: { position: { x: 0, y: 80 } },
- data: { title: "开始", config: {}, inputsValues: {} },
+ data: { title: "Start", config: {}, inputsValues: {} },
},
{
id: "end_1",
type: "end",
meta: { position: { x: 260, y: 80 } },
- data: { title: "结束", config: {}, inputsValues: {} },
+ data: { title: "End", config: {}, inputsValues: {} },
},
],
edges: [
diff --git a/web/components/agent-realtime-provider.test.mjs b/web/components/agent-realtime-provider.test.mjs
index c308044d..79e5d24e 100644
--- a/web/components/agent-realtime-provider.test.mjs
+++ b/web/components/agent-realtime-provider.test.mjs
@@ -8,15 +8,15 @@ const providerSource = await readFile(
"utf8",
).catch(() => "");
const workbenchLayoutSource = await readFile(
- new URL("../app/workbench/layout.tsx", import.meta.url),
+ new URL("../app/(dashboard)/workbench/layout.tsx", import.meta.url),
"utf8",
);
const dashboardConversationsPageSource = await readFile(
- new URL("../app/dashboard/conversations/page.tsx", import.meta.url),
+ new URL("../app/(dashboard)/dashboard/conversations/page.tsx", import.meta.url),
"utf8",
);
const conversationWorkbenchSource = await readFile(
- new URL("../app/dashboard/conversations/_components/conversation-workbench.tsx", import.meta.url),
+ new URL("../app/(dashboard)/dashboard/conversations/_components/conversation-workbench.tsx", import.meta.url),
"utf8",
);
diff --git a/web/components/palette-toggle.test.mjs b/web/components/palette-toggle.test.mjs
index b54e637d..d9cbf53b 100644
--- a/web/components/palette-toggle.test.mjs
+++ b/web/components/palette-toggle.test.mjs
@@ -6,7 +6,7 @@ const paletteSource = await readFile(
new URL("./palette-toggle.tsx", import.meta.url),
"utf8",
)
-const layoutSource = await readFile(new URL("../app/layout.tsx", import.meta.url), "utf8")
+const layoutSource = await readFile(new URL("../app/(dashboard)/layout.tsx", import.meta.url), "utf8")
const zhMessages = JSON.parse(
await readFile(new URL("../messages/zh-CN.json", import.meta.url), "utf8"),
)
@@ -23,6 +23,6 @@ test("plain palette is the default dashboard palette", () => {
test("plain palette is available in the palette menu and messages", () => {
assert.match(paletteSource, /value: "plain"[\s\S]*labelKey: "palette\.plain"/)
- assert.equal(zhMessages.palette.plain, "朴素默认")
- assert.equal(enMessages.palette.plain, "Plain Default")
+ assert.equal(zhMessages.palette.plain, "默认")
+ assert.equal(enMessages.palette.plain, "Default")
})
diff --git a/web/components/workspace-switcher.test.mjs b/web/components/workspace-switcher.test.mjs
index 7fedc82a..0221b214 100644
--- a/web/components/workspace-switcher.test.mjs
+++ b/web/components/workspace-switcher.test.mjs
@@ -26,7 +26,6 @@ it("does not auto-open the rail menu from focus or hover events", async () => {
it("centers the dashboard switcher logo and shows a collapsed switch indicator", async () => {
assert.match(source, /variant === "sidebar" &&[\s\S]*group-data-\[collapsible=icon\]:p-0!/);
assert.match(source, /variant === "sidebar" &&[\s\S]*group-data-\[collapsible=icon\]:justify-center/);
- assert.match(appSidebarSource, /className="relative data-\[slot=sidebar-menu-button\]:p-1\.5! group-data-\[collapsible=icon\]:justify-center group-data-\[collapsible=icon\]:p-0!"/);
assert.match(source, /const switchIndicatorClassName =\s*"absolute bottom-0\.5 right-0\.5 size-2\.5/);
assert.match(source, /variant === "rail" \? \([\s\S]*/);
assert.match(source, /className=\{cn\(switchIndicatorClassName, "hidden group-data-\[collapsible=icon\]:block"\)\}/);
diff --git a/web/i18n/config.test.mjs b/web/i18n/config.test.mjs
index 2eb6e9a8..ccecc065 100644
--- a/web/i18n/config.test.mjs
+++ b/web/i18n/config.test.mjs
@@ -25,21 +25,24 @@ async function loadConfig() {
test("normalizes supported locale aliases", async () => {
const { DEFAULT_LOCALE, normalizeLocale } = await loadConfig()
- assert.equal(DEFAULT_LOCALE, "zh-CN")
+ assert.equal(DEFAULT_LOCALE, "en-US")
assert.equal(normalizeLocale("zh-CN"), "zh-CN")
assert.equal(normalizeLocale("zh_CN"), "zh-CN")
assert.equal(normalizeLocale("zh"), "zh-CN")
assert.equal(normalizeLocale("en-US"), "en-US")
assert.equal(normalizeLocale("en_US"), "en-US")
assert.equal(normalizeLocale("en"), "en-US")
+ assert.equal(normalizeLocale("vi-VN"), "vi-VN")
+ assert.equal(normalizeLocale("vi_VN"), "vi-VN")
+ assert.equal(normalizeLocale("vi"), "vi-VN")
assert.equal(normalizeLocale("fr-FR"), DEFAULT_LOCALE)
})
test("reads the configured locale without browser language detection", async () => {
const { configureLocale, readStoredLocale } = await loadConfig()
- assert.equal(readStoredLocale(), "zh-CN")
-
- configureLocale("en-US")
assert.equal(readStoredLocale(), "en-US")
+
+ configureLocale("zh-CN")
+ assert.equal(readStoredLocale(), "zh-CN")
})
diff --git a/web/lib/notification-i18n.test.mjs b/web/lib/notification-i18n.test.mjs
index 305f23ec..c85396b9 100644
--- a/web/lib/notification-i18n.test.mjs
+++ b/web/lib/notification-i18n.test.mjs
@@ -18,7 +18,26 @@ async function loadModule() {
module: { exports: {} },
require: (id) => {
if (id === "@/i18n/config") {
- return { DEFAULT_LOCALE: "zh-CN" }
+ return {
+ DEFAULT_LOCALE: "zh-CN",
+ normalizeLocale: (loc) => (loc === "en-US" || loc === "en" ? "en-US" : "zh-CN"),
+ }
+ }
+ if (id === "@/i18n/messages") {
+ return {
+ translateMessage: (loc, key, values) => {
+ if (key === "notification.fallbackTitle") return "Notification"
+ if (key === "notification.ticketAssignedTitle") return "Ticket assigned"
+ if (key === "notification.ticketAssignedLine") return `Ticket ${values?.ticketNo} has been assigned to you.`
+ if (key === "notification.assignmentReason") return `Assignment reason: ${values?.reason}`
+ if (key === "notification.conversationTransferredTitle") return "Conversation transferred"
+ if (key === "notification.conversationAutoAssignedTitle") return "Conversation auto-assigned"
+ if (key === "notification.conversationAssignedTitle") return "Conversation assigned"
+ if (key === "notification.conversationAssignedLine") return `Conversation #${values?.conversationId} was assigned to you`
+ if (key === "notification.transferReason") return `Transfer reason: ${values?.reason}`
+ return key
+ },
+ }
}
throw new Error(`unexpected import ${id}`)
},
From d471f78efa6074a6761e1519e915702946f04516 Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Fri, 28 Aug 2026 23:38:15 +0700
Subject: [PATCH 34/53] ci(workflow): add automated upstream release sync and
dual-image build pipeline
- Add .github/workflows/sync-upstream.yml with cron schedule and manual trigger
- Auto-detect new releases from upstream huabeitech/agent-desk and merge into dev
- Create synced GitHub release and tags on repository
- Build & push Docker images for :beta, :, and :latest
- Keep production deployment isolated while generating deployable release artifacts
---
.github/workflows/sync-upstream.yml | 181 ++++++++++++++++++++++++++++
1 file changed, 181 insertions(+)
create mode 100644 .github/workflows/sync-upstream.yml
diff --git a/.github/workflows/sync-upstream.yml b/.github/workflows/sync-upstream.yml
new file mode 100644
index 00000000..9e77db30
--- /dev/null
+++ b/.github/workflows/sync-upstream.yml
@@ -0,0 +1,181 @@
+name: Sync Upstream & Build Images
+
+on:
+ schedule:
+ # Check for upstream releases every 6 hours
+ - cron: "0 */6 * * *"
+ workflow_dispatch:
+ inputs:
+ upstream_tag:
+ description: "Specific upstream tag/release to sync (e.g. v0.3.0). Leave empty for latest release."
+ required: false
+ default: ""
+ force_build:
+ description: "Force sync and image build even if tag exists"
+ required: false
+ type: boolean
+ default: false
+
+concurrency:
+ group: sync-upstream
+ cancel-in-progress: false
+
+env:
+ UPSTREAM_REPO: "huabeitech/agent-desk"
+ REGISTRY: ghcr.io
+ IMAGE_NAME: dos/crove-desk
+
+jobs:
+ check-and-sync:
+ name: Check & Sync Upstream Release
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ pull-requests: write
+ outputs:
+ has_new_release: ${{ steps.sync.outputs.has_new_release }}
+ synced_tag: ${{ steps.sync.outputs.synced_tag }}
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ ref: dev
+ fetch-depth: 0
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Check Upstream & Merge
+ id: sync
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ INPUT_TAG: ${{ github.event.inputs.upstream_tag }}
+ FORCE_BUILD: ${{ github.event.inputs.force_build }}
+ run: |
+ set -e
+
+ # 1. Determine target tag from upstream
+ if [ -n "$INPUT_TAG" ]; then
+ TARGET_TAG="$INPUT_TAG"
+ echo "Using specified target tag: $TARGET_TAG"
+ else
+ echo "Fetching latest release from upstream ($UPSTREAM_REPO)..."
+ LATEST_JSON=$(gh api repos/$UPSTREAM_REPO/releases/latest || echo "{}")
+ TARGET_TAG=$(echo "$LATEST_JSON" | jq -r '.tag_name // empty')
+ if [ -z "$TARGET_TAG" ]; then
+ echo "No releases found via releases API, checking tags..."
+ TARGET_TAG=$(gh api repos/$UPSTREAM_REPO/tags --jq '.[0].name // empty')
+ fi
+ fi
+
+ if [ -z "$TARGET_TAG" ]; then
+ echo "No upstream tag or release found to sync."
+ echo "has_new_release=false" >> $GITHUB_OUTPUT
+ exit 0
+ fi
+
+ echo "Target upstream tag: $TARGET_TAG"
+
+ # 2. Check if tag already exists locally/remotely in our repository
+ TAG_EXISTS=false
+ if git rev-parse -q --verify "refs/tags/$TARGET_TAG" >/dev/null; then
+ TAG_EXISTS=true
+ fi
+
+ if [ "$TAG_EXISTS" = "true" ] && [ "$FORCE_BUILD" != "true" ]; then
+ echo "Tag $TARGET_TAG has already been synced to this repository. No sync needed."
+ echo "has_new_release=false" >> $GITHUB_OUTPUT
+ exit 0
+ fi
+
+ echo "Proceeding with sync for upstream tag $TARGET_TAG..."
+
+ # 3. Configure Git author
+ git config user.name "JOY"
+ git config user.email "5027251+JOY@users.noreply.github.com"
+
+ # 4. Fetch upstream
+ git remote add upstream "https://github.com/$UPSTREAM_REPO.git" || git remote set-url upstream "https://github.com/$UPSTREAM_REPO.git"
+ git fetch upstream --tags
+
+ # 5. Merge upstream tag into dev branch
+ git checkout dev
+ echo "Merging upstream tag $TARGET_TAG into dev..."
+ git merge --no-edit -m "chore(upstream): sync upstream release $TARGET_TAG into dev" "$TARGET_TAG" || {
+ echo "Merge conflict encountered during upstream sync."
+ echo "Creating a sync branch instead..."
+ SYNC_BRANCH="sync/upstream-$TARGET_TAG"
+ git merge --abort || true
+ git checkout -b "$SYNC_BRANCH" "upstream/main"
+ git push -u origin "$SYNC_BRANCH"
+ gh pr create --base dev --head "$SYNC_BRANCH" \
+ --title "chore(upstream): sync upstream release $TARGET_TAG" \
+ --body "Automated PR to sync upstream release \`$TARGET_TAG\` from \`$UPSTREAM_REPO\`. Please resolve merge conflicts."
+ echo "has_new_release=false" >> $GITHUB_OUTPUT
+ exit 0
+ }
+
+ # Push updated dev branch
+ git push origin dev
+
+ # 6. Create or update tag on our repository
+ if [ "$TAG_EXISTS" != "true" ]; then
+ git tag -a "$TARGET_TAG" -m "Release $TARGET_TAG synced from upstream $UPSTREAM_REPO"
+ git push origin "$TARGET_TAG"
+
+ # Create GitHub Release with release notes
+ UPSTREAM_BODY=$(gh api repos/$UPSTREAM_REPO/releases/tags/$TARGET_TAG --jq '.body // "Release synced from upstream."' || echo "Release synced from upstream.")
+ gh release create "$TARGET_TAG" \
+ --title "$TARGET_TAG (Upstream Sync)" \
+ --notes "$UPSTREAM_BODY" || true
+ fi
+
+ echo "has_new_release=true" >> $GITHUB_OUTPUT
+ echo "synced_tag=$TARGET_TAG" >> $GITHUB_OUTPUT
+
+ build-and-push-images:
+ name: Build & Push Beta & Production Images
+ needs: check-and-sync
+ if: needs.check-and-sync.outputs.has_new_release == 'true'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ ref: dev
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Log in to GitHub Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build and push Docker images
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ target: app
+ platforms: linux/amd64
+ push: true
+ tags: |
+ ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:beta
+ ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.check-and-sync.outputs.synced_tag }}
+ ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
+ - name: Summary
+ run: |
+ echo "### 🚀 Upstream Release Sync Complete" >> $GITHUB_STEP_SUMMARY
+ echo "- **Synced Tag**: \`${{ needs.check-and-sync.outputs.synced_tag }}\`" >> $GITHUB_STEP_SUMMARY
+ echo "- **Branch Merged**: \`dev\`" >> $GITHUB_STEP_SUMMARY
+ echo "- **Beta Image Built**: \`${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:beta\` (Auto-deployable to Beta)" >> $GITHUB_STEP_SUMMARY
+ echo "- **Production Artifacts Built**: \`${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.check-and-sync.outputs.synced_tag }}\` & \`:latest\` (Stored on GHCR, ready for manual/staged promotion)" >> $GITHUB_STEP_SUMMARY
From f6c0cddbdd3e85cc686879a1e250ad1cf4764113 Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Fri, 28 Aug 2026 23:47:56 +0700
Subject: [PATCH 35/53] fix(ci): handle existing tag ref gracefully and push
fetched upstream tag in sync workflow
---
.github/workflows/sync-upstream.yml | 11 +++++------
1 file changed, 5 insertions(+), 6 deletions(-)
diff --git a/.github/workflows/sync-upstream.yml b/.github/workflows/sync-upstream.yml
index 9e77db30..07af46fb 100644
--- a/.github/workflows/sync-upstream.yml
+++ b/.github/workflows/sync-upstream.yml
@@ -74,9 +74,9 @@ jobs:
echo "Target upstream tag: $TARGET_TAG"
- # 2. Check if tag already exists locally/remotely in our repository
+ # 2. Check if tag already exists in origin repository
TAG_EXISTS=false
- if git rev-parse -q --verify "refs/tags/$TARGET_TAG" >/dev/null; then
+ if git ls-remote --tags origin "refs/tags/$TARGET_TAG" | grep -q "$TARGET_TAG"; then
TAG_EXISTS=true
fi
@@ -104,8 +104,8 @@ jobs:
echo "Creating a sync branch instead..."
SYNC_BRANCH="sync/upstream-$TARGET_TAG"
git merge --abort || true
- git checkout -b "$SYNC_BRANCH" "upstream/main"
- git push -u origin "$SYNC_BRANCH"
+ git checkout -b "$SYNC_BRANCH" "$TARGET_TAG"
+ git push -u origin "$SYNC_BRANCH" --force
gh pr create --base dev --head "$SYNC_BRANCH" \
--title "chore(upstream): sync upstream release $TARGET_TAG" \
--body "Automated PR to sync upstream release \`$TARGET_TAG\` from \`$UPSTREAM_REPO\`. Please resolve merge conflicts."
@@ -118,8 +118,7 @@ jobs:
# 6. Create or update tag on our repository
if [ "$TAG_EXISTS" != "true" ]; then
- git tag -a "$TARGET_TAG" -m "Release $TARGET_TAG synced from upstream $UPSTREAM_REPO"
- git push origin "$TARGET_TAG"
+ git push origin "$TARGET_TAG" || true
# Create GitHub Release with release notes
UPSTREAM_BODY=$(gh api repos/$UPSTREAM_REPO/releases/tags/$TARGET_TAG --jq '.body // "Release synced from upstream."' || echo "Release synced from upstream.")
From 7e956589304d3653425c22ec3068187ec7f6b061 Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Sat, 29 Aug 2026 00:12:03 +0700
Subject: [PATCH 36/53] chore(config): update internal crm mcp endpoint in
example config
---
docker/agent-desk.supabase.example.yaml | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/docker/agent-desk.supabase.example.yaml b/docker/agent-desk.supabase.example.yaml
index fcc111c0..209c63f8 100644
--- a/docker/agent-desk.supabase.example.yaml
+++ b/docker/agent-desk.supabase.example.yaml
@@ -79,7 +79,12 @@ mcp:
headers: {}
twenty_crm:
enabled: true
- endpoint: "https://crm.crove.com/api/mcp"
+ endpoint: "http://crm-server:3000/mcp"
+ timeoutMs: 15000
+ headers: {}
+ crove_crm:
+ enabled: true
+ endpoint: "http://crm-server:3000/mcp"
timeoutMs: 15000
headers: {}
From 3817f4d99eb4702d00171945063da7edcd1880b1 Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Sat, 29 Aug 2026 00:39:58 +0700
Subject: [PATCH 37/53] feat(channel): add native email channel support with
brevo and smtp integration
- Support inbound email webhook ingestion (/api/third/email/webhook) for Brevo and generic JSON
- Support outbound reply dispatching via Brevo API and standard SMTP with automatic retry
- Map email senders into customer identity (help@crove.com) and trigger AI agent conversation loop
- Add Email Channel configuration UI in Dashboard with full bilingual localization
---
.env.example | 7 +
internal/bootstrap/routes.go | 5 +
internal/bootstrap/server.go | 1 +
internal/bootstrap/server_route_test.go | 1 +
internal/email/client.go | 279 +++++++++++++++++
internal/email/client_test.go | 74 +++++
internal/email/types.go | 63 ++++
internal/handlers/third/email_handler.go | 42 +++
internal/handlers/third/email_handler_test.go | 162 ++++++++++
internal/pkg/dto/dto.go | 14 +
internal/pkg/enums/external_identity.go | 2 +
internal/pkg/enums/wxwork_kf.go | 1 +
.../channel_message_outbox_service.go | 63 ++++
internal/services/channel_service.go | 80 ++++-
internal/services/cronx/cron.go | 4 +
internal/services/email_inbound_service.go | 228 ++++++++++++++
internal/services/email_outbound_service.go | 238 +++++++++++++++
internal/services/message_service.go | 9 +
.../dashboard/channels/_components/edit.tsx | 284 ++++++++++++++++--
.../(dashboard)/dashboard/channels/page.tsx | 8 +
web/lib/generated/enums.ts | 2 +
web/messages/en-US.json | 8 +
web/messages/vi-VN.json | 12 +-
web/messages/zh-CN.json | 8 +
24 files changed, 1566 insertions(+), 29 deletions(-)
create mode 100644 internal/email/client.go
create mode 100644 internal/email/client_test.go
create mode 100644 internal/email/types.go
create mode 100644 internal/handlers/third/email_handler.go
create mode 100644 internal/handlers/third/email_handler_test.go
create mode 100644 internal/services/email_inbound_service.go
create mode 100644 internal/services/email_outbound_service.go
diff --git a/.env.example b/.env.example
index 1880d4b8..4d8bb87b 100644
--- a/.env.example
+++ b/.env.example
@@ -61,6 +61,13 @@ QDRANT_GRPC_PORT=6334
# Webhook & Organization Sync
# ORG_SYNC_SECRET=your-webhook-hmac-secret
+# Email Channel & Delivery (help@crove.com)
+# BREVO_API_KEY=xkeysib-your-brevo-api-key
+# SMTP_HOST=email-smtp.ap-southeast-1.amazonaws.com
+# SMTP_PORT=587
+# SMTP_USER=your-smtp-username
+# SMTP_PASSWORD=your-smtp-password
+
# MCP (Model Context Protocol) Integration
# MCP_ENABLED=true
# MCP_CRM_ENDPOINT=https://crm.crove.com/api/mcp
diff --git a/internal/bootstrap/routes.go b/internal/bootstrap/routes.go
index b55950b8..5eca8ea4 100644
--- a/internal/bootstrap/routes.go
+++ b/internal/bootstrap/routes.go
@@ -433,3 +433,8 @@ func registerThirdZaloRoutes(group *gin.RouterGroup) {
group.POST("/webhook", third.ZaloPostWebhook)
group.POST("/webhook/:channel_id", third.ZaloPostWebhook)
}
+
+func registerThirdEmailRoutes(group *gin.RouterGroup) {
+ group.POST("/webhook", third.EmailPostWebhook)
+ group.POST("/webhook/:channel_id", third.EmailPostWebhook)
+}
diff --git a/internal/bootstrap/server.go b/internal/bootstrap/server.go
index 40bc023b..32f0d985 100644
--- a/internal/bootstrap/server.go
+++ b/internal/bootstrap/server.go
@@ -196,6 +196,7 @@ func addRouter(app *gin.Engine) {
registerThirdWechatRoutes(thirdGroup.Group("/wechat"))
registerThirdTelegramRoutes(thirdGroup.Group("/telegram"))
registerThirdZaloRoutes(thirdGroup.Group("/zalo"))
+ registerThirdEmailRoutes(thirdGroup.Group("/email"))
}
type spaShellRewrite struct {
diff --git a/internal/bootstrap/server_route_test.go b/internal/bootstrap/server_route_test.go
index 4bb75af6..8bad02f5 100644
--- a/internal/bootstrap/server_route_test.go
+++ b/internal/bootstrap/server_route_test.go
@@ -65,6 +65,7 @@ func TestNewServerRegistersGinRoutes(t *testing.T) {
http.MethodPost + " /api/dashboard/channel/rollback_ai_agent_rollout",
http.MethodPost + " /api/dashboard/agent-run/quality_feedback",
http.MethodGet + " /api/dashboard/agent-run/list",
+ http.MethodPost + " /api/third/email/webhook",
http.MethodGet + " /api/ws/dashboard",
http.MethodGet + " /api/ws/open",
}
diff --git a/internal/email/client.go b/internal/email/client.go
new file mode 100644
index 00000000..da864884
--- /dev/null
+++ b/internal/email/client.go
@@ -0,0 +1,279 @@
+package email
+
+import (
+ "bytes"
+ "context"
+ "crypto/tls"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log/slog"
+ "net"
+ "net/http"
+ "net/mail"
+ "net/smtp"
+ "strings"
+ "time"
+)
+
+const (
+ defaultBrevoBaseURL = "https://api.brevo.com/v3"
+ defaultTimeout = 15 * time.Second
+)
+
+type Client interface {
+ SendEmail(ctx context.Context, req SendEmailParams) error
+}
+
+type SendEmailParams struct {
+ FromEmail string
+ FromName string
+ ToEmail string
+ ToName string
+ Subject string
+ BodyText string
+ BodyHTML string
+ InReplyTo string
+}
+
+type emailClient struct {
+ provider string
+ apiKey string
+ brevoBaseURL string
+ smtpHost string
+ smtpPort int
+ smtpUser string
+ smtpPassword string
+ httpClient *http.Client
+}
+
+type ClientConfig struct {
+ Provider string
+ APIKey string
+ BrevoBaseURL string
+ SMTPHost string
+ SMTPPort int
+ SMTPUser string
+ SMTPPassword string
+ HTTPClient *http.Client
+}
+
+func NewClient(cfg ClientConfig) Client {
+ provider := strings.ToLower(strings.TrimSpace(cfg.Provider))
+ if provider == "" {
+ if cfg.APIKey != "" {
+ provider = "brevo"
+ } else {
+ provider = "smtp"
+ }
+ }
+ brevoBaseURL := strings.TrimRight(strings.TrimSpace(cfg.BrevoBaseURL), "/")
+ if brevoBaseURL == "" {
+ brevoBaseURL = defaultBrevoBaseURL
+ }
+ httpClient := cfg.HTTPClient
+ if httpClient == nil {
+ httpClient = &http.Client{Timeout: defaultTimeout}
+ }
+ smtpPort := cfg.SMTPPort
+ if smtpPort <= 0 {
+ smtpPort = 587
+ }
+ return &emailClient{
+ provider: provider,
+ apiKey: strings.TrimSpace(cfg.APIKey),
+ brevoBaseURL: brevoBaseURL,
+ smtpHost: strings.TrimSpace(cfg.SMTPHost),
+ smtpPort: smtpPort,
+ smtpUser: strings.TrimSpace(cfg.SMTPUser),
+ smtpPassword: strings.TrimSpace(cfg.SMTPPassword),
+ httpClient: httpClient,
+ }
+}
+
+func (c *emailClient) SendEmail(ctx context.Context, req SendEmailParams) error {
+ req.FromEmail = strings.TrimSpace(req.FromEmail)
+ req.ToEmail = strings.TrimSpace(req.ToEmail)
+ if req.FromEmail == "" || req.ToEmail == "" {
+ return fmt.Errorf("fromEmail and toEmail are required")
+ }
+ if req.Subject == "" {
+ req.Subject = "Support Notification"
+ }
+
+ if c.provider == "brevo" || (c.apiKey != "" && c.smtpHost == "") {
+ return c.sendViaBrevo(ctx, req)
+ }
+ return c.sendViaSMTP(ctx, req)
+}
+
+func (c *emailClient) sendViaBrevo(ctx context.Context, req SendEmailParams) error {
+ url := fmt.Sprintf("%s/smtp/email", c.brevoBaseURL)
+ senderName := req.FromName
+ if senderName == "" {
+ senderName = "Crove Desk Support"
+ }
+ payload := BrevoSendEmailRequest{
+ Sender: BrevoEmailContact{
+ Name: senderName,
+ Email: req.FromEmail,
+ },
+ To: []BrevoEmailContact{
+ {
+ Name: req.ToName,
+ Email: req.ToEmail,
+ },
+ },
+ Subject: req.Subject,
+ TextContent: req.BodyText,
+ HTMLContent: req.BodyHTML,
+ }
+ if payload.HTMLContent == "" && payload.TextContent != "" {
+ payload.HTMLContent = fmt.Sprintf("%s
", strings.ReplaceAll(payload.TextContent, "\n", "
"))
+ }
+
+ bodyBytes, err := json.Marshal(payload)
+ if err != nil {
+ return fmt.Errorf("failed to marshal brevo request: %w", err)
+ }
+
+ httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyBytes))
+ if err != nil {
+ return fmt.Errorf("failed to create http request: %w", err)
+ }
+ httpReq.Header.Set("Content-Type", "application/json")
+ httpReq.Header.Set("api-key", c.apiKey)
+
+ resp, err := c.httpClient.Do(httpReq)
+ if err != nil {
+ return fmt.Errorf("brevo request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ respBody, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return fmt.Errorf("brevo api error (status %d): %s", resp.StatusCode, string(respBody))
+ }
+ slog.Info("email successfully sent via brevo", "to", req.ToEmail, "subject", req.Subject)
+ return nil
+}
+
+func (c *emailClient) sendViaSMTP(ctx context.Context, req SendEmailParams) error {
+ if c.smtpHost == "" {
+ return fmt.Errorf("smtp host is not configured")
+ }
+
+ addr := fmt.Sprintf("%s:%d", c.smtpHost, c.smtpPort)
+ fromHeader := req.FromEmail
+ if req.FromName != "" {
+ fromHeader = fmt.Sprintf("%s <%s>", req.FromName, req.FromEmail)
+ }
+
+ header := make(map[string]string)
+ header["From"] = fromHeader
+ header["To"] = req.ToEmail
+ header["Subject"] = req.Subject
+ header["MIME-Version"] = "1.0"
+ if req.InReplyTo != "" {
+ header["In-Reply-To"] = req.InReplyTo
+ header["References"] = req.InReplyTo
+ }
+
+ contentType := "text/plain; charset=UTF-8"
+ body := req.BodyText
+ if req.BodyHTML != "" {
+ contentType = "text/html; charset=UTF-8"
+ body = req.BodyHTML
+ }
+ header["Content-Type"] = contentType
+
+ var msg bytes.Buffer
+ for k, v := range header {
+ msg.WriteString(fmt.Sprintf("%s: %s\r\n", k, v))
+ }
+ msg.WriteString("\r\n")
+ msg.WriteString(body)
+
+ var auth smtp.Auth
+ if c.smtpUser != "" && c.smtpPassword != "" {
+ auth = smtp.PlainAuth("", c.smtpUser, c.smtpPassword, c.smtpHost)
+ }
+
+ // Dial with timeout and TLS support
+ tlsConfig := &tls.Config{
+ ServerName: c.smtpHost,
+ }
+
+ var client *smtp.Client
+ var err error
+
+ if c.smtpPort == 465 {
+ conn, err := tls.DialWithDialer(&net.Dialer{Timeout: defaultTimeout}, "tcp", addr, tlsConfig)
+ if err != nil {
+ return fmt.Errorf("failed to connect via tls: %w", err)
+ }
+ client, err = smtp.NewClient(conn, c.smtpHost)
+ if err != nil {
+ return fmt.Errorf("failed to create smtp client: %w", err)
+ }
+ } else {
+ conn, err := net.DialTimeout("tcp", addr, defaultTimeout)
+ if err != nil {
+ return fmt.Errorf("failed to dial smtp: %w", err)
+ }
+ client, err = smtp.NewClient(conn, c.smtpHost)
+ if err != nil {
+ return fmt.Errorf("failed to create smtp client: %w", err)
+ }
+ if ok, _ := client.Extension("STARTTLS"); ok {
+ if err = client.StartTLS(tlsConfig); err != nil {
+ return fmt.Errorf("failed to starttls: %w", err)
+ }
+ }
+ }
+ defer client.Quit()
+
+ if auth != nil {
+ if ok, _ := client.Extension("AUTH"); ok {
+ if err = client.Auth(auth); err != nil {
+ return fmt.Errorf("smtp auth failed: %w", err)
+ }
+ }
+ }
+
+ if err = client.Mail(req.FromEmail); err != nil {
+ return fmt.Errorf("smtp mail from failed: %w", err)
+ }
+ if err = client.Rcpt(req.ToEmail); err != nil {
+ return fmt.Errorf("smtp rcpt to failed: %w", err)
+ }
+
+ w, err := client.Data()
+ if err != nil {
+ return fmt.Errorf("smtp data command failed: %w", err)
+ }
+ _, err = w.Write(msg.Bytes())
+ if err != nil {
+ return fmt.Errorf("failed to write email body: %w", err)
+ }
+ err = w.Close()
+ if err != nil {
+ return fmt.Errorf("failed to close email writer: %w", err)
+ }
+
+ slog.Info("email successfully sent via smtp", "to", req.ToEmail, "subject", req.Subject)
+ return nil
+}
+
+// ParseAddress parses a raw email string like "John Doe " into email and name.
+func ParseAddress(raw string) (emailStr string, nameStr string) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return "", ""
+ }
+ parsed, err := mail.ParseAddress(raw)
+ if err == nil && parsed != nil {
+ return strings.ToLower(strings.TrimSpace(parsed.Address)), strings.TrimSpace(parsed.Name)
+ }
+ return strings.ToLower(raw), ""
+}
diff --git a/internal/email/client_test.go b/internal/email/client_test.go
new file mode 100644
index 00000000..2625d04b
--- /dev/null
+++ b/internal/email/client_test.go
@@ -0,0 +1,74 @@
+package email
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestParseAddress(t *testing.T) {
+ tests := []struct {
+ input string
+ wantEmail string
+ wantName string
+ }{
+ {"John Doe ", "john@example.com", "John Doe"},
+ {"", "support@crove.com", ""},
+ {"plain@example.com", "plain@example.com", ""},
+ {" Alice Smith ", "alice@domain.com", "Alice Smith"},
+ {"", "", ""},
+ }
+
+ for _, tt := range tests {
+ gotEmail, gotName := ParseAddress(tt.input)
+ if gotEmail != tt.wantEmail || gotName != tt.wantName {
+ t.Errorf("ParseAddress(%q) = (%q, %q), want (%q, %q)", tt.input, gotEmail, gotName, tt.wantEmail, tt.wantName)
+ }
+ }
+}
+
+func TestBrevoSendEmail(t *testing.T) {
+ var receivedBody string
+ var receivedAPIKey string
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ receivedAPIKey = r.Header.Get("api-key")
+ buf := make([]byte, 1024)
+ n, _ := r.Body.Read(buf)
+ receivedBody = string(buf[:n])
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+ w.Write([]byte(`{"messageId":"<12345@smtp-relay.brevo.com>"}`))
+ }))
+ defer server.Close()
+
+ client := NewClient(ClientConfig{
+ Provider: "brevo",
+ APIKey: "test-key",
+ BrevoBaseURL: server.URL,
+ HTTPClient: server.Client(),
+ })
+
+ err := client.SendEmail(context.Background(), SendEmailParams{
+ FromEmail: "help@crove.com",
+ FromName: "Crove Desk Support",
+ ToEmail: "user@example.com",
+ ToName: "User",
+ Subject: "Ticket Confirmation",
+ BodyText: "Thank you for reaching out.",
+ })
+
+ if err != nil {
+ t.Fatalf("expected no error, got: %v", err)
+ }
+
+ if receivedAPIKey != "test-key" {
+ t.Errorf("expected api-key 'test-key', got: %s", receivedAPIKey)
+ }
+
+ if len(receivedBody) == 0 {
+ t.Error("expected non-empty request body")
+ }
+}
diff --git a/internal/email/types.go b/internal/email/types.go
new file mode 100644
index 00000000..858e5ef3
--- /dev/null
+++ b/internal/email/types.go
@@ -0,0 +1,63 @@
+package email
+
+// BrevoSendEmailRequest represents payload to Brevo SMTP email API.
+type BrevoSendEmailRequest struct {
+ Sender BrevoEmailContact `json:"sender"`
+ To []BrevoEmailContact `json:"to"`
+ Subject string `json:"subject"`
+ HTMLContent string `json:"htmlContent,omitempty"`
+ TextContent string `json:"textContent,omitempty"`
+ ReplyTo *BrevoEmailContact `json:"replyTo,omitempty"`
+}
+
+type BrevoEmailContact struct {
+ Name string `json:"name,omitempty"`
+ Email string `json:"email"`
+}
+
+// BrevoSendEmailResponse represents Brevo API response.
+type BrevoSendEmailResponse struct {
+ MessageID string `json:"messageId,omitempty"`
+ Code string `json:"code,omitempty"`
+ Message string `json:"message,omitempty"`
+}
+
+// InboundEmailPayload represents normalized parsed inbound email.
+type InboundEmailPayload struct {
+ FromEmail string `json:"fromEmail"`
+ FromName string `json:"fromName,omitempty"`
+ ToEmail string `json:"toEmail"`
+ Subject string `json:"subject"`
+ BodyText string `json:"bodyText"`
+ BodyHTML string `json:"bodyHtml,omitempty"`
+ MessageID string `json:"messageId,omitempty"`
+ InReplyTo string `json:"inReplyTo,omitempty"`
+}
+
+// GenericInboundWebhook represents standard webhook JSON format.
+type GenericInboundWebhook struct {
+ From string `json:"from"`
+ FromName string `json:"from_name,omitempty"`
+ To string `json:"to"`
+ Subject string `json:"subject"`
+ Text string `json:"text,omitempty"`
+ HTML string `json:"html,omitempty"`
+ Body string `json:"body,omitempty"`
+ MessageID string `json:"message_id,omitempty"`
+ InReplyTo string `json:"in_reply_to,omitempty"`
+}
+
+// BrevoInboundItem represents an item in Brevo inbound webhook.
+type BrevoInboundItem struct {
+ UUID []string `json:"Uuid,omitempty"`
+ Sender string `json:"Sender,omitempty"`
+ Recipient string `json:"Recipient,omitempty"`
+ Subject string `json:"Subject,omitempty"`
+ RawHTMLBody string `json:"RawHtmlBody,omitempty"`
+ RawTextBody string `json:"RawTextBody,omitempty"`
+}
+
+// BrevoInboundWebhook represents Brevo inbound event payload.
+type BrevoInboundWebhook struct {
+ Items []BrevoInboundItem `json:"items,omitempty"`
+}
diff --git a/internal/handlers/third/email_handler.go b/internal/handlers/third/email_handler.go
new file mode 100644
index 00000000..529c60c8
--- /dev/null
+++ b/internal/handlers/third/email_handler.go
@@ -0,0 +1,42 @@
+package third
+
+import (
+ "bytes"
+ "io"
+ "net/http"
+ "strings"
+
+ "agent-desk/internal/services"
+
+ "github.com/gin-gonic/gin"
+)
+
+// EmailPostWebhook receives incoming inbound email webhook events from Brevo, SendGrid, Postmark or SMTP forwarders.
+func EmailPostWebhook(ctx *gin.Context) {
+ channelID := strings.TrimSpace(ctx.Param("channel_id"))
+ if channelID == "" {
+ channelID = strings.TrimSpace(ctx.Query("channel_id"))
+ }
+
+ secretHeader := ctx.GetHeader("X-Webhook-Secret")
+ if secretHeader == "" {
+ secretHeader = ctx.GetHeader("X-Brevo-Webhook-Secret")
+ }
+ if secretHeader == "" {
+ secretHeader = ctx.Query("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.EmailInboundService.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, "message": "email processed"})
+}
diff --git a/internal/handlers/third/email_handler_test.go b/internal/handlers/third/email_handler_test.go
new file mode 100644
index 00000000..0b999449
--- /dev/null
+++ b/internal/handlers/third/email_handler_test.go
@@ -0,0 +1,162 @@
+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 TestEmailPostWebhook_FullFlow(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ db := setupThirdHandlerTestDB(t)
+
+ now := time.Now()
+ agent := &models.AIAgent{
+ Name: "Email Support Agent",
+ ServiceMode: enums.IMConversationServiceModeAIFirst,
+ PublishedRevisionID: 1,
+ WelcomeMessage: "Thanks for emailing support.",
+ Status: enums.StatusOk,
+ AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now},
+ }
+ _ = db.Create(agent)
+
+ emailConfig, _ := json.Marshal(dto.EmailChannelConfig{
+ EmailAddress: "help@crove.com",
+ SenderName: "Crove Desk Support",
+ Provider: "brevo",
+ WebhookSecret: "email_secret_token_123",
+ WelcomeMessage: "We have received your email.",
+ })
+
+ operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"}
+ channel, err := services.ChannelService.CreateChannel(request.CreateChannelRequest{
+ Name: "Email Support Channel",
+ ChannelType: enums.ChannelTypeEmail,
+ AIAgentID: agent.ID,
+ AIAgentRolloutPercent: 100,
+ ConfigJSON: string(emailConfig),
+ Status: int(enums.StatusOk),
+ }, operator)
+ if err != nil {
+ t.Fatalf("CreateChannel failed: %v", err)
+ }
+
+ router := gin.New()
+ router.POST("/api/third/email/webhook/:channel_id", EmailPostWebhook)
+ router.POST("/api/third/email/webhook", EmailPostWebhook)
+
+ // 1. Test unauthorized when secret doesn't match
+ genericPayload := []byte(`{
+ "from": "alice@customer.com",
+ "from_name": "Alice Customer",
+ "to": "help@crove.com",
+ "subject": "Need help with Crove Desk",
+ "text": "Hello, I have an issue with my login credentials.",
+ "message_id": ""
+ }`)
+
+ req, _ := http.NewRequest(http.MethodPost, "/api/third/email/webhook/"+channel.ChannelID, bytes.NewBuffer(genericPayload))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("X-Webhook-Secret", "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"] != false {
+ t.Fatalf("expected ok: false on wrong secret token, got: %v", resp)
+ }
+
+ // 2. Test successful processing with Generic payload
+ req2, _ := http.NewRequest(http.MethodPost, "/api/third/email/webhook/"+channel.ChannelID, bytes.NewBuffer(genericPayload))
+ req2.Header.Set("Content-Type", "application/json")
+ req2.Header.Set("X-Webhook-Secret", "email_secret_token_123")
+
+ rec2 := httptest.NewRecorder()
+ router.ServeHTTP(rec2, req2)
+
+ var resp2 map[string]any
+ _ = json.Unmarshal(rec2.Body.Bytes(), &resp2)
+ if resp2["ok"] != true {
+ t.Fatalf("expected ok: true, got: %v", resp2)
+ }
+
+ // Verify Customer was created with Email source
+ identity := repositories.CustomerIdentityRepository.FindOne(sqls.DB(), sqls.NewCnd().
+ Eq("external_source", enums.ExternalSourceEmail).
+ Eq("external_id", "alice@customer.com"))
+ if identity == nil {
+ t.Fatalf("expected customer identity for alice@customer.com to be created")
+ }
+
+ customer := repositories.CustomerRepository.Get(sqls.DB(), identity.CustomerID)
+ if customer == nil || customer.PrimaryEmail != "alice@customer.com" {
+ t.Fatalf("expected customer primary_email 'alice@customer.com', got %+v", customer)
+ }
+
+ // Verify Conversation was created
+ conversations := repositories.ConversationRepository.Find(sqls.DB(), sqls.NewCnd().
+ Eq("customer_id", customer.ID).
+ Eq("channel_id", channel.ID))
+ if len(conversations) == 0 {
+ t.Fatalf("expected conversation to be created")
+ }
+
+ // Verify Message was saved
+ messages := repositories.MessageRepository.Find(sqls.DB(), sqls.NewCnd().
+ Eq("conversation_id", conversations[0].ID))
+ if len(messages) == 0 {
+ t.Fatalf("expected message to be stored")
+ }
+
+ // 3. Test Brevo Inbound payload format
+ brevoPayload := []byte(`{
+ "items": [
+ {
+ "Uuid": ["brevo-uuid-999"],
+ "Sender": "Bob Smith ",
+ "Recipient": "help@crove.com",
+ "Subject": "Enterprise Inquiry",
+ "RawTextBody": "We would like to request enterprise support pricing."
+ }
+ ]
+ }`)
+
+ req3, _ := http.NewRequest(http.MethodPost, "/api/third/email/webhook", bytes.NewBuffer(brevoPayload))
+ req3.Header.Set("Content-Type", "application/json")
+ req3.Header.Set("X-Webhook-Secret", "email_secret_token_123")
+
+ rec3 := httptest.NewRecorder()
+ router.ServeHTTP(rec3, req3)
+
+ var resp3 map[string]any
+ _ = json.Unmarshal(rec3.Body.Bytes(), &resp3)
+ if resp3["ok"] != true {
+ t.Fatalf("expected brevo format ok: true, got: %v", resp3)
+ }
+
+ bobIdentity := repositories.CustomerIdentityRepository.FindOne(sqls.DB(), sqls.NewCnd().
+ Eq("external_source", enums.ExternalSourceEmail).
+ Eq("external_id", "bob@partner.org"))
+ if bobIdentity == nil {
+ t.Fatalf("expected customer identity for bob@partner.org")
+ }
+}
diff --git a/internal/pkg/dto/dto.go b/internal/pkg/dto/dto.go
index 276d03a1..ad7964ec 100644
--- a/internal/pkg/dto/dto.go
+++ b/internal/pkg/dto/dto.go
@@ -49,3 +49,17 @@ type ZaloOAChannelConfig struct {
WebhookSecret string `json:"webhookSecret,omitempty"`
WelcomeMessage string `json:"welcomeMessage,omitempty"`
}
+
+type EmailChannelConfig struct {
+ EmailAddress string `json:"emailAddress"` // e.g. help@crove.com
+ SenderName string `json:"senderName,omitempty"` // e.g. Crove Desk Support
+ Provider string `json:"provider,omitempty"` // brevo | smtp
+ APIKey string `json:"apiKey,omitempty"` // Brevo / ESP API Key
+ SMTPHost string `json:"smtpHost,omitempty"` // SMTP Server Host
+ SMTPPort int `json:"smtpPort,omitempty"` // SMTP Port (587/465)
+ SMTPUser string `json:"smtpUser,omitempty"` // SMTP Username
+ SMTPPassword string `json:"smtpPassword,omitempty"` // SMTP Password
+ WebhookSecret string `json:"webhookSecret,omitempty"` // Inbound Webhook Secret
+ WelcomeMessage string `json:"welcomeMessage,omitempty"` // Auto-responder / welcome message
+}
+
diff --git a/internal/pkg/enums/external_identity.go b/internal/pkg/enums/external_identity.go
index a3425ea1..2eb84f2c 100644
--- a/internal/pkg/enums/external_identity.go
+++ b/internal/pkg/enums/external_identity.go
@@ -12,6 +12,7 @@ const (
ExternalSourceTwentyCRM ExternalSource = "twenty_crm" // Twenty CRM
ExternalSourceTelegram ExternalSource = "telegram" // Telegram Bot
ExternalSourceZaloOA ExternalSource = "zalo_oa" // Zalo Official Account
+ ExternalSourceEmail ExternalSource = "email" // Email
)
var externalSourceLabelMap = map[ExternalSource]string{
@@ -21,6 +22,7 @@ var externalSourceLabelMap = map[ExternalSource]string{
ExternalSourceTwentyCRM: "Twenty CRM",
ExternalSourceTelegram: "Telegram",
ExternalSourceZaloOA: "Zalo OA",
+ ExternalSourceEmail: "Email",
}
func GetExternalSourceLabel(v ExternalSource) string {
diff --git a/internal/pkg/enums/wxwork_kf.go b/internal/pkg/enums/wxwork_kf.go
index 825f7fa6..ae8d661f 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"
+ ChannelTypeEmail = "email"
)
type WxWorkKFMessageSendStatus string
diff --git a/internal/services/channel_message_outbox_service.go b/internal/services/channel_message_outbox_service.go
index e095b1d2..bac16350 100644
--- a/internal/services/channel_message_outbox_service.go
+++ b/internal/services/channel_message_outbox_service.go
@@ -252,6 +252,69 @@ func (s *channelMessageOutboxService) EnqueueZaloOAMessage(conversation *models.
return nil
}
+func (s *channelMessageOutboxService) EnqueueEmailMessage(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.ChannelTypeEmail {
+ return nil
+ }
+ if message.SenderType != enums.IMSenderTypeAgent && message.SenderType != enums.IMSenderTypeAI {
+ return nil
+ }
+ if message.MessageType != enums.IMMessageTypeText && message.MessageType != enums.IMMessageTypeHTML {
+ return nil
+ }
+ if existing := s.GetByMessageID(enums.ChannelTypeEmail, 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.ChannelTypeEmail,
+ 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
+ }
+
+ // Trigger async dispatch immediately
+ go func() {
+ defer func() {
+ if r := recover(); r != nil {
+ slog.Error("recovered from panic in email outbound dispatch", "error", r)
+ }
+ }()
+ EmailOutboundService.DispatchPendingOutbox()
+ }()
+
+ return nil
+}
+
func (s *channelMessageOutboxService) ListPending(channelType string, limit int) []models.ChannelMessageOutbox {
if limit <= 0 {
limit = 20
diff --git a/internal/services/channel_service.go b/internal/services/channel_service.go
index 93b39e5a..b8c7c80d 100644
--- a/internal/services/channel_service.go
+++ b/internal/services/channel_service.go
@@ -391,6 +391,35 @@ func (s *channelService) ParseZaloOAChannelConfig(raw string) (*dto.ZaloOAChanne
return cfg, nil
}
+func (s *channelService) ParseEmailChannelConfig(raw string) (*dto.EmailChannelConfig, error) {
+ raw = strings.TrimSpace(raw)
+ cfg := &dto.EmailChannelConfig{
+ Provider: "smtp",
+ }
+ if raw != "" {
+ if err := json.Unmarshal([]byte(raw), cfg); err != nil {
+ return nil, err
+ }
+ }
+ cfg.EmailAddress = strings.ToLower(strings.TrimSpace(cfg.EmailAddress))
+ cfg.SenderName = strings.TrimSpace(cfg.SenderName)
+ cfg.Provider = strings.ToLower(strings.TrimSpace(cfg.Provider))
+ if cfg.Provider == "" {
+ if cfg.APIKey != "" {
+ cfg.Provider = "brevo"
+ } else {
+ cfg.Provider = "smtp"
+ }
+ }
+ cfg.APIKey = strings.TrimSpace(cfg.APIKey)
+ cfg.SMTPHost = strings.TrimSpace(cfg.SMTPHost)
+ cfg.SMTPUser = strings.TrimSpace(cfg.SMTPUser)
+ cfg.SMTPPassword = strings.TrimSpace(cfg.SMTPPassword)
+ 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 ""
@@ -493,6 +522,31 @@ func (s *channelService) GetEnabledWxWorkKFChannelByOpenKfID(openKfID string) *m
return nil
}
+func (s *channelService) GetEnabledEmailChannelByAddress(emailAddress string) *models.Channel {
+ emailAddress = strings.ToLower(strings.TrimSpace(emailAddress))
+ if emailAddress == "" {
+ return nil
+ }
+ channels := s.Find(sqls.NewCnd().
+ Eq("channel_type", enums.ChannelTypeEmail).
+ Eq("status", enums.StatusOk).
+ Asc("id"))
+ for i := range channels {
+ cfg, err := s.ParseEmailChannelConfig(channels[i].ConfigJSON)
+ if err != nil {
+ continue
+ }
+ if cfg != nil && strings.ToLower(strings.TrimSpace(cfg.EmailAddress)) == emailAddress {
+ return &channels[i]
+ }
+ }
+ // Fallback to first active email channel if exact address match wasn't found
+ if len(channels) > 0 {
+ return &channels[0]
+ }
+ return nil
+}
+
func (s *channelService) GetEnabledChannel(ctx *gin.Context) *models.Channel {
channelID := httpx.GetChannelID(ctx)
channel := repositories.ChannelRepository.GetByChannelID(sqls.DB(), channelID)
@@ -507,7 +561,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.ChannelTypeEmail {
return nil, errorsx.InvalidParamI18n("error.e0250")
}
name := strings.TrimSpace(req.Name)
@@ -654,6 +708,30 @@ func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRe
return nil, err
}
configJSON = string(configBytes)
+ case enums.ChannelTypeEmail:
+ 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.ParseEmailChannelConfig(configJSON)
+ if err != nil {
+ return nil, errorsx.InvalidParam("invalid email channel configuration")
+ }
+ if cfg == nil || cfg.EmailAddress == "" {
+ return nil, errorsx.InvalidParam("emailAddress is required")
+ }
+ 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{
diff --git a/internal/services/cronx/cron.go b/internal/services/cronx/cron.go
index 52ab00e2..08e90162 100644
--- a/internal/services/cronx/cron.go
+++ b/internal/services/cronx/cron.go
@@ -34,6 +34,10 @@ func Init() {
if zaloCount > 0 {
slog.Info("zalo oa outbox dispatched", "count", zaloCount)
}
+ emailCount := services.EmailOutboundService.DispatchPendingOutbox()
+ if emailCount > 0 {
+ slog.Info("email outbox dispatched", "count", emailCount)
+ }
})
c.Start()
diff --git a/internal/services/email_inbound_service.go b/internal/services/email_inbound_service.go
new file mode 100644
index 00000000..db76a3ff
--- /dev/null
+++ b/internal/services/email_inbound_service.go
@@ -0,0 +1,228 @@
+package services
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "strings"
+
+ "agent-desk/internal/email"
+ "agent-desk/internal/models"
+ "agent-desk/internal/pkg/enums"
+ "agent-desk/internal/pkg/errorsx"
+ "agent-desk/internal/pkg/openidentity"
+ "agent-desk/internal/repositories"
+
+ "github.com/mlogclub/simple/common/strs"
+ "github.com/mlogclub/simple/sqls"
+)
+
+var EmailInboundService = newEmailInboundService()
+
+func newEmailInboundService() *emailInboundService {
+ return &emailInboundService{}
+}
+
+type emailInboundService struct{}
+
+// HandleWebhook processes an incoming email webhook from Brevo, SendGrid, or custom SMTP webhook gateway.
+func (s *emailInboundService) 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.ChannelTypeEmail, enums.StatusOk)
+ }
+
+ // 1. Parse inbound email items
+ inboundItems, err := s.parseInboundPayload(rawPayload)
+ if err != nil {
+ return fmt.Errorf("parse email webhook failed: %w", err)
+ }
+ if len(inboundItems) == 0 {
+ return nil
+ }
+
+ for _, item := range inboundItems {
+ targetChannel := channel
+ if targetChannel == nil {
+ targetChannel = ChannelService.GetEnabledEmailChannelByAddress(item.ToEmail)
+ }
+ if targetChannel == nil {
+ targetChannel = ChannelService.Take("channel_type = ? AND status = ?", enums.ChannelTypeEmail, enums.StatusOk)
+ }
+ if targetChannel == nil {
+ slog.Warn("no active email channel found for recipient", "to", item.ToEmail)
+ return errorsx.InvalidParam("email channel not found or disabled")
+ }
+
+ cfg, err := ChannelService.ParseEmailChannelConfig(targetChannel.ConfigJSON)
+ if err != nil || cfg == nil {
+ return errorsx.InvalidParam("email channel config invalid")
+ }
+
+ if cfg.WebhookSecret != "" && strings.TrimSpace(secretHeader) != cfg.WebhookSecret {
+ return errorsx.UnauthorizedI18n("error.auth.invalidSignature")
+ }
+
+ if err := s.processInboundItem(ctx, targetChannel, item); err != nil {
+ slog.Error("process inbound email item failed", "from", item.FromEmail, "to", item.ToEmail, "error", err)
+ return err
+ }
+ }
+
+ return nil
+}
+
+func (s *emailInboundService) processInboundItem(ctx context.Context, channel *models.Channel, item email.InboundEmailPayload) error {
+ fromEmail := strings.ToLower(strings.TrimSpace(item.FromEmail))
+ if fromEmail == "" {
+ return nil
+ }
+ fromName := strings.TrimSpace(item.FromName)
+ if fromName == "" {
+ parts := strings.Split(fromEmail, "@")
+ fromName = parts[0]
+ }
+
+ bodyText := strings.TrimSpace(item.BodyText)
+ if bodyText == "" && item.BodyHTML != "" {
+ bodyText = stripHTMLTags(item.BodyHTML)
+ }
+ if bodyText == "" {
+ bodyText = "(Empty email body)"
+ }
+
+ // Format content with subject if provided
+ content := bodyText
+ if item.Subject != "" {
+ content = fmt.Sprintf("[%s]\n\n%s", item.Subject, bodyText)
+ }
+
+ // 1. Resolve customer identity
+ externalUser := openidentity.ExternalUser{
+ ExternalSource: enums.ExternalSourceEmail,
+ ExternalID: fromEmail,
+ ExternalName: fromName,
+ }
+
+ // 2. Create or match Conversation
+ conversation, err := ConversationService.Create(externalUser, channel.ID, channel.AIAgentID)
+ if err != nil {
+ return fmt.Errorf("create email conversation failed: %w", err)
+ }
+
+ // Ensure customer primary_email is populated
+ if conversation.CustomerID > 0 {
+ customer := repositories.CustomerRepository.Get(sqls.DB(), conversation.CustomerID)
+ if customer != nil && customer.PrimaryEmail == "" {
+ _ = repositories.CustomerRepository.UpdateColumn(sqls.DB(), customer.ID, "primary_email", fromEmail)
+ }
+ }
+
+ // 3. Send message through MessageService (triggers AI response loop or agent notification)
+ msgHash := strs.UUID()
+ if item.MessageID != "" {
+ msgHash = fmt.Sprintf("email_%s", strings.Trim(item.MessageID, "<>"))
+ }
+ clientMsgID := fmt.Sprintf("mail_%s", msgHash)
+
+ payloadMap := map[string]any{
+ "email_from": fromEmail,
+ "email_from_name": fromName,
+ "email_to": item.ToEmail,
+ "email_subject": item.Subject,
+ "email_message_id": item.MessageID,
+ "email_in_reply": item.InReplyTo,
+ }
+ payloadBytes, _ := json.Marshal(payloadMap)
+
+ _, err = MessageService.SendCustomerMessage(
+ conversation.ID,
+ clientMsgID,
+ enums.IMMessageTypeText,
+ content,
+ string(payloadBytes),
+ externalUser,
+ )
+ if err != nil {
+ return fmt.Errorf("send customer message failed: %w", err)
+ }
+
+ slog.Info("inbound email successfully processed", "from", fromEmail, "channel_id", channel.ChannelID, "conversation_id", conversation.ID)
+ return nil
+}
+
+func (s *emailInboundService) parseInboundPayload(raw []byte) ([]email.InboundEmailPayload, error) {
+ rawStr := strings.TrimSpace(string(raw))
+ if rawStr == "" {
+ return nil, nil
+ }
+
+ // Try Brevo format first
+ var brevoWebhook email.BrevoInboundWebhook
+ if err := json.Unmarshal(raw, &brevoWebhook); err == nil && len(brevoWebhook.Items) > 0 {
+ var results []email.InboundEmailPayload
+ for _, item := range brevoWebhook.Items {
+ fromEmail, fromName := email.ParseAddress(item.Sender)
+ toEmail, _ := email.ParseAddress(item.Recipient)
+ msgID := ""
+ if len(item.UUID) > 0 {
+ msgID = item.UUID[0]
+ }
+ results = append(results, email.InboundEmailPayload{
+ FromEmail: fromEmail,
+ FromName: fromName,
+ ToEmail: toEmail,
+ Subject: strings.TrimSpace(item.Subject),
+ BodyText: strings.TrimSpace(item.RawTextBody),
+ BodyHTML: strings.TrimSpace(item.RawHTMLBody),
+ MessageID: msgID,
+ })
+ }
+ return results, nil
+ }
+
+ // Try Generic JSON format
+ var generic email.GenericInboundWebhook
+ if err := json.Unmarshal(raw, &generic); err == nil && generic.From != "" {
+ fromEmail, fromName := email.ParseAddress(generic.From)
+ if generic.FromName != "" {
+ fromName = generic.FromName
+ }
+ toEmail, _ := email.ParseAddress(generic.To)
+ body := generic.Text
+ if body == "" {
+ body = generic.Body
+ }
+ return []email.InboundEmailPayload{
+ {
+ FromEmail: fromEmail,
+ FromName: fromName,
+ ToEmail: toEmail,
+ Subject: strings.TrimSpace(generic.Subject),
+ BodyText: strings.TrimSpace(body),
+ BodyHTML: strings.TrimSpace(generic.HTML),
+ MessageID: generic.MessageID,
+ InReplyTo: generic.InReplyTo,
+ },
+ }, nil
+ }
+
+ return nil, fmt.Errorf("unrecognized email webhook format")
+}
+
+func stripHTMLTags(s string) string {
+ var builder strings.Builder
+ inTag := false
+ for _, r := range s {
+ if r == '<' {
+ inTag = true
+ } else if r == '>' {
+ inTag = false
+ } else if !inTag {
+ builder.WriteRune(r)
+ }
+ }
+ return strings.TrimSpace(builder.String())
+}
diff --git a/internal/services/email_outbound_service.go b/internal/services/email_outbound_service.go
new file mode 100644
index 00000000..f99c27cb
--- /dev/null
+++ b/internal/services/email_outbound_service.go
@@ -0,0 +1,238 @@
+package services
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "os"
+ "strings"
+ "time"
+
+ "agent-desk/internal/email"
+ "agent-desk/internal/models"
+ "agent-desk/internal/pkg/enums"
+ "agent-desk/internal/repositories"
+
+ "github.com/mlogclub/simple/sqls"
+)
+
+const (
+ emailOutboxBatchSize = 20
+ emailOutboxMaxRetry = 5
+)
+
+var EmailOutboundService = newEmailOutboundService()
+
+func newEmailOutboundService() *emailOutboundService {
+ return &emailOutboundService{}
+}
+
+type emailOutboundService struct{}
+
+func (s *emailOutboundService) DispatchPendingOutbox() int {
+ return s.doDispatchPendingOutbox(emailOutboxBatchSize)
+}
+
+func (s *emailOutboundService) doDispatchPendingOutbox(limit int) int {
+ if limit <= 0 {
+ limit = emailOutboxBatchSize
+ }
+ items := ChannelMessageOutboxService.ListPending(enums.ChannelTypeEmail, 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 email outbox failed",
+ "outbox_id", items[i].ID,
+ "error", err,
+ )
+ continue
+ }
+ successCount++
+ }
+ return successCount
+}
+
+func (s *emailOutboundService) processOutbox(outboxID int64) error {
+ outbox := ChannelMessageOutboxService.Get(outboxID)
+ if outbox == nil {
+ return nil
+ }
+ if outbox.ChannelType != enums.ChannelTypeEmail {
+ 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, "email channel not found or disabled")
+ }
+ cfg, err := ChannelService.ParseEmailChannelConfig(channel.ConfigJSON)
+ if err != nil || cfg == nil {
+ return s.markOutboxFailed(outbox, "email channel config invalid")
+ }
+
+ // 1. Resolve recipient email address
+ targetEmail := ""
+ targetName := ""
+ customer := repositories.CustomerRepository.Get(sqls.DB(), conversation.CustomerID)
+ if customer != nil {
+ targetEmail = strings.TrimSpace(customer.PrimaryEmail)
+ targetName = strings.TrimSpace(customer.Name)
+ }
+ if targetEmail == "" {
+ customerIdentity := repositories.CustomerIdentityRepository.FindOne(sqls.DB(), sqls.NewCnd().
+ Eq("customer_id", conversation.CustomerID).
+ Eq("external_source", enums.ExternalSourceEmail))
+ if customerIdentity != nil {
+ targetEmail = strings.TrimSpace(customerIdentity.ExternalID)
+ }
+ }
+ if targetEmail == "" {
+ return s.markOutboxFailed(outbox, "unable to resolve customer email address")
+ }
+
+ // 2. Resolve sender config & fallbacks
+ fromEmail := strings.TrimSpace(cfg.EmailAddress)
+ if fromEmail == "" {
+ fromEmail = "help@crove.com"
+ }
+ fromName := strings.TrimSpace(cfg.SenderName)
+ if fromName == "" {
+ fromName = "Crove Desk Support"
+ }
+
+ apiKey := cfg.APIKey
+ if apiKey == "" {
+ apiKey = os.Getenv("BREVO_API_KEY")
+ if apiKey == "" {
+ apiKey = os.Getenv("CROVE_BREVO_API_KEY")
+ }
+ }
+
+ smtpHost := cfg.SMTPHost
+ if smtpHost == "" {
+ smtpHost = os.Getenv("SMTP_HOST")
+ }
+ smtpPort := cfg.SMTPPort
+ if smtpPort <= 0 {
+ smtpPort = 587
+ }
+ smtpUser := cfg.SMTPUser
+ if smtpUser == "" {
+ smtpUser = os.Getenv("SMTP_USER")
+ }
+ smtpPassword := cfg.SMTPPassword
+ if smtpPassword == "" {
+ smtpPassword = os.Getenv("SMTP_PASSWORD")
+ }
+
+ provider := cfg.Provider
+ if provider == "" {
+ if apiKey != "" {
+ provider = "brevo"
+ } else {
+ provider = "smtp"
+ }
+ }
+
+ client := email.NewClient(email.ClientConfig{
+ Provider: provider,
+ APIKey: apiKey,
+ SMTPHost: smtpHost,
+ SMTPPort: smtpPort,
+ SMTPUser: smtpUser,
+ SMTPPassword: smtpPassword,
+ })
+
+ subject := fmt.Sprintf("Re: Support Ticket #%d", conversation.ID)
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
+ defer cancel()
+
+ sendErr := client.SendEmail(ctx, email.SendEmailParams{
+ FromEmail: fromEmail,
+ FromName: fromName,
+ ToEmail: targetEmail,
+ ToName: targetName,
+ Subject: subject,
+ BodyText: message.Content,
+ })
+
+ if sendErr != nil {
+ return s.handleOutboxError(outbox, sendErr.Error())
+ }
+
+ return s.markOutboxSent(outbox, fmt.Sprintf("sent to %s", targetEmail))
+}
+
+func (s *emailOutboundService) markOutboxSent(outbox *models.ChannelMessageOutbox, detail string) error {
+ now := time.Now()
+ return ChannelMessageOutboxService.Updates(outbox.ID, map[string]any{
+ "send_status": string(enums.ChannelMessageOutboxStatusSent),
+ "send_detail": detail,
+ "sent_at": &now,
+ "updated_at": now,
+ "next_retry_at": nil,
+ })
+}
+
+func (s *emailOutboundService) markOutboxFailed(outbox *models.ChannelMessageOutbox, reason string) error {
+ now := time.Now()
+ return ChannelMessageOutboxService.Updates(outbox.ID, map[string]any{
+ "send_status": string(enums.ChannelMessageOutboxStatusFailed),
+ "send_detail": reason,
+ "updated_at": now,
+ "next_retry_at": nil,
+ })
+}
+
+func (s *emailOutboundService) handleOutboxError(outbox *models.ChannelMessageOutbox, errMsg string) error {
+ retryCount := outbox.RetryCount + 1
+ now := time.Now()
+
+ if retryCount >= emailOutboxMaxRetry {
+ return ChannelMessageOutboxService.Updates(outbox.ID, map[string]any{
+ "send_status": string(enums.ChannelMessageOutboxStatusFailed),
+ "send_detail": fmt.Sprintf("max retries exceeded: %s", errMsg),
+ "retry_count": retryCount,
+ "updated_at": now,
+ "next_retry_at": nil,
+ })
+ }
+
+ // Exponential backoff
+ backoff := time.Duration(1< {
return {
title: t("channel.defaultTitleWeb"),
@@ -87,7 +99,7 @@ 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", "email"], t("channel.typeRequired")),
aiAgentId: z.string().trim().regex(/^\d+$/, t("channel.agentRequired")),
aiAgentRolloutPercent: z.coerce.number().int().min(1).max(100),
name: z.string().trim().min(1, t("channel.nameRequired")),
@@ -99,6 +111,14 @@ function createSchema(t: Translate) {
zaloOaId: z.string().trim(),
zaloAccessToken: z.string().trim(),
zaloSecretKey: z.string().trim(),
+ emailAddress: z.string().trim(),
+ senderName: z.string().trim(),
+ emailProvider: z.string().trim(),
+ emailApiKey: z.string().trim(),
+ smtpHost: z.string().trim(),
+ smtpPort: z.coerce.number().int().optional(),
+ smtpUser: z.string().trim(),
+ smtpPassword: z.string().trim(),
widgetTitle: z.string().trim(),
widgetSubtitle: z.string().trim(),
widgetThemeColor: z.string().trim(),
@@ -115,6 +135,13 @@ function createSchema(t: Translate) {
message: t("channel.wxworkAccountRequired"),
})
}
+ if (values.channelType === "email" && !values.emailAddress.trim()) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["emailAddress"],
+ message: "Email address is required (e.g. help@crove.com)",
+ })
+ }
if (values.channelType === "telegram" && !values.botToken.trim()) {
ctx.addIssue({
code: "custom",
@@ -133,7 +160,7 @@ function createSchema(t: Translate) {
}
type EditForm = {
- channelType: "web" | "wechat_mp" | "wxwork_kf" | "telegram" | "zalo_oa"
+ channelType: "web" | "wechat_mp" | "wxwork_kf" | "telegram" | "zalo_oa" | "email"
aiAgentId: string
aiAgentRolloutPercent: number
name: string
@@ -145,6 +172,14 @@ type EditForm = {
zaloOaId: string
zaloAccessToken: string
zaloSecretKey: string
+ emailAddress: string
+ senderName: string
+ emailProvider: string
+ emailApiKey: string
+ smtpHost: string
+ smtpPort?: number
+ smtpUser: string
+ smtpPassword: string
widgetTitle: string
widgetSubtitle: string
widgetThemeColor: string
@@ -169,6 +204,14 @@ function createEmptyForm(t: Translate): EditForm {
zaloOaId: "",
zaloAccessToken: "",
zaloSecretKey: "",
+ emailAddress: "help@crove.com",
+ senderName: "Crove Desk Support",
+ emailProvider: "brevo",
+ emailApiKey: "",
+ smtpHost: "",
+ smtpPort: 587,
+ smtpUser: "",
+ smtpPassword: "",
widgetTitle: defaultWebChannelConfig.title,
widgetSubtitle: defaultWebChannelConfig.subtitle,
widgetThemeColor: defaultWebChannelConfig.themeColor,
@@ -222,6 +265,26 @@ function parseZaloOAChannelConfig(configJson: string): ZaloOAChannelConfig {
}
}
+function parseEmailChannelConfig(configJson: string): EmailChannelConfig {
+ if (!configJson.trim()) return {}
+ try {
+ const parsed = JSON.parse(configJson) as EmailChannelConfig
+ return {
+ emailAddress: parsed.emailAddress?.trim() || "",
+ senderName: parsed.senderName?.trim() || "",
+ provider: parsed.provider?.trim() || "brevo",
+ apiKey: parsed.apiKey?.trim() || "",
+ smtpHost: parsed.smtpHost?.trim() || "",
+ smtpPort: parsed.smtpPort || 587,
+ smtpUser: parsed.smtpUser?.trim() || "",
+ smtpPassword: parsed.smtpPassword?.trim() || "",
+ webhookSecret: parsed.webhookSecret?.trim() || "",
+ }
+ } catch {
+ return {}
+ }
+}
+
function parseWebChannelConfig(configJson: string, t: Translate): Required {
const defaultWebChannelConfig = getDefaultWebChannelConfig(t)
if (!configJson.trim()) {
@@ -276,6 +339,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 isEmail = item.channelType === "email"
const webConfig = parseWebChannelConfig(item.configJson, t)
const wechatConfig = isWechatMP
? parseWechatMPChannelConfig(item.configJson, t)
@@ -286,6 +350,9 @@ function buildForm(item: AdminChannel | null, t: Translate): EditForm {
const zaloConfig = isZaloOA
? parseZaloOAChannelConfig(item.configJson)
: null
+ const emailConfig = isEmail
+ ? parseEmailChannelConfig(item.configJson)
+ : null
return {
channelType:
item.channelType === "wxwork_kf"
@@ -294,20 +361,30 @@ function buildForm(item: AdminChannel | null, t: Translate): EditForm {
? "telegram"
: item.channelType === "zalo_oa"
? "zalo_oa"
- : item.channelType === "wechat_mp"
- ? "wechat_mp"
- : "web",
+ : item.channelType === "email"
+ ? "email"
+ : item.channelType === "wechat_mp"
+ ? "wechat_mp"
+ : "web",
aiAgentId: item.aiAgentId > 0 ? String(item.aiAgentId) : "",
aiAgentRolloutPercent: item.aiAgentRolloutPercent || 100,
name: item.name,
openKfId: parseOpenKfId(item.configJson),
botToken: telegramConfig?.botToken ?? "",
botUsername: telegramConfig?.botUsername ?? "",
- webhookSecret: telegramConfig?.webhookSecret ?? zaloConfig?.webhookSecret ?? "",
+ webhookSecret: telegramConfig?.webhookSecret ?? zaloConfig?.webhookSecret ?? emailConfig?.webhookSecret ?? "",
zaloAppId: zaloConfig?.appId ?? "",
zaloOaId: zaloConfig?.oaId ?? "",
zaloAccessToken: zaloConfig?.accessToken ?? "",
zaloSecretKey: zaloConfig?.secretKey ?? "",
+ emailAddress: emailConfig?.emailAddress || "help@crove.com",
+ senderName: emailConfig?.senderName || "Crove Desk Support",
+ emailProvider: emailConfig?.provider || "brevo",
+ emailApiKey: emailConfig?.apiKey || "",
+ smtpHost: emailConfig?.smtpHost || "",
+ smtpPort: emailConfig?.smtpPort || 587,
+ smtpUser: emailConfig?.smtpUser || "",
+ smtpPassword: emailConfig?.smtpPassword || "",
widgetTitle: wechatConfig?.title ?? webConfig.title,
widgetSubtitle: wechatConfig?.subtitle ?? webConfig.subtitle,
widgetThemeColor: wechatConfig?.themeColor ?? webConfig.themeColor,
@@ -333,28 +410,40 @@ function buildPayload(form: EditForm, status: number, t: Translate): CreateAdmin
const configJson =
channelType === "wxwork_kf"
? JSON.stringify({ openKfId: form.openKfId.trim() })
- : channelType === "telegram"
+ : channelType === "email"
? JSON.stringify({
- botToken: form.botToken.trim(),
- botUsername: form.botUsername.trim(),
+ emailAddress: form.emailAddress.trim(),
+ senderName: form.senderName.trim(),
+ provider: form.emailProvider.trim(),
+ apiKey: form.emailApiKey.trim(),
+ smtpHost: form.smtpHost.trim(),
+ smtpPort: form.smtpPort || 587,
+ smtpUser: form.smtpUser.trim(),
+ smtpPassword: form.smtpPassword.trim(),
webhookSecret: form.webhookSecret.trim(),
})
- : channelType === "zalo_oa"
+ : channelType === "telegram"
? JSON.stringify({
- appId: form.zaloAppId.trim(),
- oaId: form.zaloOaId.trim(),
- accessToken: form.zaloAccessToken.trim(),
- secretKey: form.zaloSecretKey.trim(),
+ botToken: form.botToken.trim(),
+ botUsername: form.botUsername.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 === "zalo_oa"
+ ? JSON.stringify({
+ appId: form.zaloAppId.trim(),
+ oaId: form.zaloOaId.trim(),
+ accessToken: form.zaloAccessToken.trim(),
+ 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(),
+ })
return {
channelType,
aiAgentId: Number(form.aiAgentId),
@@ -441,6 +530,7 @@ function ChannelFormBody({
const aiAgentId = useWatch({ control, name: "aiAgentId" })
const openKfId = useWatch({ control, name: "openKfId" })
const userTokenSecret = useWatch({ control, name: "userTokenSecret" })
+ const emailProvider = useWatch({ control, name: "emailProvider" })
const previousRolloutPercent = channelDetail?.previousAiAgentRolloutPercent ?? 0
async function rollbackRolloutPercent() {
@@ -542,7 +632,9 @@ function ChannelFormBody({
}))
const channelTypeOptions = [
{ value: "web", label: t("channel.typeWeb") },
+ { value: "email", label: t("channel.typeEmail") },
{ value: "telegram", label: t("channel.typeTelegram") },
+ { value: "zalo_oa", label: t("channel.typeZaloOa") },
{ value: "wechat_mp", label: t("channel.typeWechatMp") },
{ value: "wxwork_kf", label: t("channel.typeWxworkKf") },
] as const
@@ -709,14 +801,154 @@ function ChannelFormBody({
{t("channel.configTitle")}
- {channelType === "wxwork_kf"
- ? t("channel.configWxworkDescription")
- : channelType === "wechat_mp"
- ? t("channel.configWechatDescription")
- : t("channel.configWebDescription")}
+ {channelType === "email"
+ ? t("channel.configEmailDescription")
+ : channelType === "wxwork_kf"
+ ? t("channel.configWxworkDescription")
+ : channelType === "wechat_mp"
+ ? t("channel.configWechatDescription")
+ : t("channel.configWebDescription")}
+ {channelType === "email" ? (
+
+
+
+ {t("channel.emailAddress")} *
+
+
+
+
+
+
+
+ {t("channel.senderName")}
+
+
+
+
+
+
+
+
+
+ {t("channel.emailProvider")}
+
+
+
+
+
+
+
+ {t("channel.webhookSecret")}
+
+
+
+
+
+
+
+ {emailProvider === "brevo" ? (
+
+ {t("channel.emailApiKey")}
+
+
+
+
+
+ ) : (
+
+ )}
+
+
+
{t("channel.emailAutoConnectTitle")}
+
{t("channel.emailAutoConnectDescription")}
+
+ Webhook URL: https://desk.crove.com/api/third/email/webhook
+
+
+
+ ) : null}
+
{channelType === "zalo_oa" ? (
diff --git a/web/app/(dashboard)/dashboard/channels/page.tsx b/web/app/(dashboard)/dashboard/channels/page.tsx
index aa8c06f7..9f1b657c 100644
--- a/web/app/(dashboard)/dashboard/channels/page.tsx
+++ b/web/app/(dashboard)/dashboard/channels/page.tsx
@@ -2,6 +2,7 @@
import {
Building2Icon,
+ MailIcon,
MessagesSquareIcon,
MessageSquareMoreIcon,
SendIcon,
@@ -27,6 +28,9 @@ import { useI18n } from "@/i18n/provider"
import { EditDialog } from "./_components/edit"
function getChannelTypeLabel(channelType: string, t: (key: string) => string) {
+ if (channelType === "email") {
+ return t("channel.typeEmail")
+ }
if (channelType === "wechat_mp") {
return t("channel.typeWechatMp")
}
@@ -53,6 +57,9 @@ function getStatusLabel(status: Status, t: (key: string) => string) {
}
function ChannelIcon({ channelType }: { channelType: string }) {
+ if (channelType === "email") {
+ return
+ }
if (channelType === "wechat_mp") {
return
}
@@ -77,6 +84,7 @@ export default function DashboardChannelsPage() {
const channelTypeOptions = [
{ value: "all", label: t("channel.allTypes") },
{ value: "web", label: t("channel.typeWeb") },
+ { value: "email", label: t("channel.typeEmail") },
{ value: "telegram", label: t("channel.typeTelegram") },
{ value: "zalo_oa", label: t("channel.typeZaloOa") },
{ value: "wechat_mp", label: t("channel.typeWechatMp") },
diff --git a/web/lib/generated/enums.ts b/web/lib/generated/enums.ts
index 31c4af46..95ca08e1 100644
--- a/web/lib/generated/enums.ts
+++ b/web/lib/generated/enums.ts
@@ -80,6 +80,7 @@ export enum ExternalSource {
TwentyCRM = "twenty_crm",
Telegram = "telegram",
ZaloOA = "zalo_oa",
+ Email = "email",
}
export const ExternalSourceLabels: Record = {
[ExternalSource.Guest]: "访客",
@@ -88,6 +89,7 @@ export const ExternalSourceLabels: Record = {
[ExternalSource.TwentyCRM]: "Twenty CRM",
[ExternalSource.Telegram]: "Telegram",
[ExternalSource.ZaloOA]: "Zalo OA",
+ [ExternalSource.Email]: "Email",
}
export enum Gender {
diff --git a/web/messages/en-US.json b/web/messages/en-US.json
index c83bc000..fd86d435 100644
--- a/web/messages/en-US.json
+++ b/web/messages/en-US.json
@@ -609,10 +609,18 @@
"channel": {
"allTypes": "All types",
"typeWeb": "Web",
+ "typeEmail": "Email (help@crove.com)",
"typeTelegram": "Telegram Bot",
"typeZaloOa": "Zalo Official Account",
"typeWechatMp": "WeChat Official Account",
"typeWxworkKf": "WeCom Customer Service",
+ "emailAddress": "Support Email Address",
+ "senderName": "Sender Display Name",
+ "emailProvider": "Email Delivery Service",
+ "emailApiKey": "API Key",
+ "emailAutoConnectTitle": "Automatic Inbound Email Ingestion",
+ "emailAutoConnectDescription": "Forward emails sent to help@crove.com to the Crove Desk Inbound Webhook endpoint to automatically convert emails into tickets and trigger AI agent auto-replies.",
+ "configEmailDescription": "Configure inbound email webhook ingestion and outbound reply delivery via Brevo or SMTP.",
"botToken": "Telegram Bot Token",
"botTokenRequired": "Telegram Bot Token is required",
"botUsername": "Bot Username",
diff --git a/web/messages/vi-VN.json b/web/messages/vi-VN.json
index 1b05db76..097c841b 100644
--- a/web/messages/vi-VN.json
+++ b/web/messages/vi-VN.json
@@ -614,12 +614,20 @@
"deleted": "Deleted"
},
"channel": {
- "allTypes": "All types",
- "typeWeb": "Web",
+ "allTypes": "Tất cả loại kênh",
+ "typeWeb": "Web Chat Widget",
+ "typeEmail": "Email (help@crove.com)",
"typeTelegram": "Telegram Bot",
"typeZaloOa": "Zalo Official Account",
"typeWechatMp": "WeChat Official Account",
"typeWxworkKf": "WeCom Customer Service",
+ "emailAddress": "Địa chỉ Email Hỗ trợ",
+ "senderName": "Tên Người gửi Hiển thị",
+ "emailProvider": "Dịch vụ Gửi Email",
+ "emailApiKey": "API Key",
+ "emailAutoConnectTitle": "Tự động Nhận & Xử lý Email Khách hàng",
+ "emailAutoConnectDescription": "Forward hoặc cấu hình webhook email gửi đến help@crove.com về Crove Desk để tự động tạo Ticket và kích hoạt AI Agent phản hồi.",
+ "configEmailDescription": "Cấu hình tiếp nhận email qua Inbound Webhook và gửi phản hồi qua Brevo hoặc SMTP.",
"botToken": "Telegram Bot Token",
"botTokenRequired": "Telegram Bot Token is required",
"botUsername": "Bot Username",
diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json
index 14e67ae3..12655228 100644
--- a/web/messages/zh-CN.json
+++ b/web/messages/zh-CN.json
@@ -609,10 +609,18 @@
"channel": {
"allTypes": "全部类型",
"typeWeb": "Web 站点",
+ "typeEmail": "邮件客服 (help@crove.com)",
"typeTelegram": "Telegram Bot",
"typeZaloOa": "Zalo 公众号",
"typeWechatMp": "微信公众号",
"typeWxworkKf": "企业微信客服",
+ "emailAddress": "支持邮箱地址",
+ "senderName": "发件人显示名称",
+ "emailProvider": "邮件发送服务",
+ "emailApiKey": "API Key",
+ "emailAutoConnectTitle": "邮件客服自动接入",
+ "emailAutoConnectDescription": "将发送至 help@crove.com 的邮件通过 Webhook 转发至 Crove Desk,自动创建工单并触发 AI Agent 回复。",
+ "configEmailDescription": "配置邮件 Inbound Webhook 接入与 Brevo / SMTP 邮件回复发送。",
"botToken": "Telegram Bot Token",
"botTokenRequired": "请输入 Telegram Bot Token",
"botUsername": "Bot 用户名",
From 63c085349debc67f1ad5b92d03b567925a074d7e Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Mon, 31 Aug 2026 18:31:42 +0700
Subject: [PATCH 38/53] feat(email): complete multi-provider inbound and
outbound email support with threading
- Support multiple outbound email delivery providers: SMTP, Brevo, SendGrid, Resend, Postmark, Mailgun
- Support multiple inbound email ingestion formats: Cloudflare Email Routing / Generic Webhook, Brevo, Postmark, SendGrid Inbound Parse, Mailgun
- Add email conversation threading resolution via Subject ticket ID (#123) and In-Reply-To / References message headers
- Add full EmailConfig to server config and bind environment variable aliases
- Enhance Dashboard Channel configuration UI with email delivery providers and bilingual translations
---
internal/ai/mcps/client_test.go | 40 ++
internal/email/client.go | 349 ++++++++++++++----
internal/email/client_test.go | 150 ++++++--
internal/email/inbound_parser.go | 172 +++++++++
internal/email/types.go | 198 ++++++++--
internal/handlers/third/email_handler.go | 31 +-
internal/handlers/third/email_handler_test.go | 119 ++++--
internal/pkg/config/config.go | 34 ++
internal/pkg/config/config_test.go | 20 +
internal/services/email_inbound_service.go | 138 +++----
internal/services/email_outbound_service.go | 146 +++++---
.../dashboard/channels/_components/edit.tsx | 19 +-
web/messages/en-US.json | 14 +-
web/messages/vi-VN.json | 14 +-
web/messages/zh-CN.json | 14 +-
15 files changed, 1159 insertions(+), 299 deletions(-)
create mode 100644 internal/ai/mcps/client_test.go
create mode 100644 internal/email/inbound_parser.go
diff --git a/internal/ai/mcps/client_test.go b/internal/ai/mcps/client_test.go
new file mode 100644
index 00000000..74326c7f
--- /dev/null
+++ b/internal/ai/mcps/client_test.go
@@ -0,0 +1,40 @@
+package mcps
+
+import (
+ "context"
+ "net/http/httptest"
+ "testing"
+ "time"
+)
+
+func TestClient_SystemServer(t *testing.T) {
+ handler := NewHTTPHandler()
+ server := httptest.NewServer(handler)
+ defer server.Close()
+
+ client := NewClient()
+ cfg := ServerConfig{
+ Code: "system",
+ Endpoint: server.URL,
+ TimeoutMS: 5000,
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ conn, err := client.TestConnection(ctx, cfg)
+ if err != nil {
+ t.Fatalf("TestConnection failed: %v", err)
+ }
+ if conn.ServerName != "agent-desk-mcp-server" {
+ t.Errorf("expected ServerName 'agent-desk-mcp-server', got %s", conn.ServerName)
+ }
+
+ tools, err := client.ListTools(ctx, cfg)
+ if err != nil {
+ t.Fatalf("ListTools failed: %v", err)
+ }
+ if len(tools) == 0 {
+ t.Errorf("expected at least 1 tool, got %d", len(tools))
+ }
+}
diff --git a/internal/email/client.go b/internal/email/client.go
index da864884..7136e2a4 100644
--- a/internal/email/client.go
+++ b/internal/email/client.go
@@ -12,82 +12,55 @@ import (
"net/http"
"net/mail"
"net/smtp"
+ "net/url"
"strings"
"time"
)
const (
- defaultBrevoBaseURL = "https://api.brevo.com/v3"
- defaultTimeout = 15 * time.Second
+ defaultBrevoBaseURL = "https://api.brevo.com/v3"
+ defaultSendGridBaseURL = "https://api.sendgrid.com/v3"
+ defaultResendBaseURL = "https://api.resend.com"
+ defaultPostmarkBaseURL = "https://api.postmarkapp.com"
+ defaultMailgunBaseURL = "https://api.mailgun.net/v3"
+ defaultTimeout = 20 * time.Second
)
+// Client interface for sending emails across multiple providers.
type Client interface {
SendEmail(ctx context.Context, req SendEmailParams) error
}
-type SendEmailParams struct {
- FromEmail string
- FromName string
- ToEmail string
- ToName string
- Subject string
- BodyText string
- BodyHTML string
- InReplyTo string
-}
-
type emailClient struct {
- provider string
- apiKey string
- brevoBaseURL string
- smtpHost string
- smtpPort int
- smtpUser string
- smtpPassword string
- httpClient *http.Client
-}
-
-type ClientConfig struct {
- Provider string
- APIKey string
- BrevoBaseURL string
- SMTPHost string
- SMTPPort int
- SMTPUser string
- SMTPPassword string
- HTTPClient *http.Client
+ cfg ClientConfig
+ httpClient *http.Client
}
+// NewClient creates a new unified Email client.
func NewClient(cfg ClientConfig) Client {
- provider := strings.ToLower(strings.TrimSpace(cfg.Provider))
+ provider := DeliveryProvider(strings.ToLower(strings.TrimSpace(string(cfg.Provider))))
if provider == "" {
if cfg.APIKey != "" {
- provider = "brevo"
+ if strings.HasPrefix(cfg.APIKey, "xkeysib-") {
+ provider = ProviderBrevo
+ } else if strings.HasPrefix(cfg.APIKey, "SG.") {
+ provider = ProviderSendGrid
+ } else if strings.HasPrefix(cfg.APIKey, "re_") {
+ provider = ProviderResend
+ } else {
+ provider = ProviderBrevo
+ }
} else {
- provider = "smtp"
+ provider = ProviderSMTP
}
}
- brevoBaseURL := strings.TrimRight(strings.TrimSpace(cfg.BrevoBaseURL), "/")
- if brevoBaseURL == "" {
- brevoBaseURL = defaultBrevoBaseURL
- }
- httpClient := cfg.HTTPClient
- if httpClient == nil {
- httpClient = &http.Client{Timeout: defaultTimeout}
- }
- smtpPort := cfg.SMTPPort
- if smtpPort <= 0 {
- smtpPort = 587
+ cfg.Provider = provider
+ if cfg.SMTPPort <= 0 {
+ cfg.SMTPPort = 587
}
return &emailClient{
- provider: provider,
- apiKey: strings.TrimSpace(cfg.APIKey),
- brevoBaseURL: brevoBaseURL,
- smtpHost: strings.TrimSpace(cfg.SMTPHost),
- smtpPort: smtpPort,
- smtpUser: strings.TrimSpace(cfg.SMTPUser),
- smtpPassword: strings.TrimSpace(cfg.SMTPPassword),
- httpClient: httpClient,
+ cfg: cfg,
+ httpClient: &http.Client{Timeout: defaultTimeout},
}
}
@@ -101,18 +74,35 @@ func (c *emailClient) SendEmail(ctx context.Context, req SendEmailParams) error
req.Subject = "Support Notification"
}
- if c.provider == "brevo" || (c.apiKey != "" && c.smtpHost == "") {
+ switch c.cfg.Provider {
+ case ProviderBrevo:
return c.sendViaBrevo(ctx, req)
+ case ProviderSendGrid:
+ return c.sendViaSendGrid(ctx, req)
+ case ProviderResend:
+ return c.sendViaResend(ctx, req)
+ case ProviderPostmark:
+ return c.sendViaPostmark(ctx, req)
+ case ProviderMailgun:
+ return c.sendViaMailgun(ctx, req)
+ default:
+ return c.sendViaSMTP(ctx, req)
}
- return c.sendViaSMTP(ctx, req)
}
+// 1. Brevo v3 API
func (c *emailClient) sendViaBrevo(ctx context.Context, req SendEmailParams) error {
- url := fmt.Sprintf("%s/smtp/email", c.brevoBaseURL)
+ baseURL := strings.TrimRight(c.cfg.BaseURL, "/")
+ if baseURL == "" {
+ baseURL = defaultBrevoBaseURL
+ }
+ apiURL := fmt.Sprintf("%s/smtp/email", baseURL)
+
senderName := req.FromName
if senderName == "" {
- senderName = "Crove Desk Support"
+ senderName = "Customer Support"
}
+
payload := BrevoSendEmailRequest{
Sender: BrevoEmailContact{
Name: senderName,
@@ -127,43 +117,195 @@ func (c *emailClient) sendViaBrevo(ctx context.Context, req SendEmailParams) err
Subject: req.Subject,
TextContent: req.BodyText,
HTMLContent: req.BodyHTML,
+ Headers: req.Headers,
+ }
+ if req.ReplyTo != "" {
+ payload.ReplyTo = &BrevoEmailContact{Email: req.ReplyTo}
}
if payload.HTMLContent == "" && payload.TextContent != "" {
- payload.HTMLContent = fmt.Sprintf("%s
", strings.ReplaceAll(payload.TextContent, "\n", "
"))
+ payload.HTMLContent = formatHTMLParagraphs(payload.TextContent)
}
- bodyBytes, err := json.Marshal(payload)
- if err != nil {
- return fmt.Errorf("failed to marshal brevo request: %w", err)
+ return c.postJSON(ctx, apiURL, payload, map[string]string{
+ "api-key": c.cfg.APIKey,
+ })
+}
+
+// 2. SendGrid v3 API
+func (c *emailClient) sendViaSendGrid(ctx context.Context, req SendEmailParams) error {
+ baseURL := strings.TrimRight(c.cfg.BaseURL, "/")
+ if baseURL == "" {
+ baseURL = defaultSendGridBaseURL
+ }
+ apiURL := fmt.Sprintf("%s/mail/send", baseURL)
+
+ payload := SendGridSendEmailRequest{
+ Personalizations: []SendGridPersonalization{
+ {
+ To: []SendGridContact{{Email: req.ToEmail, Name: req.ToName}},
+ },
+ },
+ From: SendGridContact{Email: req.FromEmail, Name: req.FromName},
+ Subject: req.Subject,
+ Headers: req.Headers,
+ }
+ if req.ReplyTo != "" {
+ payload.ReplyTo = &SendGridContact{Email: req.ReplyTo}
+ }
+ if req.BodyText != "" {
+ payload.Content = append(payload.Content, SendGridContent{Type: "text/plain", Value: req.BodyText})
+ }
+ if req.BodyHTML != "" {
+ payload.Content = append(payload.Content, SendGridContent{Type: "text/html", Value: req.BodyHTML})
+ } else if req.BodyText != "" {
+ payload.Content = append(payload.Content, SendGridContent{Type: "text/html", Value: formatHTMLParagraphs(req.BodyText)})
+ }
+
+ return c.postJSON(ctx, apiURL, payload, map[string]string{
+ "Authorization": fmt.Sprintf("Bearer %s", c.cfg.APIKey),
+ })
+}
+
+// 3. Resend API
+func (c *emailClient) sendViaResend(ctx context.Context, req SendEmailParams) error {
+ baseURL := strings.TrimRight(c.cfg.BaseURL, "/")
+ if baseURL == "" {
+ baseURL = defaultResendBaseURL
+ }
+ apiURL := fmt.Sprintf("%s/emails", baseURL)
+
+ fromHeader := req.FromEmail
+ if req.FromName != "" {
+ fromHeader = fmt.Sprintf("%s <%s>", req.FromName, req.FromEmail)
+ }
+
+ payload := ResendSendEmailRequest{
+ From: fromHeader,
+ To: []string{req.ToEmail},
+ ReplyTo: req.ReplyTo,
+ Subject: req.Subject,
+ Text: req.BodyText,
+ HTML: req.BodyHTML,
+ Headers: req.Headers,
+ }
+ if payload.HTML == "" && payload.Text != "" {
+ payload.HTML = formatHTMLParagraphs(payload.Text)
+ }
+
+ return c.postJSON(ctx, apiURL, payload, map[string]string{
+ "Authorization": fmt.Sprintf("Bearer %s", c.cfg.APIKey),
+ })
+}
+
+// 4. Postmark API
+func (c *emailClient) sendViaPostmark(ctx context.Context, req SendEmailParams) error {
+ baseURL := strings.TrimRight(c.cfg.BaseURL, "/")
+ if baseURL == "" {
+ baseURL = defaultPostmarkBaseURL
+ }
+ apiURL := fmt.Sprintf("%s/email", baseURL)
+
+ fromHeader := req.FromEmail
+ if req.FromName != "" {
+ fromHeader = fmt.Sprintf("%s <%s>", req.FromName, req.FromEmail)
+ }
+
+ var headers []PostmarkHeader
+ for k, v := range req.Headers {
+ headers = append(headers, PostmarkHeader{Name: k, Value: v})
+ }
+
+ payload := PostmarkSendEmailRequest{
+ From: fromHeader,
+ To: req.ToEmail,
+ ReplyTo: req.ReplyTo,
+ Subject: req.Subject,
+ TextBody: req.BodyText,
+ HtmlBody: req.BodyHTML,
+ Headers: headers,
}
+ if payload.HtmlBody == "" && payload.TextBody != "" {
+ payload.HtmlBody = formatHTMLParagraphs(payload.TextBody)
+ }
+
+ return c.postJSON(ctx, apiURL, payload, map[string]string{
+ "X-Postmark-Server-Token": c.cfg.APIKey,
+ })
+}
+
+// 5. Mailgun Messages API
+func (c *emailClient) sendViaMailgun(ctx context.Context, req SendEmailParams) error {
+ baseURL := strings.TrimRight(c.cfg.BaseURL, "/")
+ if baseURL == "" {
+ baseURL = defaultMailgunBaseURL
+ }
+ domain := c.cfg.Domain
+ if domain == "" {
+ parts := strings.Split(req.FromEmail, "@")
+ if len(parts) == 2 {
+ domain = parts[1]
+ }
+ }
+ apiURL := fmt.Sprintf("%s/%s/messages", baseURL, domain)
- httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyBytes))
+ fromHeader := req.FromEmail
+ if req.FromName != "" {
+ fromHeader = fmt.Sprintf("%s <%s>", req.FromName, req.FromEmail)
+ }
+
+ form := url.Values{}
+ form.Set("from", fromHeader)
+ form.Set("to", req.ToEmail)
+ form.Set("subject", req.Subject)
+ if req.BodyText != "" {
+ form.Set("text", req.BodyText)
+ }
+ if req.BodyHTML != "" {
+ form.Set("html", req.BodyHTML)
+ } else if req.BodyText != "" {
+ form.Set("html", formatHTMLParagraphs(req.BodyText))
+ }
+ if req.ReplyTo != "" {
+ form.Set("h:Reply-To", req.ReplyTo)
+ }
+ if req.InReplyTo != "" {
+ form.Set("h:In-Reply-To", req.InReplyTo)
+ }
+ if req.References != "" {
+ form.Set("h:References", req.References)
+ }
+ for k, v := range req.Headers {
+ form.Set(fmt.Sprintf("h:%s", k), v)
+ }
+
+ httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, strings.NewReader(form.Encode()))
if err != nil {
- return fmt.Errorf("failed to create http request: %w", err)
+ return fmt.Errorf("create mailgun request failed: %w", err)
}
- httpReq.Header.Set("Content-Type", "application/json")
- httpReq.Header.Set("api-key", c.apiKey)
+ httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ httpReq.SetBasicAuth("api", c.cfg.APIKey)
resp, err := c.httpClient.Do(httpReq)
if err != nil {
- return fmt.Errorf("brevo request failed: %w", err)
+ return fmt.Errorf("mailgun request failed: %w", err)
}
defer resp.Body.Close()
- respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
- return fmt.Errorf("brevo api error (status %d): %s", resp.StatusCode, string(respBody))
+ body, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("mailgun api error (status %d): %s", resp.StatusCode, string(body))
}
- slog.Info("email successfully sent via brevo", "to", req.ToEmail, "subject", req.Subject)
+ slog.Info("email sent via mailgun", "to", req.ToEmail, "subject", req.Subject)
return nil
}
+// 6. Standard SMTP (AWS SES, Postmark SMTP, SendGrid SMTP, Brevo SMTP, custom Postfix)
func (c *emailClient) sendViaSMTP(ctx context.Context, req SendEmailParams) error {
- if c.smtpHost == "" {
+ if c.cfg.SMTPHost == "" {
return fmt.Errorf("smtp host is not configured")
}
- addr := fmt.Sprintf("%s:%d", c.smtpHost, c.smtpPort)
+ addr := fmt.Sprintf("%s:%d", c.cfg.SMTPHost, c.cfg.SMTPPort)
fromHeader := req.FromEmail
if req.FromName != "" {
fromHeader = fmt.Sprintf("%s <%s>", req.FromName, req.FromEmail)
@@ -174,9 +316,20 @@ func (c *emailClient) sendViaSMTP(ctx context.Context, req SendEmailParams) erro
header["To"] = req.ToEmail
header["Subject"] = req.Subject
header["MIME-Version"] = "1.0"
+ if req.ReplyTo != "" {
+ header["Reply-To"] = req.ReplyTo
+ }
+ if req.MessageID != "" {
+ header["Message-ID"] = req.MessageID
+ }
if req.InReplyTo != "" {
header["In-Reply-To"] = req.InReplyTo
- header["References"] = req.InReplyTo
+ }
+ if req.References != "" {
+ header["References"] = req.References
+ }
+ for k, v := range req.Headers {
+ header[k] = v
}
contentType := "text/plain; charset=UTF-8"
@@ -195,24 +348,23 @@ func (c *emailClient) sendViaSMTP(ctx context.Context, req SendEmailParams) erro
msg.WriteString(body)
var auth smtp.Auth
- if c.smtpUser != "" && c.smtpPassword != "" {
- auth = smtp.PlainAuth("", c.smtpUser, c.smtpPassword, c.smtpHost)
+ if c.cfg.SMTPUser != "" && c.cfg.SMTPPassword != "" {
+ auth = smtp.PlainAuth("", c.cfg.SMTPUser, c.cfg.SMTPPassword, c.cfg.SMTPHost)
}
- // Dial with timeout and TLS support
tlsConfig := &tls.Config{
- ServerName: c.smtpHost,
+ ServerName: c.cfg.SMTPHost,
}
var client *smtp.Client
var err error
- if c.smtpPort == 465 {
+ if c.cfg.SMTPPort == 465 || c.cfg.SMTPUseTLS {
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: defaultTimeout}, "tcp", addr, tlsConfig)
if err != nil {
return fmt.Errorf("failed to connect via tls: %w", err)
}
- client, err = smtp.NewClient(conn, c.smtpHost)
+ client, err = smtp.NewClient(conn, c.cfg.SMTPHost)
if err != nil {
return fmt.Errorf("failed to create smtp client: %w", err)
}
@@ -221,7 +373,7 @@ func (c *emailClient) sendViaSMTP(ctx context.Context, req SendEmailParams) erro
if err != nil {
return fmt.Errorf("failed to dial smtp: %w", err)
}
- client, err = smtp.NewClient(conn, c.smtpHost)
+ client, err = smtp.NewClient(conn, c.cfg.SMTPHost)
if err != nil {
return fmt.Errorf("failed to create smtp client: %w", err)
}
@@ -261,11 +413,44 @@ func (c *emailClient) sendViaSMTP(ctx context.Context, req SendEmailParams) erro
return fmt.Errorf("failed to close email writer: %w", err)
}
- slog.Info("email successfully sent via smtp", "to", req.ToEmail, "subject", req.Subject)
+ slog.Info("email sent via smtp", "to", req.ToEmail, "subject", req.Subject)
+ return nil
+}
+
+func (c *emailClient) postJSON(ctx context.Context, apiURL string, payload any, headers map[string]string) error {
+ bodyBytes, err := json.Marshal(payload)
+ if err != nil {
+ return fmt.Errorf("failed to marshal json payload: %w", err)
+ }
+
+ httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(bodyBytes))
+ if err != nil {
+ return fmt.Errorf("failed to create http request: %w", err)
+ }
+ httpReq.Header.Set("Content-Type", "application/json")
+ for k, v := range headers {
+ httpReq.Header.Set(k, v)
+ }
+
+ resp, err := c.httpClient.Do(httpReq)
+ if err != nil {
+ return fmt.Errorf("email api request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ respBody, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("email api error (status %d): %s", resp.StatusCode, string(respBody))
+ }
return nil
}
-// ParseAddress parses a raw email string like "John Doe " into email and name.
+func formatHTMLParagraphs(text string) string {
+ escaped := strings.ReplaceAll(text, "\n", "
")
+ return fmt.Sprintf("%s
", escaped)
+}
+
+// ParseAddress parses a raw email address string like "Support Team " into email and display name.
func ParseAddress(raw string) (emailStr string, nameStr string) {
raw = strings.TrimSpace(raw)
if raw == "" {
diff --git a/internal/email/client_test.go b/internal/email/client_test.go
index 2625d04b..e08985d7 100644
--- a/internal/email/client_test.go
+++ b/internal/email/client_test.go
@@ -4,6 +4,7 @@ import (
"context"
"net/http"
"net/http/httptest"
+ "net/url"
"testing"
)
@@ -28,47 +29,144 @@ func TestParseAddress(t *testing.T) {
}
}
-func TestBrevoSendEmail(t *testing.T) {
- var receivedBody string
- var receivedAPIKey string
+func TestParseInboundWebhook_Formats(t *testing.T) {
+ // 1. Generic / Cloudflare format
+ genericJSON := []byte(`{
+ "from": "user@example.com",
+ "from_name": "Test User",
+ "to": "help@crove.com",
+ "subject": "Hello Support",
+ "text": "Please help with login.",
+ "message_id": ""
+ }`)
+
+ items, err := ParseInboundWebhook("application/json", genericJSON, nil)
+ if err != nil {
+ t.Fatalf("ParseInboundWebhook generic failed: %v", err)
+ }
+ if len(items) != 1 || items[0].FromEmail != "user@example.com" || items[0].ToEmail != "help@crove.com" {
+ t.Errorf("unexpected generic parsed output: %+v", items)
+ }
+
+ // 2. Brevo format
+ brevoJSON := []byte(`{
+ "items": [
+ {
+ "Sender": "Alice ",
+ "Recipient": "support@crove.com",
+ "Subject": "Brevo Inquiry",
+ "RawTextBody": "Brevo message text"
+ }
+ ]
+ }`)
+ items, err = ParseInboundWebhook("application/json", brevoJSON, nil)
+ if err != nil {
+ t.Fatalf("ParseInboundWebhook brevo failed: %v", err)
+ }
+ if len(items) != 1 || items[0].FromEmail != "alice@test.com" || items[0].FromName != "Alice" {
+ t.Errorf("unexpected brevo parsed output: %+v", items)
+ }
+
+ // 3. Postmark format
+ postmarkJSON := []byte(`{
+ "From": "bob@domain.org",
+ "FromName": "Bob Developer",
+ "To": "help@crove.com",
+ "Subject": "Postmark Question",
+ "TextBody": "Text from Postmark",
+ "MessageID": "pm-12345",
+ "Headers": [
+ {"Name": "In-Reply-To", "Value": ""}
+ ]
+ }`)
+ items, err = ParseInboundWebhook("application/json", postmarkJSON, nil)
+ if err != nil {
+ t.Fatalf("ParseInboundWebhook postmark failed: %v", err)
+ }
+ if len(items) != 1 || items[0].FromEmail != "bob@domain.org" || items[0].InReplyTo != "" {
+ t.Errorf("unexpected postmark parsed output: %+v", items)
+ }
+
+ // 4. Mailgun form data
+ mgForm := url.Values{}
+ mgForm.Set("sender", "developer@client.com")
+ mgForm.Set("from", "Dev ")
+ mgForm.Set("recipient", "help@crove.com")
+ mgForm.Set("subject", "Mailgun Support")
+ mgForm.Set("body-plain", "Plain body from mailgun")
+ mgForm.Set("In-Reply-To", "")
+
+ items, err = ParseInboundWebhook("application/x-www-form-urlencoded", nil, mgForm)
+ if err != nil {
+ t.Fatalf("ParseInboundWebhook mailgun failed: %v", err)
+ }
+ if len(items) != 1 || items[0].FromEmail != "developer@client.com" || items[0].InReplyTo != "" {
+ t.Errorf("unexpected mailgun parsed output: %+v", items)
+ }
+}
+
+func TestSendGridSendEmail(t *testing.T) {
+ var receivedAuth string
+ var receivedPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- receivedAPIKey = r.Header.Get("api-key")
- buf := make([]byte, 1024)
- n, _ := r.Body.Read(buf)
- receivedBody = string(buf[:n])
-
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(http.StatusCreated)
- w.Write([]byte(`{"messageId":"<12345@smtp-relay.brevo.com>"}`))
+ receivedAuth = r.Header.Get("Authorization")
+ receivedPath = r.URL.Path
+ w.WriteHeader(http.StatusAccepted)
}))
defer server.Close()
client := NewClient(ClientConfig{
- Provider: "brevo",
- APIKey: "test-key",
- BrevoBaseURL: server.URL,
- HTTPClient: server.Client(),
+ Provider: ProviderSendGrid,
+ APIKey: "SG.test-key",
+ BaseURL: server.URL,
})
err := client.SendEmail(context.Background(), SendEmailParams{
- FromEmail: "help@crove.com",
- FromName: "Crove Desk Support",
+ FromEmail: "support@crove.com",
ToEmail: "user@example.com",
- ToName: "User",
- Subject: "Ticket Confirmation",
- BodyText: "Thank you for reaching out.",
+ Subject: "SendGrid Test",
+ BodyText: "Hello via SendGrid",
})
if err != nil {
- t.Fatalf("expected no error, got: %v", err)
+ t.Fatalf("SendEmail SendGrid failed: %v", err)
}
-
- if receivedAPIKey != "test-key" {
- t.Errorf("expected api-key 'test-key', got: %s", receivedAPIKey)
+ if receivedAuth != "Bearer SG.test-key" {
+ t.Errorf("expected Bearer SG.test-key, got %s", receivedAuth)
+ }
+ if receivedPath != "/mail/send" {
+ t.Errorf("expected /mail/send, got %s", receivedPath)
}
+}
+
+func TestResendSendEmail(t *testing.T) {
+ var receivedAuth string
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ receivedAuth = r.Header.Get("Authorization")
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(`{"id":"resend-123"}`))
+ }))
+ defer server.Close()
- if len(receivedBody) == 0 {
- t.Error("expected non-empty request body")
+ client := NewClient(ClientConfig{
+ Provider: ProviderResend,
+ APIKey: "re_test_123",
+ BaseURL: server.URL,
+ })
+
+ err := client.SendEmail(context.Background(), SendEmailParams{
+ FromEmail: "support@crove.com",
+ ToEmail: "user@example.com",
+ Subject: "Resend Test",
+ BodyText: "Hello via Resend",
+ })
+
+ if err != nil {
+ t.Fatalf("SendEmail Resend failed: %v", err)
+ }
+ if receivedAuth != "Bearer re_test_123" {
+ t.Errorf("expected Bearer re_test_123, got %s", receivedAuth)
}
}
diff --git a/internal/email/inbound_parser.go b/internal/email/inbound_parser.go
new file mode 100644
index 00000000..147935ce
--- /dev/null
+++ b/internal/email/inbound_parser.go
@@ -0,0 +1,172 @@
+package email
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/url"
+ "strings"
+)
+
+// ParseInboundWebhook parses raw webhook payload from various email providers into a slice of normalized InboundEmailPayloads.
+func ParseInboundWebhook(contentType string, rawBody []byte, form url.Values) ([]InboundEmailPayload, error) {
+ contentType = strings.ToLower(contentType)
+
+ // 1. If form data provided (e.g. SendGrid Inbound Parse or Mailgun webhook)
+ if len(form) > 0 {
+ // Mailgun format check
+ if form.Get("sender") != "" || form.Get("recipient") != "" {
+ fromEmail, fromName := ParseAddress(form.Get("from"))
+ if fromEmail == "" {
+ fromEmail, fromName = ParseAddress(form.Get("sender"))
+ }
+ toEmail, toName := ParseAddress(form.Get("recipient"))
+ if toEmail == "" {
+ toEmail, toName = ParseAddress(form.Get("To"))
+ }
+ bodyText := form.Get("body-plain")
+ if bodyText == "" {
+ bodyText = form.Get("stripped-text")
+ }
+ bodyHTML := form.Get("body-html")
+ if bodyHTML == "" {
+ bodyHTML = form.Get("stripped-html")
+ }
+
+ return []InboundEmailPayload{
+ {
+ FromEmail: fromEmail,
+ FromName: fromName,
+ ToEmail: toEmail,
+ ToName: toName,
+ Subject: strings.TrimSpace(form.Get("subject")),
+ BodyText: strings.TrimSpace(bodyText),
+ BodyHTML: strings.TrimSpace(bodyHTML),
+ MessageID: strings.TrimSpace(form.Get("Message-Id")),
+ InReplyTo: strings.TrimSpace(form.Get("In-Reply-To")),
+ References: strings.TrimSpace(form.Get("References")),
+ },
+ }, nil
+ }
+
+ // SendGrid format check
+ if form.Get("from") != "" || form.Get("to") != "" {
+ fromEmail, fromName := ParseAddress(form.Get("from"))
+ toEmail, toName := ParseAddress(form.Get("to"))
+ return []InboundEmailPayload{
+ {
+ FromEmail: fromEmail,
+ FromName: fromName,
+ ToEmail: toEmail,
+ ToName: toName,
+ Subject: strings.TrimSpace(form.Get("subject")),
+ BodyText: strings.TrimSpace(form.Get("text")),
+ BodyHTML: strings.TrimSpace(form.Get("html")),
+ },
+ }, nil
+ }
+ }
+
+ rawStr := strings.TrimSpace(string(rawBody))
+ if rawStr == "" {
+ return nil, nil
+ }
+
+ // 2. Try Brevo webhook format
+ var brevoWebhook BrevoInboundWebhook
+ if err := json.Unmarshal(rawBody, &brevoWebhook); err == nil && len(brevoWebhook.Items) > 0 {
+ var results []InboundEmailPayload
+ for _, item := range brevoWebhook.Items {
+ fromEmail, fromName := ParseAddress(item.Sender)
+ toEmail, toName := ParseAddress(item.Recipient)
+ msgID := ""
+ if len(item.UUID) > 0 {
+ msgID = item.UUID[0]
+ }
+ results = append(results, InboundEmailPayload{
+ FromEmail: fromEmail,
+ FromName: fromName,
+ ToEmail: toEmail,
+ ToName: toName,
+ Subject: strings.TrimSpace(item.Subject),
+ BodyText: strings.TrimSpace(item.RawTextBody),
+ BodyHTML: strings.TrimSpace(item.RawHTMLBody),
+ MessageID: msgID,
+ Headers: item.Headers,
+ })
+ }
+ return results, nil
+ }
+
+ // 3. Try Postmark Inbound Webhook format
+ var postmarkWebhook PostmarkInboundWebhook
+ if err := json.Unmarshal(rawBody, &postmarkWebhook); err == nil && (postmarkWebhook.From != "" || postmarkWebhook.Subject != "") {
+ fromEmail, fromName := ParseAddress(postmarkWebhook.From)
+ if postmarkWebhook.FromName != "" {
+ fromName = postmarkWebhook.FromName
+ }
+ toEmail, toName := ParseAddress(postmarkWebhook.To)
+ headersMap := make(map[string]string)
+ inReplyTo := ""
+ references := ""
+ for _, h := range postmarkWebhook.Headers {
+ headersMap[h.Name] = h.Value
+ if strings.EqualFold(h.Name, "In-Reply-To") {
+ inReplyTo = h.Value
+ }
+ if strings.EqualFold(h.Name, "References") {
+ references = h.Value
+ }
+ }
+
+ return []InboundEmailPayload{
+ {
+ FromEmail: fromEmail,
+ FromName: fromName,
+ ToEmail: toEmail,
+ ToName: toName,
+ Subject: strings.TrimSpace(postmarkWebhook.Subject),
+ BodyText: strings.TrimSpace(postmarkWebhook.TextBody),
+ BodyHTML: strings.TrimSpace(postmarkWebhook.HtmlBody),
+ MessageID: strings.TrimSpace(postmarkWebhook.MessageID),
+ InReplyTo: inReplyTo,
+ References: references,
+ Headers: headersMap,
+ },
+ }, nil
+ }
+
+ // 4. Try Standard Generic / Cloudflare Email Routing format
+ var generic GenericInboundWebhook
+ if err := json.Unmarshal(rawBody, &generic); err == nil && generic.From != "" {
+ fromEmail, fromName := ParseAddress(generic.From)
+ if generic.FromName != "" {
+ fromName = generic.FromName
+ }
+ toEmail, toName := ParseAddress(generic.To)
+ if generic.ToName != "" {
+ toName = generic.ToName
+ }
+ body := generic.Text
+ if body == "" {
+ body = generic.Body
+ }
+
+ return []InboundEmailPayload{
+ {
+ FromEmail: fromEmail,
+ FromName: fromName,
+ ToEmail: toEmail,
+ ToName: toName,
+ Subject: strings.TrimSpace(generic.Subject),
+ BodyText: strings.TrimSpace(body),
+ BodyHTML: strings.TrimSpace(generic.HTML),
+ MessageID: strings.TrimSpace(generic.MessageID),
+ InReplyTo: strings.TrimSpace(generic.InReplyTo),
+ References: strings.TrimSpace(generic.References),
+ Headers: generic.Headers,
+ },
+ }, nil
+ }
+
+ return nil, fmt.Errorf("unrecognized email webhook format")
+}
diff --git a/internal/email/types.go b/internal/email/types.go
index 858e5ef3..b89030cd 100644
--- a/internal/email/types.go
+++ b/internal/email/types.go
@@ -1,13 +1,81 @@
package email
-// BrevoSendEmailRequest represents payload to Brevo SMTP email API.
+// DeliveryProvider identifies email sending service.
+type DeliveryProvider string
+
+const (
+ ProviderSMTP DeliveryProvider = "smtp"
+ ProviderBrevo DeliveryProvider = "brevo"
+ ProviderSendGrid DeliveryProvider = "sendgrid"
+ ProviderResend DeliveryProvider = "resend"
+ ProviderPostmark DeliveryProvider = "postmark"
+ ProviderMailgun DeliveryProvider = "mailgun"
+)
+
+// SendEmailParams defines parameters for sending an email.
+type SendEmailParams struct {
+ FromEmail string `json:"fromEmail"`
+ FromName string `json:"fromName,omitempty"`
+ ToEmail string `json:"toEmail"`
+ ToName string `json:"toName,omitempty"`
+ ReplyTo string `json:"replyTo,omitempty"`
+ Subject string `json:"subject"`
+ BodyText string `json:"bodyText"`
+ BodyHTML string `json:"bodyHtml,omitempty"`
+ MessageID string `json:"messageId,omitempty"`
+ InReplyTo string `json:"inReplyTo,omitempty"`
+ References string `json:"references,omitempty"`
+ Headers map[string]string `json:"headers,omitempty"`
+ Attachments []EmailAttachment `json:"attachments,omitempty"`
+}
+
+// EmailAttachment defines file attachments in email.
+type EmailAttachment struct {
+ Filename string `json:"filename"`
+ ContentType string `json:"contentType"`
+ ContentB64 string `json:"contentB64"`
+}
+
+// ClientConfig holds configuration for initializing Email client.
+type ClientConfig struct {
+ Provider DeliveryProvider
+ APIKey string
+ BaseURL string
+ SMTPHost string
+ SMTPPort int
+ SMTPUser string
+ SMTPPassword string
+ SMTPUseTLS bool
+ Domain string // For Mailgun (e.g. mg.example.com)
+}
+
+// InboundEmailPayload represents normalized parsed inbound email.
+type InboundEmailPayload struct {
+ FromEmail string `json:"fromEmail"`
+ FromName string `json:"fromName,omitempty"`
+ ToEmail string `json:"toEmail"`
+ ToName string `json:"toName,omitempty"`
+ Subject string `json:"subject"`
+ BodyText string `json:"bodyText"`
+ BodyHTML string `json:"bodyHtml,omitempty"`
+ MessageID string `json:"messageId,omitempty"`
+ InReplyTo string `json:"inReplyTo,omitempty"`
+ References string `json:"references,omitempty"`
+ Headers map[string]string `json:"headers,omitempty"`
+ Attachments []EmailAttachment `json:"attachments,omitempty"`
+}
+
+// --- Provider specific Inbound & Outbound Structs ---
+
+// BrevoSendEmailRequest payload for Brevo v3 API.
type BrevoSendEmailRequest struct {
Sender BrevoEmailContact `json:"sender"`
To []BrevoEmailContact `json:"to"`
+ ReplyTo *BrevoEmailContact `json:"replyTo,omitempty"`
Subject string `json:"subject"`
HTMLContent string `json:"htmlContent,omitempty"`
TextContent string `json:"textContent,omitempty"`
- ReplyTo *BrevoEmailContact `json:"replyTo,omitempty"`
+ Headers map[string]string `json:"headers,omitempty"`
}
type BrevoEmailContact struct {
@@ -15,49 +83,111 @@ type BrevoEmailContact struct {
Email string `json:"email"`
}
-// BrevoSendEmailResponse represents Brevo API response.
-type BrevoSendEmailResponse struct {
- MessageID string `json:"messageId,omitempty"`
- Code string `json:"code,omitempty"`
- Message string `json:"message,omitempty"`
+// SendGridSendEmailRequest payload for SendGrid v3 API.
+type SendGridSendEmailRequest struct {
+ Personalizations []SendGridPersonalization `json:"personalizations"`
+ From SendGridContact `json:"from"`
+ ReplyTo *SendGridContact `json:"reply_to,omitempty"`
+ Subject string `json:"subject"`
+ Content []SendGridContent `json:"content"`
+ Headers map[string]string `json:"headers,omitempty"`
}
-// InboundEmailPayload represents normalized parsed inbound email.
-type InboundEmailPayload struct {
- FromEmail string `json:"fromEmail"`
- FromName string `json:"fromName,omitempty"`
- ToEmail string `json:"toEmail"`
- Subject string `json:"subject"`
- BodyText string `json:"bodyText"`
- BodyHTML string `json:"bodyHtml,omitempty"`
- MessageID string `json:"messageId,omitempty"`
- InReplyTo string `json:"inReplyTo,omitempty"`
+type SendGridPersonalization struct {
+ To []SendGridContact `json:"to"`
+ Subject string `json:"subject,omitempty"`
}
-// GenericInboundWebhook represents standard webhook JSON format.
+type SendGridContact struct {
+ Email string `json:"email"`
+ Name string `json:"name,omitempty"`
+}
+
+type SendGridContent struct {
+ Type string `json:"type"`
+ Value string `json:"value"`
+}
+
+// ResendSendEmailRequest payload for Resend API.
+type ResendSendEmailRequest struct {
+ From string `json:"from"`
+ To []string `json:"to"`
+ ReplyTo string `json:"reply_to,omitempty"`
+ Subject string `json:"subject"`
+ Text string `json:"text,omitempty"`
+ HTML string `json:"html,omitempty"`
+ Headers map[string]string `json:"headers,omitempty"`
+ Attachments []ResendAttachment`json:"attachments,omitempty"`
+}
+
+type ResendAttachment struct {
+ Filename string `json:"filename"`
+ Content string `json:"content"`
+}
+
+// PostmarkSendEmailRequest payload for Postmark API.
+type PostmarkSendEmailRequest struct {
+ From string `json:"From"`
+ To string `json:"To"`
+ ReplyTo string `json:"ReplyTo,omitempty"`
+ Subject string `json:"Subject"`
+ TextBody string `json:"TextBody,omitempty"`
+ HtmlBody string `json:"HtmlBody,omitempty"`
+ Headers []PostmarkHeader `json:"Headers,omitempty"`
+ Attachments []PostmarkAttach `json:"Attachments,omitempty"`
+}
+
+type PostmarkHeader struct {
+ Name string `json:"Name"`
+ Value string `json:"Value"`
+}
+
+type PostmarkAttach struct {
+ Name string `json:"Name"`
+ Content string `json:"Content"`
+ ContentType string `json:"ContentType"`
+}
+
+// GenericInboundWebhook represents standard webhook JSON format (Cloudflare Email Routing, AWS SES, Generic).
type GenericInboundWebhook struct {
- From string `json:"from"`
- FromName string `json:"from_name,omitempty"`
- To string `json:"to"`
- Subject string `json:"subject"`
- Text string `json:"text,omitempty"`
- HTML string `json:"html,omitempty"`
- Body string `json:"body,omitempty"`
- MessageID string `json:"message_id,omitempty"`
- InReplyTo string `json:"in_reply_to,omitempty"`
+ From string `json:"from"`
+ FromName string `json:"from_name,omitempty"`
+ To string `json:"to"`
+ ToName string `json:"to_name,omitempty"`
+ Subject string `json:"subject"`
+ Text string `json:"text,omitempty"`
+ HTML string `json:"html,omitempty"`
+ Body string `json:"body,omitempty"`
+ MessageID string `json:"message_id,omitempty"`
+ InReplyTo string `json:"in_reply_to,omitempty"`
+ References string `json:"references,omitempty"`
+ Headers map[string]string `json:"headers,omitempty"`
}
// BrevoInboundItem represents an item in Brevo inbound webhook.
type BrevoInboundItem struct {
- UUID []string `json:"Uuid,omitempty"`
- Sender string `json:"Sender,omitempty"`
- Recipient string `json:"Recipient,omitempty"`
- Subject string `json:"Subject,omitempty"`
- RawHTMLBody string `json:"RawHtmlBody,omitempty"`
- RawTextBody string `json:"RawTextBody,omitempty"`
+ UUID []string `json:"Uuid,omitempty"`
+ Sender string `json:"Sender,omitempty"`
+ Recipient string `json:"Recipient,omitempty"`
+ Subject string `json:"Subject,omitempty"`
+ RawHTMLBody string `json:"RawHtmlBody,omitempty"`
+ RawTextBody string `json:"RawTextBody,omitempty"`
+ Headers map[string]string `json:"Headers,omitempty"`
}
-// BrevoInboundWebhook represents Brevo inbound event payload.
type BrevoInboundWebhook struct {
Items []BrevoInboundItem `json:"items,omitempty"`
}
+
+// PostmarkInboundWebhook represents Postmark inbound email payload.
+type PostmarkInboundWebhook struct {
+ From string `json:"From"`
+ FromName string `json:"FromName,omitempty"`
+ To string `json:"To"`
+ Subject string `json:"Subject"`
+ TextBody string `json:"TextBody,omitempty"`
+ HtmlBody string `json:"HtmlBody,omitempty"`
+ MessageID string `json:"MessageID,omitempty"`
+ MailboxHash string `json:"MailboxHash,omitempty"`
+ Headers []PostmarkHeader `json:"Headers,omitempty"`
+}
diff --git a/internal/handlers/third/email_handler.go b/internal/handlers/third/email_handler.go
index 529c60c8..9cbe8ee9 100644
--- a/internal/handlers/third/email_handler.go
+++ b/internal/handlers/third/email_handler.go
@@ -4,6 +4,7 @@ import (
"bytes"
"io"
"net/http"
+ "net/url"
"strings"
"agent-desk/internal/services"
@@ -11,7 +12,7 @@ import (
"github.com/gin-gonic/gin"
)
-// EmailPostWebhook receives incoming inbound email webhook events from Brevo, SendGrid, Postmark or SMTP forwarders.
+// EmailPostWebhook receives incoming inbound email webhook events from Cloudflare, Brevo, SendGrid, Postmark, Mailgun, or SMTP forwarders.
func EmailPostWebhook(ctx *gin.Context) {
channelID := strings.TrimSpace(ctx.Param("channel_id"))
if channelID == "" {
@@ -22,18 +23,34 @@ func EmailPostWebhook(ctx *gin.Context) {
if secretHeader == "" {
secretHeader = ctx.GetHeader("X-Brevo-Webhook-Secret")
}
+ if secretHeader == "" {
+ secretHeader = ctx.GetHeader("X-Postmark-Webhook-Secret")
+ }
if secretHeader == "" {
secretHeader = ctx.Query("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
+ contentType := ctx.GetHeader("Content-Type")
+
+ var formValues url.Values
+ var bodyBytes []byte
+
+ if strings.Contains(strings.ToLower(contentType), "multipart/form-data") {
+ if err := ctx.Request.ParseMultipartForm(32 << 20); err == nil && ctx.Request.MultipartForm != nil {
+ formValues = ctx.Request.MultipartForm.Value
+ }
+ } else if strings.Contains(strings.ToLower(contentType), "application/x-www-form-urlencoded") {
+ if err := ctx.Request.ParseForm(); err == nil {
+ formValues = ctx.Request.PostForm
+ }
+ }
+
+ if ctx.Request.Body != nil {
+ bodyBytes, _ = io.ReadAll(ctx.Request.Body)
+ ctx.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
}
- ctx.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
- if err := services.EmailInboundService.HandleWebhook(ctx.Request.Context(), channelID, secretHeader, bodyBytes); err != nil {
+ if err := services.EmailInboundService.HandleWebhook(ctx.Request.Context(), channelID, secretHeader, contentType, bodyBytes, formValues); err != nil {
ctx.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()})
return
}
diff --git a/internal/handlers/third/email_handler_test.go b/internal/handlers/third/email_handler_test.go
index 0b999449..d91c1db5 100644
--- a/internal/handlers/third/email_handler_test.go
+++ b/internal/handlers/third/email_handler_test.go
@@ -5,6 +5,8 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
+ "net/url"
+ "strings"
"testing"
"time"
@@ -19,6 +21,22 @@ import (
"github.com/mlogclub/simple/sqls"
)
+func TestEmailPostWebhook_InvalidJSON(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ router := gin.New()
+ router.POST("/webhook", EmailPostWebhook)
+
+ req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewBufferString("invalid-json"))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected status 200, got %d", w.Code)
+ }
+}
+
func TestEmailPostWebhook_FullFlow(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupThirdHandlerTestDB(t)
@@ -85,7 +103,7 @@ func TestEmailPostWebhook_FullFlow(t *testing.T) {
t.Fatalf("expected ok: false on wrong secret token, got: %v", resp)
}
- // 2. Test successful processing with Generic payload
+ // 2. Test successful processing with Generic / Cloudflare payload
req2, _ := http.NewRequest(http.MethodPost, "/api/third/email/webhook/"+channel.ChannelID, bytes.NewBuffer(genericPayload))
req2.Header.Set("Content-Type", "application/json")
req2.Header.Set("X-Webhook-Secret", "email_secret_token_123")
@@ -119,44 +137,93 @@ func TestEmailPostWebhook_FullFlow(t *testing.T) {
if len(conversations) == 0 {
t.Fatalf("expected conversation to be created")
}
+ convID := conversations[0].ID
// Verify Message was saved
messages := repositories.MessageRepository.Find(sqls.DB(), sqls.NewCnd().
- Eq("conversation_id", conversations[0].ID))
+ Eq("conversation_id", convID))
if len(messages) == 0 {
t.Fatalf("expected message to be stored")
}
- // 3. Test Brevo Inbound payload format
- brevoPayload := []byte(`{
- "items": [
- {
- "Uuid": ["brevo-uuid-999"],
- "Sender": "Bob Smith ",
- "Recipient": "help@crove.com",
- "Subject": "Enterprise Inquiry",
- "RawTextBody": "We would like to request enterprise support pricing."
- }
- ]
+ // 3. Test Threading: Send reply with ticket ID in subject
+ threadedPayload := []byte(strings.ReplaceAll(`{
+ "from": "alice@customer.com",
+ "to": "help@crove.com",
+ "subject": "Re: [#CONV_ID] Need help with Crove Desk",
+ "text": "Thanks! Here is additional information.",
+ "message_id": "",
+ "in_reply_to": ""
+ }`, "CONV_ID", string(rune('0'+convID))))
+
+ reqThread, _ := http.NewRequest(http.MethodPost, "/api/third/email/webhook", bytes.NewBuffer(threadedPayload))
+ reqThread.Header.Set("Content-Type", "application/json")
+ reqThread.Header.Set("X-Webhook-Secret", "email_secret_token_123")
+
+ recThread := httptest.NewRecorder()
+ router.ServeHTTP(recThread, reqThread)
+
+ var respThread map[string]any
+ _ = json.Unmarshal(recThread.Body.Bytes(), &respThread)
+ if respThread["ok"] != true {
+ t.Fatalf("expected threaded ok: true, got: %v", respThread)
+ }
+
+ // Verify message was attached to existing conversation rather than creating a duplicate
+ convCount := len(repositories.ConversationRepository.Find(sqls.DB(), sqls.NewCnd().Eq("customer_id", customer.ID)))
+ if convCount != 1 {
+ t.Fatalf("expected conversation count to remain 1, got %d", convCount)
+ }
+
+ // 4. Test Postmark Inbound Webhook format
+ postmarkPayload := []byte(`{
+ "From": "developer@company.org",
+ "FromName": "Dev Team",
+ "To": "help@crove.com",
+ "Subject": "API Inquiry",
+ "TextBody": "How do I call MCP tools?",
+ "MessageID": "postmark-uuid-001"
}`)
- req3, _ := http.NewRequest(http.MethodPost, "/api/third/email/webhook", bytes.NewBuffer(brevoPayload))
- req3.Header.Set("Content-Type", "application/json")
- req3.Header.Set("X-Webhook-Secret", "email_secret_token_123")
+ reqPM, _ := http.NewRequest(http.MethodPost, "/api/third/email/webhook", bytes.NewBuffer(postmarkPayload))
+ reqPM.Header.Set("Content-Type", "application/json")
+ reqPM.Header.Set("X-Webhook-Secret", "email_secret_token_123")
- rec3 := httptest.NewRecorder()
- router.ServeHTTP(rec3, req3)
+ recPM := httptest.NewRecorder()
+ router.ServeHTTP(recPM, reqPM)
+
+ var respPM map[string]any
+ _ = json.Unmarshal(recPM.Body.Bytes(), &respPM)
+ if respPM["ok"] != true {
+ t.Fatalf("expected postmark format ok: true, got: %v", respPM)
+ }
- var resp3 map[string]any
- _ = json.Unmarshal(rec3.Body.Bytes(), &resp3)
- if resp3["ok"] != true {
- t.Fatalf("expected brevo format ok: true, got: %v", resp3)
+ // 5. Test Mailgun Form Data Webhook format
+ mgForm := url.Values{}
+ mgForm.Set("sender", "mailgunner@test.com")
+ mgForm.Set("from", "Mailgun User ")
+ mgForm.Set("recipient", "help@crove.com")
+ mgForm.Set("subject", "Mailgun Inbound Ticket")
+ mgForm.Set("body-plain", "Testing mailgun webhook support.")
+ mgForm.Set("Message-Id", "")
+
+ reqMG, _ := http.NewRequest(http.MethodPost, "/api/third/email/webhook", strings.NewReader(mgForm.Encode()))
+ reqMG.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ reqMG.Header.Set("X-Webhook-Secret", "email_secret_token_123")
+
+ recMG := httptest.NewRecorder()
+ router.ServeHTTP(recMG, reqMG)
+
+ var respMG map[string]any
+ _ = json.Unmarshal(recMG.Body.Bytes(), &respMG)
+ if respMG["ok"] != true {
+ t.Fatalf("expected mailgun format ok: true, got: %v", respMG)
}
- bobIdentity := repositories.CustomerIdentityRepository.FindOne(sqls.DB(), sqls.NewCnd().
+ mgIdentity := repositories.CustomerIdentityRepository.FindOne(sqls.DB(), sqls.NewCnd().
Eq("external_source", enums.ExternalSourceEmail).
- Eq("external_id", "bob@partner.org"))
- if bobIdentity == nil {
- t.Fatalf("expected customer identity for bob@partner.org")
+ Eq("external_id", "mailgunner@test.com"))
+ if mgIdentity == nil {
+ t.Fatalf("expected customer identity for mailgunner@test.com")
}
}
diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go
index 0bcbd76e..278d19a7 100644
--- a/internal/pkg/config/config.go
+++ b/internal/pkg/config/config.go
@@ -27,6 +27,7 @@ type Config struct {
OIDC OIDCConfig `yaml:"oidc"`
CustomerSession CustomerSessionConfig `yaml:"customerSession"`
Webhook WebhookConfig `yaml:"webhook"`
+ Email EmailConfig `yaml:"email"`
}
func (c Config) LanguageOrDefault() string {
@@ -259,6 +260,19 @@ type WebhookConfig struct {
OutboundURL string `yaml:"outboundUrl"`
}
+type EmailConfig struct {
+ Provider string `yaml:"provider"`
+ FromAddress string `yaml:"fromAddress"`
+ FromName string `yaml:"fromName"`
+ APIKey string `yaml:"apiKey"`
+ SMTPHost string `yaml:"smtpHost"`
+ SMTPPort int `yaml:"smtpPort"`
+ SMTPUser string `yaml:"smtpUser"`
+ SMTPPassword string `yaml:"smtpPassword"`
+ SMTPUseTLS bool `yaml:"smtpUseTls"`
+ InboundSecret string `yaml:"inboundSecret"`
+}
+
func Load(path string) (*Config, error) {
loadDotEnv(path)
@@ -351,6 +365,16 @@ func bindConfigDefaults(v *viper.Viper) {
v.SetDefault("ai.timeoutMs", 30000)
v.SetDefault("ai.maxRetryCount", 1)
v.SetDefault("mcp.enabled", true)
+ v.SetDefault("email.provider", "smtp")
+ v.SetDefault("email.fromAddress", "")
+ v.SetDefault("email.fromName", "")
+ v.SetDefault("email.apiKey", "")
+ v.SetDefault("email.smtpHost", "")
+ v.SetDefault("email.smtpPort", 587)
+ v.SetDefault("email.smtpUser", "")
+ v.SetDefault("email.smtpPassword", "")
+ v.SetDefault("email.smtpUseTls", false)
+ v.SetDefault("email.inboundSecret", "")
}
func bindEnvironmentAliases(v *viper.Viper) {
@@ -388,6 +412,16 @@ func bindEnvironmentAliases(v *viper.Viper) {
_ = v.BindEnv("webhook.orgSyncSecret", "ORG_SYNC_SECRET", "WEBHOOK_SECRET", "AGENT_DESK_WEBHOOK_ORGSYNCSECRET")
_ = v.BindEnv("webhook.outboundUrl", "ORG_SYNC_OUTBOUND_URL", "DOS_ORG_SYNC_URL", "WEBHOOK_OUTBOUND_URL", "AGENT_DESK_WEBHOOK_OUTBOUNDURL")
_ = v.BindEnv("mcp.enabled", "MCP_ENABLED", "AGENT_DESK_MCP_ENABLED")
+ _ = v.BindEnv("email.provider", "EMAIL_PROVIDER", "AGENT_DESK_EMAIL_PROVIDER")
+ _ = v.BindEnv("email.fromAddress", "EMAIL_FROM", "EMAIL_FROM_ADDRESS", "SUPPORT_EMAIL", "AGENT_DESK_EMAIL_FROMADDRESS")
+ _ = v.BindEnv("email.fromName", "EMAIL_FROM_NAME", "EMAIL_SENDER_NAME", "SUPPORT_SENDER_NAME", "AGENT_DESK_EMAIL_FROMNAME")
+ _ = v.BindEnv("email.apiKey", "EMAIL_API_KEY", "BREVO_API_KEY", "CROVE_BREVO_API_KEY", "SENDGRID_API_KEY", "RESEND_API_KEY", "POSTMARK_API_KEY", "MAILGUN_API_KEY", "AGENT_DESK_EMAIL_APIKEY")
+ _ = v.BindEnv("email.smtpHost", "SMTP_HOST", "EMAIL_SMTP_HOST", "AGENT_DESK_EMAIL_SMTPHOST")
+ _ = v.BindEnv("email.smtpPort", "SMTP_PORT", "EMAIL_SMTP_PORT", "AGENT_DESK_EMAIL_SMTPPORT")
+ _ = v.BindEnv("email.smtpUser", "SMTP_USER", "EMAIL_SMTP_USER", "CROVE_SMTP_USER", "AGENT_DESK_EMAIL_SMTPUSER")
+ _ = v.BindEnv("email.smtpPassword", "SMTP_PASSWORD", "SMTP_PASS", "EMAIL_SMTP_PASSWORD", "CROVE_SMTP_PASSWORD", "AGENT_DESK_EMAIL_SMTPPASSWORD")
+ _ = v.BindEnv("email.smtpUseTls", "SMTP_USE_TLS", "SMTP_SSL", "AGENT_DESK_EMAIL_SMTPUSETLS")
+ _ = v.BindEnv("email.inboundSecret", "EMAIL_INBOUND_SECRET", "EMAIL_WEBHOOK_SECRET", "AGENT_DESK_EMAIL_INBOUNDSECRET")
}
func normalizeLoadedConfig(cfg *Config) {
diff --git a/internal/pkg/config/config_test.go b/internal/pkg/config/config_test.go
index 30629f8b..adb5f147 100644
--- a/internal/pkg/config/config_test.go
+++ b/internal/pkg/config/config_test.go
@@ -103,6 +103,11 @@ OIDC_CLIENT_ID=client-123
OIDC_CLIENT_SECRET=secret-456
OIDC_REDIRECT_URL=https://desk.example.com/api/auth/oidc_callback
ORG_SYNC_SECRET=webhook-secret-789
+EMAIL_PROVIDER=brevo
+EMAIL_FROM=help@example.com
+EMAIL_FROM_NAME=Helpdesk Team
+BREVO_API_KEY=xkeysib-test-123
+EMAIL_INBOUND_SECRET=inbound-secret-456
`)
if err := os.WriteFile(envPath, envContent, 0600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
@@ -175,4 +180,19 @@ ORG_SYNC_SECRET=webhook-secret-789
if cfg.Webhook.OrgSyncSecret != "webhook-secret-789" {
t.Fatalf("Webhook.OrgSyncSecret=%q", cfg.Webhook.OrgSyncSecret)
}
+ if cfg.Email.Provider != "brevo" {
+ t.Fatalf("Email.Provider=%q want brevo", cfg.Email.Provider)
+ }
+ if cfg.Email.FromAddress != "help@example.com" {
+ t.Fatalf("Email.FromAddress=%q want help@example.com", cfg.Email.FromAddress)
+ }
+ if cfg.Email.FromName != "Helpdesk Team" {
+ t.Fatalf("Email.FromName=%q want Helpdesk Team", cfg.Email.FromName)
+ }
+ if cfg.Email.APIKey != "xkeysib-test-123" {
+ t.Fatalf("Email.APIKey=%q want xkeysib-test-123", cfg.Email.APIKey)
+ }
+ if cfg.Email.InboundSecret != "inbound-secret-456" {
+ t.Fatalf("Email.InboundSecret=%q want inbound-secret-456", cfg.Email.InboundSecret)
+ }
}
diff --git a/internal/services/email_inbound_service.go b/internal/services/email_inbound_service.go
index db76a3ff..e3546e46 100644
--- a/internal/services/email_inbound_service.go
+++ b/internal/services/email_inbound_service.go
@@ -5,10 +5,14 @@ import (
"encoding/json"
"fmt"
"log/slog"
+ "net/url"
+ "regexp"
+ "strconv"
"strings"
"agent-desk/internal/email"
"agent-desk/internal/models"
+ "agent-desk/internal/pkg/config"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/openidentity"
@@ -18,7 +22,10 @@ import (
"github.com/mlogclub/simple/sqls"
)
-var EmailInboundService = newEmailInboundService()
+var (
+ EmailInboundService = newEmailInboundService()
+ ticketIDRegex = regexp.MustCompile(`(?i)(?:\[(?:#|Ticket\s*#?)|(?:#|Ticket\s*#))\s*(\d+)`)
+)
func newEmailInboundService() *emailInboundService {
return &emailInboundService{}
@@ -26,8 +33,8 @@ func newEmailInboundService() *emailInboundService {
type emailInboundService struct{}
-// HandleWebhook processes an incoming email webhook from Brevo, SendGrid, or custom SMTP webhook gateway.
-func (s *emailInboundService) HandleWebhook(ctx context.Context, channelID string, secretHeader string, rawPayload []byte) error {
+// HandleWebhook processes an incoming email webhook from any supported provider (Cloudflare, Brevo, SendGrid, Postmark, Mailgun, Resend).
+func (s *emailInboundService) HandleWebhook(ctx context.Context, channelID string, secretHeader string, contentType string, rawPayload []byte, form url.Values) error {
channelID = strings.TrimSpace(channelID)
var channel *models.Channel
if channelID != "" {
@@ -35,7 +42,7 @@ func (s *emailInboundService) HandleWebhook(ctx context.Context, channelID strin
}
// 1. Parse inbound email items
- inboundItems, err := s.parseInboundPayload(rawPayload)
+ inboundItems, err := email.ParseInboundWebhook(contentType, rawPayload, form)
if err != nil {
return fmt.Errorf("parse email webhook failed: %w", err)
}
@@ -43,6 +50,11 @@ func (s *emailInboundService) HandleWebhook(ctx context.Context, channelID strin
return nil
}
+ systemSecret := ""
+ if cfg := config.GetCurrent(); cfg != nil {
+ systemSecret = cfg.Email.InboundSecret
+ }
+
for _, item := range inboundItems {
targetChannel := channel
if targetChannel == nil {
@@ -61,7 +73,12 @@ func (s *emailInboundService) HandleWebhook(ctx context.Context, channelID strin
return errorsx.InvalidParam("email channel config invalid")
}
- if cfg.WebhookSecret != "" && strings.TrimSpace(secretHeader) != cfg.WebhookSecret {
+ expectedSecret := cfg.WebhookSecret
+ if expectedSecret == "" {
+ expectedSecret = systemSecret
+ }
+
+ if expectedSecret != "" && strings.TrimSpace(secretHeader) != expectedSecret {
return errorsx.UnauthorizedI18n("error.auth.invalidSignature")
}
@@ -106,10 +123,27 @@ func (s *emailInboundService) processInboundItem(ctx context.Context, channel *m
ExternalName: fromName,
}
- // 2. Create or match Conversation
- conversation, err := ConversationService.Create(externalUser, channel.ID, channel.AIAgentID)
- if err != nil {
- return fmt.Errorf("create email conversation failed: %w", err)
+ // 2. Threading Resolution: Find existing conversation by Ticket ID or In-Reply-To header
+ var conversation *models.Conversation
+ ticketID := s.extractTicketID(item.Subject)
+ if ticketID > 0 {
+ existing := ConversationService.Get(ticketID)
+ if existing != nil && existing.Status != enums.IMConversationStatusClosed {
+ conversation = existing
+ }
+ }
+
+ if conversation == nil && item.InReplyTo != "" {
+ conversation = s.findConversationByInReplyTo(item.InReplyTo)
+ }
+
+ // If no existing thread matched, create or match via standard ConversationService
+ if conversation == nil {
+ var err error
+ conversation, err = ConversationService.Create(externalUser, channel.ID, channel.AIAgentID)
+ if err != nil {
+ return fmt.Errorf("create email conversation failed: %w", err)
+ }
}
// Ensure customer primary_email is populated
@@ -134,10 +168,11 @@ func (s *emailInboundService) processInboundItem(ctx context.Context, channel *m
"email_subject": item.Subject,
"email_message_id": item.MessageID,
"email_in_reply": item.InReplyTo,
+ "email_references": item.References,
}
payloadBytes, _ := json.Marshal(payloadMap)
- _, err = MessageService.SendCustomerMessage(
+ _, err := MessageService.SendCustomerMessage(
conversation.ID,
clientMsgID,
enums.IMMessageTypeText,
@@ -149,67 +184,42 @@ func (s *emailInboundService) processInboundItem(ctx context.Context, channel *m
return fmt.Errorf("send customer message failed: %w", err)
}
- slog.Info("inbound email successfully processed", "from", fromEmail, "channel_id", channel.ChannelID, "conversation_id", conversation.ID)
+ slog.Info("inbound email successfully processed",
+ "from", fromEmail,
+ "channel_id", channel.ChannelID,
+ "conversation_id", conversation.ID,
+ "subject", item.Subject,
+ )
return nil
}
-func (s *emailInboundService) parseInboundPayload(raw []byte) ([]email.InboundEmailPayload, error) {
- rawStr := strings.TrimSpace(string(raw))
- if rawStr == "" {
- return nil, nil
- }
-
- // Try Brevo format first
- var brevoWebhook email.BrevoInboundWebhook
- if err := json.Unmarshal(raw, &brevoWebhook); err == nil && len(brevoWebhook.Items) > 0 {
- var results []email.InboundEmailPayload
- for _, item := range brevoWebhook.Items {
- fromEmail, fromName := email.ParseAddress(item.Sender)
- toEmail, _ := email.ParseAddress(item.Recipient)
- msgID := ""
- if len(item.UUID) > 0 {
- msgID = item.UUID[0]
- }
- results = append(results, email.InboundEmailPayload{
- FromEmail: fromEmail,
- FromName: fromName,
- ToEmail: toEmail,
- Subject: strings.TrimSpace(item.Subject),
- BodyText: strings.TrimSpace(item.RawTextBody),
- BodyHTML: strings.TrimSpace(item.RawHTMLBody),
- MessageID: msgID,
- })
+func (s *emailInboundService) extractTicketID(subject string) int64 {
+ matches := ticketIDRegex.FindStringSubmatch(subject)
+ if len(matches) > 1 {
+ if id, err := strconv.ParseInt(matches[1], 10, 64); err == nil && id > 0 {
+ return id
}
- return results, nil
}
+ return 0
+}
- // Try Generic JSON format
- var generic email.GenericInboundWebhook
- if err := json.Unmarshal(raw, &generic); err == nil && generic.From != "" {
- fromEmail, fromName := email.ParseAddress(generic.From)
- if generic.FromName != "" {
- fromName = generic.FromName
- }
- toEmail, _ := email.ParseAddress(generic.To)
- body := generic.Text
- if body == "" {
- body = generic.Body
+func (s *emailInboundService) findConversationByInReplyTo(inReplyTo string) *models.Conversation {
+ inReplyTo = strings.TrimSpace(inReplyTo)
+ if inReplyTo == "" {
+ return nil
+ }
+ // Look up recent message containing this email message ID
+ likePattern := "%" + inReplyTo + "%"
+ msg := repositories.MessageRepository.FindOne(sqls.DB(), sqls.NewCnd().
+ Where("payload LIKE ?", likePattern).
+ Desc("id"))
+ if msg != nil && msg.ConversationID > 0 {
+ conv := ConversationService.Get(msg.ConversationID)
+ if conv != nil && conv.Status != enums.IMConversationStatusClosed {
+ return conv
}
- return []email.InboundEmailPayload{
- {
- FromEmail: fromEmail,
- FromName: fromName,
- ToEmail: toEmail,
- Subject: strings.TrimSpace(generic.Subject),
- BodyText: strings.TrimSpace(body),
- BodyHTML: strings.TrimSpace(generic.HTML),
- MessageID: generic.MessageID,
- InReplyTo: generic.InReplyTo,
- },
- }, nil
- }
-
- return nil, fmt.Errorf("unrecognized email webhook format")
+ }
+ return nil
}
func stripHTMLTags(s string) string {
diff --git a/internal/services/email_outbound_service.go b/internal/services/email_outbound_service.go
index f99c27cb..173c0959 100644
--- a/internal/services/email_outbound_service.go
+++ b/internal/services/email_outbound_service.go
@@ -2,14 +2,15 @@ package services
import (
"context"
+ "encoding/json"
"fmt"
"log/slog"
- "os"
"strings"
"time"
"agent-desk/internal/email"
"agent-desk/internal/models"
+ "agent-desk/internal/pkg/config"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/repositories"
@@ -116,49 +117,60 @@ func (s *emailOutboundService) processOutbox(outboxID int64) error {
return s.markOutboxFailed(outbox, "unable to resolve customer email address")
}
- // 2. Resolve sender config & fallbacks
+ // 2. Resolve sender config & system fallbacks
+ var sysEmail config.EmailConfig
+ if c := config.GetCurrent(); c != nil {
+ sysEmail = c.Email
+ }
fromEmail := strings.TrimSpace(cfg.EmailAddress)
if fromEmail == "" {
- fromEmail = "help@crove.com"
+ fromEmail = strings.TrimSpace(sysEmail.FromAddress)
+ }
+ if fromEmail == "" {
+ fromEmail = "support@crove.com"
}
+
fromName := strings.TrimSpace(cfg.SenderName)
if fromName == "" {
- fromName = "Crove Desk Support"
+ fromName = strings.TrimSpace(sysEmail.FromName)
+ }
+ if fromName == "" {
+ fromName = "Customer Support"
+ }
+
+ provider := email.DeliveryProvider(strings.ToLower(strings.TrimSpace(cfg.Provider)))
+ if provider == "" || provider == "default" {
+ provider = email.DeliveryProvider(strings.ToLower(strings.TrimSpace(sysEmail.Provider)))
+ }
+ if provider == "" {
+ provider = email.ProviderSMTP
}
apiKey := cfg.APIKey
if apiKey == "" {
- apiKey = os.Getenv("BREVO_API_KEY")
- if apiKey == "" {
- apiKey = os.Getenv("CROVE_BREVO_API_KEY")
- }
+ apiKey = sysEmail.APIKey
}
smtpHost := cfg.SMTPHost
if smtpHost == "" {
- smtpHost = os.Getenv("SMTP_HOST")
+ smtpHost = sysEmail.SMTPHost
}
smtpPort := cfg.SMTPPort
+ if smtpPort <= 0 {
+ smtpPort = sysEmail.SMTPPort
+ }
if smtpPort <= 0 {
smtpPort = 587
}
smtpUser := cfg.SMTPUser
if smtpUser == "" {
- smtpUser = os.Getenv("SMTP_USER")
+ smtpUser = sysEmail.SMTPUser
}
smtpPassword := cfg.SMTPPassword
if smtpPassword == "" {
- smtpPassword = os.Getenv("SMTP_PASSWORD")
- }
-
- provider := cfg.Provider
- if provider == "" {
- if apiKey != "" {
- provider = "brevo"
- } else {
- provider = "smtp"
- }
+ smtpPassword = sysEmail.SMTPPassword
}
+ smtpUseTLS := sysEmail.SMTPUseTLS
client := email.NewClient(email.ClientConfig{
Provider: provider,
@@ -167,35 +179,81 @@ func (s *emailOutboundService) processOutbox(outboxID int64) error {
SMTPPort: smtpPort,
SMTPUser: smtpUser,
SMTPPassword: smtpPassword,
+ SMTPUseTLS: smtpUseTLS,
})
- subject := fmt.Sprintf("Re: Support Ticket #%d", conversation.ID)
- ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
+ // 3. Resolve threading headers and subject
+ lastInboundMessageID := ""
+ lastInboundSubject := ""
+ recentMessages := repositories.MessageRepository.Find(sqls.DB(), sqls.NewCnd().
+ Eq("conversation_id", conversation.ID).
+ Eq("sender_type", enums.IMSenderTypeCustomer).
+ Desc("id").
+ Limit(5))
+
+ for _, rm := range recentMessages {
+ if rm.Payload != "" {
+ var pMap map[string]any
+ if json.Unmarshal([]byte(rm.Payload), &pMap) == nil {
+ if msgID, ok := pMap["email_message_id"].(string); ok && msgID != "" && lastInboundMessageID == "" {
+ lastInboundMessageID = msgID
+ }
+ if subj, ok := pMap["email_subject"].(string); ok && subj != "" && lastInboundSubject == "" {
+ lastInboundSubject = subj
+ }
+ }
+ }
+ }
+
+ subject := fmt.Sprintf("Re: [#%d] Support Inquiry", conversation.ID)
+ if lastInboundSubject != "" {
+ cleanSubj := strings.TrimPrefix(lastInboundSubject, "Re: ")
+ cleanSubj = strings.TrimPrefix(cleanSubj, "re: ")
+ subject = fmt.Sprintf("Re: [#%d] %s", conversation.ID, cleanSubj)
+ }
+
+ fromDomain := "desk.crove.com"
+ parts := strings.Split(fromEmail, "@")
+ if len(parts) == 2 {
+ fromDomain = parts[1]
+ }
+ outboundMsgID := fmt.Sprintf("", conversation.ID, message.ID, fromDomain)
+
+ customHeaders := map[string]string{
+ "X-Crove-Desk-Conversation-ID": fmt.Sprintf("%d", conversation.ID),
+ "X-Crove-Desk-Message-ID": fmt.Sprintf("%d", message.ID),
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
sendErr := client.SendEmail(ctx, email.SendEmailParams{
- FromEmail: fromEmail,
- FromName: fromName,
- ToEmail: targetEmail,
- ToName: targetName,
- Subject: subject,
- BodyText: message.Content,
+ FromEmail: fromEmail,
+ FromName: fromName,
+ ToEmail: targetEmail,
+ ToName: targetName,
+ Subject: subject,
+ BodyText: message.Content,
+ MessageID: outboundMsgID,
+ InReplyTo: lastInboundMessageID,
+ References: lastInboundMessageID,
+ Headers: customHeaders,
})
if sendErr != nil {
return s.handleOutboxError(outbox, sendErr.Error())
}
- return s.markOutboxSent(outbox, fmt.Sprintf("sent to %s", targetEmail))
+ return s.markOutboxSent(outbox, fmt.Sprintf("sent to %s via %s", targetEmail, provider))
}
func (s *emailOutboundService) markOutboxSent(outbox *models.ChannelMessageOutbox, detail string) error {
now := time.Now()
return ChannelMessageOutboxService.Updates(outbox.ID, map[string]any{
- "send_status": string(enums.ChannelMessageOutboxStatusSent),
- "send_detail": detail,
- "sent_at": &now,
- "updated_at": now,
+ "send_status": string(enums.ChannelMessageOutboxStatusSent),
+ "send_detail": detail,
+ "sent_at": &now,
+ "updated_at": now,
"next_retry_at": nil,
})
}
@@ -203,9 +261,9 @@ func (s *emailOutboundService) markOutboxSent(outbox *models.ChannelMessageOutbo
func (s *emailOutboundService) markOutboxFailed(outbox *models.ChannelMessageOutbox, reason string) error {
now := time.Now()
return ChannelMessageOutboxService.Updates(outbox.ID, map[string]any{
- "send_status": string(enums.ChannelMessageOutboxStatusFailed),
- "send_detail": reason,
- "updated_at": now,
+ "send_status": string(enums.ChannelMessageOutboxStatusFailed),
+ "send_detail": reason,
+ "updated_at": now,
"next_retry_at": nil,
})
}
@@ -216,10 +274,10 @@ func (s *emailOutboundService) handleOutboxError(outbox *models.ChannelMessageOu
if retryCount >= emailOutboxMaxRetry {
return ChannelMessageOutboxService.Updates(outbox.ID, map[string]any{
- "send_status": string(enums.ChannelMessageOutboxStatusFailed),
- "send_detail": fmt.Sprintf("max retries exceeded: %s", errMsg),
- "retry_count": retryCount,
- "updated_at": now,
+ "send_status": string(enums.ChannelMessageOutboxStatusFailed),
+ "send_detail": fmt.Sprintf("max retries exceeded: %s", errMsg),
+ "retry_count": retryCount,
+ "updated_at": now,
"next_retry_at": nil,
})
}
@@ -229,10 +287,10 @@ func (s *emailOutboundService) handleOutboxError(outbox *models.ChannelMessageOu
nextRetry := now.Add(backoff)
return ChannelMessageOutboxService.Updates(outbox.ID, map[string]any{
- "send_status": string(enums.ChannelMessageOutboxStatusPending),
- "send_detail": fmt.Sprintf("retry #%d error: %s", retryCount, errMsg),
- "retry_count": retryCount,
- "updated_at": now,
+ "send_status": string(enums.ChannelMessageOutboxStatusPending),
+ "send_detail": fmt.Sprintf("retry #%d error: %s", retryCount, errMsg),
+ "retry_count": retryCount,
+ "updated_at": now,
"next_retry_at": &nextRetry,
})
}
diff --git a/web/app/(dashboard)/dashboard/channels/_components/edit.tsx b/web/app/(dashboard)/dashboard/channels/_components/edit.tsx
index 8d9c1235..a00062bb 100644
--- a/web/app/(dashboard)/dashboard/channels/_components/edit.tsx
+++ b/web/app/(dashboard)/dashboard/channels/_components/edit.tsx
@@ -849,8 +849,13 @@ function ChannelFormBody({
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
{...register("emailProvider")}
>
-
-
+
+
+
+
+
+
+
@@ -869,20 +874,20 @@ function ChannelFormBody({
- {emailProvider === "brevo" ? (
+ {emailProvider === "brevo" || emailProvider === "sendgrid" || emailProvider === "resend" || emailProvider === "postmark" || emailProvider === "mailgun" ? (
{t("channel.emailApiKey")}
- ) : (
+ ) : emailProvider === "smtp" ? (
@@ -937,13 +942,13 @@ function ChannelFormBody({
- )}
+ ) : null}
{t("channel.emailAutoConnectTitle")}
{t("channel.emailAutoConnectDescription")}
- Webhook URL: https://desk.crove.com/api/third/email/webhook
+ {t("channel.inboundWebhookUrl")}: /api/third/email/webhook
diff --git a/web/messages/en-US.json b/web/messages/en-US.json
index fd86d435..7c92c032 100644
--- a/web/messages/en-US.json
+++ b/web/messages/en-US.json
@@ -609,7 +609,7 @@
"channel": {
"allTypes": "All types",
"typeWeb": "Web",
- "typeEmail": "Email (help@crove.com)",
+ "typeEmail": "Email Support",
"typeTelegram": "Telegram Bot",
"typeZaloOa": "Zalo Official Account",
"typeWechatMp": "WeChat Official Account",
@@ -617,10 +617,18 @@
"emailAddress": "Support Email Address",
"senderName": "Sender Display Name",
"emailProvider": "Email Delivery Service",
+ "emailProviderDefault": "System Default (.env / config.yaml)",
+ "emailProviderSmtp": "Custom SMTP Server",
+ "emailProviderBrevo": "Brevo API",
+ "emailProviderSendGrid": "SendGrid API",
+ "emailProviderResend": "Resend API",
+ "emailProviderPostmark": "Postmark API",
+ "emailProviderMailgun": "Mailgun API",
"emailApiKey": "API Key",
"emailAutoConnectTitle": "Automatic Inbound Email Ingestion",
- "emailAutoConnectDescription": "Forward emails sent to help@crove.com to the Crove Desk Inbound Webhook endpoint to automatically convert emails into tickets and trigger AI agent auto-replies.",
- "configEmailDescription": "Configure inbound email webhook ingestion and outbound reply delivery via Brevo or SMTP.",
+ "emailAutoConnectDescription": "Forward emails sent to your support address to the Inbound Webhook endpoint to automatically convert incoming emails into tickets and trigger AI agent responses.",
+ "configEmailDescription": "Configure inbound email webhook ingestion and outbound delivery across SMTP, Brevo, SendGrid, Resend, Postmark, or Mailgun.",
+ "inboundWebhookUrl": "Inbound Webhook Endpoint",
"botToken": "Telegram Bot Token",
"botTokenRequired": "Telegram Bot Token is required",
"botUsername": "Bot Username",
diff --git a/web/messages/vi-VN.json b/web/messages/vi-VN.json
index 097c841b..da0c994f 100644
--- a/web/messages/vi-VN.json
+++ b/web/messages/vi-VN.json
@@ -616,7 +616,7 @@
"channel": {
"allTypes": "Tất cả loại kênh",
"typeWeb": "Web Chat Widget",
- "typeEmail": "Email (help@crove.com)",
+ "typeEmail": "Kênh Email Hỗ trợ",
"typeTelegram": "Telegram Bot",
"typeZaloOa": "Zalo Official Account",
"typeWechatMp": "WeChat Official Account",
@@ -624,10 +624,18 @@
"emailAddress": "Địa chỉ Email Hỗ trợ",
"senderName": "Tên Người gửi Hiển thị",
"emailProvider": "Dịch vụ Gửi Email",
+ "emailProviderDefault": "Mặc định hệ thống (.env / config.yaml)",
+ "emailProviderSmtp": "Máy chủ SMTP tùy chỉnh",
+ "emailProviderBrevo": "Brevo API",
+ "emailProviderSendGrid": "SendGrid API",
+ "emailProviderResend": "Resend API",
+ "emailProviderPostmark": "Postmark API",
+ "emailProviderMailgun": "Mailgun API",
"emailApiKey": "API Key",
"emailAutoConnectTitle": "Tự động Nhận & Xử lý Email Khách hàng",
- "emailAutoConnectDescription": "Forward hoặc cấu hình webhook email gửi đến help@crove.com về Crove Desk để tự động tạo Ticket và kích hoạt AI Agent phản hồi.",
- "configEmailDescription": "Cấu hình tiếp nhận email qua Inbound Webhook và gửi phản hồi qua Brevo hoặc SMTP.",
+ "emailAutoConnectDescription": "Forward hoặc cấu hình webhook email gửi đến hộp thư hỗ trợ về Crove Desk để tự động tạo Ticket và kích hoạt AI Agent phản hồi.",
+ "configEmailDescription": "Cấu hình tiếp nhận email qua Inbound Webhook và gửi phản hồi qua SMTP, Brevo, SendGrid, Resend, Postmark hoặc Mailgun.",
+ "inboundWebhookUrl": "Endpoint Nhận Inbound Webhook",
"botToken": "Telegram Bot Token",
"botTokenRequired": "Telegram Bot Token is required",
"botUsername": "Bot Username",
diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json
index 12655228..02d1c27b 100644
--- a/web/messages/zh-CN.json
+++ b/web/messages/zh-CN.json
@@ -609,7 +609,7 @@
"channel": {
"allTypes": "全部类型",
"typeWeb": "Web 站点",
- "typeEmail": "邮件客服 (help@crove.com)",
+ "typeEmail": "邮件客服",
"typeTelegram": "Telegram Bot",
"typeZaloOa": "Zalo 公众号",
"typeWechatMp": "微信公众号",
@@ -617,10 +617,18 @@
"emailAddress": "支持邮箱地址",
"senderName": "发件人显示名称",
"emailProvider": "邮件发送服务",
+ "emailProviderDefault": "系统默认配置 (.env / config.yaml)",
+ "emailProviderSmtp": "自定义 SMTP 服务器",
+ "emailProviderBrevo": "Brevo API",
+ "emailProviderSendGrid": "SendGrid API",
+ "emailProviderResend": "Resend API",
+ "emailProviderPostmark": "Postmark API",
+ "emailProviderMailgun": "Mailgun API",
"emailApiKey": "API Key",
"emailAutoConnectTitle": "邮件客服自动接入",
- "emailAutoConnectDescription": "将发送至 help@crove.com 的邮件通过 Webhook 转发至 Crove Desk,自动创建工单并触发 AI Agent 回复。",
- "configEmailDescription": "配置邮件 Inbound Webhook 接入与 Brevo / SMTP 邮件回复发送。",
+ "emailAutoConnectDescription": "将发送至支持邮箱的邮件通过 Webhook 转发至 Inbound Webhook 接口,自动创建工单并触发 AI Agent 回复。",
+ "configEmailDescription": "配置邮件 Inbound Webhook 接入与 SMTP / Brevo / SendGrid / Resend / Postmark / Mailgun 邮件发送。",
+ "inboundWebhookUrl": "Inbound Webhook 回调地址",
"botToken": "Telegram Bot Token",
"botTokenRequired": "请输入 Telegram Bot Token",
"botUsername": "Bot 用户名",
From df375addf4a0be8547dbbd18d0b47ab692776ad2 Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Mon, 31 Aug 2026 19:58:33 +0700
Subject: [PATCH 39/53] docs(config): add comprehensive email provider
environment variables and yaml configurations
---
.env.example | 24 ++++++++++++++++++++++--
config/config.example.yaml | 19 +++++++++++++++++++
docker/agent-desk.supabase.example.yaml | 7 +++++++
3 files changed, 48 insertions(+), 2 deletions(-)
diff --git a/.env.example b/.env.example
index 4d8bb87b..33b9ee0f 100644
--- a/.env.example
+++ b/.env.example
@@ -61,12 +61,32 @@ QDRANT_GRPC_PORT=6334
# Webhook & Organization Sync
# ORG_SYNC_SECRET=your-webhook-hmac-secret
-# Email Channel & Delivery (help@crove.com)
-# BREVO_API_KEY=xkeysib-your-brevo-api-key
+# Email Channel & Delivery (help@crove.com / Inbound & Outbound)
+# Provider options: smtp, brevo, sendgrid, resend, postmark, mailgun
+EMAIL_PROVIDER=brevo
+EMAIL_FROM=help@crove.com
+EMAIL_FROM_NAME="Crove Desk Support"
+# Inbound Webhook Authentication Secret (Header: X-Webhook-Secret)
+EMAIL_INBOUND_SECRET=crove_email_secret_token_123
+
+# ESP Provider API Keys (Fill the one corresponding to your EMAIL_PROVIDER):
+# Brevo (Sendinblue):
+BREVO_API_KEY=xkeysib-your-brevo-api-key
+# SendGrid:
+# SENDGRID_API_KEY=SG.your-sendgrid-api-key
+# Resend:
+# RESEND_API_KEY=re_your-resend-api-key
+# Postmark:
+# POSTMARK_API_KEY=your-postmark-server-token
+# Mailgun:
+# MAILGUN_API_KEY=your-mailgun-api-key
+
+# Custom SMTP Server (when EMAIL_PROVIDER=smtp):
# SMTP_HOST=email-smtp.ap-southeast-1.amazonaws.com
# SMTP_PORT=587
# SMTP_USER=your-smtp-username
# SMTP_PASSWORD=your-smtp-password
+# SMTP_USE_TLS=false
# MCP (Model Context Protocol) Integration
# MCP_ENABLED=true
diff --git a/config/config.example.yaml b/config/config.example.yaml
index 7ec4f022..0ac9f93e 100644
--- a/config/config.example.yaml
+++ b/config/config.example.yaml
@@ -200,3 +200,22 @@ wxWork:
enableDuplicateCheck: true
# Duplicate message check window, in seconds.
duplicateCheckInterval: 1800
+
+email:
+ # Outbound delivery provider: smtp, brevo, sendgrid, resend, postmark, mailgun
+ provider: brevo
+ # Default system support email address (e.g. help@crove.com)
+ fromAddress: "help@crove.com"
+ # Default sender display name
+ fromName: "Crove Desk Support"
+ # Provider API Key (for Brevo, SendGrid, Resend, Postmark, Mailgun)
+ apiKey: ""
+ # Custom SMTP server configuration (when provider is smtp)
+ smtpHost: ""
+ smtpPort: 587
+ smtpUser: ""
+ smtpPassword: ""
+ smtpUseTls: false
+ # Secret token to authenticate Inbound Webhook requests at /api/third/email/webhook (Header: X-Webhook-Secret)
+ inboundSecret: ""
+
diff --git a/docker/agent-desk.supabase.example.yaml b/docker/agent-desk.supabase.example.yaml
index 209c63f8..b6a4409f 100644
--- a/docker/agent-desk.supabase.example.yaml
+++ b/docker/agent-desk.supabase.example.yaml
@@ -103,3 +103,10 @@ oidc:
webhook:
orgSyncSecret: "your-org-sync-webhook-secret"
outboundUrl: "https://api.dos.me/internal/events/publish"
+
+email:
+ provider: brevo
+ fromAddress: "help@crove.com"
+ fromName: "Crove Desk Support"
+ apiKey: "your-brevo-or-esp-api-key"
+ inboundSecret: "your-email-inbound-webhook-secret"
From e9052679c7ab694005b2f57de4a699788febca36 Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Mon, 31 Aug 2026 22:32:45 +0700
Subject: [PATCH 40/53] fix(email): support universal field mapping and
heterogeneous headers in inbound parser
---
internal/email/inbound_parser.go | 318 ++++++++++++++++++++-----------
1 file changed, 202 insertions(+), 116 deletions(-)
diff --git a/internal/email/inbound_parser.go b/internal/email/inbound_parser.go
index 147935ce..83f02fb2 100644
--- a/internal/email/inbound_parser.go
+++ b/internal/email/inbound_parser.go
@@ -7,63 +7,100 @@ import (
"strings"
)
+// UniversalInboundWebhook captures all possible field names across providers.
+type UniversalInboundWebhook struct {
+ // From fields
+ From string `json:"from"`
+ FromCaps string `json:"From"`
+ Sender string `json:"sender"`
+ FromName string `json:"from_name"`
+
+ // To fields
+ To string `json:"to"`
+ ToCaps string `json:"To"`
+ Recipient string `json:"recipient"`
+ ToName string `json:"to_name"`
+
+ // Subject
+ Subject string `json:"subject"`
+ SubjectCaps string `json:"Subject"`
+
+ // Body fields
+ Text string `json:"text"`
+ Body string `json:"body"`
+ BodyPlain string `json:"body-plain"`
+ TextBody string `json:"TextBody"`
+ RawTextBody string `json:"RawTextBody"`
+ HTML string `json:"html"`
+ BodyHTML string `json:"body-html"`
+ HtmlBody string `json:"HtmlBody"`
+ RawHtmlBody string `json:"RawHtmlBody"`
+
+ // Message IDs & Threading
+ MessageID string `json:"message_id"`
+ MessageIDCaps string `json:"MessageID"`
+ InReplyTo string `json:"in_reply_to"`
+ References string `json:"references"`
+ MailboxHash string `json:"MailboxHash"`
+
+ // Headers can be a map or an array of objects
+ Headers any `json:"headers"`
+ HeadersCaps any `json:"Headers"`
+
+ // Brevo items array
+ Items []BrevoInboundItem `json:"items"`
+}
+
// ParseInboundWebhook parses raw webhook payload from various email providers into a slice of normalized InboundEmailPayloads.
func ParseInboundWebhook(contentType string, rawBody []byte, form url.Values) ([]InboundEmailPayload, error) {
contentType = strings.ToLower(contentType)
- // 1. If form data provided (e.g. SendGrid Inbound Parse or Mailgun webhook)
+ // 1. Form-data (Mailgun, SendGrid Inbound Parse)
if len(form) > 0 {
- // Mailgun format check
- if form.Get("sender") != "" || form.Get("recipient") != "" {
- fromEmail, fromName := ParseAddress(form.Get("from"))
- if fromEmail == "" {
- fromEmail, fromName = ParseAddress(form.Get("sender"))
- }
- toEmail, toName := ParseAddress(form.Get("recipient"))
- if toEmail == "" {
- toEmail, toName = ParseAddress(form.Get("To"))
- }
- bodyText := form.Get("body-plain")
- if bodyText == "" {
- bodyText = form.Get("stripped-text")
- }
- bodyHTML := form.Get("body-html")
- if bodyHTML == "" {
- bodyHTML = form.Get("stripped-html")
- }
+ fromRaw := form.Get("from")
+ if fromRaw == "" {
+ fromRaw = form.Get("sender")
+ }
+ fromEmail, fromName := ParseAddress(fromRaw)
- return []InboundEmailPayload{
- {
- FromEmail: fromEmail,
- FromName: fromName,
- ToEmail: toEmail,
- ToName: toName,
- Subject: strings.TrimSpace(form.Get("subject")),
- BodyText: strings.TrimSpace(bodyText),
- BodyHTML: strings.TrimSpace(bodyHTML),
- MessageID: strings.TrimSpace(form.Get("Message-Id")),
- InReplyTo: strings.TrimSpace(form.Get("In-Reply-To")),
- References: strings.TrimSpace(form.Get("References")),
- },
- }, nil
+ toRaw := form.Get("to")
+ if toRaw == "" {
+ toRaw = form.Get("recipient")
}
+ if toRaw == "" {
+ toRaw = form.Get("To")
+ }
+ toEmail, toName := ParseAddress(toRaw)
- // SendGrid format check
- if form.Get("from") != "" || form.Get("to") != "" {
- fromEmail, fromName := ParseAddress(form.Get("from"))
- toEmail, toName := ParseAddress(form.Get("to"))
- return []InboundEmailPayload{
- {
- FromEmail: fromEmail,
- FromName: fromName,
- ToEmail: toEmail,
- ToName: toName,
- Subject: strings.TrimSpace(form.Get("subject")),
- BodyText: strings.TrimSpace(form.Get("text")),
- BodyHTML: strings.TrimSpace(form.Get("html")),
- },
- }, nil
+ bodyText := form.Get("body-plain")
+ if bodyText == "" {
+ bodyText = form.Get("stripped-text")
+ }
+ if bodyText == "" {
+ bodyText = form.Get("text")
+ }
+ bodyHTML := form.Get("body-html")
+ if bodyHTML == "" {
+ bodyHTML = form.Get("stripped-html")
+ }
+ if bodyHTML == "" {
+ bodyHTML = form.Get("html")
}
+
+ return []InboundEmailPayload{
+ {
+ FromEmail: fromEmail,
+ FromName: fromName,
+ ToEmail: toEmail,
+ ToName: toName,
+ Subject: strings.TrimSpace(form.Get("subject")),
+ BodyText: strings.TrimSpace(bodyText),
+ BodyHTML: strings.TrimSpace(bodyHTML),
+ MessageID: strings.TrimSpace(form.Get("Message-Id")),
+ InReplyTo: strings.TrimSpace(form.Get("In-Reply-To")),
+ References: strings.TrimSpace(form.Get("References")),
+ },
+ }, nil
}
rawStr := strings.TrimSpace(string(rawBody))
@@ -71,11 +108,16 @@ func ParseInboundWebhook(contentType string, rawBody []byte, form url.Values) ([
return nil, nil
}
- // 2. Try Brevo webhook format
- var brevoWebhook BrevoInboundWebhook
- if err := json.Unmarshal(rawBody, &brevoWebhook); err == nil && len(brevoWebhook.Items) > 0 {
+ // 2. Parse JSON
+ var u UniversalInboundWebhook
+ if err := json.Unmarshal(rawBody, &u); err != nil {
+ return nil, fmt.Errorf("unmarshal email json failed: %w", err)
+ }
+
+ // Check Brevo items format
+ if len(u.Items) > 0 {
var results []InboundEmailPayload
- for _, item := range brevoWebhook.Items {
+ for _, item := range u.Items {
fromEmail, fromName := ParseAddress(item.Sender)
toEmail, toName := ParseAddress(item.Recipient)
msgID := ""
@@ -97,76 +139,120 @@ func ParseInboundWebhook(contentType string, rawBody []byte, form url.Values) ([
return results, nil
}
- // 3. Try Postmark Inbound Webhook format
- var postmarkWebhook PostmarkInboundWebhook
- if err := json.Unmarshal(rawBody, &postmarkWebhook); err == nil && (postmarkWebhook.From != "" || postmarkWebhook.Subject != "") {
- fromEmail, fromName := ParseAddress(postmarkWebhook.From)
- if postmarkWebhook.FromName != "" {
- fromName = postmarkWebhook.FromName
+ // Resolve From
+ fromRaw := firstNonEmpty(u.From, u.FromCaps, u.Sender)
+ fromEmail, fromName := ParseAddress(fromRaw)
+ if u.FromName != "" {
+ fromName = u.FromName
+ }
+
+ // Resolve To
+ toRaw := firstNonEmpty(u.To, u.ToCaps, u.Recipient)
+ toEmail, toName := ParseAddress(toRaw)
+ if u.ToName != "" {
+ toName = u.ToName
+ }
+
+ // Resolve Subject
+ subject := firstNonEmpty(u.Subject, u.SubjectCaps)
+
+ // Resolve Body Text
+ bodyText := firstNonEmpty(u.Text, u.Body, u.BodyPlain, u.TextBody, u.RawTextBody)
+
+ // Resolve Body HTML
+ bodyHTML := firstNonEmpty(u.HTML, u.BodyHTML, u.HtmlBody, u.RawHtmlBody)
+
+ // Resolve Message ID
+ messageID := firstNonEmpty(u.MessageID, u.MessageIDCaps)
+
+ // Extract headers & In-Reply-To / References
+ headersMap, inReplyTo, references := extractHeadersAndThreading(u.Headers, u.HeadersCaps, u.InReplyTo, u.References)
+
+ if fromEmail == "" && toEmail == "" && subject == "" && bodyText == "" && bodyHTML == "" {
+ return nil, fmt.Errorf("unrecognized email webhook format")
+ }
+
+ return []InboundEmailPayload{
+ {
+ FromEmail: fromEmail,
+ FromName: fromName,
+ ToEmail: toEmail,
+ ToName: toName,
+ Subject: strings.TrimSpace(subject),
+ BodyText: strings.TrimSpace(bodyText),
+ BodyHTML: strings.TrimSpace(bodyHTML),
+ MessageID: strings.TrimSpace(messageID),
+ InReplyTo: strings.TrimSpace(inReplyTo),
+ References: strings.TrimSpace(references),
+ Headers: headersMap,
+ },
+ }, nil
+}
+
+func extractHeadersAndThreading(headers1, headers2 any, fallbackInReply, fallbackRef string) (map[string]string, string, string) {
+ headersMap := make(map[string]string)
+ inReplyTo := fallbackInReply
+ references := fallbackRef
+
+ for _, h := range []any{headers1, headers2} {
+ if h == nil {
+ continue
}
- toEmail, toName := ParseAddress(postmarkWebhook.To)
- headersMap := make(map[string]string)
- inReplyTo := ""
- references := ""
- for _, h := range postmarkWebhook.Headers {
- headersMap[h.Name] = h.Value
- if strings.EqualFold(h.Name, "In-Reply-To") {
- inReplyTo = h.Value
+ switch val := h.(type) {
+ case map[string]any:
+ for k, v := range val {
+ s := fmt.Sprintf("%v", v)
+ headersMap[k] = s
+ if strings.EqualFold(k, "In-Reply-To") && inReplyTo == "" {
+ inReplyTo = s
+ }
+ if strings.EqualFold(k, "References") && references == "" {
+ references = s
+ }
}
- if strings.EqualFold(h.Name, "References") {
- references = h.Value
+ case map[string]string:
+ for k, v := range val {
+ headersMap[k] = v
+ if strings.EqualFold(k, "In-Reply-To") && inReplyTo == "" {
+ inReplyTo = v
+ }
+ if strings.EqualFold(k, "References") && references == "" {
+ references = v
+ }
+ }
+ case []any:
+ for _, item := range val {
+ if itemMap, ok := item.(map[string]any); ok {
+ name := fmt.Sprintf("%v", itemMap["Name"])
+ if name == "" {
+ name = fmt.Sprintf("%v", itemMap["name"])
+ }
+ value := fmt.Sprintf("%v", itemMap["Value"])
+ if value == "" {
+ value = fmt.Sprintf("%v", itemMap["value"])
+ }
+ if name != "" {
+ headersMap[name] = value
+ if strings.EqualFold(name, "In-Reply-To") && inReplyTo == "" {
+ inReplyTo = value
+ }
+ if strings.EqualFold(name, "References") && references == "" {
+ references = value
+ }
+ }
+ }
}
}
-
- return []InboundEmailPayload{
- {
- FromEmail: fromEmail,
- FromName: fromName,
- ToEmail: toEmail,
- ToName: toName,
- Subject: strings.TrimSpace(postmarkWebhook.Subject),
- BodyText: strings.TrimSpace(postmarkWebhook.TextBody),
- BodyHTML: strings.TrimSpace(postmarkWebhook.HtmlBody),
- MessageID: strings.TrimSpace(postmarkWebhook.MessageID),
- InReplyTo: inReplyTo,
- References: references,
- Headers: headersMap,
- },
- }, nil
}
- // 4. Try Standard Generic / Cloudflare Email Routing format
- var generic GenericInboundWebhook
- if err := json.Unmarshal(rawBody, &generic); err == nil && generic.From != "" {
- fromEmail, fromName := ParseAddress(generic.From)
- if generic.FromName != "" {
- fromName = generic.FromName
- }
- toEmail, toName := ParseAddress(generic.To)
- if generic.ToName != "" {
- toName = generic.ToName
- }
- body := generic.Text
- if body == "" {
- body = generic.Body
- }
+ return headersMap, inReplyTo, references
+}
- return []InboundEmailPayload{
- {
- FromEmail: fromEmail,
- FromName: fromName,
- ToEmail: toEmail,
- ToName: toName,
- Subject: strings.TrimSpace(generic.Subject),
- BodyText: strings.TrimSpace(body),
- BodyHTML: strings.TrimSpace(generic.HTML),
- MessageID: strings.TrimSpace(generic.MessageID),
- InReplyTo: strings.TrimSpace(generic.InReplyTo),
- References: strings.TrimSpace(generic.References),
- Headers: generic.Headers,
- },
- }, nil
+func firstNonEmpty(strs ...string) string {
+ for _, s := range strs {
+ if trimmed := strings.TrimSpace(s); trimmed != "" {
+ return trimmed
+ }
}
-
- return nil, fmt.Errorf("unrecognized email webhook format")
+ return ""
}
From 00fa48a80456be50af662913d7e15d8fb3628959 Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Tue, 1 Sep 2026 16:29:44 +0700
Subject: [PATCH 41/53] feat(scripts): add Cloudflare Email Routing inbound
forwarder worker template
---
scripts/cloudflare-email-worker/package.json | 17 ++++++
scripts/cloudflare-email-worker/src/index.ts | 58 +++++++++++++++++++
scripts/cloudflare-email-worker/wrangler.toml | 7 +++
3 files changed, 82 insertions(+)
create mode 100644 scripts/cloudflare-email-worker/package.json
create mode 100644 scripts/cloudflare-email-worker/src/index.ts
create mode 100644 scripts/cloudflare-email-worker/wrangler.toml
diff --git a/scripts/cloudflare-email-worker/package.json b/scripts/cloudflare-email-worker/package.json
new file mode 100644
index 00000000..f148a394
--- /dev/null
+++ b/scripts/cloudflare-email-worker/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "crove-email-inbound-worker",
+ "version": "1.0.0",
+ "description": "Cloudflare Email Routing Worker forwarding inbound emails to Crove Desk webhook",
+ "main": "src/index.ts",
+ "scripts": {
+ "deploy": "wrangler deploy"
+ },
+ "dependencies": {
+ "postal-mime": "^2.1.8"
+ },
+ "devDependencies": {
+ "@cloudflare/workers-types": "^4.20241022.0",
+ "typescript": "^5.6.3",
+ "wrangler": "^3.84.1"
+ }
+}
diff --git a/scripts/cloudflare-email-worker/src/index.ts b/scripts/cloudflare-email-worker/src/index.ts
new file mode 100644
index 00000000..c93e5c2a
--- /dev/null
+++ b/scripts/cloudflare-email-worker/src/index.ts
@@ -0,0 +1,58 @@
+import PostalMime from 'postal-mime';
+
+export interface Env {
+ DESK_WEBHOOK_URL: string;
+ DESK_WEBHOOK_SECRET: string;
+}
+
+export default {
+ async email(message: ForwardableEmailMessage, env: Env, ctx: ExecutionContext): Promise {
+ try {
+ const rawEmail = await new Response(message.raw).arrayBuffer();
+ const parser = new PostalMime();
+ const parsed = await parser.parse(rawEmail);
+
+ const fromEmail = message.from || parsed.from?.address || '';
+ const fromName = parsed.from?.name || '';
+ const toEmail = message.to || (parsed.to && parsed.to[0]?.address) || 'help@crove.com';
+ const toName = (parsed.to && parsed.to[0]?.name) || '';
+ const subject = message.headers.get('subject') || parsed.subject || '';
+ const messageId = message.headers.get('message-id') || parsed.messageId || '';
+ const inReplyTo = message.headers.get('in-reply-to') || parsed.inReplyTo || '';
+ const references = message.headers.get('references') || (Array.isArray(parsed.references) ? parsed.references.join(' ') : parsed.references) || '';
+
+ const payload = {
+ from: fromEmail,
+ from_name: fromName,
+ to: toEmail,
+ to_name: toName,
+ subject: subject,
+ text: parsed.text || '',
+ html: parsed.html || '',
+ message_id: messageId,
+ in_reply_to: inReplyTo,
+ references: references,
+ };
+
+ const webhookUrl = env.DESK_WEBHOOK_URL || 'https://desk.crove.com/api/third/email/webhook';
+ const webhookSecret = env.DESK_WEBHOOK_SECRET || '';
+
+ const response = await fetch(webhookUrl, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Webhook-Secret': webhookSecret,
+ },
+ body: JSON.stringify(payload),
+ });
+
+ if (!response.ok) {
+ console.error(`Failed to forward email to webhook: ${response.status} ${response.statusText}`);
+ } else {
+ console.log(`Successfully forwarded email from ${fromEmail} to Crove Desk`);
+ }
+ } catch (error) {
+ console.error('Error processing inbound email in worker:', error);
+ }
+ },
+};
diff --git a/scripts/cloudflare-email-worker/wrangler.toml b/scripts/cloudflare-email-worker/wrangler.toml
new file mode 100644
index 00000000..050493dc
--- /dev/null
+++ b/scripts/cloudflare-email-worker/wrangler.toml
@@ -0,0 +1,7 @@
+name = "crove-email-inbound-worker"
+main = "src/index.ts"
+compatibility_date = "2024-11-05"
+
+[vars]
+DESK_WEBHOOK_URL = "https://desk.crove.com/api/third/email/webhook"
+DESK_WEBHOOK_SECRET = "your-email-inbound-webhook-secret"
From 7b013b8aa26adf5dc2544352c2f01134b5f4b690 Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Tue, 1 Sep 2026 20:13:35 +0700
Subject: [PATCH 42/53] fix(ci): lowercase repository name for GHCR docker tags
to prevent uppercase build failures
---
.github/workflows/deploy-beta.yml | 9 +++++++--
.github/workflows/deploy-prod.yml | 5 ++++-
2 files changed, 11 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/deploy-beta.yml b/.github/workflows/deploy-beta.yml
index 924e2309..834f087c 100644
--- a/.github/workflows/deploy-beta.yml
+++ b/.github/workflows/deploy-beta.yml
@@ -17,7 +17,6 @@ concurrency:
env:
REGISTRY: ghcr.io
- IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
@@ -31,6 +30,10 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
+ - name: Set lowercase image name
+ run: |
+ echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
+
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -51,6 +54,8 @@ jobs:
target: app
platforms: linux/amd64
push: true
- tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
+ tags: |
+ ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
+ ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:beta
cache-from: type=gha
cache-to: type=gha,mode=max
diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml
index a3963f97..869eda70 100644
--- a/.github/workflows/deploy-prod.yml
+++ b/.github/workflows/deploy-prod.yml
@@ -14,7 +14,6 @@ concurrency:
env:
REGISTRY: ghcr.io
- IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
@@ -28,6 +27,10 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
+ - name: Set lowercase image name
+ run: |
+ echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
+
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
From e3843f117c7d4ab69e1423cafd6e2c76f38b7b17 Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Wed, 2 Sep 2026 08:07:40 +0700
Subject: [PATCH 43/53] docs(rules): add SaaS multi-tenant architecture and
product guidelines
- Define Crove OS & Crove Desk as a commercial multi-tenant B2B SaaS platform
- Specify tenant isolation and dynamic inbound/outbound routing patterns
- Add mandatory implementation checklist for SaaS multi-tenancy
---
.../rules/saas-multitenant-architecture.mdc | 25 +++++++++++++++++++
1 file changed, 25 insertions(+)
create mode 100644 .cursor/rules/saas-multitenant-architecture.mdc
diff --git a/.cursor/rules/saas-multitenant-architecture.mdc b/.cursor/rules/saas-multitenant-architecture.mdc
new file mode 100644
index 00000000..e8114450
--- /dev/null
+++ b/.cursor/rules/saas-multitenant-architecture.mdc
@@ -0,0 +1,25 @@
+---
+description: Mandatory rule defining Crove OS and Crove Desk as a Multi-Tenant B2B SaaS platform (not internal-only)
+alwaysApply: true
+---
+
+# SaaS Multi-Tenant Product Mindset & Architecture
+
+## 1. Core Principle: Multi-Tenant B2B SaaS
+- **Crove OS** (Crove Desk, Crove CRM, Crove Post, Crove Sign, Crove Cal) is a **commercial, multi-tenant B2B SaaS ecosystem**, NOT an internal single-company tool or single-user product.
+- Every feature, channel, data model, API, and workflow MUST be architected to serve multiple independent organizations/tenants (`org_id`, `org_slug`) in a fully automated, self-service manner.
+- **NEVER** hardcode single-tenant assumptions, single company emails (e.g. assuming only `help@crove.com`), or manual database edits for a single user in architecture proposals or production logic.
+
+## 2. Channel & Inbound Routing Architecture (Multi-Tenant)
+- **Tenant Isolation**: Each organization manages their own channels (Web Widget, Telegram Bot, Zalo OA, Email, etc.) via the Dashboard self-service UI.
+- **Dynamic Inbound Ingestion**:
+ - Global gateways (Cloudflare Email Routing, Inbound Webhooks) act as universal dispatchers.
+ - Inbound messages/emails are dynamically resolved to the correct Organization and Channel via recipient address format (e.g. `support@.crove.io`, `help+@crove.com`), channel ID, or verified custom domain routing.
+- **Outbound Delivery**:
+ - Tenants can use default SaaS delivery infrastructure or bring their own custom SMTP / ESP API keys (Brevo, SendGrid, Postmark, Resend, Mailgun) configured securely within their organization settings.
+
+## 3. Implementation Checklist for New Features
+- [ ] Multi-tenant scoping: Data, settings, and permissions are partitioned by `org_id` / `org_slug`.
+- [ ] Self-service UX: Any tenant admin can configure and activate the feature directly from the UI without developer intervention.
+- [ ] Scalable routing: Inbound webhooks/events dynamically look up target tenant and channel in real-time.
+- [ ] Clean upstream compatibility: Generic code stays compatible with open-source upstream while SaaS multi-tenant extensions follow clean architectural boundaries.
From 8a53f8ee1a19b4090a7dd3ccd5c27cb9c8019e9f Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Wed, 2 Sep 2026 08:54:05 +0700
Subject: [PATCH 44/53] feat(email): add multi-tenant forwarding address
resolution help@.crove.io and UI copy helper
- Add intelligent tenant slug resolution in GetEnabledEmailChannelByAddress for .crove.io, .on.crove.email, and plus addressing
- Add forwardingAddress field to EmailChannelConfig
- Add copyable dedicated forwarding address preview in channel edit UI
- Add comprehensive multi-tenant email channel unit tests across EN, VI, ZH locales
---
internal/pkg/dto/dto.go | 21 ++---
internal/services/channel_service.go | 82 +++++++++++++++++--
internal/services/channel_service_test.go | 57 +++++++++++++
.../dashboard/channels/_components/edit.tsx | 43 +++++++++-
web/messages/en-US.json | 1 +
web/messages/vi-VN.json | 1 +
web/messages/zh-CN.json | 1 +
7 files changed, 186 insertions(+), 20 deletions(-)
diff --git a/internal/pkg/dto/dto.go b/internal/pkg/dto/dto.go
index 8f14996a..a65bfc20 100644
--- a/internal/pkg/dto/dto.go
+++ b/internal/pkg/dto/dto.go
@@ -50,14 +50,15 @@ type ZaloOAChannelConfig struct {
WelcomeMessage string `json:"welcomeMessage,omitempty"`
}
type EmailChannelConfig struct {
- EmailAddress string `json:"emailAddress"` // e.g. help@crove.com
- SenderName string `json:"senderName,omitempty"` // e.g. Crove Desk Support
- Provider string `json:"provider,omitempty"` // brevo | smtp
- APIKey string `json:"apiKey,omitempty"` // Brevo / ESP API Key
- SMTPHost string `json:"smtpHost,omitempty"` // SMTP Server Host
- SMTPPort int `json:"smtpPort,omitempty"` // SMTP Port (587/465)
- SMTPUser string `json:"smtpUser,omitempty"` // SMTP Username
- SMTPPassword string `json:"smtpPassword,omitempty"` // SMTP Password
- WebhookSecret string `json:"webhookSecret,omitempty"` // Inbound Webhook Secret
- WelcomeMessage string `json:"welcomeMessage,omitempty"` // Auto-responder / welcome message
+ EmailAddress string `json:"emailAddress"` // e.g. help@dos.crove.io or support@company.com
+ ForwardingAddress string `json:"forwardingAddress,omitempty"` // e.g. help@dos.crove.io
+ SenderName string `json:"senderName,omitempty"` // e.g. Crove Desk Support
+ Provider string `json:"provider,omitempty"` // default | smtp | brevo | sendgrid | resend | postmark | mailgun
+ APIKey string `json:"apiKey,omitempty"` // Brevo / ESP API Key
+ SMTPHost string `json:"smtpHost,omitempty"` // SMTP Server Host
+ SMTPPort int `json:"smtpPort,omitempty"` // SMTP Port (587/465)
+ SMTPUser string `json:"smtpUser,omitempty"` // SMTP Username
+ SMTPPassword string `json:"smtpPassword,omitempty"` // SMTP Password
+ WebhookSecret string `json:"webhookSecret,omitempty"` // Inbound Webhook Secret
+ WelcomeMessage string `json:"welcomeMessage,omitempty"` // Auto-responder / welcome message
}
diff --git a/internal/services/channel_service.go b/internal/services/channel_service.go
index 8bd49828..4abe58a6 100644
--- a/internal/services/channel_service.go
+++ b/internal/services/channel_service.go
@@ -402,6 +402,7 @@ func (s *channelService) ParseEmailChannelConfig(raw string) (*dto.EmailChannelC
}
}
cfg.EmailAddress = strings.ToLower(strings.TrimSpace(cfg.EmailAddress))
+ cfg.ForwardingAddress = strings.ToLower(strings.TrimSpace(cfg.ForwardingAddress))
cfg.SenderName = strings.TrimSpace(cfg.SenderName)
cfg.Provider = strings.ToLower(strings.TrimSpace(cfg.Provider))
if cfg.Provider == "" {
@@ -530,20 +531,89 @@ func (s *channelService) GetEnabledEmailChannelByAddress(emailAddress string) *m
Eq("channel_type", enums.ChannelTypeEmail).
Eq("status", enums.StatusOk).
Asc("id"))
+ if len(channels) == 0 {
+ return nil
+ }
+
+ // 1. Pass 1: Exact match with EmailAddress or ForwardingAddress
for i := range channels {
cfg, err := s.ParseEmailChannelConfig(channels[i].ConfigJSON)
if err != nil {
continue
}
- if cfg != nil && strings.ToLower(strings.TrimSpace(cfg.EmailAddress)) == emailAddress {
- return &channels[i]
+ if cfg != nil {
+ if strings.ToLower(strings.TrimSpace(cfg.EmailAddress)) == emailAddress ||
+ strings.ToLower(strings.TrimSpace(cfg.ForwardingAddress)) == emailAddress {
+ return &channels[i]
+ }
}
}
- // Fallback to first active email channel if exact address match wasn't found
- if len(channels) > 0 {
- return &channels[0]
+
+ // 2. Pass 2: Extract tenant slug (e.g. help@dos.crove.io -> "dos", help@dos.on.crove.email -> "dos", help+dos@... -> "dos")
+ slug := extractTenantSlugFromEmail(emailAddress)
+ if slug != "" {
+ for i := range channels {
+ cfg, err := s.ParseEmailChannelConfig(channels[i].ConfigJSON)
+ if err != nil {
+ continue
+ }
+ channelIDLower := strings.ToLower(channels[i].ChannelID)
+ if channelIDLower == "email_"+slug || channelIDLower == slug || strings.Contains(channelIDLower, slug) {
+ return &channels[i]
+ }
+ if cfg != nil {
+ cfgEmailLower := strings.ToLower(cfg.EmailAddress)
+ cfgFwdLower := strings.ToLower(cfg.ForwardingAddress)
+ if strings.Contains(cfgEmailLower, "@"+slug+".") ||
+ strings.Contains(cfgEmailLower, "+"+slug+"@") ||
+ strings.Contains(cfgFwdLower, "@"+slug+".") ||
+ strings.Contains(cfgFwdLower, "+"+slug+"@") {
+ return &channels[i]
+ }
+ }
+ }
+
+ // Also check if Organization exists with code == slug
+ org := repositories.OrganizationRepository.GetByCode(sqls.DB(), slug)
+ if org != nil {
+ for i := range channels {
+ if strings.EqualFold(channels[i].Name, org.Name) ||
+ strings.Contains(strings.ToLower(channels[i].Name), slug) {
+ return &channels[i]
+ }
+ }
+ }
}
- return nil
+
+ // 3. Pass 3: Fallback to first active email channel
+ return &channels[0]
+}
+
+func extractTenantSlugFromEmail(emailAddress string) string {
+ emailAddress = strings.ToLower(strings.TrimSpace(emailAddress))
+ parts := strings.Split(emailAddress, "@")
+ if len(parts) != 2 {
+ return ""
+ }
+ localPart, domain := parts[0], parts[1]
+
+ // Check plus addressing (e.g. help+dos@crove.io -> "dos")
+ if strings.Contains(localPart, "+") {
+ plusParts := strings.Split(localPart, "+")
+ if len(plusParts) > 1 && plusParts[1] != "" {
+ return plusParts[1]
+ }
+ }
+
+ // Check subdomains (e.g. dos.crove.io -> "dos", dos.on.crove.email -> "dos")
+ domainParts := strings.Split(domain, ".")
+ if len(domainParts) >= 3 {
+ if domainParts[0] != "mail" && domainParts[0] != "smtp" && domainParts[0] != "email" && domainParts[0] != "inbound" {
+ return domainParts[0]
+ }
+ }
+
+ return ""
}
func (s *channelService) GetEnabledChannel(ctx *gin.Context) *models.Channel {
diff --git a/internal/services/channel_service_test.go b/internal/services/channel_service_test.go
index 7ede0c2e..1e218b29 100644
--- a/internal/services/channel_service_test.go
+++ b/internal/services/channel_service_test.go
@@ -137,3 +137,60 @@ func createChannelServiceTestAgent(t *testing.T, db *gorm.DB, publishedRevisionI
func channelServiceTestOperator() *dto.AuthPrincipal {
return &dto.AuthPrincipal{UserID: 1, Username: "admin"}
}
+
+func TestGetEnabledEmailChannelByAddress_MultiTenantSlugResolution(t *testing.T) {
+ db := setupChannelServiceTestDB(t)
+ agent := createChannelServiceTestAgent(t, db, 1001)
+
+ // Create Channel 1 for DOS (help@dos.crove.io)
+ dosCfg := `{"emailAddress":"help@dos.crove.io","forwardingAddress":"help@dos.crove.io"}`
+ _, err := ChannelService.CreateChannel(request.CreateChannelRequest{
+ Name: "DOS Support Channel",
+ ChannelType: enums.ChannelTypeEmail,
+ AIAgentID: agent.ID,
+ AIAgentRolloutPercent: 100,
+ ConfigJSON: dosCfg,
+ Status: int(enums.StatusOk),
+ }, channelServiceTestOperator())
+ if err != nil {
+ t.Fatalf("create dos channel failed: %v", err)
+ }
+
+ // Create Channel 2 for Acme (help@acme.on.crove.email)
+ acmeCfg := `{"emailAddress":"support@acme.com","forwardingAddress":"help@acme.on.crove.email"}`
+ _, err = ChannelService.CreateChannel(request.CreateChannelRequest{
+ Name: "Acme Support Channel",
+ ChannelType: enums.ChannelTypeEmail,
+ AIAgentID: agent.ID,
+ AIAgentRolloutPercent: 100,
+ ConfigJSON: acmeCfg,
+ Status: int(enums.StatusOk),
+ }, channelServiceTestOperator())
+ if err != nil {
+ t.Fatalf("create acme channel failed: %v", err)
+ }
+
+ // Test 1: Exact match on EmailAddress
+ c1 := ChannelService.GetEnabledEmailChannelByAddress("help@dos.crove.io")
+ if c1 == nil || c1.Name != "DOS Support Channel" {
+ t.Fatalf("expected DOS Support Channel, got: %+v", c1)
+ }
+
+ // Test 2: Subdomain / Slug match (e.g. any sender addressing support@dos.crove.io)
+ c2 := ChannelService.GetEnabledEmailChannelByAddress("support@dos.crove.io")
+ if c2 == nil || c2.Name != "DOS Support Channel" {
+ t.Fatalf("expected DOS Support Channel from subdomain slug, got: %+v", c2)
+ }
+
+ // Test 3: Match on forwardingAddress (help@acme.on.crove.email)
+ c3 := ChannelService.GetEnabledEmailChannelByAddress("help@acme.on.crove.email")
+ if c3 == nil || c3.Name != "Acme Support Channel" {
+ t.Fatalf("expected Acme Support Channel, got: %+v", c3)
+ }
+
+ // Test 4: Slug match on Acme (sales@acme.on.crove.email)
+ c4 := ChannelService.GetEnabledEmailChannelByAddress("sales@acme.on.crove.email")
+ if c4 == nil || c4.Name != "Acme Support Channel" {
+ t.Fatalf("expected Acme Support Channel from on.crove.email slug, got: %+v", c4)
+ }
+}
diff --git a/web/app/(dashboard)/dashboard/channels/_components/edit.tsx b/web/app/(dashboard)/dashboard/channels/_components/edit.tsx
index a00062bb..36bfde7c 100644
--- a/web/app/(dashboard)/dashboard/channels/_components/edit.tsx
+++ b/web/app/(dashboard)/dashboard/channels/_components/edit.tsx
@@ -531,8 +531,19 @@ function ChannelFormBody({
const openKfId = useWatch({ control, name: "openKfId" })
const userTokenSecret = useWatch({ control, name: "userTokenSecret" })
const emailProvider = useWatch({ control, name: "emailProvider" })
+ const emailAddressValue = useWatch({ control, name: "emailAddress" })
+ const nameValue = useWatch({ control, name: "name" })
const previousRolloutPercent = channelDetail?.previousAiAgentRolloutPercent ?? 0
+ const forwardingAddressPreview = useMemo(() => {
+ const raw = (emailAddressValue || "").trim().toLowerCase()
+ if (raw.endsWith(".crove.io") || raw.endsWith(".on.crove.email") || raw.endsWith(".crove-mail.com")) {
+ return raw
+ }
+ const cleanName = (nameValue || "").trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "org"
+ return `help@${cleanName}.crove.io`
+ }, [emailAddressValue, nameValue])
+
async function rollbackRolloutPercent() {
if (!channelDetail || previousRolloutPercent < 1) return
setRollingBackRollout(true)
@@ -944,10 +955,34 @@ function ChannelFormBody({
) : null}
-
-
{t("channel.emailAutoConnectTitle")}
-
{t("channel.emailAutoConnectDescription")}
-
+
+
{t("channel.emailAutoConnectTitle")}
+
{t("channel.emailAutoConnectDescription")}
+
+
{t("channel.forwardingAddressLabel")}
+
+
+ {forwardingAddressPreview}
+
+
+
+
+
{t("channel.inboundWebhookUrl")}: /api/third/email/webhook
diff --git a/web/messages/en-US.json b/web/messages/en-US.json
index 7c92c032..02ad51ec 100644
--- a/web/messages/en-US.json
+++ b/web/messages/en-US.json
@@ -628,6 +628,7 @@
"emailAutoConnectTitle": "Automatic Inbound Email Ingestion",
"emailAutoConnectDescription": "Forward emails sent to your support address to the Inbound Webhook endpoint to automatically convert incoming emails into tickets and trigger AI agent responses.",
"configEmailDescription": "Configure inbound email webhook ingestion and outbound delivery across SMTP, Brevo, SendGrid, Resend, Postmark, or Mailgun.",
+ "forwardingAddressLabel": "Dedicated Forwarding Address (for auto-forwarding from Gmail / Outlook):",
"inboundWebhookUrl": "Inbound Webhook Endpoint",
"botToken": "Telegram Bot Token",
"botTokenRequired": "Telegram Bot Token is required",
diff --git a/web/messages/vi-VN.json b/web/messages/vi-VN.json
index da0c994f..4be5ce07 100644
--- a/web/messages/vi-VN.json
+++ b/web/messages/vi-VN.json
@@ -635,6 +635,7 @@
"emailAutoConnectTitle": "Tự động Nhận & Xử lý Email Khách hàng",
"emailAutoConnectDescription": "Forward hoặc cấu hình webhook email gửi đến hộp thư hỗ trợ về Crove Desk để tự động tạo Ticket và kích hoạt AI Agent phản hồi.",
"configEmailDescription": "Cấu hình tiếp nhận email qua Inbound Webhook và gửi phản hồi qua SMTP, Brevo, SendGrid, Resend, Postmark hoặc Mailgun.",
+ "forwardingAddressLabel": "Địa chỉ Chuyển tiếp Tự động (dùng cấu hình Auto-Forwarding trên Gmail / Outlook):",
"inboundWebhookUrl": "Endpoint Nhận Inbound Webhook",
"botToken": "Telegram Bot Token",
"botTokenRequired": "Telegram Bot Token is required",
diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json
index 02d1c27b..53c4bd67 100644
--- a/web/messages/zh-CN.json
+++ b/web/messages/zh-CN.json
@@ -628,6 +628,7 @@
"emailAutoConnectTitle": "邮件客服自动接入",
"emailAutoConnectDescription": "将发送至支持邮箱的邮件通过 Webhook 转发至 Inbound Webhook 接口,自动创建工单并触发 AI Agent 回复。",
"configEmailDescription": "配置邮件 Inbound Webhook 接入与 SMTP / Brevo / SendGrid / Resend / Postmark / Mailgun 邮件发送。",
+ "forwardingAddressLabel": "自动转发专用地址(用于 Gmail / Outlook 邮件自动转发):",
"inboundWebhookUrl": "Inbound Webhook 回调地址",
"botToken": "Telegram Bot Token",
"botTokenRequired": "请输入 Telegram Bot Token",
From b2994a161350df0ba758e2581ba9b78ab3104c6f Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Wed, 2 Sep 2026 09:10:03 +0700
Subject: [PATCH 45/53] feat(conversation): add private internal notes support
with tab toggle and distinct styling
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add IMSenderTypeNote and IMMessageTypeNote enums
- Add mode switcher in SharedMessageEditor for Public Reply vs Internal Note
- Style internal notes with amber tint and 🔒 Internal Note badge
- Prevent internal notes from counting against customer unread count and external channels
- Add full i18n support across EN, VI, and ZH locales
---
internal/pkg/enums/im.go | 4 +
internal/services/message_service.go | 29 ++++++--
.../_components/agent-message-editor.tsx | 2 +-
.../conversations/_components/chat-panel.tsx | 26 ++++---
.../chat/conversation-message-bubble.tsx | 5 ++
web/components/chat/shared-message-editor.tsx | 73 +++++++++++++++++--
web/lib/generated/enums.ts | 4 +
web/lib/stores/agent-conversations.ts | 11 +--
web/messages/en-US.json | 5 ++
web/messages/vi-VN.json | 7 +-
web/messages/zh-CN.json | 5 ++
11 files changed, 142 insertions(+), 29 deletions(-)
diff --git a/internal/pkg/enums/im.go b/internal/pkg/enums/im.go
index da23f4c3..5fa8af23 100644
--- a/internal/pkg/enums/im.go
+++ b/internal/pkg/enums/im.go
@@ -58,6 +58,7 @@ const (
IMSenderTypeCustomer IMSenderType = "customer" // 客户
IMSenderTypeAI IMSenderType = "ai" // AI
IMSenderTypeSystem IMSenderType = "system" // 系统
+ IMSenderTypeNote IMSenderType = "note" // 内部便签
)
var imSenderTypeLabelMap = map[IMSenderType]string{
@@ -65,6 +66,7 @@ var imSenderTypeLabelMap = map[IMSenderType]string{
IMSenderTypeCustomer: "客户",
IMSenderTypeAI: "AI",
IMSenderTypeSystem: "系统",
+ IMSenderTypeNote: "便签",
}
func GetIMSenderTypeLabel(senderType IMSenderType) string {
@@ -130,6 +132,7 @@ const (
IMMessageTypeImage IMMessageType = "image"
IMMessageTypeAttachment IMMessageType = "attachment"
IMMessageTypeHTML IMMessageType = "html"
+ IMMessageTypeNote IMMessageType = "note"
)
var imMessageTypeLabelMap = map[IMMessageType]string{
@@ -137,6 +140,7 @@ var imMessageTypeLabelMap = map[IMMessageType]string{
IMMessageTypeImage: "图片",
IMMessageTypeAttachment: "附件",
IMMessageTypeHTML: "富文本",
+ IMMessageTypeNote: "便签",
}
func GetIMMessageTypeLabel(messageType IMMessageType) string {
diff --git a/internal/services/message_service.go b/internal/services/message_service.go
index 68334f8b..6c351397 100644
--- a/internal/services/message_service.go
+++ b/internal/services/message_service.go
@@ -390,7 +390,7 @@ func (s *messageService) sendMessage(conversationID int64, senderType enums.IMSe
if strs.IsBlank(string(messageType)) {
messageType = enums.IMMessageTypeText
}
- conversation, err := s.ValidateConversationSender(conversationID, senderType, operator, external)
+ conversation, err := s.ValidateConversationSenderWithMessage(conversationID, senderType, messageType, operator, external)
if err != nil {
return nil, err
}
@@ -578,6 +578,17 @@ func (s *messageService) sendValidatedMessage(conversation *models.Conversation,
// handleReadState 根据发送者类型更新会话已读状态,并返回更新后的客服和客户未读消息数。
func (s *messageService) handleReadState(ctx *sqls.TxContext, senderType enums.IMSenderType, conversation *models.Conversation, operator *dto.AuthPrincipal, message *models.Message, external *openidentity.ExternalUser) (agentUnreadCount int64, customerUnreadCount int64, err error) {
+ if message.MessageType == enums.IMMessageTypeNote {
+ if operator != nil {
+ if _, err := ConversationReadStateService.MarkAgentRead(ctx, conversation, operator, message); err != nil {
+ return 0, 0, err
+ }
+ }
+ agentReadState, customerReadState := ConversationReadStateService.getConversationReadStates(ctx.Tx, conversation.ID)
+ agentUnreadCount, _ = ConversationReadStateService.CountUnreadMessages(ctx, conversation.ID, s.readMessageID(agentReadState), enums.IMSenderTypeCustomer)
+ customerUnreadCount, _ = ConversationReadStateService.CountUnreadMessages(ctx, conversation.ID, s.readMessageID(customerReadState), enums.IMSenderTypeAgent, enums.IMSenderTypeAI)
+ return agentUnreadCount, customerUnreadCount, nil
+ }
readStateType := senderType
if senderType == enums.IMSenderTypeAI {
readStateType = enums.IMSenderTypeAgent
@@ -674,6 +685,10 @@ func (s *messageService) normalizeMessageContent(conversationID int64, messageTy
}
func (s *messageService) ValidateConversationSender(conversationID int64, senderType enums.IMSenderType, operator *dto.AuthPrincipal, external *openidentity.ExternalUser) (*models.Conversation, error) {
+ return s.ValidateConversationSenderWithMessage(conversationID, senderType, enums.IMMessageTypeText, operator, external)
+}
+
+func (s *messageService) ValidateConversationSenderWithMessage(conversationID int64, senderType enums.IMSenderType, messageType enums.IMMessageType, operator *dto.AuthPrincipal, external *openidentity.ExternalUser) (*models.Conversation, error) {
conversation := ConversationService.Get(conversationID)
if conversation == nil {
return nil, errorsx.InvalidParamI18n("error.e0116")
@@ -686,11 +701,13 @@ func (s *messageService) ValidateConversationSender(conversationID int64, sender
if operator == nil {
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
}
- if conversation.Status != enums.IMConversationStatusActive || conversation.CurrentAssigneeID == 0 {
- return nil, errorsx.InvalidParamI18n("error.e0120")
- }
- if conversation.CurrentAssigneeID != operator.UserID {
- return nil, errorsx.ForbiddenI18n("error.e0191")
+ if messageType != enums.IMMessageTypeNote {
+ if conversation.Status != enums.IMConversationStatusActive || conversation.CurrentAssigneeID == 0 {
+ return nil, errorsx.InvalidParamI18n("error.e0120")
+ }
+ if conversation.CurrentAssigneeID != operator.UserID {
+ return nil, errorsx.ForbiddenI18n("error.e0191")
+ }
}
case enums.IMSenderTypeAI:
if operator == nil {
diff --git a/web/app/(dashboard)/dashboard/conversations/_components/agent-message-editor.tsx b/web/app/(dashboard)/dashboard/conversations/_components/agent-message-editor.tsx
index 78bd0784..fe0c0bb8 100644
--- a/web/app/(dashboard)/dashboard/conversations/_components/agent-message-editor.tsx
+++ b/web/app/(dashboard)/dashboard/conversations/_components/agent-message-editor.tsx
@@ -13,7 +13,7 @@ import { fetchQuickReplyListAll, type AdminQuickReply } from "@/lib/api/admin"
type AgentMessageEditorProps = {
disabled?: boolean
uploadingAsset?: boolean
- onSend: (html: string) => Promise
+ onSend: (html: string, messageType?: "html" | "note") => Promise
onUploadImage: (file: File) => Promise
onSendAttachment: (file: File) => Promise
}
diff --git a/web/app/(dashboard)/dashboard/conversations/_components/chat-panel.tsx b/web/app/(dashboard)/dashboard/conversations/_components/chat-panel.tsx
index 3b0d6491..2cb6ec22 100644
--- a/web/app/(dashboard)/dashboard/conversations/_components/chat-panel.tsx
+++ b/web/app/(dashboard)/dashboard/conversations/_components/chat-panel.tsx
@@ -255,11 +255,11 @@ export function ChatPanel() {
}
};
- const handleSend = async (html: string) => {
+ const handleSend = async (html: string, messageType?: "html" | "note") => {
if (!conversation || sending || isClosedConversation) return;
try {
shouldStickToBottomRef.current = true;
- await sendMessage(html);
+ await sendMessage(html, messageType);
} catch (error) {
toast.error(error instanceof Error ? error.message : t("conversation.sendMessageFailed"));
}
@@ -600,13 +600,16 @@ const MessageItem = memo(
? "[&_p]:text-emerald-800"
: "[&_p]:text-muted-foreground";
const showRecallAction = canRecall && !isRecalled;
+ const isNote = message.messageType === "note";
const bubbleVariant = isRecalled
? "recalled"
- : isAi
- ? "ai"
- : isAgentSide
- ? "agent"
- : "customer";
+ : isNote
+ ? "note"
+ : isAi
+ ? "ai"
+ : isAgentSide
+ ? "agent"
+ : "customer";
return (
-
- {senderName}
+
+ {isNote ? (
+
+ 🔒 {t("conversation.internalNoteBadge")}
+
+ ) : null}
+ {senderName}
void
}
- onSend: (html: string) => Promise
+ onSend: (html: string, messageType?: "html" | "note") => Promise
onUploadImage: (file: File) => Promise
onSendAttachment: (file: File) => Promise
}
@@ -76,6 +76,7 @@ export function SharedMessageEditor({
onSendAttachment,
}: SharedMessageEditorProps) {
const t = useI18n()
+ const [editorMode, setEditorMode] = useState<"reply" | "note">("reply")
const [localUploading, setLocalUploading] = useState(false)
const imageInputRef = useRef(null)
const attachmentInputRef = useRef(null)
@@ -85,11 +86,18 @@ export function SharedMessageEditor({
const shouldRestoreFocusRef = useRef(false)
const objectUrlsRef = useRef>(new Set())
const uploadedImagesRef = useRef(new Map())
- const placeholderRef = useRef(t("conversation.editorPlaceholder"))
+ const placeholderRef = useRef(
+ editorMode === "note"
+ ? t("conversation.notePlaceholder")
+ : t("conversation.editorPlaceholder")
+ )
const isCustomer = variant === "customer"
const isUploading = uploadingAsset || (manageLocalUploading && localUploading)
- placeholderRef.current = t("conversation.editorPlaceholder")
+ placeholderRef.current =
+ editorMode === "note"
+ ? t("conversation.notePlaceholder")
+ : t("conversation.editorPlaceholder")
useEffect(() => {
const objectUrls = objectUrlsRef.current
@@ -182,7 +190,7 @@ export function SharedMessageEditor({
if (!isMeaningfulHTML(html)) {
return
}
- await onSendRef.current(html)
+ await onSendRef.current(html, editorMode === "note" ? "note" : "html")
editor.commands.clearContent(true)
revokeEditorObjectUrls(objectUrlsRef.current)
uploadedImagesRef.current.clear()
@@ -415,9 +423,18 @@ export function SharedMessageEditor({
size="sm"
onClick={() => void handleSend()}
disabled={disabled || isUploading}
+ className={
+ editorMode === "note"
+ ? "bg-amber-600 hover:bg-amber-700 text-white"
+ : undefined
+ }
>
- {isUploading ? t("conversation.uploading") : t("conversation.send")}
+ {isUploading
+ ? t("conversation.uploading")
+ : editorMode === "note"
+ ? t("conversation.addNote")
+ : t("conversation.send")}
)}
@@ -437,7 +454,49 @@ export function SharedMessageEditor({
return (
-
+
+
+
+
+
{editorContent}
diff --git a/web/lib/generated/enums.ts b/web/lib/generated/enums.ts
index 95ca08e1..fa8a008c 100644
--- a/web/lib/generated/enums.ts
+++ b/web/lib/generated/enums.ts
@@ -184,12 +184,14 @@ export enum IMMessageType {
Image = "image",
Attachment = "attachment",
HTML = "html",
+ Note = "note",
}
export const IMMessageTypeLabels: Record
= {
[IMMessageType.Text]: "文本",
[IMMessageType.Image]: "图片",
[IMMessageType.Attachment]: "附件",
[IMMessageType.HTML]: "富文本",
+ [IMMessageType.Note]: "便签",
}
export enum IMParticipantType {
@@ -210,12 +212,14 @@ export enum IMSenderType {
Customer = "customer",
AI = "ai",
System = "system",
+ Note = "note",
}
export const IMSenderTypeLabels: Record = {
[IMSenderType.Agent]: "客服",
[IMSenderType.Customer]: "客户",
[IMSenderType.AI]: "AI",
[IMSenderType.System]: "系统",
+ [IMSenderType.Note]: "便签",
}
export enum KnowledgeAnswerMode {
diff --git a/web/lib/stores/agent-conversations.ts b/web/lib/stores/agent-conversations.ts
index df1c9797..172fb640 100644
--- a/web/lib/stores/agent-conversations.ts
+++ b/web/lib/stores/agent-conversations.ts
@@ -90,7 +90,7 @@ type AgentConversationsStore = {
loadOlderMessages: () => Promise
syncLatestMessages: (conversationId: number) => Promise
markSelectedConversationRead: () => Promise
- sendMessage: (html: string) => Promise
+ sendMessage: (html: string, messageType?: "html" | "note") => Promise
uploadImage: (file: File) => Promise
sendAttachment: (file: File) => Promise
recallMessage: (messageId: number) => Promise
@@ -470,7 +470,7 @@ export const useAgentConversationsStore = create((set,
}
},
- sendMessage: async (html) => {
+ sendMessage: async (html, messageType = "html") => {
const trimmedContent = html.trim()
const { selectedConversationId, sending } = get()
if (!selectedConversationId || !trimmedContent || sending) {
@@ -481,7 +481,7 @@ export const useAgentConversationsStore = create((set,
try {
const message = await sendAgentMessage({
conversationId: selectedConversationId,
- messageType: "html",
+ messageType: messageType,
content: trimmedContent,
clientMsgId: `agent_${generateUUID()}`,
})
@@ -497,8 +497,9 @@ export const useAgentConversationsStore = create((set,
conversationId: selectedConversationId,
agentUnreadCount: 0,
customerUnreadCount:
- (current.conversations.find((item) => item.id === selectedConversationId)
- ?.customerUnreadCount ?? 0) + 1,
+ messageType === "note"
+ ? (current.conversations.find((item) => item.id === selectedConversationId)?.customerUnreadCount ?? 0)
+ : (current.conversations.find((item) => item.id === selectedConversationId)?.customerUnreadCount ?? 0) + 1,
agentLastReadMessageId: message.id,
}
),
diff --git a/web/messages/en-US.json b/web/messages/en-US.json
index 02ad51ec..98e21daa 100644
--- a/web/messages/en-US.json
+++ b/web/messages/en-US.json
@@ -404,6 +404,11 @@
"recalling": "Recalling...",
"recall": "Recall",
"loadQuickRepliesFailed": "Could not load quick replies.",
+ "replyMode": "Public Reply",
+ "noteMode": "Internal Note",
+ "notePlaceholder": "Write an internal team note (not visible to customer)...",
+ "addNote": "Add Note",
+ "internalNoteBadge": "Internal Note",
"tagRemoved": "Conversation tag removed.",
"tagAdded": "Conversation tag added.",
"tagUpdateFailed": "Could not update conversation tags.",
diff --git a/web/messages/vi-VN.json b/web/messages/vi-VN.json
index 4be5ce07..57d4c721 100644
--- a/web/messages/vi-VN.json
+++ b/web/messages/vi-VN.json
@@ -411,7 +411,12 @@
"recallFailed": "Could not recall the message.",
"recalling": "Recalling...",
"recall": "Recall",
- "loadQuickRepliesFailed": "Could not load quick replies.",
+ "loadQuickRepliesFailed": "Không thể tải câu trả lời nhanh.",
+ "replyMode": "Phản hồi khách",
+ "noteMode": "Ghi chú nội bộ",
+ "notePlaceholder": "Nhập ghi chú nội bộ (chỉ nhân viên trong đội ngũ nhìn thấy)...",
+ "addNote": "Thêm ghi chú",
+ "internalNoteBadge": "Ghi chú nội bộ",
"tagRemoved": "Conversation tag removed.",
"tagAdded": "Conversation tag added.",
"tagUpdateFailed": "Could not update conversation tags.",
diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json
index 53c4bd67..18934cf5 100644
--- a/web/messages/zh-CN.json
+++ b/web/messages/zh-CN.json
@@ -404,6 +404,11 @@
"recalling": "撤回中...",
"recall": "撤回",
"loadQuickRepliesFailed": "加载快捷回复失败",
+ "replyMode": "公开回复",
+ "noteMode": "内部便签",
+ "notePlaceholder": "输入内部便签(仅客服团队可见)...",
+ "addNote": "添加便签",
+ "internalNoteBadge": "内部便签",
"tagRemoved": "已移除会话标签",
"tagAdded": "已添加会话标签",
"tagUpdateFailed": "更新会话标签失败",
From 54be4a8013611010aa8038f4dabd73a120524861 Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Wed, 2 Sep 2026 09:20:17 +0700
Subject: [PATCH 46/53] fix(models): normalize CommentReport CreatedAt column
tag for PostgreSQL compatibility
---
internal/models/models.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/internal/models/models.go b/internal/models/models.go
index 01b3e70d..80f3dd62 100644
--- a/internal/models/models.go
+++ b/internal/models/models.go
@@ -1047,7 +1047,7 @@ type CommentReport struct {
CommentID int64 `gorm:"type:bigint;not null;index;uniqueIndex:uk_comment_report"`
UserID int64 `gorm:"type:bigint;not null;index;uniqueIndex:uk_comment_report"`
Reason string `gorm:"type:varchar(255);not null;default:''"`
- CreatedAt time.Time `gorm:"type:datetime;not null;index"`
+ CreatedAt time.Time `gorm:"not null;index"`
}
// KnowledgeRetrieveLog 检索日志表。
From 793abd7faf231671c1a2cff2b236b21b311a2ef4 Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Wed, 2 Sep 2026 12:36:20 +0700
Subject: [PATCH 47/53] feat(email-worker): configure account id and lockfile
for cloudflare email inbound worker
---
.../cloudflare-email-worker/pnpm-lock.yaml | 972 ++++++++++++++++++
scripts/cloudflare-email-worker/wrangler.toml | 3 +-
2 files changed, 974 insertions(+), 1 deletion(-)
create mode 100644 scripts/cloudflare-email-worker/pnpm-lock.yaml
diff --git a/scripts/cloudflare-email-worker/pnpm-lock.yaml b/scripts/cloudflare-email-worker/pnpm-lock.yaml
new file mode 100644
index 00000000..e850837d
--- /dev/null
+++ b/scripts/cloudflare-email-worker/pnpm-lock.yaml
@@ -0,0 +1,972 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ postal-mime:
+ specifier: ^2.1.8
+ version: 2.7.6
+ devDependencies:
+ '@cloudflare/workers-types':
+ specifier: ^4.20241022.0
+ version: 4.20260702.1
+ typescript:
+ specifier: ^5.6.3
+ version: 5.9.3
+ wrangler:
+ specifier: ^3.84.1
+ version: 3.114.17(@cloudflare/workers-types@4.20260702.1)
+
+packages:
+
+ '@cloudflare/kv-asset-handler@0.3.4':
+ resolution: {integrity: sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q==}
+ engines: {node: '>=16.13'}
+
+ '@cloudflare/unenv-preset@2.0.2':
+ resolution: {integrity: sha512-nyzYnlZjjV5xT3LizahG1Iu6mnrCaxglJ04rZLpDwlDVDZ7v46lNsfxhV3A/xtfgQuSHmLnc6SVI+KwBpc3Lwg==}
+ peerDependencies:
+ unenv: 2.0.0-rc.14
+ workerd: ^1.20250124.0
+ peerDependenciesMeta:
+ workerd:
+ optional: true
+
+ '@cloudflare/workerd-darwin-64@1.20250718.0':
+ resolution: {integrity: sha512-FHf4t7zbVN8yyXgQ/r/GqLPaYZSGUVzeR7RnL28Mwj2djyw2ZergvytVc7fdGcczl6PQh+VKGfZCfUqpJlbi9g==}
+ engines: {node: '>=16'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@cloudflare/workerd-darwin-arm64@1.20250718.0':
+ resolution: {integrity: sha512-fUiyUJYyqqp4NqJ0YgGtp4WJh/II/YZsUnEb6vVy5Oeas8lUOxnN+ZOJ8N/6/5LQCVAtYCChRiIrBbfhTn5Z8Q==}
+ engines: {node: '>=16'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@cloudflare/workerd-linux-64@1.20250718.0':
+ resolution: {integrity: sha512-5+eb3rtJMiEwp08Kryqzzu8d1rUcK+gdE442auo5eniMpT170Dz0QxBrqkg2Z48SFUPYbj+6uknuA5tzdRSUSg==}
+ engines: {node: '>=16'}
+ cpu: [x64]
+ os: [linux]
+
+ '@cloudflare/workerd-linux-arm64@1.20250718.0':
+ resolution: {integrity: sha512-Aa2M/DVBEBQDdATMbn217zCSFKE+ud/teS+fFS+OQqKABLn0azO2qq6ANAHYOIE6Q3Sq4CxDIQr8lGdaJHwUog==}
+ engines: {node: '>=16'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@cloudflare/workerd-windows-64@1.20250718.0':
+ resolution: {integrity: sha512-dY16RXKffmugnc67LTbyjdDHZn5NoTF1yHEf2fN4+OaOnoGSp3N1x77QubTDwqZ9zECWxgQfDLjddcH8dWeFhg==}
+ engines: {node: '>=16'}
+ cpu: [x64]
+ os: [win32]
+
+ '@cloudflare/workers-types@4.20260702.1':
+ resolution: {integrity: sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==}
+
+ '@cspotcode/source-map-support@0.8.1':
+ resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
+ engines: {node: '>=12'}
+
+ '@emnapi/runtime@1.11.3':
+ resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
+
+ '@esbuild-plugins/node-globals-polyfill@0.2.3':
+ resolution: {integrity: sha512-r3MIryXDeXDOZh7ih1l/yE9ZLORCd5e8vWg02azWRGj5SPTuoh69A2AIyn0Z31V/kHBfZ4HgWJ+OK3GTTwLmnw==}
+ peerDependencies:
+ esbuild: '*'
+
+ '@esbuild-plugins/node-modules-polyfill@0.2.2':
+ resolution: {integrity: sha512-LXV7QsWJxRuMYvKbiznh+U1ilIop3g2TeKRzUxOG5X3YITc8JyyTa90BmLwqqv0YnX4v32CSlG+vsziZp9dMvA==}
+ peerDependencies:
+ esbuild: '*'
+
+ '@esbuild/android-arm64@0.17.19':
+ resolution: {integrity: sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm@0.17.19':
+ resolution: {integrity: sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==}
+ engines: {node: '>=12'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-x64@0.17.19':
+ resolution: {integrity: sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/darwin-arm64@0.17.19':
+ resolution: {integrity: sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.17.19':
+ resolution: {integrity: sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/freebsd-arm64@0.17.19':
+ resolution: {integrity: sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.17.19':
+ resolution: {integrity: sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/linux-arm64@0.17.19':
+ resolution: {integrity: sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.17.19':
+ resolution: {integrity: sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==}
+ engines: {node: '>=12'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.17.19':
+ resolution: {integrity: sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.17.19':
+ resolution: {integrity: sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==}
+ engines: {node: '>=12'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.17.19':
+ resolution: {integrity: sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==}
+ engines: {node: '>=12'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.17.19':
+ resolution: {integrity: sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==}
+ engines: {node: '>=12'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.17.19':
+ resolution: {integrity: sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==}
+ engines: {node: '>=12'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.17.19':
+ resolution: {integrity: sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==}
+ engines: {node: '>=12'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.17.19':
+ resolution: {integrity: sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/netbsd-x64@0.17.19':
+ resolution: {integrity: sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/openbsd-x64@0.17.19':
+ resolution: {integrity: sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/sunos-x64@0.17.19':
+ resolution: {integrity: sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/win32-arm64@0.17.19':
+ resolution: {integrity: sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.17.19':
+ resolution: {integrity: sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.17.19':
+ resolution: {integrity: sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [win32]
+
+ '@fastify/busboy@2.1.1':
+ resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==}
+ engines: {node: '>=14'}
+
+ '@img/sharp-darwin-arm64@0.33.5':
+ resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@img/sharp-darwin-x64@0.33.5':
+ resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [darwin]
+
+ '@img/sharp-libvips-darwin-arm64@1.0.4':
+ resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@img/sharp-libvips-darwin-x64@1.0.4':
+ resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@img/sharp-libvips-linux-arm64@1.0.4':
+ resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-arm@1.0.5':
+ resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==}
+ cpu: [arm]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-s390x@1.0.4':
+ resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==}
+ cpu: [s390x]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-x64@1.0.4':
+ resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==}
+ cpu: [x64]
+ os: [linux]
+
+ '@img/sharp-libvips-linuxmusl-arm64@1.0.4':
+ resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@img/sharp-libvips-linuxmusl-x64@1.0.4':
+ resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==}
+ cpu: [x64]
+ os: [linux]
+
+ '@img/sharp-linux-arm64@0.33.5':
+ resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [linux]
+
+ '@img/sharp-linux-arm@0.33.5':
+ resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm]
+ os: [linux]
+
+ '@img/sharp-linux-s390x@0.33.5':
+ resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [s390x]
+ os: [linux]
+
+ '@img/sharp-linux-x64@0.33.5':
+ resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [linux]
+
+ '@img/sharp-linuxmusl-arm64@0.33.5':
+ resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [linux]
+
+ '@img/sharp-linuxmusl-x64@0.33.5':
+ resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [linux]
+
+ '@img/sharp-wasm32@0.33.5':
+ resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [wasm32]
+
+ '@img/sharp-win32-ia32@0.33.5':
+ resolution: {integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [ia32]
+ os: [win32]
+
+ '@img/sharp-win32-x64@0.33.5':
+ resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [win32]
+
+ '@jridgewell/resolve-uri@3.1.2':
+ resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+ engines: {node: '>=6.0.0'}
+
+ '@jridgewell/sourcemap-codec@1.6.0':
+ resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
+
+ '@jridgewell/trace-mapping@0.3.9':
+ resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
+
+ acorn-walk@8.3.2:
+ resolution: {integrity: sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==}
+ engines: {node: '>=0.4.0'}
+
+ acorn@8.14.0:
+ resolution: {integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==}
+ engines: {node: '>=0.4.0'}
+ hasBin: true
+
+ as-table@1.0.55:
+ resolution: {integrity: sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==}
+
+ blake3-wasm@2.1.5:
+ resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==}
+
+ color-convert@2.0.1:
+ resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
+ engines: {node: '>=7.0.0'}
+
+ color-name@1.1.4:
+ resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+
+ color-string@1.9.1:
+ resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==}
+
+ color@4.2.3:
+ resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==}
+ engines: {node: '>=12.5.0'}
+
+ cookie@0.7.2:
+ resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
+ engines: {node: '>= 0.6'}
+
+ data-uri-to-buffer@2.0.2:
+ resolution: {integrity: sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==}
+
+ defu@6.1.7:
+ resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
+
+ detect-libc@2.1.2:
+ resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
+ engines: {node: '>=8'}
+
+ esbuild@0.17.19:
+ resolution: {integrity: sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==}
+ engines: {node: '>=12'}
+ hasBin: true
+
+ escape-string-regexp@4.0.0:
+ resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
+ engines: {node: '>=10'}
+
+ estree-walker@0.6.1:
+ resolution: {integrity: sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==}
+
+ exit-hook@2.2.1:
+ resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==}
+ engines: {node: '>=6'}
+
+ exsolve@1.1.1:
+ resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==}
+
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ get-source@2.0.12:
+ resolution: {integrity: sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==}
+
+ glob-to-regexp@0.4.1:
+ resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==}
+
+ is-arrayish@0.3.4:
+ resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==}
+
+ magic-string@0.25.9:
+ resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==}
+
+ mime@3.0.0:
+ resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==}
+ engines: {node: '>=10.0.0'}
+ hasBin: true
+
+ miniflare@3.20250718.3:
+ resolution: {integrity: sha512-JuPrDJhwLrNLEJiNLWO7ZzJrv/Vv9kZuwMYCfv0LskQDM6Eonw4OvywO3CH/wCGjgHzha/qyjUh8JQ068TjDgQ==}
+ engines: {node: '>=16.13'}
+ hasBin: true
+
+ mustache@4.2.0:
+ resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==}
+ hasBin: true
+
+ ohash@2.0.12:
+ resolution: {integrity: sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==}
+
+ path-to-regexp@6.3.0:
+ resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
+
+ pathe@2.0.3:
+ resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
+
+ postal-mime@2.7.6:
+ resolution: {integrity: sha512-UUlyE2KlxmvwMGq060onF/VWU3zD6dVW31FwokQ5v26jWd35fbs2MoTFzh+jXnPwAyV2MULMKXcBInGOl5TO6Q==}
+
+ printable-characters@1.0.42:
+ resolution: {integrity: sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==}
+
+ rollup-plugin-inject@3.0.2:
+ resolution: {integrity: sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w==}
+ deprecated: This package has been deprecated and is no longer maintained. Please use @rollup/plugin-inject.
+
+ rollup-plugin-node-polyfills@0.2.1:
+ resolution: {integrity: sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA==}
+
+ rollup-pluginutils@2.8.2:
+ resolution: {integrity: sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==}
+
+ semver@7.8.5:
+ resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
+ engines: {node: '>=10'}
+ hasBin: true
+
+ sharp@0.33.5:
+ resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+
+ simple-swizzle@0.2.4:
+ resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
+
+ source-map@0.6.1:
+ resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
+ engines: {node: '>=0.10.0'}
+
+ sourcemap-codec@1.4.8:
+ resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==}
+ deprecated: Please use @jridgewell/sourcemap-codec instead
+
+ stacktracey@2.2.0:
+ resolution: {integrity: sha512-ETyQEz+CzXiLjEbyJqpbp+/T79RQD/6wqFucRBIlVNZfYq2Ay7wbretD4cxpbymZlaPWx58aIhPEY1Cr8DlVvg==}
+
+ stoppable@1.1.0:
+ resolution: {integrity: sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==}
+ engines: {node: '>=4', npm: '>=6'}
+
+ tslib@2.8.1:
+ resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
+ typescript@5.9.3:
+ resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
+ ufo@1.6.4:
+ resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==}
+
+ undici@5.29.0:
+ resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==}
+ engines: {node: '>=14.0'}
+
+ unenv@2.0.0-rc.14:
+ resolution: {integrity: sha512-od496pShMen7nOy5VmVJCnq8rptd45vh6Nx/r2iPbrba6pa6p+tS2ywuIHRZ/OBvSbQZB0kWvpO9XBNVFXHD3Q==}
+
+ workerd@1.20250718.0:
+ resolution: {integrity: sha512-kqkIJP/eOfDlUyBzU7joBg+tl8aB25gEAGqDap+nFWb+WHhnooxjGHgxPBy3ipw2hnShPFNOQt5lFRxbwALirg==}
+ engines: {node: '>=16'}
+ hasBin: true
+
+ wrangler@3.114.17:
+ resolution: {integrity: sha512-tAvf7ly+tB+zwwrmjsCyJ2pJnnc7SZhbnNwXbH+OIdVas3zTSmjcZOjmLKcGGptssAA3RyTKhcF9BvKZzMUycA==}
+ engines: {node: '>=16.17.0'}
+ hasBin: true
+ peerDependencies:
+ '@cloudflare/workers-types': ^4.20250408.0
+ peerDependenciesMeta:
+ '@cloudflare/workers-types':
+ optional: true
+
+ ws@8.18.0:
+ resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==}
+ engines: {node: '>=10.0.0'}
+ peerDependencies:
+ bufferutil: ^4.0.1
+ utf-8-validate: '>=5.0.2'
+ peerDependenciesMeta:
+ bufferutil:
+ optional: true
+ utf-8-validate:
+ optional: true
+
+ youch@3.3.4:
+ resolution: {integrity: sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==}
+
+ zod@3.22.3:
+ resolution: {integrity: sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==}
+
+snapshots:
+
+ '@cloudflare/kv-asset-handler@0.3.4':
+ dependencies:
+ mime: 3.0.0
+
+ '@cloudflare/unenv-preset@2.0.2(unenv@2.0.0-rc.14)(workerd@1.20250718.0)':
+ dependencies:
+ unenv: 2.0.0-rc.14
+ optionalDependencies:
+ workerd: 1.20250718.0
+
+ '@cloudflare/workerd-darwin-64@1.20250718.0':
+ optional: true
+
+ '@cloudflare/workerd-darwin-arm64@1.20250718.0':
+ optional: true
+
+ '@cloudflare/workerd-linux-64@1.20250718.0':
+ optional: true
+
+ '@cloudflare/workerd-linux-arm64@1.20250718.0':
+ optional: true
+
+ '@cloudflare/workerd-windows-64@1.20250718.0':
+ optional: true
+
+ '@cloudflare/workers-types@4.20260702.1': {}
+
+ '@cspotcode/source-map-support@0.8.1':
+ dependencies:
+ '@jridgewell/trace-mapping': 0.3.9
+
+ '@emnapi/runtime@1.11.3':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@esbuild-plugins/node-globals-polyfill@0.2.3(esbuild@0.17.19)':
+ dependencies:
+ esbuild: 0.17.19
+
+ '@esbuild-plugins/node-modules-polyfill@0.2.2(esbuild@0.17.19)':
+ dependencies:
+ esbuild: 0.17.19
+ escape-string-regexp: 4.0.0
+ rollup-plugin-node-polyfills: 0.2.1
+
+ '@esbuild/android-arm64@0.17.19':
+ optional: true
+
+ '@esbuild/android-arm@0.17.19':
+ optional: true
+
+ '@esbuild/android-x64@0.17.19':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.17.19':
+ optional: true
+
+ '@esbuild/darwin-x64@0.17.19':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.17.19':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.17.19':
+ optional: true
+
+ '@esbuild/linux-arm64@0.17.19':
+ optional: true
+
+ '@esbuild/linux-arm@0.17.19':
+ optional: true
+
+ '@esbuild/linux-ia32@0.17.19':
+ optional: true
+
+ '@esbuild/linux-loong64@0.17.19':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.17.19':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.17.19':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.17.19':
+ optional: true
+
+ '@esbuild/linux-s390x@0.17.19':
+ optional: true
+
+ '@esbuild/linux-x64@0.17.19':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.17.19':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.17.19':
+ optional: true
+
+ '@esbuild/sunos-x64@0.17.19':
+ optional: true
+
+ '@esbuild/win32-arm64@0.17.19':
+ optional: true
+
+ '@esbuild/win32-ia32@0.17.19':
+ optional: true
+
+ '@esbuild/win32-x64@0.17.19':
+ optional: true
+
+ '@fastify/busboy@2.1.1': {}
+
+ '@img/sharp-darwin-arm64@0.33.5':
+ optionalDependencies:
+ '@img/sharp-libvips-darwin-arm64': 1.0.4
+ optional: true
+
+ '@img/sharp-darwin-x64@0.33.5':
+ optionalDependencies:
+ '@img/sharp-libvips-darwin-x64': 1.0.4
+ optional: true
+
+ '@img/sharp-libvips-darwin-arm64@1.0.4':
+ optional: true
+
+ '@img/sharp-libvips-darwin-x64@1.0.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-arm64@1.0.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-arm@1.0.5':
+ optional: true
+
+ '@img/sharp-libvips-linux-s390x@1.0.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-x64@1.0.4':
+ optional: true
+
+ '@img/sharp-libvips-linuxmusl-arm64@1.0.4':
+ optional: true
+
+ '@img/sharp-libvips-linuxmusl-x64@1.0.4':
+ optional: true
+
+ '@img/sharp-linux-arm64@0.33.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-arm64': 1.0.4
+ optional: true
+
+ '@img/sharp-linux-arm@0.33.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-arm': 1.0.5
+ optional: true
+
+ '@img/sharp-linux-s390x@0.33.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-s390x': 1.0.4
+ optional: true
+
+ '@img/sharp-linux-x64@0.33.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-x64': 1.0.4
+ optional: true
+
+ '@img/sharp-linuxmusl-arm64@0.33.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linuxmusl-arm64': 1.0.4
+ optional: true
+
+ '@img/sharp-linuxmusl-x64@0.33.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linuxmusl-x64': 1.0.4
+ optional: true
+
+ '@img/sharp-wasm32@0.33.5':
+ dependencies:
+ '@emnapi/runtime': 1.11.3
+ optional: true
+
+ '@img/sharp-win32-ia32@0.33.5':
+ optional: true
+
+ '@img/sharp-win32-x64@0.33.5':
+ optional: true
+
+ '@jridgewell/resolve-uri@3.1.2': {}
+
+ '@jridgewell/sourcemap-codec@1.6.0': {}
+
+ '@jridgewell/trace-mapping@0.3.9':
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.6.0
+
+ acorn-walk@8.3.2: {}
+
+ acorn@8.14.0: {}
+
+ as-table@1.0.55:
+ dependencies:
+ printable-characters: 1.0.42
+
+ blake3-wasm@2.1.5: {}
+
+ color-convert@2.0.1:
+ dependencies:
+ color-name: 1.1.4
+ optional: true
+
+ color-name@1.1.4:
+ optional: true
+
+ color-string@1.9.1:
+ dependencies:
+ color-name: 1.1.4
+ simple-swizzle: 0.2.4
+ optional: true
+
+ color@4.2.3:
+ dependencies:
+ color-convert: 2.0.1
+ color-string: 1.9.1
+ optional: true
+
+ cookie@0.7.2: {}
+
+ data-uri-to-buffer@2.0.2: {}
+
+ defu@6.1.7: {}
+
+ detect-libc@2.1.2:
+ optional: true
+
+ esbuild@0.17.19:
+ optionalDependencies:
+ '@esbuild/android-arm': 0.17.19
+ '@esbuild/android-arm64': 0.17.19
+ '@esbuild/android-x64': 0.17.19
+ '@esbuild/darwin-arm64': 0.17.19
+ '@esbuild/darwin-x64': 0.17.19
+ '@esbuild/freebsd-arm64': 0.17.19
+ '@esbuild/freebsd-x64': 0.17.19
+ '@esbuild/linux-arm': 0.17.19
+ '@esbuild/linux-arm64': 0.17.19
+ '@esbuild/linux-ia32': 0.17.19
+ '@esbuild/linux-loong64': 0.17.19
+ '@esbuild/linux-mips64el': 0.17.19
+ '@esbuild/linux-ppc64': 0.17.19
+ '@esbuild/linux-riscv64': 0.17.19
+ '@esbuild/linux-s390x': 0.17.19
+ '@esbuild/linux-x64': 0.17.19
+ '@esbuild/netbsd-x64': 0.17.19
+ '@esbuild/openbsd-x64': 0.17.19
+ '@esbuild/sunos-x64': 0.17.19
+ '@esbuild/win32-arm64': 0.17.19
+ '@esbuild/win32-ia32': 0.17.19
+ '@esbuild/win32-x64': 0.17.19
+
+ escape-string-regexp@4.0.0: {}
+
+ estree-walker@0.6.1: {}
+
+ exit-hook@2.2.1: {}
+
+ exsolve@1.1.1: {}
+
+ fsevents@2.3.3:
+ optional: true
+
+ get-source@2.0.12:
+ dependencies:
+ data-uri-to-buffer: 2.0.2
+ source-map: 0.6.1
+
+ glob-to-regexp@0.4.1: {}
+
+ is-arrayish@0.3.4:
+ optional: true
+
+ magic-string@0.25.9:
+ dependencies:
+ sourcemap-codec: 1.4.8
+
+ mime@3.0.0: {}
+
+ miniflare@3.20250718.3:
+ dependencies:
+ '@cspotcode/source-map-support': 0.8.1
+ acorn: 8.14.0
+ acorn-walk: 8.3.2
+ exit-hook: 2.2.1
+ glob-to-regexp: 0.4.1
+ stoppable: 1.1.0
+ undici: 5.29.0
+ workerd: 1.20250718.0
+ ws: 8.18.0
+ youch: 3.3.4
+ zod: 3.22.3
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+
+ mustache@4.2.0: {}
+
+ ohash@2.0.12: {}
+
+ path-to-regexp@6.3.0: {}
+
+ pathe@2.0.3: {}
+
+ postal-mime@2.7.6: {}
+
+ printable-characters@1.0.42: {}
+
+ rollup-plugin-inject@3.0.2:
+ dependencies:
+ estree-walker: 0.6.1
+ magic-string: 0.25.9
+ rollup-pluginutils: 2.8.2
+
+ rollup-plugin-node-polyfills@0.2.1:
+ dependencies:
+ rollup-plugin-inject: 3.0.2
+
+ rollup-pluginutils@2.8.2:
+ dependencies:
+ estree-walker: 0.6.1
+
+ semver@7.8.5:
+ optional: true
+
+ sharp@0.33.5:
+ dependencies:
+ color: 4.2.3
+ detect-libc: 2.1.2
+ semver: 7.8.5
+ optionalDependencies:
+ '@img/sharp-darwin-arm64': 0.33.5
+ '@img/sharp-darwin-x64': 0.33.5
+ '@img/sharp-libvips-darwin-arm64': 1.0.4
+ '@img/sharp-libvips-darwin-x64': 1.0.4
+ '@img/sharp-libvips-linux-arm': 1.0.5
+ '@img/sharp-libvips-linux-arm64': 1.0.4
+ '@img/sharp-libvips-linux-s390x': 1.0.4
+ '@img/sharp-libvips-linux-x64': 1.0.4
+ '@img/sharp-libvips-linuxmusl-arm64': 1.0.4
+ '@img/sharp-libvips-linuxmusl-x64': 1.0.4
+ '@img/sharp-linux-arm': 0.33.5
+ '@img/sharp-linux-arm64': 0.33.5
+ '@img/sharp-linux-s390x': 0.33.5
+ '@img/sharp-linux-x64': 0.33.5
+ '@img/sharp-linuxmusl-arm64': 0.33.5
+ '@img/sharp-linuxmusl-x64': 0.33.5
+ '@img/sharp-wasm32': 0.33.5
+ '@img/sharp-win32-ia32': 0.33.5
+ '@img/sharp-win32-x64': 0.33.5
+ optional: true
+
+ simple-swizzle@0.2.4:
+ dependencies:
+ is-arrayish: 0.3.4
+ optional: true
+
+ source-map@0.6.1: {}
+
+ sourcemap-codec@1.4.8: {}
+
+ stacktracey@2.2.0:
+ dependencies:
+ as-table: 1.0.55
+ get-source: 2.0.12
+
+ stoppable@1.1.0: {}
+
+ tslib@2.8.1:
+ optional: true
+
+ typescript@5.9.3: {}
+
+ ufo@1.6.4: {}
+
+ undici@5.29.0:
+ dependencies:
+ '@fastify/busboy': 2.1.1
+
+ unenv@2.0.0-rc.14:
+ dependencies:
+ defu: 6.1.7
+ exsolve: 1.1.1
+ ohash: 2.0.12
+ pathe: 2.0.3
+ ufo: 1.6.4
+
+ workerd@1.20250718.0:
+ optionalDependencies:
+ '@cloudflare/workerd-darwin-64': 1.20250718.0
+ '@cloudflare/workerd-darwin-arm64': 1.20250718.0
+ '@cloudflare/workerd-linux-64': 1.20250718.0
+ '@cloudflare/workerd-linux-arm64': 1.20250718.0
+ '@cloudflare/workerd-windows-64': 1.20250718.0
+
+ wrangler@3.114.17(@cloudflare/workers-types@4.20260702.1):
+ dependencies:
+ '@cloudflare/kv-asset-handler': 0.3.4
+ '@cloudflare/unenv-preset': 2.0.2(unenv@2.0.0-rc.14)(workerd@1.20250718.0)
+ '@esbuild-plugins/node-globals-polyfill': 0.2.3(esbuild@0.17.19)
+ '@esbuild-plugins/node-modules-polyfill': 0.2.2(esbuild@0.17.19)
+ blake3-wasm: 2.1.5
+ esbuild: 0.17.19
+ miniflare: 3.20250718.3
+ path-to-regexp: 6.3.0
+ unenv: 2.0.0-rc.14
+ workerd: 1.20250718.0
+ optionalDependencies:
+ '@cloudflare/workers-types': 4.20260702.1
+ fsevents: 2.3.3
+ sharp: 0.33.5
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+
+ ws@8.18.0: {}
+
+ youch@3.3.4:
+ dependencies:
+ cookie: 0.7.2
+ mustache: 4.2.0
+ stacktracey: 2.2.0
+
+ zod@3.22.3: {}
diff --git a/scripts/cloudflare-email-worker/wrangler.toml b/scripts/cloudflare-email-worker/wrangler.toml
index 050493dc..3c81961b 100644
--- a/scripts/cloudflare-email-worker/wrangler.toml
+++ b/scripts/cloudflare-email-worker/wrangler.toml
@@ -1,7 +1,8 @@
name = "crove-email-inbound-worker"
main = "src/index.ts"
compatibility_date = "2024-11-05"
+account_id = "3368ff98a4c956164b7bbdc8fb950163"
[vars]
DESK_WEBHOOK_URL = "https://desk.crove.com/api/third/email/webhook"
-DESK_WEBHOOK_SECRET = "your-email-inbound-webhook-secret"
+DESK_WEBHOOK_SECRET = "crove_email_secret_token_123"
From a9b481fe706d16e3a0e8d705a8cb6f83b4b0d261 Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Wed, 2 Sep 2026 13:54:09 +0700
Subject: [PATCH 48/53] fix(ai): add robust ChatWithTools fallback in
einoAgentLoop to prevent exceeds max steps failures
---
internal/ai/application/runtime/eino_agent_loop.go | 4 +++-
scripts/cloudflare-email-worker/wrangler.toml | 2 +-
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/internal/ai/application/runtime/eino_agent_loop.go b/internal/ai/application/runtime/eino_agent_loop.go
index dad2c9f8..9a1f2631 100644
--- a/internal/ai/application/runtime/eino_agent_loop.go
+++ b/internal/ai/application/runtime/eino_agent_loop.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "log/slog"
"strings"
"time"
@@ -61,7 +62,8 @@ func einoAgentLoop(
messages = append(messages, schema.UserMessage(strings.TrimSpace(userPrompt)))
result, err := agent.Generate(ctx, messages)
if err != nil {
- return nil, err
+ slog.Warn("eino agent loop failed, falling back to standard ChatWithTools", "error", err)
+ return ai.LLM.ChatWithTools(ctx, config, systemPrompt, userPrompt, definitions, maxSteps, execute)
}
if result == nil {
return nil, fmt.Errorf("Eino agent loop returned no result")
diff --git a/scripts/cloudflare-email-worker/wrangler.toml b/scripts/cloudflare-email-worker/wrangler.toml
index 3c81961b..bf7aae36 100644
--- a/scripts/cloudflare-email-worker/wrangler.toml
+++ b/scripts/cloudflare-email-worker/wrangler.toml
@@ -1,7 +1,7 @@
name = "crove-email-inbound-worker"
main = "src/index.ts"
compatibility_date = "2024-11-05"
-account_id = "3368ff98a4c956164b7bbdc8fb950163"
+account_id = "5f2a58925e790423dfafa0e6bee46b28"
[vars]
DESK_WEBHOOK_URL = "https://desk.crove.com/api/third/email/webhook"
From 69107bc0b5bf29ff04775311e0987327c071ad66 Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Wed, 2 Sep 2026 14:23:15 +0700
Subject: [PATCH 49/53] chore(deps): upgrade eino to v0.9.19 and typescript to
v7.0.2
- Upgrade github.com/cloudwego/eino to v0.9.19 with latest eino-ext components
- Upgrade TypeScript to v7.0.2 across web, cloudflare-email-worker, and flowgram-editor
- Upgrade @types/node to v22 and update Go dependencies (golang.org/x/crypto, net, tools)
---
flowgram-editor/package.json | 2 +-
go.mod | 38 +--
go.sum | 81 +++--
scripts/cloudflare-email-worker/package.json | 2 +-
web/package.json | 4 +-
web/pnpm-lock.yaml | 327 +++++++++++++++----
web/pnpm-workspace.yaml | 5 +
7 files changed, 333 insertions(+), 126 deletions(-)
diff --git a/flowgram-editor/package.json b/flowgram-editor/package.json
index e5ced5da..de2e16a2 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": "^7.0.2",
"eslint": "^9.0.0",
"cross-env": "~7.0.3",
"@flowgram.ai/eslint-config": "1.0.12",
diff --git a/go.mod b/go.mod
index e62257c2..82e7a279 100644
--- a/go.mod
+++ b/go.mod
@@ -4,7 +4,7 @@ go 1.26.0
require (
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible
- github.com/cloudwego/eino v0.9.6
+ github.com/cloudwego/eino v0.9.19
github.com/cloudwego/eino-ext/components/model/openai v0.1.13
github.com/coreos/go-oidc/v3 v3.18.0
github.com/eino-contrib/jsonschema v1.0.3
@@ -27,13 +27,14 @@ 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/yuin/goldmark v1.4.13
- golang.org/x/crypto v0.53.0
- golang.org/x/net v0.56.0
+ golang.org/x/crypto v0.55.0
+ golang.org/x/net v0.58.0
golang.org/x/oauth2 v0.36.0
- golang.org/x/tools v0.46.0
+ golang.org/x/tools v0.49.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.5.7
gorm.io/driver/postgres v1.6.2
@@ -50,9 +51,8 @@ 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
+ go.yaml.in/yaml/v3 v3.0.5 // indirect
+ golang.org/x/text v0.41.0 // indirect
)
require (
@@ -60,10 +60,10 @@ require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/bradfitz/gomemcache v0.0.0-20220106215444-fb4bf637b56d // indirect
- github.com/buger/jsonparser v1.2.0 // indirect
+ github.com/buger/jsonparser v1.6.1 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
- github.com/bytedance/sonic v1.15.2 // indirect
- github.com/bytedance/sonic/loader v0.5.1 // indirect
+ github.com/bytedance/sonic v1.15.3 // indirect
+ github.com/bytedance/sonic/loader v0.5.2 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.7 // indirect
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 // indirect
@@ -90,7 +90,7 @@ require (
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.4 // indirect
- github.com/klauspost/cpuid/v2 v2.3.0 // indirect
+ github.com/klauspost/cpuid/v2 v2.4.0 // indirect
github.com/lancedb/lancedb-go v0.1.2
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.9.2 // indirect
@@ -101,7 +101,7 @@ require (
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/nikolalohinski/gonja v1.5.3 // indirect
github.com/nxadm/tail v1.4.11 // indirect
- github.com/pelletier/go-toml/v2 v2.3.1 // indirect
+ github.com/pelletier/go-toml/v2 v2.4.3 // indirect
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
@@ -111,7 +111,7 @@ require (
github.com/richardlehane/msoleps v1.0.6 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
- github.com/sirupsen/logrus v1.9.4 // indirect
+ github.com/sirupsen/logrus v1.10.2 // indirect
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
@@ -127,12 +127,12 @@ require (
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
- golang.org/x/sync v0.21.0 // indirect
- golang.org/x/sys v0.46.0 // indirect
- golang.org/x/telemetry v0.0.0-20260610154732-fb80ec83bdd9 // indirect
+ golang.org/x/arch v0.30.0 // indirect
+ golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect
+ golang.org/x/mod v0.39.0 // indirect
+ golang.org/x/sync v0.22.0 // indirect
+ golang.org/x/sys v0.47.0 // indirect
+ golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 // 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
diff --git a/go.sum b/go.sum
index cdf24e50..5e077bc4 100644
--- a/go.sum
+++ b/go.sum
@@ -15,18 +15,18 @@ github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngE
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
github.com/bradfitz/gomemcache v0.0.0-20220106215444-fb4bf637b56d h1:pVrfxiGfwelyab6n21ZBkbkmbevaf+WvMIiR7sr97hw=
github.com/bradfitz/gomemcache v0.0.0-20220106215444-fb4bf637b56d/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA=
-github.com/buger/jsonparser v1.2.0 h1:4EFcvK1kD4jyj6YqNK6skK6w+y7FHHBR+XBCtxwu/6g=
-github.com/buger/jsonparser v1.2.0/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
+github.com/buger/jsonparser v1.6.1 h1:I0phFv0PlbLHnM7TZAVjZ2MJ2/eWRTDyuO7GLR98IEs=
+github.com/buger/jsonparser v1.6.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/bugsnag/bugsnag-go v1.4.0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8=
github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE=
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
github.com/bytedance/mockey v1.3.0 h1:ONLRdvhqmCfr9rTasUB8ZKCfvbdD2tohOg4u+4Q/ed0=
github.com/bytedance/mockey v1.3.0/go.mod h1:1BPHF9sol5R1ud/+0VEHGQq/+i2lN+GTsr3O2Q9IENY=
-github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo=
-github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
-github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
-github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
+github.com/bytedance/sonic v1.15.3 h1:P3akjLPBtV/i6bHC6LbcLjY3KuoOvfiqF8wFHeP5IhY=
+github.com/bytedance/sonic v1.15.3/go.mod h1:8e51yTPdY8M6t+vvGL1c2Y1xL9i+frEeIAQAEl75NUc=
+github.com/bytedance/sonic/loader v0.5.2 h1:0QtP1gevc1OZ6/H8Lb9BRZiCXd1Ftjd3OKuj1T1lBIo=
+github.com/bytedance/sonic/loader v0.5.2/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@@ -36,8 +36,8 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5P
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg=
-github.com/cloudwego/eino v0.9.6 h1:M3IRhIpDxNwIuQ2SRUX1yUTkC8lcMggRI5wtHZy8A5M=
-github.com/cloudwego/eino v0.9.6/go.mod h1:OBD1mrkfkt/pJa4rkg1P0VnaMeOVl7l8IAdEqY//3IQ=
+github.com/cloudwego/eino v0.9.19 h1:i71YUBK3nwY4L53dkzRgZpAcPSZ4v4eRponN7W9sDtk=
+github.com/cloudwego/eino v0.9.19/go.mod h1:OBD1mrkfkt/pJa4rkg1P0VnaMeOVl7l8IAdEqY//3IQ=
github.com/cloudwego/eino-ext/components/model/openai v0.1.13 h1:5XHRTiTD5bt9KQrMHcfvuWNklEC3tpm3XHejdozt9vM=
github.com/cloudwego/eino-ext/components/model/openai v0.1.13/go.mod h1:mgIoqYYOc0eECCqvLbEYpOJrQNTNxkwXzSJzFU+v5sQ=
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 h1:EeVcR1TslRA2IdNW1h/2LaGbPlffwGhQm99jM3zWZiI=
@@ -169,8 +169,8 @@ github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfV
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
-github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
-github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
+github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
+github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -235,14 +235,13 @@ github.com/openai/openai-go/v3 v3.28.0 h1:2+FfrCVMdGXSQrBv1tLWtokm+BU7+3hJ/8rAHP
github.com/openai/openai-go/v3 v3.28.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
github.com/panjf2000/ants/v2 v2.12.0 h1:u9JhESo83i/GkZnhfTNuFMMWcNt7mnV1bGJ6FT4wXH8=
github.com/panjf2000/ants/v2 v2.12.0/go.mod h1:tSQuaNQ6r6NRhPt+IZVUevvDyFMTs+eS4ztZc52uJTY=
-github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
-github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
+github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/qdrant/go-client v1.17.1 h1:7QmPwDddrHL3hC4NfycwtQlraVKRLcRi++BX6TTm+3g=
github.com/qdrant/go-client v1.17.1/go.mod h1:n1h6GhkdAzcohoXt/5Z19I2yxbCkMA6Jejob3S6NZT8=
@@ -271,8 +270,8 @@ github.com/silenceper/wechat/v2 v2.1.12 h1:hoBeuL7Mgafz/ox6rn6r02rffFxHZhu7E0SlD
github.com/silenceper/wechat/v2 v2.1.12/go.mod h1:7Iu3EhQYVtDUJAj+ZVRy8yom75ga7aDWv8RurLkVm0s=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
-github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
-github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
+github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo=
+github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q=
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f h1:Z2cODYsUxQPofhpYRMQVwWz4yUVpHF+vPi+eUdruUYI=
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f/go.mod h1:JqzWyvTuI2X4+9wOHmKSQCYxybB/8j6Ko43qVmXDuZg=
github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY=
@@ -303,8 +302,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
-github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
-github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
+github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tidwall/gjson v1.14.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
@@ -363,24 +362,24 @@ go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4Len
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
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=
-go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
-golang.org/x/arch v0.28.0 h1:wVwVdqsTuUbJvhYVCspQYwZXHNYeLSoZnmHD+ggddpQ=
-golang.org/x/arch v0.28.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
+go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
+go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
+golang.org/x/arch v0.30.0 h1:sB9h+1gRGa2+LauFSV0tm8bK1J2yo1bx6/Uyi/P6DTU=
+golang.org/x/arch v0.30.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
-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/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
+golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
+golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa h1:QSyA8ishJCyT21kER9KwNt0b7BM3iRK4x9QXhjN5Fdk=
+golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa/go.mod h1:zeBbvyFKDaLwa7CH/zI8KXt7gTl14SF7sO08Pl5jBCM=
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
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=
+golang.org/x/mod v0.39.0 h1:UF5zwQdCRRUpHfyPwr7d4UrGiVeldIsogtzWVnczL74=
+golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -388,15 +387,15 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
-golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
+golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
+golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
-golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
+golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -416,25 +415,25 @@ golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
-golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
-golang.org/x/telemetry v0.0.0-20260610154732-fb80ec83bdd9 h1:FjUup8XrRy7lv+XHONi6KKUSizeF2NnVrTnz/HhbohQ=
-golang.org/x/telemetry v0.0.0-20260610154732-fb80ec83bdd9/go.mod h1:3AWMyWHS+caVoiEXpiq6+tzKA40J4vQT3MYr80ZtQpc=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 h1:ZUSxONxc981v7AW7QUg+I9WwZzSTTJ019ENBYr5pV/Q=
+golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5/go.mod h1:LVehoXe41cL5SCVQilsV7Gg6BNG+Js6P9PhSbYTIUkQ=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
-golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
+golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
+golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
-golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
+golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
+golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk=
-golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys=
+golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
+golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
diff --git a/scripts/cloudflare-email-worker/package.json b/scripts/cloudflare-email-worker/package.json
index f148a394..8421008f 100644
--- a/scripts/cloudflare-email-worker/package.json
+++ b/scripts/cloudflare-email-worker/package.json
@@ -11,7 +11,7 @@
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20241022.0",
- "typescript": "^5.6.3",
+ "typescript": "^7.0.2",
"wrangler": "^3.84.1"
}
}
diff --git a/web/package.json b/web/package.json
index 8fbc66aa..a21ac934 100644
--- a/web/package.json
+++ b/web/package.json
@@ -60,7 +60,7 @@
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/markdown-it": "^14.1.2",
- "@types/node": "^20",
+ "@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/turndown": "^5.0.6",
@@ -69,6 +69,6 @@
"sass": "^1.98.0",
"tailwindcss": "^4",
"terser": "^5.46.2",
- "typescript": "^5"
+ "typescript": "^7.0.2"
}
}
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
index 404d95eb..c7e8bb7c 100644
--- a/web/pnpm-lock.yaml
+++ b/web/pnpm-lock.yaml
@@ -121,7 +121,7 @@ importers:
version: 4.0.1
shadcn:
specifier: ^4.18.0
- version: 4.18.0(typescript@5.9.3)
+ version: 4.18.0(typescript@7.0.2)
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -151,8 +151,8 @@ importers:
specifier: ^14.1.2
version: 14.1.2
'@types/node':
- specifier: ^20
- version: 20.19.37
+ specifier: ^22
+ version: 22.20.1
'@types/react':
specifier: ^19
version: 19.2.14
@@ -167,7 +167,7 @@ importers:
version: 9.39.4(jiti@2.6.1)
eslint-config-next:
specifier: 16.1.6
- version: 16.1.6(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ version: 16.1.6(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2))(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2)
sass:
specifier: ^1.98.0
version: 1.98.0
@@ -178,8 +178,8 @@ importers:
specifier: ^5.46.2
version: 5.46.2
typescript:
- specifier: ^5
- version: 5.9.3
+ specifier: ^7.0.2
+ version: 7.0.2
packages:
@@ -1603,8 +1603,8 @@ packages:
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
- '@types/node@20.19.37':
- resolution: {integrity: sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==}
+ '@types/node@22.20.1':
+ resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==}
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
@@ -1691,6 +1691,126 @@ packages:
resolution: {integrity: sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@typescript/typescript-aix-ppc64@7.0.2':
+ resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@typescript/typescript-darwin-arm64@7.0.2':
+ resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@typescript/typescript-darwin-x64@7.0.2':
+ resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==}
+ engines: {node: '>=16.20.0'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@typescript/typescript-freebsd-arm64@7.0.2':
+ resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@typescript/typescript-freebsd-x64@7.0.2':
+ resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==}
+ engines: {node: '>=16.20.0'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@typescript/typescript-linux-arm64@7.0.2':
+ resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@typescript/typescript-linux-arm@7.0.2':
+ resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm]
+ os: [linux]
+
+ '@typescript/typescript-linux-loong64@7.0.2':
+ resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@typescript/typescript-linux-mips64el@7.0.2':
+ resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==}
+ engines: {node: '>=16.20.0'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@typescript/typescript-linux-ppc64@7.0.2':
+ resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==}
+ engines: {node: '>=16.20.0'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@typescript/typescript-linux-riscv64@7.0.2':
+ resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@typescript/typescript-linux-s390x@7.0.2':
+ resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==}
+ engines: {node: '>=16.20.0'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@typescript/typescript-linux-x64@7.0.2':
+ resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==}
+ engines: {node: '>=16.20.0'}
+ cpu: [x64]
+ os: [linux]
+
+ '@typescript/typescript-netbsd-arm64@7.0.2':
+ resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@typescript/typescript-netbsd-x64@7.0.2':
+ resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==}
+ engines: {node: '>=16.20.0'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@typescript/typescript-openbsd-arm64@7.0.2':
+ resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm64]
+ os: [openbsd]
+
+ '@typescript/typescript-openbsd-x64@7.0.2':
+ resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==}
+ engines: {node: '>=16.20.0'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@typescript/typescript-sunos-x64@7.0.2':
+ resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==}
+ engines: {node: '>=16.20.0'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@typescript/typescript-win32-arm64@7.0.2':
+ resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@typescript/typescript-win32-x64@7.0.2':
+ resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==}
+ engines: {node: '>=16.20.0'}
+ cpu: [x64]
+ os: [win32]
+
'@uiw/react-json-view@2.0.0-alpha.41':
resolution: {integrity: sha512-botRpQ5AgymYEsqXSdT2/1LefAJEYfMntvdnx1SqhTQCTW9HygeFZXx9inkYqUmiQZ3+0QlZnodjBvwnUfZhVA==}
peerDependencies:
@@ -2584,6 +2704,7 @@ packages:
eslint@9.39.4:
resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==}
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: '*'
@@ -4008,6 +4129,7 @@ packages:
recharts@2.15.4:
resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==}
engines: {node: '>=14'}
+ deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide
peerDependencies:
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -4427,9 +4549,9 @@ packages:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.0.0'
- typescript@5.9.3:
- resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
- engines: {node: '>=14.17'}
+ typescript@7.0.2:
+ resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==}
+ engines: {node: '>=16.20.0'}
hasBin: true
uc.micro@2.1.0:
@@ -6229,7 +6351,7 @@ snapshots:
'@types/ms@2.1.0': {}
- '@types/node@20.19.37':
+ '@types/node@22.20.1':
dependencies:
undici-types: 6.21.0
@@ -6254,40 +6376,40 @@ snapshots:
'@types/validate-npm-package-name@4.0.2': {}
- '@typescript-eslint/eslint-plugin@8.57.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
+ '@typescript-eslint/eslint-plugin@8.57.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2))(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2)':
dependencies:
'@eslint-community/regexpp': 4.12.2
- '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2)
'@typescript-eslint/scope-manager': 8.57.0
- '@typescript-eslint/type-utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
- '@typescript-eslint/utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/type-utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2)
+ '@typescript-eslint/utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2)
'@typescript-eslint/visitor-keys': 8.57.0
eslint: 9.39.4(jiti@2.6.1)
ignore: 7.0.5
natural-compare: 1.4.0
- ts-api-utils: 2.4.0(typescript@5.9.3)
- typescript: 5.9.3
+ ts-api-utils: 2.4.0(typescript@7.0.2)
+ typescript: 7.0.2
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
+ '@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2)':
dependencies:
'@typescript-eslint/scope-manager': 8.57.0
'@typescript-eslint/types': 8.57.0
- '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3)
+ '@typescript-eslint/typescript-estree': 8.57.0(typescript@7.0.2)
'@typescript-eslint/visitor-keys': 8.57.0
debug: 4.4.3
eslint: 9.39.4(jiti@2.6.1)
- typescript: 5.9.3
+ typescript: 7.0.2
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/project-service@8.57.0(typescript@5.9.3)':
+ '@typescript-eslint/project-service@8.57.0(typescript@7.0.2)':
dependencies:
- '@typescript-eslint/tsconfig-utils': 8.57.0(typescript@5.9.3)
+ '@typescript-eslint/tsconfig-utils': 8.57.0(typescript@7.0.2)
'@typescript-eslint/types': 8.57.0
debug: 4.4.3
- typescript: 5.9.3
+ typescript: 7.0.2
transitivePeerDependencies:
- supports-color
@@ -6296,47 +6418,47 @@ snapshots:
'@typescript-eslint/types': 8.57.0
'@typescript-eslint/visitor-keys': 8.57.0
- '@typescript-eslint/tsconfig-utils@8.57.0(typescript@5.9.3)':
+ '@typescript-eslint/tsconfig-utils@8.57.0(typescript@7.0.2)':
dependencies:
- typescript: 5.9.3
+ typescript: 7.0.2
- '@typescript-eslint/type-utils@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
+ '@typescript-eslint/type-utils@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2)':
dependencies:
'@typescript-eslint/types': 8.57.0
- '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3)
- '@typescript-eslint/utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/typescript-estree': 8.57.0(typescript@7.0.2)
+ '@typescript-eslint/utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2)
debug: 4.4.3
eslint: 9.39.4(jiti@2.6.1)
- ts-api-utils: 2.4.0(typescript@5.9.3)
- typescript: 5.9.3
+ ts-api-utils: 2.4.0(typescript@7.0.2)
+ typescript: 7.0.2
transitivePeerDependencies:
- supports-color
'@typescript-eslint/types@8.57.0': {}
- '@typescript-eslint/typescript-estree@8.57.0(typescript@5.9.3)':
+ '@typescript-eslint/typescript-estree@8.57.0(typescript@7.0.2)':
dependencies:
- '@typescript-eslint/project-service': 8.57.0(typescript@5.9.3)
- '@typescript-eslint/tsconfig-utils': 8.57.0(typescript@5.9.3)
+ '@typescript-eslint/project-service': 8.57.0(typescript@7.0.2)
+ '@typescript-eslint/tsconfig-utils': 8.57.0(typescript@7.0.2)
'@typescript-eslint/types': 8.57.0
'@typescript-eslint/visitor-keys': 8.57.0
debug: 4.4.3
minimatch: 10.2.4
semver: 7.7.4
tinyglobby: 0.2.15
- ts-api-utils: 2.4.0(typescript@5.9.3)
- typescript: 5.9.3
+ ts-api-utils: 2.4.0(typescript@7.0.2)
+ typescript: 7.0.2
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/utils@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
+ '@typescript-eslint/utils@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2)':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
'@typescript-eslint/scope-manager': 8.57.0
'@typescript-eslint/types': 8.57.0
- '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3)
+ '@typescript-eslint/typescript-estree': 8.57.0(typescript@7.0.2)
eslint: 9.39.4(jiti@2.6.1)
- typescript: 5.9.3
+ typescript: 7.0.2
transitivePeerDependencies:
- supports-color
@@ -6345,6 +6467,66 @@ snapshots:
'@typescript-eslint/types': 8.57.0
eslint-visitor-keys: 5.0.1
+ '@typescript/typescript-aix-ppc64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-darwin-arm64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-darwin-x64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-freebsd-arm64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-freebsd-x64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-linux-arm64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-linux-arm@7.0.2':
+ optional: true
+
+ '@typescript/typescript-linux-loong64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-linux-mips64el@7.0.2':
+ optional: true
+
+ '@typescript/typescript-linux-ppc64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-linux-riscv64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-linux-s390x@7.0.2':
+ optional: true
+
+ '@typescript/typescript-linux-x64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-netbsd-arm64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-netbsd-x64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-openbsd-arm64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-openbsd-x64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-sunos-x64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-win32-arm64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-win32-x64@7.0.2':
+ optional: true
+
'@uiw/react-json-view@2.0.0-alpha.41(@babel/runtime@7.28.6)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@babel/runtime': 7.28.6
@@ -6721,14 +6903,14 @@ snapshots:
dependencies:
layout-base: 2.0.1
- cosmiconfig@9.0.1(typescript@5.9.3):
+ cosmiconfig@9.0.1(typescript@7.0.2):
dependencies:
env-paths: 2.2.1
import-fresh: 3.3.1
js-yaml: 4.1.1
parse-json: 5.2.0
optionalDependencies:
- typescript: 5.9.3
+ typescript: 7.0.2
crelt@1.0.6: {}
@@ -7176,20 +7358,20 @@ snapshots:
escape-string-regexp@5.0.0: {}
- eslint-config-next@16.1.6(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3):
+ eslint-config-next@16.1.6(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2))(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2):
dependencies:
'@next/eslint-plugin-next': 16.1.6
eslint: 9.39.4(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1))
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-react-hooks: 7.0.1(eslint@9.39.4(jiti@2.6.1))
globals: 16.4.0
- typescript-eslint: 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ typescript-eslint: 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2)
optionalDependencies:
- typescript: 5.9.3
+ typescript: 7.0.2
transitivePeerDependencies:
- '@typescript-eslint/parser'
- eslint-import-resolver-webpack
@@ -7215,22 +7397,22 @@ snapshots:
tinyglobby: 0.2.15
unrs-resolver: 1.11.1
optionalDependencies:
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
transitivePeerDependencies:
- supports-color
- eslint-module-utils@2.12.1(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)):
+ eslint-module-utils@2.12.1(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)):
dependencies:
debug: 3.2.7
optionalDependencies:
- '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2)
eslint: 9.39.4(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1))
transitivePeerDependencies:
- supports-color
- eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)):
+ eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -7241,7 +7423,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.39.4(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
- eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
+ eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@@ -7253,7 +7435,7 @@ snapshots:
string.prototype.trimend: 1.0.9
tsconfig-paths: 3.15.0
optionalDependencies:
- '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2)
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
@@ -9283,7 +9465,7 @@ snapshots:
setprototypeof@1.2.0: {}
- shadcn@4.18.0(typescript@5.9.3):
+ shadcn@4.18.0(typescript@7.0.2):
dependencies:
'@babel/core': 7.29.0
'@babel/parser': 7.29.0
@@ -9294,7 +9476,7 @@ snapshots:
'@types/validate-npm-package-name': 4.0.2
browserslist: 4.28.1
commander: 14.0.3
- cosmiconfig: 9.0.1(typescript@5.9.3)
+ cosmiconfig: 9.0.1(typescript@7.0.2)
dedent: 1.7.2
deepmerge: 4.3.1
diff: 8.0.3
@@ -9570,9 +9752,9 @@ snapshots:
trough@2.2.0: {}
- ts-api-utils@2.4.0(typescript@5.9.3):
+ ts-api-utils@2.4.0(typescript@7.0.2):
dependencies:
- typescript: 5.9.3
+ typescript: 7.0.2
ts-dedent@2.3.0: {}
@@ -9645,18 +9827,39 @@ snapshots:
possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10
- typescript-eslint@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3):
+ typescript-eslint@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2):
dependencies:
- '@typescript-eslint/eslint-plugin': 8.57.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
- '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
- '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3)
- '@typescript-eslint/utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/eslint-plugin': 8.57.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2))(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2)
+ '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2)
+ '@typescript-eslint/typescript-estree': 8.57.0(typescript@7.0.2)
+ '@typescript-eslint/utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2)
eslint: 9.39.4(jiti@2.6.1)
- typescript: 5.9.3
+ typescript: 7.0.2
transitivePeerDependencies:
- supports-color
- typescript@5.9.3: {}
+ typescript@7.0.2:
+ optionalDependencies:
+ '@typescript/typescript-aix-ppc64': 7.0.2
+ '@typescript/typescript-darwin-arm64': 7.0.2
+ '@typescript/typescript-darwin-x64': 7.0.2
+ '@typescript/typescript-freebsd-arm64': 7.0.2
+ '@typescript/typescript-freebsd-x64': 7.0.2
+ '@typescript/typescript-linux-arm': 7.0.2
+ '@typescript/typescript-linux-arm64': 7.0.2
+ '@typescript/typescript-linux-loong64': 7.0.2
+ '@typescript/typescript-linux-mips64el': 7.0.2
+ '@typescript/typescript-linux-ppc64': 7.0.2
+ '@typescript/typescript-linux-riscv64': 7.0.2
+ '@typescript/typescript-linux-s390x': 7.0.2
+ '@typescript/typescript-linux-x64': 7.0.2
+ '@typescript/typescript-netbsd-arm64': 7.0.2
+ '@typescript/typescript-netbsd-x64': 7.0.2
+ '@typescript/typescript-openbsd-arm64': 7.0.2
+ '@typescript/typescript-openbsd-x64': 7.0.2
+ '@typescript/typescript-sunos-x64': 7.0.2
+ '@typescript/typescript-win32-arm64': 7.0.2
+ '@typescript/typescript-win32-x64': 7.0.2
uc.micro@2.1.0: {}
diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml
index 7150c25b..47214672 100644
--- a/web/pnpm-workspace.yaml
+++ b/web/pnpm-workspace.yaml
@@ -1,3 +1,8 @@
+allowBuilds:
+ '@parcel/watcher': set this to true or false
+ sharp: set this to true or false
+ unrs-resolver: set this to true or false
+
ignoredBuiltDependencies:
- sharp
- unrs-resolver
From 0e199f0a33480d10c223adc8fb5e68f33b7c671b Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Wed, 2 Sep 2026 14:26:09 +0700
Subject: [PATCH 50/53] chore(flowgram): update pnpm-lock.yaml for typescript 7
in flowgram-editor
---
flowgram-editor/pnpm-lock.yaml | 439 ++++++++++++++++++++++++---------
1 file changed, 325 insertions(+), 114 deletions(-)
diff --git a/flowgram-editor/pnpm-lock.yaml b/flowgram-editor/pnpm-lock.yaml
index f647c230..47d36c25 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@7.0.2))
'@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@7.0.2)
'@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: ^7.0.2
+ version: 7.0.2
packages:
@@ -2062,6 +2062,126 @@ packages:
resolution: {integrity: sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@typescript/typescript-aix-ppc64@7.0.2':
+ resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@typescript/typescript-darwin-arm64@7.0.2':
+ resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@typescript/typescript-darwin-x64@7.0.2':
+ resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==}
+ engines: {node: '>=16.20.0'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@typescript/typescript-freebsd-arm64@7.0.2':
+ resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@typescript/typescript-freebsd-x64@7.0.2':
+ resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==}
+ engines: {node: '>=16.20.0'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@typescript/typescript-linux-arm64@7.0.2':
+ resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@typescript/typescript-linux-arm@7.0.2':
+ resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm]
+ os: [linux]
+
+ '@typescript/typescript-linux-loong64@7.0.2':
+ resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@typescript/typescript-linux-mips64el@7.0.2':
+ resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==}
+ engines: {node: '>=16.20.0'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@typescript/typescript-linux-ppc64@7.0.2':
+ resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==}
+ engines: {node: '>=16.20.0'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@typescript/typescript-linux-riscv64@7.0.2':
+ resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@typescript/typescript-linux-s390x@7.0.2':
+ resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==}
+ engines: {node: '>=16.20.0'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@typescript/typescript-linux-x64@7.0.2':
+ resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==}
+ engines: {node: '>=16.20.0'}
+ cpu: [x64]
+ os: [linux]
+
+ '@typescript/typescript-netbsd-arm64@7.0.2':
+ resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@typescript/typescript-netbsd-x64@7.0.2':
+ resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==}
+ engines: {node: '>=16.20.0'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@typescript/typescript-openbsd-arm64@7.0.2':
+ resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm64]
+ os: [openbsd]
+
+ '@typescript/typescript-openbsd-x64@7.0.2':
+ resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==}
+ engines: {node: '>=16.20.0'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@typescript/typescript-sunos-x64@7.0.2':
+ resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==}
+ engines: {node: '>=16.20.0'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@typescript/typescript-win32-arm64@7.0.2':
+ resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@typescript/typescript-win32-x64@7.0.2':
+ resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==}
+ engines: {node: '>=16.20.0'}
+ cpu: [x64]
+ os: [win32]
+
'@typescript/vfs@1.6.4':
resolution: {integrity: sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==}
peerDependencies:
@@ -2803,6 +2923,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 +4334,11 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
+ typescript@7.0.2:
+ resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==}
+ engines: {node: '>=16.20.0'}
+ hasBin: true
+
unbox-primitive@1.1.0:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'}
@@ -4280,10 +4406,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 +4544,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 +4615,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 +5169,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 +5242,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 +5487,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@7.0.2))':
dependencies:
'@codemirror/commands': 6.10.4
'@codemirror/state': 6.7.1
@@ -5389,8 +5517,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@7.0.2))
+ '@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@7.0.2)))(@lezer/common@1.5.2)(vue@3.5.40(typescript@7.0.2))
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
transitivePeerDependencies:
@@ -5673,28 +5801,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@7.0.2)))(@lezer/common@1.5.2)(vue@3.5.40(typescript@7.0.2))':
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@7.0.2))
'@floating-ui/dom': 1.8.0
- vue: 3.5.40(typescript@5.9.3)
+ vue: 3.5.40(typescript@7.0.2)
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@7.0.2))':
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@7.0.2)
transitivePeerDependencies:
- '@codemirror/commands'
- '@lezer/common'
@@ -5865,14 +5993,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 +6016,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 +6030,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 +6094,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@7.0.2))':
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@7.0.2))
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 +6153,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@7.0.2)':
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@7.0.2))(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@7.0.2)
+ '@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@7.0.2)
+ 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@7.0.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))(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@7.0.2))(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@7.0.2)
transitivePeerDependencies:
- '@swc/core'
- '@swc/wasm'
@@ -6083,13 +6211,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@7.0.2))':
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@7.0.2))
'@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 +7333,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@7.0.2))(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@7.0.2)':
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@7.0.2)
'@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@7.0.2)
+ '@typescript-eslint/utils': 8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(typescript@7.0.2)
'@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@7.0.2)
+ typescript: 7.0.2
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@7.0.2)':
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@7.0.2)
'@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: 7.0.2
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@7.0.2)':
dependencies:
- '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.9.3)
+ '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@7.0.2)
'@typescript-eslint/types': 8.65.0
debug: 4.4.3(supports-color@5.5.0)
- typescript: 5.9.3
+ typescript: 7.0.2
transitivePeerDependencies:
- supports-color
@@ -7247,47 +7375,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@7.0.2)':
dependencies:
- typescript: 5.9.3
+ typescript: 7.0.2
- '@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@7.0.2)':
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@7.0.2)
+ '@typescript-eslint/utils': 8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(typescript@7.0.2)
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@7.0.2)
+ typescript: 7.0.2
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@7.0.2)':
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@7.0.2)
+ '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@7.0.2)
'@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@7.0.2)
+ typescript: 7.0.2
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@7.0.2)':
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@7.0.2)
+ eslint: 9.39.5(jiti@2.7.0)(supports-color@5.5.0)
+ typescript: 7.0.2
transitivePeerDependencies:
- supports-color
@@ -7296,6 +7424,66 @@ snapshots:
'@typescript-eslint/types': 8.65.0
eslint-visitor-keys: 5.0.1
+ '@typescript/typescript-aix-ppc64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-darwin-arm64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-darwin-x64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-freebsd-arm64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-freebsd-x64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-linux-arm64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-linux-arm@7.0.2':
+ optional: true
+
+ '@typescript/typescript-linux-loong64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-linux-mips64el@7.0.2':
+ optional: true
+
+ '@typescript/typescript-linux-ppc64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-linux-riscv64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-linux-s390x@7.0.2':
+ optional: true
+
+ '@typescript/typescript-linux-x64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-netbsd-arm64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-netbsd-x64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-openbsd-arm64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-openbsd-x64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-sunos-x64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-win32-arm64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-win32-x64@7.0.2':
+ optional: true
+
'@typescript/vfs@1.6.4(typescript@5.9.3)':
dependencies:
debug: 4.4.3(supports-color@5.5.0)
@@ -7550,11 +7738,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 +7750,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 +7758,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 +8153,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 +8167,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@7.0.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))(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@7.0.2))(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@7.0.2))(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@7.0.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))(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@7.0.2)
+ 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@7.0.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))(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@7.0.2))(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 +8207,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@7.0.2))(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@7.0.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))(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 +8221,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@7.0.2)
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 +8237,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 +8246,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 +8262,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 +8301,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 +10130,11 @@ snapshots:
trough@2.2.0: {}
- ts-api-utils@2.5.0(typescript@5.9.3):
+ ts-api-utils@2.5.0(typescript@7.0.2):
dependencies:
- typescript: 5.9.3
+ typescript: 7.0.2
- 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@7.0.2):
dependencies:
'@cspotcode/source-map-support': 0.8.1
'@tsconfig/node10': 1.0.12
@@ -9960,7 +10148,7 @@ snapshots:
create-require: 1.1.1
diff: 4.0.4
make-error: 1.3.6
- typescript: 5.9.3
+ typescript: 7.0.2
v8-compile-cache-lib: 3.0.1
yn: 3.1.1
@@ -10012,6 +10200,29 @@ snapshots:
typescript@5.9.3: {}
+ typescript@7.0.2:
+ optionalDependencies:
+ '@typescript/typescript-aix-ppc64': 7.0.2
+ '@typescript/typescript-darwin-arm64': 7.0.2
+ '@typescript/typescript-darwin-x64': 7.0.2
+ '@typescript/typescript-freebsd-arm64': 7.0.2
+ '@typescript/typescript-freebsd-x64': 7.0.2
+ '@typescript/typescript-linux-arm': 7.0.2
+ '@typescript/typescript-linux-arm64': 7.0.2
+ '@typescript/typescript-linux-loong64': 7.0.2
+ '@typescript/typescript-linux-mips64el': 7.0.2
+ '@typescript/typescript-linux-ppc64': 7.0.2
+ '@typescript/typescript-linux-riscv64': 7.0.2
+ '@typescript/typescript-linux-s390x': 7.0.2
+ '@typescript/typescript-linux-x64': 7.0.2
+ '@typescript/typescript-netbsd-arm64': 7.0.2
+ '@typescript/typescript-netbsd-x64': 7.0.2
+ '@typescript/typescript-openbsd-arm64': 7.0.2
+ '@typescript/typescript-openbsd-x64': 7.0.2
+ '@typescript/typescript-sunos-x64': 7.0.2
+ '@typescript/typescript-win32-arm64': 7.0.2
+ '@typescript/typescript-win32-x64': 7.0.2
+
unbox-primitive@1.1.0:
dependencies:
call-bound: 1.0.4
@@ -10142,7 +10353,7 @@ snapshots:
vscode-uri@3.1.0: {}
- vue@3.5.40(typescript@5.9.3):
+ vue@3.5.40(typescript@7.0.2):
dependencies:
'@vue/compiler-dom': 3.5.40
'@vue/compiler-sfc': 3.5.40
@@ -10150,7 +10361,7 @@ snapshots:
'@vue/server-renderer': 3.5.40
'@vue/shared': 3.5.40
optionalDependencies:
- typescript: 5.9.3
+ typescript: 7.0.2
w3c-keyname@2.2.8: {}
From b65cfca0594277b3dd2aa26dc9c656310f87cbbd Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Wed, 2 Sep 2026 14:39:58 +0700
Subject: [PATCH 51/53] chore(deps): update TypeScript to stable 5.9.3, migrate
next.config to mjs and update lockfiles
---
flowgram-editor/package.json | 2 +-
flowgram-editor/pnpm-lock.yaml | 334 ++++---------------
scripts/cloudflare-email-worker/package.json | 2 +-
web/{next.config.ts => next.config.mjs} | 20 +-
web/package.json | 2 +-
web/pnpm-lock.yaml | 315 ++++-------------
web/scripts/build-sdk.mjs | 4 +-
7 files changed, 136 insertions(+), 543 deletions(-)
rename web/{next.config.ts => next.config.mjs} (78%)
diff --git a/flowgram-editor/package.json b/flowgram-editor/package.json
index de2e16a2..a7204288 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": "^7.0.2",
+ "typescript": "^5.9.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 47d36c25..5d002a68 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@7.0.2))
+ 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))
'@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)(supports-color@5.5.0)(typescript@7.0.2)
+ version: 1.0.12(@types/node@18.19.130)(jiti@2.7.0)(supports-color@5.5.0)(typescript@5.9.3)
'@flowgram.ai/ts-config':
specifier: 1.0.12
version: 1.0.12
@@ -109,8 +109,8 @@ importers:
specifier: ^9.0.0
version: 9.39.5(jiti@2.7.0)(supports-color@5.5.0)
typescript:
- specifier: ^7.0.2
- version: 7.0.2
+ specifier: ^5.9.3
+ version: 5.9.3
packages:
@@ -2062,126 +2062,6 @@ packages:
resolution: {integrity: sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@typescript/typescript-aix-ppc64@7.0.2':
- resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==}
- engines: {node: '>=16.20.0'}
- cpu: [ppc64]
- os: [aix]
-
- '@typescript/typescript-darwin-arm64@7.0.2':
- resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==}
- engines: {node: '>=16.20.0'}
- cpu: [arm64]
- os: [darwin]
-
- '@typescript/typescript-darwin-x64@7.0.2':
- resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==}
- engines: {node: '>=16.20.0'}
- cpu: [x64]
- os: [darwin]
-
- '@typescript/typescript-freebsd-arm64@7.0.2':
- resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==}
- engines: {node: '>=16.20.0'}
- cpu: [arm64]
- os: [freebsd]
-
- '@typescript/typescript-freebsd-x64@7.0.2':
- resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==}
- engines: {node: '>=16.20.0'}
- cpu: [x64]
- os: [freebsd]
-
- '@typescript/typescript-linux-arm64@7.0.2':
- resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==}
- engines: {node: '>=16.20.0'}
- cpu: [arm64]
- os: [linux]
-
- '@typescript/typescript-linux-arm@7.0.2':
- resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==}
- engines: {node: '>=16.20.0'}
- cpu: [arm]
- os: [linux]
-
- '@typescript/typescript-linux-loong64@7.0.2':
- resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==}
- engines: {node: '>=16.20.0'}
- cpu: [loong64]
- os: [linux]
-
- '@typescript/typescript-linux-mips64el@7.0.2':
- resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==}
- engines: {node: '>=16.20.0'}
- cpu: [mips64el]
- os: [linux]
-
- '@typescript/typescript-linux-ppc64@7.0.2':
- resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==}
- engines: {node: '>=16.20.0'}
- cpu: [ppc64]
- os: [linux]
-
- '@typescript/typescript-linux-riscv64@7.0.2':
- resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==}
- engines: {node: '>=16.20.0'}
- cpu: [riscv64]
- os: [linux]
-
- '@typescript/typescript-linux-s390x@7.0.2':
- resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==}
- engines: {node: '>=16.20.0'}
- cpu: [s390x]
- os: [linux]
-
- '@typescript/typescript-linux-x64@7.0.2':
- resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==}
- engines: {node: '>=16.20.0'}
- cpu: [x64]
- os: [linux]
-
- '@typescript/typescript-netbsd-arm64@7.0.2':
- resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==}
- engines: {node: '>=16.20.0'}
- cpu: [arm64]
- os: [netbsd]
-
- '@typescript/typescript-netbsd-x64@7.0.2':
- resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==}
- engines: {node: '>=16.20.0'}
- cpu: [x64]
- os: [netbsd]
-
- '@typescript/typescript-openbsd-arm64@7.0.2':
- resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==}
- engines: {node: '>=16.20.0'}
- cpu: [arm64]
- os: [openbsd]
-
- '@typescript/typescript-openbsd-x64@7.0.2':
- resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==}
- engines: {node: '>=16.20.0'}
- cpu: [x64]
- os: [openbsd]
-
- '@typescript/typescript-sunos-x64@7.0.2':
- resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==}
- engines: {node: '>=16.20.0'}
- cpu: [x64]
- os: [sunos]
-
- '@typescript/typescript-win32-arm64@7.0.2':
- resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==}
- engines: {node: '>=16.20.0'}
- cpu: [arm64]
- os: [win32]
-
- '@typescript/typescript-win32-x64@7.0.2':
- resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==}
- engines: {node: '>=16.20.0'}
- cpu: [x64]
- os: [win32]
-
'@typescript/vfs@1.6.4':
resolution: {integrity: sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==}
peerDependencies:
@@ -4334,11 +4214,6 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
- typescript@7.0.2:
- resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==}
- engines: {node: '>=16.20.0'}
- hasBin: true
-
unbox-primitive@1.1.0:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'}
@@ -5487,7 +5362,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@7.0.2))':
+ '@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))':
dependencies:
'@codemirror/commands': 6.10.4
'@codemirror/state': 6.7.1
@@ -5517,8 +5392,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@7.0.2))
- '@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@7.0.2)))(@lezer/common@1.5.2)(vue@3.5.40(typescript@7.0.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-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))
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
transitivePeerDependencies:
@@ -5801,28 +5676,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@7.0.2)))(@lezer/common@1.5.2)(vue@3.5.40(typescript@7.0.2))':
+ '@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))':
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@7.0.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))
'@floating-ui/dom': 1.8.0
- vue: 3.5.40(typescript@7.0.2)
+ vue: 3.5.40(typescript@5.9.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@7.0.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))':
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@7.0.2)
+ vue: 3.5.40(typescript@5.9.3)
transitivePeerDependencies:
- '@codemirror/commands'
- '@lezer/common'
@@ -6094,10 +5969,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@7.0.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))':
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@7.0.2))
+ '@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))
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)
@@ -6153,7 +6028,7 @@ 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)(supports-color@5.5.0)(typescript@7.0.2)':
+ '@flowgram.ai/eslint-config@1.0.12(@types/node@18.19.130)(jiti@2.7.0)(supports-color@5.5.0)(typescript@5.9.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)(supports-color@5.5.0))
@@ -6161,21 +6036,21 @@ snapshots:
'@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(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@7.0.2))(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@7.0.2)
- '@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@7.0.2)
+ '@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@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.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@5.9.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(@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@7.0.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))(supports-color@5.5.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@5.9.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@7.0.2))(eslint-import-resolver-typescript@3.10.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@5.9.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@7.0.2)
+ ts-node: 10.9.2(@types/node@18.19.130)(typescript@5.9.3)
transitivePeerDependencies:
- '@swc/core'
- '@swc/wasm'
@@ -6211,13 +6086,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@7.0.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))':
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@7.0.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/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
@@ -7333,40 +7208,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)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@7.0.2))(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@7.0.2)':
+ '@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@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
- '@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@7.0.2)
+ '@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@5.9.3)
'@typescript-eslint/scope-manager': 8.65.0
- '@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@7.0.2)
- '@typescript-eslint/utils': 8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(typescript@7.0.2)
+ '@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@5.9.3)
+ '@typescript-eslint/utils': 8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.65.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@7.0.2)
- typescript: 7.0.2
+ ts-api-utils: 2.5.0(typescript@5.9.3)
+ typescript: 5.9.3
transitivePeerDependencies:
- supports-color
- '@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@7.0.2)':
+ '@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@5.9.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.65.0
'@typescript-eslint/types': 8.65.0
- '@typescript-eslint/typescript-estree': 8.65.0(supports-color@5.5.0)(typescript@7.0.2)
+ '@typescript-eslint/typescript-estree': 8.65.0(supports-color@5.5.0)(typescript@5.9.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)(supports-color@5.5.0)
- typescript: 7.0.2
+ typescript: 5.9.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/project-service@8.65.0(supports-color@5.5.0)(typescript@7.0.2)':
+ '@typescript-eslint/project-service@8.65.0(supports-color@5.5.0)(typescript@5.9.3)':
dependencies:
- '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@7.0.2)
+ '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.9.3)
'@typescript-eslint/types': 8.65.0
debug: 4.4.3(supports-color@5.5.0)
- typescript: 7.0.2
+ typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -7375,47 +7250,47 @@ snapshots:
'@typescript-eslint/types': 8.65.0
'@typescript-eslint/visitor-keys': 8.65.0
- '@typescript-eslint/tsconfig-utils@8.65.0(typescript@7.0.2)':
+ '@typescript-eslint/tsconfig-utils@8.65.0(typescript@5.9.3)':
dependencies:
- typescript: 7.0.2
+ 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@7.0.2)':
+ '@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@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.65.0
- '@typescript-eslint/typescript-estree': 8.65.0(supports-color@5.5.0)(typescript@7.0.2)
- '@typescript-eslint/utils': 8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(typescript@7.0.2)
+ '@typescript-eslint/typescript-estree': 8.65.0(supports-color@5.5.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@5.9.3)
debug: 4.4.3(supports-color@5.5.0)
eslint: 9.39.5(jiti@2.7.0)(supports-color@5.5.0)
- ts-api-utils: 2.5.0(typescript@7.0.2)
- typescript: 7.0.2
+ ts-api-utils: 2.5.0(typescript@5.9.3)
+ typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/types@8.65.0': {}
- '@typescript-eslint/typescript-estree@8.65.0(supports-color@5.5.0)(typescript@7.0.2)':
+ '@typescript-eslint/typescript-estree@8.65.0(supports-color@5.5.0)(typescript@5.9.3)':
dependencies:
- '@typescript-eslint/project-service': 8.65.0(supports-color@5.5.0)(typescript@7.0.2)
- '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@7.0.2)
+ '@typescript-eslint/project-service': 8.65.0(supports-color@5.5.0)(typescript@5.9.3)
+ '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.9.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@7.0.2)
- typescript: 7.0.2
+ ts-api-utils: 2.5.0(typescript@5.9.3)
+ typescript: 5.9.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/utils@8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(typescript@7.0.2)':
+ '@typescript-eslint/utils@8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(typescript@5.9.3)':
dependencies:
'@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(supports-color@5.5.0)(typescript@7.0.2)
+ '@typescript-eslint/typescript-estree': 8.65.0(supports-color@5.5.0)(typescript@5.9.3)
eslint: 9.39.5(jiti@2.7.0)(supports-color@5.5.0)
- typescript: 7.0.2
+ typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -7424,66 +7299,6 @@ snapshots:
'@typescript-eslint/types': 8.65.0
eslint-visitor-keys: 5.0.1
- '@typescript/typescript-aix-ppc64@7.0.2':
- optional: true
-
- '@typescript/typescript-darwin-arm64@7.0.2':
- optional: true
-
- '@typescript/typescript-darwin-x64@7.0.2':
- optional: true
-
- '@typescript/typescript-freebsd-arm64@7.0.2':
- optional: true
-
- '@typescript/typescript-freebsd-x64@7.0.2':
- optional: true
-
- '@typescript/typescript-linux-arm64@7.0.2':
- optional: true
-
- '@typescript/typescript-linux-arm@7.0.2':
- optional: true
-
- '@typescript/typescript-linux-loong64@7.0.2':
- optional: true
-
- '@typescript/typescript-linux-mips64el@7.0.2':
- optional: true
-
- '@typescript/typescript-linux-ppc64@7.0.2':
- optional: true
-
- '@typescript/typescript-linux-riscv64@7.0.2':
- optional: true
-
- '@typescript/typescript-linux-s390x@7.0.2':
- optional: true
-
- '@typescript/typescript-linux-x64@7.0.2':
- optional: true
-
- '@typescript/typescript-netbsd-arm64@7.0.2':
- optional: true
-
- '@typescript/typescript-netbsd-x64@7.0.2':
- optional: true
-
- '@typescript/typescript-openbsd-arm64@7.0.2':
- optional: true
-
- '@typescript/typescript-openbsd-x64@7.0.2':
- optional: true
-
- '@typescript/typescript-sunos-x64@7.0.2':
- optional: true
-
- '@typescript/typescript-win32-arm64@7.0.2':
- optional: true
-
- '@typescript/typescript-win32-x64@7.0.2':
- optional: true
-
'@typescript/vfs@1.6.4(typescript@5.9.3)':
dependencies:
debug: 4.4.3(supports-color@5.5.0)
@@ -8167,7 +7982,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- 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@7.0.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))(supports-color@5.5.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@5.9.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)
@@ -8178,18 +7993,18 @@ snapshots:
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)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@7.0.2))(eslint-import-resolver-typescript@3.10.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@5.9.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)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@7.0.2))(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@7.0.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))(supports-color@5.5.0))(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.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@5.9.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@5.9.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)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@7.0.2)
+ '@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@5.9.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(@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@7.0.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))(supports-color@5.5.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@5.9.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
@@ -8198,7 +8013,7 @@ snapshots:
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)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@7.0.2))(eslint-import-resolver-typescript@3.10.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@5.9.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
@@ -8209,7 +8024,7 @@ snapshots:
doctrine: 2.1.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)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@7.0.2))(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@7.0.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))(supports-color@5.5.0))(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.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@5.9.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@5.9.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
@@ -8221,7 +8036,7 @@ 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)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@7.0.2)
+ '@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@5.9.3)
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
@@ -10130,11 +9945,11 @@ snapshots:
trough@2.2.0: {}
- ts-api-utils@2.5.0(typescript@7.0.2):
+ ts-api-utils@2.5.0(typescript@5.9.3):
dependencies:
- typescript: 7.0.2
+ typescript: 5.9.3
- ts-node@10.9.2(@types/node@18.19.130)(typescript@7.0.2):
+ ts-node@10.9.2(@types/node@18.19.130)(typescript@5.9.3):
dependencies:
'@cspotcode/source-map-support': 0.8.1
'@tsconfig/node10': 1.0.12
@@ -10148,7 +9963,7 @@ snapshots:
create-require: 1.1.1
diff: 4.0.4
make-error: 1.3.6
- typescript: 7.0.2
+ typescript: 5.9.3
v8-compile-cache-lib: 3.0.1
yn: 3.1.1
@@ -10200,29 +10015,6 @@ snapshots:
typescript@5.9.3: {}
- typescript@7.0.2:
- optionalDependencies:
- '@typescript/typescript-aix-ppc64': 7.0.2
- '@typescript/typescript-darwin-arm64': 7.0.2
- '@typescript/typescript-darwin-x64': 7.0.2
- '@typescript/typescript-freebsd-arm64': 7.0.2
- '@typescript/typescript-freebsd-x64': 7.0.2
- '@typescript/typescript-linux-arm': 7.0.2
- '@typescript/typescript-linux-arm64': 7.0.2
- '@typescript/typescript-linux-loong64': 7.0.2
- '@typescript/typescript-linux-mips64el': 7.0.2
- '@typescript/typescript-linux-ppc64': 7.0.2
- '@typescript/typescript-linux-riscv64': 7.0.2
- '@typescript/typescript-linux-s390x': 7.0.2
- '@typescript/typescript-linux-x64': 7.0.2
- '@typescript/typescript-netbsd-arm64': 7.0.2
- '@typescript/typescript-netbsd-x64': 7.0.2
- '@typescript/typescript-openbsd-arm64': 7.0.2
- '@typescript/typescript-openbsd-x64': 7.0.2
- '@typescript/typescript-sunos-x64': 7.0.2
- '@typescript/typescript-win32-arm64': 7.0.2
- '@typescript/typescript-win32-x64': 7.0.2
-
unbox-primitive@1.1.0:
dependencies:
call-bound: 1.0.4
@@ -10353,7 +10145,7 @@ snapshots:
vscode-uri@3.1.0: {}
- vue@3.5.40(typescript@7.0.2):
+ vue@3.5.40(typescript@5.9.3):
dependencies:
'@vue/compiler-dom': 3.5.40
'@vue/compiler-sfc': 3.5.40
@@ -10361,7 +10153,7 @@ snapshots:
'@vue/server-renderer': 3.5.40
'@vue/shared': 3.5.40
optionalDependencies:
- typescript: 7.0.2
+ typescript: 5.9.3
w3c-keyname@2.2.8: {}
diff --git a/scripts/cloudflare-email-worker/package.json b/scripts/cloudflare-email-worker/package.json
index 8421008f..e70ad447 100644
--- a/scripts/cloudflare-email-worker/package.json
+++ b/scripts/cloudflare-email-worker/package.json
@@ -11,7 +11,7 @@
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20241022.0",
- "typescript": "^7.0.2",
+ "typescript": "^5.9.3",
"wrangler": "^3.84.1"
}
}
diff --git a/web/next.config.ts b/web/next.config.mjs
similarity index 78%
rename from web/next.config.ts
rename to web/next.config.mjs
index b5c5a510..99f63e8d 100644
--- a/web/next.config.ts
+++ b/web/next.config.mjs
@@ -1,24 +1,24 @@
-import type { NextConfig } from "next"
-import { PHASE_DEVELOPMENT_SERVER } from "next/constants"
+import { PHASE_DEVELOPMENT_SERVER } from "next/constants.js";
const backendBaseUrl =
process.env.NEXT_API_BASE_URL?.trim() ||
process.env.NEXT_PUBLIC_API_BASE_URL?.trim() ||
- "http://127.0.0.1:8083"
-const productionBasePath = ""
+ "http://127.0.0.1:8083";
+const productionBasePath = "";
-export default function nextConfig(phase: string): NextConfig {
- const config: NextConfig = {
+/** @type {(phase: string) => import('next').NextConfig} */
+export default function nextConfig(phase) {
+ const config = {
output: "export",
basePath: productionBasePath,
assetPrefix: `${productionBasePath}/`,
trailingSlash: false,
devIndicators: false,
reactStrictMode: false,
- }
+ };
if (phase !== PHASE_DEVELOPMENT_SERVER) {
- return config
+ return config;
}
return {
@@ -45,7 +45,7 @@ export default function nextConfig(phase: string): NextConfig {
source: "/storage/:path*",
destination: `${backendBaseUrl}/storage/:path*`,
},
- ]
+ ];
},
- }
+ };
}
diff --git a/web/package.json b/web/package.json
index a21ac934..52f18217 100644
--- a/web/package.json
+++ b/web/package.json
@@ -69,6 +69,6 @@
"sass": "^1.98.0",
"tailwindcss": "^4",
"terser": "^5.46.2",
- "typescript": "^7.0.2"
+ "typescript": "^5.9.3"
}
}
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
index c7e8bb7c..3759fa00 100644
--- a/web/pnpm-lock.yaml
+++ b/web/pnpm-lock.yaml
@@ -121,7 +121,7 @@ importers:
version: 4.0.1
shadcn:
specifier: ^4.18.0
- version: 4.18.0(typescript@7.0.2)
+ version: 4.18.0(typescript@5.9.3)
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -167,7 +167,7 @@ importers:
version: 9.39.4(jiti@2.6.1)
eslint-config-next:
specifier: 16.1.6
- version: 16.1.6(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2))(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2)
+ version: 16.1.6(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
sass:
specifier: ^1.98.0
version: 1.98.0
@@ -178,8 +178,8 @@ importers:
specifier: ^5.46.2
version: 5.46.2
typescript:
- specifier: ^7.0.2
- version: 7.0.2
+ specifier: ^5.9.3
+ version: 5.9.3
packages:
@@ -1691,126 +1691,6 @@ packages:
resolution: {integrity: sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@typescript/typescript-aix-ppc64@7.0.2':
- resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==}
- engines: {node: '>=16.20.0'}
- cpu: [ppc64]
- os: [aix]
-
- '@typescript/typescript-darwin-arm64@7.0.2':
- resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==}
- engines: {node: '>=16.20.0'}
- cpu: [arm64]
- os: [darwin]
-
- '@typescript/typescript-darwin-x64@7.0.2':
- resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==}
- engines: {node: '>=16.20.0'}
- cpu: [x64]
- os: [darwin]
-
- '@typescript/typescript-freebsd-arm64@7.0.2':
- resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==}
- engines: {node: '>=16.20.0'}
- cpu: [arm64]
- os: [freebsd]
-
- '@typescript/typescript-freebsd-x64@7.0.2':
- resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==}
- engines: {node: '>=16.20.0'}
- cpu: [x64]
- os: [freebsd]
-
- '@typescript/typescript-linux-arm64@7.0.2':
- resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==}
- engines: {node: '>=16.20.0'}
- cpu: [arm64]
- os: [linux]
-
- '@typescript/typescript-linux-arm@7.0.2':
- resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==}
- engines: {node: '>=16.20.0'}
- cpu: [arm]
- os: [linux]
-
- '@typescript/typescript-linux-loong64@7.0.2':
- resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==}
- engines: {node: '>=16.20.0'}
- cpu: [loong64]
- os: [linux]
-
- '@typescript/typescript-linux-mips64el@7.0.2':
- resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==}
- engines: {node: '>=16.20.0'}
- cpu: [mips64el]
- os: [linux]
-
- '@typescript/typescript-linux-ppc64@7.0.2':
- resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==}
- engines: {node: '>=16.20.0'}
- cpu: [ppc64]
- os: [linux]
-
- '@typescript/typescript-linux-riscv64@7.0.2':
- resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==}
- engines: {node: '>=16.20.0'}
- cpu: [riscv64]
- os: [linux]
-
- '@typescript/typescript-linux-s390x@7.0.2':
- resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==}
- engines: {node: '>=16.20.0'}
- cpu: [s390x]
- os: [linux]
-
- '@typescript/typescript-linux-x64@7.0.2':
- resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==}
- engines: {node: '>=16.20.0'}
- cpu: [x64]
- os: [linux]
-
- '@typescript/typescript-netbsd-arm64@7.0.2':
- resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==}
- engines: {node: '>=16.20.0'}
- cpu: [arm64]
- os: [netbsd]
-
- '@typescript/typescript-netbsd-x64@7.0.2':
- resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==}
- engines: {node: '>=16.20.0'}
- cpu: [x64]
- os: [netbsd]
-
- '@typescript/typescript-openbsd-arm64@7.0.2':
- resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==}
- engines: {node: '>=16.20.0'}
- cpu: [arm64]
- os: [openbsd]
-
- '@typescript/typescript-openbsd-x64@7.0.2':
- resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==}
- engines: {node: '>=16.20.0'}
- cpu: [x64]
- os: [openbsd]
-
- '@typescript/typescript-sunos-x64@7.0.2':
- resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==}
- engines: {node: '>=16.20.0'}
- cpu: [x64]
- os: [sunos]
-
- '@typescript/typescript-win32-arm64@7.0.2':
- resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==}
- engines: {node: '>=16.20.0'}
- cpu: [arm64]
- os: [win32]
-
- '@typescript/typescript-win32-x64@7.0.2':
- resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==}
- engines: {node: '>=16.20.0'}
- cpu: [x64]
- os: [win32]
-
'@uiw/react-json-view@2.0.0-alpha.41':
resolution: {integrity: sha512-botRpQ5AgymYEsqXSdT2/1LefAJEYfMntvdnx1SqhTQCTW9HygeFZXx9inkYqUmiQZ3+0QlZnodjBvwnUfZhVA==}
peerDependencies:
@@ -4549,9 +4429,9 @@ packages:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.0.0'
- typescript@7.0.2:
- resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==}
- engines: {node: '>=16.20.0'}
+ typescript@5.9.3:
+ resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+ engines: {node: '>=14.17'}
hasBin: true
uc.micro@2.1.0:
@@ -6376,40 +6256,40 @@ snapshots:
'@types/validate-npm-package-name@4.0.2': {}
- '@typescript-eslint/eslint-plugin@8.57.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2))(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2)':
+ '@typescript-eslint/eslint-plugin@8.57.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
- '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2)
+ '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.57.0
- '@typescript-eslint/type-utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2)
- '@typescript-eslint/utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2)
+ '@typescript-eslint/type-utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.57.0
eslint: 9.39.4(jiti@2.6.1)
ignore: 7.0.5
natural-compare: 1.4.0
- ts-api-utils: 2.4.0(typescript@7.0.2)
- typescript: 7.0.2
+ ts-api-utils: 2.4.0(typescript@5.9.3)
+ typescript: 5.9.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2)':
+ '@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.57.0
'@typescript-eslint/types': 8.57.0
- '@typescript-eslint/typescript-estree': 8.57.0(typescript@7.0.2)
+ '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.57.0
debug: 4.4.3
eslint: 9.39.4(jiti@2.6.1)
- typescript: 7.0.2
+ typescript: 5.9.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/project-service@8.57.0(typescript@7.0.2)':
+ '@typescript-eslint/project-service@8.57.0(typescript@5.9.3)':
dependencies:
- '@typescript-eslint/tsconfig-utils': 8.57.0(typescript@7.0.2)
+ '@typescript-eslint/tsconfig-utils': 8.57.0(typescript@5.9.3)
'@typescript-eslint/types': 8.57.0
debug: 4.4.3
- typescript: 7.0.2
+ typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -6418,47 +6298,47 @@ snapshots:
'@typescript-eslint/types': 8.57.0
'@typescript-eslint/visitor-keys': 8.57.0
- '@typescript-eslint/tsconfig-utils@8.57.0(typescript@7.0.2)':
+ '@typescript-eslint/tsconfig-utils@8.57.0(typescript@5.9.3)':
dependencies:
- typescript: 7.0.2
+ typescript: 5.9.3
- '@typescript-eslint/type-utils@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2)':
+ '@typescript-eslint/type-utils@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.57.0
- '@typescript-eslint/typescript-estree': 8.57.0(typescript@7.0.2)
- '@typescript-eslint/utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2)
+ '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
debug: 4.4.3
eslint: 9.39.4(jiti@2.6.1)
- ts-api-utils: 2.4.0(typescript@7.0.2)
- typescript: 7.0.2
+ ts-api-utils: 2.4.0(typescript@5.9.3)
+ typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/types@8.57.0': {}
- '@typescript-eslint/typescript-estree@8.57.0(typescript@7.0.2)':
+ '@typescript-eslint/typescript-estree@8.57.0(typescript@5.9.3)':
dependencies:
- '@typescript-eslint/project-service': 8.57.0(typescript@7.0.2)
- '@typescript-eslint/tsconfig-utils': 8.57.0(typescript@7.0.2)
+ '@typescript-eslint/project-service': 8.57.0(typescript@5.9.3)
+ '@typescript-eslint/tsconfig-utils': 8.57.0(typescript@5.9.3)
'@typescript-eslint/types': 8.57.0
'@typescript-eslint/visitor-keys': 8.57.0
debug: 4.4.3
minimatch: 10.2.4
semver: 7.7.4
tinyglobby: 0.2.15
- ts-api-utils: 2.4.0(typescript@7.0.2)
- typescript: 7.0.2
+ ts-api-utils: 2.4.0(typescript@5.9.3)
+ typescript: 5.9.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/utils@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2)':
+ '@typescript-eslint/utils@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
'@typescript-eslint/scope-manager': 8.57.0
'@typescript-eslint/types': 8.57.0
- '@typescript-eslint/typescript-estree': 8.57.0(typescript@7.0.2)
+ '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3)
eslint: 9.39.4(jiti@2.6.1)
- typescript: 7.0.2
+ typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -6467,66 +6347,6 @@ snapshots:
'@typescript-eslint/types': 8.57.0
eslint-visitor-keys: 5.0.1
- '@typescript/typescript-aix-ppc64@7.0.2':
- optional: true
-
- '@typescript/typescript-darwin-arm64@7.0.2':
- optional: true
-
- '@typescript/typescript-darwin-x64@7.0.2':
- optional: true
-
- '@typescript/typescript-freebsd-arm64@7.0.2':
- optional: true
-
- '@typescript/typescript-freebsd-x64@7.0.2':
- optional: true
-
- '@typescript/typescript-linux-arm64@7.0.2':
- optional: true
-
- '@typescript/typescript-linux-arm@7.0.2':
- optional: true
-
- '@typescript/typescript-linux-loong64@7.0.2':
- optional: true
-
- '@typescript/typescript-linux-mips64el@7.0.2':
- optional: true
-
- '@typescript/typescript-linux-ppc64@7.0.2':
- optional: true
-
- '@typescript/typescript-linux-riscv64@7.0.2':
- optional: true
-
- '@typescript/typescript-linux-s390x@7.0.2':
- optional: true
-
- '@typescript/typescript-linux-x64@7.0.2':
- optional: true
-
- '@typescript/typescript-netbsd-arm64@7.0.2':
- optional: true
-
- '@typescript/typescript-netbsd-x64@7.0.2':
- optional: true
-
- '@typescript/typescript-openbsd-arm64@7.0.2':
- optional: true
-
- '@typescript/typescript-openbsd-x64@7.0.2':
- optional: true
-
- '@typescript/typescript-sunos-x64@7.0.2':
- optional: true
-
- '@typescript/typescript-win32-arm64@7.0.2':
- optional: true
-
- '@typescript/typescript-win32-x64@7.0.2':
- optional: true
-
'@uiw/react-json-view@2.0.0-alpha.41(@babel/runtime@7.28.6)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@babel/runtime': 7.28.6
@@ -6903,14 +6723,14 @@ snapshots:
dependencies:
layout-base: 2.0.1
- cosmiconfig@9.0.1(typescript@7.0.2):
+ cosmiconfig@9.0.1(typescript@5.9.3):
dependencies:
env-paths: 2.2.1
import-fresh: 3.3.1
js-yaml: 4.1.1
parse-json: 5.2.0
optionalDependencies:
- typescript: 7.0.2
+ typescript: 5.9.3
crelt@1.0.6: {}
@@ -7358,20 +7178,20 @@ snapshots:
escape-string-regexp@5.0.0: {}
- eslint-config-next@16.1.6(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2))(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2):
+ eslint-config-next@16.1.6(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3):
dependencies:
'@next/eslint-plugin-next': 16.1.6
eslint: 9.39.4(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1))
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-react-hooks: 7.0.1(eslint@9.39.4(jiti@2.6.1))
globals: 16.4.0
- typescript-eslint: 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2)
+ typescript-eslint: 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
optionalDependencies:
- typescript: 7.0.2
+ typescript: 5.9.3
transitivePeerDependencies:
- '@typescript-eslint/parser'
- eslint-import-resolver-webpack
@@ -7397,22 +7217,22 @@ snapshots:
tinyglobby: 0.2.15
unrs-resolver: 1.11.1
optionalDependencies:
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
transitivePeerDependencies:
- supports-color
- eslint-module-utils@2.12.1(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)):
+ eslint-module-utils@2.12.1(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)):
dependencies:
debug: 3.2.7
optionalDependencies:
- '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2)
+ '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
eslint: 9.39.4(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1))
transitivePeerDependencies:
- supports-color
- eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)):
+ eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -7423,7 +7243,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.39.4(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
- eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
+ eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@@ -7435,7 +7255,7 @@ snapshots:
string.prototype.trimend: 1.0.9
tsconfig-paths: 3.15.0
optionalDependencies:
- '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2)
+ '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
@@ -9465,7 +9285,7 @@ snapshots:
setprototypeof@1.2.0: {}
- shadcn@4.18.0(typescript@7.0.2):
+ shadcn@4.18.0(typescript@5.9.3):
dependencies:
'@babel/core': 7.29.0
'@babel/parser': 7.29.0
@@ -9476,7 +9296,7 @@ snapshots:
'@types/validate-npm-package-name': 4.0.2
browserslist: 4.28.1
commander: 14.0.3
- cosmiconfig: 9.0.1(typescript@7.0.2)
+ cosmiconfig: 9.0.1(typescript@5.9.3)
dedent: 1.7.2
deepmerge: 4.3.1
diff: 8.0.3
@@ -9752,9 +9572,9 @@ snapshots:
trough@2.2.0: {}
- ts-api-utils@2.4.0(typescript@7.0.2):
+ ts-api-utils@2.4.0(typescript@5.9.3):
dependencies:
- typescript: 7.0.2
+ typescript: 5.9.3
ts-dedent@2.3.0: {}
@@ -9827,39 +9647,18 @@ snapshots:
possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10
- typescript-eslint@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2):
+ typescript-eslint@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3):
dependencies:
- '@typescript-eslint/eslint-plugin': 8.57.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2))(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2)
- '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2)
- '@typescript-eslint/typescript-estree': 8.57.0(typescript@7.0.2)
- '@typescript-eslint/utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@7.0.2)
+ '@typescript-eslint/eslint-plugin': 8.57.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
eslint: 9.39.4(jiti@2.6.1)
- typescript: 7.0.2
+ typescript: 5.9.3
transitivePeerDependencies:
- supports-color
- typescript@7.0.2:
- optionalDependencies:
- '@typescript/typescript-aix-ppc64': 7.0.2
- '@typescript/typescript-darwin-arm64': 7.0.2
- '@typescript/typescript-darwin-x64': 7.0.2
- '@typescript/typescript-freebsd-arm64': 7.0.2
- '@typescript/typescript-freebsd-x64': 7.0.2
- '@typescript/typescript-linux-arm': 7.0.2
- '@typescript/typescript-linux-arm64': 7.0.2
- '@typescript/typescript-linux-loong64': 7.0.2
- '@typescript/typescript-linux-mips64el': 7.0.2
- '@typescript/typescript-linux-ppc64': 7.0.2
- '@typescript/typescript-linux-riscv64': 7.0.2
- '@typescript/typescript-linux-s390x': 7.0.2
- '@typescript/typescript-linux-x64': 7.0.2
- '@typescript/typescript-netbsd-arm64': 7.0.2
- '@typescript/typescript-netbsd-x64': 7.0.2
- '@typescript/typescript-openbsd-arm64': 7.0.2
- '@typescript/typescript-openbsd-x64': 7.0.2
- '@typescript/typescript-sunos-x64': 7.0.2
- '@typescript/typescript-win32-arm64': 7.0.2
- '@typescript/typescript-win32-x64': 7.0.2
+ typescript@5.9.3: {}
uc.micro@2.1.0: {}
diff --git a/web/scripts/build-sdk.mjs b/web/scripts/build-sdk.mjs
index c36bbd84..062c80a1 100644
--- a/web/scripts/build-sdk.mjs
+++ b/web/scripts/build-sdk.mjs
@@ -12,15 +12,17 @@ const target = path.join(targetDir, "agent-desk-sdk.min.js");
await mkdir(targetDir, { recursive: true });
const sourceCode = await readFile(source, "utf8");
+
const compiled = ts.transpileModule(sourceCode, {
compilerOptions: {
target: ts.ScriptTarget.ES2017,
module: ts.ModuleKind.ESNext,
- importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove,
+ importsNotUsedAsValues: ts.ImportsNotUsedAsValues ? ts.ImportsNotUsedAsValues.Remove : undefined,
removeComments: true,
},
fileName: source,
});
+
const compiledCode = compiled.outputText.replace(/\nexport\s*\{\};?\s*$/, "");
const result = await minify(compiledCode, {
compress: {
From 9c4ab5ea1e1937eb5cb95f4c10a87e71a4ab6bea Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Wed, 2 Sep 2026 15:15:24 +0700
Subject: [PATCH 52/53] chore(deps): upgrade TypeScript to v6.0.3 across web,
flowgram-editor, and email-worker
---
flowgram-editor/package.json | 2 +-
flowgram-editor/pnpm-lock.yaml | 133 +++++++++---------
scripts/cloudflare-email-worker/package.json | 2 +-
.../cloudflare-email-worker/pnpm-lock.yaml | 22 ++-
web/package.json | 2 +-
web/pnpm-lock.yaml | 112 +++++++--------
6 files changed, 146 insertions(+), 127 deletions(-)
diff --git a/flowgram-editor/package.json b/flowgram-editor/package.json
index a7204288..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.9.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 5d002a68..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)(supports-color@5.5.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
@@ -109,8 +109,8 @@ importers:
specifier: ^9.0.0
version: 9.39.5(jiti@2.7.0)(supports-color@5.5.0)
typescript:
- specifier: ^5.9.3
- version: 5.9.3
+ specifier: ^6.0.3
+ version: 6.0.3
packages:
@@ -4214,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'}
@@ -5362,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
@@ -5392,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:
@@ -5676,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'
@@ -5969,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)
@@ -6028,7 +6033,7 @@ 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)(supports-color@5.5.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)(supports-color@5.5.0))
@@ -6036,21 +6041,21 @@ snapshots:
'@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(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@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.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@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)
+ '@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(@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@5.9.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-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@5.9.3))(eslint-import-resolver-typescript@3.10.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'
@@ -6086,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
@@ -7208,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)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.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)(supports-color@5.5.0))(supports-color@5.5.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)(supports-color@5.5.0))(supports-color@5.5.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@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)(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)(supports-color@5.5.0))(supports-color@5.5.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(supports-color@5.5.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)(supports-color@5.5.0)
- typescript: 5.9.3
+ typescript: 6.0.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/project-service@8.65.0(supports-color@5.5.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
@@ -7250,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)(supports-color@5.5.0))(supports-color@5.5.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(supports-color@5.5.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@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)(supports-color@5.5.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/types@8.65.0': {}
- '@typescript-eslint/typescript-estree@8.65.0(supports-color@5.5.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(supports-color@5.5.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)(supports-color@5.5.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)(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(supports-color@5.5.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: 5.9.3
+ typescript: 6.0.3
transitivePeerDependencies:
- supports-color
@@ -7982,7 +7987,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- 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@5.9.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-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)
@@ -7993,18 +7998,18 @@ snapshots:
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)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.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))
transitivePeerDependencies:
- supports-color
- 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@5.9.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@5.9.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)):
+ 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)(supports-color@5.5.0))(supports-color@5.5.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)
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(@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@5.9.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-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
@@ -8013,7 +8018,7 @@ snapshots:
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)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.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)):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -8024,7 +8029,7 @@ snapshots:
doctrine: 2.1.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)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.9.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@5.9.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))
+ 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
@@ -8036,7 +8041,7 @@ 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)(supports-color@5.5.0))(supports-color@5.5.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
@@ -9945,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
@@ -9963,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
@@ -10015,6 +10020,8 @@ snapshots:
typescript@5.9.3: {}
+ typescript@6.0.3: {}
+
unbox-primitive@1.1.0:
dependencies:
call-bound: 1.0.4
@@ -10145,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
@@ -10153,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/scripts/cloudflare-email-worker/package.json b/scripts/cloudflare-email-worker/package.json
index e70ad447..b1d37c80 100644
--- a/scripts/cloudflare-email-worker/package.json
+++ b/scripts/cloudflare-email-worker/package.json
@@ -11,7 +11,7 @@
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20241022.0",
- "typescript": "^5.9.3",
+ "typescript": "^6.0.3",
"wrangler": "^3.84.1"
}
}
diff --git a/scripts/cloudflare-email-worker/pnpm-lock.yaml b/scripts/cloudflare-email-worker/pnpm-lock.yaml
index e850837d..7ab00b22 100644
--- a/scripts/cloudflare-email-worker/pnpm-lock.yaml
+++ b/scripts/cloudflare-email-worker/pnpm-lock.yaml
@@ -16,8 +16,8 @@ importers:
specifier: ^4.20241022.0
version: 4.20260702.1
typescript:
- specifier: ^5.6.3
- version: 5.9.3
+ specifier: ^6.0.3
+ version: 6.0.3
wrangler:
specifier: ^3.84.1
version: 3.114.17(@cloudflare/workers-types@4.20260702.1)
@@ -249,67 +249,79 @@ packages:
resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linux-arm@1.0.5':
resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==}
cpu: [arm]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.0.4':
resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linux-x64@1.0.4':
resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.0.4':
resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.0.4':
resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@img/sharp-linux-arm64@0.33.5':
resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@img/sharp-linux-arm@0.33.5':
resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
+ libc: [glibc]
'@img/sharp-linux-s390x@0.33.5':
resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@img/sharp-linux-x64@0.33.5':
resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.33.5':
resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@img/sharp-linuxmusl-x64@0.33.5':
resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@img/sharp-wasm32@0.33.5':
resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==}
@@ -486,8 +498,8 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
- typescript@5.9.3:
- resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+ typescript@6.0.3:
+ resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
engines: {node: '>=14.17'}
hasBin: true
@@ -917,7 +929,7 @@ snapshots:
tslib@2.8.1:
optional: true
- typescript@5.9.3: {}
+ typescript@6.0.3: {}
ufo@1.6.4: {}
diff --git a/web/package.json b/web/package.json
index 52f18217..f8449bcf 100644
--- a/web/package.json
+++ b/web/package.json
@@ -69,6 +69,6 @@
"sass": "^1.98.0",
"tailwindcss": "^4",
"terser": "^5.46.2",
- "typescript": "^5.9.3"
+ "typescript": "^6.0.3"
}
}
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
index 3759fa00..1682ffbb 100644
--- a/web/pnpm-lock.yaml
+++ b/web/pnpm-lock.yaml
@@ -121,7 +121,7 @@ importers:
version: 4.0.1
shadcn:
specifier: ^4.18.0
- version: 4.18.0(typescript@5.9.3)
+ version: 4.18.0(typescript@6.0.3)
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -167,7 +167,7 @@ importers:
version: 9.39.4(jiti@2.6.1)
eslint-config-next:
specifier: 16.1.6
- version: 16.1.6(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ version: 16.1.6(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
sass:
specifier: ^1.98.0
version: 1.98.0
@@ -178,8 +178,8 @@ importers:
specifier: ^5.46.2
version: 5.46.2
typescript:
- specifier: ^5.9.3
- version: 5.9.3
+ specifier: ^6.0.3
+ version: 6.0.3
packages:
@@ -4429,8 +4429,8 @@ packages:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.0.0'
- typescript@5.9.3:
- resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+ typescript@6.0.3:
+ resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
engines: {node: '>=14.17'}
hasBin: true
@@ -6256,40 +6256,40 @@ snapshots:
'@types/validate-npm-package-name@4.0.2': {}
- '@typescript-eslint/eslint-plugin@8.57.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
+ '@typescript-eslint/eslint-plugin@8.57.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
- '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
'@typescript-eslint/scope-manager': 8.57.0
- '@typescript-eslint/type-utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
- '@typescript-eslint/utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/type-utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
+ '@typescript-eslint/utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
'@typescript-eslint/visitor-keys': 8.57.0
eslint: 9.39.4(jiti@2.6.1)
ignore: 7.0.5
natural-compare: 1.4.0
- ts-api-utils: 2.4.0(typescript@5.9.3)
- typescript: 5.9.3
+ ts-api-utils: 2.4.0(typescript@6.0.3)
+ typescript: 6.0.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
+ '@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.57.0
'@typescript-eslint/types': 8.57.0
- '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3)
+ '@typescript-eslint/typescript-estree': 8.57.0(typescript@6.0.3)
'@typescript-eslint/visitor-keys': 8.57.0
debug: 4.4.3
eslint: 9.39.4(jiti@2.6.1)
- typescript: 5.9.3
+ typescript: 6.0.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/project-service@8.57.0(typescript@5.9.3)':
+ '@typescript-eslint/project-service@8.57.0(typescript@6.0.3)':
dependencies:
- '@typescript-eslint/tsconfig-utils': 8.57.0(typescript@5.9.3)
+ '@typescript-eslint/tsconfig-utils': 8.57.0(typescript@6.0.3)
'@typescript-eslint/types': 8.57.0
debug: 4.4.3
- typescript: 5.9.3
+ typescript: 6.0.3
transitivePeerDependencies:
- supports-color
@@ -6298,47 +6298,47 @@ snapshots:
'@typescript-eslint/types': 8.57.0
'@typescript-eslint/visitor-keys': 8.57.0
- '@typescript-eslint/tsconfig-utils@8.57.0(typescript@5.9.3)':
+ '@typescript-eslint/tsconfig-utils@8.57.0(typescript@6.0.3)':
dependencies:
- typescript: 5.9.3
+ typescript: 6.0.3
- '@typescript-eslint/type-utils@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
+ '@typescript-eslint/type-utils@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)':
dependencies:
'@typescript-eslint/types': 8.57.0
- '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3)
- '@typescript-eslint/utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/typescript-estree': 8.57.0(typescript@6.0.3)
+ '@typescript-eslint/utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
debug: 4.4.3
eslint: 9.39.4(jiti@2.6.1)
- ts-api-utils: 2.4.0(typescript@5.9.3)
- typescript: 5.9.3
+ ts-api-utils: 2.4.0(typescript@6.0.3)
+ typescript: 6.0.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/types@8.57.0': {}
- '@typescript-eslint/typescript-estree@8.57.0(typescript@5.9.3)':
+ '@typescript-eslint/typescript-estree@8.57.0(typescript@6.0.3)':
dependencies:
- '@typescript-eslint/project-service': 8.57.0(typescript@5.9.3)
- '@typescript-eslint/tsconfig-utils': 8.57.0(typescript@5.9.3)
+ '@typescript-eslint/project-service': 8.57.0(typescript@6.0.3)
+ '@typescript-eslint/tsconfig-utils': 8.57.0(typescript@6.0.3)
'@typescript-eslint/types': 8.57.0
'@typescript-eslint/visitor-keys': 8.57.0
debug: 4.4.3
minimatch: 10.2.4
semver: 7.7.4
tinyglobby: 0.2.15
- ts-api-utils: 2.4.0(typescript@5.9.3)
- typescript: 5.9.3
+ ts-api-utils: 2.4.0(typescript@6.0.3)
+ typescript: 6.0.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/utils@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
+ '@typescript-eslint/utils@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
'@typescript-eslint/scope-manager': 8.57.0
'@typescript-eslint/types': 8.57.0
- '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3)
+ '@typescript-eslint/typescript-estree': 8.57.0(typescript@6.0.3)
eslint: 9.39.4(jiti@2.6.1)
- typescript: 5.9.3
+ typescript: 6.0.3
transitivePeerDependencies:
- supports-color
@@ -6723,14 +6723,14 @@ snapshots:
dependencies:
layout-base: 2.0.1
- cosmiconfig@9.0.1(typescript@5.9.3):
+ cosmiconfig@9.0.1(typescript@6.0.3):
dependencies:
env-paths: 2.2.1
import-fresh: 3.3.1
js-yaml: 4.1.1
parse-json: 5.2.0
optionalDependencies:
- typescript: 5.9.3
+ typescript: 6.0.3
crelt@1.0.6: {}
@@ -7178,20 +7178,20 @@ snapshots:
escape-string-regexp@5.0.0: {}
- eslint-config-next@16.1.6(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3):
+ eslint-config-next@16.1.6(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3):
dependencies:
'@next/eslint-plugin-next': 16.1.6
eslint: 9.39.4(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1))
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-react-hooks: 7.0.1(eslint@9.39.4(jiti@2.6.1))
globals: 16.4.0
- typescript-eslint: 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ typescript-eslint: 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
optionalDependencies:
- typescript: 5.9.3
+ typescript: 6.0.3
transitivePeerDependencies:
- '@typescript-eslint/parser'
- eslint-import-resolver-webpack
@@ -7217,22 +7217,22 @@ snapshots:
tinyglobby: 0.2.15
unrs-resolver: 1.11.1
optionalDependencies:
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
transitivePeerDependencies:
- supports-color
- eslint-module-utils@2.12.1(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)):
+ eslint-module-utils@2.12.1(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)):
dependencies:
debug: 3.2.7
optionalDependencies:
- '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
eslint: 9.39.4(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1))
transitivePeerDependencies:
- supports-color
- eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)):
+ eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -7243,7 +7243,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.39.4(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
- eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
+ eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@@ -7255,7 +7255,7 @@ snapshots:
string.prototype.trimend: 1.0.9
tsconfig-paths: 3.15.0
optionalDependencies:
- '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
@@ -9285,7 +9285,7 @@ snapshots:
setprototypeof@1.2.0: {}
- shadcn@4.18.0(typescript@5.9.3):
+ shadcn@4.18.0(typescript@6.0.3):
dependencies:
'@babel/core': 7.29.0
'@babel/parser': 7.29.0
@@ -9296,7 +9296,7 @@ snapshots:
'@types/validate-npm-package-name': 4.0.2
browserslist: 4.28.1
commander: 14.0.3
- cosmiconfig: 9.0.1(typescript@5.9.3)
+ cosmiconfig: 9.0.1(typescript@6.0.3)
dedent: 1.7.2
deepmerge: 4.3.1
diff: 8.0.3
@@ -9572,9 +9572,9 @@ snapshots:
trough@2.2.0: {}
- ts-api-utils@2.4.0(typescript@5.9.3):
+ ts-api-utils@2.4.0(typescript@6.0.3):
dependencies:
- typescript: 5.9.3
+ typescript: 6.0.3
ts-dedent@2.3.0: {}
@@ -9647,18 +9647,18 @@ snapshots:
possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10
- typescript-eslint@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3):
+ typescript-eslint@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3):
dependencies:
- '@typescript-eslint/eslint-plugin': 8.57.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
- '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
- '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3)
- '@typescript-eslint/utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/eslint-plugin': 8.57.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
+ '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
+ '@typescript-eslint/typescript-estree': 8.57.0(typescript@6.0.3)
+ '@typescript-eslint/utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
eslint: 9.39.4(jiti@2.6.1)
- typescript: 5.9.3
+ typescript: 6.0.3
transitivePeerDependencies:
- supports-color
- typescript@5.9.3: {}
+ typescript@6.0.3: {}
uc.micro@2.1.0: {}
From c35873da7ea4cc638d95284b2338c4d66aa82efe Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Tue, 22 Sep 2026 11:38:07 +0700
Subject: [PATCH 53/53] chore: regenerate pnpm-lock.yaml with pnpm 10 to match
CI
The merge left the lockfile's overrides snapshot inconsistent with
package.json under pnpm 10 (ERR_PNPM_LOCKFILE_CONFIG_MISMATCH in CI's
frozen install). Regenerate with the same pnpm 10.30.2 CI uses.
---
web/pnpm-lock.yaml | 730 +++++++++++++++++++++++----------------------
1 file changed, 380 insertions(+), 350 deletions(-)
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
index 32e8916a..49685b9e 100644
--- a/web/pnpm-lock.yaml
+++ b/web/pnpm-lock.yaml
@@ -4,6 +4,36 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
+overrides:
+ fast-uri@>=3.0.0 <3.1.6: 3.1.6
+ nanoid@>=3.0.0 <3.3.18: 3.3.18
+ js-yaml@>=4.0.0 <4.3.1: 4.3.1
+ brace-expansion@<1.1.18: 1.1.18
+ brace-expansion@>=3.0.0 <5.0.9: 5.0.9
+ postcss@<8.5.23: 8.5.23
+ browserslist@<4.28.7: 4.28.7
+ ws@>=8.0.0 <8.21.0: 8.21.0
+ undici@>=6.0.0 <6.28.0: 6.28.0
+ sharp@<0.35.0: 0.35.0
+ hono@>=4.0.0 <4.12.34: 4.12.34
+ '@hono/node-server@<1.19.15': 1.19.15
+ path-to-regexp@>=8.0.0 <8.4.0: 8.4.0
+ picomatch@>=2.0.0 <2.3.2: 2.3.2
+ picomatch@>=4.0.0 <4.0.4: 4.0.4
+ lodash@>=4.0.0 <4.18.0: 4.18.0
+ langsmith@<0.6.0: 0.6.0
+ uuid@<11.1.1: 11.1.1
+ linkify-it@>=5.0.0 <5.0.2: 5.0.2
+ immutable@>=5.0.0 <5.1.8: 5.1.8
+ esbuild@>=0.24.0 <0.25.0: 0.25.0
+ flatted@>=3.0.0 <3.4.2: 3.4.2
+ qs@>=6.0.0 <6.16.0: 6.16.0
+ '@humanfs/node@<0.16.8': 0.16.8
+ ip-address@<10.3.1: 10.3.1
+ body-parser@>=2.0.0 <2.3.0: 2.3.0
+ postcss-selector-parser@>=7.0.0 <7.1.3: 7.1.3
+ '@babel/core@<7.29.6': 7.29.6
+
importers:
.:
@@ -82,7 +112,7 @@ importers:
version: 11.17.0
next:
specifier: 16.2.11
- version: 16.2.11(@babel/core@7.29.6(supports-color@7.2.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.98.0)
+ version: 16.2.11(@babel/core@7.29.6)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.98.0)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -100,7 +130,7 @@ importers:
version: 7.71.2(react@19.2.3)
react-markdown:
specifier: ^10.1.0
- version: 10.1.0(@types/react@19.2.14)(react@19.2.3)(supports-color@7.2.0)
+ version: 10.1.0(@types/react@19.2.14)(react@19.2.3)
react-resizable-panels:
specifier: ^4.7.6
version: 4.7.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -118,10 +148,10 @@ importers:
version: 4.0.0
remark-gfm:
specifier: ^4.0.1
- version: 4.0.1(supports-color@7.2.0)
+ version: 4.0.1
shadcn:
specifier: ^4.18.0
- version: 4.18.0(supports-color@7.2.0)(typescript@6.0.3)
+ version: 4.18.0(typescript@6.0.3)
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -164,10 +194,10 @@ importers:
version: 5.0.6
eslint:
specifier: ^9
- version: 9.39.4(jiti@2.6.1)(supports-color@7.2.0)
+ version: 9.39.4(jiti@2.6.1)
eslint-config-next:
specifier: 16.2.11
- version: 16.2.11(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
+ version: 16.2.11(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
sass:
specifier: ^1.98.0
version: 1.98.0
@@ -226,7 +256,7 @@ packages:
resolution: {integrity: sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==}
engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': ^7.0.0
+ '@babel/core': 7.29.6
'@babel/helper-globals@7.28.0':
resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==}
@@ -244,7 +274,7 @@ packages:
resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==}
engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': ^7.0.0
+ '@babel/core': 7.29.6
'@babel/helper-optimise-call-expression@7.27.1':
resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==}
@@ -258,7 +288,7 @@ packages:
resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==}
engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': ^7.0.0
+ '@babel/core': 7.29.6
'@babel/helper-skip-transparent-expression-wrappers@7.27.1':
resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==}
@@ -302,31 +332,31 @@ packages:
resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==}
engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': ^7.0.0-0
+ '@babel/core': 7.29.6
'@babel/plugin-syntax-typescript@7.28.6':
resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==}
engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': ^7.0.0-0
+ '@babel/core': 7.29.6
'@babel/plugin-transform-modules-commonjs@7.28.6':
resolution: {integrity: sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==}
engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': ^7.0.0-0
+ '@babel/core': 7.29.6
'@babel/plugin-transform-typescript@7.28.6':
resolution: {integrity: sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==}
engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': ^7.0.0-0
+ '@babel/core': 7.29.6
'@babel/preset-typescript@7.28.5':
resolution: {integrity: sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==}
engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': ^7.0.0-0
+ '@babel/core': 7.29.6
'@babel/runtime@7.28.6':
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
@@ -579,7 +609,7 @@ packages:
resolution: {integrity: sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg==}
engines: {node: '>=18.14.1'}
peerDependencies:
- hono: ^4
+ hono: 4.12.34
'@hookform/resolvers@5.2.2':
resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==}
@@ -616,152 +646,161 @@ packages:
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
- '@img/sharp-darwin-arm64@0.34.5':
- resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-darwin-arm64@0.35.0':
+ resolution: {integrity: sha512-ZgaYEwaj+lx/5n4W8GmZ2IYz0PQHjN5eqRcfijWGB+2Aq7ZInZGa0qJyAn6DEtyLuWHRSrmWOqT9q3qqTBvmUQ==}
+ engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [darwin]
- '@img/sharp-darwin-x64@0.34.5':
- resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-darwin-x64@0.35.0':
+ resolution: {integrity: sha512-c1z9LFpKB0slQW3RchwBE8iSVzGp70TNjUUO9k4BZwwW4HH7JBGHeIy4b+kk4n/kcBASb9evKCE3/7Slmslgiw==}
+ engines: {node: '>=20.9.0'}
cpu: [x64]
os: [darwin]
- '@img/sharp-libvips-darwin-arm64@1.2.4':
- resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
+ '@img/sharp-freebsd-wasm32@0.35.0':
+ resolution: {integrity: sha512-Li2KTev0H90kEtnJHkI9xQojXt1AqWmFBMXiPw5kqd1jQgP7gi5HVK/qC5Rmh/59NuAwUuPzzPITmX22NomYYQ==}
+ engines: {node: '>=20.9.0'}
+ os: [freebsd]
+
+ '@img/sharp-libvips-darwin-arm64@1.3.0':
+ resolution: {integrity: sha512-EKbmBKtyTH+GPFDRw2TgK2oV6hyxxlJVIar4hoTYSNmIwipgMFdxPQqR392GmfdsPGWga0mCFN1cCKjRb9cljw==}
cpu: [arm64]
os: [darwin]
- '@img/sharp-libvips-darwin-x64@1.2.4':
- resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
+ '@img/sharp-libvips-darwin-x64@1.3.0':
+ resolution: {integrity: sha512-Pl2OmOvrJ42adUllESxBsG54PfXLo1OYg9i3c5/5Ln/qJ0gZuTM9YMhQJPIbXqwidLRc/c2zuHt4RsrymmNv7A==}
cpu: [x64]
os: [darwin]
- '@img/sharp-libvips-linux-arm64@1.2.4':
- resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
+ '@img/sharp-libvips-linux-arm64@1.3.0':
+ resolution: {integrity: sha512-C0SqjoFKnszqa44EQ7xoaT48nnO0lOyXEULfXMWi8krrjOPGYkeK30Okzla6ATbBYsyZ0ySinK0FVkpv3DwzfQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@img/sharp-libvips-linux-arm@1.2.4':
- resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
+ '@img/sharp-libvips-linux-arm@1.3.0':
+ resolution: {integrity: sha512-A8UpHoUDW4DwnXoV6+q3C1s7QLRAHtPDEjWuNZjwHMyoCNZnm0GeNN8ls9f/bsEYTRQRW96C/n34XJQHJ2fT7A==}
cpu: [arm]
os: [linux]
libc: [glibc]
- '@img/sharp-libvips-linux-ppc64@1.2.4':
- resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
+ '@img/sharp-libvips-linux-ppc64@1.3.0':
+ resolution: {integrity: sha512-WOpkVxAjFd369iaIzEgNRreFD+gWdUMIGD5zplhNKNeqS6mm5dac3q2AFyCBmzYoAdouzZvRBgxy4z8QHZb4/A==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
- '@img/sharp-libvips-linux-riscv64@1.2.4':
- resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
+ '@img/sharp-libvips-linux-riscv64@1.3.0':
+ resolution: {integrity: sha512-DRWw0mOHusrCCuw2rqP87oLg6PGlkomVDFqw2hIwsSfwWpu4k3XLcBPaKKl6ct/GtL/cwNkgwjV/tc0Mqht3VA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
- '@img/sharp-libvips-linux-s390x@1.2.4':
- resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
+ '@img/sharp-libvips-linux-s390x@1.3.0':
+ resolution: {integrity: sha512-9APy+nFWhHS+kzLgWZfLcyrUd7YqnAQVa4BPOo4xkoHpdoktOAPG4cEr9+Jpl0TtqfVmcMJimNL5qNTyyOHZNA==}
cpu: [s390x]
os: [linux]
libc: [glibc]
- '@img/sharp-libvips-linux-x64@1.2.4':
- resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
+ '@img/sharp-libvips-linux-x64@1.3.0':
+ resolution: {integrity: sha512-y9RNUYDe2A1UAdhLyfeOodGRszQdaEoe4nfOpp/sNVPl2CWIcUyFaDoCh4vPLPxu19803j2naLqZup2WxDXCLA==}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
- resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
+ '@img/sharp-libvips-linuxmusl-arm64@1.3.0':
+ resolution: {integrity: sha512-cC1wkC0Mlucd0KSiGrLkJnB/ZqPvZCntc/Lk7ZnYO5ZSbF2euNek4Xvxafojq+wN1q/W0eprdpUIjUr/EV2PBg==}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@img/sharp-libvips-linuxmusl-x64@1.2.4':
- resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
+ '@img/sharp-libvips-linuxmusl-x64@1.3.0':
+ resolution: {integrity: sha512-LiYMhUZicB1QG//+RvmYZpXJO8fYRENfp+MZUCnG9aw+AKvGAy9gPaCnuwsPcBFs8EV66M0NNxj9VHcNklE8zw==}
cpu: [x64]
os: [linux]
libc: [musl]
- '@img/sharp-linux-arm64@0.34.5':
- resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linux-arm64@0.35.0':
+ resolution: {integrity: sha512-4+4XHLNT5wDT0roYlHTEmH9lDKt0acf9Tv+3hM3iceOirkxrR404/3WjAYZ9F9CkHrxeRcGLJXbi4vluMZ9O+A==}
+ engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@img/sharp-linux-arm@0.34.5':
- resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linux-arm@0.35.0':
+ resolution: {integrity: sha512-VVlpEWwizEFIOom0zdoeKuO5nuTswzVE5uHcBNvHzmeHUpNFajY3HFfbQ+zIH4E2kVaZ/yVxmsShW56TtEy4uA==}
+ engines: {node: '>=20.9.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
- '@img/sharp-linux-ppc64@0.34.5':
- resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linux-ppc64@0.35.0':
+ resolution: {integrity: sha512-N3hzbEpUTJC8pWpPVJvgzGxM+so/MAXc8O2s/53B0LL9ZGpfXpME7Wizkc5d/8fRBlBtkDjzoZGDCqqNDHqLEw==}
+ engines: {node: '>=20.9.0'}
cpu: [ppc64]
os: [linux]
libc: [glibc]
- '@img/sharp-linux-riscv64@0.34.5':
- resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linux-riscv64@0.35.0':
+ resolution: {integrity: sha512-l6vmKVPnbS0RhVMbyxP5meAARsbhCnBN4fy31qz0+3a6Rv4jEqfzDrT89y6ZPkCi0AJGnwp2En528yXo401Hpw==}
+ engines: {node: '>=20.9.0'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
- '@img/sharp-linux-s390x@0.34.5':
- resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linux-s390x@0.35.0':
+ resolution: {integrity: sha512-MYlMiPFiv/EKPAHnp3yNZ9AAWFsxga9c5Bkc6wkar6bqzHLlkGVJHRm0u1ei+VXnZxp3Mz9MG9ZIsI8vSOf3sQ==}
+ engines: {node: '>=20.9.0'}
cpu: [s390x]
os: [linux]
libc: [glibc]
- '@img/sharp-linux-x64@0.34.5':
- resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linux-x64@0.35.0':
+ resolution: {integrity: sha512-TYaItB5oj1ioXjhyn2xrR208vf+YuIIcHptQWRRaBmFhvIvL9D72DXN8w75xup0KXA8UdEAhQ9Qb2S49FD/9Cw==}
+ engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@img/sharp-linuxmusl-arm64@0.34.5':
- resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linuxmusl-arm64@0.35.0':
+ resolution: {integrity: sha512-DSTb6ijQzqe6DdAaOBVqJ/SYf1vO8EW5bK6X6LRXufEBebf2722VCdvBUtZ3rtV0x2ApfPNDy/p7LrrjaWjiyQ==}
+ engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@img/sharp-linuxmusl-x64@0.34.5':
- resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linuxmusl-x64@0.35.0':
+ resolution: {integrity: sha512-K7ykQ+26Rt6+4BTU80AuGgTPIYX86UxiAKT4rcXX/WNTo7k1ZxpKz+TguHnwVpCqQK3B5PK0vZ0ZBe6nz/ib1w==}
+ engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
libc: [musl]
- '@img/sharp-wasm32@0.34.5':
- resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-wasm32@0.35.0':
+ resolution: {integrity: sha512-9woLIFORERCr+6cWu87dQ22J34EExkhc73U1kZW0c+RclQqWetoodByp4dWZ/hN8/KVmTRAx2HOnUwib8AwZdA==}
+ engines: {node: '>=20.9.0'}
+
+ '@img/sharp-webcontainers-wasm32@0.35.0':
+ resolution: {integrity: sha512-t+kie1TOyaDM6Dho+f+y0VqIUNhYQaKCUahuZVi0E0frgdiaOaPsDxDW3wfKacUdaNBCnK/ZDBMg33ydvHj8uA==}
+ engines: {node: '>=20.9.0'}
cpu: [wasm32]
- '@img/sharp-win32-arm64@0.34.5':
- resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-win32-arm64@0.35.0':
+ resolution: {integrity: sha512-M5eKxug0dabbaWgFKvPa3odNs2OpaP+81NASfGKkt4GcYXpNhSu7CaeYxWkLNV6vHmUp4hnCxnxrUyhUJhXbKA==}
+ engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [win32]
- '@img/sharp-win32-ia32@0.34.5':
- resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-win32-ia32@0.35.0':
+ resolution: {integrity: sha512-z0+pZ03QCDvdVN0Ez9IX/yjWC19ikMlXrmdYMwYNLTh2BLPx3hXWPvyqWfquZ0BTO9O6GVOjIVoTcyyacMnWlQ==}
+ engines: {node: ^20.9.0}
cpu: [ia32]
os: [win32]
- '@img/sharp-win32-x64@0.34.5':
- resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-win32-x64@0.35.0':
+ resolution: {integrity: sha512-feNnlz5ZHKr0MY1LPHvZQyJeBkbo4ctsn0D8FvA53VTw5TC63rfEL2UrWbkSBR19htSE7Mw78xYVwdJqoMWVHw==}
+ engines: {node: '>=20.9.0'}
cpu: [x64]
os: [win32]
@@ -2735,7 +2774,7 @@ packages:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
peerDependencies:
- picomatch: ^3 || ^4
+ picomatch: 4.0.4
peerDependenciesMeta:
picomatch:
optional: true
@@ -2984,10 +3023,6 @@ packages:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
- ip-address@10.1.0:
- resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==}
- engines: {node: '>= 12'}
-
ip-address@10.3.1:
resolution: {integrity: sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==}
engines: {node: '>= 12'}
@@ -3839,10 +3874,6 @@ packages:
resolution: {integrity: sha512-ajnd7iZnqjJDkyHNfznl/ZVO0lWqvBmQXfKKENx9/p/bEiF/L3eHwdydNUg9RXZx6xfZWOCmXmBa5oeB+YrAPQ==}
engines: {node: '>=4'}
- postcss@8.4.31:
- resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
- engines: {node: ^10 || ^12 || >=14}
-
postcss@8.5.23:
resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==}
engines: {node: ^10 || ^12 || >=14}
@@ -4197,9 +4228,9 @@ packages:
engines: {node: '>=20.18.1'}
hasBin: true
- sharp@0.34.5:
- resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ sharp@0.35.0:
+ resolution: {integrity: sha512-BqvG5XbwPZ4NV0DK90d86leEECMsoa8bO0nqnKWlBDYxri4GJ7c4EDInaF6q20lTh/mATmnDIKWJFfXnoVfH5g==}
+ engines: {node: '>=20.9.0'}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
@@ -4542,7 +4573,7 @@ packages:
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
hasBin: true
peerDependencies:
- browserslist: '>= 4.21.0'
+ browserslist: 4.28.7
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
@@ -4719,20 +4750,20 @@ snapshots:
'@babel/compat-data@7.29.0': {}
- '@babel/core@7.29.6(supports-color@7.2.0)':
+ '@babel/core@7.29.6':
dependencies:
'@babel/code-frame': 7.29.0
'@babel/generator': 7.29.8
'@babel/helper-compilation-targets': 7.28.6
- '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.6(supports-color@7.2.0))(supports-color@7.2.0)
+ '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.6)
'@babel/helpers': 7.29.7
'@babel/parser': 7.29.8
'@babel/template': 7.28.6
- '@babel/traverse': 7.29.0(supports-color@7.2.0)
+ '@babel/traverse': 7.29.0
'@babel/types': 7.29.0
'@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0
- debug: 4.4.3(supports-color@7.2.0)
+ debug: 4.4.3
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
@@ -4767,41 +4798,41 @@ snapshots:
lru-cache: 5.1.1
semver: 6.3.1
- '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.6(supports-color@7.2.0))(supports-color@7.2.0)':
+ '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.6)':
dependencies:
- '@babel/core': 7.29.6(supports-color@7.2.0)
+ '@babel/core': 7.29.6
'@babel/helper-annotate-as-pure': 7.27.3
- '@babel/helper-member-expression-to-functions': 7.28.5(supports-color@7.2.0)
+ '@babel/helper-member-expression-to-functions': 7.28.5
'@babel/helper-optimise-call-expression': 7.27.1
- '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.6(supports-color@7.2.0))(supports-color@7.2.0)
- '@babel/helper-skip-transparent-expression-wrappers': 7.27.1(supports-color@7.2.0)
- '@babel/traverse': 7.29.0(supports-color@7.2.0)
+ '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.6)
+ '@babel/helper-skip-transparent-expression-wrappers': 7.27.1
+ '@babel/traverse': 7.29.0
semver: 6.3.1
transitivePeerDependencies:
- supports-color
'@babel/helper-globals@7.28.0': {}
- '@babel/helper-member-expression-to-functions@7.28.5(supports-color@7.2.0)':
+ '@babel/helper-member-expression-to-functions@7.28.5':
dependencies:
- '@babel/traverse': 7.29.0(supports-color@7.2.0)
+ '@babel/traverse': 7.29.0
'@babel/types': 7.29.0
transitivePeerDependencies:
- supports-color
- '@babel/helper-module-imports@7.28.6(supports-color@7.2.0)':
+ '@babel/helper-module-imports@7.28.6':
dependencies:
- '@babel/traverse': 7.29.0(supports-color@7.2.0)
+ '@babel/traverse': 7.29.0
'@babel/types': 7.29.0
transitivePeerDependencies:
- supports-color
- '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.6(supports-color@7.2.0))(supports-color@7.2.0)':
+ '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.6)':
dependencies:
- '@babel/core': 7.29.6(supports-color@7.2.0)
- '@babel/helper-module-imports': 7.28.6(supports-color@7.2.0)
+ '@babel/core': 7.29.6
+ '@babel/helper-module-imports': 7.28.6
'@babel/helper-validator-identifier': 7.28.5
- '@babel/traverse': 7.29.0(supports-color@7.2.0)
+ '@babel/traverse': 7.29.0
transitivePeerDependencies:
- supports-color
@@ -4811,18 +4842,18 @@ snapshots:
'@babel/helper-plugin-utils@7.28.6': {}
- '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.6(supports-color@7.2.0))(supports-color@7.2.0)':
+ '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.6)':
dependencies:
- '@babel/core': 7.29.6(supports-color@7.2.0)
- '@babel/helper-member-expression-to-functions': 7.28.5(supports-color@7.2.0)
+ '@babel/core': 7.29.6
+ '@babel/helper-member-expression-to-functions': 7.28.5
'@babel/helper-optimise-call-expression': 7.27.1
- '@babel/traverse': 7.29.0(supports-color@7.2.0)
+ '@babel/traverse': 7.29.0
transitivePeerDependencies:
- supports-color
- '@babel/helper-skip-transparent-expression-wrappers@7.27.1(supports-color@7.2.0)':
+ '@babel/helper-skip-transparent-expression-wrappers@7.27.1':
dependencies:
- '@babel/traverse': 7.29.0(supports-color@7.2.0)
+ '@babel/traverse': 7.29.0
'@babel/types': 7.29.0
transitivePeerDependencies:
- supports-color
@@ -4850,43 +4881,43 @@ snapshots:
dependencies:
'@babel/types': 7.29.8
- '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.6(supports-color@7.2.0))':
+ '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.6)':
dependencies:
- '@babel/core': 7.29.6(supports-color@7.2.0)
+ '@babel/core': 7.29.6
'@babel/helper-plugin-utils': 7.28.6
- '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.6(supports-color@7.2.0))':
+ '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.6)':
dependencies:
- '@babel/core': 7.29.6(supports-color@7.2.0)
+ '@babel/core': 7.29.6
'@babel/helper-plugin-utils': 7.28.6
- '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.6(supports-color@7.2.0))(supports-color@7.2.0)':
+ '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.6)':
dependencies:
- '@babel/core': 7.29.6(supports-color@7.2.0)
- '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.6(supports-color@7.2.0))(supports-color@7.2.0)
+ '@babel/core': 7.29.6
+ '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.6)
'@babel/helper-plugin-utils': 7.28.6
transitivePeerDependencies:
- supports-color
- '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.6(supports-color@7.2.0))(supports-color@7.2.0)':
+ '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.6)':
dependencies:
- '@babel/core': 7.29.6(supports-color@7.2.0)
+ '@babel/core': 7.29.6
'@babel/helper-annotate-as-pure': 7.27.3
- '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.6(supports-color@7.2.0))(supports-color@7.2.0)
+ '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.6)
'@babel/helper-plugin-utils': 7.28.6
- '@babel/helper-skip-transparent-expression-wrappers': 7.27.1(supports-color@7.2.0)
- '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.6(supports-color@7.2.0))
+ '@babel/helper-skip-transparent-expression-wrappers': 7.27.1
+ '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.6)
transitivePeerDependencies:
- supports-color
- '@babel/preset-typescript@7.28.5(@babel/core@7.29.6(supports-color@7.2.0))(supports-color@7.2.0)':
+ '@babel/preset-typescript@7.28.5(@babel/core@7.29.6)':
dependencies:
- '@babel/core': 7.29.6(supports-color@7.2.0)
+ '@babel/core': 7.29.6
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-validator-option': 7.27.1
- '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.6(supports-color@7.2.0))
- '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.6(supports-color@7.2.0))(supports-color@7.2.0)
- '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.6(supports-color@7.2.0))(supports-color@7.2.0)
+ '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.6)
+ '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.6)
+ '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.6)
transitivePeerDependencies:
- supports-color
@@ -4904,7 +4935,7 @@ snapshots:
'@babel/parser': 7.29.8
'@babel/types': 7.29.8
- '@babel/traverse@7.29.0(supports-color@7.2.0)':
+ '@babel/traverse@7.29.0':
dependencies:
'@babel/code-frame': 7.29.0
'@babel/generator': 7.29.1
@@ -4912,7 +4943,7 @@ snapshots:
'@babel/parser': 7.29.0
'@babel/template': 7.28.6
'@babel/types': 7.29.0
- debug: 4.4.3(supports-color@7.2.0)
+ debug: 4.4.3
transitivePeerDependencies:
- supports-color
@@ -5273,17 +5304,17 @@ snapshots:
tslib: 2.8.1
optional: true
- '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))':
+ '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))':
dependencies:
- eslint: 9.39.4(jiti@2.6.1)(supports-color@7.2.0)
+ eslint: 9.39.4(jiti@2.6.1)
eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.2': {}
- '@eslint/config-array@0.21.2(supports-color@7.2.0)':
+ '@eslint/config-array@0.21.2':
dependencies:
'@eslint/object-schema': 2.1.7
- debug: 4.4.3(supports-color@7.2.0)
+ debug: 4.4.3
minimatch: 3.1.5
transitivePeerDependencies:
- supports-color
@@ -5296,10 +5327,10 @@ snapshots:
dependencies:
'@types/json-schema': 7.0.15
- '@eslint/eslintrc@3.3.5(supports-color@7.2.0)':
+ '@eslint/eslintrc@3.3.5':
dependencies:
ajv: 6.14.0
- debug: 4.4.3(supports-color@7.2.0)
+ debug: 4.4.3
espree: 10.4.0
globals: 14.0.0
ignore: 5.3.2
@@ -5372,98 +5403,108 @@ snapshots:
'@img/colour@1.1.0':
optional: true
- '@img/sharp-darwin-arm64@0.34.5':
+ '@img/sharp-darwin-arm64@0.35.0':
optionalDependencies:
- '@img/sharp-libvips-darwin-arm64': 1.2.4
+ '@img/sharp-libvips-darwin-arm64': 1.3.0
optional: true
- '@img/sharp-darwin-x64@0.34.5':
+ '@img/sharp-darwin-x64@0.35.0':
optionalDependencies:
- '@img/sharp-libvips-darwin-x64': 1.2.4
+ '@img/sharp-libvips-darwin-x64': 1.3.0
+ optional: true
+
+ '@img/sharp-freebsd-wasm32@0.35.0':
+ dependencies:
+ '@img/sharp-wasm32': 0.35.0
optional: true
- '@img/sharp-libvips-darwin-arm64@1.2.4':
+ '@img/sharp-libvips-darwin-arm64@1.3.0':
optional: true
- '@img/sharp-libvips-darwin-x64@1.2.4':
+ '@img/sharp-libvips-darwin-x64@1.3.0':
optional: true
- '@img/sharp-libvips-linux-arm64@1.2.4':
+ '@img/sharp-libvips-linux-arm64@1.3.0':
optional: true
- '@img/sharp-libvips-linux-arm@1.2.4':
+ '@img/sharp-libvips-linux-arm@1.3.0':
optional: true
- '@img/sharp-libvips-linux-ppc64@1.2.4':
+ '@img/sharp-libvips-linux-ppc64@1.3.0':
optional: true
- '@img/sharp-libvips-linux-riscv64@1.2.4':
+ '@img/sharp-libvips-linux-riscv64@1.3.0':
optional: true
- '@img/sharp-libvips-linux-s390x@1.2.4':
+ '@img/sharp-libvips-linux-s390x@1.3.0':
optional: true
- '@img/sharp-libvips-linux-x64@1.2.4':
+ '@img/sharp-libvips-linux-x64@1.3.0':
optional: true
- '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
+ '@img/sharp-libvips-linuxmusl-arm64@1.3.0':
optional: true
- '@img/sharp-libvips-linuxmusl-x64@1.2.4':
+ '@img/sharp-libvips-linuxmusl-x64@1.3.0':
optional: true
- '@img/sharp-linux-arm64@0.34.5':
+ '@img/sharp-linux-arm64@0.35.0':
optionalDependencies:
- '@img/sharp-libvips-linux-arm64': 1.2.4
+ '@img/sharp-libvips-linux-arm64': 1.3.0
optional: true
- '@img/sharp-linux-arm@0.34.5':
+ '@img/sharp-linux-arm@0.35.0':
optionalDependencies:
- '@img/sharp-libvips-linux-arm': 1.2.4
+ '@img/sharp-libvips-linux-arm': 1.3.0
optional: true
- '@img/sharp-linux-ppc64@0.34.5':
+ '@img/sharp-linux-ppc64@0.35.0':
optionalDependencies:
- '@img/sharp-libvips-linux-ppc64': 1.2.4
+ '@img/sharp-libvips-linux-ppc64': 1.3.0
optional: true
- '@img/sharp-linux-riscv64@0.34.5':
+ '@img/sharp-linux-riscv64@0.35.0':
optionalDependencies:
- '@img/sharp-libvips-linux-riscv64': 1.2.4
+ '@img/sharp-libvips-linux-riscv64': 1.3.0
optional: true
- '@img/sharp-linux-s390x@0.34.5':
+ '@img/sharp-linux-s390x@0.35.0':
optionalDependencies:
- '@img/sharp-libvips-linux-s390x': 1.2.4
+ '@img/sharp-libvips-linux-s390x': 1.3.0
optional: true
- '@img/sharp-linux-x64@0.34.5':
+ '@img/sharp-linux-x64@0.35.0':
optionalDependencies:
- '@img/sharp-libvips-linux-x64': 1.2.4
+ '@img/sharp-libvips-linux-x64': 1.3.0
optional: true
- '@img/sharp-linuxmusl-arm64@0.34.5':
+ '@img/sharp-linuxmusl-arm64@0.35.0':
optionalDependencies:
- '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
+ '@img/sharp-libvips-linuxmusl-arm64': 1.3.0
optional: true
- '@img/sharp-linuxmusl-x64@0.34.5':
+ '@img/sharp-linuxmusl-x64@0.35.0':
optionalDependencies:
- '@img/sharp-libvips-linuxmusl-x64': 1.2.4
+ '@img/sharp-libvips-linuxmusl-x64': 1.3.0
optional: true
- '@img/sharp-wasm32@0.34.5':
+ '@img/sharp-wasm32@0.35.0':
dependencies:
'@emnapi/runtime': 1.11.3
optional: true
- '@img/sharp-win32-arm64@0.34.5':
+ '@img/sharp-webcontainers-wasm32@0.35.0':
+ dependencies:
+ '@img/sharp-wasm32': 0.35.0
+ optional: true
+
+ '@img/sharp-win32-arm64@0.35.0':
optional: true
- '@img/sharp-win32-ia32@0.34.5':
+ '@img/sharp-win32-ia32@0.35.0':
optional: true
- '@img/sharp-win32-x64@0.34.5':
+ '@img/sharp-win32-x64@0.35.0':
optional: true
'@jridgewell/gen-mapping@0.3.13':
@@ -5591,7 +5632,7 @@ snapshots:
'@mixmark-io/domino@2.2.0': {}
- '@modelcontextprotocol/sdk@1.27.1(supports-color@7.2.0)(zod@3.25.76)':
+ '@modelcontextprotocol/sdk@1.27.1(zod@3.25.76)':
dependencies:
'@hono/node-server': 1.19.15(hono@4.12.34)
ajv: 8.18.0
@@ -5601,8 +5642,8 @@ snapshots:
cross-spawn: 7.0.6
eventsource: 3.0.7
eventsource-parser: 3.0.6
- express: 5.2.1(supports-color@7.2.0)
- express-rate-limit: 8.3.1(express@5.2.1(supports-color@7.2.0))
+ express: 5.2.1
+ express-rate-limit: 8.3.1(express@5.2.1)
hono: 4.12.34
jose: 6.2.3
json-schema-typed: 8.0.2
@@ -6345,15 +6386,15 @@ snapshots:
'@types/validate-npm-package-name@4.0.2': {}
- '@typescript-eslint/eslint-plugin@8.57.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)':
+ '@typescript-eslint/eslint-plugin@8.57.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
- '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
+ '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
'@typescript-eslint/scope-manager': 8.57.0
- '@typescript-eslint/type-utils': 8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
- '@typescript-eslint/utils': 8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
+ '@typescript-eslint/type-utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
+ '@typescript-eslint/utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
'@typescript-eslint/visitor-keys': 8.57.0
- eslint: 9.39.4(jiti@2.6.1)(supports-color@7.2.0)
+ eslint: 9.39.4(jiti@2.6.1)
ignore: 7.0.5
natural-compare: 1.4.0
ts-api-utils: 2.4.0(typescript@6.0.3)
@@ -6361,23 +6402,23 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)':
+ '@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.57.0
'@typescript-eslint/types': 8.57.0
- '@typescript-eslint/typescript-estree': 8.57.0(supports-color@7.2.0)(typescript@6.0.3)
+ '@typescript-eslint/typescript-estree': 8.57.0(typescript@6.0.3)
'@typescript-eslint/visitor-keys': 8.57.0
- debug: 4.4.3(supports-color@7.2.0)
- eslint: 9.39.4(jiti@2.6.1)(supports-color@7.2.0)
+ debug: 4.4.3
+ eslint: 9.39.4(jiti@2.6.1)
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/project-service@8.57.0(supports-color@7.2.0)(typescript@6.0.3)':
+ '@typescript-eslint/project-service@8.57.0(typescript@6.0.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.57.0(typescript@6.0.3)
'@typescript-eslint/types': 8.57.0
- debug: 4.4.3(supports-color@7.2.0)
+ debug: 4.4.3
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
@@ -6391,13 +6432,13 @@ snapshots:
dependencies:
typescript: 6.0.3
- '@typescript-eslint/type-utils@8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)':
+ '@typescript-eslint/type-utils@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)':
dependencies:
'@typescript-eslint/types': 8.57.0
- '@typescript-eslint/typescript-estree': 8.57.0(supports-color@7.2.0)(typescript@6.0.3)
- '@typescript-eslint/utils': 8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
- debug: 4.4.3(supports-color@7.2.0)
- eslint: 9.39.4(jiti@2.6.1)(supports-color@7.2.0)
+ '@typescript-eslint/typescript-estree': 8.57.0(typescript@6.0.3)
+ '@typescript-eslint/utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
+ debug: 4.4.3
+ eslint: 9.39.4(jiti@2.6.1)
ts-api-utils: 2.4.0(typescript@6.0.3)
typescript: 6.0.3
transitivePeerDependencies:
@@ -6405,13 +6446,13 @@ snapshots:
'@typescript-eslint/types@8.57.0': {}
- '@typescript-eslint/typescript-estree@8.57.0(supports-color@7.2.0)(typescript@6.0.3)':
+ '@typescript-eslint/typescript-estree@8.57.0(typescript@6.0.3)':
dependencies:
- '@typescript-eslint/project-service': 8.57.0(supports-color@7.2.0)(typescript@6.0.3)
+ '@typescript-eslint/project-service': 8.57.0(typescript@6.0.3)
'@typescript-eslint/tsconfig-utils': 8.57.0(typescript@6.0.3)
'@typescript-eslint/types': 8.57.0
'@typescript-eslint/visitor-keys': 8.57.0
- debug: 4.4.3(supports-color@7.2.0)
+ debug: 4.4.3
minimatch: 10.2.4
semver: 7.7.4
tinyglobby: 0.2.15
@@ -6420,13 +6461,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/utils@8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)':
+ '@typescript-eslint/utils@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)':
dependencies:
- '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
'@typescript-eslint/scope-manager': 8.57.0
'@typescript-eslint/types': 8.57.0
- '@typescript-eslint/typescript-estree': 8.57.0(supports-color@7.2.0)(typescript@6.0.3)
- eslint: 9.39.4(jiti@2.6.1)(supports-color@7.2.0)
+ '@typescript-eslint/typescript-estree': 8.57.0(typescript@6.0.3)
+ eslint: 9.39.4(jiti@2.6.1)
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
@@ -6648,11 +6689,11 @@ snapshots:
baseline-browser-mapping@2.11.21: {}
- body-parser@2.3.0(supports-color@7.2.0):
+ body-parser@2.3.0:
dependencies:
bytes: 3.1.2
content-type: 2.1.0
- debug: 4.4.3(supports-color@7.2.0)
+ debug: 4.4.3
http-errors: 2.0.1
iconv-lite: 0.7.2
on-finished: 2.4.1
@@ -7051,17 +7092,13 @@ snapshots:
dayjs@1.11.23: {}
- debug@3.2.7(supports-color@7.2.0):
+ debug@3.2.7:
dependencies:
ms: 2.1.3
- optionalDependencies:
- supports-color: 7.2.0
- debug@4.4.3(supports-color@7.2.0):
+ debug@4.4.3:
dependencies:
ms: 2.1.3
- optionalDependencies:
- supports-color: 7.2.0
decimal.js-light@2.5.1: {}
@@ -7277,18 +7314,18 @@ snapshots:
escape-string-regexp@5.0.0: {}
- eslint-config-next@16.2.11(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3):
+ eslint-config-next@16.2.11(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3):
dependencies:
'@next/eslint-plugin-next': 16.2.11
- eslint: 9.39.4(jiti@2.6.1)(supports-color@7.2.0)
- eslint-import-resolver-node: 0.3.9(supports-color@7.2.0)
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)
- eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))
- eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))
- eslint-plugin-react-hooks: 7.0.1(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)
+ eslint: 9.39.4(jiti@2.6.1)
+ eslint-import-resolver-node: 0.3.9
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1))
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
+ eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1))
+ eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1))
+ eslint-plugin-react-hooks: 7.0.1(eslint@9.39.4(jiti@2.6.1))
globals: 16.4.0
- typescript-eslint: 8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
+ typescript-eslint: 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
optionalDependencies:
typescript: 6.0.3
transitivePeerDependencies:
@@ -7297,52 +7334,52 @@ snapshots:
- eslint-plugin-import-x
- supports-color
- eslint-import-resolver-node@0.3.9(supports-color@7.2.0):
+ eslint-import-resolver-node@0.3.9:
dependencies:
- debug: 3.2.7(supports-color@7.2.0)
+ debug: 3.2.7
is-core-module: 2.16.1
resolve: 1.22.11
transitivePeerDependencies:
- supports-color
- eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0):
+ eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)):
dependencies:
'@nolyfill/is-core-module': 1.0.39
- debug: 4.4.3(supports-color@7.2.0)
- eslint: 9.39.4(jiti@2.6.1)(supports-color@7.2.0)
+ debug: 4.4.3
+ eslint: 9.39.4(jiti@2.6.1)
get-tsconfig: 4.13.6
is-bun-module: 2.0.0
stable-hash: 0.0.5
tinyglobby: 0.2.15
unrs-resolver: 1.11.1
optionalDependencies:
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
transitivePeerDependencies:
- supports-color
- eslint-module-utils@2.12.1(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint-import-resolver-node@0.3.9(supports-color@7.2.0))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0):
+ eslint-module-utils@2.12.1(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)):
dependencies:
- debug: 3.2.7(supports-color@7.2.0)
+ debug: 3.2.7
optionalDependencies:
- '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
- eslint: 9.39.4(jiti@2.6.1)(supports-color@7.2.0)
- eslint-import-resolver-node: 0.3.9(supports-color@7.2.0)
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)
+ '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
+ eslint: 9.39.4(jiti@2.6.1)
+ eslint-import-resolver-node: 0.3.9
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1))
transitivePeerDependencies:
- supports-color
- eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0):
+ eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
array.prototype.findlastindex: 1.2.6
array.prototype.flat: 1.3.3
array.prototype.flatmap: 1.3.3
- debug: 3.2.7(supports-color@7.2.0)
+ debug: 3.2.7
doctrine: 2.1.0
- eslint: 9.39.4(jiti@2.6.1)(supports-color@7.2.0)
- eslint-import-resolver-node: 0.3.9(supports-color@7.2.0)
- eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint-import-resolver-node@0.3.9(supports-color@7.2.0))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)
+ eslint: 9.39.4(jiti@2.6.1)
+ eslint-import-resolver-node: 0.3.9
+ eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@@ -7354,13 +7391,13 @@ snapshots:
string.prototype.trimend: 1.0.9
tsconfig-paths: 3.15.0
optionalDependencies:
- '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
+ '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1))(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.4(jiti@2.6.1)(supports-color@7.2.0)):
+ eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4(jiti@2.6.1)):
dependencies:
aria-query: 5.3.2
array-includes: 3.1.9
@@ -7370,7 +7407,7 @@ snapshots:
axobject-query: 4.1.0
damerau-levenshtein: 1.0.8
emoji-regex: 9.2.2
- eslint: 9.39.4(jiti@2.6.1)(supports-color@7.2.0)
+ eslint: 9.39.4(jiti@2.6.1)
hasown: 2.0.2
jsx-ast-utils: 3.3.5
language-tags: 1.0.9
@@ -7379,18 +7416,18 @@ snapshots:
safe-regex-test: 1.1.0
string.prototype.includes: 2.0.1
- eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0):
+ eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@2.6.1)):
dependencies:
- '@babel/core': 7.29.6(supports-color@7.2.0)
+ '@babel/core': 7.29.6
'@babel/parser': 7.29.0
- eslint: 9.39.4(jiti@2.6.1)(supports-color@7.2.0)
+ eslint: 9.39.4(jiti@2.6.1)
hermes-parser: 0.25.1
zod: 4.3.6
zod-validation-error: 4.0.2(zod@4.3.6)
transitivePeerDependencies:
- supports-color
- eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0)):
+ eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@2.6.1)):
dependencies:
array-includes: 3.1.9
array.prototype.findlast: 1.2.5
@@ -7398,7 +7435,7 @@ snapshots:
array.prototype.tosorted: 1.1.4
doctrine: 2.1.0
es-iterator-helpers: 1.3.1
- eslint: 9.39.4(jiti@2.6.1)(supports-color@7.2.0)
+ eslint: 9.39.4(jiti@2.6.1)
estraverse: 5.3.0
hasown: 2.0.2
jsx-ast-utils: 3.3.5
@@ -7423,14 +7460,14 @@ snapshots:
eslint-visitor-keys@5.0.1: {}
- eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0):
+ eslint@9.39.4(jiti@2.6.1):
dependencies:
- '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
'@eslint-community/regexpp': 4.12.2
- '@eslint/config-array': 0.21.2(supports-color@7.2.0)
+ '@eslint/config-array': 0.21.2
'@eslint/config-helpers': 0.4.2
'@eslint/core': 0.17.0
- '@eslint/eslintrc': 3.3.5(supports-color@7.2.0)
+ '@eslint/eslintrc': 3.3.5
'@eslint/js': 9.39.4
'@eslint/plugin-kit': 0.4.1
'@humanfs/node': 0.16.8
@@ -7440,7 +7477,7 @@ snapshots:
ajv: 6.14.0
chalk: 4.1.2
cross-spawn: 7.0.6
- debug: 4.4.3(supports-color@7.2.0)
+ debug: 4.4.3
escape-string-regexp: 4.0.0
eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1
@@ -7523,25 +7560,25 @@ snapshots:
strip-final-newline: 4.0.0
yoctocolors: 2.1.2
- express-rate-limit@8.3.1(express@5.2.1(supports-color@7.2.0)):
+ express-rate-limit@8.3.1(express@5.2.1):
dependencies:
- express: 5.2.1(supports-color@7.2.0)
- ip-address: 10.1.0
+ express: 5.2.1
+ ip-address: 10.3.1
- express@5.2.1(supports-color@7.2.0):
+ express@5.2.1:
dependencies:
accepts: 2.0.0
- body-parser: 2.3.0(supports-color@7.2.0)
+ body-parser: 2.3.0
content-disposition: 1.0.1
content-type: 1.0.5
cookie: 0.7.2
cookie-signature: 1.2.2
- debug: 4.4.3(supports-color@7.2.0)
+ debug: 4.4.3
depd: 2.0.0
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
- finalhandler: 2.1.1(supports-color@7.2.0)
+ finalhandler: 2.1.1
fresh: 2.0.0
http-errors: 2.0.1
merge-descriptors: 2.0.0
@@ -7552,9 +7589,9 @@ snapshots:
proxy-addr: 2.0.7
qs: 6.16.0
range-parser: 1.2.1
- router: 2.2.0(supports-color@7.2.0)
- send: 1.2.1(supports-color@7.2.0)
- serve-static: 2.2.1(supports-color@7.2.0)
+ router: 2.2.0
+ send: 1.2.1
+ serve-static: 2.2.1
statuses: 2.0.2
type-is: 2.0.1
vary: 1.1.2
@@ -7613,9 +7650,9 @@ snapshots:
dependencies:
to-regex-range: 5.0.1
- finalhandler@2.1.1(supports-color@7.2.0):
+ finalhandler@2.1.1:
dependencies:
- debug: 4.4.3(supports-color@7.2.0)
+ debug: 4.4.3
encodeurl: 2.0.0
escape-html: 1.0.3
on-finished: 2.4.1
@@ -7759,7 +7796,7 @@ snapshots:
dependencies:
'@types/hast': 3.0.5
- hast-util-to-jsx-runtime@2.3.6(supports-color@7.2.0):
+ hast-util-to-jsx-runtime@2.3.6:
dependencies:
'@types/estree': 1.0.8
'@types/hast': 3.0.5
@@ -7768,9 +7805,9 @@ snapshots:
devlop: 1.1.0
estree-util-is-identifier-name: 3.0.0
hast-util-whitespace: 3.0.0
- mdast-util-mdx-expression: 2.0.1(supports-color@7.2.0)
- mdast-util-mdx-jsx: 3.2.0(supports-color@7.2.0)
- mdast-util-mdxjs-esm: 2.0.1(supports-color@7.2.0)
+ mdast-util-mdx-expression: 2.0.1
+ mdast-util-mdx-jsx: 3.2.0
+ mdast-util-mdxjs-esm: 2.0.1
property-information: 7.2.0
space-separated-tokens: 2.0.2
style-to-js: 1.1.21
@@ -7851,8 +7888,6 @@ snapshots:
internmap@2.0.3: {}
- ip-address@10.1.0: {}
-
ip-address@10.3.1: {}
ipaddr.js@1.9.1: {}
@@ -8257,14 +8292,14 @@ snapshots:
unist-util-is: 6.0.1
unist-util-visit-parents: 6.0.2
- mdast-util-from-markdown@2.0.3(supports-color@7.2.0):
+ mdast-util-from-markdown@2.0.3:
dependencies:
'@types/mdast': 4.0.4
'@types/unist': 3.0.3
decode-named-character-reference: 1.3.0
devlop: 1.1.0
mdast-util-to-string: 4.0.0
- micromark: 4.0.2(supports-color@7.2.0)
+ micromark: 4.0.2
micromark-util-decode-numeric-character-reference: 2.0.2
micromark-util-decode-string: 2.0.1
micromark-util-normalize-identifier: 2.0.1
@@ -8282,67 +8317,67 @@ snapshots:
mdast-util-find-and-replace: 3.0.2
micromark-util-character: 2.1.1
- mdast-util-gfm-footnote@2.1.0(supports-color@7.2.0):
+ mdast-util-gfm-footnote@2.1.0:
dependencies:
'@types/mdast': 4.0.4
devlop: 1.1.0
- mdast-util-from-markdown: 2.0.3(supports-color@7.2.0)
+ mdast-util-from-markdown: 2.0.3
mdast-util-to-markdown: 2.1.2
micromark-util-normalize-identifier: 2.0.1
transitivePeerDependencies:
- supports-color
- mdast-util-gfm-strikethrough@2.0.0(supports-color@7.2.0):
+ mdast-util-gfm-strikethrough@2.0.0:
dependencies:
'@types/mdast': 4.0.4
- mdast-util-from-markdown: 2.0.3(supports-color@7.2.0)
+ mdast-util-from-markdown: 2.0.3
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
- mdast-util-gfm-table@2.0.0(supports-color@7.2.0):
+ mdast-util-gfm-table@2.0.0:
dependencies:
'@types/mdast': 4.0.4
devlop: 1.1.0
markdown-table: 3.0.4
- mdast-util-from-markdown: 2.0.3(supports-color@7.2.0)
+ mdast-util-from-markdown: 2.0.3
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
- mdast-util-gfm-task-list-item@2.0.0(supports-color@7.2.0):
+ mdast-util-gfm-task-list-item@2.0.0:
dependencies:
'@types/mdast': 4.0.4
devlop: 1.1.0
- mdast-util-from-markdown: 2.0.3(supports-color@7.2.0)
+ mdast-util-from-markdown: 2.0.3
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
- mdast-util-gfm@3.1.0(supports-color@7.2.0):
+ mdast-util-gfm@3.1.0:
dependencies:
- mdast-util-from-markdown: 2.0.3(supports-color@7.2.0)
+ mdast-util-from-markdown: 2.0.3
mdast-util-gfm-autolink-literal: 2.0.1
- mdast-util-gfm-footnote: 2.1.0(supports-color@7.2.0)
- mdast-util-gfm-strikethrough: 2.0.0(supports-color@7.2.0)
- mdast-util-gfm-table: 2.0.0(supports-color@7.2.0)
- mdast-util-gfm-task-list-item: 2.0.0(supports-color@7.2.0)
+ mdast-util-gfm-footnote: 2.1.0
+ mdast-util-gfm-strikethrough: 2.0.0
+ mdast-util-gfm-table: 2.0.0
+ mdast-util-gfm-task-list-item: 2.0.0
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
- mdast-util-mdx-expression@2.0.1(supports-color@7.2.0):
+ mdast-util-mdx-expression@2.0.1:
dependencies:
'@types/estree-jsx': 1.0.5
'@types/hast': 3.0.5
'@types/mdast': 4.0.4
devlop: 1.1.0
- mdast-util-from-markdown: 2.0.3(supports-color@7.2.0)
+ mdast-util-from-markdown: 2.0.3
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
- mdast-util-mdx-jsx@3.2.0(supports-color@7.2.0):
+ mdast-util-mdx-jsx@3.2.0:
dependencies:
'@types/estree-jsx': 1.0.5
'@types/hast': 3.0.5
@@ -8350,7 +8385,7 @@ snapshots:
'@types/unist': 3.0.3
ccount: 2.0.1
devlop: 1.1.0
- mdast-util-from-markdown: 2.0.3(supports-color@7.2.0)
+ mdast-util-from-markdown: 2.0.3
mdast-util-to-markdown: 2.1.2
parse-entities: 4.0.2
stringify-entities: 4.0.4
@@ -8359,13 +8394,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
- mdast-util-mdxjs-esm@2.0.1(supports-color@7.2.0):
+ mdast-util-mdxjs-esm@2.0.1:
dependencies:
'@types/estree-jsx': 1.0.5
'@types/hast': 3.0.5
'@types/mdast': 4.0.4
devlop: 1.1.0
- mdast-util-from-markdown: 2.0.3(supports-color@7.2.0)
+ mdast-util-from-markdown: 2.0.3
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
@@ -8614,10 +8649,10 @@ snapshots:
micromark-util-types@2.0.2: {}
- micromark@4.0.2(supports-color@7.2.0):
+ micromark@4.0.2:
dependencies:
'@types/debug': 4.1.13
- debug: 4.4.3(supports-color@7.2.0)
+ debug: 4.4.3
decode-named-character-reference: 1.3.0
devlop: 1.1.0
micromark-core-commonmark: 2.0.3
@@ -8676,16 +8711,16 @@ snapshots:
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
- next@16.2.11(@babel/core@7.29.6(supports-color@7.2.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.98.0):
+ next@16.2.11(@babel/core@7.29.6)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.98.0):
dependencies:
'@next/env': 16.2.11
'@swc/helpers': 0.5.15
baseline-browser-mapping: 2.10.7
caniuse-lite: 1.0.30001778
- postcss: 8.4.31
+ postcss: 8.5.23
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
- styled-jsx: 5.1.6(@babel/core@7.29.6(supports-color@7.2.0))(react@19.2.3)
+ styled-jsx: 5.1.6(@babel/core@7.29.6)(react@19.2.3)
optionalDependencies:
'@next/swc-darwin-arm64': 16.2.11
'@next/swc-darwin-x64': 16.2.11
@@ -8696,7 +8731,7 @@ snapshots:
'@next/swc-win32-arm64-msvc': 16.2.11
'@next/swc-win32-x64-msvc': 16.2.11
sass: 1.98.0
- sharp: 0.34.5
+ sharp: 0.35.0
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros
@@ -8891,12 +8926,6 @@ snapshots:
cssesc: 3.0.0
util-deprecate: 1.0.2
- postcss@8.4.31:
- dependencies:
- nanoid: 3.3.18
- picocolors: 1.1.1
- source-map-js: 1.2.1
-
postcss@8.5.23:
dependencies:
nanoid: 3.3.18
@@ -9058,17 +9087,17 @@ snapshots:
react-is@18.3.1: {}
- react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.3)(supports-color@7.2.0):
+ react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.3):
dependencies:
'@types/hast': 3.0.5
'@types/mdast': 4.0.4
'@types/react': 19.2.14
devlop: 1.1.0
- hast-util-to-jsx-runtime: 2.3.6(supports-color@7.2.0)
+ hast-util-to-jsx-runtime: 2.3.6
html-url-attributes: 3.0.1
mdast-util-to-hast: 13.2.1
react: 19.2.3
- remark-parse: 11.0.0(supports-color@7.2.0)
+ remark-parse: 11.0.0
remark-rehype: 11.1.2
unified: 11.0.5
unist-util-visit: 5.1.0
@@ -9193,21 +9222,21 @@ snapshots:
mdast-util-newline-to-break: 2.0.0
unified: 11.0.5
- remark-gfm@4.0.1(supports-color@7.2.0):
+ remark-gfm@4.0.1:
dependencies:
'@types/mdast': 4.0.4
- mdast-util-gfm: 3.1.0(supports-color@7.2.0)
+ mdast-util-gfm: 3.1.0
micromark-extension-gfm: 3.0.0
- remark-parse: 11.0.0(supports-color@7.2.0)
+ remark-parse: 11.0.0
remark-stringify: 11.0.0
unified: 11.0.5
transitivePeerDependencies:
- supports-color
- remark-parse@11.0.0(supports-color@7.2.0):
+ remark-parse@11.0.0:
dependencies:
'@types/mdast': 4.0.4
- mdast-util-from-markdown: 2.0.3(supports-color@7.2.0)
+ mdast-util-from-markdown: 2.0.3
micromark-util-types: 2.0.2
unified: 11.0.5
transitivePeerDependencies:
@@ -9268,9 +9297,9 @@ snapshots:
points-on-curve: 0.2.0
points-on-path: 0.2.1
- router@2.2.0(supports-color@7.2.0):
+ router@2.2.0:
dependencies:
- debug: 4.4.3(supports-color@7.2.0)
+ debug: 4.4.3
depd: 2.0.0
is-promise: 4.0.0
parseurl: 1.3.3
@@ -9324,9 +9353,9 @@ snapshots:
semver@7.8.5:
optional: true
- send@1.2.1(supports-color@7.2.0):
+ send@1.2.1:
dependencies:
- debug: 4.4.3(supports-color@7.2.0)
+ debug: 4.4.3
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
@@ -9340,12 +9369,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
- serve-static@2.2.1(supports-color@7.2.0):
+ serve-static@2.2.1:
dependencies:
encodeurl: 2.0.0
escape-html: 1.0.3
parseurl: 1.3.3
- send: 1.2.1(supports-color@7.2.0)
+ send: 1.2.1
transitivePeerDependencies:
- supports-color
@@ -9373,14 +9402,14 @@ snapshots:
setprototypeof@1.2.0: {}
- shadcn@4.18.0(supports-color@7.2.0)(typescript@6.0.3):
+ shadcn@4.18.0(typescript@6.0.3):
dependencies:
- '@babel/core': 7.29.6(supports-color@7.2.0)
+ '@babel/core': 7.29.6
'@babel/parser': 7.29.0
- '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.6(supports-color@7.2.0))(supports-color@7.2.0)
- '@babel/preset-typescript': 7.28.5(@babel/core@7.29.6(supports-color@7.2.0))(supports-color@7.2.0)
+ '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.6)
+ '@babel/preset-typescript': 7.28.5(@babel/core@7.29.6)
'@dotenvx/dotenvx': 1.55.0
- '@modelcontextprotocol/sdk': 1.27.1(supports-color@7.2.0)(zod@3.25.76)
+ '@modelcontextprotocol/sdk': 1.27.1(zod@3.25.76)
'@types/validate-npm-package-name': 4.0.2
browserslist: 4.28.7
commander: 14.0.3
@@ -9414,36 +9443,37 @@ snapshots:
- supports-color
- typescript
- sharp@0.34.5:
+ sharp@0.35.0:
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
semver: 7.8.5
optionalDependencies:
- '@img/sharp-darwin-arm64': 0.34.5
- '@img/sharp-darwin-x64': 0.34.5
- '@img/sharp-libvips-darwin-arm64': 1.2.4
- '@img/sharp-libvips-darwin-x64': 1.2.4
- '@img/sharp-libvips-linux-arm': 1.2.4
- '@img/sharp-libvips-linux-arm64': 1.2.4
- '@img/sharp-libvips-linux-ppc64': 1.2.4
- '@img/sharp-libvips-linux-riscv64': 1.2.4
- '@img/sharp-libvips-linux-s390x': 1.2.4
- '@img/sharp-libvips-linux-x64': 1.2.4
- '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
- '@img/sharp-libvips-linuxmusl-x64': 1.2.4
- '@img/sharp-linux-arm': 0.34.5
- '@img/sharp-linux-arm64': 0.34.5
- '@img/sharp-linux-ppc64': 0.34.5
- '@img/sharp-linux-riscv64': 0.34.5
- '@img/sharp-linux-s390x': 0.34.5
- '@img/sharp-linux-x64': 0.34.5
- '@img/sharp-linuxmusl-arm64': 0.34.5
- '@img/sharp-linuxmusl-x64': 0.34.5
- '@img/sharp-wasm32': 0.34.5
- '@img/sharp-win32-arm64': 0.34.5
- '@img/sharp-win32-ia32': 0.34.5
- '@img/sharp-win32-x64': 0.34.5
+ '@img/sharp-darwin-arm64': 0.35.0
+ '@img/sharp-darwin-x64': 0.35.0
+ '@img/sharp-freebsd-wasm32': 0.35.0
+ '@img/sharp-libvips-darwin-arm64': 1.3.0
+ '@img/sharp-libvips-darwin-x64': 1.3.0
+ '@img/sharp-libvips-linux-arm': 1.3.0
+ '@img/sharp-libvips-linux-arm64': 1.3.0
+ '@img/sharp-libvips-linux-ppc64': 1.3.0
+ '@img/sharp-libvips-linux-riscv64': 1.3.0
+ '@img/sharp-libvips-linux-s390x': 1.3.0
+ '@img/sharp-libvips-linux-x64': 1.3.0
+ '@img/sharp-libvips-linuxmusl-arm64': 1.3.0
+ '@img/sharp-libvips-linuxmusl-x64': 1.3.0
+ '@img/sharp-linux-arm': 0.35.0
+ '@img/sharp-linux-arm64': 0.35.0
+ '@img/sharp-linux-ppc64': 0.35.0
+ '@img/sharp-linux-riscv64': 0.35.0
+ '@img/sharp-linux-s390x': 0.35.0
+ '@img/sharp-linux-x64': 0.35.0
+ '@img/sharp-linuxmusl-arm64': 0.35.0
+ '@img/sharp-linuxmusl-x64': 0.35.0
+ '@img/sharp-webcontainers-wasm32': 0.35.0
+ '@img/sharp-win32-arm64': 0.35.0
+ '@img/sharp-win32-ia32': 0.35.0
+ '@img/sharp-win32-x64': 0.35.0
optional: true
shebang-command@2.0.0:
@@ -9624,12 +9654,12 @@ snapshots:
dependencies:
inline-style-parser: 0.2.7
- styled-jsx@5.1.6(@babel/core@7.29.6(supports-color@7.2.0))(react@19.2.3):
+ styled-jsx@5.1.6(@babel/core@7.29.6)(react@19.2.3):
dependencies:
client-only: 0.0.1
react: 19.2.3
optionalDependencies:
- '@babel/core': 7.29.6(supports-color@7.2.0)
+ '@babel/core': 7.29.6
stylis@4.4.0: {}
@@ -9754,13 +9784,13 @@ snapshots:
possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10
- typescript-eslint@8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3):
+ typescript-eslint@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3):
dependencies:
- '@typescript-eslint/eslint-plugin': 8.57.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
- '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
- '@typescript-eslint/typescript-estree': 8.57.0(supports-color@7.2.0)(typescript@6.0.3)
- '@typescript-eslint/utils': 8.57.0(eslint@9.39.4(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
- eslint: 9.39.4(jiti@2.6.1)(supports-color@7.2.0)
+ '@typescript-eslint/eslint-plugin': 8.57.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
+ '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
+ '@typescript-eslint/typescript-estree': 8.57.0(typescript@6.0.3)
+ '@typescript-eslint/utils': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
+ eslint: 9.39.4(jiti@2.6.1)
typescript: 6.0.3
transitivePeerDependencies:
- supports-color