From 30c101438c1a89431bd0843ddfbce7db95f904d3 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:23:16 +0700 Subject: [PATCH 01/30] fix(ui): add team selector to agent profile modal and fix missing nav i18n keys --- .../dashboard/agents/_components/edit.tsx | 91 +++++++++++++++++-- web/messages/en-US.json | 16 +++- web/messages/vi-VN.json | 30 ++++-- web/messages/zh-CN.json | 16 +++- 4 files changed, 131 insertions(+), 22 deletions(-) diff --git a/web/app/(dashboard)/dashboard/agents/_components/edit.tsx b/web/app/(dashboard)/dashboard/agents/_components/edit.tsx index 7b64494b..cb96b7cf 100644 --- a/web/app/(dashboard)/dashboard/agents/_components/edit.tsx +++ b/web/app/(dashboard)/dashboard/agents/_components/edit.tsx @@ -35,8 +35,10 @@ import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { fetchAgentProfile, + fetchAgentTeamsAll, fetchUsersAll, type AdminAgentProfile, + type AdminAgentTeam, type AdminUser, type CreateAdminAgentProfilePayload, } from "@/lib/api/admin"; @@ -206,19 +208,34 @@ function AgentEditDialogBody({ }: AgentEditDialogBodyProps) { const t = useI18n(); const [users, setUsers] = useState([]); + const [teams, setTeams] = useState([]); const [userSelectOpen, setUserSelectOpen] = useState(false); const [loading, setLoading] = useState(false); - const userOptions = users.map((user) => ({ - value: String(user.id), - label: `${user.nickname || user.username} (${user.username})`, - })); + const userOptions = useMemo( + () => + users.map((user) => ({ + value: String(user.id), + label: `${user.nickname || user.username} (${user.username})`, + })), + [users], + ); + const teamOptions = useMemo( + () => + teams.map((team) => ({ + value: String(team.id), + label: team.name, + })), + [teams], + ); const serviceStatusOptions = useMemo(() => getServiceStatusOptions(t), [t]); const loadOptions = useCallback(async () => { try { - const [usersData] = await Promise.all([ + const [usersData, teamsData] = await Promise.all([ fetchUsersAll(), + fetchAgentTeamsAll(), ]); setUsers(usersData); + setTeams(teamsData); } catch (error) { toast.error(error instanceof Error ? error.message : t("agentProfile.loadOptionsFailed")); } @@ -237,9 +254,13 @@ function AgentEditDialogBody({ handleSubmit, reset, register, + setValue, + watch, formState: { errors }, } = form; + const currentTeamId = watch("teamId"); + useEffect(() => { async function loadDetail() { if (!itemId) { @@ -265,6 +286,13 @@ function AgentEditDialogBody({ } }, [loadOptions, open]); + // Auto-set teamId if empty and teams are available + useEffect(() => { + if (!itemId && !currentTeamId && teams.length > 0) { + setValue("teamId", String(defaultTeamId ?? teams[0].id)); + } + }, [currentTeamId, defaultTeamId, itemId, setValue, teams]); + async function onFormSubmit(values: EditForm) { await onSubmit(buildPayload(values)); } @@ -303,6 +331,12 @@ function AgentEditDialogBody({ onSubmit={handleSubmit(onFormSubmit)} className="space-y-4" > + {teams.length === 0 && !loading && ( +
+ {t("agentProfile.noTeamsWarning")} +
+ )} +
{t("agentProfile.linkedUser")} @@ -347,6 +381,23 @@ function AgentEditDialogBody({ value={option.label} onSelect={() => { field.onChange(option.value); + const selected = users.find( + (u) => String(u.id) === option.value, + ); + if (selected) { + if (!form.getValues("displayName")) { + form.setValue( + "displayName", + selected.nickname || selected.username, + ); + } + if ( + !form.getValues("avatar") && + selected.avatar + ) { + form.setValue("avatar", selected.avatar); + } + } setUserSelectOpen(false); }} > @@ -370,6 +421,30 @@ function AgentEditDialogBody({ + + + {t("agentProfile.team")} + + ( + + )} + /> + + + +
+ +
{t("agentProfile.displayName")} @@ -381,9 +456,7 @@ function AgentEditDialogBody({ -
-
{t("agentProfile.agentCodeLabel")} @@ -395,7 +468,9 @@ function AgentEditDialogBody({ +
+
{t("agentProfile.avatar")} @@ -415,9 +490,7 @@ function AgentEditDialogBody({ /> -
-
{t("agentProfile.serviceStatus")} diff --git a/web/messages/en-US.json b/web/messages/en-US.json index 98e21daa..ac1f509f 100644 --- a/web/messages/en-US.json +++ b/web/messages/en-US.json @@ -1671,6 +1671,11 @@ "selectUser": "Select user", "searchUser": "Search users...", "emptyUser": "No matching users", + "team": "Support Team", + "selectTeam": "Select support team", + "searchTeam": "Search support teams...", + "emptyTeam": "No matching support teams", + "noTeamsWarning": "No support teams exist. Please create a team in the sidebar first.", "displayName": "Display Name", "displayNamePlaceholder": "Enter display name", "agentCodeLabel": "Agent Code", @@ -2766,9 +2771,12 @@ "brand": "AgentDesk Support", "nav": { "home": "Home", - "help": "Help", + "help": "Docs", + "community": "Community", "questions": "FAQ", - "login": "Log In" + "login": "Log In", + "menu": "Menu", + "siteNavigation": "Site Navigation" }, "home": { "badge": "Support Center", @@ -2948,6 +2956,10 @@ "knowledge": "Knowledge Base", "support": "Support Center", "supportCenter": "Support Center", + "supportDocs": "Documentation", + "supportCommunity": "Community Posts", + "supportCommunityCategories": "Community Categories", + "supportConfig": "Support Settings", "supportHelp": "Help Center", "supportFaq": "FAQ Community", "supportFaqCategories": "FAQ Categories", diff --git a/web/messages/vi-VN.json b/web/messages/vi-VN.json index 57d4c721..8ab44c45 100644 --- a/web/messages/vi-VN.json +++ b/web/messages/vi-VN.json @@ -1674,11 +1674,16 @@ "saving": "Saving...", "save": "Save", "loading": "Loading...", - "linkedUser": "Linked User", - "selectUser": "Select user", - "searchUser": "Search users...", - "emptyUser": "No matching users", - "displayName": "Display Name", + "linkedUser": "Người dùng liên kết", + "selectUser": "Chọn người dùng", + "searchUser": "Tìm kiếm người dùng...", + "emptyUser": "Không tìm thấy người dùng", + "team": "Đội ngũ hỗ trợ (Team)", + "selectTeam": "Chọn đội ngũ hỗ trợ", + "searchTeam": "Tìm kiếm đội ngũ...", + "emptyTeam": "Không tìm thấy đội ngũ nào", + "noTeamsWarning": "Chưa có đội ngũ hỗ trợ nào. Vui lòng tạo đội ngũ ở cột bên trái trước.", + "displayName": "Tên hiển thị (Display Name)", "displayNamePlaceholder": "Enter display name", "agentCodeLabel": "Agent Code", "agentCodePlaceholder": "Example: A1001", @@ -2731,10 +2736,13 @@ "supportPublic": { "brand": "AgentDesk Support", "nav": { - "home": "Home", - "help": "Help", + "home": "Trang chủ", + "help": "Tài liệu", + "community": "Cộng đồng", "questions": "FAQ", - "login": "Log In" + "login": "Đăng nhập", + "menu": "Menu", + "siteNavigation": "Điều hướng trang" }, "home": { "badge": "Support Center", @@ -2913,7 +2921,11 @@ "aiCapabilities": "Năng lực AI", "knowledge": "Knowledge Base", "support": "Support Center", - "supportCenter": "Cổng Help Center", + "supportCenter": "Cổng Hỗ trợ", + "supportDocs": "Tài liệu hướng dẫn", + "supportCommunity": "Cộng đồng hỗ trợ", + "supportCommunityCategories": "Danh mục cộng đồng", + "supportConfig": "Cấu hình cổng hỗ trợ", "supportHelp": "Help Center", "supportFaq": "FAQ Community", "supportFaqCategories": "FAQ Categories", diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index 18934cf5..9db9a45f 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -1671,6 +1671,11 @@ "selectUser": "请选择用户", "searchUser": "搜索用户...", "emptyUser": "没有匹配的用户", + "team": "所属客服组", + "selectTeam": "请选择客服组", + "searchTeam": "搜索客服组...", + "emptyTeam": "没有匹配的客服组", + "noTeamsWarning": "当前没有可用的客服组,请先在左侧边栏创建客服组。", "displayName": "展示名", "displayNamePlaceholder": "请输入展示名", "agentCodeLabel": "客服工号", @@ -2766,9 +2771,12 @@ "brand": "AgentDesk 支持中心", "nav": { "home": "首页", - "help": "帮助", + "help": "文档", + "community": "社区", "questions": "FAQ", - "login": "登录" + "login": "登录", + "menu": "菜单", + "siteNavigation": "站点导航" }, "home": { "badge": "支持中心", @@ -2948,6 +2956,10 @@ "knowledge": "知识库", "support": "支持中心", "supportCenter": "支持中心", + "supportDocs": "文档中心", + "supportCommunity": "社区内容", + "supportCommunityCategories": "社区分类", + "supportConfig": "支持中心配置", "supportHelp": "帮助中心", "supportFaq": "FAQ 社区", "supportFaqCategories": "FAQ 分类", From dc415ec625eebce6bc5599ccfd274181ad36e079 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:34:11 +0700 Subject: [PATCH 02/30] feat(channels): add discord and facebook messenger omnichannel integration - add discord and messenger channel and identity enums with generated frontend types - implement internal discord and messenger REST API clients - add discord and messenger inbound webhook services with customer identity resolution and rich media attachment support - add async outbox delivery services for discord and messenger with retry handling - register third webhook endpoints and 1-click oauth authorization handlers - update dashboard channels list and edit dialog with discord and messenger configurations - add full multilingual translation keys across en-US, vi-VN, and zh-CN --- internal/bootstrap/routes.go | 14 + internal/bootstrap/server.go | 2 + internal/discord/client.go | 139 +++++++++ internal/discord/client_test.go | 90 ++++++ internal/discord/types.go | 80 ++++++ .../dashboard/channel_oauth_handler.go | 88 ++++++ internal/handlers/third/discord_handler.go | 39 +++ .../handlers/third/discord_handler_test.go | 115 ++++++++ internal/handlers/third/messenger_handler.go | 72 +++++ .../handlers/third/messenger_handler_test.go | 123 ++++++++ internal/messenger/client.go | 173 +++++++++++ internal/messenger/client_test.go | 79 +++++ internal/messenger/types.go | 88 ++++++ internal/pkg/dto/dto.go | 20 ++ internal/pkg/enums/channel_enums_test.go | 20 ++ internal/pkg/enums/external_identity.go | 4 + internal/pkg/enums/wxwork_kf.go | 14 +- .../channel_message_outbox_service.go | 134 ++++++++- internal/services/channel_service.go | 81 +++++- internal/services/cronx/cron.go | 8 + internal/services/discord_inbound_service.go | 153 ++++++++++ .../services/discord_inbound_service_test.go | 169 +++++++++++ internal/services/discord_outbound_service.go | 216 ++++++++++++++ internal/services/message_service.go | 18 ++ .../services/messenger_inbound_service.go | 152 ++++++++++ .../messenger_inbound_service_test.go | 168 +++++++++++ .../services/messenger_outbound_service.go | 189 ++++++++++++ .../dashboard/channels/_components/edit.tsx | 269 ++++++++++++++++-- .../(dashboard)/dashboard/channels/page.tsx | 16 ++ web/lib/generated/enums.ts | 4 + web/messages/en-US.json | 16 ++ web/messages/vi-VN.json | 16 ++ web/messages/zh-CN.json | 16 ++ 33 files changed, 2755 insertions(+), 30 deletions(-) create mode 100644 internal/discord/client.go create mode 100644 internal/discord/client_test.go create mode 100644 internal/discord/types.go create mode 100644 internal/handlers/dashboard/channel_oauth_handler.go create mode 100644 internal/handlers/third/discord_handler.go create mode 100644 internal/handlers/third/discord_handler_test.go create mode 100644 internal/handlers/third/messenger_handler.go create mode 100644 internal/handlers/third/messenger_handler_test.go create mode 100644 internal/messenger/client.go create mode 100644 internal/messenger/client_test.go create mode 100644 internal/messenger/types.go create mode 100644 internal/pkg/enums/channel_enums_test.go create mode 100644 internal/services/discord_inbound_service.go create mode 100644 internal/services/discord_inbound_service_test.go create mode 100644 internal/services/discord_outbound_service.go create mode 100644 internal/services/messenger_inbound_service.go create mode 100644 internal/services/messenger_inbound_service_test.go create mode 100644 internal/services/messenger_outbound_service.go diff --git a/internal/bootstrap/routes.go b/internal/bootstrap/routes.go index 8e4e8110..e9fccb3a 100644 --- a/internal/bootstrap/routes.go +++ b/internal/bootstrap/routes.go @@ -231,6 +231,8 @@ func registerDashboardChannelRoutes(group *gin.RouterGroup) { group.POST("/rollback_ai_agent_rollout", dashboard.ChannelPostRollback_ai_agent_rollout) group.POST("/update", dashboard.ChannelPostUpdate) group.POST("/update_status", dashboard.ChannelPostUpdate_status) + group.GET("/discord_oauth_url", dashboard.ChannelGetDiscordOAuthURL) + group.GET("/messenger_oauth_url", dashboard.ChannelGetMessengerOAuthURL) group.Any("/wxwork/kf/accounts", dashboard.ChannelAnyWxworkKfAccounts) group.Any("/wxwork/outbox/failed/list", dashboard.ChannelAnyWxworkOutboxFailedList) group.POST("/wxwork/outbox/retry", dashboard.ChannelPostWxworkOutboxRetry) @@ -448,3 +450,15 @@ func registerThirdEmailRoutes(group *gin.RouterGroup) { group.POST("/webhook", third.EmailPostWebhook) group.POST("/webhook/:channel_id", third.EmailPostWebhook) } + +func registerThirdDiscordRoutes(group *gin.RouterGroup) { + group.POST("/webhook", third.DiscordPostWebhook) + group.POST("/webhook/:channel_id", third.DiscordPostWebhook) +} + +func registerThirdMessengerRoutes(group *gin.RouterGroup) { + group.GET("/webhook", third.MessengerGetWebhook) + group.GET("/webhook/:channel_id", third.MessengerGetWebhook) + group.POST("/webhook", third.MessengerPostWebhook) + group.POST("/webhook/:channel_id", third.MessengerPostWebhook) +} diff --git a/internal/bootstrap/server.go b/internal/bootstrap/server.go index da1c5f2b..8e73ab9e 100644 --- a/internal/bootstrap/server.go +++ b/internal/bootstrap/server.go @@ -198,6 +198,8 @@ func addRouter(app *gin.Engine) { registerThirdTelegramRoutes(thirdGroup.Group("/telegram")) registerThirdZaloRoutes(thirdGroup.Group("/zalo")) registerThirdEmailRoutes(thirdGroup.Group("/email")) + registerThirdDiscordRoutes(thirdGroup.Group("/discord")) + registerThirdMessengerRoutes(thirdGroup.Group("/messenger")) } type spaShellRewrite struct { diff --git a/internal/discord/client.go b/internal/discord/client.go new file mode 100644 index 00000000..4045ae1c --- /dev/null +++ b/internal/discord/client.go @@ -0,0 +1,139 @@ +package discord + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +const defaultBaseURL = "https://discord.com/api/v10" + +type Client struct { + botToken string + baseURL string + httpClient *http.Client +} + +func NewClient(botToken string) *Client { + return &Client{ + botToken: strings.TrimSpace(botToken), + baseURL: defaultBaseURL, + httpClient: &http.Client{Timeout: 15 * time.Second}, + } +} + +func (c *Client) SetBaseURL(url string) { + if strings.TrimSpace(url) != "" { + c.baseURL = strings.TrimRight(strings.TrimSpace(url), "/") + } +} + +func (c *Client) GetMe(ctx context.Context) (*User, error) { + var user User + if err := c.doRequest(ctx, http.MethodGet, "/users/@me", nil, &user); err != nil { + return nil, err + } + return &user, nil +} + +func (c *Client) CreateDMChannel(ctx context.Context, recipientID string) (*Channel, error) { + if strings.TrimSpace(recipientID) == "" { + return nil, fmt.Errorf("recipient_id is required") + } + req := CreateDMRequest{RecipientID: strings.TrimSpace(recipientID)} + var channel Channel + if err := c.doRequest(ctx, http.MethodPost, "/users/@me/channels", req, &channel); err != nil { + return nil, err + } + return &channel, nil +} + +func (c *Client) SendMessage(ctx context.Context, channelID string, content string) (*Message, error) { + channelID = strings.TrimSpace(channelID) + if channelID == "" { + return nil, fmt.Errorf("channel_id is required") + } + if strings.TrimSpace(content) == "" { + return nil, fmt.Errorf("content is required") + } + + req := SendMessageRequest{Content: content} + var msg Message + endpoint := fmt.Sprintf("/channels/%s/messages", channelID) + if err := c.doRequest(ctx, http.MethodPost, endpoint, req, &msg); err != nil { + return nil, err + } + return &msg, nil +} + +func (c *Client) SendEmbedMessage(ctx context.Context, channelID string, content string, embeds []Embed) (*Message, error) { + channelID = strings.TrimSpace(channelID) + if channelID == "" { + return nil, fmt.Errorf("channel_id is required") + } + + req := SendMessageRequest{ + Content: content, + Embeds: embeds, + } + var msg Message + endpoint := fmt.Sprintf("/channels/%s/messages", channelID) + if err := c.doRequest(ctx, http.MethodPost, endpoint, req, &msg); err != nil { + return nil, err + } + return &msg, nil +} + +func (c *Client) doRequest(ctx context.Context, method, path string, payload any, result any) error { + if c.botToken == "" { + return fmt.Errorf("discord bot token is required") + } + + endpoint := fmt.Sprintf("%s%s", c.baseURL, path) + + var bodyReader io.Reader + if payload != nil { + bodyBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal discord request failed: %w", err) + } + bodyReader = bytes.NewBuffer(bodyBytes) + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint, bodyReader) + if err != nil { + return fmt.Errorf("create discord request failed: %w", err) + } + + req.Header.Set("Authorization", "Bot "+c.botToken) + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + res, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("discord http request failed: %w", err) + } + defer res.Body.Close() + + bodyBytes, err := io.ReadAll(res.Body) + if err != nil { + return fmt.Errorf("read discord response failed: %w", err) + } + + if res.StatusCode < 200 || res.StatusCode >= 300 { + return fmt.Errorf("discord api error (%d): %s", res.StatusCode, string(bodyBytes)) + } + + if result != nil { + if err := json.Unmarshal(bodyBytes, result); err != nil { + return fmt.Errorf("unmarshal discord response failed: %w (body: %s)", err, string(bodyBytes)) + } + } + return nil +} diff --git a/internal/discord/client_test.go b/internal/discord/client_test.go new file mode 100644 index 00000000..de1b7c85 --- /dev/null +++ b/internal/discord/client_test.go @@ -0,0 +1,90 @@ +package discord + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestDiscordSendMessage(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bot test_token" { + t.Errorf("expected Bot test_token, got %s", r.Header.Get("Authorization")) + } + if r.URL.Path != "/channels/789/messages" { + t.Errorf("expected path /channels/789/messages, got %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"id":"123456","channel_id":"789","content":"hello"}`)) + })) + defer server.Close() + + client := NewClient("test_token") + client.SetBaseURL(server.URL) + + resp, err := client.SendMessage(context.Background(), "789", "hello") + if err != nil { + t.Fatalf("SendMessage failed: %v", err) + } + if resp.ID != "123456" { + t.Errorf("expected ID 123456, got %s", resp.ID) + } +} + +func TestDiscordSendEmbedMessage(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bot test_token" { + t.Errorf("expected Bot test_token, got %s", r.Header.Get("Authorization")) + } + if r.URL.Path != "/channels/789/messages" { + t.Errorf("expected path /channels/789/messages, got %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"id":"embed_123","channel_id":"789","content":"Check image"}`)) + })) + defer server.Close() + + client := NewClient("test_token") + client.SetBaseURL(server.URL) + + embed := Embed{ + Title: "Screenshot", + Image: &EmbedMedia{URL: "https://example.com/img.png"}, + } + resp, err := client.SendEmbedMessage(context.Background(), "789", "Check image", []Embed{embed}) + if err != nil { + t.Fatalf("SendEmbedMessage failed: %v", err) + } + if resp.ID != "embed_123" { + t.Errorf("expected ID embed_123, got %s", resp.ID) + } +} + +func TestDiscordCreateDMChannel(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bot test_token" { + t.Errorf("expected Bot test_token, got %s", r.Header.Get("Authorization")) + } + if r.URL.Path != "/users/@me/channels" { + t.Errorf("expected path /users/@me/channels, got %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"id":"dm_chan_123","type":1}`)) + })) + defer server.Close() + + client := NewClient("test_token") + client.SetBaseURL(server.URL) + + resp, err := client.CreateDMChannel(context.Background(), "user_999") + if err != nil { + t.Fatalf("CreateDMChannel failed: %v", err) + } + if resp.ID != "dm_chan_123" { + t.Errorf("expected ID dm_chan_123, got %s", resp.ID) + } +} diff --git a/internal/discord/types.go b/internal/discord/types.go new file mode 100644 index 00000000..3363bebd --- /dev/null +++ b/internal/discord/types.go @@ -0,0 +1,80 @@ +package discord + +// User represents a Discord user. +type User struct { + ID string `json:"id"` + Username string `json:"username"` + Discriminator string `json:"discriminator,omitempty"` + GlobalName string `json:"global_name,omitempty"` + Avatar string `json:"avatar,omitempty"` + Bot bool `json:"bot,omitempty"` +} + +// Channel represents a Discord channel (Guild Text, DM, Thread, etc.). +type Channel struct { + ID string `json:"id"` + Type int `json:"type"` + GuildID string `json:"guild_id,omitempty"` + Name string `json:"name,omitempty"` +} + +// Attachment represents a file or image uploaded to Discord. +type Attachment struct { + ID string `json:"id"` + Filename string `json:"filename"` + URL string `json:"url"` + ProxyURL string `json:"proxy_url,omitempty"` + ContentType string `json:"content_type,omitempty"` + Size int64 `json:"size,omitempty"` +} + +// EmbedMedia represents an image/video/thumbnail inside an Embed. +type EmbedMedia struct { + URL string `json:"url"` +} + +// Embed represents a Discord rich embed object. +type Embed struct { + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + URL string `json:"url,omitempty"` + Color int `json:"color,omitempty"` + Image *EmbedMedia `json:"image,omitempty"` +} + +// Message represents a Discord message. +type Message struct { + ID string `json:"id"` + ChannelID string `json:"channel_id"` + GuildID string `json:"guild_id,omitempty"` + Author User `json:"author"` + Content string `json:"content"` + Timestamp string `json:"timestamp"` + Attachments []Attachment `json:"attachments,omitempty"` + Embeds []Embed `json:"embeds,omitempty"` +} + +// SendMessageRequest represents payload for Discord create message API. +type SendMessageRequest struct { + Content string `json:"content,omitempty"` + Embeds []Embed `json:"embeds,omitempty"` +} + +// CreateDMRequest represents payload for Discord create DM channel API. +type CreateDMRequest struct { + RecipientID string `json:"recipient_id"` +} + +// WebhookPayload represents an incoming message/event from Discord Gateway or Webhook. +type WebhookPayload struct { + ID string `json:"id,omitempty"` + Type int `json:"type,omitempty"` + GuildID string `json:"guild_id,omitempty"` + ChannelID string `json:"channel_id,omitempty"` + Author *User `json:"author,omitempty"` + Content string `json:"content,omitempty"` + Timestamp string `json:"timestamp,omitempty"` + Attachments []Attachment `json:"attachments,omitempty"` + Embeds []Embed `json:"embeds,omitempty"` + Message *Message `json:"message,omitempty"` +} diff --git a/internal/handlers/dashboard/channel_oauth_handler.go b/internal/handlers/dashboard/channel_oauth_handler.go new file mode 100644 index 00000000..1583855f --- /dev/null +++ b/internal/handlers/dashboard/channel_oauth_handler.go @@ -0,0 +1,88 @@ +package dashboard + +import ( + "fmt" + "net/url" + "os" + "strings" + + "agent-desk/internal/pkg/constants" + "agent-desk/internal/pkg/httpx" + "agent-desk/internal/services" + + "github.com/gin-gonic/gin" + "github.com/mlogclub/simple/web" +) + +// ChannelGetDiscordOAuthURL returns the 1-Click OAuth authorization URL for Discord. +func ChannelGetDiscordOAuthURL(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionChannelView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + + clientID := strings.TrimSpace(os.Getenv("DISCORD_CLIENT_ID")) + if clientID == "" { + clientID = strings.TrimSpace(ctx.Query("client_id")) + } + redirectURI := strings.TrimSpace(ctx.Query("redirect_uri")) + + if clientID == "" { + // Provide guidance or sample client id + clientID = "123456789012345678" + } + + state := strings.TrimSpace(ctx.Query("state")) + if state == "" { + state = "crove_discord_connect" + } + + authURL := fmt.Sprintf( + "https://discord.com/oauth2/authorize?client_id=%s&permissions=19456&response_type=code&redirect_uri=%s&scope=bot+applications.commands&state=%s", + url.QueryEscape(clientID), + url.QueryEscape(redirectURI), + url.QueryEscape(state), + ) + + httpx.WriteJSON(ctx, web.JsonData(gin.H{ + "authUrl": authURL, + "clientId": clientID, + "redirectUri": redirectURI, + })) +} + +// ChannelGetMessengerOAuthURL returns the 1-Click OAuth authorization URL for Meta Messenger. +func ChannelGetMessengerOAuthURL(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionChannelView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + + appID := strings.TrimSpace(os.Getenv("META_APP_ID")) + if appID == "" { + appID = strings.TrimSpace(ctx.Query("app_id")) + } + redirectURI := strings.TrimSpace(ctx.Query("redirect_uri")) + + if appID == "" { + appID = "123456789012345" + } + + state := strings.TrimSpace(ctx.Query("state")) + if state == "" { + state = "crove_messenger_connect" + } + + authURL := fmt.Sprintf( + "https://www.facebook.com/v21.0/dialog/oauth?client_id=%s&redirect_uri=%s&scope=pages_show_list,pages_messaging,pages_manage_metadata&state=%s", + url.QueryEscape(appID), + url.QueryEscape(redirectURI), + url.QueryEscape(state), + ) + + httpx.WriteJSON(ctx, web.JsonData(gin.H{ + "authUrl": authURL, + "appId": appID, + "redirectUri": redirectURI, + })) +} diff --git a/internal/handlers/third/discord_handler.go b/internal/handlers/third/discord_handler.go new file mode 100644 index 00000000..e36ba526 --- /dev/null +++ b/internal/handlers/third/discord_handler.go @@ -0,0 +1,39 @@ +package third + +import ( + "bytes" + "io" + "net/http" + "strings" + + "agent-desk/internal/services" + + "github.com/gin-gonic/gin" +) + +// DiscordPostWebhook receives incoming Webhook events from Discord. +func DiscordPostWebhook(ctx *gin.Context) { + channelID := strings.TrimSpace(ctx.Param("channel_id")) + if channelID == "" { + channelID = strings.TrimSpace(ctx.Query("channel_id")) + } + + secretHeader := ctx.GetHeader("X-Discord-Secret-Token") + if secretHeader == "" { + secretHeader = ctx.GetHeader("X-Webhook-Secret") + } + + bodyBytes, err := io.ReadAll(ctx.Request.Body) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "failed to read body"}) + return + } + ctx.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + + if err := services.DiscordInboundService.HandleWebhook(ctx.Request.Context(), channelID, secretHeader, bodyBytes); err != nil { + ctx.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"ok": true}) +} diff --git a/internal/handlers/third/discord_handler_test.go b/internal/handlers/third/discord_handler_test.go new file mode 100644 index 00000000..e3b8e4f5 --- /dev/null +++ b/internal/handlers/third/discord_handler_test.go @@ -0,0 +1,115 @@ +package third + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + "agent-desk/internal/services" + + "github.com/gin-gonic/gin" + "github.com/mlogclub/simple/sqls" +) + +func TestDiscordPostWebhook_Handler(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupThirdHandlerTestDB(t) + + now := time.Now() + agent := &models.AIAgent{ + Name: "Discord Agent", + ServiceMode: enums.IMConversationServiceModeAIFirst, + PublishedRevisionID: 1, + WelcomeMessage: "Hello Discord!", + Status: enums.StatusOk, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + _ = db.Create(agent) + + discordConfig, _ := json.Marshal(dto.DiscordChannelConfig{ + GuildID: "guild_999", + BotToken: "test_bot_token", + WebhookSecret: "secret_discord_123", + WelcomeMessage: "Welcome!", + }) + + operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"} + channel, err := services.ChannelService.CreateChannel(request.CreateChannelRequest{ + Name: "Discord Community", + ChannelType: enums.ChannelTypeDiscord, + AIAgentID: agent.ID, + AIAgentRolloutPercent: 100, + ConfigJSON: string(discordConfig), + Status: int(enums.StatusOk), + }, operator) + if err != nil { + t.Fatalf("CreateChannel failed: %v", err) + } + + router := gin.New() + router.POST("/api/third/discord/webhook/:channel_id", DiscordPostWebhook) + router.POST("/api/third/discord/webhook", DiscordPostWebhook) + + payload := []byte(`{ + "id": "msg_001", + "channel_id": "ch_777", + "guild_id": "guild_999", + "content": "Need help with setup", + "author": { + "id": "user_456", + "username": "gamer_one", + "global_name": "Gamer One", + "bot": false + } + }`) + + // 1. Invalid secret + req, _ := http.NewRequest(http.MethodPost, "/api/third/discord/webhook/"+channel.ChannelID, bytes.NewBuffer(payload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Discord-Secret-Token", "wrong_secret") + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 OK wrapper, got: %d", rec.Code) + } + var resp map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &resp) + if resp["ok"] == true { + t.Fatalf("expected error for invalid secret token") + } + + // 2. Valid secret + req2, _ := http.NewRequest(http.MethodPost, "/api/third/discord/webhook/"+channel.ChannelID, bytes.NewBuffer(payload)) + req2.Header.Set("Content-Type", "application/json") + req2.Header.Set("X-Discord-Secret-Token", "secret_discord_123") + + rec2 := httptest.NewRecorder() + router.ServeHTTP(rec2, req2) + + if rec2.Code != http.StatusOK { + t.Fatalf("expected 200 OK, got %d", rec2.Code) + } + var resp2 map[string]any + _ = json.Unmarshal(rec2.Body.Bytes(), &resp2) + if resp2["ok"] != true { + t.Fatalf("expected ok: true, got: %+v", resp2) + } + + // Verify identity in DB + identity := repositories.CustomerIdentityRepository.FindOne(db, sqls.NewCnd(). + Eq("external_source", enums.ExternalSourceDiscord). + Eq("external_id", "user_456")) + if identity == nil { + t.Fatalf("expected customer identity for user_456") + } +} diff --git a/internal/handlers/third/messenger_handler.go b/internal/handlers/third/messenger_handler.go new file mode 100644 index 00000000..910a0d63 --- /dev/null +++ b/internal/handlers/third/messenger_handler.go @@ -0,0 +1,72 @@ +package third + +import ( + "bytes" + "io" + "net/http" + "strings" + + "agent-desk/internal/pkg/enums" + "agent-desk/internal/services" + + "github.com/gin-gonic/gin" +) + +// MessengerGetWebhook handles Meta Webhook verification (hub.challenge). +func MessengerGetWebhook(ctx *gin.Context) { + mode := strings.TrimSpace(ctx.Query("hub.mode")) + token := strings.TrimSpace(ctx.Query("hub.verify_token")) + challenge := strings.TrimSpace(ctx.Query("hub.challenge")) + + channelID := strings.TrimSpace(ctx.Param("channel_id")) + if channelID == "" { + channelID = strings.TrimSpace(ctx.Query("channel_id")) + } + + if mode == "subscribe" { + // Verify token against channel config if present, or accept if valid + if channelID != "" { + channel := services.ChannelService.Take("channel_id = ? AND channel_type = ? AND status = ?", channelID, enums.ChannelTypeMessenger, enums.StatusOk) + if channel != nil { + if cfg, err := services.ChannelService.ParseMessengerChannelConfig(channel.ConfigJSON); err == nil && cfg != nil { + if cfg.WebhookVerifyToken != "" && cfg.WebhookVerifyToken != token { + ctx.String(http.StatusForbidden, "Verification token mismatch") + return + } + } + } + } + + ctx.String(http.StatusOK, challenge) + return + } + + ctx.String(http.StatusBadRequest, "Invalid verification request") +} + +// MessengerPostWebhook receives incoming Webhook events from Meta Messenger. +func MessengerPostWebhook(ctx *gin.Context) { + channelID := strings.TrimSpace(ctx.Param("channel_id")) + if channelID == "" { + channelID = strings.TrimSpace(ctx.Query("channel_id")) + } + + sigHeader := ctx.GetHeader("X-Hub-Signature-256") + if sigHeader == "" { + sigHeader = ctx.GetHeader("X-Hub-Signature") + } + + 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.MessengerInboundService.HandleWebhook(ctx.Request.Context(), channelID, sigHeader, bodyBytes); err != nil { + ctx.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "EVENT_RECEIVED"}) +} diff --git a/internal/handlers/third/messenger_handler_test.go b/internal/handlers/third/messenger_handler_test.go new file mode 100644 index 00000000..e1ae5cf3 --- /dev/null +++ b/internal/handlers/third/messenger_handler_test.go @@ -0,0 +1,123 @@ +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 TestMessengerWebhook_Handler(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupThirdHandlerTestDB(t) + + now := time.Now() + agent := &models.AIAgent{ + Name: "Messenger Agent", + ServiceMode: enums.IMConversationServiceModeAIFirst, + PublishedRevisionID: 1, + WelcomeMessage: "Hello Messenger!", + Status: enums.StatusOk, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + _ = db.Create(agent) + + messengerConfig, _ := json.Marshal(dto.MessengerChannelConfig{ + PageID: "page_888", + PageName: "Official FB Page", + PageAccessToken: "test_page_access_token", + WebhookVerifyToken: "my_verify_token_456", + WelcomeMessage: "Welcome to FB support!", + }) + + operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"} + channel, err := services.ChannelService.CreateChannel(request.CreateChannelRequest{ + Name: "FB Messenger Channel", + ChannelType: enums.ChannelTypeMessenger, + AIAgentID: agent.ID, + AIAgentRolloutPercent: 100, + ConfigJSON: string(messengerConfig), + Status: int(enums.StatusOk), + }, operator) + if err != nil { + t.Fatalf("CreateChannel failed: %v", err) + } + + router := gin.New() + router.GET("/api/third/messenger/webhook/:channel_id", MessengerGetWebhook) + router.GET("/api/third/messenger/webhook", MessengerGetWebhook) + router.POST("/api/third/messenger/webhook/:channel_id", MessengerPostWebhook) + router.POST("/api/third/messenger/webhook", MessengerPostWebhook) + + // 1. Test GET Verification Challenge Success + reqGet, _ := http.NewRequest(http.MethodGet, "/api/third/messenger/webhook/"+channel.ChannelID+"?hub.mode=subscribe&hub.verify_token=my_verify_token_456&hub.challenge=challenge_code_12345", nil) + recGet := httptest.NewRecorder() + router.ServeHTTP(recGet, reqGet) + + if recGet.Code != http.StatusOK { + t.Fatalf("expected 200 OK for challenge, got: %d", recGet.Code) + } + if recGet.Body.String() != "challenge_code_12345" { + t.Fatalf("expected challenge code in body, got: %s", recGet.Body.String()) + } + + // 2. Test GET Verification Challenge Mismatch + reqGetBad, _ := http.NewRequest(http.MethodGet, "/api/third/messenger/webhook/"+channel.ChannelID+"?hub.mode=subscribe&hub.verify_token=wrong_token&hub.challenge=challenge_code_12345", nil) + recGetBad := httptest.NewRecorder() + router.ServeHTTP(recGetBad, reqGetBad) + + if recGetBad.Code != http.StatusForbidden { + t.Fatalf("expected 403 Forbidden for wrong token, got: %d", recGetBad.Code) + } + + // 3. Test POST Inbound Message + payload := []byte(`{ + "object": "page", + "entry": [ + { + "id": "page_888", + "time": 1725260000, + "messaging": [ + { + "sender": {"id": "psid_999000"}, + "recipient": {"id": "page_888"}, + "timestamp": 1725260000, + "message": { + "mid": "mid_112233", + "text": "Hello Meta Support!" + } + } + ] + } + ] + }`) + + reqPost, _ := http.NewRequest(http.MethodPost, "/api/third/messenger/webhook/"+channel.ChannelID, bytes.NewBuffer(payload)) + reqPost.Header.Set("Content-Type", "application/json") + recPost := httptest.NewRecorder() + router.ServeHTTP(recPost, reqPost) + + if recPost.Code != http.StatusOK { + t.Fatalf("expected 200 OK for POST webhook, got: %d", recPost.Code) + } + + // Verify identity in DB + identity := repositories.CustomerIdentityRepository.FindOne(db, sqls.NewCnd(). + Eq("external_source", enums.ExternalSourceMessenger). + Eq("external_id", "psid_999000")) + if identity == nil { + t.Fatalf("expected customer identity for psid_999000") + } +} diff --git a/internal/messenger/client.go b/internal/messenger/client.go new file mode 100644 index 00000000..beb72e3f --- /dev/null +++ b/internal/messenger/client.go @@ -0,0 +1,173 @@ +package messenger + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const defaultBaseURL = "https://graph.facebook.com/v21.0" + +type Client struct { + pageAccessToken string + baseURL string + httpClient *http.Client +} + +func NewClient(pageAccessToken string) *Client { + return &Client{ + pageAccessToken: strings.TrimSpace(pageAccessToken), + 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) SendTextMessage(ctx context.Context, psid string, text string) (*SendMessageResponse, error) { + psid = strings.TrimSpace(psid) + if psid == "" { + return nil, fmt.Errorf("recipient psid is required") + } + text = strings.TrimSpace(text) + if text == "" { + return nil, fmt.Errorf("message text is required") + } + + payload := SendMessageRequest{ + Recipient: Recipient{ + ID: psid, + }, + Message: OutgoingMessage{ + Text: text, + }, + MessagingType: "RESPONSE", + } + + var resp SendMessageResponse + if err := c.doRequest(ctx, http.MethodPost, "/me/messages", payload, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +func (c *Client) SendMediaMessage(ctx context.Context, psid string, mediaType string, mediaURL string) (*SendMessageResponse, error) { + psid = strings.TrimSpace(psid) + if psid == "" { + return nil, fmt.Errorf("recipient psid is required") + } + mediaURL = strings.TrimSpace(mediaURL) + if mediaURL == "" { + return nil, fmt.Errorf("media url is required") + } + mediaType = strings.ToLower(strings.TrimSpace(mediaType)) + if mediaType == "" { + mediaType = "image" + } + + payload := SendMessageRequest{ + Recipient: Recipient{ + ID: psid, + }, + Message: OutgoingMessage{ + Attachment: &OutgoingAttachment{ + Type: mediaType, + Payload: OutgoingAttachmentPayload{ + URL: mediaURL, + IsReusable: true, + }, + }, + }, + MessagingType: "RESPONSE", + } + + var resp SendMessageResponse + if err := c.doRequest(ctx, http.MethodPost, "/me/messages", payload, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +func (c *Client) SubscribeAppToPage(ctx context.Context, pageID string) error { + pageID = strings.TrimSpace(pageID) + if pageID == "" { + return fmt.Errorf("page_id is required") + } + endpoint := fmt.Sprintf("/%s/subscribed_apps?subscribed_fields=messages,messaging_postbacks", pageID) + return c.doRequest(ctx, http.MethodPost, endpoint, nil, nil) +} + +func (c *Client) GetPageInfo(ctx context.Context, pageID string) (*PageInfo, error) { + pageID = strings.TrimSpace(pageID) + if pageID == "" { + pageID = "me" + } + var page PageInfo + endpoint := fmt.Sprintf("/%s?fields=id,name", pageID) + if err := c.doRequest(ctx, http.MethodGet, endpoint, nil, &page); err != nil { + return nil, err + } + return &page, nil +} + +func (c *Client) doRequest(ctx context.Context, method, path string, payload any, result any) error { + if c.pageAccessToken == "" { + return fmt.Errorf("messenger page access token is required") + } + + separator := "?" + if strings.Contains(path, "?") { + separator = "&" + } + endpoint := fmt.Sprintf("%s%s%saccess_token=%s", c.baseURL, path, separator, url.QueryEscape(c.pageAccessToken)) + + var bodyReader io.Reader + if payload != nil { + bodyBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal messenger request failed: %w", err) + } + bodyReader = bytes.NewBuffer(bodyBytes) + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint, bodyReader) + if err != nil { + return fmt.Errorf("create messenger 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("messenger http request failed: %w", err) + } + defer res.Body.Close() + + bodyBytes, err := io.ReadAll(res.Body) + if err != nil { + return fmt.Errorf("read messenger response failed: %w", err) + } + + if res.StatusCode < 200 || res.StatusCode >= 300 { + return fmt.Errorf("messenger api error (%d): %s", res.StatusCode, string(bodyBytes)) + } + + if result != nil { + if err := json.Unmarshal(bodyBytes, result); err != nil { + return fmt.Errorf("unmarshal messenger response failed: %w (body: %s)", err, string(bodyBytes)) + } + } + return nil +} diff --git a/internal/messenger/client_test.go b/internal/messenger/client_test.go new file mode 100644 index 00000000..3dea74af --- /dev/null +++ b/internal/messenger/client_test.go @@ -0,0 +1,79 @@ +package messenger + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestMessengerSendMessage(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/me/messages" { + t.Errorf("expected path /me/messages, got %s", r.URL.Path) + } + if r.URL.Query().Get("access_token") != "test_page_token" { + t.Errorf("expected access_token test_page_token, got %s", r.URL.Query().Get("access_token")) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"recipient_id":"psid_123","message_id":"mid_456"}`)) + })) + defer server.Close() + + client := NewClient("test_page_token") + client.SetBaseURL(server.URL) + + resp, err := client.SendTextMessage(context.Background(), "psid_123", "hello") + if err != nil { + t.Fatalf("SendTextMessage failed: %v", err) + } + if resp.MessageID != "mid_456" { + t.Errorf("expected MessageID mid_456, got %s", resp.MessageID) + } + if resp.RecipientID != "psid_123" { + t.Errorf("expected RecipientID psid_123, got %s", resp.RecipientID) + } +} + +func TestMessengerSendMediaMessage(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/me/messages" { + t.Errorf("expected path /me/messages, got %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"recipient_id":"psid_123","message_id":"mid_media_789"}`)) + })) + defer server.Close() + + client := NewClient("test_page_token") + client.SetBaseURL(server.URL) + + resp, err := client.SendMediaMessage(context.Background(), "psid_123", "image", "https://example.com/pic.jpg") + if err != nil { + t.Fatalf("SendMediaMessage failed: %v", err) + } + if resp.MessageID != "mid_media_789" { + t.Errorf("expected MessageID mid_media_789, got %s", resp.MessageID) + } +} + +func TestMessengerSubscribeAppToPage(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/page_123/subscribed_apps" { + t.Errorf("expected path /page_123/subscribed_apps, got %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"success":true}`)) + })) + defer server.Close() + + client := NewClient("test_page_token") + client.SetBaseURL(server.URL) + + if err := client.SubscribeAppToPage(context.Background(), "page_123"); err != nil { + t.Fatalf("SubscribeAppToPage failed: %v", err) + } +} diff --git a/internal/messenger/types.go b/internal/messenger/types.go new file mode 100644 index 00000000..04c624c2 --- /dev/null +++ b/internal/messenger/types.go @@ -0,0 +1,88 @@ +package messenger + +// Recipient represents recipient of a Messenger message (PSID). +type Recipient struct { + ID string `json:"id"` +} + +// OutgoingAttachmentPayload represents payload of an outgoing media attachment. +type OutgoingAttachmentPayload struct { + URL string `json:"url"` + IsReusable bool `json:"is_reusable,omitempty"` +} + +// OutgoingAttachment represents an attachment sent via Send API. +type OutgoingAttachment struct { + Type string `json:"type"` // image | audio | video | file | template + Payload OutgoingAttachmentPayload `json:"payload"` +} + +// OutgoingMessage represents text or media content to send to Facebook Messenger. +type OutgoingMessage struct { + Text string `json:"text,omitempty"` + Attachment *OutgoingAttachment `json:"attachment,omitempty"` +} + +// SendMessageRequest represents payload for Meta Graph Send API. +type SendMessageRequest struct { + Recipient Recipient `json:"recipient"` + Message OutgoingMessage `json:"message"` + MessagingType string `json:"messaging_type,omitempty"` +} + +// SendMessageResponse represents response from Meta Graph Send API. +type SendMessageResponse struct { + RecipientID string `json:"recipient_id"` + MessageID string `json:"message_id"` +} + +// PageInfo represents Facebook Page details. +type PageInfo struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// WebhookSender represents sender or recipient in a webhook event. +type WebhookSender struct { + ID string `json:"id"` +} + +// WebhookAttachmentData represents payload of an incoming webhook attachment. +type WebhookAttachmentData struct { + URL string `json:"url"` + Title string `json:"title,omitempty"` +} + +// WebhookAttachment represents an attachment in an incoming webhook. +type WebhookAttachment struct { + Type string `json:"type"` // image | audio | video | file | fallback + Payload WebhookAttachmentData `json:"payload"` +} + +// WebhookMessage represents message data in a webhook event. +type WebhookMessage struct { + MID string `json:"mid"` + Text string `json:"text,omitempty"` + Attachments []WebhookAttachment `json:"attachments,omitempty"` +} + +// WebhookMessaging represents messaging object inside an entry. +type WebhookMessaging struct { + Sender WebhookSender `json:"sender"` + Recipient WebhookSender `json:"recipient"` + Timestamp int64 `json:"timestamp"` + Message *WebhookMessage `json:"message,omitempty"` +} + +// WebhookEntry represents an entry within the webhook payload. +type WebhookEntry struct { + ID string `json:"id"` + Time int64 `json:"time"` + Messaging []WebhookMessaging `json:"messaging"` +} + +// WebhookEvent represents root Facebook Messenger webhook payload. +type WebhookEvent struct { + Object string `json:"object"` + Entry []WebhookEntry `json:"entry"` +} diff --git a/internal/pkg/dto/dto.go b/internal/pkg/dto/dto.go index a65bfc20..a3885c85 100644 --- a/internal/pkg/dto/dto.go +++ b/internal/pkg/dto/dto.go @@ -62,3 +62,23 @@ type EmailChannelConfig struct { WebhookSecret string `json:"webhookSecret,omitempty"` // Inbound Webhook Secret WelcomeMessage string `json:"welcomeMessage,omitempty"` // Auto-responder / welcome message } + +type DiscordChannelConfig struct { + GuildID string `json:"guildId,omitempty"` + GuildName string `json:"guildName,omitempty"` + ChannelScope string `json:"channelScope,omitempty"` // all | dm_only + BotToken string `json:"botToken,omitempty"` // Bot Token (BYOA / Enterprise) + ApplicationID string `json:"applicationId,omitempty"` + PublicKey string `json:"publicKey,omitempty"` + WebhookSecret string `json:"webhookSecret,omitempty"` + WelcomeMessage string `json:"welcomeMessage,omitempty"` +} + +type MessengerChannelConfig struct { + PageID string `json:"pageId,omitempty"` + PageName string `json:"pageName,omitempty"` + PageAccessToken string `json:"pageAccessToken,omitempty"` + WebhookVerifyToken string `json:"webhookVerifyToken,omitempty"` + AppSecret string `json:"appSecret,omitempty"` // Meta App Secret + WelcomeMessage string `json:"welcomeMessage,omitempty"` +} diff --git a/internal/pkg/enums/channel_enums_test.go b/internal/pkg/enums/channel_enums_test.go new file mode 100644 index 00000000..3ac93a33 --- /dev/null +++ b/internal/pkg/enums/channel_enums_test.go @@ -0,0 +1,20 @@ +package enums + +import ( + "testing" +) + +func TestChannelAndExternalSourceEnums(t *testing.T) { + if ChannelTypeDiscord != "discord" { + t.Fatalf("expected ChannelTypeDiscord to be 'discord', got %s", ChannelTypeDiscord) + } + if ChannelTypeMessenger != "messenger" { + t.Fatalf("expected ChannelTypeMessenger to be 'messenger', got %s", ChannelTypeMessenger) + } + if ExternalSourceDiscord != "discord" { + t.Fatalf("expected ExternalSourceDiscord to be 'discord', got %s", ExternalSourceDiscord) + } + if ExternalSourceMessenger != "messenger" { + t.Fatalf("expected ExternalSourceMessenger to be 'messenger', got %s", ExternalSourceMessenger) + } +} diff --git a/internal/pkg/enums/external_identity.go b/internal/pkg/enums/external_identity.go index 2eb84f2c..702d012b 100644 --- a/internal/pkg/enums/external_identity.go +++ b/internal/pkg/enums/external_identity.go @@ -13,6 +13,8 @@ const ( ExternalSourceTelegram ExternalSource = "telegram" // Telegram Bot ExternalSourceZaloOA ExternalSource = "zalo_oa" // Zalo Official Account ExternalSourceEmail ExternalSource = "email" // Email + ExternalSourceDiscord ExternalSource = "discord" // Discord + ExternalSourceMessenger ExternalSource = "messenger" // Facebook Messenger ) var externalSourceLabelMap = map[ExternalSource]string{ @@ -23,6 +25,8 @@ var externalSourceLabelMap = map[ExternalSource]string{ ExternalSourceTelegram: "Telegram", ExternalSourceZaloOA: "Zalo OA", ExternalSourceEmail: "Email", + ExternalSourceDiscord: "Discord", + ExternalSourceMessenger: "Messenger", } func GetExternalSourceLabel(v ExternalSource) string { diff --git a/internal/pkg/enums/wxwork_kf.go b/internal/pkg/enums/wxwork_kf.go index ae8d661f..f190bf30 100644 --- a/internal/pkg/enums/wxwork_kf.go +++ b/internal/pkg/enums/wxwork_kf.go @@ -18,12 +18,14 @@ const ( ) const ( - ChannelTypeWeb = "web" - ChannelTypeWechatMP = "wechat_mp" - ChannelTypeWxWorkKF = "wxwork_kf" - ChannelTypeTelegram = "telegram" - ChannelTypeZaloOA = "zalo_oa" - ChannelTypeEmail = "email" + ChannelTypeWeb = "web" + ChannelTypeWechatMP = "wechat_mp" + ChannelTypeWxWorkKF = "wxwork_kf" + ChannelTypeTelegram = "telegram" + ChannelTypeZaloOA = "zalo_oa" + ChannelTypeEmail = "email" + ChannelTypeDiscord = "discord" + ChannelTypeMessenger = "messenger" ) type WxWorkKFMessageSendStatus string diff --git a/internal/services/channel_message_outbox_service.go b/internal/services/channel_message_outbox_service.go index bac16350..08fc2d45 100644 --- a/internal/services/channel_message_outbox_service.go +++ b/internal/services/channel_message_outbox_service.go @@ -89,7 +89,7 @@ func (s *channelMessageOutboxService) EnqueueWxWorkKFMessage(conversation *model if message.SenderType != enums.IMSenderTypeAgent && message.SenderType != enums.IMSenderTypeAI { return nil } - if message.MessageType != enums.IMMessageTypeText && message.MessageType != enums.IMMessageTypeHTML { + if message.MessageType != enums.IMMessageTypeText && message.MessageType != enums.IMMessageTypeHTML && message.MessageType != enums.IMMessageTypeImage && message.MessageType != enums.IMMessageTypeAttachment { return nil } if existing := s.GetByMessageID(enums.ChannelTypeWxWorkKF, message.ID); existing != nil { @@ -137,7 +137,7 @@ func (s *channelMessageOutboxService) EnqueueTelegramMessage(conversation *model if message.SenderType != enums.IMSenderTypeAgent && message.SenderType != enums.IMSenderTypeAI { return nil } - if message.MessageType != enums.IMMessageTypeText && message.MessageType != enums.IMMessageTypeHTML { + if message.MessageType != enums.IMMessageTypeText && message.MessageType != enums.IMMessageTypeHTML && message.MessageType != enums.IMMessageTypeImage && message.MessageType != enums.IMMessageTypeAttachment { return nil } if existing := s.GetByMessageID(enums.ChannelTypeTelegram, message.ID); existing != nil { @@ -200,7 +200,7 @@ func (s *channelMessageOutboxService) EnqueueZaloOAMessage(conversation *models. if message.SenderType != enums.IMSenderTypeAgent && message.SenderType != enums.IMSenderTypeAI { return nil } - if message.MessageType != enums.IMMessageTypeText && message.MessageType != enums.IMMessageTypeHTML { + if message.MessageType != enums.IMMessageTypeText && message.MessageType != enums.IMMessageTypeHTML && message.MessageType != enums.IMMessageTypeImage && message.MessageType != enums.IMMessageTypeAttachment { return nil } if existing := s.GetByMessageID(enums.ChannelTypeZaloOA, message.ID); existing != nil { @@ -263,7 +263,7 @@ func (s *channelMessageOutboxService) EnqueueEmailMessage(conversation *models.C if message.SenderType != enums.IMSenderTypeAgent && message.SenderType != enums.IMSenderTypeAI { return nil } - if message.MessageType != enums.IMMessageTypeText && message.MessageType != enums.IMMessageTypeHTML { + if message.MessageType != enums.IMMessageTypeText && message.MessageType != enums.IMMessageTypeHTML && message.MessageType != enums.IMMessageTypeImage && message.MessageType != enums.IMMessageTypeAttachment { return nil } if existing := s.GetByMessageID(enums.ChannelTypeEmail, message.ID); existing != nil { @@ -315,6 +315,132 @@ func (s *channelMessageOutboxService) EnqueueEmailMessage(conversation *models.C return nil } +func (s *channelMessageOutboxService) EnqueueDiscordMessage(conversation *models.Conversation, message *models.Message) error { + if conversation == nil || message == nil { + return nil + } + channel := ChannelService.Get(conversation.ChannelID) + if channel == nil || channel.ChannelType != enums.ChannelTypeDiscord { + return nil + } + if message.SenderType != enums.IMSenderTypeAgent && message.SenderType != enums.IMSenderTypeAI { + return nil + } + if message.MessageType != enums.IMMessageTypeText && message.MessageType != enums.IMMessageTypeHTML && message.MessageType != enums.IMMessageTypeImage && message.MessageType != enums.IMMessageTypeAttachment { + return nil + } + if existing := s.GetByMessageID(enums.ChannelTypeDiscord, message.ID); existing != nil { + return nil + } + + payload, err := json.Marshal(map[string]any{ + "conversationId": conversation.ID, + "messageId": message.ID, + "messageType": message.MessageType, + "content": strings.TrimSpace(message.Content), + "payload": strings.TrimSpace(message.Payload), + "senderId": message.SenderID, + }) + if err != nil { + return err + } + + now := time.Now() + err = s.Create(&models.ChannelMessageOutbox{ + ChannelType: enums.ChannelTypeDiscord, + ConversationID: conversation.ID, + MessageID: message.ID, + Payload: string(payload), + SendStatus: string(enums.ChannelMessageOutboxStatusPending), + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: message.UpdateUserID, + CreateUserName: message.UpdateUserName, + UpdatedAt: now, + UpdateUserID: message.UpdateUserID, + UpdateUserName: message.UpdateUserName, + }, + }) + if err != nil { + return err + } + + // Trigger async dispatch immediately + go func() { + defer func() { + if r := recover(); r != nil { + slog.Error("recovered from panic in discord outbound dispatch", "error", r) + } + }() + DiscordOutboundService.DispatchPendingOutbox() + }() + + return nil +} + +func (s *channelMessageOutboxService) EnqueueMessengerMessage(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.ChannelTypeMessenger { + return nil + } + if message.SenderType != enums.IMSenderTypeAgent && message.SenderType != enums.IMSenderTypeAI { + return nil + } + if message.MessageType != enums.IMMessageTypeText && message.MessageType != enums.IMMessageTypeHTML && message.MessageType != enums.IMMessageTypeImage && message.MessageType != enums.IMMessageTypeAttachment { + return nil + } + if existing := s.GetByMessageID(enums.ChannelTypeMessenger, 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.ChannelTypeMessenger, + 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 messenger outbound dispatch", "error", r) + } + }() + MessengerOutboundService.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 4abe58a6..1de7f930 100644 --- a/internal/services/channel_service.go +++ b/internal/services/channel_service.go @@ -420,6 +420,43 @@ func (s *channelService) ParseEmailChannelConfig(raw string) (*dto.EmailChannelC cfg.WelcomeMessage = strings.TrimSpace(cfg.WelcomeMessage) return cfg, nil } + +func (s *channelService) ParseDiscordChannelConfig(raw string) (*dto.DiscordChannelConfig, error) { + raw = strings.TrimSpace(raw) + cfg := &dto.DiscordChannelConfig{} + if raw != "" { + if err := json.Unmarshal([]byte(raw), cfg); err != nil { + return nil, err + } + } + cfg.GuildID = strings.TrimSpace(cfg.GuildID) + cfg.GuildName = strings.TrimSpace(cfg.GuildName) + cfg.ChannelScope = strings.TrimSpace(cfg.ChannelScope) + cfg.BotToken = strings.TrimSpace(cfg.BotToken) + cfg.ApplicationID = strings.TrimSpace(cfg.ApplicationID) + cfg.PublicKey = strings.TrimSpace(cfg.PublicKey) + cfg.WebhookSecret = strings.TrimSpace(cfg.WebhookSecret) + cfg.WelcomeMessage = strings.TrimSpace(cfg.WelcomeMessage) + return cfg, nil +} + +func (s *channelService) ParseMessengerChannelConfig(raw string) (*dto.MessengerChannelConfig, error) { + raw = strings.TrimSpace(raw) + cfg := &dto.MessengerChannelConfig{} + if raw != "" { + if err := json.Unmarshal([]byte(raw), cfg); err != nil { + return nil, err + } + } + cfg.PageID = strings.TrimSpace(cfg.PageID) + cfg.PageName = strings.TrimSpace(cfg.PageName) + cfg.PageAccessToken = strings.TrimSpace(cfg.PageAccessToken) + cfg.WebhookVerifyToken = strings.TrimSpace(cfg.WebhookVerifyToken) + cfg.AppSecret = strings.TrimSpace(cfg.AppSecret) + cfg.WelcomeMessage = strings.TrimSpace(cfg.WelcomeMessage) + return cfg, nil +} + func (s *channelService) GetUserTokenSecret(channel *models.Channel) string { if channel == nil { return "" @@ -630,7 +667,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 && channelType != enums.ChannelTypeEmail { + if channelType != enums.ChannelTypeWeb && channelType != enums.ChannelTypeWechatMP && channelType != enums.ChannelTypeWxWorkKF && channelType != enums.ChannelTypeTelegram && channelType != enums.ChannelTypeZaloOA && channelType != enums.ChannelTypeEmail && channelType != enums.ChannelTypeDiscord && channelType != enums.ChannelTypeMessenger { return nil, errorsx.InvalidParamI18n("error.e0250") } name := strings.TrimSpace(req.Name) @@ -801,6 +838,48 @@ func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRe return nil, err } configJSON = string(configBytes) + case enums.ChannelTypeDiscord: + if channelID == "" { + channelID = strs.UUID() + } + if exists := s.Take("channel_id = ? AND status <> ? AND id <> ?", channelID, enums.StatusDeleted, id); exists != nil { + return nil, errorsx.InvalidParamI18n("error.e0248") + } + cfg, err := s.ParseDiscordChannelConfig(configJSON) + if err != nil { + return nil, errorsx.InvalidParam("invalid discord configuration") + } + 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.ChannelTypeMessenger: + 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.ParseMessengerChannelConfig(configJSON) + if err != nil { + return nil, errorsx.InvalidParam("invalid messenger configuration") + } + if cfg.WebhookVerifyToken == "" { + if secret, err := generateUserTokenSecret(); err == nil { + cfg.WebhookVerifyToken = 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 08e90162..773084a5 100644 --- a/internal/services/cronx/cron.go +++ b/internal/services/cronx/cron.go @@ -38,6 +38,14 @@ func Init() { if emailCount > 0 { slog.Info("email outbox dispatched", "count", emailCount) } + discordCount := services.DiscordOutboundService.DispatchPendingOutbox() + if discordCount > 0 { + slog.Info("discord outbox dispatched", "count", discordCount) + } + messengerCount := services.MessengerOutboundService.DispatchPendingOutbox() + if messengerCount > 0 { + slog.Info("messenger outbox dispatched", "count", messengerCount) + } }) c.Start() diff --git a/internal/services/discord_inbound_service.go b/internal/services/discord_inbound_service.go new file mode 100644 index 00000000..355ccedf --- /dev/null +++ b/internal/services/discord_inbound_service.go @@ -0,0 +1,153 @@ +package services + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "agent-desk/internal/discord" + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/openidentity" +) + +var DiscordInboundService = newDiscordInboundService() + +func newDiscordInboundService() *discordInboundService { + return &discordInboundService{} +} + +type discordInboundService struct{} + +// HandleWebhook processes an incoming webhook or gateway payload from Discord. +func (s *discordInboundService) HandleWebhook(ctx context.Context, channelID string, secretHeader string, rawPayload []byte) error { + channelID = strings.TrimSpace(channelID) + var channel *models.Channel + if channelID != "" { + channel = ChannelService.Take("channel_id = ? AND channel_type = ? AND status = ?", channelID, enums.ChannelTypeDiscord, enums.StatusOk) + } + if channel == nil { + channel = ChannelService.Take("channel_type = ? AND status = ?", enums.ChannelTypeDiscord, enums.StatusOk) + } + if channel == nil { + return errorsx.InvalidParam("discord channel not found or disabled") + } + + cfg, err := ChannelService.ParseDiscordChannelConfig(channel.ConfigJSON) + if err != nil || cfg == nil { + return errorsx.InvalidParam("discord channel config invalid") + } + + if cfg.WebhookSecret != "" && strings.TrimSpace(secretHeader) != cfg.WebhookSecret { + return errorsx.UnauthorizedI18n("error.auth.invalidSignature") + } + + var payload discord.WebhookPayload + if err := json.Unmarshal(rawPayload, &payload); err != nil { + return fmt.Errorf("unmarshal discord payload failed: %w", err) + } + + author := payload.Author + text := strings.TrimSpace(payload.Content) + msgID := payload.ID + targetChannelID := payload.ChannelID + guildID := payload.GuildID + attachments := payload.Attachments + embeds := payload.Embeds + + if payload.Message != nil { + if author == nil { + author = &payload.Message.Author + } + if text == "" { + text = strings.TrimSpace(payload.Message.Content) + } + if msgID == "" { + msgID = payload.Message.ID + } + if targetChannelID == "" { + targetChannelID = payload.Message.ChannelID + } + if guildID == "" { + guildID = payload.Message.GuildID + } + if len(attachments) == 0 && len(payload.Message.Attachments) > 0 { + attachments = payload.Message.Attachments + } + if len(embeds) == 0 && len(payload.Message.Embeds) > 0 { + embeds = payload.Message.Embeds + } + } + + if author == nil || author.Bot || strings.TrimSpace(author.ID) == "" { + return nil // Ignore bot messages or invalid authors + } + + if text == "" && len(attachments) > 0 { + firstAtt := attachments[0] + if firstAtt.Filename != "" { + text = fmt.Sprintf("[%s] %s", firstAtt.Filename, firstAtt.URL) + } else { + text = firstAtt.URL + } + } + + if text == "" && len(attachments) == 0 && len(embeds) == 0 { + return nil // Ignore empty messages + } + if text == "" && len(embeds) > 0 { + text = embeds[0].Description + if text == "" { + text = embeds[0].Title + } + } + + // 1. Resolve customer identity + externalID := author.ID + name := strings.TrimSpace(author.GlobalName) + if name == "" { + name = strings.TrimSpace(author.Username) + } + if name == "" { + name = fmt.Sprintf("Discord User %s", author.ID) + } + + externalUser := openidentity.ExternalUser{ + ExternalSource: enums.ExternalSourceDiscord, + ExternalID: externalID, + ExternalName: name, + } + + // 2. Create or match Conversation + conversation, err := ConversationService.Create(externalUser, channel.ID, channel.AIAgentID) + if err != nil { + return fmt.Errorf("create discord conversation failed: %w", err) + } + + // 3. Send message through MessageService + clientMsgID := fmt.Sprintf("discord_%s_%s", targetChannelID, msgID) + payloadMap := map[string]any{ + "discord_message_id": msgID, + "discord_channel_id": targetChannelID, + "discord_guild_id": guildID, + "discord_user_id": author.ID, + "discord_attachments": attachments, + } + payloadBytes, _ := json.Marshal(payloadMap) + + _, err = MessageService.SendCustomerMessage( + conversation.ID, + clientMsgID, + enums.IMMessageTypeText, + text, + string(payloadBytes), + externalUser, + ) + if err != nil { + return fmt.Errorf("send customer message failed: %w", err) + } + + return nil +} diff --git a/internal/services/discord_inbound_service_test.go b/internal/services/discord_inbound_service_test.go new file mode 100644 index 00000000..b05fb0e5 --- /dev/null +++ b/internal/services/discord_inbound_service_test.go @@ -0,0 +1,169 @@ +package services + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +func setupDiscordTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{ + TablePrefix: "t_", + SingularTable: true, + }, + }) + if err != nil { + t.Fatalf("open sqlite db: %v", err) + } + if err := db.AutoMigrate( + &models.Channel{}, + &models.ChannelMessageOutbox{}, + &models.Customer{}, + &models.CustomerIdentity{}, + &models.CustomerContact{}, + &models.Conversation{}, + &models.ConversationParticipant{}, + &models.ConversationReadState{}, + &models.ConversationInterrupt{}, + &models.ConversationEventLog{}, + &models.Message{}, + &models.AIAgent{}, + &models.User{}, + &models.Role{}, + &models.UserRole{}, + &models.Permission{}, + &models.RolePermission{}, + &models.UserPermission{}, + ); err != nil { + t.Fatalf("migrate discord test tables: %v", err) + } + sqls.SetDB(db) + return db +} + +func TestDiscordInboundAndOutbound(t *testing.T) { + db := setupDiscordTestDB(t) + + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"id":"out_msg_100","channel_id":"text_chan_1","content":"Agent reply"}`)) + })) + defer mockServer.Close() + + now := time.Now() + aiAgent := &models.AIAgent{ + Name: "Support AI", + Status: enums.StatusOk, + PublishedRevisionID: 1, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + if err := db.Create(aiAgent).Error; err != nil { + t.Fatalf("create ai agent: %v", err) + } + + discordConfig := dto.DiscordChannelConfig{ + GuildID: "guild_12345", + GuildName: "Test Guild", + BotToken: "discord_bot_token", + WebhookSecret: "test_secret", + } + cfgBytes, _ := json.Marshal(discordConfig) + + channel := &models.Channel{ + ChannelType: enums.ChannelTypeDiscord, + ChannelID: "discord_ch_1", + AIAgentID: aiAgent.ID, + AIAgentRolloutPercent: 100, + Name: "Community Support", + ConfigJSON: string(cfgBytes), + Status: enums.StatusOk, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + if err := db.Create(channel).Error; err != nil { + t.Fatalf("create discord channel: %v", err) + } + + payload := `{ + "id": "msg_999", + "channel_id": "text_chan_1", + "guild_id": "guild_12345", + "content": "", + "author": { + "id": "user_888", + "username": "gamer_joy", + "global_name": "Joy Le", + "bot": false + }, + "attachments": [ + { + "id": "att_1", + "filename": "screenshot.png", + "url": "https://cdn.discordapp.com/attachments/1/screenshot.png", + "content_type": "image/png", + "size": 10240 + } + ] + }` + + ctx := context.Background() + err := DiscordInboundService.HandleWebhook(ctx, channel.ChannelID, "test_secret", []byte(payload)) + if err != nil { + t.Fatalf("HandleWebhook failed: %v", err) + } + + // Verify customer identity + identity := repositories.CustomerIdentityRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("external_source", enums.ExternalSourceDiscord). + Eq("external_id", "user_888")) + if identity == nil { + t.Fatalf("expected customer identity to be created") + } + + // Verify conversation + conv := repositories.ConversationRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("customer_id", identity.CustomerID). + Eq("channel_id", channel.ID)) + if conv == nil { + t.Fatalf("expected conversation to be created") + } + + // Verify image message created from attachment + custMsg := repositories.MessageRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("conversation_id", conv.ID). + Eq("sender_type", enums.IMSenderTypeCustomer)) + if custMsg == nil { + t.Fatalf("expected customer message to be created") + } + + operator := &dto.AuthPrincipal{UserID: 1, Nickname: "Agent Joy"} + + // Test Outbound enqueue with Message + replyMsg, err := MessageService.SendAIMessage(conv.ID, aiAgent.ID, "ai_msg_1", enums.IMMessageTypeText, "Here is your response image: https://example.com/response_img.png", "", operator) + if err != nil { + t.Fatalf("MessageService.SendAIMessage failed: %v", err) + } + + outbox := ChannelMessageOutboxService.GetByMessageID(enums.ChannelTypeDiscord, replyMsg.ID) + if outbox == nil { + t.Fatalf("expected outbox entry for discord message") + } + if outbox.SendStatus != string(enums.ChannelMessageOutboxStatusPending) && outbox.SendStatus != string(enums.ChannelMessageOutboxStatusSending) && outbox.SendStatus != string(enums.ChannelMessageOutboxStatusFailed) { + t.Fatalf("unexpected outbox status: %s", outbox.SendStatus) + } +} diff --git a/internal/services/discord_outbound_service.go b/internal/services/discord_outbound_service.go new file mode 100644 index 00000000..952d7db9 --- /dev/null +++ b/internal/services/discord_outbound_service.go @@ -0,0 +1,216 @@ +package services + +import ( + "context" + "encoding/json" + "log/slog" + "strings" + "time" + + "agent-desk/internal/discord" + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + "agent-desk/internal/services/storage" + + "github.com/mlogclub/simple/sqls" +) + +const ( + discordOutboxBatchSize = 20 + discordOutboxMaxRetry = 5 +) + +var DiscordOutboundService = newDiscordOutboundService() + +func newDiscordOutboundService() *discordOutboundService { + return &discordOutboundService{} +} + +type discordOutboundService struct{} + +func (s *discordOutboundService) DispatchPendingOutbox() int { + return s.doDispatchPendingOutbox(discordOutboxBatchSize) +} + +func (s *discordOutboundService) doDispatchPendingOutbox(limit int) int { + if limit <= 0 { + limit = discordOutboxBatchSize + } + items := ChannelMessageOutboxService.ListPending(enums.ChannelTypeDiscord, limit) + if len(items) == 0 { + return 0 + } + + successCount := 0 + for i := range items { + if err := s.processOutbox(items[i].ID); err != nil { + slog.Warn("process discord outbox failed", + "outbox_id", items[i].ID, + "error", err, + ) + continue + } + successCount++ + } + return successCount +} + +func (s *discordOutboundService) processOutbox(outboxID int64) error { + outbox := ChannelMessageOutboxService.Get(outboxID) + if outbox == nil { + return nil + } + if outbox.ChannelType != enums.ChannelTypeDiscord { + return nil + } + if outbox.SendStatus == string(enums.ChannelMessageOutboxStatusSent) { + return nil + } + if outbox.NextRetryAt != nil && outbox.NextRetryAt.After(time.Now()) { + return nil + } + + if err := ChannelMessageOutboxService.Updates(outbox.ID, map[string]any{ + "send_status": string(enums.ChannelMessageOutboxStatusSending), + "updated_at": time.Now(), + }); err != nil { + return err + } + + message := MessageService.Get(outbox.MessageID) + if message == nil { + return s.markOutboxFailed(outbox, "message not found") + } + conversation := ConversationService.Get(outbox.ConversationID) + if conversation == nil { + return s.markOutboxFailed(outbox, "conversation not found") + } + + channel := ChannelService.Get(conversation.ChannelID) + if channel == nil || channel.Status != enums.StatusOk { + return s.markOutboxFailed(outbox, "discord channel not found or disabled") + } + cfg, err := ChannelService.ParseDiscordChannelConfig(channel.ConfigJSON) + if err != nil || cfg == nil || cfg.BotToken == "" { + return s.markOutboxFailed(outbox, "discord bot token not configured") + } + + // Resolve target Discord User ID and/or Channel ID + var discordUserID string + customerIdentity := repositories.CustomerIdentityRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("customer_id", conversation.CustomerID). + Eq("external_source", enums.ExternalSourceDiscord)) + if customerIdentity != nil { + discordUserID = strings.TrimSpace(customerIdentity.ExternalID) + } + + // Check if there is a discord_channel_id in last message payload + var targetChannelID string + lastCustomerMsg := repositories.MessageRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("conversation_id", conversation.ID). + Eq("sender_type", enums.IMSenderTypeCustomer). + Desc("id")) + if lastCustomerMsg != nil && lastCustomerMsg.Payload != "" { + var payloadMap map[string]any + if err := json.Unmarshal([]byte(lastCustomerMsg.Payload), &payloadMap); err == nil { + if chID, ok := payloadMap["discord_channel_id"].(string); ok && chID != "" { + targetChannelID = chID + } + } + } + + client := discord.NewClient(cfg.BotToken) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + if targetChannelID == "" { + if discordUserID == "" { + return s.markOutboxFailed(outbox, "unable to resolve discord target user or channel") + } + dmChannel, err := client.CreateDMChannel(ctx, discordUserID) + if err != nil { + return s.markOutboxFailed(outbox, "create discord dm channel failed: "+err.Error()) + } + targetChannelID = dmChannel.ID + } + + var sendErr error + if message.MessageType == enums.IMMessageTypeImage { + assetPayload, err := parseIMMessageAssetPayload(message.Payload) + var imageURL string + if err == nil && assetPayload != nil { + assetPayload = hydrateIMMessageAssetPayload(assetPayload) + if assetPayload.Provider != "" && assetPayload.StorageKey != "" { + if provider, err := storage.NewProvider(assetPayload.Provider); err == nil { + imageURL = provider.GetSignedURL(assetPayload.StorageKey) + } + } + } + if imageURL == "" && strings.HasPrefix(strings.TrimSpace(message.Content), "http") { + imageURL = strings.TrimSpace(message.Content) + } + + if imageURL != "" { + embed := discord.Embed{ + Title: "Image Attachment", + Image: &discord.EmbedMedia{URL: imageURL}, + } + _, sendErr = client.SendEmbedMessage(ctx, targetChannelID, message.Content, []discord.Embed{embed}) + } else { + _, sendErr = client.SendMessage(ctx, targetChannelID, message.Content) + } + } else if message.MessageType == enums.IMMessageTypeAttachment { + assetPayload, err := parseIMMessageAssetPayload(message.Payload) + var fileURL string + if err == nil && assetPayload != nil { + assetPayload = hydrateIMMessageAssetPayload(assetPayload) + if assetPayload.Provider != "" && assetPayload.StorageKey != "" { + if provider, err := storage.NewProvider(assetPayload.Provider); err == nil { + fileURL = provider.GetSignedURL(assetPayload.StorageKey) + } + } + } + textToSend := message.Content + if fileURL != "" { + if textToSend != "" { + textToSend += "\n" + fileURL + } else { + textToSend = fileURL + } + } + _, sendErr = client.SendMessage(ctx, targetChannelID, textToSend) + } else { + _, sendErr = client.SendMessage(ctx, targetChannelID, message.Content) + } + + if sendErr != nil { + return s.markOutboxFailed(outbox, sendErr.Error()) + } + + return ChannelMessageOutboxService.Updates(outbox.ID, map[string]any{ + "send_status": string(enums.ChannelMessageOutboxStatusSent), + "sent_at": time.Now(), + "updated_at": time.Now(), + }) +} + +func (s *discordOutboundService) markOutboxFailed(outbox *models.ChannelMessageOutbox, errMsg string) error { + if outbox == nil { + return nil + } + retryCount := outbox.RetryCount + 1 + status := string(enums.ChannelMessageOutboxStatusFailed) + if retryCount >= discordOutboxMaxRetry { + status = string(enums.ChannelMessageOutboxStatusIgnored) + } + nextRetryAt := time.Now().Add(time.Duration(retryCount*30) * time.Second) + + return ChannelMessageOutboxService.Updates(outbox.ID, map[string]any{ + "send_status": status, + "retry_count": retryCount, + "next_retry_at": &nextRetryAt, + "last_error": errMsg, + "updated_at": time.Now(), + }) +} diff --git a/internal/services/message_service.go b/internal/services/message_service.go index 6c351397..9b010ab6 100644 --- a/internal/services/message_service.go +++ b/internal/services/message_service.go @@ -567,6 +567,24 @@ func (s *messageService) sendValidatedMessage(conversation *models.Conversation, "error", enqueueErr, ) } + + // Discord 渠道消息入队,异步发送 + if enqueueErr := ChannelMessageOutboxService.EnqueueDiscordMessage(conversation, message); enqueueErr != nil { + slog.Error("enqueue discord outbox failed", + "conversation_id", conversation.ID, + "message_id", message.ID, + "error", enqueueErr, + ) + } + + // Messenger 渠道消息入队,异步发送 + if enqueueErr := ChannelMessageOutboxService.EnqueueMessengerMessage(conversation, message); enqueueErr != nil { + slog.Error("enqueue messenger 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/messenger_inbound_service.go b/internal/services/messenger_inbound_service.go new file mode 100644 index 00000000..9ee99502 --- /dev/null +++ b/internal/services/messenger_inbound_service.go @@ -0,0 +1,152 @@ +package services + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + "agent-desk/internal/messenger" + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/openidentity" +) + +var MessengerInboundService = newMessengerInboundService() + +func newMessengerInboundService() *messengerInboundService { + return &messengerInboundService{} +} + +type messengerInboundService struct{} + +// HandleWebhook processes an incoming Webhook event from Meta Messenger Platform. +func (s *messengerInboundService) HandleWebhook(ctx context.Context, channelID string, signatureHeader string, rawPayload []byte) error { + var event messenger.WebhookEvent + if err := json.Unmarshal(rawPayload, &event); err != nil { + return fmt.Errorf("unmarshal messenger webhook failed: %w", err) + } + + if event.Object != "page" { + return nil // Ignore non-page events + } + + for _, entry := range event.Entry { + pageID := strings.TrimSpace(entry.ID) + var channel *models.Channel + + channelID = strings.TrimSpace(channelID) + if channelID != "" { + channel = ChannelService.Take("channel_id = ? AND channel_type = ? AND status = ?", channelID, enums.ChannelTypeMessenger, enums.StatusOk) + } + if channel == nil && pageID != "" { + // Find channel by Page ID in ConfigJSON or channel_id + channel = ChannelService.Take("channel_type = ? AND status = ? AND (channel_id = ? OR config_json LIKE ?)", + enums.ChannelTypeMessenger, enums.StatusOk, pageID, "%"+pageID+"%") + } + if channel == nil { + channel = ChannelService.Take("channel_type = ? AND status = ?", enums.ChannelTypeMessenger, enums.StatusOk) + } + if channel == nil { + continue + } + + cfg, err := ChannelService.ParseMessengerChannelConfig(channel.ConfigJSON) + if err != nil || cfg == nil { + continue + } + + // Optional signature verification if appSecret is configured + if cfg.AppSecret != "" && strings.TrimSpace(signatureHeader) != "" { + if !verifyMessengerSignature(cfg.AppSecret, signatureHeader, rawPayload) { + return errorsx.UnauthorizedI18n("error.auth.invalidSignature") + } + } + + for _, messaging := range entry.Messaging { + if messaging.Message == nil { + continue + } + + senderID := strings.TrimSpace(messaging.Sender.ID) + if senderID == "" || senderID == pageID { + continue // Ignore echo / self-sent messages + } + + text := strings.TrimSpace(messaging.Message.Text) + attachments := messaging.Message.Attachments + + if text == "" && len(attachments) > 0 { + firstAtt := attachments[0] + if firstAtt.Payload.Title != "" { + text = fmt.Sprintf("[%s] %s", firstAtt.Payload.Title, firstAtt.Payload.URL) + } else { + text = firstAtt.Payload.URL + } + } + + if text == "" && len(attachments) == 0 { + continue + } + + mid := messaging.Message.MID + if mid == "" { + mid = fmt.Sprintf("mid_%d", messaging.Timestamp) + } + + // 1. Resolve customer identity + externalUser := openidentity.ExternalUser{ + ExternalSource: enums.ExternalSourceMessenger, + ExternalID: senderID, + ExternalName: fmt.Sprintf("Facebook User %s", senderID), + } + + // 2. Create or match Conversation + conversation, err := ConversationService.Create(externalUser, channel.ID, channel.AIAgentID) + if err != nil { + return fmt.Errorf("create messenger conversation failed: %w", err) + } + + // 3. Send message through MessageService + clientMsgID := fmt.Sprintf("fb_%s", mid) + payloadMap := map[string]any{ + "messenger_mid": mid, + "messenger_sender_id": senderID, + "messenger_page_id": pageID, + "messenger_timestamp": messaging.Timestamp, + "messenger_attachments": attachments, + } + payloadBytes, _ := json.Marshal(payloadMap) + + _, err = MessageService.SendCustomerMessage( + conversation.ID, + clientMsgID, + enums.IMMessageTypeText, + text, + string(payloadBytes), + externalUser, + ) + if err != nil { + return fmt.Errorf("send customer message failed: %w", err) + } + } + } + + return nil +} + +func verifyMessengerSignature(appSecret string, signatureHeader string, payload []byte) bool { + signature := strings.TrimSpace(signatureHeader) + if strings.HasPrefix(signature, "sha256=") { + expectedSig := signature[len("sha256="):] + mac := hmac.New(sha256.New, []byte(appSecret)) + mac.Write(payload) + actualSig := hex.EncodeToString(mac.Sum(nil)) + return hmac.Equal([]byte(actualSig), []byte(expectedSig)) + } + return true +} diff --git a/internal/services/messenger_inbound_service_test.go b/internal/services/messenger_inbound_service_test.go new file mode 100644 index 00000000..95a0c883 --- /dev/null +++ b/internal/services/messenger_inbound_service_test.go @@ -0,0 +1,168 @@ +package services + +import ( + "context" + "encoding/json" + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +func setupMessengerTestDB(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 messenger test tables: %v", err) + } + sqls.SetDB(db) + return db +} + +func TestMessengerInboundAndOutbound(t *testing.T) { + db := setupMessengerTestDB(t) + + now := time.Now() + aiAgent := &models.AIAgent{ + Name: "Support AI", + Status: enums.StatusOk, + PublishedRevisionID: 1, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + if err := db.Create(aiAgent).Error; err != nil { + t.Fatalf("create ai agent: %v", err) + } + + messengerConfig := dto.MessengerChannelConfig{ + PageID: "page_1001", + PageName: "Acme Fanpage", + PageAccessToken: "page_token_xyz", + WebhookVerifyToken: "verify_token_123", + } + cfgBytes, _ := json.Marshal(messengerConfig) + + channel := &models.Channel{ + ChannelType: enums.ChannelTypeMessenger, + ChannelID: "page_1001", + AIAgentID: aiAgent.ID, + AIAgentRolloutPercent: 100, + Name: "FB Messenger Support", + ConfigJSON: string(cfgBytes), + Status: enums.StatusOk, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + if err := db.Create(channel).Error; err != nil { + t.Fatalf("create messenger channel: %v", err) + } + + payload := `{ + "object": "page", + "entry": [ + { + "id": "page_1001", + "time": 1725260000, + "messaging": [ + { + "sender": { "id": "psid_555" }, + "recipient": { "id": "page_1001" }, + "timestamp": 1725260000, + "message": { + "mid": "mid_fb_777", + "text": "", + "attachments": [ + { + "type": "image", + "payload": { + "url": "https://scontent.facebook.com/image.jpg", + "title": "photo.jpg" + } + } + ] + } + } + ] + } + ] + }` + + ctx := context.Background() + err := MessengerInboundService.HandleWebhook(ctx, "", "", []byte(payload)) + if err != nil { + t.Fatalf("HandleWebhook failed: %v", err) + } + + // Verify customer identity + identity := repositories.CustomerIdentityRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("external_source", enums.ExternalSourceMessenger). + Eq("external_id", "psid_555")) + if identity == nil { + t.Fatalf("expected customer identity to be created") + } + + // Verify conversation + conv := repositories.ConversationRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("customer_id", identity.CustomerID). + Eq("channel_id", channel.ID)) + if conv == nil { + t.Fatalf("expected conversation to be created") + } + + // Verify message + msg := repositories.MessageRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("conversation_id", conv.ID). + Eq("sender_type", enums.IMSenderTypeCustomer)) + if msg == nil { + t.Fatalf("expected message to be created") + } + + operator := &dto.AuthPrincipal{UserID: 1, Nickname: "Agent Joy"} + + // Test Outbound enqueue with image + replyMsg, err := MessageService.SendAIMessage(conv.ID, aiAgent.ID, "ai_msg_2", enums.IMMessageTypeText, "https://example.com/banner.png", "", operator) + if err != nil { + t.Fatalf("MessageService.SendAIMessage failed: %v", err) + } + + outbox := ChannelMessageOutboxService.GetByMessageID(enums.ChannelTypeMessenger, replyMsg.ID) + if outbox == nil { + t.Fatalf("expected outbox entry for messenger message") + } + if outbox.SendStatus != string(enums.ChannelMessageOutboxStatusPending) && outbox.SendStatus != string(enums.ChannelMessageOutboxStatusSending) && outbox.SendStatus != string(enums.ChannelMessageOutboxStatusFailed) { + t.Fatalf("unexpected outbox status: %s", outbox.SendStatus) + } +} diff --git a/internal/services/messenger_outbound_service.go b/internal/services/messenger_outbound_service.go new file mode 100644 index 00000000..34310394 --- /dev/null +++ b/internal/services/messenger_outbound_service.go @@ -0,0 +1,189 @@ +package services + +import ( + "context" + "log/slog" + "strings" + "time" + + "agent-desk/internal/messenger" + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + "agent-desk/internal/services/storage" + + "github.com/mlogclub/simple/sqls" +) + +const ( + messengerOutboxBatchSize = 20 + messengerOutboxMaxRetry = 5 +) + +var MessengerOutboundService = newMessengerOutboundService() + +func newMessengerOutboundService() *messengerOutboundService { + return &messengerOutboundService{} +} + +type messengerOutboundService struct{} + +func (s *messengerOutboundService) DispatchPendingOutbox() int { + return s.doDispatchPendingOutbox(messengerOutboxBatchSize) +} + +func (s *messengerOutboundService) doDispatchPendingOutbox(limit int) int { + if limit <= 0 { + limit = messengerOutboxBatchSize + } + items := ChannelMessageOutboxService.ListPending(enums.ChannelTypeMessenger, 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 messenger outbox failed", + "outbox_id", items[i].ID, + "error", err, + ) + continue + } + successCount++ + } + return successCount +} + +func (s *messengerOutboundService) processOutbox(outboxID int64) error { + outbox := ChannelMessageOutboxService.Get(outboxID) + if outbox == nil { + return nil + } + if outbox.ChannelType != enums.ChannelTypeMessenger { + 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, "messenger channel not found or disabled") + } + cfg, err := ChannelService.ParseMessengerChannelConfig(channel.ConfigJSON) + if err != nil || cfg == nil || cfg.PageAccessToken == "" { + return s.markOutboxFailed(outbox, "messenger page access token not configured") + } + + // Resolve target Messenger PSID + var psid string + customerIdentity := repositories.CustomerIdentityRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("customer_id", conversation.CustomerID). + Eq("external_source", enums.ExternalSourceMessenger)) + if customerIdentity != nil { + psid = strings.TrimSpace(customerIdentity.ExternalID) + } + if psid == "" { + return s.markOutboxFailed(outbox, "unable to resolve messenger psid") + } + + // Send message via Meta Graph API + client := messenger.NewClient(cfg.PageAccessToken) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var sendErr error + if message.MessageType == enums.IMMessageTypeImage { + assetPayload, err := parseIMMessageAssetPayload(message.Payload) + var imageURL string + if err == nil && assetPayload != nil { + assetPayload = hydrateIMMessageAssetPayload(assetPayload) + if assetPayload.Provider != "" && assetPayload.StorageKey != "" { + if provider, err := storage.NewProvider(assetPayload.Provider); err == nil { + imageURL = provider.GetSignedURL(assetPayload.StorageKey) + } + } + } + if imageURL == "" && strings.HasPrefix(strings.TrimSpace(message.Content), "http") { + imageURL = strings.TrimSpace(message.Content) + } + + if imageURL != "" { + _, sendErr = client.SendMediaMessage(ctx, psid, "image", imageURL) + } else { + _, sendErr = client.SendTextMessage(ctx, psid, message.Content) + } + } else if message.MessageType == enums.IMMessageTypeAttachment { + assetPayload, err := parseIMMessageAssetPayload(message.Payload) + var fileURL string + if err == nil && assetPayload != nil { + assetPayload = hydrateIMMessageAssetPayload(assetPayload) + if assetPayload.Provider != "" && assetPayload.StorageKey != "" { + if provider, err := storage.NewProvider(assetPayload.Provider); err == nil { + fileURL = provider.GetSignedURL(assetPayload.StorageKey) + } + } + } + if fileURL == "" && strings.HasPrefix(strings.TrimSpace(message.Content), "http") { + fileURL = strings.TrimSpace(message.Content) + } + + if fileURL != "" { + _, sendErr = client.SendMediaMessage(ctx, psid, "file", fileURL) + } else { + _, sendErr = client.SendTextMessage(ctx, psid, message.Content) + } + } else { + _, sendErr = client.SendTextMessage(ctx, psid, 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 *messengerOutboundService) markOutboxFailed(outbox *models.ChannelMessageOutbox, errMsg string) error { + if outbox == nil { + return nil + } + retryCount := outbox.RetryCount + 1 + status := string(enums.ChannelMessageOutboxStatusFailed) + if retryCount >= messengerOutboxMaxRetry { + 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/web/app/(dashboard)/dashboard/channels/_components/edit.tsx b/web/app/(dashboard)/dashboard/channels/_components/edit.tsx index 36bfde7c..187d1618 100644 --- a/web/app/(dashboard)/dashboard/channels/_components/edit.tsx +++ b/web/app/(dashboard)/dashboard/channels/_components/edit.tsx @@ -85,6 +85,22 @@ type EmailChannelConfig = { webhookSecret?: string } +type DiscordChannelConfig = { + guildId?: string + guildName?: string + botToken?: string + channelScope?: string + webhookSecret?: string +} + +type MessengerChannelConfig = { + pageId?: string + pageName?: string + pageAccessToken?: string + webhookVerifyToken?: string + appSecret?: string +} + function getDefaultWebChannelConfig(t: Translate): Required { return { title: t("channel.defaultTitleWeb"), @@ -99,7 +115,7 @@ function getDefaultWebChannelConfig(t: Translate): Required { function createSchema(t: Translate) { return z .object({ - channelType: z.enum(["web", "wechat_mp", "wxwork_kf", "telegram", "zalo_oa", "email"], t("channel.typeRequired")), + channelType: z.enum(["web", "wechat_mp", "wxwork_kf", "telegram", "zalo_oa", "email", "discord", "messenger"], 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")), @@ -111,6 +127,14 @@ function createSchema(t: Translate) { zaloOaId: z.string().trim(), zaloAccessToken: z.string().trim(), zaloSecretKey: z.string().trim(), + discordGuildId: z.string().trim(), + discordGuildName: z.string().trim(), + discordBotToken: z.string().trim(), + messengerPageId: z.string().trim(), + messengerPageName: z.string().trim(), + messengerPageAccessToken: z.string().trim(), + messengerWebhookVerifyToken: z.string().trim(), + messengerAppSecret: z.string().trim(), emailAddress: z.string().trim(), senderName: z.string().trim(), emailProvider: z.string().trim(), @@ -160,7 +184,7 @@ function createSchema(t: Translate) { } type EditForm = { - channelType: "web" | "wechat_mp" | "wxwork_kf" | "telegram" | "zalo_oa" | "email" + channelType: "web" | "wechat_mp" | "wxwork_kf" | "telegram" | "zalo_oa" | "email" | "discord" | "messenger" aiAgentId: string aiAgentRolloutPercent: number name: string @@ -172,6 +196,14 @@ type EditForm = { zaloOaId: string zaloAccessToken: string zaloSecretKey: string + discordGuildId: string + discordGuildName: string + discordBotToken: string + messengerPageId: string + messengerPageName: string + messengerPageAccessToken: string + messengerWebhookVerifyToken: string + messengerAppSecret: string emailAddress: string senderName: string emailProvider: string @@ -204,6 +236,14 @@ function createEmptyForm(t: Translate): EditForm { zaloOaId: "", zaloAccessToken: "", zaloSecretKey: "", + discordGuildId: "", + discordGuildName: "", + discordBotToken: "", + messengerPageId: "", + messengerPageName: "", + messengerPageAccessToken: "", + messengerWebhookVerifyToken: "", + messengerAppSecret: "", emailAddress: "help@crove.com", senderName: "Crove Desk Support", emailProvider: "brevo", @@ -332,6 +372,38 @@ function parseWechatMPChannelConfig(configJson: string, t: Translate): Required< } } +function parseDiscordChannelConfig(configJson: string): DiscordChannelConfig { + if (!configJson.trim()) return {} + try { + const parsed = JSON.parse(configJson) as DiscordChannelConfig + return { + guildId: parsed.guildId?.trim() || "", + guildName: parsed.guildName?.trim() || "", + botToken: parsed.botToken?.trim() || "", + channelScope: parsed.channelScope?.trim() || "all", + webhookSecret: parsed.webhookSecret?.trim() || "", + } + } catch { + return {} + } +} + +function parseMessengerChannelConfig(configJson: string): MessengerChannelConfig { + if (!configJson.trim()) return {} + try { + const parsed = JSON.parse(configJson) as MessengerChannelConfig + return { + pageId: parsed.pageId?.trim() || "", + pageName: parsed.pageName?.trim() || "", + pageAccessToken: parsed.pageAccessToken?.trim() || "", + webhookVerifyToken: parsed.webhookVerifyToken?.trim() || "", + appSecret: parsed.appSecret?.trim() || "", + } + } catch { + return {} + } +} + function buildForm(item: AdminChannel | null, t: Translate): EditForm { if (!item) { return createEmptyForm(t) @@ -340,6 +412,8 @@ function buildForm(item: AdminChannel | null, t: Translate): EditForm { const isTelegram = item.channelType === "telegram" const isZaloOA = item.channelType === "zalo_oa" const isEmail = item.channelType === "email" + const isDiscord = item.channelType === "discord" + const isMessenger = item.channelType === "messenger" const webConfig = parseWebChannelConfig(item.configJson, t) const wechatConfig = isWechatMP ? parseWechatMPChannelConfig(item.configJson, t) @@ -353,6 +427,12 @@ function buildForm(item: AdminChannel | null, t: Translate): EditForm { const emailConfig = isEmail ? parseEmailChannelConfig(item.configJson) : null + const discordConfig = isDiscord + ? parseDiscordChannelConfig(item.configJson) + : null + const messengerConfig = isMessenger + ? parseMessengerChannelConfig(item.configJson) + : null return { channelType: item.channelType === "wxwork_kf" @@ -361,22 +441,34 @@ function buildForm(item: AdminChannel | null, t: Translate): EditForm { ? "telegram" : item.channelType === "zalo_oa" ? "zalo_oa" - : item.channelType === "email" - ? "email" - : item.channelType === "wechat_mp" - ? "wechat_mp" - : "web", + : item.channelType === "discord" + ? "discord" + : item.channelType === "messenger" + ? "messenger" + : 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 ?? "", + botToken: telegramConfig?.botToken || discordConfig?.botToken || "", botUsername: telegramConfig?.botUsername ?? "", - webhookSecret: telegramConfig?.webhookSecret ?? zaloConfig?.webhookSecret ?? emailConfig?.webhookSecret ?? "", + webhookSecret: telegramConfig?.webhookSecret || zaloConfig?.webhookSecret || emailConfig?.webhookSecret || discordConfig?.webhookSecret || "", zaloAppId: zaloConfig?.appId ?? "", zaloOaId: zaloConfig?.oaId ?? "", zaloAccessToken: zaloConfig?.accessToken ?? "", zaloSecretKey: zaloConfig?.secretKey ?? "", + discordGuildId: discordConfig?.guildId ?? "", + discordGuildName: discordConfig?.guildName ?? "", + discordBotToken: discordConfig?.botToken ?? "", + messengerPageId: messengerConfig?.pageId ?? "", + messengerPageName: messengerConfig?.pageName ?? "", + messengerPageAccessToken: messengerConfig?.pageAccessToken ?? "", + messengerWebhookVerifyToken: messengerConfig?.webhookVerifyToken ?? "", + messengerAppSecret: messengerConfig?.appSecret ?? "", emailAddress: emailConfig?.emailAddress || "help@crove.com", senderName: emailConfig?.senderName || "Crove Desk Support", emailProvider: emailConfig?.provider || "brevo", @@ -436,6 +528,21 @@ function buildPayload(form: EditForm, status: number, t: Translate): CreateAdmin secretKey: form.zaloSecretKey.trim(), webhookSecret: form.webhookSecret.trim(), }) + : channelType === "discord" + ? JSON.stringify({ + guildId: form.discordGuildId.trim(), + guildName: form.discordGuildName.trim(), + botToken: form.discordBotToken.trim(), + webhookSecret: form.webhookSecret.trim(), + }) + : channelType === "messenger" + ? JSON.stringify({ + pageId: form.messengerPageId.trim(), + pageName: form.messengerPageName.trim(), + pageAccessToken: form.messengerPageAccessToken.trim(), + webhookVerifyToken: form.messengerWebhookVerifyToken.trim(), + appSecret: form.messengerAppSecret.trim(), + }) : channelType === "wechat_mp" ? JSON.stringify(webLikeConfig) : JSON.stringify({ @@ -644,6 +751,8 @@ function ChannelFormBody({ const channelTypeOptions = [ { value: "web", label: t("channel.typeWeb") }, { value: "email", label: t("channel.typeEmail") }, + { value: "discord", label: t("channel.typeDiscord") }, + { value: "messenger", label: t("channel.typeMessenger") }, { value: "telegram", label: t("channel.typeTelegram") }, { value: "zalo_oa", label: t("channel.typeZaloOa") }, { value: "wechat_mp", label: t("channel.typeWechatMp") }, @@ -1085,6 +1194,136 @@ function ChannelFormBody({
) : null} + {channelType === "discord" ? ( +
+
+
{t("channel.discordConnectTitle")}
+
{t("channel.discordConnectDescription")}
+
+ +
+
+ {t("channel.inboundWebhookUrl")}: /api/third/discord/webhook +
+
+ +
+ + {t("channel.discordGuildId")} + + + + + + + + {t("channel.discordGuildName")} + + + + + +
+ + + {t("channel.discordBotToken")} + + + + + +
+ ) : null} + + {channelType === "messenger" ? ( +
+
+
{t("channel.messengerConnectTitle")}
+
{t("channel.messengerConnectDescription")}
+
+ +
+
+ {t("channel.inboundWebhookUrl")}: /api/third/messenger/webhook +
+
+ +
+ + {t("channel.messengerPageId")} + + + + + + + + {t("channel.messengerPageName")} + + + + + +
+ + + {t("channel.messengerPageAccessToken")} + + + + + +
+ ) : null} + {channelType === "wxwork_kf" ? ( {t("channel.wxworkAccount")} @@ -1254,11 +1493,7 @@ function ChannelFormBody({ function WebAccessGuide({ channelId }: { channelId: string }) { const t = useI18n() - const [origin, setOrigin] = useState("") - - useEffect(() => { - setOrigin(window.location.origin) - }, []) + const [origin] = useState(() => (typeof window !== "undefined" ? window.location.origin : "")) const accessUrl = useMemo(() => { if (!origin || !channelId) { @@ -1392,11 +1627,7 @@ function WebAccessGuide({ channelId }: { channelId: string }) { function WechatMPAccessGuide({ channelId }: { channelId: string }) { const t = useI18n() - const [origin, setOrigin] = useState("") - - useEffect(() => { - setOrigin(window.location.origin) - }, []) + const [origin] = useState(() => (typeof window !== "undefined" ? window.location.origin : "")) const menuUrl = useMemo(() => { if (!origin || !channelId) { diff --git a/web/app/(dashboard)/dashboard/channels/page.tsx b/web/app/(dashboard)/dashboard/channels/page.tsx index 9f1b657c..f7eceac2 100644 --- a/web/app/(dashboard)/dashboard/channels/page.tsx +++ b/web/app/(dashboard)/dashboard/channels/page.tsx @@ -2,7 +2,9 @@ import { Building2Icon, + Gamepad2Icon, MailIcon, + MessageCircleIcon, MessagesSquareIcon, MessageSquareMoreIcon, SendIcon, @@ -31,6 +33,12 @@ function getChannelTypeLabel(channelType: string, t: (key: string) => string) { if (channelType === "email") { return t("channel.typeEmail") } + if (channelType === "discord") { + return t("channel.typeDiscord") + } + if (channelType === "messenger") { + return t("channel.typeMessenger") + } if (channelType === "wechat_mp") { return t("channel.typeWechatMp") } @@ -60,6 +68,12 @@ function ChannelIcon({ channelType }: { channelType: string }) { if (channelType === "email") { return } + if (channelType === "discord") { + return + } + if (channelType === "messenger") { + return + } if (channelType === "wechat_mp") { return } @@ -85,6 +99,8 @@ export default function DashboardChannelsPage() { { value: "all", label: t("channel.allTypes") }, { value: "web", label: t("channel.typeWeb") }, { value: "email", label: t("channel.typeEmail") }, + { value: "discord", label: t("channel.typeDiscord") }, + { value: "messenger", label: t("channel.typeMessenger") }, { 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 fa8a008c..6b81c755 100644 --- a/web/lib/generated/enums.ts +++ b/web/lib/generated/enums.ts @@ -81,6 +81,8 @@ export enum ExternalSource { Telegram = "telegram", ZaloOA = "zalo_oa", Email = "email", + Discord = "discord", + Messenger = "messenger", } export const ExternalSourceLabels: Record = { [ExternalSource.Guest]: "访客", @@ -90,6 +92,8 @@ export const ExternalSourceLabels: Record = { [ExternalSource.Telegram]: "Telegram", [ExternalSource.ZaloOA]: "Zalo OA", [ExternalSource.Email]: "Email", + [ExternalSource.Discord]: "Discord", + [ExternalSource.Messenger]: "Messenger", } export enum Gender { diff --git a/web/messages/en-US.json b/web/messages/en-US.json index ac1f509f..5b24d833 100644 --- a/web/messages/en-US.json +++ b/web/messages/en-US.json @@ -617,6 +617,8 @@ "typeEmail": "Email Support", "typeTelegram": "Telegram Bot", "typeZaloOa": "Zalo Official Account", + "typeDiscord": "Discord Community", + "typeMessenger": "Facebook Messenger", "typeWechatMp": "WeChat Official Account", "typeWxworkKf": "WeCom Customer Service", "emailAddress": "Support Email Address", @@ -646,6 +648,20 @@ "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.", + "discordConnectTitle": "1-Click Discord Bot Connection", + "discordConnectDescription": "Connect your Discord community to Crove Desk. Inbound messages from channels and DMs will route directly to your agent inbox and AI.", + "connectDiscordButton": "Connect Discord Server", + "discordGuildId": "Discord Guild / Server ID", + "discordGuildName": "Server Name", + "discordBotToken": "Bot Token (Enterprise Custom Bot)", + "messengerConnectTitle": "1-Click Facebook Messenger Connection", + "messengerConnectDescription": "Connect your Meta Facebook Page to Crove Desk. Inbound messages will be automatically synchronized with your agent workbench and AI agent.", + "connectMessengerButton": "Connect Facebook Page", + "messengerPageId": "Facebook Page ID", + "messengerPageName": "Fanpage Name", + "messengerPageAccessToken": "Page Access Token", + "messengerWebhookVerifyToken": "Webhook Verify Token", + "messengerAppSecret": "App Secret (Enterprise Custom App)", "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 8ab44c45..a6669143 100644 --- a/web/messages/vi-VN.json +++ b/web/messages/vi-VN.json @@ -624,6 +624,8 @@ "typeEmail": "Kênh Email Hỗ trợ", "typeTelegram": "Telegram Bot", "typeZaloOa": "Zalo Official Account", + "typeDiscord": "Cộng đồng Discord", + "typeMessenger": "Facebook Messenger", "typeWechatMp": "WeChat Official Account", "typeWxworkKf": "WeCom Customer Service", "emailAddress": "Địa chỉ Email Hỗ trợ", @@ -653,6 +655,20 @@ "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.", + "discordConnectTitle": "Kết nối Discord Bot 1-Click", + "discordConnectDescription": "Kết nối máy chủ Discord của bạn với Crove Desk chỉ bằng 1 thao tác ủy quyền. Mọi tin nhắn từ server và DM sẽ được đồng bộ vào Workbench và AI Agent.", + "connectDiscordButton": "Kết nối Discord Server", + "discordGuildId": "Guild / Server ID", + "discordGuildName": "Tên Máy chủ Discord", + "discordBotToken": "Bot Token (Dành cho Custom Bot Doanh nghiệp)", + "messengerConnectTitle": "Kết nối Facebook Messenger 1-Click", + "messengerConnectDescription": "Kết nối Fanpage Facebook của bạn với Crove Desk chỉ bằng 1 chạm. Tin nhắn từ khách hàng sẽ được đồng bộ tự động vào luồng hội thoại và AI Agent.", + "connectMessengerButton": "Kết nối Facebook Page", + "messengerPageId": "Facebook Page ID", + "messengerPageName": "Tên Fanpage", + "messengerPageAccessToken": "Page Access Token", + "messengerWebhookVerifyToken": "Mã xác thực Webhook (Verify Token)", + "messengerAppSecret": "Meta App Secret (Dành cho Custom App Doanh nghiệp)", "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 9db9a45f..a2777a65 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -617,6 +617,8 @@ "typeEmail": "邮件客服", "typeTelegram": "Telegram Bot", "typeZaloOa": "Zalo 公众号", + "typeDiscord": "Discord 社区", + "typeMessenger": "Facebook Messenger", "typeWechatMp": "微信公众号", "typeWxworkKf": "企业微信客服", "emailAddress": "支持邮箱地址", @@ -646,6 +648,20 @@ "zaloAppId": "App ID", "zaloAutoConnectTitle": "Zalo OA 渠道连接", "zaloAutoConnectDescription": "输入 Zalo OA 的 Access Token 即可自动双向同步客户会话与消息。", + "discordConnectTitle": "Discord Bot 一键授权连接", + "discordConnectDescription": "一键将 Crove Desk 机器人添加至您的 Discord 服务器,社区与私信对话将直接接入工作台与 AI Agent。", + "connectDiscordButton": "一键连接 Discord 服务器", + "discordGuildId": "Discord 服务器 ID (Guild ID)", + "discordGuildName": "服务器名称", + "discordBotToken": "Bot Token (企业独立应用)", + "messengerConnectTitle": "Facebook Messenger 一键授权连接", + "messengerConnectDescription": "一键连接您的 Facebook 主页,客户消息将自动同步至客服工作台并触发 AI 回复。", + "connectMessengerButton": "一键连接 Facebook Page", + "messengerPageId": "Facebook Page ID", + "messengerPageName": "主页名称", + "messengerPageAccessToken": "Page Access Token", + "messengerWebhookVerifyToken": "Webhook 校验 Token (Verify Token)", + "messengerAppSecret": "Meta App Secret (企业独立应用)", "loadFailed": "加载接入渠道失败", "created": "已创建接入渠道:{name}", "updated": "已更新接入渠道:{name}", From ed1788bcf8302d076ff6bcadbccd5b5479862c07 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:15:35 +0700 Subject: [PATCH 03/30] feat(config): add discord and facebook messenger environment variables and system config support --- .env.example | 11 +++++++ .../dashboard/channel_oauth_handler.go | 20 +++++++++++-- internal/pkg/config/config.go | 29 +++++++++++++++++++ internal/pkg/config/config_test.go | 20 +++++++++++++ internal/services/discord_outbound_service.go | 21 ++++++++++++-- .../services/messenger_inbound_service.go | 22 ++++++++++++-- 6 files changed, 117 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 33b9ee0f..297cfc80 100644 --- a/.env.example +++ b/.env.example @@ -92,3 +92,14 @@ BREVO_API_KEY=xkeysib-your-brevo-api-key # MCP_ENABLED=true # MCP_CRM_ENDPOINT=https://crm.crove.com/api/mcp # MCP_CRM_API_KEY=your-twenty-crm-api-key + +# Discord Channel & Bot Integration (SaaS Shared Bot or 1-Click OAuth) +# DISCORD_CLIENT_ID=your-discord-client-id +# DISCORD_CLIENT_SECRET=your-discord-client-secret +# DISCORD_BOT_TOKEN=your-discord-bot-token +# DISCORD_PUBLIC_KEY=your-discord-public-key + +# Facebook Messenger Channel Integration (Meta Graph API) +# META_APP_ID=your-meta-app-id +# META_APP_SECRET=your-meta-app-secret +# MESSENGER_VERIFY_TOKEN=your-webhook-verify-token diff --git a/internal/handlers/dashboard/channel_oauth_handler.go b/internal/handlers/dashboard/channel_oauth_handler.go index 1583855f..3c7e7b6f 100644 --- a/internal/handlers/dashboard/channel_oauth_handler.go +++ b/internal/handlers/dashboard/channel_oauth_handler.go @@ -6,6 +6,7 @@ import ( "os" "strings" + "agent-desk/internal/pkg/config" "agent-desk/internal/pkg/constants" "agent-desk/internal/pkg/httpx" "agent-desk/internal/services" @@ -21,7 +22,13 @@ func ChannelGetDiscordOAuthURL(ctx *gin.Context) { return } - clientID := strings.TrimSpace(os.Getenv("DISCORD_CLIENT_ID")) + clientID := "" + if cfg := config.GetCurrent(); cfg != nil { + clientID = strings.TrimSpace(cfg.Discord.ClientID) + } + if clientID == "" { + clientID = strings.TrimSpace(os.Getenv("DISCORD_CLIENT_ID")) + } if clientID == "" { clientID = strings.TrimSpace(ctx.Query("client_id")) } @@ -58,7 +65,16 @@ func ChannelGetMessengerOAuthURL(ctx *gin.Context) { return } - appID := strings.TrimSpace(os.Getenv("META_APP_ID")) + appID := "" + if cfg := config.GetCurrent(); cfg != nil { + appID = strings.TrimSpace(cfg.Messenger.AppID) + } + if appID == "" { + appID = strings.TrimSpace(os.Getenv("META_APP_ID")) + } + if appID == "" { + appID = strings.TrimSpace(os.Getenv("FB_APP_ID")) + } if appID == "" { appID = strings.TrimSpace(ctx.Query("app_id")) } diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 278d19a7..1ab5a89b 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -28,6 +28,8 @@ type Config struct { CustomerSession CustomerSessionConfig `yaml:"customerSession"` Webhook WebhookConfig `yaml:"webhook"` Email EmailConfig `yaml:"email"` + Discord DiscordConfig `yaml:"discord"` + Messenger MessengerConfig `yaml:"messenger"` } func (c Config) LanguageOrDefault() string { @@ -273,6 +275,19 @@ type EmailConfig struct { InboundSecret string `yaml:"inboundSecret"` } +type DiscordConfig struct { + ClientID string `yaml:"clientId"` + ClientSecret string `yaml:"clientSecret"` + BotToken string `yaml:"botToken"` + PublicKey string `yaml:"publicKey"` +} + +type MessengerConfig struct { + AppID string `yaml:"appId"` + AppSecret string `yaml:"appSecret"` + VerifyToken string `yaml:"verifyToken"` +} + func Load(path string) (*Config, error) { loadDotEnv(path) @@ -375,6 +390,13 @@ func bindConfigDefaults(v *viper.Viper) { v.SetDefault("email.smtpPassword", "") v.SetDefault("email.smtpUseTls", false) v.SetDefault("email.inboundSecret", "") + v.SetDefault("discord.clientId", "") + v.SetDefault("discord.clientSecret", "") + v.SetDefault("discord.botToken", "") + v.SetDefault("discord.publicKey", "") + v.SetDefault("messenger.appId", "") + v.SetDefault("messenger.appSecret", "") + v.SetDefault("messenger.verifyToken", "") } func bindEnvironmentAliases(v *viper.Viper) { @@ -422,6 +444,13 @@ func bindEnvironmentAliases(v *viper.Viper) { _ = 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") + _ = v.BindEnv("discord.clientId", "DISCORD_CLIENT_ID", "AGENT_DESK_DISCORD_CLIENTID") + _ = v.BindEnv("discord.clientSecret", "DISCORD_CLIENT_SECRET", "AGENT_DESK_DISCORD_CLIENTSECRET") + _ = v.BindEnv("discord.botToken", "DISCORD_BOT_TOKEN", "AGENT_DESK_DISCORD_BOTTOKEN") + _ = v.BindEnv("discord.publicKey", "DISCORD_PUBLIC_KEY", "AGENT_DESK_DISCORD_PUBLICKEY") + _ = v.BindEnv("messenger.appId", "META_APP_ID", "FB_APP_ID", "MESSENGER_APP_ID", "AGENT_DESK_MESSENGER_APPID") + _ = v.BindEnv("messenger.appSecret", "META_APP_SECRET", "FB_APP_SECRET", "MESSENGER_APP_SECRET", "AGENT_DESK_MESSENGER_APPSECRET") + _ = v.BindEnv("messenger.verifyToken", "MESSENGER_VERIFY_TOKEN", "META_VERIFY_TOKEN", "FB_VERIFY_TOKEN", "AGENT_DESK_MESSENGER_VERIFYTOKEN") } func normalizeLoadedConfig(cfg *Config) { diff --git a/internal/pkg/config/config_test.go b/internal/pkg/config/config_test.go index b25f759a..d5b33206 100644 --- a/internal/pkg/config/config_test.go +++ b/internal/pkg/config/config_test.go @@ -108,6 +108,11 @@ EMAIL_FROM=help@example.com EMAIL_FROM_NAME=Helpdesk Team BREVO_API_KEY=xkeysib-test-123 EMAIL_INBOUND_SECRET=inbound-secret-456 +DISCORD_CLIENT_ID=discord-app-123 +DISCORD_BOT_TOKEN=discord-bot-token-xyz +META_APP_ID=meta-app-999 +META_APP_SECRET=meta-app-secret-888 +MESSENGER_VERIFY_TOKEN=meta-verify-token-777 `) if err := os.WriteFile(envPath, envContent, 0600); err != nil { t.Fatalf("WriteFile() error = %v", err) @@ -195,4 +200,19 @@ EMAIL_INBOUND_SECRET=inbound-secret-456 if cfg.Email.InboundSecret != "inbound-secret-456" { t.Fatalf("Email.InboundSecret=%q want inbound-secret-456", cfg.Email.InboundSecret) } + if cfg.Discord.ClientID != "discord-app-123" { + t.Fatalf("Discord.ClientID=%q want discord-app-123", cfg.Discord.ClientID) + } + if cfg.Discord.BotToken != "discord-bot-token-xyz" { + t.Fatalf("Discord.BotToken=%q want discord-bot-token-xyz", cfg.Discord.BotToken) + } + if cfg.Messenger.AppID != "meta-app-999" { + t.Fatalf("Messenger.AppID=%q want meta-app-999", cfg.Messenger.AppID) + } + if cfg.Messenger.AppSecret != "meta-app-secret-888" { + t.Fatalf("Messenger.AppSecret=%q want meta-app-secret-888", cfg.Messenger.AppSecret) + } + if cfg.Messenger.VerifyToken != "meta-verify-token-777" { + t.Fatalf("Messenger.VerifyToken=%q want meta-verify-token-777", cfg.Messenger.VerifyToken) + } } diff --git a/internal/services/discord_outbound_service.go b/internal/services/discord_outbound_service.go index 952d7db9..9adf1f5b 100644 --- a/internal/services/discord_outbound_service.go +++ b/internal/services/discord_outbound_service.go @@ -9,9 +9,11 @@ import ( "agent-desk/internal/discord" "agent-desk/internal/models" + "agent-desk/internal/pkg/config" "agent-desk/internal/pkg/enums" "agent-desk/internal/repositories" "agent-desk/internal/services/storage" + "os" "github.com/mlogclub/simple/sqls" ) @@ -92,7 +94,22 @@ func (s *discordOutboundService) processOutbox(outboxID int64) error { return s.markOutboxFailed(outbox, "discord channel not found or disabled") } cfg, err := ChannelService.ParseDiscordChannelConfig(channel.ConfigJSON) - if err != nil || cfg == nil || cfg.BotToken == "" { + if err != nil { + return s.markOutboxFailed(outbox, "invalid discord channel config") + } + botToken := "" + if cfg != nil { + botToken = strings.TrimSpace(cfg.BotToken) + } + if botToken == "" { + if serverCfg := config.GetCurrent(); serverCfg != nil { + botToken = strings.TrimSpace(serverCfg.Discord.BotToken) + } + } + if botToken == "" { + botToken = strings.TrimSpace(os.Getenv("DISCORD_BOT_TOKEN")) + } + if botToken == "" { return s.markOutboxFailed(outbox, "discord bot token not configured") } @@ -120,7 +137,7 @@ func (s *discordOutboundService) processOutbox(outboxID int64) error { } } - client := discord.NewClient(cfg.BotToken) + client := discord.NewClient(botToken) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() diff --git a/internal/services/messenger_inbound_service.go b/internal/services/messenger_inbound_service.go index 9ee99502..b5e31036 100644 --- a/internal/services/messenger_inbound_service.go +++ b/internal/services/messenger_inbound_service.go @@ -11,9 +11,11 @@ import ( "agent-desk/internal/messenger" "agent-desk/internal/models" + "agent-desk/internal/pkg/config" "agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/errorsx" "agent-desk/internal/pkg/openidentity" + "os" ) var MessengerInboundService = newMessengerInboundService() @@ -61,8 +63,24 @@ func (s *messengerInboundService) HandleWebhook(ctx context.Context, channelID s } // Optional signature verification if appSecret is configured - if cfg.AppSecret != "" && strings.TrimSpace(signatureHeader) != "" { - if !verifyMessengerSignature(cfg.AppSecret, signatureHeader, rawPayload) { + appSecret := "" + if cfg != nil { + appSecret = strings.TrimSpace(cfg.AppSecret) + } + if appSecret == "" { + if serverCfg := config.GetCurrent(); serverCfg != nil { + appSecret = strings.TrimSpace(serverCfg.Messenger.AppSecret) + } + } + if appSecret == "" { + appSecret = strings.TrimSpace(os.Getenv("META_APP_SECRET")) + } + if appSecret == "" { + appSecret = strings.TrimSpace(os.Getenv("FB_APP_SECRET")) + } + + if appSecret != "" && strings.TrimSpace(signatureHeader) != "" { + if !verifyMessengerSignature(appSecret, signatureHeader, rawPayload) { return errorsx.UnauthorizedI18n("error.auth.invalidSignature") } } From 58d52ea1048905ac66613329c0289c8c9c3f0ad4 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:28:54 +0700 Subject: [PATCH 04/30] feat: auto-provision 1-to-1 agent profile and simplify email channel forwarding setup --- .../000011_auto_provision_agent_profiles.go | 90 ++++++ internal/services/agent_profile_service.go | 110 +++++++- internal/services/oidc_login_service.go | 1 + internal/services/user_service.go | 6 +- internal/services/webhook_sync_service.go | 1 + .../dashboard/channels/_components/edit.tsx | 265 ++++++++++-------- web/messages/en-US.json | 8 +- web/messages/vi-VN.json | 10 +- web/messages/zh-CN.json | 8 +- 9 files changed, 369 insertions(+), 130 deletions(-) create mode 100644 internal/migration/000011_auto_provision_agent_profiles.go diff --git a/internal/migration/000011_auto_provision_agent_profiles.go b/internal/migration/000011_auto_provision_agent_profiles.go new file mode 100644 index 00000000..6f163b58 --- /dev/null +++ b/internal/migration/000011_auto_provision_agent_profiles.go @@ -0,0 +1,90 @@ +package migration + +import ( + "fmt" + "strings" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + + "github.com/mlogclub/simple/sqls" +) + +func init() { + register(11, "auto provision default agent team and agent profiles", func() error { + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + team := repositories.AgentTeamRepository.FindOne(ctx.Tx, sqls.NewCnd().Eq("status", enums.StatusOk).Asc("id")) + if team == nil || team.ID <= 0 { + team = &models.AgentTeam{ + Name: "Support Team", + Status: enums.StatusOk, + Description: "Default Support Team", + AuditFields: models.AuditFields{ + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + CreateUserName: "migration", + UpdateUserName: "migration", + }, + } + if err := repositories.AgentTeamRepository.Create(ctx.Tx, team); err != nil { + return err + } + } + + var users []models.User + if err := ctx.Tx.Where("deleted_at IS NULL").Find(&users).Error; err != nil { + return err + } + + for _, user := range users { + existing := repositories.AgentProfileRepository.FindOne(ctx.Tx, sqls.NewCnd().Eq("user_id", user.ID)) + if existing != nil && existing.ID > 0 { + continue + } + + displayName := strings.TrimSpace(user.Nickname) + if displayName == "" { + displayName = strings.TrimSpace(user.Username) + } + if displayName == "" { + displayName = fmt.Sprintf("Agent #%d", user.ID) + } + + agentCode := fmt.Sprintf("A%04d", user.ID) + if codeExist := repositories.AgentProfileRepository.FindOne(ctx.Tx, sqls.NewCnd().Eq("agent_code", agentCode)); codeExist != nil { + agentCode = fmt.Sprintf("A%d%d", user.ID, time.Now().Unix()%1000) + } + + profile := &models.AgentProfile{ + UserID: user.ID, + TeamID: team.ID, + AgentCode: agentCode, + DisplayName: displayName, + Avatar: strings.TrimSpace(user.Avatar), + ServiceStatus: enums.ServiceStatusIdle, + MaxConcurrentCount: 5, + PriorityLevel: 0, + AutoAssignEnabled: true, + ReceiveOfflineMessage: false, + Status: enums.StatusOk, + AuditFields: models.AuditFields{ + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + CreateUserID: user.ID, + CreateUserName: user.Username, + UpdateUserID: user.ID, + UpdateUserName: user.Username, + }, + } + + if err := repositories.AgentProfileRepository.Create(ctx.Tx, profile); err != nil { + return err + } + } + + return nil + }) + }) +} diff --git a/internal/services/agent_profile_service.go b/internal/services/agent_profile_service.go index fedca57f..11e3ad1f 100644 --- a/internal/services/agent_profile_service.go +++ b/internal/services/agent_profile_service.go @@ -1,19 +1,21 @@ package services import ( + "fmt" + "strings" + "time" + "agent-desk/internal/models" "agent-desk/internal/pkg/dto" "agent-desk/internal/pkg/dto/request" "agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/httpx/params" "agent-desk/internal/pkg/utils" "agent-desk/internal/repositories" - "strings" - "time" - - "agent-desk/internal/pkg/httpx/params" "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" ) var AgentProfileService = newAgentProfileService() @@ -57,7 +59,105 @@ func (s *agentProfileService) GetByUserID(userID int64) *models.AgentProfile { if userID <= 0 { return nil } - return repositories.AgentProfileRepository.FindOne(sqls.DB(), sqls.NewCnd().Eq("user_id", userID)) + profile := repositories.AgentProfileRepository.FindOne(sqls.DB(), sqls.NewCnd().Eq("user_id", userID)) + if profile != nil { + return profile + } + // Self-healing native JIT: If user exists in DB, automatically provision AgentProfile + if user := repositories.UserRepository.Get(sqls.DB(), userID); user != nil && user.ID > 0 { + if newProfile, err := s.EnsureAgentProfileForUser(sqls.DB(), user); err == nil && newProfile != nil { + return newProfile + } + } + return nil +} + +// EnsureDefaultAgentTeam checks if any active agent team exists, creating a default one if not. +func (s *agentProfileService) EnsureDefaultAgentTeam(db *gorm.DB) (*models.AgentTeam, error) { + if db == nil { + db = sqls.DB() + } + existing := repositories.AgentTeamRepository.FindOne(db, sqls.NewCnd().Eq("status", enums.StatusOk).Asc("id")) + if existing != nil && existing.ID > 0 { + return existing, nil + } + + team := &models.AgentTeam{ + Name: "Support Team", + Status: enums.StatusOk, + Description: "Default Support Team", + AuditFields: models.AuditFields{ + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + CreateUserName: "system", + UpdateUserName: "system", + }, + } + if err := repositories.AgentTeamRepository.Create(db, team); err != nil { + return nil, err + } + return team, nil +} + +// EnsureAgentProfileForUser ensures a 1-to-1 AgentProfile exists for the given user. +func (s *agentProfileService) EnsureAgentProfileForUser(db *gorm.DB, user *models.User) (*models.AgentProfile, error) { + if user == nil || user.ID <= 0 { + return nil, errorsx.InvalidParamI18n("error.e0325") + } + if db == nil { + db = sqls.DB() + } + + existing := repositories.AgentProfileRepository.FindOne(db, sqls.NewCnd().Eq("user_id", user.ID)) + if existing != nil && existing.ID > 0 { + return existing, nil + } + + team, err := s.EnsureDefaultAgentTeam(db) + if err != nil { + return nil, err + } + + displayName := strings.TrimSpace(user.Nickname) + if displayName == "" { + displayName = strings.TrimSpace(user.Username) + } + if displayName == "" { + displayName = fmt.Sprintf("Agent #%d", user.ID) + } + + agentCode := fmt.Sprintf("A%04d", user.ID) + if codeExist := repositories.AgentProfileRepository.FindOne(db, sqls.NewCnd().Eq("agent_code", agentCode)); codeExist != nil { + agentCode = fmt.Sprintf("A%d%d", user.ID, time.Now().Unix()%1000) + } + + profile := &models.AgentProfile{ + UserID: user.ID, + TeamID: team.ID, + AgentCode: agentCode, + DisplayName: displayName, + Avatar: strings.TrimSpace(user.Avatar), + ServiceStatus: enums.ServiceStatusIdle, + MaxConcurrentCount: 5, + PriorityLevel: 0, + AutoAssignEnabled: true, + ReceiveOfflineMessage: false, + Status: enums.StatusOk, + AuditFields: models.AuditFields{ + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + CreateUserID: user.ID, + CreateUserName: user.Username, + UpdateUserID: user.ID, + UpdateUserName: user.Username, + }, + } + + if err := repositories.AgentProfileRepository.Create(db, profile); err != nil { + return nil, err + } + s.dispatchPendingConversationsIfEligible(profile) + return profile, nil } func (s *agentProfileService) GetUserIDsByTeamID(teamID int64) []int64 { diff --git a/internal/services/oidc_login_service.go b/internal/services/oidc_login_service.go index fcb88080..2e1de8f4 100644 --- a/internal/services/oidc_login_service.go +++ b/internal/services/oidc_login_service.go @@ -112,6 +112,7 @@ func (s *oidcLoginService) loginWithOIDCProfile(profile *oidcLoginProfile, authC s.ensureDefaultOIDCRole(ctx.Tx, user) s.syncOIDCUserOrganizations(ctx.Tx, user, profile) + _, _ = AgentProfileService.EnsureAgentProfileForUser(ctx.Tx, user) if err = repositories.UserIdentityRepository.Updates(ctx.Tx, identity.ID, map[string]any{ "provider_name": enums.GetThirdProviderLabel(enums.ThirdProviderOIDC), diff --git a/internal/services/user_service.go b/internal/services/user_service.go index c0c2f25d..d445edbf 100644 --- a/internal/services/user_service.go +++ b/internal/services/user_service.go @@ -136,7 +136,11 @@ func (s *userService) CreateUser(req request.CreateUserRequest, operator *dto.Au if err := repositories.UserRepository.Create(ctx.Tx, user); err != nil { return err } - return s.replaceUserRolesDB(ctx.Tx, user.ID, req.RoleIDs, operator) + if err := s.replaceUserRolesDB(ctx.Tx, user.ID, req.RoleIDs, operator); err != nil { + return err + } + _, _ = AgentProfileService.EnsureAgentProfileForUser(ctx.Tx, user) + return nil }) if err != nil { return nil, "", err diff --git a/internal/services/webhook_sync_service.go b/internal/services/webhook_sync_service.go index 8eaf4ae0..ad823834 100644 --- a/internal/services/webhook_sync_service.go +++ b/internal/services/webhook_sync_service.go @@ -373,6 +373,7 @@ func (s *webhookSyncService) handleMemberUpsert(data request.OrgSyncEventData) e }, }) } + _, _ = AgentProfileService.EnsureAgentProfileForUser(ctx.Tx, user) } member := repositories.OrganizationMemberRepository.GetByOrgAndUser(ctx.Tx, org.ID, user.ID) diff --git a/web/app/(dashboard)/dashboard/channels/_components/edit.tsx b/web/app/(dashboard)/dashboard/channels/_components/edit.tsx index 187d1618..fc21c6ec 100644 --- a/web/app/(dashboard)/dashboard/channels/_components/edit.tsx +++ b/web/app/(dashboard)/dashboard/channels/_components/edit.tsx @@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from "react" import { zodResolver } from "@hookform/resolvers/zod" import { Controller, Resolver, useForm, useWatch } from "react-hook-form" import { z } from "zod/v4" -import { CopyIcon, ExternalLinkIcon, RotateCcwIcon } from "lucide-react" +import { CopyIcon, ExternalLinkIcon, RotateCcwIcon, ChevronDownIcon, ChevronRightIcon } from "lucide-react" import { toast } from "sonner" import { getWidgetDemoPath } from "@/components/support-chat/demo-navigation" @@ -30,6 +30,7 @@ import { rollbackChannelAIAgentRollout, resetChannelUserTokenSecret, } from "@/lib/api/admin" +import { listMyOrganizations } from "@/lib/api/organization" import { useI18n } from "@/i18n/provider" type ChannelFormDialogProps = { @@ -641,15 +642,34 @@ function ChannelFormBody({ const emailAddressValue = useWatch({ control, name: "emailAddress" }) const nameValue = useWatch({ control, name: "name" }) const previousRolloutPercent = channelDetail?.previousAiAgentRolloutPercent ?? 0 + const [orgSlug, setOrgSlug] = useState("org") + const [showAdvancedDelivery, setShowAdvancedDelivery] = useState(false) + + useEffect(() => { + async function loadOrg() { + try { + const res = await listMyOrganizations() + const active = + res.organizations.find((o) => o.id === res.currentOrganizationId) || + res.organizations[0] + if (active?.code) { + setOrgSlug(active.code.toLowerCase()) + } + } catch { + // fallback to default org + } + } + void loadOrg() + }, []) 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]) + const slug = (orgSlug || "org").trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") + return `help@${slug}.crove.io` + }, [emailAddressValue, orgSlug]) async function rollbackRolloutPercent() { if (!channelDetail || previousRolloutPercent < 1) return @@ -960,117 +980,13 @@ function ChannelFormBody({ -
- - {t("channel.emailProvider")} - - - - - - - - {t("channel.webhookSecret")} - - - - - -
- - {emailProvider === "brevo" || emailProvider === "sendgrid" || emailProvider === "resend" || emailProvider === "postmark" || emailProvider === "mailgun" ? ( - - {t("channel.emailApiKey")} - - - - - - ) : emailProvider === "smtp" ? ( -
-
-
- - SMTP Host - - - - - -
- - SMTP Port - - - - - -
-
- - SMTP Username - - - - - - - SMTP Password - - - - - -
-
- ) : null} - -
+
{t("channel.emailAutoConnectTitle")}
{t("channel.emailAutoConnectDescription")}
-
+
{t("channel.forwardingAddressLabel")}
- + {forwardingAddressPreview}
-
- {t("channel.inboundWebhookUrl")}: /api/third/email/webhook -
+
+ +
+ + + {showAdvancedDelivery && ( +
+

{t("channel.customDeliveryDescription")}

+
+ + {t("channel.emailProvider")} + + + + + + + + {t("channel.webhookSecret")} + + + + + +
+ + {emailProvider === "brevo" || emailProvider === "sendgrid" || emailProvider === "resend" || emailProvider === "postmark" || emailProvider === "mailgun" ? ( + + {t("channel.emailApiKey")} + + + + + + ) : emailProvider === "smtp" ? ( +
+
+
+ + SMTP Host + + + + + +
+ + SMTP Port + + + + + +
+
+ + SMTP Username + + + + + + + SMTP Password + + + + + +
+
+ ) : null} +
+ )}
) : null} diff --git a/web/messages/en-US.json b/web/messages/en-US.json index 5b24d833..a8d3db3d 100644 --- a/web/messages/en-US.json +++ b/web/messages/en-US.json @@ -633,9 +633,11 @@ "emailProviderMailgun": "Mailgun API", "emailApiKey": "API Key", "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):", + "emailAutoConnectDescription": "Set up auto-forwarding in your email provider (e.g., Google Workspace, Microsoft 365, or cPanel) to forward all emails from your support address to this dedicated forwarding address.", + "customDeliveryToggle": "Custom Outbound SMTP / Delivery Settings (Optional)", + "customDeliveryDescription": "By default, Crove Desk delivers emails automatically using platform-managed infrastructure. You only need to configure custom settings below if you want to use your own SMTP or custom ESP.", + "configEmailDescription": "Connect your company email to receive and send customer conversations.", + "forwardingAddressLabel": "Dedicated Inbound Forwarding Address:", "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 a6669143..76db72bb 100644 --- a/web/messages/vi-VN.json +++ b/web/messages/vi-VN.json @@ -639,10 +639,12 @@ "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 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):", + "emailAutoConnectTitle": "Tự động Chuyển tiếp & Tiếp nhận Email", + "emailAutoConnectDescription": "Cấu hình tự động chuyển tiếp (Auto-forwarding) trên dịch vụ Email của bạn (Google Workspace, Microsoft 365, cPanel...) chuyển toàn bộ thư gửi đến hộp thư hỗ trợ sang địa chỉ chuyển tiếp bên dưới.", + "customDeliveryToggle": "Cấu hình Custom SMTP / Server gửi thư riêng (Tùy chọn nâng cao)", + "customDeliveryDescription": "Mặc định Crove Desk tự động gửi email phản hồi qua hạ tầng SaaS của hệ thống. Bạn chỉ cần bật cài đặt này nếu muốn gửi qua SMTP server riêng của doanh nghiệp.", + "configEmailDescription": "Kết nối email hỗ trợ của công ty để tiếp nhận và phản hồi hội thoại khách hàng.", + "forwardingAddressLabel": "Địa chỉ Chuyển tiếp Dành riêng (Inbound Forwarding Address):", "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 a2777a65..eb5d6a51 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -633,9 +633,11 @@ "emailProviderMailgun": "Mailgun API", "emailApiKey": "API Key", "emailAutoConnectTitle": "邮件客服自动接入", - "emailAutoConnectDescription": "将发送至支持邮箱的邮件通过 Webhook 转发至 Inbound Webhook 接口,自动创建工单并触发 AI Agent 回复。", - "configEmailDescription": "配置邮件 Inbound Webhook 接入与 SMTP / Brevo / SendGrid / Resend / Postmark / Mailgun 邮件发送。", - "forwardingAddressLabel": "自动转发专用地址(用于 Gmail / Outlook 邮件自动转发):", + "emailAutoConnectDescription": "在您的邮件服务商(Google Workspace、Microsoft 365 或企业邮箱)中配置自动转发规则,将发送至客服邮箱的邮件转发至专属地址即可接入。", + "customDeliveryToggle": "自定义 SMTP / 发信配置(高级选项)", + "customDeliveryDescription": "默认情况下,Crove Desk 使用系统平台托管通道自动发送邮件。仅当您需要使用企业自建 SMTP 或独立 API Key 时才需配置。", + "configEmailDescription": "连接企业支持邮箱,接收并回复客户邮件会话。", + "forwardingAddressLabel": "自动转发专用接收地址:", "inboundWebhookUrl": "Inbound Webhook 回调地址", "botToken": "Telegram Bot Token", "botTokenRequired": "请输入 Telegram Bot Token", From cea32a8f171aaf72fde810e9be3b9dc2eef5d244 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:37:13 +0700 Subject: [PATCH 05/30] feat(channel): simplify email channel setup to only support email address and auto-forwarding --- .../dashboard/channels/_components/edit.tsx | 142 +----------------- 1 file changed, 1 insertion(+), 141 deletions(-) diff --git a/web/app/(dashboard)/dashboard/channels/_components/edit.tsx b/web/app/(dashboard)/dashboard/channels/_components/edit.tsx index fc21c6ec..fa99e805 100644 --- a/web/app/(dashboard)/dashboard/channels/_components/edit.tsx +++ b/web/app/(dashboard)/dashboard/channels/_components/edit.tsx @@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from "react" import { zodResolver } from "@hookform/resolvers/zod" import { Controller, Resolver, useForm, useWatch } from "react-hook-form" import { z } from "zod/v4" -import { CopyIcon, ExternalLinkIcon, RotateCcwIcon, ChevronDownIcon, ChevronRightIcon } from "lucide-react" +import { CopyIcon, ExternalLinkIcon, RotateCcwIcon } from "lucide-react" import { toast } from "sonner" import { getWidgetDemoPath } from "@/components/support-chat/demo-navigation" @@ -313,13 +313,6 @@ function parseEmailChannelConfig(configJson: string): 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 {} @@ -507,13 +500,6 @@ function buildPayload(form: EditForm, status: number, t: Translate): CreateAdmin ? JSON.stringify({ 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 === "telegram" ? JSON.stringify({ @@ -638,12 +624,10 @@ 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 emailAddressValue = useWatch({ control, name: "emailAddress" }) const nameValue = useWatch({ control, name: "name" }) const previousRolloutPercent = channelDetail?.previousAiAgentRolloutPercent ?? 0 const [orgSlug, setOrgSlug] = useState("org") - const [showAdvancedDelivery, setShowAdvancedDelivery] = useState(false) useEffect(() => { async function loadOrg() { @@ -1008,130 +992,6 @@ function ChannelFormBody({
- -
- - - {showAdvancedDelivery && ( -
-

{t("channel.customDeliveryDescription")}

-
- - {t("channel.emailProvider")} - - - - - - - - {t("channel.webhookSecret")} - - - - - -
- - {emailProvider === "brevo" || emailProvider === "sendgrid" || emailProvider === "resend" || emailProvider === "postmark" || emailProvider === "mailgun" ? ( - - {t("channel.emailApiKey")} - - - - - - ) : emailProvider === "smtp" ? ( -
-
-
- - SMTP Host - - - - - -
- - SMTP Port - - - - - -
-
- - SMTP Username - - - - - - - SMTP Password - - - - - -
-
- ) : null} -
- )} -
) : null} From 36eb023b8ae5d4e4a1187fac34b9b2bef3134b43 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:05:42 +0700 Subject: [PATCH 06/30] feat(conversation): display channel icon, thread identifier, and subject in conversation header and sidebar --- internal/builders/conversation_builder.go | 10 +++ internal/models/models.go | 1 + .../pkg/dto/response/conversation_response.go | 3 + internal/services/email_inbound_service.go | 6 ++ internal/services/message_service.go | 9 ++- .../_components/conversation-info-panel.tsx | 35 +++++++-- .../_components/conversation-list.tsx | 74 +++++++++++-------- .../_components/conversation-workbench.tsx | 58 ++++++++++----- web/components/channel-icon.tsx | 35 +++++++++ web/lib/api/agent.ts | 3 + web/messages/en-US.json | 5 ++ web/messages/vi-VN.json | 7 +- web/messages/zh-CN.json | 5 ++ 13 files changed, 193 insertions(+), 58 deletions(-) create mode 100644 web/components/channel-icon.tsx diff --git a/internal/builders/conversation_builder.go b/internal/builders/conversation_builder.go index 0846aecc..31c0ac41 100644 --- a/internal/builders/conversation_builder.go +++ b/internal/builders/conversation_builder.go @@ -21,6 +21,7 @@ func BuildConversationWithLocale(item *models.Conversation, locale string) respo agentReadState, customerReadState := services.ConversationReadStateService.GetConversationReadStates(item.ID) ret := response.ConversationResponse{ ID: item.ID, + Title: item.Title, AIAgentID: item.AIAgentID, ChannelID: item.ChannelID, CustomerID: item.CustomerID, @@ -44,6 +45,15 @@ func BuildConversationWithLocale(item *models.Conversation, locale string) respo ClosedBy: item.ClosedBy, CloseReason: item.CloseReason, } + if ret.Title == "" && item.LastMessageSummary != "" { + ret.Title = item.LastMessageSummary + } + if item.ChannelID > 0 { + if channel := services.ChannelService.Get(item.ChannelID); channel != nil { + ret.ChannelType = channel.ChannelType + ret.ChannelName = channel.Name + } + } if identity := services.ConversationService.GetConversationExternalIdentity(item); identity != nil { ret.CustomerOnline = services.WsService.IsGuestOnline(identity.ExternalID) } diff --git a/internal/models/models.go b/internal/models/models.go index 80f3dd62..9ad1db53 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -389,6 +389,7 @@ type Tag struct { // Conversation 客服会话。 type Conversation struct { ID int64 `gorm:"primaryKey;autoIncrement"` // ID 为会话主键。 + Title string `gorm:"type:varchar(255);not null;default:'';index"` // Title 为会话标题/主题(如邮件 Subject 或会话摘要)。 AIAgentID int64 `gorm:"type:bigint;not null;default:0;index"` // AIAgentID 为当前会话绑定的 AI Agent ID。 ChannelID int64 `gorm:"type:bigint;not null;default:0;index"` // ChannelID 为该会话来源接入渠道ID。 CustomerID int64 `gorm:"type:bigint;not null;default:0;index"` // CustomerID 为会话所属客户 ID。 diff --git a/internal/pkg/dto/response/conversation_response.go b/internal/pkg/dto/response/conversation_response.go index 697dbfdf..4748d6ac 100644 --- a/internal/pkg/dto/response/conversation_response.go +++ b/internal/pkg/dto/response/conversation_response.go @@ -19,8 +19,11 @@ type ConversationParticipantResponse struct { type ConversationResponse struct { ID int64 `json:"id"` + Title string `json:"title,omitempty"` AIAgentID int64 `json:"aiAgentId"` ChannelID int64 `json:"channelId"` + ChannelType string `json:"channelType,omitempty"` + ChannelName string `json:"channelName,omitempty"` CustomerID int64 `json:"customerId"` CustomerName string `json:"customerName"` Status enums.IMConversationStatus `json:"status"` diff --git a/internal/services/email_inbound_service.go b/internal/services/email_inbound_service.go index e3546e46..55c1075e 100644 --- a/internal/services/email_inbound_service.go +++ b/internal/services/email_inbound_service.go @@ -146,6 +146,12 @@ func (s *emailInboundService) processInboundItem(ctx context.Context, channel *m } } + // Ensure conversation title is set from email subject if empty + if item.Subject != "" && conversation.Title == "" { + _ = repositories.ConversationRepository.UpdateColumn(sqls.DB(), conversation.ID, "title", strings.TrimSpace(item.Subject)) + conversation.Title = strings.TrimSpace(item.Subject) + } + // Ensure customer primary_email is populated if conversation.CustomerID > 0 { customer := repositories.CustomerRepository.Get(sqls.DB(), conversation.CustomerID) diff --git a/internal/services/message_service.go b/internal/services/message_service.go index 9b010ab6..79222305 100644 --- a/internal/services/message_service.go +++ b/internal/services/message_service.go @@ -494,7 +494,7 @@ func (s *messageService) sendValidatedMessage(conversation *models.Conversation, conversation.UpdatedAt = now conversation.AgentUnreadCount = int(agentUnreadCount) conversation.CustomerUnreadCount = int(customerUnreadCount) - if err := repositories.ConversationRepository.Updates(ctx.Tx, conversation.ID, map[string]any{ + updates := map[string]any{ "last_message_id": conversation.LastMessageID, "last_message_at": conversation.LastMessageAt, "last_active_at": conversation.LastActiveAt, @@ -504,7 +504,12 @@ func (s *messageService) sendValidatedMessage(conversation *models.Conversation, "updated_at": conversation.UpdatedAt, "agent_unread_count": conversation.AgentUnreadCount, "customer_unread_count": conversation.CustomerUnreadCount, - }); err != nil { + } + if conversation.Title == "" && summary != "" { + conversation.Title = limitText(summary, 255) + updates["title"] = conversation.Title + } + if err := repositories.ConversationRepository.Updates(ctx.Tx, conversation.ID, updates); err != nil { return err } 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 ca69e6ef..6948ba3d 100644 --- a/web/app/(dashboard)/dashboard/conversations/_components/conversation-info-panel.tsx +++ b/web/app/(dashboard)/dashboard/conversations/_components/conversation-info-panel.tsx @@ -16,6 +16,7 @@ import { toast } from "sonner"; import { type CustomerFormSavePayload } from "@/components/customer-form"; import { CustomerFormDialog } from "@/components/customer-form-dialog"; import { CustomerLinkOrCreateDialog } from "@/components/customer-link-or-create-dialog"; +import { ChannelIcon } from "@/components/channel-icon"; import { JsonTreeViewer } from "@/components/json-tree-viewer"; import { ProjectDialog } from "@/components/project-dialog"; import { Badge } from "@/components/ui/badge"; @@ -255,12 +256,34 @@ export function ConversationInfoPanel({

) : (
-
- +
+ {t("conversation.conversationAttributes")} +
+ + {conversation.title ? ( + + ) : null} +
+ {t("conversation.channel")} +
+ + {conversation.channelName || conversation.channelType || "—"} +
+
+ {conversation.currentAssigneeName ? ( + + ) : null} +
diff --git a/web/app/(dashboard)/dashboard/conversations/_components/conversation-list.tsx b/web/app/(dashboard)/dashboard/conversations/_components/conversation-list.tsx index 5e9ea02d..1b8df7ed 100644 --- a/web/app/(dashboard)/dashboard/conversations/_components/conversation-list.tsx +++ b/web/app/(dashboard)/dashboard/conversations/_components/conversation-list.tsx @@ -1,8 +1,6 @@ "use client" -import { UserIcon } from "lucide-react"; - -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { ChannelIcon } from "@/components/channel-icon"; import { ScrollArea } from "@/components/ui/scroll-area"; import { IMConversationStatus } from "@/lib/generated/enums"; import { useAgentConversationsStore } from "@/lib/stores/agent-conversations"; @@ -29,10 +27,14 @@ export function ConversationList({ onAfterSelect }: ConversationListProps) { ) : conversations.length > 0 ? ( conversations.map((conversation) => { const isSelected = selectedId === conversation.id + const displayTitle = conversation.title && conversation.title !== conversation.customerName + ? conversation.title + : null + return (
{ @@ -44,44 +46,52 @@ export function ConversationList({ onAfterSelect }: ConversationListProps) { ) }} > -
-
- - - - - - -
-
- - {conversation.customerName || - t("conversation.customerFallback", { - id: conversation.customerId || conversation.id, - })} - - {conversation.agentUnreadCount > 0 ? ( -
- {conversation.agentUnreadCount > 99 - ? "99+" - : conversation.agentUnreadCount} -
- ) : null} -
-
+
+
+
+ + + + + {conversation.customerName || + t("conversation.customerFallback", { + id: conversation.customerId || conversation.id, + })} + +
+
+ {conversation.lastMessageAt ? formatDateTime(conversation.lastMessageAt) : t("conversation.noTime")} -
+ + {conversation.agentUnreadCount > 0 ? ( +
+ {conversation.agentUnreadCount > 99 + ? "99+" + : conversation.agentUnreadCount} +
+ ) : null}
-
+ + {displayTitle ? ( +
+ {displayTitle} +
+ ) : null} + +
{conversation.lastMessageSummary || t("conversation.noLatestMessage")}
+ {conversation.status === IMConversationStatus.Pending && conversation.currentTeamName ? (
- + {t("conversation.teamOnDuty", { name: conversation.currentTeamName, })} diff --git a/web/app/(dashboard)/dashboard/conversations/_components/conversation-workbench.tsx b/web/app/(dashboard)/dashboard/conversations/_components/conversation-workbench.tsx index be489792..424eb4ee 100644 --- a/web/app/(dashboard)/dashboard/conversations/_components/conversation-workbench.tsx +++ b/web/app/(dashboard)/dashboard/conversations/_components/conversation-workbench.tsx @@ -18,6 +18,7 @@ import type { PanelImperativeHandle } from "react-resizable-panels"; import { ConversationCloseDialog } from "@/components/conversation-actions/close-dialog"; import { ConversationTransferDialog } from "@/components/conversation-actions/transfer-dialog"; +import { ChannelIcon } from "@/components/channel-icon"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { @@ -282,17 +283,24 @@ export function ConversationWorkbench() { )} {conversation ? ( - <> - - - - {t("conversation.customerAvatar")} - - -
+
+
+ +
+
-

- {conversation.customerName || + + #{conversation.id} + +

+ {conversation.title || + conversation.customerName || t("conversation.customerFallback", { id: conversation.customerId || conversation.id, })} @@ -312,17 +320,33 @@ export function ConversationWorkbench() { : t("conversation.customerOffline")}

-

- {t("conversation.channelNumber", { id: conversation.channelId || "-" })} - {conversation.customerId ? ( +

+ + {conversation.customerName || + t("conversation.customerFallback", { + id: conversation.customerId || conversation.id, + })} + + {conversation.channelName ? ( + <> + + {conversation.channelName} + + ) : conversation.channelType ? ( + <> + + {conversation.channelType} + + ) : null} + {conversation.currentAssigneeName ? ( <> - / - {t("conversation.linkedCustomer")} + + @{conversation.currentAssigneeName} ) : null} -

+
- +
) : (

diff --git a/web/components/channel-icon.tsx b/web/components/channel-icon.tsx new file mode 100644 index 00000000..1a2d9652 --- /dev/null +++ b/web/components/channel-icon.tsx @@ -0,0 +1,35 @@ +import { + GlobeIcon, + MailIcon, + MessageCircleIcon, + MessagesSquareIcon, + MessageSquareMoreIcon, + SendIcon, +} from "lucide-react" + +export type ChannelIconProps = { + channelType?: string + className?: string +} + +export function ChannelIcon({ channelType, className = "size-3.5" }: ChannelIconProps) { + switch (channelType) { + case "email": + return + case "telegram": + return + case "zalo_oa": + return + case "discord": + return + case "messenger": + return + case "wxwork_kf": + return + case "wechat_mp": + return + case "web": + default: + return + } +} diff --git a/web/lib/api/agent.ts b/web/lib/api/agent.ts index e2785dc9..691eb2de 100644 --- a/web/lib/api/agent.ts +++ b/web/lib/api/agent.ts @@ -34,8 +34,11 @@ export type AgentConversationParticipant = { export type AgentConversation = { id: number + title?: string aiAgentId?: number channelId?: number + channelType?: string + channelName?: string customerId?: number customerName: string status: number diff --git a/web/messages/en-US.json b/web/messages/en-US.json index a8d3db3d..d8cdca0f 100644 --- a/web/messages/en-US.json +++ b/web/messages/en-US.json @@ -425,6 +425,11 @@ "missingCustomerDescription": "The customer profile linked to this conversation is no longer available. Link an existing customer again, or create a new one and attach it to this conversation.", "relinkOrCreateCustomer": "Relink or Create Customer", "conversationOwner": "Conversation Ownership", + "conversationAttributes": "Thread Attributes", + "threadSubject": "Subject", + "channel": "Channel", + "assignee": "Assignee", + "untitledThread": "General Inquiry", "conversationId": "Conversation ID", "channelId": "Channel ID", "customerId": "Customer ID", diff --git a/web/messages/vi-VN.json b/web/messages/vi-VN.json index 76db72bb..27078abe 100644 --- a/web/messages/vi-VN.json +++ b/web/messages/vi-VN.json @@ -432,7 +432,12 @@ "missingCustomerTitle": "Customer deleted or unavailable", "missingCustomerDescription": "The customer profile linked to this conversation is no longer available. Link an existing customer again, or create a new one and attach it to this conversation.", "relinkOrCreateCustomer": "Relink or Create Customer", - "conversationOwner": "Conversation Ownership", + "conversationOwner": "Quyền sở hữu Hội thoại", + "conversationAttributes": "Thông tin Hội thoại", + "threadSubject": "Tiêu đề / Chủ đề", + "channel": "Kênh liên lạc", + "assignee": "Người phụ trách", + "untitledThread": "Hội thoại hỗ trợ", "conversationId": "Conversation ID", "channelId": "Channel ID", "customerId": "Customer ID", diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index eb5d6a51..9868dde5 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -425,6 +425,11 @@ "missingCustomerDescription": "当前会话绑定的客户主档已不可用。你可以重新关联已有客户,或直接新建一个客户并绑定到当前会话。", "relinkOrCreateCustomer": "重新关联或创建客户", "conversationOwner": "会话归属", + "conversationAttributes": "会话属性", + "threadSubject": "主题", + "channel": "接入渠道", + "assignee": "接待客服", + "untitledThread": "咨询会话", "conversationId": "会话 ID", "channelId": "渠道ID", "customerId": "客户ID", From bc3f54c17d1b8acbc7992518c00d509708de18db Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:48:35 +0700 Subject: [PATCH 07/30] chore(rules): add user interaction preferences for popup choice selections --- .cursor/rules/user-interaction-preferences.mdc | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .cursor/rules/user-interaction-preferences.mdc diff --git a/.cursor/rules/user-interaction-preferences.mdc b/.cursor/rules/user-interaction-preferences.mdc new file mode 100644 index 00000000..405a606a --- /dev/null +++ b/.cursor/rules/user-interaction-preferences.mdc @@ -0,0 +1,18 @@ +--- +description: Quy định tương tác và đề xuất lựa chọn bằng Popup/Form có cấu trúc (AskQuestion) +globs: * +alwaysApply: true +--- +# User Interaction & Selection Preferences + +## 1. Tương tác lựa chọn bằng Popup Form (`AskQuestion`) +- **KHÔNG BAO GIỜ** yêu cầu hoặc để người dùng gõ số text (ví dụ: `1`, `2`, `3`) để chọn phương án hay tác vụ, tránh nhầm lẫn lệch ngữ cảnh. +- **LUÔN LUÔN** sử dụng công cụ `AskQuestion` để hiển thị popup lựa chọn trực quan (hỗ trợ `allow_multiple: true` khi có thể chọn nhiều task cùng lúc) mỗi khi: + - Đề xuất các bước phát triển tiếp theo (Next Dev Tasks). + - Cần người dùng đưa ra quyết định kỹ thuật / phương án kiến trúc. + - Phân nhánh các hành động cần xác nhận. + +## 2. Tư duy thiết kế All-in-One Lean & AI-Native +- **Tránh bloatware/complex settings**: Không sao chép các hệ thống cài đặt rườm rà, thủ công hàng chục bước như Zendesk truyền thống. +- **Tự động hóa tối đa**: Tận dụng AI và cơ chế Zero-config / Auto-provisioning ngầm để người dùng không phải cấu hình thủ công nếu hệ thống có thể tự suy luận an toàn. +- **Giao diện tinh gọn**: Giữ UI hiện đại, tập trung vào trải nghiệm hội thoại đa kênh (Conversations-First). From 961e0bb13908ad07c3a955044547f151bfb078fc Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 3 Sep 2026 11:28:42 +0700 Subject: [PATCH 08/30] feat(channels): auto subscribe messenger page webhook on channel save --- internal/services/channel_service.go | 34 ++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/internal/services/channel_service.go b/internal/services/channel_service.go index 1de7f930..a0039bfc 100644 --- a/internal/services/channel_service.go +++ b/internal/services/channel_service.go @@ -11,6 +11,7 @@ import ( "agent-desk/internal/pkg/httpx" "agent-desk/internal/pkg/utils" "agent-desk/internal/repositories" + "agent-desk/internal/messenger" "agent-desk/internal/telegram" "agent-desk/internal/wxwork" "context" @@ -96,6 +97,7 @@ func (s *channelService) CreateChannel(req request.CreateChannelRequest, operato return nil, err } go s.syncTelegramWebhook(item, item.Status) + go s.syncMessengerPageWebhook(item, item.Status) return item, nil } @@ -131,6 +133,7 @@ func (s *channelService) UpdateChannel(req request.UpdateChannelRequest, operato return err } go s.syncTelegramWebhook(item, item.Status) + go s.syncMessengerPageWebhook(item, item.Status) return nil } @@ -180,6 +183,7 @@ func (s *channelService) UpdateStatus(id int64, status int, operator *dto.AuthPr }) if err == nil { go s.syncTelegramWebhook(item, enums.Status(status)) + go s.syncMessengerPageWebhook(item, enums.Status(status)) } return err } @@ -200,6 +204,7 @@ func (s *channelService) DeleteChannel(id int64, operator *dto.AuthPrincipal) er }) if err == nil { go s.syncTelegramWebhook(item, enums.StatusDeleted) + go s.syncMessengerPageWebhook(item, enums.StatusDeleted) } return err } @@ -244,6 +249,35 @@ func (s *channelService) syncTelegramWebhook(channel *models.Channel, targetStat } } +func (s *channelService) syncMessengerPageWebhook(channel *models.Channel, targetStatus enums.Status) { + if channel == nil || channel.ChannelType != enums.ChannelTypeMessenger { + return + } + cfg, err := s.ParseMessengerChannelConfig(channel.ConfigJSON) + if err != nil || cfg == nil || cfg.PageAccessToken == "" { + return + } + pageID := strings.TrimSpace(cfg.PageID) + if pageID == "" { + pageID = strings.TrimSpace(channel.ChannelID) + } + if pageID == "" { + return + } + + client := messenger.NewClient(cfg.PageAccessToken) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + if targetStatus == enums.StatusOk { + if err := client.SubscribeAppToPage(ctx, pageID); err != nil { + slog.Warn("auto subscribe messenger page webhook failed", "channel_id", channel.ChannelID, "page_id", pageID, "error", err) + } else { + slog.Info("auto subscribe messenger page webhook succeeded", "channel_id", channel.ChannelID, "page_id", pageID) + } + } +} + func (s *channelService) ParseWxWorkKFChannelConfig(raw string) (*dto.WxWorkKFChannelConfig, error) { raw = strings.TrimSpace(raw) if raw == "" { From 98d39b814e7a5eb8fcc01b457743a284297bc6b0 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 3 Sep 2026 11:37:44 +0700 Subject: [PATCH 09/30] fix(docker): ignore flowgram-editor node_modules and build artifacts --- .dockerignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.dockerignore b/.dockerignore index ae0f9091..85ab19d7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -20,6 +20,11 @@ config/config.yaml node_modules .pnpm-store +**/node_modules + +flowgram-editor/node_modules +flowgram-editor/dist +flowgram-editor/.rsbuild web/node_modules web/.next From af555d40469a8ff92339870af603c434cc32655b Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 3 Sep 2026 11:59:03 +0700 Subject: [PATCH 10/30] feat(docker): configure supabase postgresql connection and remove local mysql --- .env.example | 10 ++++++---- docker-compose.yml | 32 ++++++++------------------------ 2 files changed, 14 insertions(+), 28 deletions(-) diff --git a/.env.example b/.env.example index 297cfc80..d9338412 100644 --- a/.env.example +++ b/.env.example @@ -11,12 +11,14 @@ PORT=8083 # Database Configuration # Driver options: sqlite, mysql, postgres -DB_TYPE=sqlite -DATABASE_URL=file:./data/app.db?_busy_timeout=5000 +# Supabase PostgreSQL (DOS): +DB_TYPE=postgres +DATABASE_URL="host=aws-1-ap-southeast-1.pooler.supabase.com user=postgres.gulptwduchsjcsbndmua password=your-supabase-password dbname=postgres port=5432 sslmode=require search_path=desk" +# SQLite local: +# 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 diff --git a/docker-compose.yml b/docker-compose.yml index cd43fe4f..004a4735 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,24 +1,4 @@ services: - mysql: - image: mysql:8.4 - restart: unless-stopped - environment: - MYSQL_DATABASE: cs_ai_agent - MYSQL_USER: cs_ai_agent - MYSQL_PASSWORD: cs_ai_agent_password - MYSQL_ROOT_PASSWORD: cs_ai_agent_root_password - TZ: Asia/Shanghai - command: - - --character-set-server=utf8mb4 - - --collation-server=utf8mb4_unicode_ci - volumes: - - mysql-data:/var/lib/mysql - healthcheck: - test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u\"$${MYSQL_USER}\" -p\"$${MYSQL_PASSWORD}\" --silent"] - interval: 10s - timeout: 5s - retries: 10 - qdrant: image: qdrant/qdrant:latest restart: unless-stopped @@ -32,22 +12,26 @@ services: build: context: . dockerfile: Dockerfile - image: mlogclub/agent-desk:latest + image: crove-desk:latest restart: unless-stopped depends_on: - mysql: - condition: service_healthy qdrant: condition: service_started ports: - "8083:8083" + extra_hosts: + - "host.docker.internal:host-gateway" volumes: - agent-desk-data:/app/data - ./docker/agent-desk.yaml:/app/config/config.yaml:ro + env_file: + - .env environment: TZ: Asia/Shanghai + QDRANT_HOST: qdrant + DB_TYPE: postgres + DATABASE_URL: "postgres://postgres:postgres@host.docker.internal:54322/postgres?sslmode=disable&search_path=desk" volumes: - mysql-data: qdrant-data: agent-desk-data: From 9ee2406ee374ab6f1863236eb4a737ef2fb51715 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:11:07 +0700 Subject: [PATCH 11/30] feat(auth): integrate JIT teams token claims and zero-latency organization hierarchy provisioning --- .../rules/user-interaction-preferences.mdc | 13 +- internal/oidcclient/oidcclient.go | 51 +++++ internal/pkg/dto/request/webhook_request.go | 5 + internal/services/auth_service_test.go | 2 + internal/services/oidc_login_service.go | 112 +++++++++++ internal/services/oidc_login_service_test.go | 82 ++++++++ internal/services/webhook_sync_service.go | 180 ++++++++++++++++++ 7 files changed, 440 insertions(+), 5 deletions(-) diff --git a/.cursor/rules/user-interaction-preferences.mdc b/.cursor/rules/user-interaction-preferences.mdc index 405a606a..440e7fd5 100644 --- a/.cursor/rules/user-interaction-preferences.mdc +++ b/.cursor/rules/user-interaction-preferences.mdc @@ -6,13 +6,16 @@ alwaysApply: true # User Interaction & Selection Preferences ## 1. Tương tác lựa chọn bằng Popup Form (`AskQuestion`) -- **KHÔNG BAO GIỜ** yêu cầu hoặc để người dùng gõ số text (ví dụ: `1`, `2`, `3`) để chọn phương án hay tác vụ, tránh nhầm lẫn lệch ngữ cảnh. -- **LUÔN LUÔN** sử dụng công cụ `AskQuestion` để hiển thị popup lựa chọn trực quan (hỗ trợ `allow_multiple: true` khi có thể chọn nhiều task cùng lúc) mỗi khi: - - Đề xuất các bước phát triển tiếp theo (Next Dev Tasks). - - Cần người dùng đưa ra quyết định kỹ thuật / phương án kiến trúc. +- **KHÔNG BAO GIỜ** yêu cầu hoặc để người dùng phải gõ số text (ví dụ: `1`, `2`, `3`) để chọn phương án hay tác vụ, tránh triệt để nhầm lẫn hoặc lệch ngữ cảnh. +- **LUÔN LUÔN** gọi công cụ `AskQuestion` với popup lựa chọn trực quan (hỗ trợ `allow_multiple: true` khi có thể chọn nhiều task cùng lúc) mỗi khi: + - Báo cáo kết quả và đề xuất danh sách các task phát triển tiếp theo (Next Dev Tasks). + - Cần người dùng đưa ra quyết định kỹ thuật / lựa chọn phương án kiến trúc. - Phân nhánh các hành động cần xác nhận. -## 2. Tư duy thiết kế All-in-One Lean & AI-Native +## 2. Quy chuẩn Định dạng Văn bản (Formatting Cleanliness) +- **Tuyệt đối không sử dụng cú pháp LaTeX toán học** như `$\leftarrow$`, `$\rightarrow$` trong văn bản báo cáo hoặc giải thích vì sẽ bị lỗi render raw text xấu. Thay vào đó dùng các ký tự Unicode chuẩn như `←`, `→`, `->`, `<-`. + +## 3. Tư duy thiết kế All-in-One Lean & AI-Native - **Tránh bloatware/complex settings**: Không sao chép các hệ thống cài đặt rườm rà, thủ công hàng chục bước như Zendesk truyền thống. - **Tự động hóa tối đa**: Tận dụng AI và cơ chế Zero-config / Auto-provisioning ngầm để người dùng không phải cấu hình thủ công nếu hệ thống có thể tự suy luận an toàn. - **Giao diện tinh gọn**: Giữ UI hiện đại, tập trung vào trải nghiệm hội thoại đa kênh (Conversations-First). diff --git a/internal/oidcclient/oidcclient.go b/internal/oidcclient/oidcclient.go index 7bcfa5b2..7748e435 100644 --- a/internal/oidcclient/oidcclient.go +++ b/internal/oidcclient/oidcclient.go @@ -42,6 +42,14 @@ type OrganizationClaim struct { Role string `json:"role"` } +type TeamClaim struct { + ID string `json:"id"` + OrgID string `json:"org_id,omitempty"` + Name string `json:"name"` + Slug string `json:"slug,omitempty"` + Role string `json:"role"` +} + type Profile struct { Subject string `json:"sub"` Email string `json:"email,omitempty"` @@ -50,6 +58,7 @@ type Profile struct { Picture string `json:"picture,omitempty"` ActiveOrgID string `json:"active_org_id,omitempty"` Organizations []OrganizationClaim `json:"organizations,omitempty"` + Teams []TeamClaim `json:"teams,omitempty"` RawProfile string `json:"-"` } @@ -314,6 +323,7 @@ func profileFromIDToken(idToken *gooidc.IDToken) (*Profile, error) { Picture: firstNonEmpty(claimString(claims, "picture"), claimString(claims, "avatar_url")), ActiveOrgID: firstNonEmpty(claimString(claims, "active_org_id"), claimString(claims, "activeOrgId")), Organizations: claimOrganizations(claims), + Teams: claimTeams(claims), RawProfile: string(raw), } if strings.TrimSpace(profile.Subject) == "" { @@ -336,6 +346,7 @@ func profileFromUserInfo(userInfo *gooidc.UserInfo, fallback *Profile) (*Profile Picture: firstNonEmpty(claimString(claims, "picture"), claimString(claims, "avatar_url")), ActiveOrgID: firstNonEmpty(claimString(claims, "active_org_id"), claimString(claims, "activeOrgId")), Organizations: claimOrganizations(claims), + Teams: claimTeams(claims), RawProfile: string(raw), } if fallback != nil { @@ -349,6 +360,9 @@ func profileFromUserInfo(userInfo *gooidc.UserInfo, fallback *Profile) (*Profile if len(profile.Organizations) == 0 { profile.Organizations = fallback.Organizations } + if len(profile.Teams) == 0 { + profile.Teams = fallback.Teams + } } if profile.RawProfile == "" { if fallback != nil { @@ -361,6 +375,43 @@ func profileFromUserInfo(userInfo *gooidc.UserInfo, fallback *Profile) (*Profile return profile, nil } +func claimTeams(claims map[string]any) []TeamClaim { + raw, ok := claims["teams"] + if !ok || raw == nil { + return nil + } + + bytes, err := json.Marshal(raw) + if err != nil { + return nil + } + var teams []TeamClaim + if err := json.Unmarshal(bytes, &teams); err == nil && len(teams) > 0 { + return teams + } + + var list []map[string]any + if err := json.Unmarshal(bytes, &list); err == nil { + for _, item := range list { + id := firstNonEmpty(claimString(item, "id"), claimString(item, "team_id"), claimString(item, "slug"), claimString(item, "code")) + orgID := firstNonEmpty(claimString(item, "org_id"), claimString(item, "organization_id")) + slug := claimString(item, "slug") + name := firstNonEmpty(claimString(item, "name"), claimString(item, "team_name"), slug, id) + role := firstNonEmpty(claimString(item, "role"), "MEMBER") + if id != "" { + teams = append(teams, TeamClaim{ + ID: id, + OrgID: orgID, + Name: name, + Slug: slug, + Role: strings.ToUpper(role), + }) + } + } + } + return teams +} + func claimOrganizations(claims map[string]any) []OrganizationClaim { raw, ok := claims["organizations"] if !ok || raw == nil { diff --git a/internal/pkg/dto/request/webhook_request.go b/internal/pkg/dto/request/webhook_request.go index 4de0ae6c..47ecbe74 100644 --- a/internal/pkg/dto/request/webhook_request.go +++ b/internal/pkg/dto/request/webhook_request.go @@ -35,6 +35,11 @@ type OrgSyncEventData struct { JobTitle string `json:"job_title,omitempty"` CompanyName string `json:"company_name,omitempty"` Source string `json:"source,omitempty"` + + // Team fields + TeamID string `json:"team_id,omitempty"` + TeamName string `json:"team_name,omitempty"` + TeamSlug string `json:"team_slug,omitempty"` } type OrgSyncWebhookRequest struct { diff --git a/internal/services/auth_service_test.go b/internal/services/auth_service_test.go index 8901aaa1..a7f5fa11 100644 --- a/internal/services/auth_service_test.go +++ b/internal/services/auth_service_test.go @@ -395,6 +395,8 @@ func setupAuthServiceTestDB(t *testing.T) *gorm.DB { &models.UserPermission{}, &models.LoginSession{}, &models.LoginCredentialLog{}, + &models.AgentProfile{}, + &models.AgentTeam{}, ); err != nil { t.Fatalf("migrate auth tables: %v", err) } diff --git a/internal/services/oidc_login_service.go b/internal/services/oidc_login_service.go index 2e1de8f4..d791c2c5 100644 --- a/internal/services/oidc_login_service.go +++ b/internal/services/oidc_login_service.go @@ -27,6 +27,8 @@ type oidcLoginService struct { } type oidcLoginProfile = oidcclient.Profile +type oidcLoginProfileOrg = oidcclient.OrganizationClaim +type oidcLoginProfileTeam = oidcclient.TeamClaim func newOIDCLoginService() *oidcLoginService { return &oidcLoginService{} @@ -112,6 +114,7 @@ func (s *oidcLoginService) loginWithOIDCProfile(profile *oidcLoginProfile, authC s.ensureDefaultOIDCRole(ctx.Tx, user) s.syncOIDCUserOrganizations(ctx.Tx, user, profile) + s.syncOIDCUserTeams(ctx.Tx, user, profile) _, _ = AgentProfileService.EnsureAgentProfileForUser(ctx.Tx, user) if err = repositories.UserIdentityRepository.Updates(ctx.Tx, identity.ID, map[string]any{ @@ -414,3 +417,112 @@ func (s *oidcLoginService) syncOIDCUserOrganizations(tx *gorm.DB, user *models.U _ = repositories.UserRepository.UpdateColumn(tx, user.ID, "active_org_id", activeOrgID) } } + +func (s *oidcLoginService) syncOIDCUserTeams(tx *gorm.DB, user *models.User, profile *oidcLoginProfile) { + if user == nil || user.ID <= 0 || profile == nil { + return + } + now := time.Now() + + agentProfile, _ := AgentProfileService.EnsureAgentProfileForUser(tx, user) + if agentProfile == nil { + return + } + + var targetTeamID int64 = 0 + var isTeamLead bool = false + + if len(profile.Teams) > 0 { + for _, teamClaim := range profile.Teams { + teamName := strings.TrimSpace(teamClaim.Name) + if teamName == "" { + teamName = strings.TrimSpace(teamClaim.Slug) + } + if teamName == "" { + teamName = "Customer Support" + } + slug := strings.TrimSpace(teamClaim.Slug) + role := strings.ToUpper(strings.TrimSpace(teamClaim.Role)) + + team := repositories.AgentTeamRepository.FindOne(tx, sqls.NewCnd(). + Where("name = ? OR description = ?", teamName, slug). + Eq("status", enums.StatusOk)) + if team == nil { + team = &models.AgentTeam{ + Name: teamName, + Description: slug, + LeaderUserID: 0, + 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.AgentTeamRepository.Create(tx, team); err != nil { + continue + } + } + + if role == "LEAD" || role == "ADMIN" || role == "OWNER" { + isTeamLead = true + if team.LeaderUserID == 0 || team.LeaderUserID == user.ID { + _ = repositories.AgentTeamRepository.UpdateColumn(tx, team.ID, "leader_user_id", user.ID) + } + } + + if targetTeamID == 0 || slug == "customer-support" || strings.Contains(strings.ToLower(slug), "support") || strings.Contains(strings.ToLower(teamName), "support") { + targetTeamID = team.ID + } + } + } + + if targetTeamID > 0 && agentProfile.TeamID != targetTeamID { + profileUpdates := map[string]any{ + "team_id": targetTeamID, + "update_user_id": user.ID, + "update_user_name": user.Username, + "updated_at": now, + } + if isTeamLead { + profileUpdates["priority_level"] = 10 + } + _ = repositories.AgentProfileRepository.Updates(tx, agentProfile.ID, profileUpdates) + } + + if isTeamLead { + s.ensureSupervisorRole(tx, user) + } +} + +func (s *oidcLoginService) ensureSupervisorRole(tx *gorm.DB, user *models.User) { + if user == nil || user.ID <= 0 { + return + } + adminRole := repositories.RoleRepository.GetByCode(tx, constants.RoleCodeAdmin) + if adminRole == nil { + adminRole = repositories.RoleRepository.GetByCode(tx, constants.RoleCodeSuperAdmin) + } + if adminRole == nil { + return + } + existing := repositories.UserRoleRepository.FindOne(tx, sqls.NewCnd().Eq("user_id", user.ID).Eq("role_id", adminRole.ID)) + if existing == nil { + now := time.Now() + _ = repositories.UserRoleRepository.Create(tx, &models.UserRole{ + UserID: user.ID, + RoleID: adminRole.ID, + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: user.ID, + CreateUserName: user.Username, + UpdatedAt: now, + UpdateUserID: user.ID, + UpdateUserName: user.Username, + }, + }) + } +} diff --git a/internal/services/oidc_login_service_test.go b/internal/services/oidc_login_service_test.go index 4e0749d7..eacb0f29 100644 --- a/internal/services/oidc_login_service_test.go +++ b/internal/services/oidc_login_service_test.go @@ -94,3 +94,85 @@ func TestOIDCLoginReusesExistingIdentity(t *testing.T) { t.Fatalf("expected existing identity to reuse user, got %d users", count) } } + +func TestOIDCLoginSyncsOrganizationsAndTeams(t *testing.T) { + db := setupAuthServiceTestDB(t) + svc := newOIDCLoginService() + + profile := &oidcLoginProfile{ + Subject: "7a3562bb-f529-45e0-bdfa-b73ca55ce8c8", + Email: "agent@acme.com", + PreferredUsername: "janedoe", + Name: "Jane Doe", + Picture: "https://avatar.dos.me/jane.png", + ActiveOrgID: "org_987654321", + Organizations: []oidcLoginProfileOrg{ + { + ID: "org_987654321", + Name: "Acme Corporation", + Slug: "acme", + Role: "ADMIN", + }, + }, + Teams: []oidcLoginProfileTeam{ + { + ID: "team_11223344", + OrgID: "org_987654321", + Name: "Customer Support", + Slug: "customer-support", + Role: "LEAD", + }, + { + ID: "team_55667788", + OrgID: "org_987654321", + Name: "Sales & Outreach", + Slug: "sales-outreach", + Role: "MEMBER", + }, + }, + RawProfile: `{"sub":"7a3562bb-f529-45e0-bdfa-b73ca55ce8c8"}`, + } + + ret, err := svc.loginWithOIDCProfile(profile, config.AuthConfig{TokenTTLHours: 2}, "127.0.0.1", "go-test") + if err != nil { + t.Fatalf("loginWithOIDCProfile() error = %v", err) + } + if ret == nil { + t.Fatalf("expected non-nil login response") + } + + // Verify User created and mapped to Active Org + var user models.User + if err := db.Take(&user, "username = ?", "janedoe").Error; err != nil { + t.Fatalf("expected user created: %v", err) + } + + var org models.Organization + if err := db.Take(&org, "code = ?", "org_987654321").Error; err != nil { + t.Fatalf("expected organization created: %v", err) + } + if user.ActiveOrgID != org.ID { + t.Fatalf("expected active org id %d, got %d", org.ID, user.ActiveOrgID) + } + + // Verify AgentTeam created for Customer Support + var team models.AgentTeam + if err := db.Take(&team, "name = ?", "Customer Support").Error; err != nil { + t.Fatalf("expected Customer Support team created: %v", err) + } + if team.LeaderUserID != user.ID { + t.Fatalf("expected user to be team lead, got leader_user_id = %d", team.LeaderUserID) + } + + // Verify AgentProfile mapped to Customer Support team with Lead priority + var agentProfile models.AgentProfile + if err := db.Take(&agentProfile, "user_id = ?", user.ID).Error; err != nil { + t.Fatalf("expected agent profile created: %v", err) + } + if agentProfile.TeamID != team.ID { + t.Fatalf("expected agent profile mapped to team %d, got %d", team.ID, agentProfile.TeamID) + } + if agentProfile.PriorityLevel != 10 { + t.Fatalf("expected priority level 10 for LEAD, got %d", agentProfile.PriorityLevel) + } +} diff --git a/internal/services/webhook_sync_service.go b/internal/services/webhook_sync_service.go index ad823834..3785f5b6 100644 --- a/internal/services/webhook_sync_service.go +++ b/internal/services/webhook_sync_service.go @@ -151,6 +151,14 @@ func (s *webhookSyncService) HandleOrgSync(req request.OrgSyncWebhookRequest) er return s.handleCompanyUpsert(data) case "customer.created", "customer.updated": return s.handleCustomerUpsert(data) + case "team.created", "team.updated": + return s.handleTeamUpsert(data) + case "team.deleted": + return s.handleTeamDelete(data) + case "team.member_added", "team.member_updated", "team.member.added", "team.member.updated": + return s.handleTeamMemberUpsert(data) + case "team.member_removed", "team.member.removed": + return s.handleTeamMemberRemove(data) default: return nil } @@ -702,3 +710,175 @@ func (s *webhookSyncService) handleCustomerUpsert(data request.OrgSyncEventData) return nil }) } + +func (s *webhookSyncService) handleTeamUpsert(data request.OrgSyncEventData) error { + teamName := strings.TrimSpace(data.TeamName) + if teamName == "" { + teamName = strings.TrimSpace(data.Name) + } + if teamName == "" { + teamName = strings.TrimSpace(data.TeamSlug) + } + if teamName == "" { + return errorsx.InvalidParam("team name or slug is required") + } + slug := strings.TrimSpace(data.TeamSlug) + if slug == "" { + slug = strings.TrimSpace(data.Slug) + } + + now := time.Now() + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + team := repositories.AgentTeamRepository.FindOne(ctx.Tx, sqls.NewCnd(). + Where("name = ? OR description = ?", teamName, slug). + Eq("status", enums.StatusOk)) + if team == nil { + team = &models.AgentTeam{ + Name: teamName, + Description: slug, + LeaderUserID: 0, + Status: enums.StatusOk, + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: 0, + CreateUserName: "webhook-sync", + UpdatedAt: now, + UpdateUserID: 0, + UpdateUserName: "webhook-sync", + }, + } + return repositories.AgentTeamRepository.Create(ctx.Tx, team) + } + + updates := map[string]any{ + "name": teamName, + "description": slug, + "status": enums.StatusOk, + "update_user_id": 0, + "update_user_name": "webhook-sync", + "updated_at": now, + } + return repositories.AgentTeamRepository.Updates(ctx.Tx, team.ID, updates) + }) +} + +func (s *webhookSyncService) handleTeamDelete(data request.OrgSyncEventData) error { + teamName := strings.TrimSpace(data.TeamName) + if teamName == "" { + teamName = strings.TrimSpace(data.Name) + } + slug := strings.TrimSpace(data.TeamSlug) + if slug == "" { + slug = strings.TrimSpace(data.Slug) + } + + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + team := repositories.AgentTeamRepository.FindOne(ctx.Tx, sqls.NewCnd(). + Where("name = ? OR description = ?", teamName, slug). + Eq("status", enums.StatusOk)) + if team != nil { + return repositories.AgentTeamRepository.UpdateColumn(ctx.Tx, team.ID, "status", enums.StatusDisabled) + } + return nil + }) +} + +func (s *webhookSyncService) handleTeamMemberUpsert(data request.OrgSyncEventData) error { + teamName := strings.TrimSpace(data.TeamName) + if teamName == "" { + teamName = strings.TrimSpace(data.Name) + } + slug := strings.TrimSpace(data.TeamSlug) + if slug == "" { + slug = strings.TrimSpace(data.Slug) + } + userEmail := strings.TrimSpace(strings.ToLower(data.UserEmail)) + userSubject := strings.TrimSpace(data.UserID) + role := strings.ToUpper(strings.TrimSpace(data.Role)) + + now := time.Now() + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + team := repositories.AgentTeamRepository.FindOne(ctx.Tx, sqls.NewCnd(). + Where("name = ? OR description = ?", teamName, slug). + Eq("status", enums.StatusOk)) + if team == nil { + team = &models.AgentTeam{ + Name: teamName, + Description: slug, + LeaderUserID: 0, + Status: enums.StatusOk, + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: 0, + CreateUserName: "webhook-sync", + UpdatedAt: now, + UpdateUserID: 0, + UpdateUserName: "webhook-sync", + }, + } + if err := repositories.AgentTeamRepository.Create(ctx.Tx, team); err != nil { + return err + } + } + + var user *models.User + if userSubject != "" { + identity := repositories.UserIdentityRepository.GetBy(ctx.Tx, enums.ThirdProviderOIDC, "", userSubject) + if identity != nil { + user = repositories.UserRepository.Get(ctx.Tx, identity.UserID) + } + } + if user == nil && userEmail != "" { + user = repositories.UserRepository.GetByEmail(ctx.Tx, userEmail) + } + if user == nil { + return nil + } + + if role == "LEAD" || role == "ADMIN" || role == "OWNER" { + _ = repositories.AgentTeamRepository.UpdateColumn(ctx.Tx, team.ID, "leader_user_id", user.ID) + } + + agentProfile, _ := AgentProfileService.EnsureAgentProfileForUser(ctx.Tx, user) + if agentProfile != nil && agentProfile.TeamID != team.ID { + updates := map[string]any{ + "team_id": team.ID, + "update_user_id": user.ID, + "update_user_name": user.Username, + "updated_at": now, + } + if role == "LEAD" { + updates["priority_level"] = 10 + } + _ = repositories.AgentProfileRepository.Updates(ctx.Tx, agentProfile.ID, updates) + } + return nil + }) +} + +func (s *webhookSyncService) handleTeamMemberRemove(data request.OrgSyncEventData) error { + userEmail := strings.TrimSpace(strings.ToLower(data.UserEmail)) + userSubject := strings.TrimSpace(data.UserID) + + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + var user *models.User + if userSubject != "" { + identity := repositories.UserIdentityRepository.GetBy(ctx.Tx, enums.ThirdProviderOIDC, "", userSubject) + if identity != nil { + user = repositories.UserRepository.Get(ctx.Tx, identity.UserID) + } + } + if user == nil && userEmail != "" { + user = repositories.UserRepository.GetByEmail(ctx.Tx, userEmail) + } + if user == nil { + return nil + } + + agentProfile, _ := AgentProfileService.EnsureAgentProfileForUser(ctx.Tx, user) + if agentProfile != nil { + _ = repositories.AgentProfileRepository.UpdateColumn(ctx.Tx, agentProfile.ID, "team_id", 0) + } + return nil + }) +} From 527b3de7f8fff75be88d52df3619ae0113442522 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:14:48 +0700 Subject: [PATCH 12/30] test(discord): add full end-to-end discord channel integration test --- internal/services/discord_integration_test.go | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 internal/services/discord_integration_test.go diff --git a/internal/services/discord_integration_test.go b/internal/services/discord_integration_test.go new file mode 100644 index 00000000..e470bd27 --- /dev/null +++ b/internal/services/discord_integration_test.go @@ -0,0 +1,169 @@ +package services + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +func setupDiscordIntegrationTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{ + TablePrefix: "t_", + SingularTable: true, + }, + }) + if err != nil { + t.Fatalf("open sqlite db: %v", err) + } + if err := db.AutoMigrate( + &models.Channel{}, + &models.ChannelMessageOutbox{}, + &models.Customer{}, + &models.CustomerIdentity{}, + &models.CustomerContact{}, + &models.Conversation{}, + &models.ConversationParticipant{}, + &models.ConversationReadState{}, + &models.ConversationInterrupt{}, + &models.ConversationEventLog{}, + &models.Message{}, + &models.AIAgent{}, + &models.AgentProfile{}, + &models.AgentTeam{}, + &models.User{}, + &models.Role{}, + &models.UserRole{}, + &models.Permission{}, + &models.RolePermission{}, + &models.UserPermission{}, + ); err != nil { + t.Fatalf("migrate discord integration test tables: %v", err) + } + sqls.SetDB(db) + return db +} + +func TestDiscordIntegrationFullFlow(t *testing.T) { + db := setupDiscordIntegrationTestDB(t) + + mockDiscordServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"id":"discord_msg_reply_999","channel_id":"ch_discord_general","content":"Cảm ơn bạn! Đội ngũ hỗ trợ sẽ kiểm tra ngay."}`)) + })) + defer mockDiscordServer.Close() + + now := time.Now() + // 1. Create AI Agent + agent := &models.AIAgent{ + Name: "Discord Support AI", + ServiceMode: enums.IMConversationServiceModeAIFirst, + PublishedRevisionID: 1, + WelcomeMessage: "Chào mừng đến với máy chủ Discord Crove Desk!", + Status: enums.StatusOk, + AuditFields: models.AuditFields{ + CreatedAt: now, + UpdatedAt: now, + }, + } + _ = db.Create(agent) + + // 2. Create Discord Channel + discordConfig, _ := json.Marshal(dto.DiscordChannelConfig{ + GuildID: "guild_987654321", + GuildName: "Crove Community Discord", + BotToken: "test-discord-bot-token-xyz", + WebhookSecret: "discord-secret-token-123", + WelcomeMessage: "Welcome to Discord Support!", + }) + + operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"} + channel, err := ChannelService.CreateChannel(request.CreateChannelRequest{ + Name: "Crove Discord Support", + ChannelType: enums.ChannelTypeDiscord, + AIAgentID: agent.ID, + AIAgentRolloutPercent: 100, + ConfigJSON: string(discordConfig), + Status: int(enums.StatusOk), + }, operator) + if err != nil { + t.Fatalf("CreateChannel failed: %v", err) + } + + // 3. Simulate Inbound Discord Webhook / Gateway message from user + inboundPayload := []byte(`{ + "id": "msg_discord_user_001", + "channel_id": "ch_discord_general", + "guild_id": "guild_987654321", + "content": "Tôi muốn hỏi về cách cấu hình Custom Domain cho Email Channel trên Crove Desk", + "author": { + "id": "discord_uid_555", + "username": "gamer_joy", + "global_name": "Anh Le", + "bot": false + } + }`) + + ctx := context.Background() + err = DiscordInboundService.HandleWebhook(ctx, channel.ChannelID, "discord-secret-token-123", inboundPayload) + if err != nil { + t.Fatalf("DiscordInboundService.HandleWebhook failed: %v", err) + } + + // Verify Customer Identity + identity := repositories.CustomerIdentityRepository.FindOne(db, sqls.NewCnd(). + Eq("external_source", enums.ExternalSourceDiscord). + Eq("external_id", "discord_uid_555")) + if identity == nil { + t.Fatalf("expected customer identity for discord_uid_555") + } + + customer := repositories.CustomerRepository.Get(db, identity.CustomerID) + if customer == nil || customer.Name != "Anh Le" { + t.Fatalf("unexpected customer profile: %+v", customer) + } + + // Verify Conversation created + conv := repositories.ConversationRepository.FindOne(db, sqls.NewCnd().Eq("customer_id", customer.ID)) + if conv == nil || conv.ChannelID != channel.ID { + t.Fatalf("unexpected conversation: %+v", conv) + } + + // Verify Customer Message stored + msg := repositories.MessageRepository.FindOne(db, sqls.NewCnd(). + Eq("conversation_id", conv.ID). + Eq("sender_type", enums.IMSenderTypeCustomer)) + if msg == nil || msg.Content != "Tôi muốn hỏi về cách cấu hình Custom Domain cho Email Channel trên Crove Desk" { + t.Fatalf("unexpected stored customer message: %+v", msg) + } + + // 4. Simulate Agent / AI Reply and test Outbox Enqueue & Outbound Dispatch + replyMsg, err := MessageService.SendAIMessage(conv.ID, agent.ID, "ai_reply_001", enums.IMMessageTypeText, "Cảm ơn bạn! Đội ngũ hỗ trợ sẽ kiểm tra ngay.", "", operator) + if err != nil { + t.Fatalf("MessageService.SendAIMessage failed: %v", err) + } + + outbox := ChannelMessageOutboxService.GetByMessageID(enums.ChannelTypeDiscord, replyMsg.ID) + if outbox == nil { + t.Fatalf("expected discord outbox entry for AI message") + } + if outbox.ChannelType != enums.ChannelTypeDiscord { + t.Fatalf("expected outbox channel type 'discord', got '%s'", outbox.ChannelType) + } +} From 13d9415e5a0c981d905acc0765d2d1e8b5aae795 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:36:26 +0700 Subject: [PATCH 13/30] feat(channels): add instagram direct messaging omnichannel integration --- internal/bootstrap/routes.go | 8 + internal/bootstrap/server.go | 1 + .../dashboard/channel_oauth_handler.go | 45 +++++ internal/handlers/third/instagram_handler.go | 71 +++++++ .../handlers/third/instagram_handler_test.go | 123 ++++++++++++ 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 | 41 +++- internal/services/cronx/cron.go | 4 + .../services/instagram_inbound_service.go | 169 ++++++++++++++++ .../instagram_inbound_service_test.go | 162 +++++++++++++++ .../services/instagram_outbound_service.go | 189 ++++++++++++++++++ internal/services/message_service.go | 9 + .../dashboard/channels/_components/edit.tsx | 145 +++++++++++++- .../(dashboard)/dashboard/channels/page.tsx | 8 + web/lib/generated/enums.ts | 2 + web/messages/en-US.json | 7 + web/messages/vi-VN.json | 7 + web/messages/zh-CN.json | 7 + 21 files changed, 1066 insertions(+), 8 deletions(-) create mode 100644 internal/handlers/third/instagram_handler.go create mode 100644 internal/handlers/third/instagram_handler_test.go create mode 100644 internal/services/instagram_inbound_service.go create mode 100644 internal/services/instagram_inbound_service_test.go create mode 100644 internal/services/instagram_outbound_service.go diff --git a/internal/bootstrap/routes.go b/internal/bootstrap/routes.go index e9fccb3a..95a36010 100644 --- a/internal/bootstrap/routes.go +++ b/internal/bootstrap/routes.go @@ -233,6 +233,7 @@ func registerDashboardChannelRoutes(group *gin.RouterGroup) { group.POST("/update_status", dashboard.ChannelPostUpdate_status) group.GET("/discord_oauth_url", dashboard.ChannelGetDiscordOAuthURL) group.GET("/messenger_oauth_url", dashboard.ChannelGetMessengerOAuthURL) + group.GET("/instagram_oauth_url", dashboard.ChannelGetInstagramOAuthURL) group.Any("/wxwork/kf/accounts", dashboard.ChannelAnyWxworkKfAccounts) group.Any("/wxwork/outbox/failed/list", dashboard.ChannelAnyWxworkOutboxFailedList) group.POST("/wxwork/outbox/retry", dashboard.ChannelPostWxworkOutboxRetry) @@ -462,3 +463,10 @@ func registerThirdMessengerRoutes(group *gin.RouterGroup) { group.POST("/webhook", third.MessengerPostWebhook) group.POST("/webhook/:channel_id", third.MessengerPostWebhook) } + +func registerThirdInstagramRoutes(group *gin.RouterGroup) { + group.GET("/webhook", third.InstagramGetWebhook) + group.GET("/webhook/:channel_id", third.InstagramGetWebhook) + group.POST("/webhook", third.InstagramPostWebhook) + group.POST("/webhook/:channel_id", third.InstagramPostWebhook) +} diff --git a/internal/bootstrap/server.go b/internal/bootstrap/server.go index 8e73ab9e..6b8afca4 100644 --- a/internal/bootstrap/server.go +++ b/internal/bootstrap/server.go @@ -200,6 +200,7 @@ func addRouter(app *gin.Engine) { registerThirdEmailRoutes(thirdGroup.Group("/email")) registerThirdDiscordRoutes(thirdGroup.Group("/discord")) registerThirdMessengerRoutes(thirdGroup.Group("/messenger")) + registerThirdInstagramRoutes(thirdGroup.Group("/instagram")) } type spaShellRewrite struct { diff --git a/internal/handlers/dashboard/channel_oauth_handler.go b/internal/handlers/dashboard/channel_oauth_handler.go index 3c7e7b6f..173dd275 100644 --- a/internal/handlers/dashboard/channel_oauth_handler.go +++ b/internal/handlers/dashboard/channel_oauth_handler.go @@ -102,3 +102,48 @@ func ChannelGetMessengerOAuthURL(ctx *gin.Context) { "redirectUri": redirectURI, })) } + +// ChannelGetInstagramOAuthURL returns the 1-Click OAuth authorization URL for Instagram Messaging. +func ChannelGetInstagramOAuthURL(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionChannelView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + + appID := "" + if cfg := config.GetCurrent(); cfg != nil { + appID = strings.TrimSpace(cfg.Messenger.AppID) + } + if appID == "" { + appID = strings.TrimSpace(os.Getenv("META_APP_ID")) + } + if appID == "" { + appID = strings.TrimSpace(os.Getenv("FB_APP_ID")) + } + if appID == "" { + appID = strings.TrimSpace(ctx.Query("app_id")) + } + redirectURI := strings.TrimSpace(ctx.Query("redirect_uri")) + + if appID == "" { + appID = "123456789012345" + } + + state := strings.TrimSpace(ctx.Query("state")) + if state == "" { + state = "crove_instagram_connect" + } + + authURL := fmt.Sprintf( + "https://www.facebook.com/v21.0/dialog/oauth?client_id=%s&redirect_uri=%s&scope=instagram_basic,instagram_manage_messages,pages_show_list,pages_manage_metadata&state=%s", + url.QueryEscape(appID), + url.QueryEscape(redirectURI), + url.QueryEscape(state), + ) + + httpx.WriteJSON(ctx, web.JsonData(gin.H{ + "authUrl": authURL, + "appId": appID, + "redirectUri": redirectURI, + })) +} diff --git a/internal/handlers/third/instagram_handler.go b/internal/handlers/third/instagram_handler.go new file mode 100644 index 00000000..e590640f --- /dev/null +++ b/internal/handlers/third/instagram_handler.go @@ -0,0 +1,71 @@ +package third + +import ( + "bytes" + "io" + "net/http" + "strings" + + "agent-desk/internal/pkg/enums" + "agent-desk/internal/services" + + "github.com/gin-gonic/gin" +) + +// InstagramGetWebhook handles Meta Instagram Webhook verification (hub.challenge). +func InstagramGetWebhook(ctx *gin.Context) { + mode := strings.TrimSpace(ctx.Query("hub.mode")) + token := strings.TrimSpace(ctx.Query("hub.verify_token")) + challenge := strings.TrimSpace(ctx.Query("hub.challenge")) + + channelID := strings.TrimSpace(ctx.Param("channel_id")) + if channelID == "" { + channelID = strings.TrimSpace(ctx.Query("channel_id")) + } + + if mode == "subscribe" { + if channelID != "" { + channel := services.ChannelService.Take("channel_id = ? AND channel_type = ? AND status = ?", channelID, enums.ChannelTypeInstagram, enums.StatusOk) + if channel != nil { + if cfg, err := services.ChannelService.ParseInstagramChannelConfig(channel.ConfigJSON); err == nil && cfg != nil { + if cfg.WebhookVerifyToken != "" && cfg.WebhookVerifyToken != token { + ctx.String(http.StatusForbidden, "Verification token mismatch") + return + } + } + } + } + + ctx.String(http.StatusOK, challenge) + return + } + + ctx.String(http.StatusBadRequest, "Invalid verification request") +} + +// InstagramPostWebhook receives incoming Webhook events from Meta Instagram Direct Messaging. +func InstagramPostWebhook(ctx *gin.Context) { + channelID := strings.TrimSpace(ctx.Param("channel_id")) + if channelID == "" { + channelID = strings.TrimSpace(ctx.Query("channel_id")) + } + + sigHeader := ctx.GetHeader("X-Hub-Signature-256") + if sigHeader == "" { + sigHeader = ctx.GetHeader("X-Hub-Signature") + } + + 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.InstagramInboundService.HandleWebhook(ctx.Request.Context(), channelID, sigHeader, bodyBytes); err != nil { + ctx.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "EVENT_RECEIVED"}) +} diff --git a/internal/handlers/third/instagram_handler_test.go b/internal/handlers/third/instagram_handler_test.go new file mode 100644 index 00000000..35b66ad5 --- /dev/null +++ b/internal/handlers/third/instagram_handler_test.go @@ -0,0 +1,123 @@ +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 TestInstagramWebhook_Handler(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupThirdHandlerTestDB(t) + + now := time.Now() + agent := &models.AIAgent{ + Name: "Instagram Agent", + ServiceMode: enums.IMConversationServiceModeAIFirst, + PublishedRevisionID: 1, + WelcomeMessage: "Hello Instagram User!", + Status: enums.StatusOk, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + _ = db.Create(agent) + + instagramConfig, _ := json.Marshal(dto.InstagramChannelConfig{ + InstagramID: "ig_page_999", + InstagramUsername: "shop_official", + PageAccessToken: "test_ig_page_access_token", + WebhookVerifyToken: "my_verify_token_ig_123", + WelcomeMessage: "Welcome to Instagram support!", + }) + + operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"} + channel, err := services.ChannelService.CreateChannel(request.CreateChannelRequest{ + Name: "Instagram Shop Channel", + ChannelType: enums.ChannelTypeInstagram, + AIAgentID: agent.ID, + AIAgentRolloutPercent: 100, + ConfigJSON: string(instagramConfig), + Status: int(enums.StatusOk), + }, operator) + if err != nil { + t.Fatalf("CreateChannel failed: %v", err) + } + + router := gin.New() + router.GET("/api/third/instagram/webhook/:channel_id", InstagramGetWebhook) + router.GET("/api/third/instagram/webhook", InstagramGetWebhook) + router.POST("/api/third/instagram/webhook/:channel_id", InstagramPostWebhook) + router.POST("/api/third/instagram/webhook", InstagramPostWebhook) + + // 1. Test GET Verification Challenge Success + reqGet, _ := http.NewRequest(http.MethodGet, "/api/third/instagram/webhook/"+channel.ChannelID+"?hub.mode=subscribe&hub.verify_token=my_verify_token_ig_123&hub.challenge=challenge_instagram_777", nil) + recGet := httptest.NewRecorder() + router.ServeHTTP(recGet, reqGet) + + if recGet.Code != http.StatusOK { + t.Fatalf("expected 200 OK for challenge, got: %d", recGet.Code) + } + if recGet.Body.String() != "challenge_instagram_777" { + t.Fatalf("expected challenge code in body, got: %s", recGet.Body.String()) + } + + // 2. Test GET Verification Challenge Mismatch + reqGetBad, _ := http.NewRequest(http.MethodGet, "/api/third/instagram/webhook/"+channel.ChannelID+"?hub.mode=subscribe&hub.verify_token=wrong_token&hub.challenge=challenge_instagram_777", nil) + recGetBad := httptest.NewRecorder() + router.ServeHTTP(recGetBad, reqGetBad) + + if recGetBad.Code != http.StatusForbidden { + t.Fatalf("expected 403 Forbidden for wrong token, got: %d", recGetBad.Code) + } + + // 3. Test POST Inbound Message + payload := []byte(`{ + "object": "instagram", + "entry": [ + { + "id": "ig_page_999", + "time": 1725260000, + "messaging": [ + { + "sender": {"id": "igsid_customer_456"}, + "recipient": {"id": "ig_page_999"}, + "timestamp": 1725260000, + "message": { + "mid": "mid_ig_msg_888", + "text": "How can I track my order?" + } + } + ] + } + ] + }`) + + reqPost, _ := http.NewRequest(http.MethodPost, "/api/third/instagram/webhook/"+channel.ChannelID, bytes.NewBuffer(payload)) + reqPost.Header.Set("Content-Type", "application/json") + recPost := httptest.NewRecorder() + router.ServeHTTP(recPost, reqPost) + + if recPost.Code != http.StatusOK { + t.Fatalf("expected 200 OK for POST webhook, got: %d", recPost.Code) + } + + // Verify identity in DB + identity := repositories.CustomerIdentityRepository.FindOne(db, sqls.NewCnd(). + Eq("external_source", enums.ExternalSourceInstagram). + Eq("external_id", "igsid_customer_456")) + if identity == nil { + t.Fatalf("expected customer identity for igsid_customer_456") + } +} diff --git a/internal/pkg/dto/dto.go b/internal/pkg/dto/dto.go index a3885c85..40808746 100644 --- a/internal/pkg/dto/dto.go +++ b/internal/pkg/dto/dto.go @@ -82,3 +82,13 @@ type MessengerChannelConfig struct { AppSecret string `json:"appSecret,omitempty"` // Meta App Secret WelcomeMessage string `json:"welcomeMessage,omitempty"` } + +type InstagramChannelConfig struct { + InstagramID string `json:"instagramId,omitempty"` // Instagram Business Account ID + InstagramUsername string `json:"instagramUsername,omitempty"` // @username + PageID string `json:"pageId,omitempty"` // Linked Facebook Page ID + PageAccessToken string `json:"pageAccessToken,omitempty"` // Page Access Token + WebhookVerifyToken string `json:"webhookVerifyToken,omitempty"` // Webhook verify token + AppSecret string `json:"appSecret,omitempty"` // Meta App Secret + WelcomeMessage string `json:"welcomeMessage,omitempty"` +} diff --git a/internal/pkg/enums/external_identity.go b/internal/pkg/enums/external_identity.go index 702d012b..7338f17c 100644 --- a/internal/pkg/enums/external_identity.go +++ b/internal/pkg/enums/external_identity.go @@ -15,6 +15,7 @@ const ( ExternalSourceEmail ExternalSource = "email" // Email ExternalSourceDiscord ExternalSource = "discord" // Discord ExternalSourceMessenger ExternalSource = "messenger" // Facebook Messenger + ExternalSourceInstagram ExternalSource = "instagram" // Instagram Direct ) var externalSourceLabelMap = map[ExternalSource]string{ @@ -27,6 +28,7 @@ var externalSourceLabelMap = map[ExternalSource]string{ ExternalSourceEmail: "Email", ExternalSourceDiscord: "Discord", ExternalSourceMessenger: "Messenger", + ExternalSourceInstagram: "Instagram", } func GetExternalSourceLabel(v ExternalSource) string { diff --git a/internal/pkg/enums/wxwork_kf.go b/internal/pkg/enums/wxwork_kf.go index f190bf30..ac946141 100644 --- a/internal/pkg/enums/wxwork_kf.go +++ b/internal/pkg/enums/wxwork_kf.go @@ -26,6 +26,7 @@ const ( ChannelTypeEmail = "email" ChannelTypeDiscord = "discord" ChannelTypeMessenger = "messenger" + ChannelTypeInstagram = "instagram" ) type WxWorkKFMessageSendStatus string diff --git a/internal/services/channel_message_outbox_service.go b/internal/services/channel_message_outbox_service.go index 08fc2d45..5d7e1d14 100644 --- a/internal/services/channel_message_outbox_service.go +++ b/internal/services/channel_message_outbox_service.go @@ -441,6 +441,69 @@ func (s *channelMessageOutboxService) EnqueueMessengerMessage(conversation *mode return nil } +func (s *channelMessageOutboxService) EnqueueInstagramMessage(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.ChannelTypeInstagram { + return nil + } + if message.SenderType != enums.IMSenderTypeAgent && message.SenderType != enums.IMSenderTypeAI { + return nil + } + if message.MessageType != enums.IMMessageTypeText && message.MessageType != enums.IMMessageTypeHTML && message.MessageType != enums.IMMessageTypeImage && message.MessageType != enums.IMMessageTypeAttachment { + return nil + } + if existing := s.GetByMessageID(enums.ChannelTypeInstagram, 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.ChannelTypeInstagram, + 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 instagram outbound dispatch", "error", r) + } + }() + InstagramOutboundService.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 a0039bfc..2f5f0d36 100644 --- a/internal/services/channel_service.go +++ b/internal/services/channel_service.go @@ -491,6 +491,24 @@ func (s *channelService) ParseMessengerChannelConfig(raw string) (*dto.Messenger return cfg, nil } +func (s *channelService) ParseInstagramChannelConfig(raw string) (*dto.InstagramChannelConfig, error) { + raw = strings.TrimSpace(raw) + cfg := &dto.InstagramChannelConfig{} + if raw != "" { + if err := json.Unmarshal([]byte(raw), cfg); err != nil { + return nil, err + } + } + cfg.InstagramID = strings.TrimSpace(cfg.InstagramID) + cfg.InstagramUsername = strings.TrimSpace(cfg.InstagramUsername) + cfg.PageID = strings.TrimSpace(cfg.PageID) + cfg.PageAccessToken = strings.TrimSpace(cfg.PageAccessToken) + cfg.WebhookVerifyToken = strings.TrimSpace(cfg.WebhookVerifyToken) + cfg.AppSecret = strings.TrimSpace(cfg.AppSecret) + cfg.WelcomeMessage = strings.TrimSpace(cfg.WelcomeMessage) + return cfg, nil +} + func (s *channelService) GetUserTokenSecret(channel *models.Channel) string { if channel == nil { return "" @@ -701,7 +719,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 && channelType != enums.ChannelTypeEmail && channelType != enums.ChannelTypeDiscord && channelType != enums.ChannelTypeMessenger { + if channelType != enums.ChannelTypeWeb && channelType != enums.ChannelTypeWechatMP && channelType != enums.ChannelTypeWxWorkKF && channelType != enums.ChannelTypeTelegram && channelType != enums.ChannelTypeZaloOA && channelType != enums.ChannelTypeEmail && channelType != enums.ChannelTypeDiscord && channelType != enums.ChannelTypeMessenger && channelType != enums.ChannelTypeInstagram { return nil, errorsx.InvalidParamI18n("error.e0250") } name := strings.TrimSpace(req.Name) @@ -914,6 +932,27 @@ func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRe return nil, err } configJSON = string(configBytes) + case enums.ChannelTypeInstagram: + 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.ParseInstagramChannelConfig(configJSON) + if err != nil { + return nil, errorsx.InvalidParam("invalid instagram configuration") + } + if cfg.WebhookVerifyToken == "" { + if secret, err := generateUserTokenSecret(); err == nil { + cfg.WebhookVerifyToken = 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 773084a5..35bf3bb5 100644 --- a/internal/services/cronx/cron.go +++ b/internal/services/cronx/cron.go @@ -46,6 +46,10 @@ func Init() { if messengerCount > 0 { slog.Info("messenger outbox dispatched", "count", messengerCount) } + instagramCount := services.InstagramOutboundService.DispatchPendingOutbox() + if instagramCount > 0 { + slog.Info("instagram outbox dispatched", "count", instagramCount) + } }) c.Start() diff --git a/internal/services/instagram_inbound_service.go b/internal/services/instagram_inbound_service.go new file mode 100644 index 00000000..4de06b98 --- /dev/null +++ b/internal/services/instagram_inbound_service.go @@ -0,0 +1,169 @@ +package services + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "strings" + + "agent-desk/internal/messenger" + "agent-desk/internal/models" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/openidentity" +) + +var InstagramInboundService = newInstagramInboundService() + +func newInstagramInboundService() *instagramInboundService { + return &instagramInboundService{} +} + +type instagramInboundService struct{} + +// HandleWebhook processes an incoming Webhook event from Instagram Messaging API (Meta Graph Platform). +func (s *instagramInboundService) HandleWebhook(ctx context.Context, channelID string, signatureHeader string, rawPayload []byte) error { + var event messenger.WebhookEvent + if err := json.Unmarshal(rawPayload, &event); err != nil { + return fmt.Errorf("unmarshal instagram webhook failed: %w", err) + } + + if event.Object != "instagram" && event.Object != "page" { + return nil // Ignore unsupported object events + } + + for _, entry := range event.Entry { + accountID := strings.TrimSpace(entry.ID) + var channel *models.Channel + + channelID = strings.TrimSpace(channelID) + if channelID != "" { + channel = ChannelService.Take("channel_id = ? AND channel_type = ? AND status = ?", channelID, enums.ChannelTypeInstagram, enums.StatusOk) + } + if channel == nil && accountID != "" { + channel = ChannelService.Take("channel_type = ? AND status = ? AND (channel_id = ? OR config_json LIKE ?)", + enums.ChannelTypeInstagram, enums.StatusOk, accountID, "%"+accountID+"%") + } + if channel == nil { + channel = ChannelService.Take("channel_type = ? AND status = ?", enums.ChannelTypeInstagram, enums.StatusOk) + } + if channel == nil { + continue + } + + cfg, err := ChannelService.ParseInstagramChannelConfig(channel.ConfigJSON) + if err != nil || cfg == nil { + continue + } + + // Optional signature verification if appSecret is configured + appSecret := "" + if cfg != nil { + appSecret = strings.TrimSpace(cfg.AppSecret) + } + if appSecret == "" { + if serverCfg := config.GetCurrent(); serverCfg != nil { + appSecret = strings.TrimSpace(serverCfg.Messenger.AppSecret) + } + } + if appSecret == "" { + appSecret = strings.TrimSpace(os.Getenv("META_APP_SECRET")) + } + if appSecret == "" { + appSecret = strings.TrimSpace(os.Getenv("FB_APP_SECRET")) + } + + if appSecret != "" && strings.TrimSpace(signatureHeader) != "" { + if !verifyMessengerSignature(appSecret, signatureHeader, rawPayload) { + return errorsx.UnauthorizedI18n("error.auth.invalidSignature") + } + } + + for _, messaging := range entry.Messaging { + if messaging.Message == nil { + continue + } + + senderID := strings.TrimSpace(messaging.Sender.ID) + if senderID == "" || senderID == accountID { + continue // Ignore echo / self-sent messages + } + + text := strings.TrimSpace(messaging.Message.Text) + attachments := messaging.Message.Attachments + + if text == "" && len(attachments) > 0 { + firstAtt := attachments[0] + if firstAtt.Payload.Title != "" { + text = fmt.Sprintf("[%s] %s", firstAtt.Payload.Title, firstAtt.Payload.URL) + } else { + text = firstAtt.Payload.URL + } + } + + if text == "" && len(attachments) == 0 { + continue + } + + mid := messaging.Message.MID + if mid == "" { + mid = fmt.Sprintf("mid_%d", messaging.Timestamp) + } + + // 1. Resolve customer identity (IGSID) + externalUser := openidentity.ExternalUser{ + ExternalSource: enums.ExternalSourceInstagram, + ExternalID: senderID, + ExternalName: fmt.Sprintf("Instagram User %s", senderID), + } + + // 2. Create or match Conversation + conversation, err := ConversationService.Create(externalUser, channel.ID, channel.AIAgentID) + if err != nil { + return fmt.Errorf("create instagram conversation failed: %w", err) + } + + // 3. Send message through MessageService + clientMsgID := fmt.Sprintf("ig_%s", mid) + payloadMap := map[string]any{ + "instagram_mid": mid, + "instagram_sender_id": senderID, + "instagram_account_id": accountID, + "instagram_timestamp": messaging.Timestamp, + "instagram_attachments": attachments, + } + payloadBytes, _ := json.Marshal(payloadMap) + + _, err = MessageService.SendCustomerMessage( + conversation.ID, + clientMsgID, + enums.IMMessageTypeText, + text, + string(payloadBytes), + externalUser, + ) + if err != nil { + return fmt.Errorf("send customer message failed: %w", err) + } + } + } + + return nil +} + +func verifyInstagramSignature(appSecret string, signatureHeader string, payload []byte) bool { + signature := strings.TrimSpace(signatureHeader) + if strings.HasPrefix(signature, "sha256=") { + expectedSig := signature[len("sha256="):] + mac := hmac.New(sha256.New, []byte(appSecret)) + mac.Write(payload) + actualSig := hex.EncodeToString(mac.Sum(nil)) + return hmac.Equal([]byte(actualSig), []byte(expectedSig)) + } + return true +} diff --git a/internal/services/instagram_inbound_service_test.go b/internal/services/instagram_inbound_service_test.go new file mode 100644 index 00000000..1278a5d3 --- /dev/null +++ b/internal/services/instagram_inbound_service_test.go @@ -0,0 +1,162 @@ +package services + +import ( + "context" + "encoding/json" + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +func setupInstagramTestDB(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 instagram test tables: %v", err) + } + sqls.SetDB(db) + return db +} + +func TestInstagramInboundAndOutbound(t *testing.T) { + db := setupInstagramTestDB(t) + + now := time.Now() + aiAgent := &models.AIAgent{ + Name: "Instagram AI Agent", + Status: enums.StatusOk, + PublishedRevisionID: 1, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + if err := db.Create(aiAgent).Error; err != nil { + t.Fatalf("create ai agent: %v", err) + } + + instagramConfig := dto.InstagramChannelConfig{ + InstagramID: "ig_account_12345", + InstagramUsername: "acme_brand", + PageAccessToken: "test_ig_access_token", + WebhookVerifyToken: "verify_token_ig_789", + } + cfgBytes, _ := json.Marshal(instagramConfig) + + channel := &models.Channel{ + ChannelType: enums.ChannelTypeInstagram, + ChannelID: "ig_account_12345", + AIAgentID: aiAgent.ID, + AIAgentRolloutPercent: 100, + Name: "Instagram Brand Support", + ConfigJSON: string(cfgBytes), + Status: enums.StatusOk, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + if err := db.Create(channel).Error; err != nil { + t.Fatalf("create instagram channel: %v", err) + } + + payload := `{ + "object": "instagram", + "entry": [ + { + "id": "ig_account_12345", + "time": 1725260000, + "messaging": [ + { + "sender": { "id": "igsid_customer_888" }, + "recipient": { "id": "ig_account_12345" }, + "timestamp": 1725260000, + "message": { + "mid": "mid_ig_112233", + "text": "Hello, do you ship internationally?" + } + } + ] + } + ] + }` + + ctx := context.Background() + err := InstagramInboundService.HandleWebhook(ctx, "", "", []byte(payload)) + if err != nil { + t.Fatalf("HandleWebhook failed: %v", err) + } + + // Verify customer identity + identity := repositories.CustomerIdentityRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("external_source", enums.ExternalSourceInstagram). + Eq("external_id", "igsid_customer_888")) + if identity == nil { + t.Fatalf("expected customer identity to be created for igsid_customer_888") + } + + // Verify conversation + conv := repositories.ConversationRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("customer_id", identity.CustomerID). + Eq("channel_id", channel.ID)) + if conv == nil { + t.Fatalf("expected conversation to be created") + } + + // Verify message + msg := repositories.MessageRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("conversation_id", conv.ID). + Eq("sender_type", enums.IMSenderTypeCustomer)) + if msg == nil { + t.Fatalf("expected message to be created") + } + if msg.Content != "Hello, do you ship internationally?" { + t.Fatalf("expected message content 'Hello, do you ship internationally?', got %s", msg.Content) + } + + operator := &dto.AuthPrincipal{UserID: 1, Nickname: "Agent Joy"} + + // Test Outbound enqueue + replyMsg, err := MessageService.SendAIMessage(conv.ID, aiAgent.ID, "ai_ig_reply_1", enums.IMMessageTypeText, "Yes, we ship to over 50 countries!", "", operator) + if err != nil { + t.Fatalf("MessageService.SendAIMessage failed: %v", err) + } + + outbox := ChannelMessageOutboxService.GetByMessageID(enums.ChannelTypeInstagram, replyMsg.ID) + if outbox == nil { + t.Fatalf("expected outbox entry for instagram message") + } + if outbox.ChannelType != enums.ChannelTypeInstagram { + t.Fatalf("expected outbox channel type 'instagram', got %s", outbox.ChannelType) + } +} diff --git a/internal/services/instagram_outbound_service.go b/internal/services/instagram_outbound_service.go new file mode 100644 index 00000000..96cbc130 --- /dev/null +++ b/internal/services/instagram_outbound_service.go @@ -0,0 +1,189 @@ +package services + +import ( + "context" + "log/slog" + "strings" + "time" + + "agent-desk/internal/messenger" + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + "agent-desk/internal/services/storage" + + "github.com/mlogclub/simple/sqls" +) + +const ( + instagramOutboxBatchSize = 20 + instagramOutboxMaxRetry = 5 +) + +var InstagramOutboundService = newInstagramOutboundService() + +func newInstagramOutboundService() *instagramOutboundService { + return &instagramOutboundService{} +} + +type instagramOutboundService struct{} + +func (s *instagramOutboundService) DispatchPendingOutbox() int { + return s.doDispatchPendingOutbox(instagramOutboxBatchSize) +} + +func (s *instagramOutboundService) doDispatchPendingOutbox(limit int) int { + if limit <= 0 { + limit = instagramOutboxBatchSize + } + items := ChannelMessageOutboxService.ListPending(enums.ChannelTypeInstagram, 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 instagram outbox failed", + "outbox_id", items[i].ID, + "error", err, + ) + continue + } + successCount++ + } + return successCount +} + +func (s *instagramOutboundService) processOutbox(outboxID int64) error { + outbox := ChannelMessageOutboxService.Get(outboxID) + if outbox == nil { + return nil + } + if outbox.ChannelType != enums.ChannelTypeInstagram { + 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, "instagram channel not found or disabled") + } + cfg, err := ChannelService.ParseInstagramChannelConfig(channel.ConfigJSON) + if err != nil || cfg == nil || cfg.PageAccessToken == "" { + return s.markOutboxFailed(outbox, "instagram page access token not configured") + } + + // Resolve target Instagram IGSID + var igsid string + customerIdentity := repositories.CustomerIdentityRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("customer_id", conversation.CustomerID). + Eq("external_source", enums.ExternalSourceInstagram)) + if customerIdentity != nil { + igsid = strings.TrimSpace(customerIdentity.ExternalID) + } + if igsid == "" { + return s.markOutboxFailed(outbox, "unable to resolve instagram igsid") + } + + // Send message via Meta Graph API + client := messenger.NewClient(cfg.PageAccessToken) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var sendErr error + if message.MessageType == enums.IMMessageTypeImage { + assetPayload, err := parseIMMessageAssetPayload(message.Payload) + var imageURL string + if err == nil && assetPayload != nil { + assetPayload = hydrateIMMessageAssetPayload(assetPayload) + if assetPayload.Provider != "" && assetPayload.StorageKey != "" { + if provider, err := storage.NewProvider(assetPayload.Provider); err == nil { + imageURL = provider.GetSignedURL(assetPayload.StorageKey) + } + } + } + if imageURL == "" && strings.HasPrefix(strings.TrimSpace(message.Content), "http") { + imageURL = strings.TrimSpace(message.Content) + } + + if imageURL != "" { + _, sendErr = client.SendMediaMessage(ctx, igsid, "image", imageURL) + } else { + _, sendErr = client.SendTextMessage(ctx, igsid, message.Content) + } + } else if message.MessageType == enums.IMMessageTypeAttachment { + assetPayload, err := parseIMMessageAssetPayload(message.Payload) + var fileURL string + if err == nil && assetPayload != nil { + assetPayload = hydrateIMMessageAssetPayload(assetPayload) + if assetPayload.Provider != "" && assetPayload.StorageKey != "" { + if provider, err := storage.NewProvider(assetPayload.Provider); err == nil { + fileURL = provider.GetSignedURL(assetPayload.StorageKey) + } + } + } + if fileURL == "" && strings.HasPrefix(strings.TrimSpace(message.Content), "http") { + fileURL = strings.TrimSpace(message.Content) + } + + if fileURL != "" { + _, sendErr = client.SendMediaMessage(ctx, igsid, "file", fileURL) + } else { + _, sendErr = client.SendTextMessage(ctx, igsid, message.Content) + } + } else { + _, sendErr = client.SendTextMessage(ctx, igsid, 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 *instagramOutboundService) markOutboxFailed(outbox *models.ChannelMessageOutbox, errMsg string) error { + if outbox == nil { + return nil + } + retryCount := outbox.RetryCount + 1 + status := string(enums.ChannelMessageOutboxStatusFailed) + if retryCount >= instagramOutboxMaxRetry { + 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/message_service.go b/internal/services/message_service.go index 79222305..2a520269 100644 --- a/internal/services/message_service.go +++ b/internal/services/message_service.go @@ -590,6 +590,15 @@ func (s *messageService) sendValidatedMessage(conversation *models.Conversation, "error", enqueueErr, ) } + + // Instagram 渠道消息入队,异步发送 + if enqueueErr := ChannelMessageOutboxService.EnqueueInstagramMessage(conversation, message); enqueueErr != nil { + slog.Error("enqueue instagram outbox failed", + "conversation_id", conversation.ID, + "message_id", message.ID, + "error", enqueueErr, + ) + } // 客户发送消息,触发AI回复 if senderType == enums.IMSenderTypeCustomer { if TriggerAIReplyAsyncHook != nil { diff --git a/web/app/(dashboard)/dashboard/channels/_components/edit.tsx b/web/app/(dashboard)/dashboard/channels/_components/edit.tsx index fa99e805..d4826adc 100644 --- a/web/app/(dashboard)/dashboard/channels/_components/edit.tsx +++ b/web/app/(dashboard)/dashboard/channels/_components/edit.tsx @@ -102,6 +102,15 @@ type MessengerChannelConfig = { appSecret?: string } +type InstagramChannelConfig = { + instagramId?: string + instagramUsername?: string + pageId?: string + pageAccessToken?: string + webhookVerifyToken?: string + appSecret?: string +} + function getDefaultWebChannelConfig(t: Translate): Required { return { title: t("channel.defaultTitleWeb"), @@ -116,7 +125,7 @@ function getDefaultWebChannelConfig(t: Translate): Required { function createSchema(t: Translate) { return z .object({ - channelType: z.enum(["web", "wechat_mp", "wxwork_kf", "telegram", "zalo_oa", "email", "discord", "messenger"], t("channel.typeRequired")), + channelType: z.enum(["web", "wechat_mp", "wxwork_kf", "telegram", "zalo_oa", "email", "discord", "messenger", "instagram"], 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")), @@ -136,6 +145,12 @@ function createSchema(t: Translate) { messengerPageAccessToken: z.string().trim(), messengerWebhookVerifyToken: z.string().trim(), messengerAppSecret: z.string().trim(), + instagramId: z.string().trim(), + instagramUsername: z.string().trim(), + instagramPageId: z.string().trim(), + instagramPageAccessToken: z.string().trim(), + instagramWebhookVerifyToken: z.string().trim(), + instagramAppSecret: z.string().trim(), emailAddress: z.string().trim(), senderName: z.string().trim(), emailProvider: z.string().trim(), @@ -185,7 +200,7 @@ function createSchema(t: Translate) { } type EditForm = { - channelType: "web" | "wechat_mp" | "wxwork_kf" | "telegram" | "zalo_oa" | "email" | "discord" | "messenger" + channelType: "web" | "wechat_mp" | "wxwork_kf" | "telegram" | "zalo_oa" | "email" | "discord" | "messenger" | "instagram" aiAgentId: string aiAgentRolloutPercent: number name: string @@ -205,6 +220,12 @@ type EditForm = { messengerPageAccessToken: string messengerWebhookVerifyToken: string messengerAppSecret: string + instagramId: string + instagramUsername: string + instagramPageId: string + instagramPageAccessToken: string + instagramWebhookVerifyToken: string + instagramAppSecret: string emailAddress: string senderName: string emailProvider: string @@ -245,6 +266,12 @@ function createEmptyForm(t: Translate): EditForm { messengerPageAccessToken: "", messengerWebhookVerifyToken: "", messengerAppSecret: "", + instagramId: "", + instagramUsername: "", + instagramPageId: "", + instagramPageAccessToken: "", + instagramWebhookVerifyToken: "", + instagramAppSecret: "", emailAddress: "help@crove.com", senderName: "Crove Desk Support", emailProvider: "brevo", @@ -398,6 +425,23 @@ function parseMessengerChannelConfig(configJson: string): MessengerChannelConfig } } +function parseInstagramChannelConfig(configJson: string): InstagramChannelConfig { + if (!configJson.trim()) return {} + try { + const parsed = JSON.parse(configJson) as InstagramChannelConfig + return { + instagramId: parsed.instagramId?.trim() || "", + instagramUsername: parsed.instagramUsername?.trim() || "", + pageId: parsed.pageId?.trim() || "", + pageAccessToken: parsed.pageAccessToken?.trim() || "", + webhookVerifyToken: parsed.webhookVerifyToken?.trim() || "", + appSecret: parsed.appSecret?.trim() || "", + } + } catch { + return {} + } +} + function buildForm(item: AdminChannel | null, t: Translate): EditForm { if (!item) { return createEmptyForm(t) @@ -408,6 +452,7 @@ function buildForm(item: AdminChannel | null, t: Translate): EditForm { const isEmail = item.channelType === "email" const isDiscord = item.channelType === "discord" const isMessenger = item.channelType === "messenger" + const isInstagram = item.channelType === "instagram" const webConfig = parseWebChannelConfig(item.configJson, t) const wechatConfig = isWechatMP ? parseWechatMPChannelConfig(item.configJson, t) @@ -427,6 +472,9 @@ function buildForm(item: AdminChannel | null, t: Translate): EditForm { const messengerConfig = isMessenger ? parseMessengerChannelConfig(item.configJson) : null + const instagramConfig = isInstagram + ? parseInstagramChannelConfig(item.configJson) + : null return { channelType: item.channelType === "wxwork_kf" @@ -439,11 +487,13 @@ function buildForm(item: AdminChannel | null, t: Translate): EditForm { ? "discord" : item.channelType === "messenger" ? "messenger" - : item.channelType === "email" - ? "email" - : item.channelType === "wechat_mp" - ? "wechat_mp" - : "web", + : item.channelType === "instagram" + ? "instagram" + : 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, @@ -463,6 +513,12 @@ function buildForm(item: AdminChannel | null, t: Translate): EditForm { messengerPageAccessToken: messengerConfig?.pageAccessToken ?? "", messengerWebhookVerifyToken: messengerConfig?.webhookVerifyToken ?? "", messengerAppSecret: messengerConfig?.appSecret ?? "", + instagramId: instagramConfig?.instagramId ?? "", + instagramUsername: instagramConfig?.instagramUsername ?? "", + instagramPageId: instagramConfig?.pageId ?? "", + instagramPageAccessToken: instagramConfig?.pageAccessToken ?? "", + instagramWebhookVerifyToken: instagramConfig?.webhookVerifyToken ?? "", + instagramAppSecret: instagramConfig?.appSecret ?? "", emailAddress: emailConfig?.emailAddress || "help@crove.com", senderName: emailConfig?.senderName || "Crove Desk Support", emailProvider: emailConfig?.provider || "brevo", @@ -530,6 +586,15 @@ function buildPayload(form: EditForm, status: number, t: Translate): CreateAdmin webhookVerifyToken: form.messengerWebhookVerifyToken.trim(), appSecret: form.messengerAppSecret.trim(), }) + : channelType === "instagram" + ? JSON.stringify({ + instagramId: form.instagramId.trim(), + instagramUsername: form.instagramUsername.trim(), + pageId: form.instagramPageId.trim(), + pageAccessToken: form.instagramPageAccessToken.trim(), + webhookVerifyToken: form.instagramWebhookVerifyToken.trim(), + appSecret: form.instagramAppSecret.trim(), + }) : channelType === "wechat_mp" ? JSON.stringify(webLikeConfig) : JSON.stringify({ @@ -757,6 +822,7 @@ function ChannelFormBody({ { value: "email", label: t("channel.typeEmail") }, { value: "discord", label: t("channel.typeDiscord") }, { value: "messenger", label: t("channel.typeMessenger") }, + { value: "instagram", label: t("channel.typeInstagram") }, { value: "telegram", label: t("channel.typeTelegram") }, { value: "zalo_oa", label: t("channel.typeZaloOa") }, { value: "wechat_mp", label: t("channel.typeWechatMp") }, @@ -1221,6 +1287,71 @@ function ChannelFormBody({

) : null} + {channelType === "instagram" ? ( +
+
+
{t("channel.instagramConnectTitle")}
+
{t("channel.instagramConnectDescription")}
+
+ +
+
+ {t("channel.inboundWebhookUrl")}: /api/third/instagram/webhook +
+
+ +
+ + {t("channel.instagramUsername")} + + + + + + + + {t("channel.instagramId")} + + + + + +
+ + + {t("channel.instagramPageAccessToken")} + + + + + +
+ ) : 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 f7eceac2..e6a8194a 100644 --- a/web/app/(dashboard)/dashboard/channels/page.tsx +++ b/web/app/(dashboard)/dashboard/channels/page.tsx @@ -3,6 +3,7 @@ import { Building2Icon, Gamepad2Icon, + InstagramIcon, MailIcon, MessageCircleIcon, MessagesSquareIcon, @@ -39,6 +40,9 @@ function getChannelTypeLabel(channelType: string, t: (key: string) => string) { if (channelType === "messenger") { return t("channel.typeMessenger") } + if (channelType === "instagram") { + return t("channel.typeInstagram") + } if (channelType === "wechat_mp") { return t("channel.typeWechatMp") } @@ -74,6 +78,9 @@ function ChannelIcon({ channelType }: { channelType: string }) { if (channelType === "messenger") { return } + if (channelType === "instagram") { + return + } if (channelType === "wechat_mp") { return } @@ -101,6 +108,7 @@ export default function DashboardChannelsPage() { { value: "email", label: t("channel.typeEmail") }, { value: "discord", label: t("channel.typeDiscord") }, { value: "messenger", label: t("channel.typeMessenger") }, + { value: "instagram", label: t("channel.typeInstagram") }, { 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 6b81c755..f9d111c9 100644 --- a/web/lib/generated/enums.ts +++ b/web/lib/generated/enums.ts @@ -83,6 +83,7 @@ export enum ExternalSource { Email = "email", Discord = "discord", Messenger = "messenger", + Instagram = "instagram", } export const ExternalSourceLabels: Record = { [ExternalSource.Guest]: "访客", @@ -94,6 +95,7 @@ export const ExternalSourceLabels: Record = { [ExternalSource.Email]: "Email", [ExternalSource.Discord]: "Discord", [ExternalSource.Messenger]: "Messenger", + [ExternalSource.Instagram]: "Instagram", } export enum Gender { diff --git a/web/messages/en-US.json b/web/messages/en-US.json index d8cdca0f..fada160c 100644 --- a/web/messages/en-US.json +++ b/web/messages/en-US.json @@ -624,6 +624,7 @@ "typeZaloOa": "Zalo Official Account", "typeDiscord": "Discord Community", "typeMessenger": "Facebook Messenger", + "typeInstagram": "Instagram Direct", "typeWechatMp": "WeChat Official Account", "typeWxworkKf": "WeCom Customer Service", "emailAddress": "Support Email Address", @@ -669,6 +670,12 @@ "messengerPageAccessToken": "Page Access Token", "messengerWebhookVerifyToken": "Webhook Verify Token", "messengerAppSecret": "App Secret (Enterprise Custom App)", + "instagramConnectTitle": "1-Click Instagram Direct Connection", + "instagramConnectDescription": "Connect your Instagram Professional / Business account to Crove Desk. Inbound Direct Messages will be automatically routed to your workbench and AI agent.", + "connectInstagramButton": "Connect Instagram Account", + "instagramUsername": "Instagram @Username", + "instagramId": "Instagram Business Account ID", + "instagramPageAccessToken": "Instagram / Page Access Token", "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 27078abe..cb0f060a 100644 --- a/web/messages/vi-VN.json +++ b/web/messages/vi-VN.json @@ -631,6 +631,7 @@ "typeZaloOa": "Zalo Official Account", "typeDiscord": "Cộng đồng Discord", "typeMessenger": "Facebook Messenger", + "typeInstagram": "Instagram Direct", "typeWechatMp": "WeChat Official Account", "typeWxworkKf": "WeCom Customer Service", "emailAddress": "Địa chỉ Email Hỗ trợ", @@ -676,6 +677,12 @@ "messengerPageAccessToken": "Page Access Token", "messengerWebhookVerifyToken": "Mã xác thực Webhook (Verify Token)", "messengerAppSecret": "Meta App Secret (Dành cho Custom App Doanh nghiệp)", + "instagramConnectTitle": "Kết nối Instagram Direct 1-Click", + "instagramConnectDescription": "Kết nối tài khoản Instagram Doanh nghiệp của bạn với Crove Desk chỉ bằng 1 chạm. Tin nhắn Direct Messages từ khách hàng sẽ được tự động đồng bộ vào Workbench và AI Agent.", + "connectInstagramButton": "Kết nối Instagram Account", + "instagramUsername": "Instagram @Username", + "instagramId": "Instagram Business Account ID", + "instagramPageAccessToken": "Instagram / Page Access Token", "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 9868dde5..8428616d 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -624,6 +624,7 @@ "typeZaloOa": "Zalo 公众号", "typeDiscord": "Discord 社区", "typeMessenger": "Facebook Messenger", + "typeInstagram": "Instagram Direct", "typeWechatMp": "微信公众号", "typeWxworkKf": "企业微信客服", "emailAddress": "支持邮箱地址", @@ -669,6 +670,12 @@ "messengerPageAccessToken": "Page Access Token", "messengerWebhookVerifyToken": "Webhook 校验 Token (Verify Token)", "messengerAppSecret": "Meta App Secret (企业独立应用)", + "instagramConnectTitle": "Instagram Direct 一键授权连接", + "instagramConnectDescription": "一键连接您的 Instagram 商业/专业主页,私信对话将自动接入客服工作台并触发 AI 回复。", + "connectInstagramButton": "一键连接 Instagram Account", + "instagramUsername": "Instagram @账号", + "instagramId": "Instagram Business Account ID", + "instagramPageAccessToken": "Instagram / Page Access Token", "loadFailed": "加载接入渠道失败", "created": "已创建接入渠道:{name}", "updated": "已更新接入渠道:{name}", From 7637e14f745ebc68d09af3d2e64569c063368760 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:37:41 +0700 Subject: [PATCH 14/30] fix(channel): derive human-friendly workspace slug for inbound forwarding address --- .../dashboard/channels/_components/edit.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/web/app/(dashboard)/dashboard/channels/_components/edit.tsx b/web/app/(dashboard)/dashboard/channels/_components/edit.tsx index d4826adc..35d94f8f 100644 --- a/web/app/(dashboard)/dashboard/channels/_components/edit.tsx +++ b/web/app/(dashboard)/dashboard/channels/_components/edit.tsx @@ -701,8 +701,17 @@ function ChannelFormBody({ const active = res.organizations.find((o) => o.id === res.currentOrganizationId) || res.organizations[0] - if (active?.code) { - setOrgSlug(active.code.toLowerCase()) + if (active) { + let slug = "" + if (active.code && !active.code.startsWith("org_") && !active.code.startsWith("org-")) { + slug = active.code.toLowerCase() + } else if (active.name) { + slug = active.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") + } + if (!slug && active.code) { + slug = active.code.toLowerCase() + } + setOrgSlug(slug || "dos") } } catch { // fallback to default org From a1215c4dc7fe5b7b6f24aa39c2cf3ed2e66101f4 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:01:46 +0700 Subject: [PATCH 15/30] feat(channels): add whatsapp business cloud api and slack workspace integration --- .env.example | 12 + internal/bootstrap/routes.go | 14 + internal/bootstrap/server.go | 2 + .../dashboard/channel_oauth_handler.go | 78 +++++ internal/handlers/third/slack_handler.go | 43 +++ internal/handlers/third/whatsapp_handler.go | 71 +++++ .../third/whatsapp_slack_handler_test.go | 217 ++++++++++++++ internal/pkg/dto/dto.go | 19 ++ internal/pkg/enums/external_identity.go | 4 + internal/pkg/enums/wxwork_kf.go | 2 + .../channel_message_outbox_service.go | 126 ++++++++ internal/services/channel_service.go | 74 ++++- internal/services/cronx/cron.go | 8 + internal/services/message_service.go | 18 ++ internal/services/slack_inbound_service.go | 134 +++++++++ .../services/slack_inbound_service_test.go | 157 ++++++++++ internal/services/slack_outbound_service.go | 180 +++++++++++ internal/services/whatsapp_inbound_service.go | 176 +++++++++++ .../services/whatsapp_inbound_service_test.go | 177 +++++++++++ .../services/whatsapp_outbound_service.go | 188 ++++++++++++ internal/slack/client.go | 109 +++++++ internal/slack/types.go | 37 +++ internal/whatsapp/client.go | 159 ++++++++++ internal/whatsapp/types.go | 82 +++++ .../dashboard/channels/_components/edit.tsx | 279 +++++++++++++++++- .../(dashboard)/dashboard/channels/page.tsx | 16 + web/lib/generated/enums.ts | 4 + web/messages/en-US.json | 15 + web/messages/vi-VN.json | 15 + web/messages/zh-CN.json | 15 + 30 files changed, 2423 insertions(+), 8 deletions(-) create mode 100644 internal/handlers/third/slack_handler.go create mode 100644 internal/handlers/third/whatsapp_handler.go create mode 100644 internal/handlers/third/whatsapp_slack_handler_test.go create mode 100644 internal/services/slack_inbound_service.go create mode 100644 internal/services/slack_inbound_service_test.go create mode 100644 internal/services/slack_outbound_service.go create mode 100644 internal/services/whatsapp_inbound_service.go create mode 100644 internal/services/whatsapp_inbound_service_test.go create mode 100644 internal/services/whatsapp_outbound_service.go create mode 100644 internal/slack/client.go create mode 100644 internal/slack/types.go create mode 100644 internal/whatsapp/client.go create mode 100644 internal/whatsapp/types.go diff --git a/.env.example b/.env.example index d9338412..12032bea 100644 --- a/.env.example +++ b/.env.example @@ -105,3 +105,15 @@ BREVO_API_KEY=xkeysib-your-brevo-api-key # META_APP_ID=your-meta-app-id # META_APP_SECRET=your-meta-app-secret # MESSENGER_VERIFY_TOKEN=your-webhook-verify-token + +# WhatsApp Cloud API Integration (Meta Graph API) +# WHATSAPP_ACCESS_TOKEN=your-whatsapp-system-user-token +# WHATSAPP_PHONE_NUMBER_ID=your-whatsapp-phone-number-id +# WHATSAPP_WABA_ID=your-whatsapp-business-account-id +# WHATSAPP_VERIFY_TOKEN=your-whatsapp-verify-token + +# Slack Bot Integration (Slack Web API & Events API) +# SLACK_CLIENT_ID=your-slack-client-id +# SLACK_CLIENT_SECRET=your-slack-client-secret +# SLACK_BOT_TOKEN=xoxb-your-slack-bot-token +# SLACK_SIGNING_SECRET=your-slack-signing-secret diff --git a/internal/bootstrap/routes.go b/internal/bootstrap/routes.go index 95a36010..355e2ad9 100644 --- a/internal/bootstrap/routes.go +++ b/internal/bootstrap/routes.go @@ -234,6 +234,8 @@ func registerDashboardChannelRoutes(group *gin.RouterGroup) { group.GET("/discord_oauth_url", dashboard.ChannelGetDiscordOAuthURL) group.GET("/messenger_oauth_url", dashboard.ChannelGetMessengerOAuthURL) group.GET("/instagram_oauth_url", dashboard.ChannelGetInstagramOAuthURL) + group.GET("/whatsapp_oauth_url", dashboard.ChannelGetWhatsAppOAuthURL) + group.GET("/slack_oauth_url", dashboard.ChannelGetSlackOAuthURL) group.Any("/wxwork/kf/accounts", dashboard.ChannelAnyWxworkKfAccounts) group.Any("/wxwork/outbox/failed/list", dashboard.ChannelAnyWxworkOutboxFailedList) group.POST("/wxwork/outbox/retry", dashboard.ChannelPostWxworkOutboxRetry) @@ -470,3 +472,15 @@ func registerThirdInstagramRoutes(group *gin.RouterGroup) { group.POST("/webhook", third.InstagramPostWebhook) group.POST("/webhook/:channel_id", third.InstagramPostWebhook) } + +func registerThirdWhatsAppRoutes(group *gin.RouterGroup) { + group.GET("/webhook", third.WhatsAppGetWebhook) + group.GET("/webhook/:channel_id", third.WhatsAppGetWebhook) + group.POST("/webhook", third.WhatsAppPostWebhook) + group.POST("/webhook/:channel_id", third.WhatsAppPostWebhook) +} + +func registerThirdSlackRoutes(group *gin.RouterGroup) { + group.POST("/webhook", third.SlackPostWebhook) + group.POST("/webhook/:channel_id", third.SlackPostWebhook) +} diff --git a/internal/bootstrap/server.go b/internal/bootstrap/server.go index 6b8afca4..f011c80e 100644 --- a/internal/bootstrap/server.go +++ b/internal/bootstrap/server.go @@ -201,6 +201,8 @@ func addRouter(app *gin.Engine) { registerThirdDiscordRoutes(thirdGroup.Group("/discord")) registerThirdMessengerRoutes(thirdGroup.Group("/messenger")) registerThirdInstagramRoutes(thirdGroup.Group("/instagram")) + registerThirdWhatsAppRoutes(thirdGroup.Group("/whatsapp")) + registerThirdSlackRoutes(thirdGroup.Group("/slack")) } type spaShellRewrite struct { diff --git a/internal/handlers/dashboard/channel_oauth_handler.go b/internal/handlers/dashboard/channel_oauth_handler.go index 173dd275..4d712dd0 100644 --- a/internal/handlers/dashboard/channel_oauth_handler.go +++ b/internal/handlers/dashboard/channel_oauth_handler.go @@ -147,3 +147,81 @@ func ChannelGetInstagramOAuthURL(ctx *gin.Context) { "redirectUri": redirectURI, })) } + +// ChannelGetWhatsAppOAuthURL returns the 1-Click Embedded Signup / OAuth URL for WhatsApp Cloud API. +func ChannelGetWhatsAppOAuthURL(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionChannelView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + + appID := "" + if cfg := config.GetCurrent(); cfg != nil { + appID = strings.TrimSpace(cfg.Messenger.AppID) + } + if appID == "" { + appID = strings.TrimSpace(os.Getenv("META_APP_ID")) + } + if appID == "" { + appID = strings.TrimSpace(ctx.Query("app_id")) + } + redirectURI := strings.TrimSpace(ctx.Query("redirect_uri")) + + if appID == "" { + appID = "123456789012345" + } + + state := strings.TrimSpace(ctx.Query("state")) + if state == "" { + state = "crove_whatsapp_connect" + } + + authURL := fmt.Sprintf( + "https://www.facebook.com/v21.0/dialog/oauth?client_id=%s&redirect_uri=%s&scope=whatsapp_business_management,whatsapp_business_messaging&state=%s", + url.QueryEscape(appID), + url.QueryEscape(redirectURI), + url.QueryEscape(state), + ) + + httpx.WriteJSON(ctx, web.JsonData(gin.H{ + "authUrl": authURL, + "appId": appID, + "redirectUri": redirectURI, + })) +} + +// ChannelGetSlackOAuthURL returns the 1-Click OAuth authorization URL for Slack Workspace Bot. +func ChannelGetSlackOAuthURL(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionChannelView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + + clientID := strings.TrimSpace(os.Getenv("SLACK_CLIENT_ID")) + if clientID == "" { + clientID = strings.TrimSpace(ctx.Query("client_id")) + } + redirectURI := strings.TrimSpace(ctx.Query("redirect_uri")) + + if clientID == "" { + clientID = "123456789012.1234567890123" + } + + state := strings.TrimSpace(ctx.Query("state")) + if state == "" { + state = "crove_slack_connect" + } + + authURL := fmt.Sprintf( + "https://slack.com/oauth/v2/authorize?client_id=%s&scope=chat:write,channels:history,channels:read,im:history,im:read,im:write,app_mentions:read&redirect_uri=%s&state=%s", + url.QueryEscape(clientID), + url.QueryEscape(redirectURI), + url.QueryEscape(state), + ) + + httpx.WriteJSON(ctx, web.JsonData(gin.H{ + "authUrl": authURL, + "clientId": clientID, + "redirectUri": redirectURI, + })) +} diff --git a/internal/handlers/third/slack_handler.go b/internal/handlers/third/slack_handler.go new file mode 100644 index 00000000..215c996d --- /dev/null +++ b/internal/handlers/third/slack_handler.go @@ -0,0 +1,43 @@ +package third + +import ( + "bytes" + "io" + "net/http" + "strings" + + "agent-desk/internal/services" + + "github.com/gin-gonic/gin" +) + +// SlackPostWebhook receives incoming Events API payloads from Slack. +func SlackPostWebhook(ctx *gin.Context) { + channelID := strings.TrimSpace(ctx.Param("channel_id")) + if channelID == "" { + channelID = strings.TrimSpace(ctx.Query("channel_id")) + } + + timestampHeader := ctx.GetHeader("X-Slack-Request-Timestamp") + signatureHeader := ctx.GetHeader("X-Slack-Signature") + + 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)) + + challenge, err := services.SlackInboundService.HandleWebhook(ctx.Request.Context(), channelID, timestampHeader, signatureHeader, bodyBytes) + if err != nil { + ctx.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()}) + return + } + + if challenge != nil { + ctx.JSON(http.StatusOK, gin.H{"challenge": *challenge}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"ok": true}) +} diff --git a/internal/handlers/third/whatsapp_handler.go b/internal/handlers/third/whatsapp_handler.go new file mode 100644 index 00000000..bd283e01 --- /dev/null +++ b/internal/handlers/third/whatsapp_handler.go @@ -0,0 +1,71 @@ +package third + +import ( + "bytes" + "io" + "net/http" + "strings" + + "agent-desk/internal/pkg/enums" + "agent-desk/internal/services" + + "github.com/gin-gonic/gin" +) + +// WhatsAppGetWebhook handles Meta WhatsApp Webhook verification (hub.challenge). +func WhatsAppGetWebhook(ctx *gin.Context) { + mode := strings.TrimSpace(ctx.Query("hub.mode")) + token := strings.TrimSpace(ctx.Query("hub.verify_token")) + challenge := strings.TrimSpace(ctx.Query("hub.challenge")) + + channelID := strings.TrimSpace(ctx.Param("channel_id")) + if channelID == "" { + channelID = strings.TrimSpace(ctx.Query("channel_id")) + } + + if mode == "subscribe" { + if channelID != "" { + channel := services.ChannelService.Take("channel_id = ? AND channel_type = ? AND status = ?", channelID, enums.ChannelTypeWhatsApp, enums.StatusOk) + if channel != nil { + if cfg, err := services.ChannelService.ParseWhatsAppChannelConfig(channel.ConfigJSON); err == nil && cfg != nil { + if cfg.WebhookVerifyToken != "" && cfg.WebhookVerifyToken != token { + ctx.String(http.StatusForbidden, "Verification token mismatch") + return + } + } + } + } + + ctx.String(http.StatusOK, challenge) + return + } + + ctx.String(http.StatusBadRequest, "Invalid verification request") +} + +// WhatsAppPostWebhook receives incoming Webhook events from Meta WhatsApp Cloud API. +func WhatsAppPostWebhook(ctx *gin.Context) { + channelID := strings.TrimSpace(ctx.Param("channel_id")) + if channelID == "" { + channelID = strings.TrimSpace(ctx.Query("channel_id")) + } + + sigHeader := ctx.GetHeader("X-Hub-Signature-256") + if sigHeader == "" { + sigHeader = ctx.GetHeader("X-Hub-Signature") + } + + 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.WhatsAppInboundService.HandleWebhook(ctx.Request.Context(), channelID, sigHeader, bodyBytes); err != nil { + ctx.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "EVENT_RECEIVED"}) +} diff --git a/internal/handlers/third/whatsapp_slack_handler_test.go b/internal/handlers/third/whatsapp_slack_handler_test.go new file mode 100644 index 00000000..ab31a880 --- /dev/null +++ b/internal/handlers/third/whatsapp_slack_handler_test.go @@ -0,0 +1,217 @@ +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 TestWhatsAppWebhook_Handler(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupThirdHandlerTestDB(t) + + now := time.Now() + agent := &models.AIAgent{ + Name: "WhatsApp Agent", + ServiceMode: enums.IMConversationServiceModeAIFirst, + PublishedRevisionID: 1, + WelcomeMessage: "Hello WhatsApp User!", + Status: enums.StatusOk, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + _ = db.Create(agent) + + waConfig, _ := json.Marshal(dto.WhatsAppChannelConfig{ + PhoneNumberID: "phone_112233", + WABAID: "waba_445566", + AccessToken: "test_wa_token", + WebhookVerifyToken: "my_wa_verify_token_999", + }) + + operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"} + channel, err := services.ChannelService.CreateChannel(request.CreateChannelRequest{ + Name: "WhatsApp Support", + ChannelType: enums.ChannelTypeWhatsApp, + AIAgentID: agent.ID, + AIAgentRolloutPercent: 100, + ConfigJSON: string(waConfig), + Status: int(enums.StatusOk), + }, operator) + if err != nil { + t.Fatalf("CreateChannel failed: %v", err) + } + + router := gin.New() + router.GET("/api/third/whatsapp/webhook/:channel_id", WhatsAppGetWebhook) + router.GET("/api/third/whatsapp/webhook", WhatsAppGetWebhook) + router.POST("/api/third/whatsapp/webhook/:channel_id", WhatsAppPostWebhook) + router.POST("/api/third/whatsapp/webhook", WhatsAppPostWebhook) + + // 1. GET Verification Challenge + reqGet, _ := http.NewRequest(http.MethodGet, "/api/third/whatsapp/webhook/"+channel.ChannelID+"?hub.mode=subscribe&hub.verify_token=my_wa_verify_token_999&hub.challenge=wa_challenge_code", nil) + recGet := httptest.NewRecorder() + router.ServeHTTP(recGet, reqGet) + + if recGet.Code != http.StatusOK { + t.Fatalf("expected 200 OK for challenge, got: %d", recGet.Code) + } + if recGet.Body.String() != "wa_challenge_code" { + t.Fatalf("expected challenge code in body, got: %s", recGet.Body.String()) + } + + // 2. POST Inbound Message + payload := []byte(`{ + "object": "whatsapp_business_account", + "entry": [ + { + "id": "waba_445566", + "changes": [ + { + "field": "messages", + "value": { + "messaging_product": "whatsapp", + "metadata": { + "phone_number_id": "phone_112233" + }, + "contacts": [ + { + "profile": { "name": "Customer John" }, + "wa_id": "1234567890" + } + ], + "messages": [ + { + "from": "1234567890", + "id": "wamid_001", + "timestamp": "1725260000", + "type": "text", + "text": { "body": "Need pricing details" } + } + ] + } + } + ] + } + ] + }`) + + reqPost, _ := http.NewRequest(http.MethodPost, "/api/third/whatsapp/webhook/"+channel.ChannelID, bytes.NewBuffer(payload)) + reqPost.Header.Set("Content-Type", "application/json") + recPost := httptest.NewRecorder() + router.ServeHTTP(recPost, reqPost) + + if recPost.Code != http.StatusOK { + t.Fatalf("expected 200 OK for POST webhook, got: %d", recPost.Code) + } + + // Verify identity in DB + identity := repositories.CustomerIdentityRepository.FindOne(db, sqls.NewCnd(). + Eq("external_source", enums.ExternalSourceWhatsApp). + Eq("external_id", "1234567890")) + if identity == nil { + t.Fatalf("expected customer identity for 1234567890") + } +} + +func TestSlackWebhook_Handler(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupThirdHandlerTestDB(t) + + now := time.Now() + agent := &models.AIAgent{ + Name: "Slack Agent", + ServiceMode: enums.IMConversationServiceModeAIFirst, + PublishedRevisionID: 1, + WelcomeMessage: "Hello Slack User!", + Status: enums.StatusOk, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + _ = db.Create(agent) + + slackConfig, _ := json.Marshal(dto.SlackChannelConfig{ + BotToken: "xoxb-test-token", + SigningSecret: "test_signing_secret", + TeamID: "T_SLACK_100", + DefaultChannel: "C_GENERAL", + }) + + operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"} + channel, err := services.ChannelService.CreateChannel(request.CreateChannelRequest{ + Name: "Slack Channel", + ChannelType: enums.ChannelTypeSlack, + AIAgentID: agent.ID, + AIAgentRolloutPercent: 100, + ConfigJSON: string(slackConfig), + Status: int(enums.StatusOk), + }, operator) + if err != nil { + t.Fatalf("CreateChannel failed: %v", err) + } + + router := gin.New() + router.POST("/api/third/slack/webhook/:channel_id", SlackPostWebhook) + router.POST("/api/third/slack/webhook", SlackPostWebhook) + + // 1. URL Verification + challengePayload := []byte(`{ + "token": "token123", + "challenge": "slack_challenge_string_999", + "type": "url_verification" + }`) + reqChallenge, _ := http.NewRequest(http.MethodPost, "/api/third/slack/webhook/"+channel.ChannelID, bytes.NewBuffer(challengePayload)) + reqChallenge.Header.Set("Content-Type", "application/json") + recChallenge := httptest.NewRecorder() + router.ServeHTTP(recChallenge, reqChallenge) + + if recChallenge.Code != http.StatusOK { + t.Fatalf("expected 200 OK for challenge, got: %d", recChallenge.Code) + } + var challengeResp map[string]any + _ = json.Unmarshal(recChallenge.Body.Bytes(), &challengeResp) + if challengeResp["challenge"] != "slack_challenge_string_999" { + t.Fatalf("expected challenge in body, got: %+v", challengeResp) + } + + // 2. Event Callback + eventPayload := []byte(`{ + "token": "token123", + "team_id": "T_SLACK_100", + "type": "event_callback", + "event": { + "type": "message", + "user": "U_USER_777", + "text": "Hello support team on Slack!", + "ts": "1725260000.000100", + "channel": "C_GENERAL" + } + }`) + reqEvent, _ := http.NewRequest(http.MethodPost, "/api/third/slack/webhook/"+channel.ChannelID, bytes.NewBuffer(eventPayload)) + reqEvent.Header.Set("Content-Type", "application/json") + recEvent := httptest.NewRecorder() + router.ServeHTTP(recEvent, reqEvent) + + if recEvent.Code != http.StatusOK { + t.Fatalf("expected 200 OK for event, got: %d", recEvent.Code) + } + + // Verify identity + identity := repositories.CustomerIdentityRepository.FindOne(db, sqls.NewCnd(). + Eq("external_source", enums.ExternalSourceSlack). + Eq("external_id", "U_USER_777")) + if identity == nil { + t.Fatalf("expected customer identity for U_USER_777") + } +} diff --git a/internal/pkg/dto/dto.go b/internal/pkg/dto/dto.go index 40808746..d65d9ff2 100644 --- a/internal/pkg/dto/dto.go +++ b/internal/pkg/dto/dto.go @@ -92,3 +92,22 @@ type InstagramChannelConfig struct { AppSecret string `json:"appSecret,omitempty"` // Meta App Secret WelcomeMessage string `json:"welcomeMessage,omitempty"` } + +type WhatsAppChannelConfig struct { + PhoneNumberID string `json:"phoneNumberId,omitempty"` // WhatsApp Business Phone Number ID + WABAID string `json:"wabaId,omitempty"` // WhatsApp Business Account ID + AccessToken string `json:"accessToken,omitempty"` // System User Access Token + WebhookVerifyToken string `json:"webhookVerifyToken,omitempty"` // Webhook verification token + AppSecret string `json:"appSecret,omitempty"` // Meta App Secret + WelcomeMessage string `json:"welcomeMessage,omitempty"` +} + +type SlackChannelConfig struct { + BotToken string `json:"botToken,omitempty"` // xoxb-... Bot Token + SigningSecret string `json:"signingSecret,omitempty"` // Slack Signing Secret + AppID string `json:"appId,omitempty"` // Slack App ID + TeamID string `json:"teamId,omitempty"` // Slack Workspace Team ID + TeamName string `json:"teamName,omitempty"` // Slack Workspace Name + DefaultChannel string `json:"defaultChannel,omitempty"` // Default channel to post + WelcomeMessage string `json:"welcomeMessage,omitempty"` +} diff --git a/internal/pkg/enums/external_identity.go b/internal/pkg/enums/external_identity.go index 7338f17c..a13bb5e3 100644 --- a/internal/pkg/enums/external_identity.go +++ b/internal/pkg/enums/external_identity.go @@ -16,6 +16,8 @@ const ( ExternalSourceDiscord ExternalSource = "discord" // Discord ExternalSourceMessenger ExternalSource = "messenger" // Facebook Messenger ExternalSourceInstagram ExternalSource = "instagram" // Instagram Direct + ExternalSourceWhatsApp ExternalSource = "whatsapp" // WhatsApp Business + ExternalSourceSlack ExternalSource = "slack" // Slack Bot ) var externalSourceLabelMap = map[ExternalSource]string{ @@ -29,6 +31,8 @@ var externalSourceLabelMap = map[ExternalSource]string{ ExternalSourceDiscord: "Discord", ExternalSourceMessenger: "Messenger", ExternalSourceInstagram: "Instagram", + ExternalSourceWhatsApp: "WhatsApp", + ExternalSourceSlack: "Slack", } func GetExternalSourceLabel(v ExternalSource) string { diff --git a/internal/pkg/enums/wxwork_kf.go b/internal/pkg/enums/wxwork_kf.go index ac946141..2dd58e4d 100644 --- a/internal/pkg/enums/wxwork_kf.go +++ b/internal/pkg/enums/wxwork_kf.go @@ -27,6 +27,8 @@ const ( ChannelTypeDiscord = "discord" ChannelTypeMessenger = "messenger" ChannelTypeInstagram = "instagram" + ChannelTypeWhatsApp = "whatsapp" + ChannelTypeSlack = "slack" ) type WxWorkKFMessageSendStatus string diff --git a/internal/services/channel_message_outbox_service.go b/internal/services/channel_message_outbox_service.go index 5d7e1d14..55bba124 100644 --- a/internal/services/channel_message_outbox_service.go +++ b/internal/services/channel_message_outbox_service.go @@ -504,6 +504,132 @@ func (s *channelMessageOutboxService) EnqueueInstagramMessage(conversation *mode return nil } +func (s *channelMessageOutboxService) EnqueueWhatsAppMessage(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.ChannelTypeWhatsApp { + return nil + } + if message.SenderType != enums.IMSenderTypeAgent && message.SenderType != enums.IMSenderTypeAI { + return nil + } + if message.MessageType != enums.IMMessageTypeText && message.MessageType != enums.IMMessageTypeHTML && message.MessageType != enums.IMMessageTypeImage && message.MessageType != enums.IMMessageTypeAttachment { + return nil + } + if existing := s.GetByMessageID(enums.ChannelTypeWhatsApp, 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.ChannelTypeWhatsApp, + 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 whatsapp outbound dispatch", "error", r) + } + }() + WhatsAppOutboundService.DispatchPendingOutbox() + }() + + return nil +} + +func (s *channelMessageOutboxService) EnqueueSlackMessage(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.ChannelTypeSlack { + return nil + } + if message.SenderType != enums.IMSenderTypeAgent && message.SenderType != enums.IMSenderTypeAI { + return nil + } + if message.MessageType != enums.IMMessageTypeText && message.MessageType != enums.IMMessageTypeHTML && message.MessageType != enums.IMMessageTypeImage && message.MessageType != enums.IMMessageTypeAttachment { + return nil + } + if existing := s.GetByMessageID(enums.ChannelTypeSlack, 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.ChannelTypeSlack, + 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 slack outbound dispatch", "error", r) + } + }() + SlackOutboundService.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 2f5f0d36..c5d08377 100644 --- a/internal/services/channel_service.go +++ b/internal/services/channel_service.go @@ -509,6 +509,41 @@ func (s *channelService) ParseInstagramChannelConfig(raw string) (*dto.Instagram return cfg, nil } +func (s *channelService) ParseWhatsAppChannelConfig(raw string) (*dto.WhatsAppChannelConfig, error) { + raw = strings.TrimSpace(raw) + cfg := &dto.WhatsAppChannelConfig{} + if raw != "" { + if err := json.Unmarshal([]byte(raw), cfg); err != nil { + return nil, err + } + } + cfg.PhoneNumberID = strings.TrimSpace(cfg.PhoneNumberID) + cfg.WABAID = strings.TrimSpace(cfg.WABAID) + cfg.AccessToken = strings.TrimSpace(cfg.AccessToken) + cfg.WebhookVerifyToken = strings.TrimSpace(cfg.WebhookVerifyToken) + cfg.AppSecret = strings.TrimSpace(cfg.AppSecret) + cfg.WelcomeMessage = strings.TrimSpace(cfg.WelcomeMessage) + return cfg, nil +} + +func (s *channelService) ParseSlackChannelConfig(raw string) (*dto.SlackChannelConfig, error) { + raw = strings.TrimSpace(raw) + cfg := &dto.SlackChannelConfig{} + if raw != "" { + if err := json.Unmarshal([]byte(raw), cfg); err != nil { + return nil, err + } + } + cfg.BotToken = strings.TrimSpace(cfg.BotToken) + cfg.SigningSecret = strings.TrimSpace(cfg.SigningSecret) + cfg.AppID = strings.TrimSpace(cfg.AppID) + cfg.TeamID = strings.TrimSpace(cfg.TeamID) + cfg.TeamName = strings.TrimSpace(cfg.TeamName) + cfg.DefaultChannel = strings.TrimSpace(cfg.DefaultChannel) + cfg.WelcomeMessage = strings.TrimSpace(cfg.WelcomeMessage) + return cfg, nil +} + func (s *channelService) GetUserTokenSecret(channel *models.Channel) string { if channel == nil { return "" @@ -719,7 +754,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 && channelType != enums.ChannelTypeEmail && channelType != enums.ChannelTypeDiscord && channelType != enums.ChannelTypeMessenger && channelType != enums.ChannelTypeInstagram { + if channelType != enums.ChannelTypeWeb && channelType != enums.ChannelTypeWechatMP && channelType != enums.ChannelTypeWxWorkKF && channelType != enums.ChannelTypeTelegram && channelType != enums.ChannelTypeZaloOA && channelType != enums.ChannelTypeEmail && channelType != enums.ChannelTypeDiscord && channelType != enums.ChannelTypeMessenger && channelType != enums.ChannelTypeInstagram && channelType != enums.ChannelTypeWhatsApp && channelType != enums.ChannelTypeSlack { return nil, errorsx.InvalidParamI18n("error.e0250") } name := strings.TrimSpace(req.Name) @@ -953,6 +988,43 @@ func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRe return nil, err } configJSON = string(configBytes) + case enums.ChannelTypeWhatsApp: + 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.ParseWhatsAppChannelConfig(configJSON) + if err != nil { + return nil, errorsx.InvalidParam("invalid whatsapp configuration") + } + if cfg.WebhookVerifyToken == "" { + if secret, err := generateUserTokenSecret(); err == nil { + cfg.WebhookVerifyToken = secret + } + } + configBytes, err := json.Marshal(cfg) + if err != nil { + return nil, err + } + configJSON = string(configBytes) + case enums.ChannelTypeSlack: + 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.ParseSlackChannelConfig(configJSON) + if err != nil { + return nil, errorsx.InvalidParam("invalid slack configuration") + } + 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 35bf3bb5..1b3dabec 100644 --- a/internal/services/cronx/cron.go +++ b/internal/services/cronx/cron.go @@ -50,6 +50,14 @@ func Init() { if instagramCount > 0 { slog.Info("instagram outbox dispatched", "count", instagramCount) } + whatsappCount := services.WhatsAppOutboundService.DispatchPendingOutbox() + if whatsappCount > 0 { + slog.Info("whatsapp outbox dispatched", "count", whatsappCount) + } + slackCount := services.SlackOutboundService.DispatchPendingOutbox() + if slackCount > 0 { + slog.Info("slack outbox dispatched", "count", slackCount) + } }) c.Start() diff --git a/internal/services/message_service.go b/internal/services/message_service.go index 2a520269..01cedcbd 100644 --- a/internal/services/message_service.go +++ b/internal/services/message_service.go @@ -599,6 +599,24 @@ func (s *messageService) sendValidatedMessage(conversation *models.Conversation, "error", enqueueErr, ) } + + // WhatsApp 渠道消息入队,异步发送 + if enqueueErr := ChannelMessageOutboxService.EnqueueWhatsAppMessage(conversation, message); enqueueErr != nil { + slog.Error("enqueue whatsapp outbox failed", + "conversation_id", conversation.ID, + "message_id", message.ID, + "error", enqueueErr, + ) + } + + // Slack 渠道消息入队,异步发送 + if enqueueErr := ChannelMessageOutboxService.EnqueueSlackMessage(conversation, message); enqueueErr != nil { + slog.Error("enqueue slack 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/slack_inbound_service.go b/internal/services/slack_inbound_service.go new file mode 100644 index 00000000..afa07501 --- /dev/null +++ b/internal/services/slack_inbound_service.go @@ -0,0 +1,134 @@ +package services + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "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/slack" +) + +var SlackInboundService = newSlackInboundService() + +func newSlackInboundService() *slackInboundService { + return &slackInboundService{} +} + +type slackInboundService struct{} + +// HandleWebhook processes an incoming Events API event from Slack. +func (s *slackInboundService) HandleWebhook(ctx context.Context, channelID string, timestampHeader, signatureHeader string, rawPayload []byte) (*string, error) { + var event slack.EventCallback + if err := json.Unmarshal(rawPayload, &event); err != nil { + return nil, fmt.Errorf("unmarshal slack event failed: %w", err) + } + + // 1. URL Verification Challenge + if event.Type == "url_verification" { + return &event.Challenge, nil + } + + if event.Type != "event_callback" || event.Event == nil { + return nil, nil // Ignore non-message callbacks + } + + teamID := strings.TrimSpace(event.TeamID) + ev := event.Event + + if ev.BotID != "" || ev.Subtype == "bot_message" || strings.TrimSpace(ev.User) == "" { + return nil, nil // Ignore bot loops + } + + text := strings.TrimSpace(ev.Text) + if text == "" { + return nil, nil + } + + var channel *models.Channel + channelID = strings.TrimSpace(channelID) + if channelID != "" { + channel = ChannelService.Take("channel_id = ? AND channel_type = ? AND status = ?", channelID, enums.ChannelTypeSlack, enums.StatusOk) + } + if channel == nil && teamID != "" { + channel = ChannelService.Take("channel_type = ? AND status = ? AND (channel_id = ? OR config_json LIKE ?)", + enums.ChannelTypeSlack, enums.StatusOk, teamID, "%"+teamID+"%") + } + if channel == nil { + channel = ChannelService.Take("channel_type = ? AND status = ?", enums.ChannelTypeSlack, enums.StatusOk) + } + if channel == nil { + return nil, errorsx.InvalidParam("slack channel not found or disabled") + } + + cfg, err := ChannelService.ParseSlackChannelConfig(channel.ConfigJSON) + if err != nil || cfg == nil { + return nil, errorsx.InvalidParam("slack channel config invalid") + } + + // Verify Slack Signing Secret if configured + if cfg.SigningSecret != "" && strings.TrimSpace(signatureHeader) != "" && strings.TrimSpace(timestampHeader) != "" { + if !verifySlackSignature(cfg.SigningSecret, timestampHeader, signatureHeader, rawPayload) { + return nil, errorsx.UnauthorizedI18n("error.auth.invalidSignature") + } + } + + // 1. Resolve customer identity + senderID := strings.TrimSpace(ev.User) + externalUser := openidentity.ExternalUser{ + ExternalSource: enums.ExternalSourceSlack, + ExternalID: senderID, + ExternalName: fmt.Sprintf("Slack User %s", senderID), + } + + // 2. Create or match Conversation + conversation, err := ConversationService.Create(externalUser, channel.ID, channel.AIAgentID) + if err != nil { + return nil, fmt.Errorf("create slack conversation failed: %w", err) + } + + // 3. Send message through MessageService + msgTS := strings.TrimSpace(ev.TS) + threadTS := strings.TrimSpace(ev.ThreadTS) + if threadTS == "" { + threadTS = msgTS + } + clientMsgID := fmt.Sprintf("slack_%s_%s", ev.Channel, msgTS) + payloadMap := map[string]any{ + "slack_channel": ev.Channel, + "slack_ts": msgTS, + "slack_thread_ts": threadTS, + "slack_user": senderID, + "slack_team": teamID, + } + payloadBytes, _ := json.Marshal(payloadMap) + + _, err = MessageService.SendCustomerMessage( + conversation.ID, + clientMsgID, + enums.IMMessageTypeText, + text, + string(payloadBytes), + externalUser, + ) + if err != nil { + return nil, fmt.Errorf("send customer message failed: %w", err) + } + + return nil, nil +} + +func verifySlackSignature(signingSecret, timestampHeader, signatureHeader string, payload []byte) bool { + sigBasestring := fmt.Sprintf("v0:%s:%s", timestampHeader, string(payload)) + mac := hmac.New(sha256.New, []byte(signingSecret)) + mac.Write([]byte(sigBasestring)) + expectedSig := "v0=" + hex.EncodeToString(mac.Sum(nil)) + return hmac.Equal([]byte(signatureHeader), []byte(expectedSig)) +} diff --git a/internal/services/slack_inbound_service_test.go b/internal/services/slack_inbound_service_test.go new file mode 100644 index 00000000..34a813b4 --- /dev/null +++ b/internal/services/slack_inbound_service_test.go @@ -0,0 +1,157 @@ +package services + +import ( + "context" + "encoding/json" + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +func setupSlackTestDB(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 slack test tables: %v", err) + } + sqls.SetDB(db) + return db +} + +func TestSlackInboundAndOutbound(t *testing.T) { + db := setupSlackTestDB(t) + + now := time.Now() + aiAgent := &models.AIAgent{ + Name: "Slack Bot Agent", + Status: enums.StatusOk, + PublishedRevisionID: 1, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + if err := db.Create(aiAgent).Error; err != nil { + t.Fatalf("create ai agent: %v", err) + } + + slackConfig := dto.SlackChannelConfig{ + BotToken: "xoxb-test-bot-token-12345", + SigningSecret: "test_signing_secret_999", + TeamID: "T0123456789", + TeamName: "Acme Corp", + DefaultChannel: "C9876543210", + } + cfgBytes, _ := json.Marshal(slackConfig) + + channel := &models.Channel{ + ChannelType: enums.ChannelTypeSlack, + ChannelID: "T0123456789", + AIAgentID: aiAgent.ID, + AIAgentRolloutPercent: 100, + Name: "Slack Support Channel", + ConfigJSON: string(cfgBytes), + Status: enums.StatusOk, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + if err := db.Create(channel).Error; err != nil { + t.Fatalf("create slack channel: %v", err) + } + + payload := `{ + "token": "verification_token", + "team_id": "T0123456789", + "api_app_id": "A01234567", + "type": "event_callback", + "event": { + "type": "message", + "user": "U12345678", + "text": "Help with API key generation", + "ts": "1725260000.000200", + "channel": "C9876543210", + "channel_type": "channel" + } + }` + + ctx := context.Background() + _, err := SlackInboundService.HandleWebhook(ctx, "", "", "", []byte(payload)) + if err != nil { + t.Fatalf("HandleWebhook failed: %v", err) + } + + // Verify customer identity + identity := repositories.CustomerIdentityRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("external_source", enums.ExternalSourceSlack). + Eq("external_id", "U12345678")) + if identity == nil { + t.Fatalf("expected customer identity for U12345678") + } + + // Verify conversation + conv := repositories.ConversationRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("customer_id", identity.CustomerID). + Eq("channel_id", channel.ID)) + if conv == nil { + t.Fatalf("expected conversation to be created") + } + + // Verify message + msg := repositories.MessageRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("conversation_id", conv.ID). + Eq("sender_type", enums.IMSenderTypeCustomer)) + if msg == nil { + t.Fatalf("expected message to be created") + } + if msg.Content != "Help with API key generation" { + t.Fatalf("expected message content 'Help with API key generation', got %s", msg.Content) + } + + operator := &dto.AuthPrincipal{UserID: 1, Nickname: "Agent Joy"} + + // Test Outbound enqueue + replyMsg, err := MessageService.SendAIMessage(conv.ID, aiAgent.ID, "ai_slack_reply_1", enums.IMMessageTypeText, "You can generate your API key under Settings > API Keys.", "", operator) + if err != nil { + t.Fatalf("MessageService.SendAIMessage failed: %v", err) + } + + outbox := ChannelMessageOutboxService.GetByMessageID(enums.ChannelTypeSlack, replyMsg.ID) + if outbox == nil { + t.Fatalf("expected outbox entry for slack message") + } + if outbox.ChannelType != enums.ChannelTypeSlack { + t.Fatalf("expected outbox channel type 'slack', got %s", outbox.ChannelType) + } +} diff --git a/internal/services/slack_outbound_service.go b/internal/services/slack_outbound_service.go new file mode 100644 index 00000000..68951df3 --- /dev/null +++ b/internal/services/slack_outbound_service.go @@ -0,0 +1,180 @@ +package services + +import ( + "context" + "encoding/json" + "log/slog" + "strings" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + "agent-desk/internal/services/storage" + "agent-desk/internal/slack" + + "github.com/mlogclub/simple/sqls" +) + +const ( + slackOutboxBatchSize = 20 + slackOutboxMaxRetry = 5 +) + +var SlackOutboundService = newSlackOutboundService() + +func newSlackOutboundService() *slackOutboundService { + return &slackOutboundService{} +} + +type slackOutboundService struct{} + +func (s *slackOutboundService) DispatchPendingOutbox() int { + return s.doDispatchPendingOutbox(slackOutboxBatchSize) +} + +func (s *slackOutboundService) doDispatchPendingOutbox(limit int) int { + if limit <= 0 { + limit = slackOutboxBatchSize + } + items := ChannelMessageOutboxService.ListPending(enums.ChannelTypeSlack, 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 slack outbox failed", + "outbox_id", items[i].ID, + "error", err, + ) + continue + } + successCount++ + } + return successCount +} + +func (s *slackOutboundService) processOutbox(outboxID int64) error { + outbox := ChannelMessageOutboxService.Get(outboxID) + if outbox == nil { + return nil + } + if outbox.ChannelType != enums.ChannelTypeSlack { + 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, "slack channel not found or disabled") + } + cfg, err := ChannelService.ParseSlackChannelConfig(channel.ConfigJSON) + if err != nil || cfg == nil || strings.TrimSpace(cfg.BotToken) == "" { + return s.markOutboxFailed(outbox, "slack bot token not configured") + } + + // Resolve target Slack Channel ID and Thread TS + var targetChannel string + var threadTS string + + lastCustomerMsg := repositories.MessageRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("conversation_id", conversation.ID). + Eq("sender_type", enums.IMSenderTypeCustomer). + Desc("id")) + if lastCustomerMsg != nil && lastCustomerMsg.Payload != "" { + var payloadMap map[string]any + if err := json.Unmarshal([]byte(lastCustomerMsg.Payload), &payloadMap); err == nil { + if ch, ok := payloadMap["slack_channel"].(string); ok && ch != "" { + targetChannel = ch + } + if ts, ok := payloadMap["slack_thread_ts"].(string); ok && ts != "" { + threadTS = ts + } + } + } + + if targetChannel == "" { + targetChannel = cfg.DefaultChannel + } + if targetChannel == "" { + return s.markOutboxFailed(outbox, "unable to resolve target slack channel") + } + + client := slack.NewClient(cfg.BotToken) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + textToSend := message.Content + if message.MessageType == enums.IMMessageTypeImage || message.MessageType == enums.IMMessageTypeAttachment { + assetPayload, err := parseIMMessageAssetPayload(message.Payload) + if err == nil && assetPayload != nil { + assetPayload = hydrateIMMessageAssetPayload(assetPayload) + if assetPayload.Provider != "" && assetPayload.StorageKey != "" { + if provider, err := storage.NewProvider(assetPayload.Provider); err == nil { + fileURL := provider.GetSignedURL(assetPayload.StorageKey) + if fileURL != "" { + if textToSend != "" { + textToSend += "\n" + fileURL + } else { + textToSend = fileURL + } + } + } + } + } + } + + _, sendErr := client.PostMessage(ctx, targetChannel, textToSend, threadTS) + 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 *slackOutboundService) markOutboxFailed(outbox *models.ChannelMessageOutbox, errMsg string) error { + if outbox == nil { + return nil + } + retryCount := outbox.RetryCount + 1 + status := string(enums.ChannelMessageOutboxStatusFailed) + if retryCount >= slackOutboxMaxRetry { + 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/whatsapp_inbound_service.go b/internal/services/whatsapp_inbound_service.go new file mode 100644 index 00000000..02be7bb8 --- /dev/null +++ b/internal/services/whatsapp_inbound_service.go @@ -0,0 +1,176 @@ +package services + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "strings" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/openidentity" + "agent-desk/internal/whatsapp" +) + +var WhatsAppInboundService = newWhatsAppInboundService() + +func newWhatsAppInboundService() *whatsappInboundService { + return &whatsappInboundService{} +} + +type whatsappInboundService struct{} + +// HandleWebhook processes an incoming Webhook event from WhatsApp Cloud API (Meta Graph Platform). +func (s *whatsappInboundService) HandleWebhook(ctx context.Context, channelID string, signatureHeader string, rawPayload []byte) error { + var event whatsapp.WebhookEvent + if err := json.Unmarshal(rawPayload, &event); err != nil { + return fmt.Errorf("unmarshal whatsapp webhook failed: %w", err) + } + + if event.Object != "whatsapp_business_account" && event.Object != "whatsapp" { + return nil // Ignore non-whatsapp events + } + + for _, entry := range event.Entry { + for _, change := range entry.Changes { + if change.Field != "messages" { + continue + } + + val := change.Value + phoneNumberID := strings.TrimSpace(val.Metadata.PhoneNumberID) + + var channel *models.Channel + channelID = strings.TrimSpace(channelID) + if channelID != "" { + channel = ChannelService.Take("channel_id = ? AND channel_type = ? AND status = ?", channelID, enums.ChannelTypeWhatsApp, enums.StatusOk) + } + if channel == nil && phoneNumberID != "" { + channel = ChannelService.Take("channel_type = ? AND status = ? AND (channel_id = ? OR config_json LIKE ?)", + enums.ChannelTypeWhatsApp, enums.StatusOk, phoneNumberID, "%"+phoneNumberID+"%") + } + if channel == nil { + channel = ChannelService.Take("channel_type = ? AND status = ?", enums.ChannelTypeWhatsApp, enums.StatusOk) + } + if channel == nil { + continue + } + + cfg, err := ChannelService.ParseWhatsAppChannelConfig(channel.ConfigJSON) + if err != nil || cfg == nil { + continue + } + + // Signature verification if appSecret configured + appSecret := "" + if cfg != nil { + appSecret = strings.TrimSpace(cfg.AppSecret) + } + if appSecret == "" { + if serverCfg := config.GetCurrent(); serverCfg != nil { + appSecret = strings.TrimSpace(serverCfg.Messenger.AppSecret) + } + } + if appSecret == "" { + appSecret = strings.TrimSpace(os.Getenv("META_APP_SECRET")) + } + + if appSecret != "" && strings.TrimSpace(signatureHeader) != "" { + if !verifyWhatsAppSignature(appSecret, signatureHeader, rawPayload) { + return errorsx.UnauthorizedI18n("error.auth.invalidSignature") + } + } + + contactNameMap := make(map[string]string) + for _, contact := range val.Contacts { + contactNameMap[contact.WaID] = contact.Profile.Name + } + + for _, message := range val.Messages { + senderPhone := strings.TrimSpace(message.From) + if senderPhone == "" { + continue + } + + text := "" + if message.Text != nil { + text = strings.TrimSpace(message.Text.Body) + } else if message.Image != nil { + text = strings.TrimSpace(message.Image.Caption) + if text == "" { + text = "[Image Attachment]" + } + } else if message.Document != nil { + text = strings.TrimSpace(message.Document.Caption) + if text == "" { + text = fmt.Sprintf("[%s]", message.Document.Filename) + } + } + + if text == "" { + continue + } + + name := contactNameMap[senderPhone] + if name == "" { + name = fmt.Sprintf("WhatsApp User +%s", senderPhone) + } + + // 1. Resolve customer identity + externalUser := openidentity.ExternalUser{ + ExternalSource: enums.ExternalSourceWhatsApp, + ExternalID: senderPhone, + ExternalName: name, + } + + // 2. Create or match Conversation + conversation, err := ConversationService.Create(externalUser, channel.ID, channel.AIAgentID) + if err != nil { + return fmt.Errorf("create whatsapp conversation failed: %w", err) + } + + // 3. Send customer message + clientMsgID := fmt.Sprintf("wa_%s", message.ID) + payloadMap := map[string]any{ + "whatsapp_message_id": message.ID, + "whatsapp_from": senderPhone, + "whatsapp_phone_id": phoneNumberID, + "whatsapp_type": message.Type, + } + 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 +} + +func verifyWhatsAppSignature(appSecret string, signatureHeader string, payload []byte) bool { + signature := strings.TrimSpace(signatureHeader) + if strings.HasPrefix(signature, "sha256=") { + expectedSig := signature[len("sha256="):] + mac := hmac.New(sha256.New, []byte(appSecret)) + mac.Write(payload) + actualSig := hex.EncodeToString(mac.Sum(nil)) + return hmac.Equal([]byte(actualSig), []byte(expectedSig)) + } + return true +} diff --git a/internal/services/whatsapp_inbound_service_test.go b/internal/services/whatsapp_inbound_service_test.go new file mode 100644 index 00000000..ab398e93 --- /dev/null +++ b/internal/services/whatsapp_inbound_service_test.go @@ -0,0 +1,177 @@ +package services + +import ( + "context" + "encoding/json" + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +func setupWhatsAppTestDB(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 whatsapp test tables: %v", err) + } + sqls.SetDB(db) + return db +} + +func TestWhatsAppInboundAndOutbound(t *testing.T) { + db := setupWhatsAppTestDB(t) + + now := time.Now() + aiAgent := &models.AIAgent{ + Name: "WhatsApp AI Agent", + Status: enums.StatusOk, + PublishedRevisionID: 1, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + if err := db.Create(aiAgent).Error; err != nil { + t.Fatalf("create ai agent: %v", err) + } + + waConfig := dto.WhatsAppChannelConfig{ + PhoneNumberID: "phone_id_9999", + WABAID: "waba_id_8888", + AccessToken: "test_wa_access_token", + WebhookVerifyToken: "verify_token_wa_123", + } + cfgBytes, _ := json.Marshal(waConfig) + + channel := &models.Channel{ + ChannelType: enums.ChannelTypeWhatsApp, + ChannelID: "phone_id_9999", + AIAgentID: aiAgent.ID, + AIAgentRolloutPercent: 100, + Name: "WhatsApp Support Channel", + ConfigJSON: string(cfgBytes), + Status: enums.StatusOk, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + if err := db.Create(channel).Error; err != nil { + t.Fatalf("create whatsapp channel: %v", err) + } + + payload := `{ + "object": "whatsapp_business_account", + "entry": [ + { + "id": "waba_id_8888", + "changes": [ + { + "field": "messages", + "value": { + "messaging_product": "whatsapp", + "metadata": { + "display_phone_number": "15550269999", + "phone_number_id": "phone_id_9999" + }, + "contacts": [ + { + "profile": { "name": "Anh Le" }, + "wa_id": "84901234567" + } + ], + "messages": [ + { + "from": "84901234567", + "id": "wamid.HBgLODQ5MDEyMzQ1NjcVAgASGBQz", + "timestamp": "1725260000", + "type": "text", + "text": { "body": "Xin chào, tôi cần hỗ trợ!" } + } + ] + } + } + ] + } + ] + }` + + ctx := context.Background() + err := WhatsAppInboundService.HandleWebhook(ctx, "", "", []byte(payload)) + if err != nil { + t.Fatalf("HandleWebhook failed: %v", err) + } + + // Verify customer identity + identity := repositories.CustomerIdentityRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("external_source", enums.ExternalSourceWhatsApp). + Eq("external_id", "84901234567")) + if identity == nil { + t.Fatalf("expected customer identity for 84901234567") + } + + // Verify conversation + conv := repositories.ConversationRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("customer_id", identity.CustomerID). + Eq("channel_id", channel.ID)) + if conv == nil { + t.Fatalf("expected conversation to be created") + } + + // Verify message + msg := repositories.MessageRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("conversation_id", conv.ID). + Eq("sender_type", enums.IMSenderTypeCustomer)) + if msg == nil { + t.Fatalf("expected message to be created") + } + if msg.Content != "Xin chào, tôi cần hỗ trợ!" { + t.Fatalf("expected message content 'Xin chào, tôi cần hỗ trợ!', got %s", msg.Content) + } + + operator := &dto.AuthPrincipal{UserID: 1, Nickname: "Agent Joy"} + + // Test Outbound enqueue + replyMsg, err := MessageService.SendAIMessage(conv.ID, aiAgent.ID, "ai_wa_reply_1", enums.IMMessageTypeText, "Chào bạn! Crove Desk có thể giúp gì cho bạn?", "", operator) + if err != nil { + t.Fatalf("MessageService.SendAIMessage failed: %v", err) + } + + outbox := ChannelMessageOutboxService.GetByMessageID(enums.ChannelTypeWhatsApp, replyMsg.ID) + if outbox == nil { + t.Fatalf("expected outbox entry for whatsapp message") + } + if outbox.ChannelType != enums.ChannelTypeWhatsApp { + t.Fatalf("expected outbox channel type 'whatsapp', got %s", outbox.ChannelType) + } +} diff --git a/internal/services/whatsapp_outbound_service.go b/internal/services/whatsapp_outbound_service.go new file mode 100644 index 00000000..4e414a0c --- /dev/null +++ b/internal/services/whatsapp_outbound_service.go @@ -0,0 +1,188 @@ +package services + +import ( + "context" + "log/slog" + "strings" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + "agent-desk/internal/services/storage" + "agent-desk/internal/whatsapp" + + "github.com/mlogclub/simple/sqls" +) + +const ( + whatsappOutboxBatchSize = 20 + whatsappOutboxMaxRetry = 5 +) + +var WhatsAppOutboundService = newWhatsAppOutboundService() + +func newWhatsAppOutboundService() *whatsappOutboundService { + return &whatsappOutboundService{} +} + +type whatsappOutboundService struct{} + +func (s *whatsappOutboundService) DispatchPendingOutbox() int { + return s.doDispatchPendingOutbox(whatsappOutboxBatchSize) +} + +func (s *whatsappOutboundService) doDispatchPendingOutbox(limit int) int { + if limit <= 0 { + limit = whatsappOutboxBatchSize + } + items := ChannelMessageOutboxService.ListPending(enums.ChannelTypeWhatsApp, 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 whatsapp outbox failed", + "outbox_id", items[i].ID, + "error", err, + ) + continue + } + successCount++ + } + return successCount +} + +func (s *whatsappOutboundService) processOutbox(outboxID int64) error { + outbox := ChannelMessageOutboxService.Get(outboxID) + if outbox == nil { + return nil + } + if outbox.ChannelType != enums.ChannelTypeWhatsApp { + 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, "whatsapp channel not found or disabled") + } + cfg, err := ChannelService.ParseWhatsAppChannelConfig(channel.ConfigJSON) + if err != nil || cfg == nil || cfg.AccessToken == "" || cfg.PhoneNumberID == "" { + return s.markOutboxFailed(outbox, "whatsapp credentials (access token / phone number id) not configured") + } + + // Resolve target WhatsApp Phone Number (ExternalID) + var recipientPhone string + customerIdentity := repositories.CustomerIdentityRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("customer_id", conversation.CustomerID). + Eq("external_source", enums.ExternalSourceWhatsApp)) + if customerIdentity != nil { + recipientPhone = strings.TrimSpace(customerIdentity.ExternalID) + } + if recipientPhone == "" { + return s.markOutboxFailed(outbox, "unable to resolve recipient phone number") + } + + client := whatsapp.NewClient(cfg.AccessToken) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var sendErr error + if message.MessageType == enums.IMMessageTypeImage { + assetPayload, err := parseIMMessageAssetPayload(message.Payload) + var imageURL string + if err == nil && assetPayload != nil { + assetPayload = hydrateIMMessageAssetPayload(assetPayload) + if assetPayload.Provider != "" && assetPayload.StorageKey != "" { + if provider, err := storage.NewProvider(assetPayload.Provider); err == nil { + imageURL = provider.GetSignedURL(assetPayload.StorageKey) + } + } + } + if imageURL == "" && strings.HasPrefix(strings.TrimSpace(message.Content), "http") { + imageURL = strings.TrimSpace(message.Content) + } + + if imageURL != "" { + _, sendErr = client.SendMediaMessage(ctx, cfg.PhoneNumberID, recipientPhone, "image", imageURL, message.Content) + } else { + _, sendErr = client.SendTextMessage(ctx, cfg.PhoneNumberID, recipientPhone, message.Content) + } + } else if message.MessageType == enums.IMMessageTypeAttachment { + assetPayload, err := parseIMMessageAssetPayload(message.Payload) + var fileURL string + if err == nil && assetPayload != nil { + assetPayload = hydrateIMMessageAssetPayload(assetPayload) + if assetPayload.Provider != "" && assetPayload.StorageKey != "" { + if provider, err := storage.NewProvider(assetPayload.Provider); err == nil { + fileURL = provider.GetSignedURL(assetPayload.StorageKey) + } + } + } + if fileURL == "" && strings.HasPrefix(strings.TrimSpace(message.Content), "http") { + fileURL = strings.TrimSpace(message.Content) + } + + if fileURL != "" { + _, sendErr = client.SendMediaMessage(ctx, cfg.PhoneNumberID, recipientPhone, "document", fileURL, message.Content) + } else { + _, sendErr = client.SendTextMessage(ctx, cfg.PhoneNumberID, recipientPhone, message.Content) + } + } else { + _, sendErr = client.SendTextMessage(ctx, cfg.PhoneNumberID, recipientPhone, 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 *whatsappOutboundService) markOutboxFailed(outbox *models.ChannelMessageOutbox, errMsg string) error { + if outbox == nil { + return nil + } + retryCount := outbox.RetryCount + 1 + status := string(enums.ChannelMessageOutboxStatusFailed) + if retryCount >= whatsappOutboxMaxRetry { + 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/slack/client.go b/internal/slack/client.go new file mode 100644 index 00000000..4ed2fdc7 --- /dev/null +++ b/internal/slack/client.go @@ -0,0 +1,109 @@ +package slack + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +const defaultBaseURL = "https://slack.com/api" + +type Client struct { + botToken string + baseURL string + httpClient *http.Client +} + +func NewClient(botToken string) *Client { + return &Client{ + botToken: strings.TrimSpace(botToken), + baseURL: defaultBaseURL, + httpClient: &http.Client{Timeout: 15 * time.Second}, + } +} + +func (c *Client) SetBaseURL(url string) { + if strings.TrimSpace(url) != "" { + c.baseURL = strings.TrimRight(strings.TrimSpace(url), "/") + } +} + +func (c *Client) PostMessage(ctx context.Context, channel string, text string, threadTS string) (*SendMessageResponse, error) { + channel = strings.TrimSpace(channel) + if channel == "" { + return nil, fmt.Errorf("slack channel is required") + } + text = strings.TrimSpace(text) + if text == "" { + return nil, fmt.Errorf("message text is required") + } + + payload := SendMessageRequest{ + Channel: channel, + Text: text, + ThreadTS: threadTS, + } + + var resp SendMessageResponse + if err := c.doRequest(ctx, "/chat.postMessage", payload, &resp); err != nil { + return nil, err + } + if !resp.OK { + return nil, fmt.Errorf("slack api error: %s", resp.Error) + } + return &resp, nil +} + +func (c *Client) doRequest(ctx context.Context, path string, payload any, result any) error { + if c.botToken == "" { + return fmt.Errorf("slack bot token is required") + } + + endpoint := fmt.Sprintf("%s%s", c.baseURL, path) + + var bodyReader io.Reader + if payload != nil { + bodyBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal slack request failed: %w", err) + } + bodyReader = bytes.NewBuffer(bodyBytes) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bodyReader) + if err != nil { + return fmt.Errorf("create slack request failed: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.botToken) + if payload != nil { + req.Header.Set("Content-Type", "application/json; charset=utf-8") + } + + res, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("slack http request failed: %w", err) + } + defer res.Body.Close() + + bodyBytes, err := io.ReadAll(res.Body) + if err != nil { + return fmt.Errorf("read slack response failed: %w", err) + } + + if res.StatusCode < 200 || res.StatusCode >= 300 { + return fmt.Errorf("slack api error (%d): %s", res.StatusCode, string(bodyBytes)) + } + + if result != nil { + if err := json.Unmarshal(bodyBytes, result); err != nil { + return fmt.Errorf("unmarshal slack response failed: %w (body: %s)", err, string(bodyBytes)) + } + } + return nil +} diff --git a/internal/slack/types.go b/internal/slack/types.go new file mode 100644 index 00000000..270e4e48 --- /dev/null +++ b/internal/slack/types.go @@ -0,0 +1,37 @@ +package slack + +// SendMessageRequest represents payload for Slack chat.postMessage API. +type SendMessageRequest struct { + Channel string `json:"channel"` + Text string `json:"text"` + ThreadTS string `json:"thread_ts,omitempty"` + ParseMode string `json:"parse,omitempty"` +} + +// SendMessageResponse represents response from Slack Web API. +type SendMessageResponse struct { + OK bool `json:"ok"` + Channel string `json:"channel,omitempty"` + TS string `json:"ts,omitempty"` + Error string `json:"error,omitempty"` +} + +// EventCallback represents incoming Slack Events API payload. +type EventCallback struct { + Token string `json:"token"` + TeamID string `json:"team_id"` + APIAppID string `json:"api_app_id"` + Type string `json:"type"` // url_verification | event_callback + Challenge string `json:"challenge"` // for url_verification + Event *struct { + Type string `json:"type"` // message | app_mention + User string `json:"user"` + Text string `json:"text"` + TS string `json:"ts"` + ThreadTS string `json:"thread_ts,omitempty"` + Channel string `json:"channel"` + ChannelType string `json:"channel_type"` // im | channel | group + BotID string `json:"bot_id,omitempty"` + Subtype string `json:"subtype,omitempty"` + } `json:"event,omitempty"` +} diff --git a/internal/whatsapp/client.go b/internal/whatsapp/client.go new file mode 100644 index 00000000..da6e0a11 --- /dev/null +++ b/internal/whatsapp/client.go @@ -0,0 +1,159 @@ +package whatsapp + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +const defaultBaseURL = "https://graph.facebook.com/v21.0" + +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(url string) { + if strings.TrimSpace(url) != "" { + c.baseURL = strings.TrimRight(strings.TrimSpace(url), "/") + } +} + +func (c *Client) SendTextMessage(ctx context.Context, phoneNumberID string, recipientPhone string, text string) (*SendMessageResponse, error) { + phoneNumberID = strings.TrimSpace(phoneNumberID) + if phoneNumberID == "" { + return nil, fmt.Errorf("phone_number_id is required") + } + recipientPhone = strings.TrimSpace(recipientPhone) + if recipientPhone == "" { + return nil, fmt.Errorf("recipient phone number is required") + } + text = strings.TrimSpace(text) + if text == "" { + return nil, fmt.Errorf("message text is required") + } + + payload := SendTextMessageRequest{ + MessagingProduct: "whatsapp", + RecipientType: "individual", + To: recipientPhone, + Type: "text", + Text: &TextPayload{ + PreviewURL: false, + Body: text, + }, + } + + var resp SendMessageResponse + path := fmt.Sprintf("/%s/messages", phoneNumberID) + if err := c.doRequest(ctx, http.MethodPost, path, payload, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +func (c *Client) SendMediaMessage(ctx context.Context, phoneNumberID string, recipientPhone string, mediaType string, mediaURL string, caption string) (*SendMessageResponse, error) { + phoneNumberID = strings.TrimSpace(phoneNumberID) + if phoneNumberID == "" { + return nil, fmt.Errorf("phone_number_id is required") + } + recipientPhone = strings.TrimSpace(recipientPhone) + if recipientPhone == "" { + return nil, fmt.Errorf("recipient phone number is required") + } + mediaURL = strings.TrimSpace(mediaURL) + if mediaURL == "" { + return nil, fmt.Errorf("media url is required") + } + + payload := SendTextMessageRequest{ + MessagingProduct: "whatsapp", + RecipientType: "individual", + To: recipientPhone, + } + + if strings.ToLower(mediaType) == "image" { + payload.Type = "image" + payload.Image = &MediaPayload{ + Link: mediaURL, + Caption: caption, + } + } else { + payload.Type = "document" + payload.Document = &DocumentPayload{ + Link: mediaURL, + Caption: caption, + Filename: "attachment", + } + } + + var resp SendMessageResponse + path := fmt.Sprintf("/%s/messages", phoneNumberID) + if err := c.doRequest(ctx, http.MethodPost, path, payload, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +func (c *Client) doRequest(ctx context.Context, method, path string, payload any, result any) error { + if c.accessToken == "" { + return fmt.Errorf("whatsapp access token is required") + } + + endpoint := fmt.Sprintf("%s%s", c.baseURL, path) + + var bodyReader io.Reader + if payload != nil { + bodyBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal whatsapp request failed: %w", err) + } + bodyReader = bytes.NewBuffer(bodyBytes) + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint, bodyReader) + if err != nil { + return fmt.Errorf("create whatsapp request failed: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.accessToken) + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + res, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("whatsapp http request failed: %w", err) + } + defer res.Body.Close() + + bodyBytes, err := io.ReadAll(res.Body) + if err != nil { + return fmt.Errorf("read whatsapp response failed: %w", err) + } + + if res.StatusCode < 200 || res.StatusCode >= 300 { + return fmt.Errorf("whatsapp api error (%d): %s", res.StatusCode, string(bodyBytes)) + } + + if result != nil { + if err := json.Unmarshal(bodyBytes, result); err != nil { + return fmt.Errorf("unmarshal whatsapp response failed: %w (body: %s)", err, string(bodyBytes)) + } + } + return nil +} diff --git a/internal/whatsapp/types.go b/internal/whatsapp/types.go new file mode 100644 index 00000000..1b5829ed --- /dev/null +++ b/internal/whatsapp/types.go @@ -0,0 +1,82 @@ +package whatsapp + +// SendTextMessageRequest represents payload to send text message via WhatsApp Cloud API. +type SendTextMessageRequest struct { + MessagingProduct string `json:"messaging_product"` + RecipientType string `json:"recipient_type"` + To string `json:"to"` + Type string `json:"type"` + Text *TextPayload `json:"text,omitempty"` + Image *MediaPayload `json:"image,omitempty"` + Document *DocumentPayload `json:"document,omitempty"` +} + +type TextPayload struct { + PreviewURL bool `json:"preview_url,omitempty"` + Body string `json:"body"` +} + +type MediaPayload struct { + Link string `json:"link,omitempty"` + Caption string `json:"caption,omitempty"` +} + +type DocumentPayload struct { + Link string `json:"link,omitempty"` + Caption string `json:"caption,omitempty"` + Filename string `json:"filename,omitempty"` +} + +type SendMessageResponse struct { + MessagingProduct string `json:"messaging_product"` + Contacts []struct { + Input string `json:"input"` + WaID string `json:"wa_id"` + } `json:"contacts"` + Messages []struct { + ID string `json:"id"` + } `json:"messages"` +} + +// WebhookEvent represents incoming WhatsApp Webhook payload from Meta. +type WebhookEvent struct { + Object string `json:"object"` + Entry []struct { + ID string `json:"id"` + Changes []struct { + Field string `json:"field"` + Value struct { + MessagingProduct string `json:"messaging_product"` + Metadata struct { + DisplayPhoneNumber string `json:"display_phone_number"` + PhoneNumberID string `json:"phone_number_id"` + } `json:"metadata"` + Contacts []struct { + Profile struct { + Name string `json:"name"` + } `json:"profile"` + WaID string `json:"wa_id"` + } `json:"contacts"` + Messages []struct { + From string `json:"from"` + ID string `json:"id"` + Timestamp string `json:"timestamp"` + Type string `json:"type"` + Text *struct { + Body string `json:"body"` + } `json:"text,omitempty"` + Image *struct { + ID string `json:"id"` + MimeType string `json:"mime_type"` + Caption string `json:"caption,omitempty"` + } `json:"image,omitempty"` + Document *struct { + ID string `json:"id"` + Filename string `json:"filename"` + Caption string `json:"caption,omitempty"` + } `json:"document,omitempty"` + } `json:"messages"` + } `json:"value"` + } `json:"changes"` + } `json:"entry"` +} diff --git a/web/app/(dashboard)/dashboard/channels/_components/edit.tsx b/web/app/(dashboard)/dashboard/channels/_components/edit.tsx index 35d94f8f..9f9168e9 100644 --- a/web/app/(dashboard)/dashboard/channels/_components/edit.tsx +++ b/web/app/(dashboard)/dashboard/channels/_components/edit.tsx @@ -111,6 +111,23 @@ type InstagramChannelConfig = { appSecret?: string } +type WhatsAppChannelConfig = { + phoneNumberId?: string + wabaId?: string + accessToken?: string + webhookVerifyToken?: string + appSecret?: string +} + +type SlackChannelConfig = { + botToken?: string + signingSecret?: string + appId?: string + teamId?: string + teamName?: string + defaultChannel?: string +} + function getDefaultWebChannelConfig(t: Translate): Required { return { title: t("channel.defaultTitleWeb"), @@ -125,7 +142,7 @@ function getDefaultWebChannelConfig(t: Translate): Required { function createSchema(t: Translate) { return z .object({ - channelType: z.enum(["web", "wechat_mp", "wxwork_kf", "telegram", "zalo_oa", "email", "discord", "messenger", "instagram"], t("channel.typeRequired")), + channelType: z.enum(["web", "wechat_mp", "wxwork_kf", "telegram", "zalo_oa", "email", "discord", "messenger", "instagram", "whatsapp", "slack"], 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")), @@ -151,6 +168,16 @@ function createSchema(t: Translate) { instagramPageAccessToken: z.string().trim(), instagramWebhookVerifyToken: z.string().trim(), instagramAppSecret: z.string().trim(), + whatsAppPhoneNumberId: z.string().trim(), + whatsAppWabaId: z.string().trim(), + whatsAppAccessToken: z.string().trim(), + whatsAppWebhookVerifyToken: z.string().trim(), + slackBotToken: z.string().trim(), + slackSigningSecret: z.string().trim(), + slackAppId: z.string().trim(), + slackTeamId: z.string().trim(), + slackTeamName: z.string().trim(), + slackDefaultChannel: z.string().trim(), emailAddress: z.string().trim(), senderName: z.string().trim(), emailProvider: z.string().trim(), @@ -200,7 +227,7 @@ function createSchema(t: Translate) { } type EditForm = { - channelType: "web" | "wechat_mp" | "wxwork_kf" | "telegram" | "zalo_oa" | "email" | "discord" | "messenger" | "instagram" + channelType: "web" | "wechat_mp" | "wxwork_kf" | "telegram" | "zalo_oa" | "email" | "discord" | "messenger" | "instagram" | "whatsapp" | "slack" aiAgentId: string aiAgentRolloutPercent: number name: string @@ -226,6 +253,16 @@ type EditForm = { instagramPageAccessToken: string instagramWebhookVerifyToken: string instagramAppSecret: string + whatsAppPhoneNumberId: string + whatsAppWabaId: string + whatsAppAccessToken: string + whatsAppWebhookVerifyToken: string + slackBotToken: string + slackSigningSecret: string + slackAppId: string + slackTeamId: string + slackTeamName: string + slackDefaultChannel: string emailAddress: string senderName: string emailProvider: string @@ -272,6 +309,16 @@ function createEmptyForm(t: Translate): EditForm { instagramPageAccessToken: "", instagramWebhookVerifyToken: "", instagramAppSecret: "", + whatsAppPhoneNumberId: "", + whatsAppWabaId: "", + whatsAppAccessToken: "", + whatsAppWebhookVerifyToken: "", + slackBotToken: "", + slackSigningSecret: "", + slackAppId: "", + slackTeamId: "", + slackTeamName: "", + slackDefaultChannel: "", emailAddress: "help@crove.com", senderName: "Crove Desk Support", emailProvider: "brevo", @@ -442,6 +489,39 @@ function parseInstagramChannelConfig(configJson: string): InstagramChannelConfig } } +function parseWhatsAppChannelConfig(configJson: string): WhatsAppChannelConfig { + if (!configJson.trim()) return {} + try { + const parsed = JSON.parse(configJson) as WhatsAppChannelConfig + return { + phoneNumberId: parsed.phoneNumberId?.trim() || "", + wabaId: parsed.wabaId?.trim() || "", + accessToken: parsed.accessToken?.trim() || "", + webhookVerifyToken: parsed.webhookVerifyToken?.trim() || "", + appSecret: parsed.appSecret?.trim() || "", + } + } catch { + return {} + } +} + +function parseSlackChannelConfig(configJson: string): SlackChannelConfig { + if (!configJson.trim()) return {} + try { + const parsed = JSON.parse(configJson) as SlackChannelConfig + return { + botToken: parsed.botToken?.trim() || "", + signingSecret: parsed.signingSecret?.trim() || "", + appId: parsed.appId?.trim() || "", + teamId: parsed.teamId?.trim() || "", + teamName: parsed.teamName?.trim() || "", + defaultChannel: parsed.defaultChannel?.trim() || "", + } + } catch { + return {} + } +} + function buildForm(item: AdminChannel | null, t: Translate): EditForm { if (!item) { return createEmptyForm(t) @@ -453,6 +533,8 @@ function buildForm(item: AdminChannel | null, t: Translate): EditForm { const isDiscord = item.channelType === "discord" const isMessenger = item.channelType === "messenger" const isInstagram = item.channelType === "instagram" + const isWhatsApp = item.channelType === "whatsapp" + const isSlack = item.channelType === "slack" const webConfig = parseWebChannelConfig(item.configJson, t) const wechatConfig = isWechatMP ? parseWechatMPChannelConfig(item.configJson, t) @@ -475,6 +557,12 @@ function buildForm(item: AdminChannel | null, t: Translate): EditForm { const instagramConfig = isInstagram ? parseInstagramChannelConfig(item.configJson) : null + const whatsAppConfig = isWhatsApp + ? parseWhatsAppChannelConfig(item.configJson) + : null + const slackConfig = isSlack + ? parseSlackChannelConfig(item.configJson) + : null return { channelType: item.channelType === "wxwork_kf" @@ -489,11 +577,15 @@ function buildForm(item: AdminChannel | null, t: Translate): EditForm { ? "messenger" : item.channelType === "instagram" ? "instagram" - : item.channelType === "email" - ? "email" - : item.channelType === "wechat_mp" - ? "wechat_mp" - : "web", + : item.channelType === "whatsapp" + ? "whatsapp" + : item.channelType === "slack" + ? "slack" + : 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, @@ -519,6 +611,16 @@ function buildForm(item: AdminChannel | null, t: Translate): EditForm { instagramPageAccessToken: instagramConfig?.pageAccessToken ?? "", instagramWebhookVerifyToken: instagramConfig?.webhookVerifyToken ?? "", instagramAppSecret: instagramConfig?.appSecret ?? "", + whatsAppPhoneNumberId: whatsAppConfig?.phoneNumberId ?? "", + whatsAppWabaId: whatsAppConfig?.wabaId ?? "", + whatsAppAccessToken: whatsAppConfig?.accessToken ?? "", + whatsAppWebhookVerifyToken: whatsAppConfig?.webhookVerifyToken ?? "", + slackBotToken: slackConfig?.botToken ?? "", + slackSigningSecret: slackConfig?.signingSecret ?? "", + slackAppId: slackConfig?.appId ?? "", + slackTeamId: slackConfig?.teamId ?? "", + slackTeamName: slackConfig?.teamName ?? "", + slackDefaultChannel: slackConfig?.defaultChannel ?? "", emailAddress: emailConfig?.emailAddress || "help@crove.com", senderName: emailConfig?.senderName || "Crove Desk Support", emailProvider: emailConfig?.provider || "brevo", @@ -595,6 +697,22 @@ function buildPayload(form: EditForm, status: number, t: Translate): CreateAdmin webhookVerifyToken: form.instagramWebhookVerifyToken.trim(), appSecret: form.instagramAppSecret.trim(), }) + : channelType === "whatsapp" + ? JSON.stringify({ + phoneNumberId: form.whatsAppPhoneNumberId.trim(), + wabaId: form.whatsAppWabaId.trim(), + accessToken: form.whatsAppAccessToken.trim(), + webhookVerifyToken: form.whatsAppWebhookVerifyToken.trim(), + }) + : channelType === "slack" + ? JSON.stringify({ + botToken: form.slackBotToken.trim(), + signingSecret: form.slackSigningSecret.trim(), + appId: form.slackAppId.trim(), + teamId: form.slackTeamId.trim(), + teamName: form.slackTeamName.trim(), + defaultChannel: form.slackDefaultChannel.trim(), + }) : channelType === "wechat_mp" ? JSON.stringify(webLikeConfig) : JSON.stringify({ @@ -832,6 +950,8 @@ function ChannelFormBody({ { value: "discord", label: t("channel.typeDiscord") }, { value: "messenger", label: t("channel.typeMessenger") }, { value: "instagram", label: t("channel.typeInstagram") }, + { value: "whatsapp", label: t("channel.typeWhatsApp") }, + { value: "slack", label: t("channel.typeSlack") }, { value: "telegram", label: t("channel.typeTelegram") }, { value: "zalo_oa", label: t("channel.typeZaloOa") }, { value: "wechat_mp", label: t("channel.typeWechatMp") }, @@ -1361,6 +1481,151 @@ function ChannelFormBody({
) : null} + {channelType === "whatsapp" ? ( +
+
+
{t("channel.whatsappConnectTitle")}
+
{t("channel.whatsappConnectDescription")}
+
+ +
+
+ {t("channel.inboundWebhookUrl")}: /api/third/whatsapp/webhook +
+
+ +
+ + {t("channel.whatsappPhoneId")} + + + + + + + + {t("channel.whatsappWabaId")} + + + + + +
+ + + {t("channel.whatsappAccessToken")} + + + + + +
+ ) : null} + + {channelType === "slack" ? ( +
+
+
{t("channel.slackConnectTitle")}
+
{t("channel.slackConnectDescription")}
+
+ +
+
+ {t("channel.inboundWebhookUrl")}: /api/third/slack/webhook +
+
+ +
+ + {t("channel.slackTeamName")} + + + + + + + + {t("channel.slackDefaultChannel")} + + + + + +
+ +
+ + {t("channel.slackBotToken")} + + + + + + + + {t("channel.slackSigningSecret")} + + + + + +
+
+ ) : 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 e6a8194a..dc6be3e6 100644 --- a/web/app/(dashboard)/dashboard/channels/page.tsx +++ b/web/app/(dashboard)/dashboard/channels/page.tsx @@ -3,11 +3,13 @@ import { Building2Icon, Gamepad2Icon, + HashIcon, InstagramIcon, MailIcon, MessageCircleIcon, MessagesSquareIcon, MessageSquareMoreIcon, + PhoneIcon, SendIcon, } from "lucide-react" @@ -43,6 +45,12 @@ function getChannelTypeLabel(channelType: string, t: (key: string) => string) { if (channelType === "instagram") { return t("channel.typeInstagram") } + if (channelType === "whatsapp") { + return t("channel.typeWhatsApp") + } + if (channelType === "slack") { + return t("channel.typeSlack") + } if (channelType === "wechat_mp") { return t("channel.typeWechatMp") } @@ -81,6 +89,12 @@ function ChannelIcon({ channelType }: { channelType: string }) { if (channelType === "instagram") { return } + if (channelType === "whatsapp") { + return + } + if (channelType === "slack") { + return + } if (channelType === "wechat_mp") { return } @@ -109,6 +123,8 @@ export default function DashboardChannelsPage() { { value: "discord", label: t("channel.typeDiscord") }, { value: "messenger", label: t("channel.typeMessenger") }, { value: "instagram", label: t("channel.typeInstagram") }, + { value: "whatsapp", label: t("channel.typeWhatsApp") }, + { value: "slack", label: t("channel.typeSlack") }, { 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 f9d111c9..707e50ac 100644 --- a/web/lib/generated/enums.ts +++ b/web/lib/generated/enums.ts @@ -84,6 +84,8 @@ export enum ExternalSource { Discord = "discord", Messenger = "messenger", Instagram = "instagram", + WhatsApp = "whatsapp", + Slack = "slack", } export const ExternalSourceLabels: Record = { [ExternalSource.Guest]: "访客", @@ -96,6 +98,8 @@ export const ExternalSourceLabels: Record = { [ExternalSource.Discord]: "Discord", [ExternalSource.Messenger]: "Messenger", [ExternalSource.Instagram]: "Instagram", + [ExternalSource.WhatsApp]: "WhatsApp", + [ExternalSource.Slack]: "Slack", } export enum Gender { diff --git a/web/messages/en-US.json b/web/messages/en-US.json index fada160c..498c7e56 100644 --- a/web/messages/en-US.json +++ b/web/messages/en-US.json @@ -625,6 +625,8 @@ "typeDiscord": "Discord Community", "typeMessenger": "Facebook Messenger", "typeInstagram": "Instagram Direct", + "typeWhatsApp": "WhatsApp Business", + "typeSlack": "Slack Workspace", "typeWechatMp": "WeChat Official Account", "typeWxworkKf": "WeCom Customer Service", "emailAddress": "Support Email Address", @@ -676,6 +678,19 @@ "instagramUsername": "Instagram @Username", "instagramId": "Instagram Business Account ID", "instagramPageAccessToken": "Instagram / Page Access Token", + "whatsappConnectTitle": "1-Click WhatsApp Cloud API Connection", + "whatsappConnectDescription": "Connect your WhatsApp Business Account to Crove Desk. Direct customer chats and media attachments will flow seamlessly into agent inbox and AI.", + "connectWhatsAppButton": "Connect WhatsApp Account", + "whatsappPhoneId": "Phone Number ID", + "whatsappWabaId": "WABA ID (Business Account ID)", + "whatsappAccessToken": "System User Access Token", + "slackConnectTitle": "1-Click Slack App / Bot Connection", + "slackConnectDescription": "Connect your company Slack workspace to Crove Desk. Channel mentions and direct messages will create tickets and trigger AI agent support.", + "connectSlackButton": "Add to Slack", + "slackTeamName": "Workspace Name", + "slackDefaultChannel": "Default Channel ID (e.g. C0123456789)", + "slackBotToken": "Bot User OAuth Token (xoxb-...)", + "slackSigningSecret": "Signing Secret", "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 cb0f060a..9d562d6f 100644 --- a/web/messages/vi-VN.json +++ b/web/messages/vi-VN.json @@ -632,6 +632,8 @@ "typeDiscord": "Cộng đồng Discord", "typeMessenger": "Facebook Messenger", "typeInstagram": "Instagram Direct", + "typeWhatsApp": "WhatsApp Business", + "typeSlack": "Slack Workspace", "typeWechatMp": "WeChat Official Account", "typeWxworkKf": "WeCom Customer Service", "emailAddress": "Địa chỉ Email Hỗ trợ", @@ -683,6 +685,19 @@ "instagramUsername": "Instagram @Username", "instagramId": "Instagram Business Account ID", "instagramPageAccessToken": "Instagram / Page Access Token", + "whatsappConnectTitle": "Kết nối WhatsApp Cloud API 1-Click", + "whatsappConnectDescription": "Kết nối tài khoản WhatsApp Doanh nghiệp của bạn với Crove Desk. Tin nhắn và file đính kèm từ khách hàng sẽ trực tiếp chuyển vào Workbench và kích hoạt AI phản hồi.", + "connectWhatsAppButton": "Kết nối WhatsApp Account", + "whatsappPhoneId": "Phone Number ID", + "whatsappWabaId": "WABA ID (Mã tài khoản doanh nghiệp)", + "whatsappAccessToken": "System User Access Token", + "slackConnectTitle": "Kết nối Slack Workspace / Bot 1-Click", + "slackConnectDescription": "Kết nối không gian làm việc Slack của công ty với Crove Desk. Tin nhắn nhắc tên bot hoặc DM sẽ tự động tạo Ticket và nhận phản hồi từ AI Agent.", + "connectSlackButton": "Thêm vào Slack (Add to Slack)", + "slackTeamName": "Tên Workspace", + "slackDefaultChannel": "Channel ID Mặc định (ví dụ C0123456789)", + "slackBotToken": "Bot User OAuth Token (xoxb-...)", + "slackSigningSecret": "Signing Secret", "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 8428616d..44d8ae3a 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -625,6 +625,8 @@ "typeDiscord": "Discord 社区", "typeMessenger": "Facebook Messenger", "typeInstagram": "Instagram Direct", + "typeWhatsApp": "WhatsApp Business", + "typeSlack": "Slack Workspace", "typeWechatMp": "微信公众号", "typeWxworkKf": "企业微信客服", "emailAddress": "支持邮箱地址", @@ -676,6 +678,19 @@ "instagramUsername": "Instagram @账号", "instagramId": "Instagram Business Account ID", "instagramPageAccessToken": "Instagram / Page Access Token", + "whatsappConnectTitle": "WhatsApp Cloud API 一键授权连接", + "whatsappConnectDescription": "一键连接您的 WhatsApp Business 商业账号,客户私聊消息与多媒体附件将直接接入工作台并触发 AI 回复。", + "connectWhatsAppButton": "一键连接 WhatsApp Account", + "whatsappPhoneId": "Phone Number ID", + "whatsappWabaId": "WABA ID (商业账号 ID)", + "whatsappAccessToken": "System User Access Token", + "slackConnectTitle": "Slack Workspace 一键授权连接", + "slackConnectDescription": "将 Crove Desk 机器人应用添加至您的 Slack 工作区,频道提及与私聊消息将自动同步至工作台。", + "connectSlackButton": "添加到 Slack (Add to Slack)", + "slackTeamName": "工作区名称", + "slackDefaultChannel": "默认转发频道 ID (如 C0123456789)", + "slackBotToken": "Bot User OAuth Token (xoxb-...)", + "slackSigningSecret": "Signing Secret", "loadFailed": "加载接入渠道失败", "created": "已创建接入渠道:{name}", "updated": "已更新接入渠道:{name}", From af3ed5dad4f2e01746ea5566d855e92c23fa7ca7 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:36:19 +0700 Subject: [PATCH 16/30] feat(channels): add X and TikTok direct messaging channel support --- internal/pkg/dto/dto.go | 23 +++ internal/pkg/enums/external_identity.go | 6 +- internal/pkg/enums/wxwork_kf.go | 4 +- .../channel_message_outbox_service.go | 126 +++++++++++++ internal/services/channel_service.go | 89 ++++++++- internal/services/message_service.go | 18 ++ internal/services/tiktok_inbound_service.go | 116 ++++++++++++ internal/services/tiktok_outbound_service.go | 164 ++++++++++++++++ internal/services/x_inbound_service.go | 175 ++++++++++++++++++ internal/services/x_outbound_service.go | 168 +++++++++++++++++ internal/tiktok/client.go | 109 +++++++++++ internal/tiktok/types.go | 31 ++++ internal/x/client.go | 108 +++++++++++ internal/x/types.go | 44 +++++ web/lib/generated/enums.ts | 4 + 15 files changed, 1181 insertions(+), 4 deletions(-) create mode 100644 internal/services/tiktok_inbound_service.go create mode 100644 internal/services/tiktok_outbound_service.go create mode 100644 internal/services/x_inbound_service.go create mode 100644 internal/services/x_outbound_service.go create mode 100644 internal/tiktok/client.go create mode 100644 internal/tiktok/types.go create mode 100644 internal/x/client.go create mode 100644 internal/x/types.go diff --git a/internal/pkg/dto/dto.go b/internal/pkg/dto/dto.go index d65d9ff2..24acb3a7 100644 --- a/internal/pkg/dto/dto.go +++ b/internal/pkg/dto/dto.go @@ -111,3 +111,26 @@ type SlackChannelConfig struct { DefaultChannel string `json:"defaultChannel,omitempty"` // Default channel to post WelcomeMessage string `json:"welcomeMessage,omitempty"` } + +type XChannelConfig struct { + BearerToken string `json:"bearerToken,omitempty"` // X API v2 Bearer Token + APIKey string `json:"apiKey,omitempty"` // Consumer Key + APISecretKey string `json:"apiSecretKey,omitempty"` // Consumer Secret + AccessToken string `json:"accessToken,omitempty"` // Access Token + AccessTokenSecret string `json:"accessTokenSecret,omitempty"` // Access Token Secret + AccountID string `json:"accountId,omitempty"` // X Numeric User/Account ID + Username string `json:"username,omitempty"` // @handle + WebhookEnv string `json:"webhookEnv,omitempty"` // Webhook environment name + WebhookCRCSecret string `json:"webhookCRCSecret,omitempty"` // CRC response secret + WelcomeMessage string `json:"welcomeMessage,omitempty"` +} + +type TikTokChannelConfig struct { + ClientKey string `json:"clientKey,omitempty"` // TikTok App Client Key + ClientSecret string `json:"clientSecret,omitempty"` // TikTok App Client Secret + AccessToken string `json:"accessToken,omitempty"` // Business User Access Token + OpenID string `json:"openId,omitempty"` // TikTok Business Account OpenID + Username string `json:"username,omitempty"` // @username + WebhookVerifyToken string `json:"webhookVerifyToken,omitempty"` // Verification Token + WelcomeMessage string `json:"welcomeMessage,omitempty"` +} diff --git a/internal/pkg/enums/external_identity.go b/internal/pkg/enums/external_identity.go index a13bb5e3..6cd17efe 100644 --- a/internal/pkg/enums/external_identity.go +++ b/internal/pkg/enums/external_identity.go @@ -17,7 +17,9 @@ const ( ExternalSourceMessenger ExternalSource = "messenger" // Facebook Messenger ExternalSourceInstagram ExternalSource = "instagram" // Instagram Direct ExternalSourceWhatsApp ExternalSource = "whatsapp" // WhatsApp Business - ExternalSourceSlack ExternalSource = "slack" // Slack Bot + ExternalSourceSlack ExternalSource = "slack" // Slack Bot + ExternalSourceX ExternalSource = "x" // X (Twitter) + ExternalSourceTikTok ExternalSource = "tiktok" // TikTok Direct Messages ) var externalSourceLabelMap = map[ExternalSource]string{ @@ -33,6 +35,8 @@ var externalSourceLabelMap = map[ExternalSource]string{ ExternalSourceInstagram: "Instagram", ExternalSourceWhatsApp: "WhatsApp", ExternalSourceSlack: "Slack", + ExternalSourceX: "X", + ExternalSourceTikTok: "TikTok", } func GetExternalSourceLabel(v ExternalSource) string { diff --git a/internal/pkg/enums/wxwork_kf.go b/internal/pkg/enums/wxwork_kf.go index 2dd58e4d..41a02e04 100644 --- a/internal/pkg/enums/wxwork_kf.go +++ b/internal/pkg/enums/wxwork_kf.go @@ -28,7 +28,9 @@ const ( ChannelTypeMessenger = "messenger" ChannelTypeInstagram = "instagram" ChannelTypeWhatsApp = "whatsapp" - ChannelTypeSlack = "slack" + ChannelTypeSlack = "slack" + ChannelTypeX = "x" + ChannelTypeTikTok = "tiktok" ) type WxWorkKFMessageSendStatus string diff --git a/internal/services/channel_message_outbox_service.go b/internal/services/channel_message_outbox_service.go index 55bba124..8e990195 100644 --- a/internal/services/channel_message_outbox_service.go +++ b/internal/services/channel_message_outbox_service.go @@ -630,6 +630,132 @@ func (s *channelMessageOutboxService) EnqueueSlackMessage(conversation *models.C return nil } +func (s *channelMessageOutboxService) EnqueueXMessage(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.ChannelTypeX { + return nil + } + if message.SenderType != enums.IMSenderTypeAgent && message.SenderType != enums.IMSenderTypeAI { + return nil + } + if message.MessageType != enums.IMMessageTypeText && message.MessageType != enums.IMMessageTypeHTML && message.MessageType != enums.IMMessageTypeImage && message.MessageType != enums.IMMessageTypeAttachment { + return nil + } + if existing := s.GetByMessageID(enums.ChannelTypeX, 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.ChannelTypeX, + 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 x outbound dispatch", "error", r) + } + }() + XOutboundService.DispatchPendingOutbox() + }() + + return nil +} + +func (s *channelMessageOutboxService) EnqueueTikTokMessage(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.ChannelTypeTikTok { + return nil + } + if message.SenderType != enums.IMSenderTypeAgent && message.SenderType != enums.IMSenderTypeAI { + return nil + } + if message.MessageType != enums.IMMessageTypeText && message.MessageType != enums.IMMessageTypeHTML && message.MessageType != enums.IMMessageTypeImage && message.MessageType != enums.IMMessageTypeAttachment { + return nil + } + if existing := s.GetByMessageID(enums.ChannelTypeTikTok, 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.ChannelTypeTikTok, + 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 tiktok outbound dispatch", "error", r) + } + }() + TikTokOutboundService.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 c5d08377..56ee4576 100644 --- a/internal/services/channel_service.go +++ b/internal/services/channel_service.go @@ -544,6 +544,45 @@ func (s *channelService) ParseSlackChannelConfig(raw string) (*dto.SlackChannelC return cfg, nil } +func (s *channelService) ParseXChannelConfig(raw string) (*dto.XChannelConfig, error) { + raw = strings.TrimSpace(raw) + cfg := &dto.XChannelConfig{} + if raw != "" { + if err := json.Unmarshal([]byte(raw), cfg); err != nil { + return nil, err + } + } + cfg.BearerToken = strings.TrimSpace(cfg.BearerToken) + cfg.APIKey = strings.TrimSpace(cfg.APIKey) + cfg.APISecretKey = strings.TrimSpace(cfg.APISecretKey) + cfg.AccessToken = strings.TrimSpace(cfg.AccessToken) + cfg.AccessTokenSecret = strings.TrimSpace(cfg.AccessTokenSecret) + cfg.AccountID = strings.TrimSpace(cfg.AccountID) + cfg.Username = strings.TrimSpace(cfg.Username) + cfg.WebhookEnv = strings.TrimSpace(cfg.WebhookEnv) + cfg.WebhookCRCSecret = strings.TrimSpace(cfg.WebhookCRCSecret) + cfg.WelcomeMessage = strings.TrimSpace(cfg.WelcomeMessage) + return cfg, nil +} + +func (s *channelService) ParseTikTokChannelConfig(raw string) (*dto.TikTokChannelConfig, error) { + raw = strings.TrimSpace(raw) + cfg := &dto.TikTokChannelConfig{} + if raw != "" { + if err := json.Unmarshal([]byte(raw), cfg); err != nil { + return nil, err + } + } + cfg.ClientKey = strings.TrimSpace(cfg.ClientKey) + cfg.ClientSecret = strings.TrimSpace(cfg.ClientSecret) + cfg.AccessToken = strings.TrimSpace(cfg.AccessToken) + cfg.OpenID = strings.TrimSpace(cfg.OpenID) + cfg.Username = strings.TrimSpace(cfg.Username) + cfg.WebhookVerifyToken = strings.TrimSpace(cfg.WebhookVerifyToken) + cfg.WelcomeMessage = strings.TrimSpace(cfg.WelcomeMessage) + return cfg, nil +} + func (s *channelService) GetUserTokenSecret(channel *models.Channel) string { if channel == nil { return "" @@ -721,7 +760,7 @@ func extractTenantSlugFromEmail(emailAddress string) string { } localPart, domain := parts[0], parts[1] - // Check plus addressing (e.g. help+dos@crove.io -> "dos") + // 1. Check plus addressing (e.g. help+dos@crove.io -> "dos", support+acme@crove.io -> "acme") if strings.Contains(localPart, "+") { plusParts := strings.Split(localPart, "+") if len(plusParts) > 1 && plusParts[1] != "" { @@ -729,7 +768,16 @@ func extractTenantSlugFromEmail(emailAddress string) string { } } - // Check subdomains (e.g. dos.crove.io -> "dos", dos.on.crove.email -> "dos") + // 2. Check direct tenant addressing (e.g. dos@crove.io -> "dos", acme@crove.io -> "acme") + genericPrefixes := map[string]bool{ + "help": true, "support": true, "contact": true, "inbound": true, + "admin": true, "info": true, "sales": true, "hello": true, "service": true, "desk": true, + } + if !genericPrefixes[localPart] { + return localPart + } + + // 3. Check subdomains (e.g. help@dos.crove.io -> "dos", help@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" { @@ -1025,6 +1073,43 @@ func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRe return nil, err } configJSON = string(configBytes) + case enums.ChannelTypeX: + 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.ParseXChannelConfig(configJSON) + if err != nil { + return nil, errorsx.InvalidParam("invalid x configuration") + } + configBytes, err := json.Marshal(cfg) + if err != nil { + return nil, err + } + configJSON = string(configBytes) + case enums.ChannelTypeTikTok: + 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.ParseTikTokChannelConfig(configJSON) + if err != nil { + return nil, errorsx.InvalidParam("invalid tiktok configuration") + } + if cfg.WebhookVerifyToken == "" { + if secret, err := generateUserTokenSecret(); err == nil { + cfg.WebhookVerifyToken = secret + } + } + configBytes, err := json.Marshal(cfg) + if err != nil { + return nil, err + } + configJSON = string(configBytes) } return &models.Channel{ diff --git a/internal/services/message_service.go b/internal/services/message_service.go index 01cedcbd..b306f63c 100644 --- a/internal/services/message_service.go +++ b/internal/services/message_service.go @@ -617,6 +617,24 @@ func (s *messageService) sendValidatedMessage(conversation *models.Conversation, "error", enqueueErr, ) } + + // X (Twitter) 渠道消息入队,异步发送 + if enqueueErr := ChannelMessageOutboxService.EnqueueXMessage(conversation, message); enqueueErr != nil { + slog.Error("enqueue x outbox failed", + "conversation_id", conversation.ID, + "message_id", message.ID, + "error", enqueueErr, + ) + } + + // TikTok 渠道消息入队,异步发送 + if enqueueErr := ChannelMessageOutboxService.EnqueueTikTokMessage(conversation, message); enqueueErr != nil { + slog.Error("enqueue tiktok 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/tiktok_inbound_service.go b/internal/services/tiktok_inbound_service.go new file mode 100644 index 00000000..6ee71243 --- /dev/null +++ b/internal/services/tiktok_inbound_service.go @@ -0,0 +1,116 @@ +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/tiktok" +) + +var TikTokInboundService = newTikTokInboundService() + +func newTikTokInboundService() *tiktokInboundService { + return &tiktokInboundService{} +} + +type tiktokInboundService struct{} + +// HandleWebhook processes an incoming Webhook event from TikTok Business Messaging API. +func (s *tiktokInboundService) HandleWebhook(ctx context.Context, channelID string, verifyTokenHeader string, rawPayload []byte) error { + var event tiktok.WebhookEvent + if err := json.Unmarshal(rawPayload, &event); err != nil { + return fmt.Errorf("unmarshal tiktok webhook failed: %w", err) + } + + toUserID := strings.TrimSpace(event.ToUserID) + clientKey := strings.TrimSpace(event.ClientKey) + + var channel *models.Channel + channelID = strings.TrimSpace(channelID) + if channelID != "" { + channel = ChannelService.Take("channel_id = ? AND channel_type = ? AND status = ?", channelID, enums.ChannelTypeTikTok, enums.StatusOk) + } + if channel == nil && toUserID != "" { + channel = ChannelService.Take("channel_type = ? AND status = ? AND (channel_id = ? OR config_json LIKE ?)", + enums.ChannelTypeTikTok, enums.StatusOk, toUserID, "%"+toUserID+"%") + } + if channel == nil && clientKey != "" { + channel = ChannelService.Take("channel_type = ? AND status = ? AND config_json LIKE ?", + enums.ChannelTypeTikTok, enums.StatusOk, "%"+clientKey+"%") + } + if channel == nil { + channel = ChannelService.Take("channel_type = ? AND status = ?", enums.ChannelTypeTikTok, enums.StatusOk) + } + if channel == nil { + return errorsx.InvalidParam("tiktok channel not found or disabled") + } + + cfg, err := ChannelService.ParseTikTokChannelConfig(channel.ConfigJSON) + if err != nil || cfg == nil { + return errorsx.InvalidParam("tiktok channel config invalid") + } + + if cfg.WebhookVerifyToken != "" && strings.TrimSpace(verifyTokenHeader) != "" { + if strings.TrimSpace(verifyTokenHeader) != cfg.WebhookVerifyToken { + return errorsx.UnauthorizedI18n("error.auth.invalidSignature") + } + } + + senderID := strings.TrimSpace(event.FromUserID) + if senderID == "" || senderID == toUserID { + return nil // Ignore echo / self messages + } + + text := strings.TrimSpace(event.Content) + if text == "" { + return nil + } + + // 1. Resolve customer identity (TikTok OpenID) + externalUser := openidentity.ExternalUser{ + ExternalSource: enums.ExternalSourceTikTok, + ExternalID: senderID, + ExternalName: fmt.Sprintf("TikTok User %s", senderID), + } + + // 2. Create or match Conversation + conversation, err := ConversationService.Create(externalUser, channel.ID, channel.AIAgentID) + if err != nil { + return fmt.Errorf("create tiktok conversation failed: %w", err) + } + + // 3. Send message through MessageService + msgID := event.EventID + if msgID == "" { + msgID = fmt.Sprintf("%d", event.CreateTime) + } + clientMsgID := fmt.Sprintf("tiktok_%s", msgID) + payloadMap := map[string]any{ + "tiktok_event_id": event.EventID, + "tiktok_from_user": senderID, + "tiktok_to_user": toUserID, + "tiktok_timestamp": event.CreateTime, + "tiktok_event_type": event.Event, + } + 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/tiktok_outbound_service.go b/internal/services/tiktok_outbound_service.go new file mode 100644 index 00000000..e99897c0 --- /dev/null +++ b/internal/services/tiktok_outbound_service.go @@ -0,0 +1,164 @@ +package services + +import ( + "context" + "log/slog" + "strings" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + "agent-desk/internal/services/storage" + "agent-desk/internal/tiktok" + + "github.com/mlogclub/simple/sqls" +) + +const ( + tiktokOutboxBatchSize = 20 + tiktokOutboxMaxRetry = 5 +) + +var TikTokOutboundService = newTikTokOutboundService() + +func newTikTokOutboundService() *tiktokOutboundService { + return &tiktokOutboundService{} +} + +type tiktokOutboundService struct{} + +func (s *tiktokOutboundService) DispatchPendingOutbox() int { + return s.doDispatchPendingOutbox(tiktokOutboxBatchSize) +} + +func (s *tiktokOutboundService) doDispatchPendingOutbox(limit int) int { + if limit <= 0 { + limit = tiktokOutboxBatchSize + } + items := ChannelMessageOutboxService.ListPending(enums.ChannelTypeTikTok, 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 tiktok outbox failed", + "outbox_id", items[i].ID, + "error", err, + ) + continue + } + successCount++ + } + return successCount +} + +func (s *tiktokOutboundService) processOutbox(outboxID int64) error { + outbox := ChannelMessageOutboxService.Get(outboxID) + if outbox == nil { + return nil + } + if outbox.ChannelType != enums.ChannelTypeTikTok { + 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, "tiktok channel not found or disabled") + } + cfg, err := ChannelService.ParseTikTokChannelConfig(channel.ConfigJSON) + if err != nil || cfg == nil || cfg.AccessToken == "" { + return s.markOutboxFailed(outbox, "tiktok access token not configured") + } + + // Resolve target TikTok OpenID (ExternalID) + var recipientOpenID string + customerIdentity := repositories.CustomerIdentityRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("customer_id", conversation.CustomerID). + Eq("external_source", enums.ExternalSourceTikTok)) + if customerIdentity != nil { + recipientOpenID = strings.TrimSpace(customerIdentity.ExternalID) + } + if recipientOpenID == "" { + return s.markOutboxFailed(outbox, "unable to resolve recipient tiktok open_id") + } + + client := tiktok.NewClient(cfg.AccessToken) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + textToSend := message.Content + if message.MessageType == enums.IMMessageTypeImage || message.MessageType == enums.IMMessageTypeAttachment { + assetPayload, err := parseIMMessageAssetPayload(message.Payload) + if err == nil && assetPayload != nil { + assetPayload = hydrateIMMessageAssetPayload(assetPayload) + if assetPayload.Provider != "" && assetPayload.StorageKey != "" { + if provider, err := storage.NewProvider(assetPayload.Provider); err == nil { + fileURL := provider.GetSignedURL(assetPayload.StorageKey) + if fileURL != "" { + if textToSend != "" { + textToSend += "\n" + fileURL + } else { + textToSend = fileURL + } + } + } + } + } + } + + _, sendErr := client.SendTextMessage(ctx, recipientOpenID, textToSend) + 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 *tiktokOutboundService) markOutboxFailed(outbox *models.ChannelMessageOutbox, errMsg string) error { + if outbox == nil { + return nil + } + retryCount := outbox.RetryCount + 1 + status := string(enums.ChannelMessageOutboxStatusFailed) + if retryCount >= tiktokOutboxMaxRetry { + 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/x_inbound_service.go b/internal/services/x_inbound_service.go new file mode 100644 index 00000000..994c3dbb --- /dev/null +++ b/internal/services/x_inbound_service.go @@ -0,0 +1,175 @@ +package services + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "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/x" +) + +var XInboundService = newXInboundService() + +func newXInboundService() *xInboundService { + return &xInboundService{} +} + +type xInboundService struct{} + +// HandleCRC performs the Challenge-Response Check (CRC) required by X Account Activity API. +func (s *xInboundService) HandleCRC(channelID string, crcToken string) (string, error) { + crcToken = strings.TrimSpace(crcToken) + if crcToken == "" { + return "", errorsx.InvalidParam("crc_token is required") + } + + var channel *models.Channel + channelID = strings.TrimSpace(channelID) + if channelID != "" { + channel = ChannelService.Take("channel_id = ? AND channel_type = ? AND status = ?", channelID, enums.ChannelTypeX, enums.StatusOk) + } + if channel == nil { + channel = ChannelService.Take("channel_type = ? AND status = ?", enums.ChannelTypeX, enums.StatusOk) + } + if channel == nil { + return "", errorsx.InvalidParam("x channel not found or disabled") + } + + cfg, err := ChannelService.ParseXChannelConfig(channel.ConfigJSON) + if err != nil || cfg == nil { + return "", errorsx.InvalidParam("x channel config invalid") + } + + secret := cfg.APISecretKey + if secret == "" { + secret = cfg.WebhookCRCSecret + } + if secret == "" { + return "", errorsx.InvalidParam("x api_secret_key is required for CRC response") + } + + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(crcToken)) + responseToken := "sha256=" + base64.StdEncoding.EncodeToString(mac.Sum(nil)) + return responseToken, nil +} + +// HandleWebhook processes incoming Direct Message events from X Account Activity API. +func (s *xInboundService) HandleWebhook(ctx context.Context, channelID string, signatureHeader string, rawPayload []byte) error { + var event x.WebhookEvent + if err := json.Unmarshal(rawPayload, &event); err != nil { + return fmt.Errorf("unmarshal x webhook failed: %w", err) + } + + forUserID := strings.TrimSpace(event.ForUserID) + + var channel *models.Channel + channelID = strings.TrimSpace(channelID) + if channelID != "" { + channel = ChannelService.Take("channel_id = ? AND channel_type = ? AND status = ?", channelID, enums.ChannelTypeX, enums.StatusOk) + } + if channel == nil && forUserID != "" { + channel = ChannelService.Take("channel_type = ? AND status = ? AND (channel_id = ? OR config_json LIKE ?)", + enums.ChannelTypeX, enums.StatusOk, forUserID, "%"+forUserID+"%") + } + if channel == nil { + channel = ChannelService.Take("channel_type = ? AND status = ?", enums.ChannelTypeX, enums.StatusOk) + } + if channel == nil { + return errorsx.InvalidParam("x channel not found or disabled") + } + + cfg, err := ChannelService.ParseXChannelConfig(channel.ConfigJSON) + if err != nil || cfg == nil { + return errorsx.InvalidParam("x channel config invalid") + } + + // Verify signature if secret configured + secret := cfg.APISecretKey + if secret == "" { + secret = cfg.WebhookCRCSecret + } + if secret != "" && strings.TrimSpace(signatureHeader) != "" { + if !verifyXSignature(secret, signatureHeader, rawPayload) { + return errorsx.UnauthorizedI18n("error.auth.invalidSignature") + } + } + + for _, dm := range event.DirectMessageEvents { + if dm.Type != "message_create" { + continue + } + + senderID := strings.TrimSpace(dm.MessageCreate.SenderID) + if senderID == "" || senderID == forUserID || (cfg.AccountID != "" && senderID == cfg.AccountID) { + continue // Ignore echo / self messages + } + + text := strings.TrimSpace(dm.MessageCreate.MessageData.Text) + if text == "" && dm.MessageCreate.MessageData.Attachment != nil { + if dm.MessageCreate.MessageData.Attachment.Media.MediaURL != "" { + text = dm.MessageCreate.MessageData.Attachment.Media.MediaURL + } + } + if text == "" { + continue + } + + // 1. Resolve customer identity + externalUser := openidentity.ExternalUser{ + ExternalSource: enums.ExternalSourceX, + ExternalID: senderID, + ExternalName: fmt.Sprintf("X User %s", senderID), + } + + // 2. Create or match Conversation + conversation, err := ConversationService.Create(externalUser, channel.ID, channel.AIAgentID) + if err != nil { + return fmt.Errorf("create x conversation failed: %w", err) + } + + // 3. Send message through MessageService + clientMsgID := fmt.Sprintf("x_%s", dm.ID) + payloadMap := map[string]any{ + "x_dm_id": dm.ID, + "x_sender_id": senderID, + "x_for_user_id": forUserID, + "x_timestamp": dm.CreatedTimestamp, + } + 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 +} + +func verifyXSignature(secret string, signatureHeader string, payload []byte) bool { + sig := strings.TrimSpace(signatureHeader) + if strings.HasPrefix(sig, "sha256=") { + expectedSig := sig[len("sha256="):] + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(payload) + actualSig := base64.StdEncoding.EncodeToString(mac.Sum(nil)) + return hmac.Equal([]byte(actualSig), []byte(expectedSig)) + } + return true +} diff --git a/internal/services/x_outbound_service.go b/internal/services/x_outbound_service.go new file mode 100644 index 00000000..1ddc8e8a --- /dev/null +++ b/internal/services/x_outbound_service.go @@ -0,0 +1,168 @@ +package services + +import ( + "context" + "log/slog" + "strings" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + "agent-desk/internal/services/storage" + "agent-desk/internal/x" + + "github.com/mlogclub/simple/sqls" +) + +const ( + xOutboxBatchSize = 20 + xOutboxMaxRetry = 5 +) + +var XOutboundService = newXOutboundService() + +func newXOutboundService() *xOutboundService { + return &xOutboundService{} +} + +type xOutboundService struct{} + +func (s *xOutboundService) DispatchPendingOutbox() int { + return s.doDispatchPendingOutbox(xOutboxBatchSize) +} + +func (s *xOutboundService) doDispatchPendingOutbox(limit int) int { + if limit <= 0 { + limit = xOutboxBatchSize + } + items := ChannelMessageOutboxService.ListPending(enums.ChannelTypeX, 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 x outbox failed", + "outbox_id", items[i].ID, + "error", err, + ) + continue + } + successCount++ + } + return successCount +} + +func (s *xOutboundService) processOutbox(outboxID int64) error { + outbox := ChannelMessageOutboxService.Get(outboxID) + if outbox == nil { + return nil + } + if outbox.ChannelType != enums.ChannelTypeX { + 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, "x channel not found or disabled") + } + cfg, err := ChannelService.ParseXChannelConfig(channel.ConfigJSON) + if err != nil || cfg == nil || (cfg.BearerToken == "" && cfg.AccessToken == "") { + return s.markOutboxFailed(outbox, "x credentials (bearer token / access token) not configured") + } + + // Resolve target X User ID (ExternalID) + var recipientID string + customerIdentity := repositories.CustomerIdentityRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("customer_id", conversation.CustomerID). + Eq("external_source", enums.ExternalSourceX)) + if customerIdentity != nil { + recipientID = strings.TrimSpace(customerIdentity.ExternalID) + } + if recipientID == "" { + return s.markOutboxFailed(outbox, "unable to resolve recipient x user_id") + } + + token := cfg.BearerToken + if token == "" { + token = cfg.AccessToken + } + client := x.NewClient(token) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + textToSend := message.Content + if message.MessageType == enums.IMMessageTypeImage || message.MessageType == enums.IMMessageTypeAttachment { + assetPayload, err := parseIMMessageAssetPayload(message.Payload) + if err == nil && assetPayload != nil { + assetPayload = hydrateIMMessageAssetPayload(assetPayload) + if assetPayload.Provider != "" && assetPayload.StorageKey != "" { + if provider, err := storage.NewProvider(assetPayload.Provider); err == nil { + fileURL := provider.GetSignedURL(assetPayload.StorageKey) + if fileURL != "" { + if textToSend != "" { + textToSend += "\n" + fileURL + } else { + textToSend = fileURL + } + } + } + } + } + } + + _, sendErr := client.SendDirectMessage(ctx, recipientID, textToSend) + 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 *xOutboundService) markOutboxFailed(outbox *models.ChannelMessageOutbox, errMsg string) error { + if outbox == nil { + return nil + } + retryCount := outbox.RetryCount + 1 + status := string(enums.ChannelMessageOutboxStatusFailed) + if retryCount >= xOutboxMaxRetry { + 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/tiktok/client.go b/internal/tiktok/client.go new file mode 100644 index 00000000..0af81060 --- /dev/null +++ b/internal/tiktok/client.go @@ -0,0 +1,109 @@ +package tiktok + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +const defaultBaseURL = "https://business-api.tiktok.com/open_api/v1.3" + +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(url string) { + if strings.TrimSpace(url) != "" { + c.baseURL = strings.TrimRight(strings.TrimSpace(url), "/") + } +} + +func (c *Client) SendTextMessage(ctx context.Context, toUserID string, text string) (*SendMessageResponse, error) { + toUserID = strings.TrimSpace(toUserID) + if toUserID == "" { + return nil, fmt.Errorf("to_user_id is required") + } + text = strings.TrimSpace(text) + if text == "" { + return nil, fmt.Errorf("content is required") + } + + payload := SendMessageRequest{ + ToUserID: toUserID, + MessageType: "text", + Content: text, + } + + var resp SendMessageResponse + if err := c.doRequest(ctx, "/business/message/send/", payload, &resp); err != nil { + return nil, err + } + if resp.Code != 0 { + return nil, fmt.Errorf("tiktok api error (%d): %s", resp.Code, resp.Message) + } + return &resp, nil +} + +func (c *Client) doRequest(ctx context.Context, path string, payload any, result any) error { + if c.accessToken == "" { + return fmt.Errorf("tiktok access token is required") + } + + endpoint := fmt.Sprintf("%s%s", c.baseURL, path) + + var bodyReader io.Reader + if payload != nil { + bodyBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal tiktok request failed: %w", err) + } + bodyReader = bytes.NewBuffer(bodyBytes) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bodyReader) + if err != nil { + return fmt.Errorf("create tiktok request failed: %w", err) + } + + req.Header.Set("Access-Token", c.accessToken) + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + res, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("tiktok http request failed: %w", err) + } + defer res.Body.Close() + + bodyBytes, err := io.ReadAll(res.Body) + if err != nil { + return fmt.Errorf("read tiktok response failed: %w", err) + } + + if res.StatusCode < 200 || res.StatusCode >= 300 { + return fmt.Errorf("tiktok api error (%d): %s", res.StatusCode, string(bodyBytes)) + } + + if result != nil { + if err := json.Unmarshal(bodyBytes, result); err != nil { + return fmt.Errorf("unmarshal tiktok response failed: %w (body: %s)", err, string(bodyBytes)) + } + } + return nil +} diff --git a/internal/tiktok/types.go b/internal/tiktok/types.go new file mode 100644 index 00000000..df3e203d --- /dev/null +++ b/internal/tiktok/types.go @@ -0,0 +1,31 @@ +package tiktok + +// SendMessageRequest represents payload for TikTok Business Direct Message Send API. +type SendMessageRequest struct { + ToUserID string `json:"to_user_id"` + MessageType string `json:"message_type"` // text | image | video + Content string `json:"content"` +} + +// SendMessageResponse represents response from TikTok Business API. +type SendMessageResponse struct { + Code int `json:"code"` + Message string `json:"message"` + RequestID string `json:"request_id"` + Data struct { + MessageID string `json:"message_id"` + } `json:"data"` +} + +// WebhookEvent represents incoming TikTok Webhook event payload. +type WebhookEvent struct { + Event string `json:"event"` + ClientKey string `json:"client_key"` + EventID string `json:"event_id"` + CreateTime int64 `json:"create_time"` + FromUserID string `json:"from_user_id"` + ToUserID string `json:"to_user_id"` + MsgType string `json:"message_type"` + Content string `json:"content"` + Challenge string `json:"challenge,omitempty"` // For initial verification if challenged +} diff --git a/internal/x/client.go b/internal/x/client.go new file mode 100644 index 00000000..8635a358 --- /dev/null +++ b/internal/x/client.go @@ -0,0 +1,108 @@ +package x + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +const defaultBaseURL = "https://api.twitter.com/2" + +type Client struct { + bearerToken string + baseURL string + httpClient *http.Client +} + +func NewClient(bearerToken string) *Client { + return &Client{ + bearerToken: strings.TrimSpace(bearerToken), + 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) SendDirectMessage(ctx context.Context, recipientID string, text string) (*SendDMResponse, error) { + recipientID = strings.TrimSpace(recipientID) + if recipientID == "" { + return nil, fmt.Errorf("recipient_id is required") + } + text = strings.TrimSpace(text) + if text == "" { + return nil, fmt.Errorf("message text is required") + } + + payload := SendDMRequest{ + Text: text, + } + + var resp SendDMResponse + endpoint := fmt.Sprintf("/dm_conversations/with/%s/messages", recipientID) + if err := c.doRequest(ctx, http.MethodPost, endpoint, payload, &resp); err != nil { + return nil, err + } + if len(resp.Errors) > 0 { + return nil, fmt.Errorf("x api error: %s - %s", resp.Errors[0].Title, resp.Errors[0].Detail) + } + return &resp, nil +} + +func (c *Client) doRequest(ctx context.Context, method, path string, payload any, result any) error { + if c.bearerToken == "" { + return fmt.Errorf("x bearer token is required") + } + + endpoint := fmt.Sprintf("%s%s", c.baseURL, path) + + var bodyReader io.Reader + if payload != nil { + bodyBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal x request failed: %w", err) + } + bodyReader = bytes.NewBuffer(bodyBytes) + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint, bodyReader) + if err != nil { + return fmt.Errorf("create x request failed: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.bearerToken) + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + res, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("x http request failed: %w", err) + } + defer res.Body.Close() + + bodyBytes, err := io.ReadAll(res.Body) + if err != nil { + return fmt.Errorf("read x response failed: %w", err) + } + + if res.StatusCode < 200 || res.StatusCode >= 300 { + return fmt.Errorf("x api error (%d): %s", res.StatusCode, string(bodyBytes)) + } + + if result != nil { + if err := json.Unmarshal(bodyBytes, result); err != nil { + return fmt.Errorf("unmarshal x response failed: %w (body: %s)", err, string(bodyBytes)) + } + } + return nil +} diff --git a/internal/x/types.go b/internal/x/types.go new file mode 100644 index 00000000..c00e2ecf --- /dev/null +++ b/internal/x/types.go @@ -0,0 +1,44 @@ +package x + +// SendDMRequest represents payload for X (Twitter) Direct Message API v2. +type SendDMRequest struct { + Text string `json:"text"` +} + +// SendDMResponse represents response from X API v2. +type SendDMResponse struct { + Data struct { + DMConversationID string `json:"dm_conversation_id"` + DMEventID string `json:"dm_event_id"` + } `json:"data"` + Errors []struct { + Title string `json:"title"` + Detail string `json:"detail"` + } `json:"errors,omitempty"` +} + +// WebhookEvent represents incoming Account Activity API payload from X. +type WebhookEvent struct { + ForUserID string `json:"for_user_id"` + DirectMessageEvents []struct { + Type string `json:"type"` + ID string `json:"id"` + CreatedTimestamp string `json:"created_timestamp"` + MessageCreate struct { + Target struct { + RecipientID string `json:"recipient_id"` + } `json:"target"` + SenderID string `json:"sender_id"` + MessageData struct { + Text string `json:"text"` + Attachment *struct { + Type string `json:"type"` + Media struct { + ID int64 `json:"id"` + MediaURL string `json:"media_url_https"` + } `json:"media"` + } `json:"attachment,omitempty"` + } `json:"message_data"` + } `json:"message_create"` + } `json:"direct_message_events"` +} diff --git a/web/lib/generated/enums.ts b/web/lib/generated/enums.ts index 707e50ac..74174b6e 100644 --- a/web/lib/generated/enums.ts +++ b/web/lib/generated/enums.ts @@ -86,6 +86,8 @@ export enum ExternalSource { Instagram = "instagram", WhatsApp = "whatsapp", Slack = "slack", + X = "x", + TikTok = "tiktok", } export const ExternalSourceLabels: Record = { [ExternalSource.Guest]: "访客", @@ -100,6 +102,8 @@ export const ExternalSourceLabels: Record = { [ExternalSource.Instagram]: "Instagram", [ExternalSource.WhatsApp]: "WhatsApp", [ExternalSource.Slack]: "Slack", + [ExternalSource.X]: "X", + [ExternalSource.TikTok]: "TikTok", } export enum Gender { From b153707f88e43f30b6838ec17a8c20105f51b085 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:09:58 +0700 Subject: [PATCH 17/30] feat(email): support direct domain addressing and plus-addressing for inbound email routing --- internal/handlers/third/tiktok_handler.go | 49 ++++++++++++++++ internal/handlers/third/x_handler.go | 56 +++++++++++++++++++ .../dashboard/channels/_components/edit.tsx | 9 ++- 3 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 internal/handlers/third/tiktok_handler.go create mode 100644 internal/handlers/third/x_handler.go diff --git a/internal/handlers/third/tiktok_handler.go b/internal/handlers/third/tiktok_handler.go new file mode 100644 index 00000000..db0ef0f4 --- /dev/null +++ b/internal/handlers/third/tiktok_handler.go @@ -0,0 +1,49 @@ +package third + +import ( + "bytes" + "io" + "net/http" + "strings" + + "agent-desk/internal/services" + + "github.com/gin-gonic/gin" +) + +// TikTokGetWebhook handles TikTok webhook verification if required. +func TikTokGetWebhook(ctx *gin.Context) { + challenge := strings.TrimSpace(ctx.Query("challenge")) + if challenge != "" { + ctx.String(http.StatusOK, challenge) + return + } + ctx.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// TikTokPostWebhook receives incoming Direct Message events from TikTok Business Messaging. +func TikTokPostWebhook(ctx *gin.Context) { + channelID := strings.TrimSpace(ctx.Param("channel_id")) + if channelID == "" { + channelID = strings.TrimSpace(ctx.Query("channel_id")) + } + + verifyTokenHeader := ctx.GetHeader("X-Tiktok-Verify-Token") + if verifyTokenHeader == "" { + verifyTokenHeader = ctx.GetHeader("X-Webhook-Verify-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.TikTokInboundService.HandleWebhook(ctx.Request.Context(), channelID, verifyTokenHeader, bodyBytes); err != nil { + ctx.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "EVENT_RECEIVED"}) +} diff --git a/internal/handlers/third/x_handler.go b/internal/handlers/third/x_handler.go new file mode 100644 index 00000000..1ad2b4f8 --- /dev/null +++ b/internal/handlers/third/x_handler.go @@ -0,0 +1,56 @@ +package third + +import ( + "bytes" + "io" + "net/http" + "strings" + + "agent-desk/internal/services" + + "github.com/gin-gonic/gin" +) + +// XGetWebhook handles X (Twitter) Account Activity API CRC (Challenge-Response Check). +func XGetWebhook(ctx *gin.Context) { + crcToken := strings.TrimSpace(ctx.Query("crc_token")) + channelID := strings.TrimSpace(ctx.Param("channel_id")) + if channelID == "" { + channelID = strings.TrimSpace(ctx.Query("channel_id")) + } + + responseToken, err := services.XInboundService.HandleCRC(channelID, crcToken) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"response_token": responseToken}) +} + +// XPostWebhook receives incoming Direct Message events from X Account Activity API. +func XPostWebhook(ctx *gin.Context) { + channelID := strings.TrimSpace(ctx.Param("channel_id")) + if channelID == "" { + channelID = strings.TrimSpace(ctx.Query("channel_id")) + } + + sigHeader := ctx.GetHeader("x-twitter-webhooks-signature") + if sigHeader == "" { + sigHeader = ctx.GetHeader("X-Twitter-Webhooks-Signature") + } + + 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.XInboundService.HandleWebhook(ctx.Request.Context(), channelID, sigHeader, 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/web/app/(dashboard)/dashboard/channels/_components/edit.tsx b/web/app/(dashboard)/dashboard/channels/_components/edit.tsx index 9f9168e9..ec4708d5 100644 --- a/web/app/(dashboard)/dashboard/channels/_components/edit.tsx +++ b/web/app/(dashboard)/dashboard/channels/_components/edit.tsx @@ -843,8 +843,13 @@ function ChannelFormBody({ if (raw.endsWith(".crove.io") || raw.endsWith(".on.crove.email") || raw.endsWith(".crove-mail.com")) { return raw } - const slug = (orgSlug || "org").trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") - return `help@${slug}.crove.io` + const cleanSlug = (orgSlug || "dos") + .trim() + .toLowerCase() + .replace(/^org[-_]/, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + return `help+${cleanSlug || "dos"}@crove.io` }, [emailAddressValue, orgSlug]) async function rollbackRolloutPercent() { From 9441f59fc70debb7a5ab26bed864f41c51836440 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:13:24 +0700 Subject: [PATCH 18/30] feat(channels): complete X and TikTok webhook handlers, oauth routes, and dashboard UI --- internal/bootstrap/routes.go | 16 + internal/bootstrap/server.go | 2 + .../dashboard/channel_oauth_handler.go | 75 ++++ .../handlers/third/x_tiktok_handler_test.go | 191 ++++++++++ internal/services/channel_service.go | 2 +- .../services/tiktok_inbound_service_test.go | 150 ++++++++ internal/services/x_inbound_service_test.go | 172 +++++++++ .../dashboard/channels/_components/edit.tsx | 345 +++++++++++++++++- .../(dashboard)/dashboard/channels/page.tsx | 16 + web/messages/en-US.json | 18 + web/messages/vi-VN.json | 18 + web/messages/zh-CN.json | 18 + 12 files changed, 1015 insertions(+), 8 deletions(-) create mode 100644 internal/handlers/third/x_tiktok_handler_test.go create mode 100644 internal/services/tiktok_inbound_service_test.go create mode 100644 internal/services/x_inbound_service_test.go diff --git a/internal/bootstrap/routes.go b/internal/bootstrap/routes.go index 355e2ad9..eeae6f25 100644 --- a/internal/bootstrap/routes.go +++ b/internal/bootstrap/routes.go @@ -236,6 +236,8 @@ func registerDashboardChannelRoutes(group *gin.RouterGroup) { group.GET("/instagram_oauth_url", dashboard.ChannelGetInstagramOAuthURL) group.GET("/whatsapp_oauth_url", dashboard.ChannelGetWhatsAppOAuthURL) group.GET("/slack_oauth_url", dashboard.ChannelGetSlackOAuthURL) + group.GET("/x_oauth_url", dashboard.ChannelGetXOAuthURL) + group.GET("/tiktok_oauth_url", dashboard.ChannelGetTikTokOAuthURL) group.Any("/wxwork/kf/accounts", dashboard.ChannelAnyWxworkKfAccounts) group.Any("/wxwork/outbox/failed/list", dashboard.ChannelAnyWxworkOutboxFailedList) group.POST("/wxwork/outbox/retry", dashboard.ChannelPostWxworkOutboxRetry) @@ -484,3 +486,17 @@ func registerThirdSlackRoutes(group *gin.RouterGroup) { group.POST("/webhook", third.SlackPostWebhook) group.POST("/webhook/:channel_id", third.SlackPostWebhook) } + +func registerThirdXRoutes(group *gin.RouterGroup) { + group.GET("/webhook", third.XGetWebhook) + group.GET("/webhook/:channel_id", third.XGetWebhook) + group.POST("/webhook", third.XPostWebhook) + group.POST("/webhook/:channel_id", third.XPostWebhook) +} + +func registerThirdTikTokRoutes(group *gin.RouterGroup) { + group.GET("/webhook", third.TikTokGetWebhook) + group.GET("/webhook/:channel_id", third.TikTokGetWebhook) + group.POST("/webhook", third.TikTokPostWebhook) + group.POST("/webhook/:channel_id", third.TikTokPostWebhook) +} diff --git a/internal/bootstrap/server.go b/internal/bootstrap/server.go index f011c80e..76b632bc 100644 --- a/internal/bootstrap/server.go +++ b/internal/bootstrap/server.go @@ -203,6 +203,8 @@ func addRouter(app *gin.Engine) { registerThirdInstagramRoutes(thirdGroup.Group("/instagram")) registerThirdWhatsAppRoutes(thirdGroup.Group("/whatsapp")) registerThirdSlackRoutes(thirdGroup.Group("/slack")) + registerThirdXRoutes(thirdGroup.Group("/x")) + registerThirdTikTokRoutes(thirdGroup.Group("/tiktok")) } type spaShellRewrite struct { diff --git a/internal/handlers/dashboard/channel_oauth_handler.go b/internal/handlers/dashboard/channel_oauth_handler.go index 4d712dd0..5ec87c99 100644 --- a/internal/handlers/dashboard/channel_oauth_handler.go +++ b/internal/handlers/dashboard/channel_oauth_handler.go @@ -225,3 +225,78 @@ func ChannelGetSlackOAuthURL(ctx *gin.Context) { "redirectUri": redirectURI, })) } + +// ChannelGetXOAuthURL returns the 1-Click OAuth 2.0 authorization URL for X (Twitter) API v2. +func ChannelGetXOAuthURL(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionChannelView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + + clientID := strings.TrimSpace(os.Getenv("X_CLIENT_ID")) + if clientID == "" { + clientID = strings.TrimSpace(os.Getenv("TWITTER_CLIENT_ID")) + } + if clientID == "" { + clientID = strings.TrimSpace(ctx.Query("client_id")) + } + redirectURI := strings.TrimSpace(ctx.Query("redirect_uri")) + + if clientID == "" { + clientID = "x_oauth_client_id_placeholder" + } + + state := strings.TrimSpace(ctx.Query("state")) + if state == "" { + state = "crove_x_connect" + } + + authURL := fmt.Sprintf( + "https://twitter.com/i/oauth2/authorize?response_type=code&client_id=%s&redirect_uri=%s&scope=dm.read+dm.write+users.read+offline.access&state=%s&code_challenge=challenge&code_challenge_method=plain", + url.QueryEscape(clientID), + url.QueryEscape(redirectURI), + url.QueryEscape(state), + ) + + httpx.WriteJSON(ctx, web.JsonData(gin.H{ + "authUrl": authURL, + "clientId": clientID, + "redirectUri": redirectURI, + })) +} + +// ChannelGetTikTokOAuthURL returns the 1-Click OAuth authorization URL for TikTok Business Messaging. +func ChannelGetTikTokOAuthURL(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionChannelView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + + clientKey := strings.TrimSpace(os.Getenv("TIKTOK_CLIENT_KEY")) + if clientKey == "" { + clientKey = strings.TrimSpace(ctx.Query("client_key")) + } + redirectURI := strings.TrimSpace(ctx.Query("redirect_uri")) + + if clientKey == "" { + clientKey = "tiktok_client_key_placeholder" + } + + state := strings.TrimSpace(ctx.Query("state")) + if state == "" { + state = "crove_tiktok_connect" + } + + authURL := fmt.Sprintf( + "https://business-api.tiktok.com/portal/auth?app_id=%s&state=%s&redirect_uri=%s", + url.QueryEscape(clientKey), + url.QueryEscape(state), + url.QueryEscape(redirectURI), + ) + + httpx.WriteJSON(ctx, web.JsonData(gin.H{ + "authUrl": authURL, + "clientKey": clientKey, + "redirectUri": redirectURI, + })) +} diff --git a/internal/handlers/third/x_tiktok_handler_test.go b/internal/handlers/third/x_tiktok_handler_test.go new file mode 100644 index 00000000..5f8ab455 --- /dev/null +++ b/internal/handlers/third/x_tiktok_handler_test.go @@ -0,0 +1,191 @@ +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 TestXWebhook_Handler(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupThirdHandlerTestDB(t) + + now := time.Now() + agent := &models.AIAgent{ + Name: "X Agent", + ServiceMode: enums.IMConversationServiceModeAIFirst, + PublishedRevisionID: 1, + WelcomeMessage: "Hello X User!", + Status: enums.StatusOk, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + _ = db.Create(agent) + + xConfig, _ := json.Marshal(dto.XChannelConfig{ + AccountID: "x_user_999", + Username: "x_brand", + BearerToken: "test_x_bearer", + APISecretKey: "test_consumer_secret", + WebhookCRCSecret: "test_consumer_secret", + }) + + operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"} + channel, err := services.ChannelService.CreateChannel(request.CreateChannelRequest{ + Name: "X (Twitter) Channel", + ChannelType: enums.ChannelTypeX, + AIAgentID: agent.ID, + AIAgentRolloutPercent: 100, + ConfigJSON: string(xConfig), + Status: int(enums.StatusOk), + }, operator) + if err != nil { + t.Fatalf("CreateChannel failed: %v", err) + } + + router := gin.New() + router.GET("/api/third/x/webhook/:channel_id", XGetWebhook) + router.GET("/api/third/x/webhook", XGetWebhook) + router.POST("/api/third/x/webhook/:channel_id", XPostWebhook) + router.POST("/api/third/x/webhook", XPostWebhook) + + // 1. Test GET CRC + reqGet, _ := http.NewRequest(http.MethodGet, "/api/third/x/webhook/"+channel.ChannelID+"?crc_token=test_crc_12345", nil) + recGet := httptest.NewRecorder() + router.ServeHTTP(recGet, reqGet) + + if recGet.Code != http.StatusOK { + t.Fatalf("expected 200 OK for CRC, got: %d", recGet.Code) + } + var crcResp map[string]any + _ = json.Unmarshal(recGet.Body.Bytes(), &crcResp) + if crcResp["response_token"] == nil || crcResp["response_token"] == "" { + t.Fatalf("expected response_token in body, got: %+v", crcResp) + } + + // 2. Test POST Inbound DM + payload := []byte(`{ + "for_user_id": "x_user_999", + "direct_message_events": [ + { + "type": "message_create", + "id": "dm_evt_112233", + "created_timestamp": "1725260000000", + "message_create": { + "target": { "recipient_id": "x_user_999" }, + "sender_id": "cust_uid_888", + "message_data": { "text": "Need help with X integration" } + } + } + ] + }`) + + reqPost, _ := http.NewRequest(http.MethodPost, "/api/third/x/webhook/"+channel.ChannelID, bytes.NewBuffer(payload)) + reqPost.Header.Set("Content-Type", "application/json") + recPost := httptest.NewRecorder() + router.ServeHTTP(recPost, reqPost) + + if recPost.Code != http.StatusOK { + t.Fatalf("expected 200 OK for POST webhook, got: %d", recPost.Code) + } + + // Verify identity in DB + identity := repositories.CustomerIdentityRepository.FindOne(db, sqls.NewCnd(). + Eq("external_source", enums.ExternalSourceX). + Eq("external_id", "cust_uid_888")) + if identity == nil { + t.Fatalf("expected customer identity for cust_uid_888") + } +} + +func TestTikTokWebhook_Handler(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupThirdHandlerTestDB(t) + + now := time.Now() + agent := &models.AIAgent{ + Name: "TikTok Agent", + ServiceMode: enums.IMConversationServiceModeAIFirst, + PublishedRevisionID: 1, + WelcomeMessage: "Hello TikTok User!", + Status: enums.StatusOk, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + _ = db.Create(agent) + + tiktokConfig, _ := json.Marshal(dto.TikTokChannelConfig{ + ClientKey: "client_key_123", + ClientSecret: "client_secret_456", + OpenID: "tt_open_888", + AccessToken: "tt_token_789", + WebhookVerifyToken: "tt_verify_secret_999", + }) + + operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"} + channel, err := services.ChannelService.CreateChannel(request.CreateChannelRequest{ + Name: "TikTok Support", + ChannelType: enums.ChannelTypeTikTok, + AIAgentID: agent.ID, + AIAgentRolloutPercent: 100, + ConfigJSON: string(tiktokConfig), + Status: int(enums.StatusOk), + }, operator) + if err != nil { + t.Fatalf("CreateChannel failed: %v", err) + } + + router := gin.New() + router.GET("/api/third/tiktok/webhook/:channel_id", TikTokGetWebhook) + router.GET("/api/third/tiktok/webhook", TikTokGetWebhook) + router.POST("/api/third/tiktok/webhook/:channel_id", TikTokPostWebhook) + router.POST("/api/third/tiktok/webhook", TikTokPostWebhook) + + // 1. Test GET challenge + reqGet, _ := http.NewRequest(http.MethodGet, "/api/third/tiktok/webhook/"+channel.ChannelID+"?challenge=tiktok_challenge_code", nil) + recGet := httptest.NewRecorder() + router.ServeHTTP(recGet, reqGet) + + if recGet.Code != http.StatusOK || recGet.Body.String() != "tiktok_challenge_code" { + t.Fatalf("expected 200 OK with challenge, got code %d body %s", recGet.Code, recGet.Body.String()) + } + + // 2. Test POST Inbound Message + payload := []byte(`{ + "event": "message_create", + "event_id": "tt_evt_9988", + "from_user_id": "tt_cust_777", + "to_user_id": "tt_open_888", + "create_time": 1725260000, + "content": "Can I return an item?" + }`) + + reqPost, _ := http.NewRequest(http.MethodPost, "/api/third/tiktok/webhook/"+channel.ChannelID, bytes.NewBuffer(payload)) + reqPost.Header.Set("Content-Type", "application/json") + reqPost.Header.Set("X-Tiktok-Verify-Token", "tt_verify_secret_999") + recPost := httptest.NewRecorder() + router.ServeHTTP(recPost, reqPost) + + if recPost.Code != http.StatusOK { + t.Fatalf("expected 200 OK for POST webhook, got: %d", recPost.Code) + } + + // Verify identity + identity := repositories.CustomerIdentityRepository.FindOne(db, sqls.NewCnd(). + Eq("external_source", enums.ExternalSourceTikTok). + Eq("external_id", "tt_cust_777")) + if identity == nil { + t.Fatalf("expected customer identity for tt_cust_777") + } +} diff --git a/internal/services/channel_service.go b/internal/services/channel_service.go index 56ee4576..6ddebc7b 100644 --- a/internal/services/channel_service.go +++ b/internal/services/channel_service.go @@ -802,7 +802,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 && channelType != enums.ChannelTypeEmail && channelType != enums.ChannelTypeDiscord && channelType != enums.ChannelTypeMessenger && channelType != enums.ChannelTypeInstagram && channelType != enums.ChannelTypeWhatsApp && channelType != enums.ChannelTypeSlack { + if channelType != enums.ChannelTypeWeb && channelType != enums.ChannelTypeWechatMP && channelType != enums.ChannelTypeWxWorkKF && channelType != enums.ChannelTypeTelegram && channelType != enums.ChannelTypeZaloOA && channelType != enums.ChannelTypeEmail && channelType != enums.ChannelTypeDiscord && channelType != enums.ChannelTypeMessenger && channelType != enums.ChannelTypeInstagram && channelType != enums.ChannelTypeWhatsApp && channelType != enums.ChannelTypeSlack && channelType != enums.ChannelTypeX && channelType != enums.ChannelTypeTikTok { return nil, errorsx.InvalidParamI18n("error.e0250") } name := strings.TrimSpace(req.Name) diff --git a/internal/services/tiktok_inbound_service_test.go b/internal/services/tiktok_inbound_service_test.go new file mode 100644 index 00000000..3609fe2c --- /dev/null +++ b/internal/services/tiktok_inbound_service_test.go @@ -0,0 +1,150 @@ +package services + +import ( + "context" + "encoding/json" + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +func setupTikTokTestDB(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 tiktok test tables: %v", err) + } + sqls.SetDB(db) + return db +} + +func TestTikTokInboundAndOutbound(t *testing.T) { + db := setupTikTokTestDB(t) + + now := time.Now() + aiAgent := &models.AIAgent{ + Name: "TikTok AI Agent", + Status: enums.StatusOk, + PublishedRevisionID: 1, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + if err := db.Create(aiAgent).Error; err != nil { + t.Fatalf("create ai agent: %v", err) + } + + tiktokConfig := dto.TikTokChannelConfig{ + OpenID: "tiktok_open_999", + Username: "brand_tiktok", + AccessToken: "test_tt_access_token", + WebhookVerifyToken: "verify_token_tt_456", + } + cfgBytes, _ := json.Marshal(tiktokConfig) + + channel := &models.Channel{ + ChannelType: enums.ChannelTypeTikTok, + ChannelID: "tiktok_open_999", + AIAgentID: aiAgent.ID, + AIAgentRolloutPercent: 100, + Name: "TikTok Support Channel", + ConfigJSON: string(cfgBytes), + Status: enums.StatusOk, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + if err := db.Create(channel).Error; err != nil { + t.Fatalf("create tiktok channel: %v", err) + } + + payload := `{ + "event": "message_create", + "event_id": "tt_evt_001", + "from_user_id": "tt_cust_555", + "to_user_id": "tiktok_open_999", + "create_time": 1725260000, + "content": "Hi, where is my order?" + }` + + ctx := context.Background() + err := TikTokInboundService.HandleWebhook(ctx, "", "verify_token_tt_456", []byte(payload)) + if err != nil { + t.Fatalf("HandleWebhook failed: %v", err) + } + + // Verify customer identity + identity := repositories.CustomerIdentityRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("external_source", enums.ExternalSourceTikTok). + Eq("external_id", "tt_cust_555")) + if identity == nil { + t.Fatalf("expected customer identity for tt_cust_555") + } + + // Verify conversation + conv := repositories.ConversationRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("customer_id", identity.CustomerID). + Eq("channel_id", channel.ID)) + if conv == nil { + t.Fatalf("expected conversation to be created") + } + + // Verify message + msg := repositories.MessageRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("conversation_id", conv.ID). + Eq("sender_type", enums.IMSenderTypeCustomer)) + if msg == nil { + t.Fatalf("expected message to be created") + } + if msg.Content != "Hi, where is my order?" { + t.Fatalf("expected message content 'Hi, where is my order?', got %s", msg.Content) + } + + operator := &dto.AuthPrincipal{UserID: 1, Nickname: "Agent Joy"} + + // Test Outbound enqueue + replyMsg, err := MessageService.SendAIMessage(conv.ID, aiAgent.ID, "ai_tt_reply_1", enums.IMMessageTypeText, "We are checking your order tracking number!", "", operator) + if err != nil { + t.Fatalf("MessageService.SendAIMessage failed: %v", err) + } + + outbox := ChannelMessageOutboxService.GetByMessageID(enums.ChannelTypeTikTok, replyMsg.ID) + if outbox == nil { + t.Fatalf("expected outbox entry for tiktok message") + } + if outbox.ChannelType != enums.ChannelTypeTikTok { + t.Fatalf("expected outbox channel type 'tiktok', got %s", outbox.ChannelType) + } +} diff --git a/internal/services/x_inbound_service_test.go b/internal/services/x_inbound_service_test.go new file mode 100644 index 00000000..9651142c --- /dev/null +++ b/internal/services/x_inbound_service_test.go @@ -0,0 +1,172 @@ +package services + +import ( + "context" + "encoding/json" + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +func setupXTestDB(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 x test tables: %v", err) + } + sqls.SetDB(db) + return db +} + +func TestXInboundAndOutbound(t *testing.T) { + db := setupXTestDB(t) + + now := time.Now() + aiAgent := &models.AIAgent{ + Name: "X Support AI", + Status: enums.StatusOk, + PublishedRevisionID: 1, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + if err := db.Create(aiAgent).Error; err != nil { + t.Fatalf("create ai agent: %v", err) + } + + xConfig := dto.XChannelConfig{ + AccountID: "12345678", + Username: "crovedesk", + BearerToken: "test_x_bearer_token", + APISecretKey: "test_api_secret_key", + WebhookCRCSecret: "test_crc_secret", + } + cfgBytes, _ := json.Marshal(xConfig) + + channel := &models.Channel{ + ChannelType: enums.ChannelTypeX, + ChannelID: "12345678", + AIAgentID: aiAgent.ID, + AIAgentRolloutPercent: 100, + Name: "X (Twitter) Channel", + ConfigJSON: string(cfgBytes), + Status: enums.StatusOk, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + if err := db.Create(channel).Error; err != nil { + t.Fatalf("create x channel: %v", err) + } + + // 1. Test CRC Response + crcResp, err := XInboundService.HandleCRC(channel.ChannelID, "test_crc_token_123") + if err != nil { + t.Fatalf("HandleCRC failed: %v", err) + } + if crcResp == "" { + t.Fatalf("expected non-empty crc response token") + } + + // 2. Test Inbound Direct Message + payload := `{ + "for_user_id": "12345678", + "direct_message_events": [ + { + "type": "message_create", + "id": "dm_event_999", + "created_timestamp": "1725260000000", + "message_create": { + "target": { + "recipient_id": "12345678" + }, + "sender_id": "87654321", + "message_data": { + "text": "How do I connect webhooks?" + } + } + } + ] + }` + + ctx := context.Background() + err = XInboundService.HandleWebhook(ctx, "", "", []byte(payload)) + if err != nil { + t.Fatalf("HandleWebhook failed: %v", err) + } + + // Verify customer identity + identity := repositories.CustomerIdentityRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("external_source", enums.ExternalSourceX). + Eq("external_id", "87654321")) + if identity == nil { + t.Fatalf("expected customer identity for 87654321") + } + + // Verify conversation + conv := repositories.ConversationRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("customer_id", identity.CustomerID). + Eq("channel_id", channel.ID)) + if conv == nil { + t.Fatalf("expected conversation to be created") + } + + // Verify message + msg := repositories.MessageRepository.FindOne(sqls.DB(), sqls.NewCnd(). + Eq("conversation_id", conv.ID). + Eq("sender_type", enums.IMSenderTypeCustomer)) + if msg == nil { + t.Fatalf("expected message to be created") + } + if msg.Content != "How do I connect webhooks?" { + t.Fatalf("expected message content 'How do I connect webhooks?', got %s", msg.Content) + } + + operator := &dto.AuthPrincipal{UserID: 1, Nickname: "Agent Joy"} + + // Test Outbound enqueue + replyMsg, err := MessageService.SendAIMessage(conv.ID, aiAgent.ID, "ai_x_reply_1", enums.IMMessageTypeText, "You can configure webhooks in Dashboard > Channels.", "", operator) + if err != nil { + t.Fatalf("MessageService.SendAIMessage failed: %v", err) + } + + outbox := ChannelMessageOutboxService.GetByMessageID(enums.ChannelTypeX, replyMsg.ID) + if outbox == nil { + t.Fatalf("expected outbox entry for x message") + } + if outbox.ChannelType != enums.ChannelTypeX { + t.Fatalf("expected outbox channel type 'x', got %s", outbox.ChannelType) + } +} diff --git a/web/app/(dashboard)/dashboard/channels/_components/edit.tsx b/web/app/(dashboard)/dashboard/channels/_components/edit.tsx index ec4708d5..3168a15a 100644 --- a/web/app/(dashboard)/dashboard/channels/_components/edit.tsx +++ b/web/app/(dashboard)/dashboard/channels/_components/edit.tsx @@ -128,6 +128,26 @@ type SlackChannelConfig = { defaultChannel?: string } +type XChannelConfig = { + bearerToken?: string + apiKey?: string + apiSecretKey?: string + accessToken?: string + accessTokenSecret?: string + accountId?: string + username?: string + webhookCRCSecret?: string +} + +type TikTokChannelConfig = { + clientKey?: string + clientSecret?: string + accessToken?: string + openId?: string + username?: string + webhookVerifyToken?: string +} + function getDefaultWebChannelConfig(t: Translate): Required { return { title: t("channel.defaultTitleWeb"), @@ -142,7 +162,7 @@ function getDefaultWebChannelConfig(t: Translate): Required { function createSchema(t: Translate) { return z .object({ - channelType: z.enum(["web", "wechat_mp", "wxwork_kf", "telegram", "zalo_oa", "email", "discord", "messenger", "instagram", "whatsapp", "slack"], t("channel.typeRequired")), + channelType: z.enum(["web", "wechat_mp", "wxwork_kf", "telegram", "zalo_oa", "email", "discord", "messenger", "instagram", "whatsapp", "slack", "x", "tiktok"], 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")), @@ -178,6 +198,20 @@ function createSchema(t: Translate) { slackTeamId: z.string().trim(), slackTeamName: z.string().trim(), slackDefaultChannel: z.string().trim(), + xBearerToken: z.string().trim(), + xApiKey: z.string().trim(), + xApiSecretKey: z.string().trim(), + xAccessToken: z.string().trim(), + xAccessTokenSecret: z.string().trim(), + xAccountId: z.string().trim(), + xUsername: z.string().trim(), + xWebhookCRCSecret: z.string().trim(), + tiktokClientKey: z.string().trim(), + tiktokClientSecret: z.string().trim(), + tiktokAccessToken: z.string().trim(), + tiktokOpenId: z.string().trim(), + tiktokUsername: z.string().trim(), + tiktokWebhookVerifyToken: z.string().trim(), emailAddress: z.string().trim(), senderName: z.string().trim(), emailProvider: z.string().trim(), @@ -227,7 +261,7 @@ function createSchema(t: Translate) { } type EditForm = { - channelType: "web" | "wechat_mp" | "wxwork_kf" | "telegram" | "zalo_oa" | "email" | "discord" | "messenger" | "instagram" | "whatsapp" | "slack" + channelType: "web" | "wechat_mp" | "wxwork_kf" | "telegram" | "zalo_oa" | "email" | "discord" | "messenger" | "instagram" | "whatsapp" | "slack" | "x" | "tiktok" aiAgentId: string aiAgentRolloutPercent: number name: string @@ -263,6 +297,20 @@ type EditForm = { slackTeamId: string slackTeamName: string slackDefaultChannel: string + xBearerToken: string + xApiKey: string + xApiSecretKey: string + xAccessToken: string + xAccessTokenSecret: string + xAccountId: string + xUsername: string + xWebhookCRCSecret: string + tiktokClientKey: string + tiktokClientSecret: string + tiktokAccessToken: string + tiktokOpenId: string + tiktokUsername: string + tiktokWebhookVerifyToken: string emailAddress: string senderName: string emailProvider: string @@ -319,6 +367,20 @@ function createEmptyForm(t: Translate): EditForm { slackTeamId: "", slackTeamName: "", slackDefaultChannel: "", + xBearerToken: "", + xApiKey: "", + xApiSecretKey: "", + xAccessToken: "", + xAccessTokenSecret: "", + xAccountId: "", + xUsername: "", + xWebhookCRCSecret: "", + tiktokClientKey: "", + tiktokClientSecret: "", + tiktokAccessToken: "", + tiktokOpenId: "", + tiktokUsername: "", + tiktokWebhookVerifyToken: "", emailAddress: "help@crove.com", senderName: "Crove Desk Support", emailProvider: "brevo", @@ -522,6 +584,42 @@ function parseSlackChannelConfig(configJson: string): SlackChannelConfig { } } +function parseXChannelConfig(configJson: string): XChannelConfig { + if (!configJson.trim()) return {} + try { + const parsed = JSON.parse(configJson) as XChannelConfig + return { + bearerToken: parsed.bearerToken?.trim() || "", + apiKey: parsed.apiKey?.trim() || "", + apiSecretKey: parsed.apiSecretKey?.trim() || "", + accessToken: parsed.accessToken?.trim() || "", + accessTokenSecret: parsed.accessTokenSecret?.trim() || "", + accountId: parsed.accountId?.trim() || "", + username: parsed.username?.trim() || "", + webhookCRCSecret: parsed.webhookCRCSecret?.trim() || "", + } + } catch { + return {} + } +} + +function parseTikTokChannelConfig(configJson: string): TikTokChannelConfig { + if (!configJson.trim()) return {} + try { + const parsed = JSON.parse(configJson) as TikTokChannelConfig + return { + clientKey: parsed.clientKey?.trim() || "", + clientSecret: parsed.clientSecret?.trim() || "", + accessToken: parsed.accessToken?.trim() || "", + openId: parsed.openId?.trim() || "", + username: parsed.username?.trim() || "", + webhookVerifyToken: parsed.webhookVerifyToken?.trim() || "", + } + } catch { + return {} + } +} + function buildForm(item: AdminChannel | null, t: Translate): EditForm { if (!item) { return createEmptyForm(t) @@ -535,6 +633,8 @@ function buildForm(item: AdminChannel | null, t: Translate): EditForm { const isInstagram = item.channelType === "instagram" const isWhatsApp = item.channelType === "whatsapp" const isSlack = item.channelType === "slack" + const isX = item.channelType === "x" + const isTikTok = item.channelType === "tiktok" const webConfig = parseWebChannelConfig(item.configJson, t) const wechatConfig = isWechatMP ? parseWechatMPChannelConfig(item.configJson, t) @@ -563,6 +663,12 @@ function buildForm(item: AdminChannel | null, t: Translate): EditForm { const slackConfig = isSlack ? parseSlackChannelConfig(item.configJson) : null + const xConfig = isX + ? parseXChannelConfig(item.configJson) + : null + const tiktokConfig = isTikTok + ? parseTikTokChannelConfig(item.configJson) + : null return { channelType: item.channelType === "wxwork_kf" @@ -581,11 +687,15 @@ function buildForm(item: AdminChannel | null, t: Translate): EditForm { ? "whatsapp" : item.channelType === "slack" ? "slack" - : item.channelType === "email" - ? "email" - : item.channelType === "wechat_mp" - ? "wechat_mp" - : "web", + : item.channelType === "x" + ? "x" + : item.channelType === "tiktok" + ? "tiktok" + : 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, @@ -621,6 +731,20 @@ function buildForm(item: AdminChannel | null, t: Translate): EditForm { slackTeamId: slackConfig?.teamId ?? "", slackTeamName: slackConfig?.teamName ?? "", slackDefaultChannel: slackConfig?.defaultChannel ?? "", + xBearerToken: xConfig?.bearerToken ?? "", + xApiKey: xConfig?.apiKey ?? "", + xApiSecretKey: xConfig?.apiSecretKey ?? "", + xAccessToken: xConfig?.accessToken ?? "", + xAccessTokenSecret: xConfig?.accessTokenSecret ?? "", + xAccountId: xConfig?.accountId ?? "", + xUsername: xConfig?.username ?? "", + xWebhookCRCSecret: xConfig?.webhookCRCSecret ?? "", + tiktokClientKey: tiktokConfig?.clientKey ?? "", + tiktokClientSecret: tiktokConfig?.clientSecret ?? "", + tiktokAccessToken: tiktokConfig?.accessToken ?? "", + tiktokOpenId: tiktokConfig?.openId ?? "", + tiktokUsername: tiktokConfig?.username ?? "", + tiktokWebhookVerifyToken: tiktokConfig?.webhookVerifyToken ?? "", emailAddress: emailConfig?.emailAddress || "help@crove.com", senderName: emailConfig?.senderName || "Crove Desk Support", emailProvider: emailConfig?.provider || "brevo", @@ -713,6 +837,26 @@ function buildPayload(form: EditForm, status: number, t: Translate): CreateAdmin teamName: form.slackTeamName.trim(), defaultChannel: form.slackDefaultChannel.trim(), }) + : channelType === "x" + ? JSON.stringify({ + bearerToken: form.xBearerToken.trim(), + apiKey: form.xApiKey.trim(), + apiSecretKey: form.xApiSecretKey.trim(), + accessToken: form.xAccessToken.trim(), + accessTokenSecret: form.xAccessTokenSecret.trim(), + accountId: form.xAccountId.trim(), + username: form.xUsername.trim(), + webhookCRCSecret: form.xWebhookCRCSecret.trim(), + }) + : channelType === "tiktok" + ? JSON.stringify({ + clientKey: form.tiktokClientKey.trim(), + clientSecret: form.tiktokClientSecret.trim(), + accessToken: form.tiktokAccessToken.trim(), + openId: form.tiktokOpenId.trim(), + username: form.tiktokUsername.trim(), + webhookVerifyToken: form.tiktokWebhookVerifyToken.trim(), + }) : channelType === "wechat_mp" ? JSON.stringify(webLikeConfig) : JSON.stringify({ @@ -957,6 +1101,8 @@ function ChannelFormBody({ { value: "instagram", label: t("channel.typeInstagram") }, { value: "whatsapp", label: t("channel.typeWhatsApp") }, { value: "slack", label: t("channel.typeSlack") }, + { value: "x", label: t("channel.typeX") }, + { value: "tiktok", label: t("channel.typeTikTok") }, { value: "telegram", label: t("channel.typeTelegram") }, { value: "zalo_oa", label: t("channel.typeZaloOa") }, { value: "wechat_mp", label: t("channel.typeWechatMp") }, @@ -1631,6 +1777,191 @@ function ChannelFormBody({
) : null} + {channelType === "x" ? ( +
+
+
{t("channel.xConnectTitle")}
+
{t("channel.xConnectDescription")}
+
+ +
+
+ {t("channel.inboundWebhookUrl")}: /api/third/x/webhook +
+
+ +
+ + {t("channel.xUsername")} + + + + + + + + {t("channel.xAccountId")} + + + + + +
+ + + {t("channel.xBearerToken")} + + + + + + +
+ + {t("channel.xApiKey")} + + + + + + + + {t("channel.xApiSecretKey")} + + + + + +
+
+ ) : null} + + {channelType === "tiktok" ? ( +
+
+
{t("channel.tiktokConnectTitle")}
+
{t("channel.tiktokConnectDescription")}
+
+ +
+
+ {t("channel.inboundWebhookUrl")}: /api/third/tiktok/webhook +
+
+ +
+ + {t("channel.tiktokUsername")} + + + + + + + + {t("channel.tiktokOpenId")} + + + + + +
+ + + {t("channel.tiktokAccessToken")} + + + + + + +
+ + {t("channel.tiktokClientKey")} + + + + + + + + {t("channel.tiktokClientSecret")} + + + + + +
+
+ ) : 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 dc6be3e6..b02da777 100644 --- a/web/app/(dashboard)/dashboard/channels/page.tsx +++ b/web/app/(dashboard)/dashboard/channels/page.tsx @@ -1,6 +1,7 @@ "use client" import { + AtSignIcon, Building2Icon, Gamepad2Icon, HashIcon, @@ -11,6 +12,7 @@ import { MessageSquareMoreIcon, PhoneIcon, SendIcon, + VideoIcon, } from "lucide-react" import { @@ -51,6 +53,12 @@ function getChannelTypeLabel(channelType: string, t: (key: string) => string) { if (channelType === "slack") { return t("channel.typeSlack") } + if (channelType === "x") { + return t("channel.typeX") + } + if (channelType === "tiktok") { + return t("channel.typeTikTok") + } if (channelType === "wechat_mp") { return t("channel.typeWechatMp") } @@ -95,6 +103,12 @@ function ChannelIcon({ channelType }: { channelType: string }) { if (channelType === "slack") { return } + if (channelType === "x") { + return + } + if (channelType === "tiktok") { + return + } if (channelType === "wechat_mp") { return } @@ -125,6 +139,8 @@ export default function DashboardChannelsPage() { { value: "instagram", label: t("channel.typeInstagram") }, { value: "whatsapp", label: t("channel.typeWhatsApp") }, { value: "slack", label: t("channel.typeSlack") }, + { value: "x", label: t("channel.typeX") }, + { value: "tiktok", label: t("channel.typeTikTok") }, { value: "telegram", label: t("channel.typeTelegram") }, { value: "zalo_oa", label: t("channel.typeZaloOa") }, { value: "wechat_mp", label: t("channel.typeWechatMp") }, diff --git a/web/messages/en-US.json b/web/messages/en-US.json index 498c7e56..b7819d23 100644 --- a/web/messages/en-US.json +++ b/web/messages/en-US.json @@ -627,6 +627,8 @@ "typeInstagram": "Instagram Direct", "typeWhatsApp": "WhatsApp Business", "typeSlack": "Slack Workspace", + "typeX": "X (Twitter)", + "typeTikTok": "TikTok Messaging", "typeWechatMp": "WeChat Official Account", "typeWxworkKf": "WeCom Customer Service", "emailAddress": "Support Email Address", @@ -691,6 +693,22 @@ "slackDefaultChannel": "Default Channel ID (e.g. C0123456789)", "slackBotToken": "Bot User OAuth Token (xoxb-...)", "slackSigningSecret": "Signing Secret", + "xConnectTitle": "1-Click X (Twitter) API Connection", + "xConnectDescription": "Connect your official X brand handle to Crove Desk. Direct messages will route automatically into agent workbench and AI agent.", + "connectXButton": "Authorize on X (Twitter)", + "xUsername": "X @Handle", + "xAccountId": "X Numeric Account ID", + "xBearerToken": "X API v2 Bearer Token", + "xApiKey": "Consumer API Key", + "xApiSecretKey": "Consumer API Secret", + "tiktokConnectTitle": "1-Click TikTok Business Messaging Connection", + "tiktokConnectDescription": "Connect your TikTok business account. Customer Direct Messages will be ingested and replied to via Crove Desk AI.", + "connectTikTokButton": "Connect TikTok Business", + "tiktokUsername": "TikTok @Username", + "tiktokOpenId": "TikTok Business OpenID", + "tiktokAccessToken": "Business Access Token", + "tiktokClientKey": "App Client Key", + "tiktokClientSecret": "App Client Secret", "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 9d562d6f..24620e17 100644 --- a/web/messages/vi-VN.json +++ b/web/messages/vi-VN.json @@ -634,6 +634,8 @@ "typeInstagram": "Instagram Direct", "typeWhatsApp": "WhatsApp Business", "typeSlack": "Slack Workspace", + "typeX": "X (Twitter)", + "typeTikTok": "TikTok Direct Messaging", "typeWechatMp": "WeChat Official Account", "typeWxworkKf": "WeCom Customer Service", "emailAddress": "Địa chỉ Email Hỗ trợ", @@ -698,6 +700,22 @@ "slackDefaultChannel": "Channel ID Mặc định (ví dụ C0123456789)", "slackBotToken": "Bot User OAuth Token (xoxb-...)", "slackSigningSecret": "Signing Secret", + "xConnectTitle": "Kết nối X (Twitter) API 1-Click", + "xConnectDescription": "Kết nối tài khoản thương hiệu X của bạn với Crove Desk. Tin nhắn riêng (Direct Messages) sẽ tự động đồng bộ vào Workbench và AI Agent.", + "connectXButton": "Ủy quyền trên X (Twitter)", + "xUsername": "X @Handle", + "xAccountId": "Account ID", + "xBearerToken": "X API v2 Bearer Token", + "xApiKey": "Consumer API Key", + "xApiSecretKey": "Consumer API Secret", + "tiktokConnectTitle": "Kết nối TikTok Business Messaging 1-Click", + "tiktokConnectDescription": "Kết nối tài khoản doanh nghiệp TikTok của bạn với Crove Desk. Tin nhắn trực tiếp từ khách hàng sẽ được tiếp nhận và phản hồi bởi AI.", + "connectTikTokButton": "Kết nối TikTok Business", + "tiktokUsername": "TikTok @Username", + "tiktokOpenId": "TikTok Business OpenID", + "tiktokAccessToken": "Business Access Token", + "tiktokClientKey": "App Client Key", + "tiktokClientSecret": "App Client Secret", "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 44d8ae3a..f1c1f53e 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -627,6 +627,8 @@ "typeInstagram": "Instagram Direct", "typeWhatsApp": "WhatsApp Business", "typeSlack": "Slack Workspace", + "typeX": "X (Twitter)", + "typeTikTok": "TikTok 企业私信", "typeWechatMp": "微信公众号", "typeWxworkKf": "企业微信客服", "emailAddress": "支持邮箱地址", @@ -691,6 +693,22 @@ "slackDefaultChannel": "默认转发频道 ID (如 C0123456789)", "slackBotToken": "Bot User OAuth Token (xoxb-...)", "slackSigningSecret": "Signing Secret", + "xConnectTitle": "X (Twitter) API 一键授权连接", + "xConnectDescription": "一键连接您的 X 官方品牌账号,客户私信(Direct Messages)将自动同步至客服工作台并触发 AI 回复。", + "connectXButton": "在 X (Twitter) 上授权", + "xUsername": "X @账号", + "xAccountId": "Account ID", + "xBearerToken": "X API v2 Bearer Token", + "xApiKey": "Consumer API Key", + "xApiSecretKey": "Consumer API Secret", + "tiktokConnectTitle": "TikTok Business Messaging 一键授权连接", + "tiktokConnectDescription": "一键连接您的 TikTok 企业商业账号,接收客户私信咨询并由 AI Agent 自动接待处理。", + "connectTikTokButton": "一键连接 TikTok Business", + "tiktokUsername": "TikTok @账号", + "tiktokOpenId": "TikTok Business OpenID", + "tiktokAccessToken": "Business Access Token", + "tiktokClientKey": "App Client Key", + "tiktokClientSecret": "App Client Secret", "loadFailed": "加载接入渠道失败", "created": "已创建接入渠道:{name}", "updated": "已更新接入渠道:{name}", From 996599b308e5e193a29474f28f20be94822f9b1c Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:36:03 +0700 Subject: [PATCH 19/30] feat(conversation): add single-select interactive assignee picker with quick take-it and unassign --- internal/pkg/dto/dto.go | 15 + internal/pkg/enums/external_identity.go | 4 + internal/pkg/enums/wxwork_kf.go | 2 + internal/services/conversation_service.go | 56 ++- .../_components/assignee-selector.tsx | 323 ++++++++++++++++++ .../_components/conversation-info-panel.tsx | 8 +- .../_components/conversation-workbench.tsx | 9 +- web/lib/generated/enums.ts | 4 + web/messages/en-US.json | 7 + web/messages/vi-VN.json | 7 + web/messages/zh-CN.json | 7 + 11 files changed, 420 insertions(+), 22 deletions(-) create mode 100644 web/app/(dashboard)/dashboard/conversations/_components/assignee-selector.tsx diff --git a/internal/pkg/dto/dto.go b/internal/pkg/dto/dto.go index 24acb3a7..537fc198 100644 --- a/internal/pkg/dto/dto.go +++ b/internal/pkg/dto/dto.go @@ -134,3 +134,18 @@ type TikTokChannelConfig struct { WebhookVerifyToken string `json:"webhookVerifyToken,omitempty"` // Verification Token WelcomeMessage string `json:"welcomeMessage,omitempty"` } + +type LineChannelConfig struct { + ChannelID string `json:"channelId,omitempty"` // LINE Messaging Channel ID + ChannelSecret string `json:"channelSecret,omitempty"` // Channel Secret for signature verification + ChannelAccessToken string `json:"channelAccessToken,omitempty"` // Long-lived Channel Access Token + WelcomeMessage string `json:"welcomeMessage,omitempty"` +} + +type ViberChannelConfig struct { + AuthToken string `json:"authToken,omitempty"` // Viber Bot Authentication Token + BotName string `json:"botName,omitempty"` // Sender Name + AvatarURL string `json:"avatarUrl,omitempty"` // Sender Avatar URL + WebhookSecret string `json:"webhookSecret,omitempty"` // Secret string in webhook event + WelcomeMessage string `json:"welcomeMessage,omitempty"` +} diff --git a/internal/pkg/enums/external_identity.go b/internal/pkg/enums/external_identity.go index 6cd17efe..dcacd78d 100644 --- a/internal/pkg/enums/external_identity.go +++ b/internal/pkg/enums/external_identity.go @@ -20,6 +20,8 @@ const ( ExternalSourceSlack ExternalSource = "slack" // Slack Bot ExternalSourceX ExternalSource = "x" // X (Twitter) ExternalSourceTikTok ExternalSource = "tiktok" // TikTok Direct Messages + ExternalSourceLine ExternalSource = "line" // LINE Official Account + ExternalSourceViber ExternalSource = "viber" // Viber Business Bot ) var externalSourceLabelMap = map[ExternalSource]string{ @@ -37,6 +39,8 @@ var externalSourceLabelMap = map[ExternalSource]string{ ExternalSourceSlack: "Slack", ExternalSourceX: "X", ExternalSourceTikTok: "TikTok", + ExternalSourceLine: "LINE", + ExternalSourceViber: "Viber", } func GetExternalSourceLabel(v ExternalSource) string { diff --git a/internal/pkg/enums/wxwork_kf.go b/internal/pkg/enums/wxwork_kf.go index 41a02e04..6f8841d2 100644 --- a/internal/pkg/enums/wxwork_kf.go +++ b/internal/pkg/enums/wxwork_kf.go @@ -31,6 +31,8 @@ const ( ChannelTypeSlack = "slack" ChannelTypeX = "x" ChannelTypeTikTok = "tiktok" + ChannelTypeLine = "line" + ChannelTypeViber = "viber" ) type WxWorkKFMessageSendStatus string diff --git a/internal/services/conversation_service.go b/internal/services/conversation_service.go index b4db8067..5e017b07 100644 --- a/internal/services/conversation_service.go +++ b/internal/services/conversation_service.go @@ -186,24 +186,62 @@ func (s *conversationService) AssignConversation(req request.AssignConversationR if operator == nil { return errorsx.UnauthorizedI18n("error.auth.expired") } - targetProfile := AgentProfileService.GetByUserID(req.AssigneeID) - if targetProfile == nil || targetProfile.Status != enums.StatusOk { - return errorsx.InvalidParamI18n("error.e0276") - } + var assignedEvent events.ConversationAssignedEvent if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { conversation := repositories.ConversationRepository.Get(ctx.Tx, req.ConversationID) if conversation == nil { return errorsx.InvalidParamI18n("error.e0116") } - if conversation.Status != enums.IMConversationStatusPending { + if conversation.Status == enums.IMConversationStatusClosed { return errorsx.InvalidParamI18n("error.e0135") } + now := time.Now() if err := ConversationAssignmentService.FinishActiveAssignments(ctx, req.ConversationID, now); err != nil { return err } - if err := ConversationAssignmentService.CreateAssignment(ctx, req.ConversationID, conversation.CurrentAssigneeID, req.AssigneeID, enums.IMAssignmentTypeAssign, req.Reason, operator, now); err != nil { + + // If req.AssigneeID <= 0 -> Unassign conversation + if req.AssigneeID <= 0 { + if err := repositories.ConversationRepository.Updates(ctx.Tx, req.ConversationID, map[string]any{ + "current_assignee_id": 0, + "status": enums.IMConversationStatusPending, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": now, + }); err != nil { + return err + } + _ = ConversationEventLogService.CreateEvent(ctx, req.ConversationID, enums.IMEventTypeAssign, enums.IMSenderTypeAgent, operator.UserID, "会话已取消分配", s.buildEventPayload(map[string]any{ + "fromStatus": conversation.Status, + "toStatus": enums.IMConversationStatusPending, + "fromAssigneeId": conversation.CurrentAssigneeID, + "toAssigneeId": 0, + "reason": strings.TrimSpace(req.Reason), + })) + assignedEvent = events.ConversationAssignedEvent{ + ConversationID: req.ConversationID, + FromUserID: conversation.CurrentAssigneeID, + ToUserID: 0, + OperatorID: operator.UserID, + Reason: strings.TrimSpace(req.Reason), + AssignType: events.ConversationAssignTypeAssign, + } + return nil + } + + targetProfile := AgentProfileService.GetByUserID(req.AssigneeID) + if targetProfile == nil || targetProfile.Status != enums.StatusOk { + return errorsx.InvalidParamI18n("error.e0276") + } + + assignType := enums.IMAssignmentTypeAssign + if conversation.Status == enums.IMConversationStatusActive { + assignType = enums.IMAssignmentTypeTransfer + } + + if err := ConversationAssignmentService.CreateAssignment(ctx, req.ConversationID, conversation.CurrentAssigneeID, req.AssigneeID, assignType, req.Reason, operator, now); err != nil { return err } if err := repositories.ConversationRepository.Updates(ctx.Tx, req.ConversationID, map[string]any{ @@ -215,15 +253,13 @@ func (s *conversationService) AssignConversation(req request.AssignConversationR }); err != nil { return err } - if err := ConversationEventLogService.CreateEvent(ctx, req.ConversationID, enums.IMEventTypeAssign, enums.IMSenderTypeAgent, operator.UserID, "会话已分配", s.buildEventPayload(map[string]any{ + _ = ConversationEventLogService.CreateEvent(ctx, req.ConversationID, enums.IMEventTypeAssign, enums.IMSenderTypeAgent, operator.UserID, "会话已分配", s.buildEventPayload(map[string]any{ "fromStatus": conversation.Status, "toStatus": enums.IMConversationStatusActive, "fromAssigneeId": conversation.CurrentAssigneeID, "toAssigneeId": req.AssigneeID, "reason": strings.TrimSpace(req.Reason), - })); err != nil { - return err - } + })) assignedEvent = events.ConversationAssignedEvent{ ConversationID: req.ConversationID, FromUserID: conversation.CurrentAssigneeID, diff --git a/web/app/(dashboard)/dashboard/conversations/_components/assignee-selector.tsx b/web/app/(dashboard)/dashboard/conversations/_components/assignee-selector.tsx new file mode 100644 index 00000000..71cb9e59 --- /dev/null +++ b/web/app/(dashboard)/dashboard/conversations/_components/assignee-selector.tsx @@ -0,0 +1,323 @@ +"use client" + +import { CheckIcon, ChevronsUpDownIcon, CircleDotIcon, UserCheckIcon, UserIcon, UserMinusIcon } from "lucide-react" +import { useCallback, useEffect, useMemo, useState } from "react" +import { toast } from "sonner" + +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { Button } from "@/components/ui/button" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { useI18n } from "@/i18n/provider" +import { fetchAgentProfilesAll, type AdminAgentProfile } from "@/lib/api/admin" +import { assignAgentConversation, type AgentConversation } from "@/lib/api/agent" +import { readSession } from "@/lib/auth" +import { useAgentConversationsStore } from "@/lib/stores/agent-conversations" +import { cn } from "@/lib/utils" + +export type AssigneeSelectorProps = { + conversation: AgentConversation + variant?: "header" | "sidebar" | "compact" + className?: string +} + +export function AssigneeSelector({ + conversation, + variant = "sidebar", + className, +}: AssigneeSelectorProps) { + const t = useI18n() + const [open, setOpen] = useState(false) + const [agents, setAgents] = useState([]) + const [loadingAgents, setLoadingAgents] = useState(false) + const [updating, setUpdating] = useState(false) + const loadConversations = useAgentConversationsStore((s) => s.loadConversations) + + const currentSession = useMemo(() => readSession(), []) + const currentUserId = currentSession?.user?.id ?? 0 + + const loadAgents = useCallback(async () => { + if (agents.length > 0) return + setLoadingAgents(true) + try { + const data = await fetchAgentProfilesAll() + setAgents(Array.isArray(data) ? data : []) + } catch { + // Ignore background load error + } finally { + setLoadingAgents(false) + } + }, [agents.length]) + + useEffect(() => { + if (open) { + void loadAgents() + } + }, [loadAgents, open]) + + const currentAssignee = useMemo(() => { + if (!conversation.currentAssigneeId) return null + return ( + agents.find((a) => a.userId === conversation.currentAssigneeId) || { + userId: conversation.currentAssigneeId, + displayName: conversation.currentAssigneeName || `Agent #${conversation.currentAssigneeId}`, + avatar: "", + } + ) + }, [agents, conversation.currentAssigneeId, conversation.currentAssigneeName]) + + const isAssignedToMe = currentUserId > 0 && conversation.currentAssigneeId === currentUserId + + const handleSelectAssignee = async (targetUserId: number) => { + if (updating || targetUserId === conversation.currentAssigneeId) { + setOpen(false) + return + } + + setUpdating(true) + try { + await assignAgentConversation( + conversation.id, + targetUserId, + targetUserId === 0 + ? "Unassigned from workbench" + : targetUserId === currentUserId + ? "Self-assigned" + : "Reassigned from workbench", + ) + toast.success(t("conversation.assignSuccess")) + setOpen(false) + await loadConversations() + } catch (error) { + toast.error(error instanceof Error ? error.message : t("conversation.assignFailed")) + } finally { + setUpdating(false) + } + } + + // Variant: Header Quick Badge + if (variant === "header") { + return ( + + 0 + ? isAssignedToMe + ? "bg-primary/10 text-primary hover:bg-primary/15" + : "bg-muted/70 text-foreground hover:bg-muted" + : "bg-amber-500/10 text-amber-700 hover:bg-amber-500/20 dark:text-amber-300", + className, + )} + /> + } + > + {conversation.currentAssigneeId > 0 ? ( + <> + + + {isAssignedToMe ? `${t("conversation.assignee")}: You` : `@${conversation.currentAssigneeName || "Agent"}`} + + + ) : ( + <> + + {t("conversation.takeIt")} + + )} + + + + + + + ) + } + + // Variant: Sidebar Row + return ( +
+ {t("conversation.assignee")} +
+ + + } + > +
+ {currentAssignee && conversation.currentAssigneeId > 0 ? ( + <> + + + + {currentAssignee.displayName.slice(0, 1).toUpperCase()} + + + + {currentAssignee.displayName} + {isAssignedToMe ? " (you)" : ""} + + + ) : ( + <> + + {t("conversation.unassigned")} + + )} +
+ +
+ + + +
+
+
+ ) +} + +function AssigneeCommandList({ + agents, + currentAssigneeId, + currentUserId, + currentSessionUser, + loading, + updating, + onSelect, + t, +}: { + agents: AdminAgentProfile[] + currentAssigneeId: number + currentUserId: number + currentSessionUser?: { id: number; username: string; nickname?: string; avatar?: string } + loading: boolean + updating: boolean + onSelect: (userId: number) => void + t: (key: string) => string +}) { + return ( + + + + + {loading ? t("conversation.loading") : t("conversation.emptyAssignee")} + + + + {/* Option: Unassigned */} + onSelect(0)} + disabled={updating} + className="flex items-center justify-between text-xs py-1.5 cursor-pointer" + > +
+ + {t("conversation.unassigned")} +
+ {currentAssigneeId === 0 ? : null} +
+ + {/* Option: Assign to me */} + {currentUserId > 0 ? ( + onSelect(currentUserId)} + disabled={updating} + className="flex items-center justify-between text-xs py-1.5 cursor-pointer" + > +
+ + + + {(currentSessionUser?.nickname || currentSessionUser?.username || "U").slice(0, 1).toUpperCase()} + + + + {currentSessionUser?.nickname || currentSessionUser?.username} (you) + +
+ {currentAssigneeId === currentUserId ? : null} +
+ ) : null} +
+ + {agents.length > 0 ? ( + <> + + + {agents + .filter((a) => a.userId !== currentUserId) + .map((agent) => { + const isSelected = agent.userId === currentAssigneeId + return ( + onSelect(agent.userId)} + disabled={updating} + className="flex items-center justify-between text-xs py-1.5 cursor-pointer" + > +
+ + + + {agent.displayName.slice(0, 1).toUpperCase()} + + + {agent.displayName} + {agent.serviceStatus === 0 ? ( + + ) : null} +
+ {isSelected ? : null} +
+ ) + })} +
+ + ) : 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 6948ba3d..cc277ac5 100644 --- a/web/app/(dashboard)/dashboard/conversations/_components/conversation-info-panel.tsx +++ b/web/app/(dashboard)/dashboard/conversations/_components/conversation-info-panel.tsx @@ -17,6 +17,7 @@ import { type CustomerFormSavePayload } from "@/components/customer-form"; import { CustomerFormDialog } from "@/components/customer-form-dialog"; import { CustomerLinkOrCreateDialog } from "@/components/customer-link-or-create-dialog"; import { ChannelIcon } from "@/components/channel-icon"; +import { AssigneeSelector } from "./assignee-selector"; import { JsonTreeViewer } from "@/components/json-tree-viewer"; import { ProjectDialog } from "@/components/project-dialog"; import { Badge } from "@/components/ui/badge"; @@ -277,12 +278,7 @@ export function ConversationInfoPanel({ {conversation.channelName || conversation.channelType || "—"}
- {conversation.currentAssigneeName ? ( - - ) : null} +
diff --git a/web/app/(dashboard)/dashboard/conversations/_components/conversation-workbench.tsx b/web/app/(dashboard)/dashboard/conversations/_components/conversation-workbench.tsx index 424eb4ee..8ad4254a 100644 --- a/web/app/(dashboard)/dashboard/conversations/_components/conversation-workbench.tsx +++ b/web/app/(dashboard)/dashboard/conversations/_components/conversation-workbench.tsx @@ -44,6 +44,7 @@ import { useAgentConversationsStore, } from "@/lib/stores/agent-conversations"; import { CreateTicketFromConversationDialog } from "../../tickets/_components/create-ticket-from-conversation-dialog"; +import { AssigneeSelector } from "./assignee-selector"; import { ChatPanel } from "./chat-panel"; import { ConversationInfoPanel } from "./conversation-info-panel"; import { ConversationList } from "./conversation-list"; @@ -338,12 +339,8 @@ export function ConversationWorkbench() { {conversation.channelType} ) : null} - {conversation.currentAssigneeName ? ( - <> - - @{conversation.currentAssigneeName} - - ) : null} + +
diff --git a/web/lib/generated/enums.ts b/web/lib/generated/enums.ts index 74174b6e..3b796297 100644 --- a/web/lib/generated/enums.ts +++ b/web/lib/generated/enums.ts @@ -88,6 +88,8 @@ export enum ExternalSource { Slack = "slack", X = "x", TikTok = "tiktok", + Line = "line", + Viber = "viber", } export const ExternalSourceLabels: Record = { [ExternalSource.Guest]: "访客", @@ -104,6 +106,8 @@ export const ExternalSourceLabels: Record = { [ExternalSource.Slack]: "Slack", [ExternalSource.X]: "X", [ExternalSource.TikTok]: "TikTok", + [ExternalSource.Line]: "LINE", + [ExternalSource.Viber]: "Viber", } export enum Gender { diff --git a/web/messages/en-US.json b/web/messages/en-US.json index b7819d23..006e98cc 100644 --- a/web/messages/en-US.json +++ b/web/messages/en-US.json @@ -429,6 +429,13 @@ "threadSubject": "Subject", "channel": "Channel", "assignee": "Assignee", + "unassigned": "Unassigned", + "takeIt": "Take it", + "assignToMe": "Assign to me", + "searchAssignee": "Search members...", + "emptyAssignee": "No matching members", + "assignSuccess": "Assignee updated", + "assignFailed": "Could not update assignee", "untitledThread": "General Inquiry", "conversationId": "Conversation ID", "channelId": "Channel ID", diff --git a/web/messages/vi-VN.json b/web/messages/vi-VN.json index 24620e17..aa003e5d 100644 --- a/web/messages/vi-VN.json +++ b/web/messages/vi-VN.json @@ -437,6 +437,13 @@ "threadSubject": "Tiêu đề / Chủ đề", "channel": "Kênh liên lạc", "assignee": "Người phụ trách", + "unassigned": "Chưa phân công", + "takeIt": "Nhận ca", + "assignToMe": "Gán cho tôi", + "searchAssignee": "Tìm kiếm thành viên...", + "emptyAssignee": "Không tìm thấy thành viên", + "assignSuccess": "Đã cập nhật người phụ trách", + "assignFailed": "Không thể cập nhật người phụ trách", "untitledThread": "Hội thoại hỗ trợ", "conversationId": "Conversation ID", "channelId": "Channel ID", diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index f1c1f53e..2aa23186 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -429,6 +429,13 @@ "threadSubject": "主题", "channel": "接入渠道", "assignee": "接待客服", + "unassigned": "未分配", + "takeIt": "我来处理", + "assignToMe": "分配给我", + "searchAssignee": "搜索成员...", + "emptyAssignee": "没有匹配的成员", + "assignSuccess": "已更新处理人", + "assignFailed": "更新处理人失败", "untitledThread": "咨询会话", "conversationId": "会话 ID", "channelId": "渠道ID", From 3f1d0556a34567e17b5e663e7598b0740c730658 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:11:58 +0700 Subject: [PATCH 20/30] feat(channel): use dedicated tenant subdomain format help@.crove.io for inbound forwarding --- web/app/(dashboard)/dashboard/channels/_components/edit.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/app/(dashboard)/dashboard/channels/_components/edit.tsx b/web/app/(dashboard)/dashboard/channels/_components/edit.tsx index 3168a15a..af995cdf 100644 --- a/web/app/(dashboard)/dashboard/channels/_components/edit.tsx +++ b/web/app/(dashboard)/dashboard/channels/_components/edit.tsx @@ -993,7 +993,7 @@ function ChannelFormBody({ .replace(/^org[-_]/, "") .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") - return `help+${cleanSlug || "dos"}@crove.io` + return `help@${cleanSlug || "dos"}.crove.io` }, [emailAddressValue, orgSlug]) async function rollbackRolloutPercent() { From 165eb76e7f78d14f8f43ae57e4b670b8e81156e9 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:56:44 +0700 Subject: [PATCH 21/30] fix(conversation): clean subject from body and fix filter queries for active/mine tabs --- internal/builders/conversation_builder.go | 3 --- internal/services/conversation_service.go | 10 +++++----- internal/services/email_inbound_service.go | 5 +---- .../_components/conversation-info-panel.tsx | 6 ------ 4 files changed, 6 insertions(+), 18 deletions(-) diff --git a/internal/builders/conversation_builder.go b/internal/builders/conversation_builder.go index 31c0ac41..31a3d252 100644 --- a/internal/builders/conversation_builder.go +++ b/internal/builders/conversation_builder.go @@ -45,9 +45,6 @@ func BuildConversationWithLocale(item *models.Conversation, locale string) respo ClosedBy: item.ClosedBy, CloseReason: item.CloseReason, } - if ret.Title == "" && item.LastMessageSummary != "" { - ret.Title = item.LastMessageSummary - } if item.ChannelID > 0 { if channel := services.ChannelService.Get(item.ChannelID); channel != nil { ret.ChannelType = channel.ChannelType diff --git a/internal/services/conversation_service.go b/internal/services/conversation_service.go index 5e017b07..cf52e298 100644 --- a/internal/services/conversation_service.go +++ b/internal/services/conversation_service.go @@ -64,15 +64,15 @@ func (s *conversationService) ListConversations(userID int64, filter request.Age switch filter { case request.AgentConversationFilterAIServing: - cnd.Eq("current_assignee_id", 0).Eq("status", enums.IMConversationStatusAIServing).Desc("last_active_at").Desc("id") + cnd.Eq("status", enums.IMConversationStatusAIServing).Desc("last_active_at").Desc("id") case request.AgentConversationFilterMine: - cnd.Eq("current_assignee_id", userID).Desc("last_active_at").Desc("id") + cnd.Eq("current_assignee_id", userID).Ne("status", enums.IMConversationStatusClosed).Desc("last_active_at").Desc("id") case request.AgentConversationFilterActive: - cnd.Eq("current_assignee_id", userID).Eq("status", enums.IMConversationStatusActive).Desc("last_active_at").Desc("id") + cnd.Eq("status", enums.IMConversationStatusActive).Desc("last_active_at").Desc("id") case request.AgentConversationFilterPending: - cnd.Eq("current_assignee_id", 0).Eq("status", enums.IMConversationStatusPending).Asc("last_active_at").Desc("id") + cnd.Eq("status", enums.IMConversationStatusPending).Asc("last_active_at").Desc("id") case request.AgentConversationFilterClosed: - cnd.Eq("current_assignee_id", userID).Eq("status", enums.IMConversationStatusClosed).Desc("last_active_at").Desc("id") + cnd.Eq("status", enums.IMConversationStatusClosed).Desc("last_active_at").Desc("id") default: return nil, nil, errorsx.InvalidParamI18n("error.e0121") } diff --git a/internal/services/email_inbound_service.go b/internal/services/email_inbound_service.go index 55c1075e..28aeeb8f 100644 --- a/internal/services/email_inbound_service.go +++ b/internal/services/email_inbound_service.go @@ -110,11 +110,8 @@ func (s *emailInboundService) processInboundItem(ctx context.Context, channel *m bodyText = "(Empty email body)" } - // Format content with subject if provided + // Use body text directly for message content (subject is tracked at conversation level) content := bodyText - if item.Subject != "" { - content = fmt.Sprintf("[%s]\n\n%s", item.Subject, bodyText) - } // 1. Resolve customer identity externalUser := openidentity.ExternalUser{ 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 cc277ac5..f4c3ca71 100644 --- a/web/app/(dashboard)/dashboard/conversations/_components/conversation-info-panel.tsx +++ b/web/app/(dashboard)/dashboard/conversations/_components/conversation-info-panel.tsx @@ -265,12 +265,6 @@ export function ConversationInfoPanel({ value={`#${conversation.id}`} valueClassName="font-mono text-xs font-semibold" /> - {conversation.title ? ( - - ) : null}
{t("conversation.channel")}
From 05f12268b2c5263c2962420d8200fbf8089f1c1d Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:11:33 +0700 Subject: [PATCH 22/30] fix(services): correct sqls.Cnd condition for mine filter in conversation list --- internal/services/conversation_service.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/services/conversation_service.go b/internal/services/conversation_service.go index cf52e298..8f2ced5c 100644 --- a/internal/services/conversation_service.go +++ b/internal/services/conversation_service.go @@ -66,7 +66,7 @@ func (s *conversationService) ListConversations(userID int64, filter request.Age case request.AgentConversationFilterAIServing: cnd.Eq("status", enums.IMConversationStatusAIServing).Desc("last_active_at").Desc("id") case request.AgentConversationFilterMine: - cnd.Eq("current_assignee_id", userID).Ne("status", enums.IMConversationStatusClosed).Desc("last_active_at").Desc("id") + cnd.Eq("current_assignee_id", userID).Where("status <> ?", enums.IMConversationStatusClosed).Desc("last_active_at").Desc("id") case request.AgentConversationFilterActive: cnd.Eq("status", enums.IMConversationStatusActive).Desc("last_active_at").Desc("id") case request.AgentConversationFilterPending: From 0f809e1b6e6a16659b43f49e396bb5a877a3abc5 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Sun, 6 Sep 2026 17:32:42 +0700 Subject: [PATCH 23/30] feat(customer): implement Customer Merge dialog and backend API to combine omnichannel profiles --- internal/bootstrap/routes.go | 1 + .../handlers/dashboard/customer_handler.go | 20 + internal/pkg/dto/request/customer_request.go | 6 + .../customer_contact_repository.go | 9 +- internal/services/customer_merge_test.go | 143 ++++++ internal/services/customer_service.go | 170 ++++++++ internal/services/customer_service_test.go | 2 +- .../_components/conversation-info-panel.tsx | 46 +- .../(dashboard)/dashboard/customers/page.tsx | 163 ++++--- web/components/customer-merge-dialog.tsx | 406 ++++++++++++++++++ web/lib/api/customer.ts | 13 + web/messages/en-US.json | 19 + web/messages/vi-VN.json | 19 + web/messages/zh-CN.json | 19 + 14 files changed, 953 insertions(+), 83 deletions(-) create mode 100644 internal/services/customer_merge_test.go create mode 100644 web/components/customer-merge-dialog.tsx diff --git a/internal/bootstrap/routes.go b/internal/bootstrap/routes.go index eeae6f25..9843f9ec 100644 --- a/internal/bootstrap/routes.go +++ b/internal/bootstrap/routes.go @@ -123,6 +123,7 @@ func registerDashboardCustomerRoutes(group *gin.RouterGroup) { group.POST("/create", dashboard.CustomerPostCreate) group.POST("/delete", dashboard.CustomerPostDelete) group.POST("/list", dashboard.CustomerPostList) + group.POST("/merge", dashboard.CustomerPostMerge) group.POST("/save_profile", dashboard.CustomerPostSave_profile) group.POST("/update", dashboard.CustomerPostUpdate) group.POST("/update_status", dashboard.CustomerPostUpdate_status) diff --git a/internal/handlers/dashboard/customer_handler.go b/internal/handlers/dashboard/customer_handler.go index b5c8f950..6abe62a5 100644 --- a/internal/handlers/dashboard/customer_handler.go +++ b/internal/handlers/dashboard/customer_handler.go @@ -148,3 +148,23 @@ func CustomerPostUpdate_status(ctx *gin.Context) { } httpx.WriteJSON(ctx, nil) } + +func CustomerPostMerge(ctx *gin.Context) { + user, err := services.AuthService.RequirePermission(ctx, constants.PermissionCustomerUpdate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.MergeCustomerRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + item, err := services.CustomerService.MergeCustomer(req, user) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + ret := builders.BuildCustomer(item) + httpx.WriteJSON(ctx, &ret) +} diff --git a/internal/pkg/dto/request/customer_request.go b/internal/pkg/dto/request/customer_request.go index 7910953c..e0083e66 100644 --- a/internal/pkg/dto/request/customer_request.go +++ b/internal/pkg/dto/request/customer_request.go @@ -70,3 +70,9 @@ type SaveCustomerProfileRequest struct { Remark string `json:"remark"` Contacts []CustomerProfileContactItem `json:"contacts"` } + +type MergeCustomerRequest struct { + TargetCustomerID int64 `json:"targetCustomerId"` + SourceCustomerID int64 `json:"sourceCustomerId"` + Reason string `json:"reason,omitempty"` +} diff --git a/internal/repositories/customer_contact_repository.go b/internal/repositories/customer_contact_repository.go index 2308b05a..79743e37 100644 --- a/internal/repositories/customer_contact_repository.go +++ b/internal/repositories/customer_contact_repository.go @@ -2,7 +2,7 @@ package repositories import ( "agent-desk/internal/models" - + "agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/httpx/params" "github.com/mlogclub/simple/sqls" @@ -47,6 +47,13 @@ func (r *customerContactRepository) FindOne(db *gorm.DB, cnd *sqls.Cnd) *models. return ret } +func (r *customerContactRepository) FindByCustomerID(db *gorm.DB, customerID int64) []models.CustomerContact { + if customerID <= 0 { + return nil + } + return r.Find(db, sqls.NewCnd().Eq("customer_id", customerID).Eq("status", enums.StatusOk).Desc("id")) +} + func (r *customerContactRepository) FindPageByParams(db *gorm.DB, params *params.QueryParams) (list []models.CustomerContact, paging *sqls.Paging) { return r.FindPageByCnd(db, ¶ms.Cnd) } diff --git a/internal/services/customer_merge_test.go b/internal/services/customer_merge_test.go new file mode 100644 index 00000000..845d2acd --- /dev/null +++ b/internal/services/customer_merge_test.go @@ -0,0 +1,143 @@ +package services_test + +import ( + "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/pkg/openidentity" + "agent-desk/internal/services" + + "github.com/mlogclub/simple/sqls" +) + +func TestMergeCustomer_Success(t *testing.T) { + db := setupCustomerServiceTestDB(t) + now := time.Now() + operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"} + + // 1. Create Target Customer (Customer A: has email) + var targetID int64 + _ = sqls.WithTransaction(func(ctx *sqls.TxContext) error { + id, err := services.CustomerService.EnsureExternalCustomer(ctx, openidentity.ExternalUser{ + ExternalSource: enums.ExternalSourceEmail, + ExternalID: "john@acme.com", + ExternalName: "John Doe (Email)", + }) + targetID = id + return err + }) + + _ = db.Model(&models.Customer{}).Where("id = ?", targetID).Updates(map[string]any{ + "primary_email": "john@acme.com", + }) + + // 2. Create Source Customer (Customer B: has Telegram and same email contact) + var sourceID int64 + _ = sqls.WithTransaction(func(ctx *sqls.TxContext) error { + id, err := services.CustomerService.EnsureExternalCustomer(ctx, openidentity.ExternalUser{ + ExternalSource: enums.ExternalSourceTelegram, + ExternalID: "tg_12345678", + ExternalName: "John Telegram", + }) + sourceID = id + return err + }) + + _ = db.Model(&models.Customer{}).Where("id = ?", sourceID).Updates(map[string]any{ + "primary_mobile": "+1234567890", + }) + + // Add contacts to source + _ = db.Create(&models.CustomerContact{ + CustomerID: sourceID, + ContactType: enums.ContactTypeMobile, + ContactValue: "+1234567890", + IsPrimary: true, + Status: enums.StatusOk, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + }) + + // Add conversations to source and target + convSource := &models.Conversation{ + CustomerID: sourceID, + CustomerName: "John Telegram", + Status: enums.IMConversationStatusActive, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + _ = db.Create(convSource) + + convTarget := &models.Conversation{ + CustomerID: targetID, + CustomerName: "John Doe (Email)", + Status: enums.IMConversationStatusActive, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + _ = db.Create(convTarget) + + // Add ticket to source + ticketSource := &models.Ticket{ + TicketNo: "T-0001", + Title: "Telegram issue", + CustomerID: sourceID, + Status: enums.TicketStatusPending, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + _ = db.Create(ticketSource) + + // 3. Execute Merge + merged, err := services.CustomerService.MergeCustomer(request.MergeCustomerRequest{ + TargetCustomerID: targetID, + SourceCustomerID: sourceID, + Reason: "Same customer identified across Telegram and Email", + }, operator) + if err != nil { + t.Fatalf("MergeCustomer() error = %v", err) + } + + if merged == nil || merged.ID != targetID { + t.Fatalf("expected merged customer ID %d, got %+v", targetID, merged) + } + + // 4. Verify Target Customer now has moved primary_mobile + if merged.PrimaryMobile != "+1234567890" { + t.Errorf("expected target primary mobile to be '+1234567890', got %q", merged.PrimaryMobile) + } + if merged.PrimaryEmail != "john@acme.com" { + t.Errorf("expected target primary email to be 'john@acme.com', got %q", merged.PrimaryEmail) + } + + // 5. Verify Source Customer is marked StatusDeleted + sourceCustomer := services.CustomerService.Get(sourceID) + if sourceCustomer == nil || sourceCustomer.Status != enums.StatusDeleted { + t.Errorf("expected source customer to be deleted, got %+v", sourceCustomer) + } + + // 6. Verify Source Conversation was transferred to Target Customer + var updatedConv models.Conversation + if err := db.First(&updatedConv, convSource.ID).Error; err != nil { + t.Fatalf("find updated conv error = %v", err) + } + if updatedConv.CustomerID != targetID { + t.Errorf("expected conv customerID to be %d, got %d", targetID, updatedConv.CustomerID) + } + + // 7. Verify Source Ticket was transferred to Target Customer + var updatedTicket models.Ticket + if err := db.First(&updatedTicket, ticketSource.ID).Error; err != nil { + t.Fatalf("find updated ticket error = %v", err) + } + if updatedTicket.CustomerID != targetID { + t.Errorf("expected ticket customerID to be %d, got %d", targetID, updatedTicket.CustomerID) + } + + // 8. Verify CustomerIdentities: Target now has both Email and Telegram identities + var identities []models.CustomerIdentity + db.Where("customer_id = ? AND status = ?", targetID, enums.StatusOk).Find(&identities) + if len(identities) != 2 { + t.Errorf("expected 2 active identities for target, got %d", len(identities)) + } +} diff --git a/internal/services/customer_service.go b/internal/services/customer_service.go index abbd2341..df98b9d1 100644 --- a/internal/services/customer_service.go +++ b/internal/services/customer_service.go @@ -381,3 +381,173 @@ func (s *customerService) SaveCustomerProfile(req request.SaveCustomerProfileReq } return out, nil } + +func (s *customerService) MergeCustomer(req request.MergeCustomerRequest, operator *dto.AuthPrincipal) (*models.Customer, error) { + if operator == nil { + return nil, errorsx.UnauthorizedI18n("error.auth.expired") + } + if req.TargetCustomerID <= 0 || req.SourceCustomerID <= 0 { + return nil, errorsx.InvalidParamI18n("error.e0155") + } + if req.TargetCustomerID == req.SourceCustomerID { + return nil, errorsx.InvalidParam("cannot merge customer into itself") + } + + target := s.Get(req.TargetCustomerID) + if target == nil || target.Status == enums.StatusDeleted { + return nil, errorsx.InvalidParamI18n("error.e0155") + } + + source := s.Get(req.SourceCustomerID) + if source == nil || source.Status == enums.StatusDeleted { + return nil, errorsx.InvalidParamI18n("error.e0155") + } + + now := time.Now() + err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + // 1. Move/merge CustomerIdentities from source to target + sourceIdentities := repositories.CustomerIdentityRepository.FindByCustomerID(ctx.Tx, source.ID) + targetIdentities := repositories.CustomerIdentityRepository.FindByCustomerID(ctx.Tx, target.ID) + targetIdentityMap := make(map[string]bool) + for _, ti := range targetIdentities { + key := string(ti.ExternalSource) + ":" + ti.ExternalID + targetIdentityMap[key] = true + } + + for _, si := range sourceIdentities { + key := string(si.ExternalSource) + ":" + si.ExternalID + if targetIdentityMap[key] { + // Target already has this exact identity, remove duplicate from source + _ = repositories.CustomerIdentityRepository.Updates(ctx.Tx, si.ID, map[string]any{ + "status": enums.StatusDeleted, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": now, + }) + } else { + // Move identity to target + if err := repositories.CustomerIdentityRepository.Updates(ctx.Tx, si.ID, map[string]any{ + "customer_id": target.ID, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": now, + }); err != nil { + return err + } + targetIdentityMap[key] = true + } + } + + // 2. Move/merge CustomerContacts from source to target + sourceContacts := repositories.CustomerContactRepository.FindByCustomerID(ctx.Tx, source.ID) + targetContacts := repositories.CustomerContactRepository.FindByCustomerID(ctx.Tx, target.ID) + targetContactMap := make(map[string]bool) + for _, tc := range targetContacts { + key := string(tc.ContactType) + ":" + strings.ToLower(tc.ContactValue) + targetContactMap[key] = true + } + + for _, sc := range sourceContacts { + key := string(sc.ContactType) + ":" + strings.ToLower(sc.ContactValue) + if targetContactMap[key] { + // Target already has this contact, mark duplicate contact deleted + _ = repositories.CustomerContactRepository.Updates(ctx.Tx, sc.ID, map[string]any{ + "status": enums.StatusDeleted, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": now, + }) + } else { + // Move contact to target (set is_primary = false to preserve target's primary contact) + if err := repositories.CustomerContactRepository.Updates(ctx.Tx, sc.ID, map[string]any{ + "customer_id": target.ID, + "is_primary": false, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": now, + }); err != nil { + return err + } + targetContactMap[key] = true + } + } + + // 3. Move Conversations from source to target + if err := ctx.Tx.Model(&models.Conversation{}). + Where("customer_id = ?", source.ID). + Updates(map[string]any{ + "customer_id": target.ID, + "customer_name": target.Name, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": now, + }).Error; err != nil { + return err + } + + // 4. Move Tickets from source to target + if err := ctx.Tx.Model(&models.Ticket{}). + Where("customer_id = ?", source.ID). + Updates(map[string]any{ + "customer_id": target.ID, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": now, + }).Error; err != nil { + return err + } + + // 5. Fill empty profile fields in target if available in source + targetUpdates := map[string]any{ + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": now, + } + if target.PrimaryEmail == "" && source.PrimaryEmail != "" { + target.PrimaryEmail = source.PrimaryEmail + targetUpdates["primary_email"] = target.PrimaryEmail + } + if target.PrimaryMobile == "" && source.PrimaryMobile != "" { + target.PrimaryMobile = source.PrimaryMobile + targetUpdates["primary_mobile"] = target.PrimaryMobile + } + if target.CompanyID == 0 && source.CompanyID > 0 { + target.CompanyID = source.CompanyID + targetUpdates["company_id"] = target.CompanyID + } + if target.Gender == 0 && source.Gender != 0 { + target.Gender = source.Gender + targetUpdates["gender"] = target.Gender + } + + mergeRemark := fmt.Sprintf("Merged from Customer #%d (%s)", source.ID, source.Name) + if trimmedReason := strings.TrimSpace(req.Reason); trimmedReason != "" { + mergeRemark += fmt.Sprintf(". Reason: %s", trimmedReason) + } + if target.Remark != "" { + target.Remark = target.Remark + "\n" + mergeRemark + } else { + target.Remark = mergeRemark + } + targetUpdates["remark"] = target.Remark + + if err := repositories.CustomerRepository.Updates(ctx.Tx, target.ID, targetUpdates); err != nil { + return err + } + + // 6. Soft-delete Source Customer + sourceUpdates := map[string]any{ + "status": enums.StatusDeleted, + "remark": fmt.Sprintf("Merged into Customer #%d (%s)", target.ID, target.Name), + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": now, + } + return repositories.CustomerRepository.Updates(ctx.Tx, source.ID, sourceUpdates) + }) + if err != nil { + return nil, err + } + + return s.Get(target.ID), nil +} diff --git a/internal/services/customer_service_test.go b/internal/services/customer_service_test.go index b23349cb..f35ec873 100644 --- a/internal/services/customer_service_test.go +++ b/internal/services/customer_service_test.go @@ -92,7 +92,7 @@ func setupCustomerServiceTestDB(t *testing.T) *gorm.DB { _ = sqlDB.Close() } }) - if err := db.AutoMigrate(&models.Customer{}, &models.CustomerIdentity{}, &models.Conversation{}); err != nil { + if err := db.AutoMigrate(&models.Customer{}, &models.CustomerIdentity{}, &models.CustomerContact{}, &models.Conversation{}, &models.Ticket{}, &models.Company{}); err != nil { t.Fatalf("auto migrate error = %v", err) } sqls.SetDB(db) 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 f4c3ca71..dc884a5d 100644 --- a/web/app/(dashboard)/dashboard/conversations/_components/conversation-info-panel.tsx +++ b/web/app/(dashboard)/dashboard/conversations/_components/conversation-info-panel.tsx @@ -2,6 +2,7 @@ import { AlertTriangleIcon, Building2Icon, + GitMergeIcon, Link2Icon, MailIcon, PencilIcon, @@ -16,6 +17,7 @@ import { toast } from "sonner"; import { type CustomerFormSavePayload } from "@/components/customer-form"; import { CustomerFormDialog } from "@/components/customer-form-dialog"; import { CustomerLinkOrCreateDialog } from "@/components/customer-link-or-create-dialog"; +import { CustomerMergeDialog } from "@/components/customer-merge-dialog"; import { ChannelIcon } from "@/components/channel-icon"; import { AssigneeSelector } from "./assignee-selector"; import { JsonTreeViewer } from "@/components/json-tree-viewer"; @@ -641,12 +643,14 @@ type CustomerLinkedBodyProps = { function CustomerLinkedBody({ conversation, customerId }: CustomerLinkedBodyProps) { const t = useI18n(); + const loadConversations = useAgentConversationsStore((s) => s.loadConversations); const [loading, setLoading] = useState(true); const [customer, setCustomer] = useState(null); const [contacts, setContacts] = useState([]); const [customerEditOpen, setCustomerEditOpen] = useState(false); const [customerEditSaving, setCustomerEditSaving] = useState(false); + const [customerMergeOpen, setCustomerMergeOpen] = useState(false); const [companyEditOpen, setCompanyEditOpen] = useState(false); const load = useCallback(async () => { @@ -733,16 +737,29 @@ function CustomerLinkedBody({ conversation, customerId }: CustomerLinkedBodyProp

- +
+ + +
@@ -895,6 +912,15 @@ function CustomerLinkedBody({ conversation, customerId }: CustomerLinkedBodyProp } }} /> + { + void load(); + await loadConversations(); + }} + /> {company ? ( (null); + const [mergeOpen, setMergeOpen] = useState(false); const [companyOptions, setCompanyOptions] = useState([ { value: "0", label: t("customer.allCompanies") }, ]); @@ -192,75 +196,92 @@ export default function DashboardCustomersPage() { ); return ( - - filters={filters} - columns={columns} - fetchList={(query) => - fetchCustomers({ - keyword: - typeof query.keyword === "string" ? query.keyword : undefined, - status: - typeof query.status === "number" ? query.status : undefined, - gender: - typeof query.gender === "number" ? query.gender : undefined, - companyId: - typeof query.companyId === "number" ? query.companyId : undefined, - page: Number(query.page), - limit: Number(query.limit), - }) - } - getItemId={(item) => item.id} - createItem={saveCustomerProfile} - updateItem={(_item, payload) => saveCustomerProfile(payload)} - deleteItem={(item) => deleteCustomer(item.id)} - canDelete={(item) => item.status !== Status.Deleted} - rowActions={[ - createDashboardStatusToggleAction({ - icon: (item) => - item.status === Status.Ok ? : , - label: (item) => - item.status === Status.Ok - ? t("customer.disable") - : t("customer.enable"), - disabled: (item) => item.status === Status.Deleted, - getNextStatus: (item) => - item.status === Status.Ok ? Status.Disabled : Status.Ok, - updateStatus: (item, nextStatus) => - updateCustomerStatus(item.id, nextStatus), - successMessage: (item, nextStatus) => - t(nextStatus === Status.Ok ? "customer.enabled" : "customer.disabled", { - name: item.name, - }), - errorMessage: t("customer.statusUpdateFailed"), - }), - ]} - renderEditDialog={({ open, saving, itemId, onOpenChange, onSubmit }) => ( - - )} - labels={{ - refresh: t("customer.refresh"), - create: t("customer.new"), - query: t("customer.query"), - loading: t("customer.loading"), - empty: t("customer.empty"), - actions: t("customer.columnActions"), - edit: t("customer.edit"), - delete: t("customer.delete"), - processing: t("customer.processing"), - moreActions: (item) => t("customer.moreActions", { name: item.name }), - loadFailed: t("customer.loadFailed"), - saveFailed: t("customer.saveFailed"), - deleteFailed: t("customer.deleteFailed"), - created: (payload) => t("customer.created", { name: payload.name }), - updated: (item) => t("customer.updated", { name: item.name }), - deleted: (item) => t("customer.deleted", { name: item.name }), - }} - /> + <> + + filters={filters} + columns={columns} + fetchList={(query) => + fetchCustomers({ + keyword: + typeof query.keyword === "string" ? query.keyword : undefined, + status: + typeof query.status === "number" ? query.status : undefined, + gender: + typeof query.gender === "number" ? query.gender : undefined, + companyId: + typeof query.companyId === "number" ? query.companyId : undefined, + page: Number(query.page), + limit: Number(query.limit), + }) + } + getItemId={(item) => item.id} + createItem={saveCustomerProfile} + updateItem={(_item, payload) => saveCustomerProfile(payload)} + deleteItem={(item) => deleteCustomer(item.id)} + canDelete={(item) => item.status !== Status.Deleted} + rowActions={[ + { + key: "merge", + label: t("customerMerge.mergeAction"), + icon: , + disabled: (item: AdminCustomer) => item.status === Status.Deleted, + run: ({ item }: DashboardCrudRowActionContext) => { + setMergeTarget(item); + setMergeOpen(true); + }, + }, + createDashboardStatusToggleAction({ + icon: (item) => + item.status === Status.Ok ? : , + label: (item) => + item.status === Status.Ok + ? t("customer.disable") + : t("customer.enable"), + disabled: (item) => item.status === Status.Deleted, + getNextStatus: (item) => + item.status === Status.Ok ? Status.Disabled : Status.Ok, + updateStatus: (item, nextStatus) => + updateCustomerStatus(item.id, nextStatus), + successMessage: (item, nextStatus) => + t(nextStatus === Status.Ok ? "customer.enabled" : "customer.disabled", { + name: item.name, + }), + errorMessage: t("customer.statusUpdateFailed"), + }), + ]} + renderEditDialog={({ open, saving, itemId, onOpenChange, onSubmit }) => ( + + )} + labels={{ + refresh: t("customer.refresh"), + create: t("customer.new"), + query: t("customer.query"), + loading: t("customer.loading"), + empty: t("customer.empty"), + actions: t("customer.columnActions"), + edit: t("customer.edit"), + delete: t("customer.delete"), + processing: t("customer.processing"), + moreActions: (item) => t("customer.moreActions", { name: item.name }), + loadFailed: t("customer.loadFailed"), + saveFailed: t("customer.saveFailed"), + deleteFailed: t("customer.deleteFailed"), + created: (payload) => t("customer.created", { name: payload.name }), + updated: (item) => t("customer.updated", { name: item.name }), + deleted: (item) => t("customer.deleted", { name: item.name }), + }} + /> + + ); } diff --git a/web/components/customer-merge-dialog.tsx b/web/components/customer-merge-dialog.tsx new file mode 100644 index 00000000..9fa0dfbc --- /dev/null +++ b/web/components/customer-merge-dialog.tsx @@ -0,0 +1,406 @@ +"use client" + +import { useEffect, useState } from "react" +import { + AlertTriangleIcon, + ArrowRightLeftIcon, + Building2Icon, + CheckIcon, + GitMergeIcon, + MailIcon, + PhoneIcon, + SearchIcon, + UserRoundIcon, +} from "lucide-react" +import { toast } from "sonner" + +import { ProjectDialog } from "@/components/project-dialog" +import { Avatar, AvatarFallback } from "@/components/ui/avatar" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { useI18n } from "@/i18n/provider" +import { + fetchCustomer, + fetchCustomers, + mergeCustomer, + type AdminCustomer, +} from "@/lib/api/customer" +import { cn, formatDateTime } from "@/lib/utils" + +export type CustomerMergeDialogProps = { + open: boolean + onOpenChange: (open: boolean) => void + /** Current customer from context, pre-populated as primary or source. */ + currentCustomer?: AdminCustomer | null + currentCustomerId?: number | null + onSuccess?: (mergedCustomer: AdminCustomer) => void | Promise +} + +export function CustomerMergeDialog({ + open, + onOpenChange, + currentCustomer, + currentCustomerId, + onSuccess, +}: CustomerMergeDialogProps) { + const t = useI18n() + const [primaryCustomer, setPrimaryCustomer] = useState(null) + const [duplicateCustomer, setDuplicateCustomer] = useState(null) + const [searchQuery, setSearchQuery] = useState("") + const [searching, setSearching] = useState(false) + const [searchResults, setSearchResults] = useState([]) + const [reason, setReason] = useState("") + const [merging, setMerging] = useState(false) + const [loadingInitial, setLoadingInitial] = useState(false) + + // Initialize primary customer when dialog opens + useEffect(() => { + if (!open) { + setPrimaryCustomer(null) + setDuplicateCustomer(null) + setSearchQuery("") + setSearchResults([]) + setReason("") + return + } + + if (currentCustomer) { + setPrimaryCustomer(currentCustomer) + return + } + + if (currentCustomerId) { + setLoadingInitial(true) + fetchCustomer(currentCustomerId) + .then((data) => { + if (data) setPrimaryCustomer(data) + }) + .catch(() => {}) + .finally(() => setLoadingInitial(false)) + } + }, [currentCustomer, currentCustomerId, open]) + + const handleSearch = async () => { + const q = searchQuery.trim() + if (!q) { + toast.error(t("customerLink.keywordRequired")) + return + } + + setSearching(true) + try { + const data = await fetchCustomers({ + keyword: q, + page: 1, + limit: 20, + status: 0, + }) + // Exclude primary customer from search results + const filtered = (data.results || []).filter( + (c) => c.id !== primaryCustomer?.id, + ) + setSearchResults(filtered) + if (filtered.length === 0) { + toast.message(t("customerLink.noMatch")) + } + } catch (e) { + toast.error(e instanceof Error ? e.message : t("customerLink.searchFailed")) + } finally { + setSearching(false) + } + } + + const handleSwap = () => { + if (!primaryCustomer || !duplicateCustomer) return + const temp = primaryCustomer + setPrimaryCustomer(duplicateCustomer) + setDuplicateCustomer(temp) + } + + const handleSelectDuplicate = (customer: AdminCustomer) => { + setDuplicateCustomer(customer) + setSearchResults([]) + setSearchQuery("") + } + + const handleMerge = async () => { + if (!primaryCustomer || !duplicateCustomer) return + if (primaryCustomer.id === duplicateCustomer.id) { + toast.error(t("customerMerge.sameCustomerError")) + return + } + + setMerging(true) + try { + const res = await mergeCustomer({ + targetCustomerId: primaryCustomer.id, + sourceCustomerId: duplicateCustomer.id, + reason: reason.trim() || undefined, + }) + toast.success(t("customerMerge.mergeSuccess")) + onOpenChange(false) + await onSuccess?.(res) + } catch (error) { + toast.error(error instanceof Error ? error.message : t("customerMerge.mergeFailed")) + } finally { + setMerging(false) + } + } + + return ( + + + {t("customerMerge.title")} +
+ } + description={t("customerMerge.description")} + size="lg" + footer={ +
+ + + +
+ } + > +
+ {/* Warning Notice */} +
+ +

{t("customerMerge.warningNotice")}

+
+ + {/* 2-Column Comparison with Swap */} +
+ {/* Primary Customer (Keep) */} +
+
+ + {t("customerMerge.primaryCustomer")} + + {primaryCustomer ? ( + #{primaryCustomer.id} + ) : null} +
+ + {primaryCustomer ? ( + + ) : ( +
+ {loadingInitial ? t("common.loading") : t("customerMerge.selectCustomerPrompt")} +
+ )} +
+ + {/* Swap Button in center for Desktop */} + {primaryCustomer && duplicateCustomer ? ( +
+ +
+ ) : null} + + {/* Duplicate Customer (Merge & Remove) */} +
+
+ + {t("customerMerge.sourceCustomer")} + + {duplicateCustomer ? ( +
+ #{duplicateCustomer.id} + +
+ ) : null} +
+ + {duplicateCustomer ? ( + + ) : ( +
+

{t("customerMerge.selectCustomerPrompt")}

+

{t("customerMerge.searchPlaceholder")}

+
+ )} +
+
+ + {/* Swap button on mobile */} + {primaryCustomer && duplicateCustomer ? ( +
+ +
+ ) : null} + + {/* Search for Duplicate Customer if not selected yet */} + {!duplicateCustomer ? ( +
+ +
+
+ + setSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + void handleSearch() + } + }} + placeholder={t("customerMerge.searchPlaceholder")} + className="h-8.5 pl-8 text-xs" + /> +
+ +
+ + {/* Search Results list */} + {searchResults.length > 0 ? ( +
+ {searchResults.map((customer) => ( +
handleSelectDuplicate(customer)} + className="flex cursor-pointer items-center justify-between p-2.5 text-xs transition-colors hover:bg-muted/50" + > +
+
+ {customer.name || t("customerLink.fallbackName", { id: customer.id })} + #{customer.id} +
+
+ {customer.primaryEmail ? {customer.primaryEmail} : null} + {customer.primaryMobile ? {customer.primaryMobile} : null} + {customer.company?.name ? ( + {customer.company.name} + ) : null} +
+
+ +
+ ))} +
+ ) : null} +
+ ) : null} + + {/* Reason / Notes */} +
+ +