e029d31f86
Until now the IM channels a tenant has connected were only visible from
inside each agent's editor (AgentEditorModal → IMChannelPanel). Finding
out which bots are live across all agents meant clicking through every
one of them. Add a tenant-scoped overview so the set of connected IMs is
always one hop away from the avatar.
Backend
- im.Service.ListChannelsByTenant(tenantID) returns all non-deleted
channels in the tenant, LEFT JOIN'd with custom_agents.name so
built-in agents (which don't have rows in custom_agents) still show
up with an empty agent_name; the frontend substitutes a localized
"built-in agent" label.
- Credentials are intentionally stripped from this response — the list
view is read-only and the editor route (GET /agents/:id/im-channels)
remains the only source for secrets.
- New ChannelWithAgent DTO + IMHandler.ListAllIMChannels wired at
GET /api/v1/im-channels under the existing RegisterIMChannelRoutes
group, so auth middleware is inherited.
Frontend
- IMChannelsOverviewPanel.vue: floating submenu pane, stacked rows
(agent on top, channel below) with identical 20px avatars and 12px
labels so neither identity dominates. Each row is the whole click
target (jumps to the agent editor); the switch uses @click.stop to
toggle in place without triggering navigation.
- UserMenu.vue: new hover-driven submenu entry ("已接入的 IM" + link
icon). The pane is teleported to <body> because the sidebar container
has overflow:hidden that would otherwise clip right-flying content.
Position is computed from the menu item's rect and clamps/flips
against the viewport edges.
- "Live" indicator: a pulsing green dot on the menu item when at least
one channel is enabled. UserMenu prefetches the list once on mount;
the panel re-emits channels-changed after every load/toggle so the
dot stays in sync. Respects prefers-reduced-motion.
- AgentList.vue: supplement the existing onMounted-only
checkAndOpenEditModal with a watch(route.query.edit) so
router.push({ path: '/platform/agents', query: { edit, section } })
from an already-mounted AgentList (the common case when navigating
from the overview) opens the editor immediately instead of only
after a hard refresh.
Assets
- Seven platform SVGs under assets/img/im/, pulled from iconify
(simple-icons / logos / tdesign / icon-park / remix icon) and baked
with brand colors so monochrome sources (wecom/wechat/dingtalk/
feishu) don't render black.
i18n
- New imOverview namespace in zh-CN / en-US / ko-KR / ru-RU covering
the menu label, panel title/subtitle, column headers, builtin-agent
fallback, and the live-indicator tooltip.
Tested
- go build ./...
- go test ./internal/im/...
- vue-tsc --noEmit
- npm run build
347 lines
10 KiB
Go
347 lines
10 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/Tencent/WeKnora/internal/im"
|
|
"github.com/Tencent/WeKnora/internal/logger"
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// validIMPlatforms is the set of supported IM platforms.
|
|
var validIMPlatforms = map[string]bool{
|
|
"wecom": true, "feishu": true, "slack": true, "telegram": true, "dingtalk": true, "mattermost": true,
|
|
"wechat": true,
|
|
}
|
|
|
|
// IMHandler handles IM platform callback requests and channel CRUD.
|
|
type IMHandler struct {
|
|
imService *im.Service
|
|
}
|
|
|
|
// NewIMHandler creates a new IM handler.
|
|
func NewIMHandler(imService *im.Service) *IMHandler {
|
|
return &IMHandler{
|
|
imService: imService,
|
|
}
|
|
}
|
|
|
|
// ── Channel CRUD handlers ──
|
|
|
|
// CreateIMChannel creates a new IM channel for an agent.
|
|
func (h *IMHandler) CreateIMChannel(c *gin.Context) {
|
|
agentID := c.Param("id")
|
|
if agentID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "agent_id is required"})
|
|
return
|
|
}
|
|
|
|
tenantID, ok := c.Request.Context().Value(types.TenantIDContextKey).(uint64)
|
|
if !ok {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Platform string `json:"platform" binding:"required"`
|
|
Name string `json:"name"`
|
|
Mode string `json:"mode"`
|
|
OutputMode string `json:"output_mode"`
|
|
KnowledgeBaseID string `json:"knowledge_base_id"`
|
|
Credentials types.JSON `json:"credentials"`
|
|
Enabled *bool `json:"enabled"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
if !validIMPlatforms[req.Platform] {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "platform must be 'wecom', 'feishu', 'slack', 'telegram', 'dingtalk', 'mattermost' or 'wechat'"})
|
|
return
|
|
}
|
|
|
|
channel := &im.IMChannel{
|
|
TenantID: tenantID,
|
|
AgentID: agentID,
|
|
Platform: req.Platform,
|
|
Name: req.Name,
|
|
Mode: req.Mode,
|
|
OutputMode: req.OutputMode,
|
|
KnowledgeBaseID: req.KnowledgeBaseID,
|
|
Credentials: req.Credentials,
|
|
Enabled: true,
|
|
}
|
|
if req.Enabled != nil {
|
|
channel.Enabled = *req.Enabled
|
|
}
|
|
// WeChat uses long-polling mode and full output only
|
|
if req.Platform == "wechat" {
|
|
channel.Mode = "longpoll"
|
|
channel.OutputMode = "full"
|
|
} else {
|
|
if channel.Mode == "" {
|
|
if channel.Platform == "mattermost" {
|
|
channel.Mode = "webhook"
|
|
} else {
|
|
channel.Mode = "websocket"
|
|
}
|
|
}
|
|
if channel.OutputMode == "" {
|
|
channel.OutputMode = "stream"
|
|
}
|
|
}
|
|
if channel.Credentials == nil {
|
|
channel.Credentials = types.JSON("{}")
|
|
}
|
|
|
|
if err := h.imService.CreateChannel(channel); err != nil {
|
|
logger.Errorf(c.Request.Context(), "[IM] Create channel failed: %v", err)
|
|
if strings.HasPrefix(err.Error(), "duplicate_bot:") {
|
|
c.JSON(http.StatusConflict, gin.H{"error": strings.TrimPrefix(err.Error(), "duplicate_bot: ")})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"data": channel})
|
|
}
|
|
|
|
// ListIMChannels lists all IM channels for an agent.
|
|
func (h *IMHandler) ListIMChannels(c *gin.Context) {
|
|
agentID := c.Param("id")
|
|
if agentID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "agent_id is required"})
|
|
return
|
|
}
|
|
|
|
tenantID, ok := c.Request.Context().Value(types.TenantIDContextKey).(uint64)
|
|
if !ok {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
|
return
|
|
}
|
|
|
|
channels, err := h.imService.ListChannelsByAgent(agentID, tenantID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list channels"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"data": channels})
|
|
}
|
|
|
|
// ListAllIMChannels lists every IM channel in the current tenant, across
|
|
// agents, for the cross-agent overview page. Credentials are intentionally
|
|
// NOT included in the response — callers that need credentials must use the
|
|
// per-agent endpoint (GET /agents/:id/im-channels).
|
|
func (h *IMHandler) ListAllIMChannels(c *gin.Context) {
|
|
tenantID, ok := c.Request.Context().Value(types.TenantIDContextKey).(uint64)
|
|
if !ok {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
|
return
|
|
}
|
|
|
|
channels, err := h.imService.ListChannelsByTenant(tenantID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list channels"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"data": channels})
|
|
}
|
|
|
|
// UpdateIMChannel updates an IM channel.
|
|
func (h *IMHandler) UpdateIMChannel(c *gin.Context) {
|
|
channelID := c.Param("id")
|
|
if channelID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "channel id is required"})
|
|
return
|
|
}
|
|
|
|
tenantID, ok := c.Request.Context().Value(types.TenantIDContextKey).(uint64)
|
|
if !ok {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
|
return
|
|
}
|
|
|
|
channel, err := h.imService.GetChannelByIDAndTenant(channelID, tenantID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Name *string `json:"name"`
|
|
Mode *string `json:"mode"`
|
|
OutputMode *string `json:"output_mode"`
|
|
KnowledgeBaseID *string `json:"knowledge_base_id"`
|
|
Credentials types.JSON `json:"credentials"`
|
|
Enabled *bool `json:"enabled"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
if req.Name != nil {
|
|
channel.Name = *req.Name
|
|
}
|
|
if req.Mode != nil {
|
|
channel.Mode = *req.Mode
|
|
}
|
|
if req.OutputMode != nil {
|
|
channel.OutputMode = *req.OutputMode
|
|
}
|
|
if req.KnowledgeBaseID != nil {
|
|
channel.KnowledgeBaseID = *req.KnowledgeBaseID
|
|
}
|
|
if req.Credentials != nil {
|
|
channel.Credentials = req.Credentials
|
|
}
|
|
if req.Enabled != nil {
|
|
channel.Enabled = *req.Enabled
|
|
}
|
|
|
|
if err := h.imService.UpdateChannel(channel); err != nil {
|
|
logger.Errorf(c.Request.Context(), "[IM] Update channel failed: %v", err)
|
|
if strings.HasPrefix(err.Error(), "duplicate_bot:") {
|
|
c.JSON(http.StatusConflict, gin.H{"error": strings.TrimPrefix(err.Error(), "duplicate_bot: ")})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update channel"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"data": channel})
|
|
}
|
|
|
|
// DeleteIMChannel deletes an IM channel.
|
|
func (h *IMHandler) DeleteIMChannel(c *gin.Context) {
|
|
channelID := c.Param("id")
|
|
if channelID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "channel id is required"})
|
|
return
|
|
}
|
|
|
|
tenantID, ok := c.Request.Context().Value(types.TenantIDContextKey).(uint64)
|
|
if !ok {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
|
return
|
|
}
|
|
|
|
if err := h.imService.DeleteChannel(channelID, tenantID); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete channel"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
|
}
|
|
|
|
// ToggleIMChannel toggles the enabled state of an IM channel.
|
|
func (h *IMHandler) ToggleIMChannel(c *gin.Context) {
|
|
channelID := c.Param("id")
|
|
if channelID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "channel id is required"})
|
|
return
|
|
}
|
|
|
|
tenantID, ok := c.Request.Context().Value(types.TenantIDContextKey).(uint64)
|
|
if !ok {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
|
return
|
|
}
|
|
|
|
channel, err := h.imService.ToggleChannel(channelID, tenantID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to toggle channel"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"data": channel})
|
|
}
|
|
|
|
// ── Callback handlers ──
|
|
|
|
// IMCallback handles IM platform callback requests for a specific channel.
|
|
// Route: POST /api/v1/im/callback/:channel_id
|
|
func (h *IMHandler) IMCallback(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
channelID := c.Param("channel_id")
|
|
|
|
adapter, channel, ok := h.imService.GetChannelAdapter(channelID)
|
|
if !ok {
|
|
// Try loading from DB
|
|
ch, err := h.imService.GetChannelByID(channelID)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "[IM] Channel not found for callback: %s", channelID)
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
|
|
return
|
|
}
|
|
if err := h.imService.StartChannel(ch); err != nil {
|
|
logger.Errorf(ctx, "[IM] Failed to start channel for callback: %v", err)
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "channel not available"})
|
|
return
|
|
}
|
|
adapter, channel, ok = h.imService.GetChannelAdapter(channelID)
|
|
if !ok {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "channel not available"})
|
|
return
|
|
}
|
|
}
|
|
|
|
if !channel.Enabled {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "channel is disabled"})
|
|
return
|
|
}
|
|
|
|
logger.Infof(ctx, "[IM] Callback received platform=%s path_channel_id=%s", channel.Platform, channelID)
|
|
|
|
// Handle URL verification
|
|
if adapter.HandleURLVerification(c) {
|
|
return
|
|
}
|
|
|
|
// Verify callback signature
|
|
if err := adapter.VerifyCallback(c); err != nil {
|
|
logger.Errorf(ctx, "[IM] Callback verification failed for channel %s: %v", channelID, err)
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "verification failed"})
|
|
return
|
|
}
|
|
|
|
// Parse the callback message
|
|
msg, err := adapter.ParseCallback(c)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "[IM] Parse callback failed for channel %s: %v", channelID, err)
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "parse failed"})
|
|
return
|
|
}
|
|
|
|
// If nil, it's a non-message event - just acknowledge
|
|
if msg == nil {
|
|
if channel.Platform == "mattermost" {
|
|
logger.Infof(ctx, "[IM] Mattermost callback ignored (no message): path_channel_id=%s — check: (1) trigger word must be the *first word* of the post; (2) if channel+trigger are both set, post must be in that channel; (3) bot_user_id must not match the sender", channelID)
|
|
} else {
|
|
logger.Infof(ctx, "[IM] Callback parsed no message to process platform=%s path_channel_id=%s", channel.Platform, channelID)
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
|
return
|
|
}
|
|
|
|
// Respond immediately to avoid platform timeout
|
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
|
|
|
// Detach from gin request context
|
|
asyncCtx := context.WithoutCancel(ctx)
|
|
|
|
// Process message asynchronously
|
|
go func() {
|
|
if err := h.imService.HandleMessage(asyncCtx, msg, channelID); err != nil {
|
|
logger.Errorf(asyncCtx, "[IM] Handle message error for channel %s: %v", channelID, err)
|
|
}
|
|
}()
|
|
}
|