9afb8b7b68
Lark and Feishu are the same product deployed on two isolated clouds (open.larksuite.com and open.feishu.cn). WeKnora touches both from two places — the IM bot and the Wiki data-source connector — and this brings Lark to parity across both. Neither API surface differs, so rather than duplicate either implementation, each is parametrized by a Region and registered twice. IM (internal/im/feishu): Every API call routes through a.api(), which prefixes the region's host, and the WebSocket client passes larkws.WithDomain — the SDK defaults to open.feishu.cn, so Lark long connections would otherwise fail to authenticate. Data source (internal/datasource/connector/feishu): Lark was half-supported: the client honoured a base_url override and a LarkBaseURL constant existed, but the connector registered only as "feishu" and the UI never exposed base_url, so Lark wikis could not be configured at all. Lark is now its own connector type. base_url is kept as an explicit override so data sources that pointed a "feishu" connector at open.larksuite.com keep working. Bugs found and fixed along the way: - Wiki resource links were hardcoded to https://feishu.cn/wiki/{token} in four places, so a Lark wiki linked into the wrong cloud entirely. - Bot identity is now platform-prefixed (feishu:cli_x vs lark:cli_x). BotIdentity carries a unique index, so without this a Lark channel would be rejected as a duplicate of an unrelated Feishu bot sharing an app_id. - The card image cache is now keyed by app_id. image_keys are issued per app on one cloud; a Feishu key handed to a Lark app fails the card update with code=200570. - The datasource doc claimed Lark "adapts automatically" — it could not, since the UI had no way to reach base_url. Permissions are where the clouds genuinely diverge. Identical APIs do not mean identical permission catalogues: aily:file:read, aily:file:write and corehr:file:download exist on Feishu but not on Lark, so pasting Feishu's scope JSON into Lark fails to import. That JSON is also over-broad (it covers products WeKnora never calls) and simultaneously incomplete (it omits the drive:*/docx:* scopes the connector needs). Lark therefore gets its own list, derived from the 17 endpoints the code actually calls and checked identifier-by-identifier against Lark's own API reference, plus a full Feishu→Lark mapping table marking each scope verified, unverified, or absent. Both features use tenant_access_token only — no user_access_ token exists anywhere in the repo — so the user dimension stays empty. Known limitation: Region.ThinkingText makes Lark's initial card placeholder English, but once streaming starts the think-block headers come from the shared MarkdownThinkStyle and are Chinese on every platform (Lark, Slack, Telegram, Mattermost alike). Fixing that means threading a locale through FormatIMDisplayContent, which has no platform context — a cross-platform change, not a Lark one. Documented as such. The channel-creation 400 message is now derived from validIMPlatforms so the list cannot drift as platforms are added. Tests: region invariants and per-region URL construction on both sides, the Feishu/Lark bot-identity split, platform-to-channel mapping, base_url override precedence, an end-to-end SendReply against a fake Open Platform (token fetch, reply, thread-unsupported fallback, hard-error surfacing), and a ListResources drive asserting wiki links never leak the other cloud's host. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
424 lines
13 KiB
Go
424 lines
13 KiB
Go
package handler
|
||
|
||
import (
|
||
"context"
|
||
"net/http"
|
||
"sort"
|
||
"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, "lark": true, "slack": true, "telegram": true, "dingtalk": true,
|
||
"mattermost": true, "wechat": true, "qqbot": true,
|
||
}
|
||
|
||
// invalidIMPlatformError is the 400 message listing the accepted platforms. It
|
||
// is derived from validIMPlatforms so the two cannot drift apart as platforms
|
||
// are added.
|
||
var invalidIMPlatformError = func() string {
|
||
names := make([]string, 0, len(validIMPlatforms))
|
||
for p := range validIMPlatforms {
|
||
names = append(names, "'"+p+"'")
|
||
}
|
||
sort.Strings(names)
|
||
return "platform must be one of: " + strings.Join(names, ", ")
|
||
}()
|
||
|
||
// 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": invalidIMPlatformError})
|
||
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": im.SummarizeIMChannels(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.
|
||
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.
|
||
//
|
||
// UpdateIMChannel godoc
|
||
// @Summary 更新 IM 渠道
|
||
// @Description 更新指定 IM 渠道的名称、模式、知识库、凭证或启用状态
|
||
// @Tags IM 渠道
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param id path string true "渠道 ID"
|
||
// @Param request body map[string]interface{} true "更新字段(name/mode/output_mode/knowledge_base_id/credentials/enabled)"
|
||
// @Success 200 {object} map[string]interface{} "更新后的渠道"
|
||
// @Failure 400 {object} map[string]interface{} "请求参数错误"
|
||
// @Failure 404 {object} map[string]interface{} "渠道不存在"
|
||
// @Security Bearer
|
||
// @Security ApiKeyAuth
|
||
// @Router /im-channels/{id} [put]
|
||
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"`
|
||
AgentID *string `json:"agent_id"`
|
||
}
|
||
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 req.AgentID != nil {
|
||
newAgentID := strings.TrimSpace(*req.AgentID)
|
||
if newAgentID != "" && newAgentID != channel.AgentID {
|
||
if err := h.imService.SetChannelAgentID(c.Request.Context(), channel, newAgentID); err != nil {
|
||
logger.Errorf(c.Request.Context(), "[IM] Update channel agent failed: %v", err)
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "agent not found"})
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
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.
|
||
//
|
||
// DeleteIMChannel godoc
|
||
// @Summary 删除 IM 渠道
|
||
// @Description 删除指定 IM 渠道
|
||
// @Tags IM 渠道
|
||
// @Produce json
|
||
// @Param id path string true "渠道 ID"
|
||
// @Success 200 {object} map[string]interface{} "success: true"
|
||
// @Failure 400 {object} map[string]interface{} "请求参数错误"
|
||
// @Failure 404 {object} map[string]interface{} "渠道不存在"
|
||
// @Security Bearer
|
||
// @Security ApiKeyAuth
|
||
// @Router /im-channels/{id} [delete]
|
||
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.
|
||
//
|
||
// ToggleIMChannel godoc
|
||
// @Summary 启用/停用 IM 渠道
|
||
// @Description 切换指定 IM 渠道的启用状态
|
||
// @Tags IM 渠道
|
||
// @Produce json
|
||
// @Param id path string true "渠道 ID"
|
||
// @Success 200 {object} map[string]interface{} "更新后的渠道"
|
||
// @Failure 400 {object} map[string]interface{} "请求参数错误"
|
||
// @Failure 404 {object} map[string]interface{} "渠道不存在"
|
||
// @Security Bearer
|
||
// @Security ApiKeyAuth
|
||
// @Router /im-channels/{id}/toggle [post]
|
||
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
|
||
//
|
||
// IMCallback godoc
|
||
// @Summary IM 平台回调
|
||
// @Description 接收各 IM 平台的事件回调;走平台自身签名校验,不使用 API Key
|
||
// @Tags IM 回调
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param channel_id path string true "渠道 ID"
|
||
// @Success 200 {object} map[string]interface{} "处理结果"
|
||
// @Failure 400 {object} map[string]interface{} "请求参数错误"
|
||
// @Failure 401 {object} map[string]interface{} "签名校验失败"
|
||
// @Router /im/callback/{channel_id} [get]
|
||
// @Router /im/callback/{channel_id} [post]
|
||
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)
|
||
}
|
||
}()
|
||
}
|