mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
feat(feishu): add webhook inbound mode, region support, and callback/attachment enhancements (#107)
This commit is contained in:
+6
-3
@@ -41,13 +41,13 @@ import (
|
||||
"github.com/memohai/memoh/internal/embeddings"
|
||||
"github.com/memohai/memoh/internal/handlers"
|
||||
"github.com/memohai/memoh/internal/healthcheck"
|
||||
"github.com/memohai/memoh/internal/inbox"
|
||||
channelchecker "github.com/memohai/memoh/internal/healthcheck/checkers/channel"
|
||||
mcpchecker "github.com/memohai/memoh/internal/healthcheck/checkers/mcp"
|
||||
"github.com/memohai/memoh/internal/inbox"
|
||||
"github.com/memohai/memoh/internal/logger"
|
||||
"github.com/memohai/memoh/internal/mcp"
|
||||
mcpcontainer "github.com/memohai/memoh/internal/mcp/providers/container"
|
||||
mcpcontacts "github.com/memohai/memoh/internal/mcp/providers/contacts"
|
||||
mcpcontainer "github.com/memohai/memoh/internal/mcp/providers/container"
|
||||
mcpinbox "github.com/memohai/memoh/internal/mcp/providers/inbox"
|
||||
mcpmemory "github.com/memohai/memoh/internal/mcp/providers/memory"
|
||||
mcpmessage "github.com/memohai/memoh/internal/mcp/providers/message"
|
||||
@@ -203,6 +203,7 @@ func runServe() {
|
||||
provideServerHandler(handlers.NewScheduleHandler),
|
||||
provideServerHandler(handlers.NewSubagentHandler),
|
||||
provideServerHandler(handlers.NewChannelHandler),
|
||||
provideServerHandler(feishu.NewWebhookServerHandler),
|
||||
provideServerHandler(provideUsersHandler),
|
||||
provideServerHandler(handlers.NewMCPHandler),
|
||||
provideServerHandler(handlers.NewInboxHandler),
|
||||
@@ -396,7 +397,9 @@ func provideChannelRegistry(log *slog.Logger, hub *local.RouteHub, mediaService
|
||||
tgAdapter.SetAssetOpener(mediaService)
|
||||
registry.MustRegister(tgAdapter)
|
||||
registry.MustRegister(discord.NewDiscordAdapter(log))
|
||||
registry.MustRegister(feishu.NewFeishuAdapter(log))
|
||||
feishuAdapter := feishu.NewFeishuAdapter(log)
|
||||
feishuAdapter.SetAssetOpener(mediaService)
|
||||
registry.MustRegister(feishuAdapter)
|
||||
registry.MustRegister(local.NewCLIAdapter(hub))
|
||||
registry.MustRegister(local.NewWebAdapter(hub))
|
||||
return registry
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package feishu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/memohai/memoh/internal/channel"
|
||||
)
|
||||
|
||||
func resolveConfiguredBotOpenID(cfg channel.ChannelConfig) string {
|
||||
if value := strings.TrimSpace(channel.ReadString(cfg.SelfIdentity, "open_id", "openId")); value != "" {
|
||||
return value
|
||||
}
|
||||
external := strings.TrimSpace(cfg.ExternalIdentity)
|
||||
if external == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(external, "open_id:") {
|
||||
return strings.TrimSpace(strings.TrimPrefix(external, "open_id:"))
|
||||
}
|
||||
// Legacy records may persist raw open_id without prefix.
|
||||
if !strings.Contains(external, ":") {
|
||||
return external
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *FeishuAdapter) resolveBotOpenID(ctx context.Context, cfg channel.ChannelConfig) string {
|
||||
if openID := resolveConfiguredBotOpenID(cfg); openID != "" {
|
||||
return openID
|
||||
}
|
||||
discovered, externalID, err := a.DiscoverSelf(ctx, cfg.Credentials)
|
||||
if err != nil {
|
||||
if a != nil && a.logger != nil {
|
||||
a.logger.Warn("discover self fallback failed", slog.String("config_id", cfg.ID), slog.Any("error", err))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if discoveredOpenID := strings.TrimSpace(channel.ReadString(discovered, "open_id", "openId")); discoveredOpenID != "" {
|
||||
return discoveredOpenID
|
||||
}
|
||||
return resolveConfiguredBotOpenID(channel.ChannelConfig{ExternalIdentity: externalID})
|
||||
}
|
||||
@@ -4,15 +4,27 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
|
||||
"github.com/memohai/memoh/internal/channel"
|
||||
)
|
||||
|
||||
const (
|
||||
regionFeishu = "feishu"
|
||||
regionLark = "lark"
|
||||
|
||||
inboundModeWebsocket = "websocket"
|
||||
inboundModeWebhook = "webhook"
|
||||
)
|
||||
|
||||
// Config holds the Feishu app credentials extracted from a channel configuration.
|
||||
type Config struct {
|
||||
AppID string
|
||||
AppSecret string
|
||||
EncryptKey string
|
||||
VerificationToken string
|
||||
Region string
|
||||
InboundMode string
|
||||
}
|
||||
|
||||
// UserConfig holds the identifiers used to target a Feishu user.
|
||||
@@ -29,6 +41,8 @@ func normalizeConfig(raw map[string]any) (map[string]any, error) {
|
||||
result := map[string]any{
|
||||
"appId": cfg.AppID,
|
||||
"appSecret": cfg.AppSecret,
|
||||
"region": cfg.Region,
|
||||
"inboundMode": cfg.InboundMode,
|
||||
}
|
||||
if cfg.EncryptKey != "" {
|
||||
result["encryptKey"] = cfg.EncryptKey
|
||||
@@ -103,6 +117,14 @@ func parseConfig(raw map[string]any) (Config, error) {
|
||||
appSecret := strings.TrimSpace(channel.ReadString(raw, "appSecret", "app_secret"))
|
||||
encryptKey := strings.TrimSpace(channel.ReadString(raw, "encryptKey", "encrypt_key"))
|
||||
verificationToken := strings.TrimSpace(channel.ReadString(raw, "verificationToken", "verification_token"))
|
||||
region, err := normalizeRegion(channel.ReadString(raw, "region"))
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
inboundMode, err := normalizeInboundMode(channel.ReadString(raw, "inboundMode", "inbound_mode"))
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if appID == "" || appSecret == "" {
|
||||
return Config{}, fmt.Errorf("feishu appId and appSecret are required")
|
||||
}
|
||||
@@ -111,6 +133,8 @@ func parseConfig(raw map[string]any) (Config, error) {
|
||||
AppSecret: appSecret,
|
||||
EncryptKey: encryptKey,
|
||||
VerificationToken: verificationToken,
|
||||
Region: region,
|
||||
InboundMode: inboundMode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -139,3 +163,32 @@ func normalizeTarget(raw string) string {
|
||||
}
|
||||
return "open_id:" + value
|
||||
}
|
||||
|
||||
func normalizeRegion(raw string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "", regionFeishu, "cn", "china":
|
||||
return regionFeishu, nil
|
||||
case regionLark, "global", "intl", "international":
|
||||
return regionLark, nil
|
||||
default:
|
||||
return "", fmt.Errorf("feishu region must be feishu or lark")
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeInboundMode(raw string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "", inboundModeWebsocket:
|
||||
return inboundModeWebsocket, nil
|
||||
case inboundModeWebhook:
|
||||
return inboundModeWebhook, nil
|
||||
default:
|
||||
return "", fmt.Errorf("feishu inbound_mode must be websocket or webhook")
|
||||
}
|
||||
}
|
||||
|
||||
func (c Config) openBaseURL() string {
|
||||
if c.Region == regionLark {
|
||||
return lark.LarkBaseUrl
|
||||
}
|
||||
return lark.FeishuBaseUrl
|
||||
}
|
||||
|
||||
@@ -20,6 +20,12 @@ func TestNormalizeConfig(t *testing.T) {
|
||||
if got["encryptKey"] != "enc" || got["verificationToken"] != "verify" {
|
||||
t.Fatalf("unexpected feishu security config: %#v", got)
|
||||
}
|
||||
if got["region"] != regionFeishu {
|
||||
t.Fatalf("unexpected default region: %#v", got["region"])
|
||||
}
|
||||
if got["inboundMode"] != inboundModeWebsocket {
|
||||
t.Fatalf("unexpected default inbound mode: %#v", got["inboundMode"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeConfigRequiresApp(t *testing.T) {
|
||||
@@ -31,6 +37,52 @@ func TestNormalizeConfigRequiresApp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeConfigSupportsLarkAndWebhook(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := normalizeConfig(map[string]any{
|
||||
"app_id": "app",
|
||||
"app_secret": "secret",
|
||||
"region": "lark",
|
||||
"inbound_mode": "webhook",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if got["region"] != regionLark {
|
||||
t.Fatalf("unexpected region: %#v", got["region"])
|
||||
}
|
||||
if got["inboundMode"] != inboundModeWebhook {
|
||||
t.Fatalf("unexpected inbound mode: %#v", got["inboundMode"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeConfigRejectsInvalidRegion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := normalizeConfig(map[string]any{
|
||||
"app_id": "app",
|
||||
"app_secret": "secret",
|
||||
"region": "unknown",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid region error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeConfigRejectsInvalidInboundMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := normalizeConfig(map[string]any{
|
||||
"app_id": "app",
|
||||
"app_secret": "secret",
|
||||
"inbound_mode": "invalid",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid inbound_mode error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeUserConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package feishu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/memohai/memoh/internal/channel"
|
||||
)
|
||||
|
||||
func TestConnectWebhookModeDoesNotStartWebsocket(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adapter := NewFeishuAdapter(nil)
|
||||
cfg := channel.ChannelConfig{
|
||||
ID: "cfg-1",
|
||||
BotID: "bot-1",
|
||||
ChannelType: Type,
|
||||
Credentials: map[string]any{
|
||||
"app_id": "app",
|
||||
"app_secret": "secret",
|
||||
"inbound_mode": "webhook",
|
||||
},
|
||||
}
|
||||
conn, err := adapter.Connect(context.Background(), cfg, func(ctx context.Context, cfg channel.ChannelConfig, msg channel.InboundMessage) error {
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if conn == nil {
|
||||
t.Fatal("expected non-nil connection")
|
||||
}
|
||||
if !conn.Running() {
|
||||
t.Fatal("expected connection to be running")
|
||||
}
|
||||
if err := conn.Stop(context.Background()); err != nil {
|
||||
t.Fatalf("expected stop to succeed, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ func (a *FeishuAdapter) ListPeers(ctx context.Context, cfg channel.ChannelConfig
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client := lark.NewClient(feishuCfg.AppID, feishuCfg.AppSecret)
|
||||
client := lark.NewClient(feishuCfg.AppID, feishuCfg.AppSecret, lark.WithOpenBaseUrl(feishuCfg.openBaseURL()))
|
||||
pageSize := directoryLimit(query.Limit)
|
||||
req := larkcontact.NewListUserReqBuilder().
|
||||
UserIdType(larkcontact.UserIdTypeOpenId).
|
||||
@@ -65,7 +65,7 @@ func (a *FeishuAdapter) ListGroups(ctx context.Context, cfg channel.ChannelConfi
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client := lark.NewClient(feishuCfg.AppID, feishuCfg.AppSecret)
|
||||
client := lark.NewClient(feishuCfg.AppID, feishuCfg.AppSecret, lark.WithOpenBaseUrl(feishuCfg.openBaseURL()))
|
||||
pageSize := directoryLimit(query.Limit)
|
||||
var items []*larkim.ListChat
|
||||
if strings.TrimSpace(query.Query) != "" {
|
||||
@@ -116,7 +116,7 @@ func (a *FeishuAdapter) ListGroupMembers(ctx context.Context, cfg channel.Channe
|
||||
if chatID == "" {
|
||||
return nil, fmt.Errorf("feishu list group members: empty group id")
|
||||
}
|
||||
client := lark.NewClient(feishuCfg.AppID, feishuCfg.AppSecret)
|
||||
client := lark.NewClient(feishuCfg.AppID, feishuCfg.AppSecret, lark.WithOpenBaseUrl(feishuCfg.openBaseURL()))
|
||||
pageSize := directoryLimit(query.Limit)
|
||||
req := larkim.NewGetChatMembersReqBuilder().
|
||||
ChatId(chatID).
|
||||
@@ -147,7 +147,7 @@ func (a *FeishuAdapter) ResolveEntry(ctx context.Context, cfg channel.ChannelCon
|
||||
if err != nil {
|
||||
return channel.DirectoryEntry{}, err
|
||||
}
|
||||
client := lark.NewClient(feishuCfg.AppID, feishuCfg.AppSecret)
|
||||
client := lark.NewClient(feishuCfg.AppID, feishuCfg.AppSecret, lark.WithOpenBaseUrl(feishuCfg.openBaseURL()))
|
||||
input = strings.TrimSpace(input)
|
||||
switch kind {
|
||||
case channel.DirectoryEntryUser:
|
||||
@@ -226,7 +226,9 @@ func parseFeishuUserInput(raw string) (userID, userIDType string) {
|
||||
if strings.HasPrefix(raw, "u_") || strings.HasPrefix(raw, "u-") {
|
||||
return raw, larkcontact.UserIdTypeUserId
|
||||
}
|
||||
return raw, larkcontact.UserIdTypeOpenId
|
||||
// For raw IDs without explicit prefix, default to user_id. In practice
|
||||
// open_id is usually "ou_*", while bare IDs are commonly user_id.
|
||||
return raw, larkcontact.UserIdTypeUserId
|
||||
}
|
||||
|
||||
func feishuUserToEntry(u *larkcontact.User) channel.DirectoryEntry {
|
||||
|
||||
@@ -38,6 +38,7 @@ func Test_parseFeishuUserInput(t *testing.T) {
|
||||
{"user_id:u_yyy", "u_yyy", larkcontact.UserIdTypeUserId},
|
||||
{"ou_abc", "ou_abc", larkcontact.UserIdTypeOpenId},
|
||||
{"u_123", "u_123", larkcontact.UserIdTypeUserId},
|
||||
{"b3f195f9", "b3f195f9", larkcontact.UserIdTypeUserId},
|
||||
{" open_id: ou_zzz ", "ou_zzz", larkcontact.UserIdTypeOpenId},
|
||||
{"", "", ""},
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package feishu
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -17,14 +18,20 @@ import (
|
||||
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
||||
larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
|
||||
|
||||
attachmentpkg "github.com/memohai/memoh/internal/attachment"
|
||||
"github.com/memohai/memoh/internal/channel"
|
||||
"github.com/memohai/memoh/internal/channel/adapters/common"
|
||||
"github.com/memohai/memoh/internal/media"
|
||||
)
|
||||
|
||||
type assetOpener interface {
|
||||
Open(ctx context.Context, botID, contentHash string) (io.ReadCloser, media.Asset, error)
|
||||
}
|
||||
|
||||
// FeishuAdapter implements the channel.Adapter, channel.Sender, and channel.Receiver interfaces for Feishu.
|
||||
type FeishuAdapter struct {
|
||||
logger *slog.Logger
|
||||
assets assetOpener
|
||||
}
|
||||
|
||||
const processingBusyReactionType = "Typing"
|
||||
@@ -106,6 +113,11 @@ func NewFeishuAdapter(log *slog.Logger) *FeishuAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
// SetAssetOpener injects media asset reader for content_hash attachment delivery.
|
||||
func (a *FeishuAdapter) SetAssetOpener(opener assetOpener) {
|
||||
a.assets = opener
|
||||
}
|
||||
|
||||
// Type returns the Feishu channel type.
|
||||
func (a *FeishuAdapter) Type() channel.ChannelType {
|
||||
return Type
|
||||
@@ -127,7 +139,7 @@ func (a *FeishuAdapter) Descriptor() channel.Descriptor {
|
||||
BlockStreaming: true,
|
||||
},
|
||||
ConfigSchema: channel.ConfigSchema{
|
||||
Version: 1,
|
||||
Version: 2,
|
||||
Fields: map[string]channel.FieldSchema{
|
||||
"appId": {Type: channel.FieldString, Required: true, Title: "App ID"},
|
||||
"appSecret": {Type: channel.FieldSecret, Required: true, Title: "App Secret"},
|
||||
@@ -139,6 +151,20 @@ func (a *FeishuAdapter) Descriptor() channel.Descriptor {
|
||||
Type: channel.FieldSecret,
|
||||
Title: "Verification Token",
|
||||
},
|
||||
"region": {
|
||||
Type: channel.FieldEnum,
|
||||
Title: "Region",
|
||||
Description: "API endpoint region: feishu.cn or larksuite.com",
|
||||
Enum: []string{regionFeishu, regionLark},
|
||||
Example: regionFeishu,
|
||||
},
|
||||
"inboundMode": {
|
||||
Type: channel.FieldEnum,
|
||||
Title: "Inbound Mode",
|
||||
Description: "Choose websocket long-connection or webhook callback for inbound messages",
|
||||
Enum: []string{inboundModeWebsocket, inboundModeWebhook},
|
||||
Example: inboundModeWebsocket,
|
||||
},
|
||||
},
|
||||
},
|
||||
UserConfigSchema: channel.ConfigSchema{
|
||||
@@ -200,7 +226,7 @@ func (a *FeishuAdapter) processingReactionGateway(cfg channel.ChannelConfig) (pr
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client := lark.NewClient(feishuCfg.AppID, feishuCfg.AppSecret)
|
||||
client := lark.NewClient(feishuCfg.AppID, feishuCfg.AppSecret, lark.WithOpenBaseUrl(feishuCfg.openBaseURL()))
|
||||
gateway := &larkProcessingReactionGateway{api: client.Im.V1.MessageReaction}
|
||||
return gateway, nil
|
||||
}
|
||||
@@ -264,7 +290,7 @@ func (a *FeishuAdapter) DiscoverSelf(ctx context.Context, credentials map[string
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
client := lark.NewClient(cfg.AppID, cfg.AppSecret)
|
||||
client := lark.NewClient(cfg.AppID, cfg.AppSecret, lark.WithOpenBaseUrl(cfg.openBaseURL()))
|
||||
resp, err := client.Get(ctx, "/open-apis/bot/v3/info", nil, larkcore.AccessTokenTypeTenant)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("feishu discover self: %w", err)
|
||||
@@ -342,16 +368,13 @@ func (a *FeishuAdapter) Connect(ctx context.Context, cfg channel.ChannelConfig,
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
botOpenID := channel.ReadString(cfg.SelfIdentity, "open_id")
|
||||
if botOpenID == "" {
|
||||
if discovered, _, err := a.DiscoverSelf(ctx, cfg.Credentials); err == nil {
|
||||
if id, ok := discovered["open_id"].(string); ok {
|
||||
botOpenID = strings.TrimSpace(id)
|
||||
}
|
||||
} else if a.logger != nil {
|
||||
a.logger.Warn("discover self fallback failed", slog.String("config_id", cfg.ID), slog.Any("error", err))
|
||||
if feishuCfg.InboundMode == inboundModeWebhook {
|
||||
if a.logger != nil {
|
||||
a.logger.Info("webhook mode enabled; websocket connect skipped", slog.String("config_id", cfg.ID))
|
||||
}
|
||||
return channel.NewConnection(cfg, func(context.Context) error { return nil }), nil
|
||||
}
|
||||
botOpenID := a.resolveBotOpenID(ctx, cfg)
|
||||
if a.logger != nil {
|
||||
a.logger.Info("bot identity", slog.String("config_id", cfg.ID), slog.String("bot_open_id", botOpenID))
|
||||
}
|
||||
@@ -403,6 +426,7 @@ func (a *FeishuAdapter) Connect(ctx context.Context, cfg channel.ChannelConfig,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
a.enrichSenderProfile(connCtx, cfg, event, &msg)
|
||||
msg.BotID = cfg.BotID
|
||||
if a.logger != nil {
|
||||
isMentioned := false
|
||||
@@ -444,6 +468,7 @@ func (a *FeishuAdapter) Connect(ctx context.Context, cfg channel.ChannelConfig,
|
||||
feishuCfg.AppID,
|
||||
feishuCfg.AppSecret,
|
||||
larkws.WithEventHandler(eventDispatcher),
|
||||
larkws.WithDomain(feishuCfg.openBaseURL()),
|
||||
larkws.WithLogger(newLarkSlogLogger(a.logger)),
|
||||
larkws.WithLogLevel(larkcore.LogLevelDebug),
|
||||
)
|
||||
@@ -499,11 +524,11 @@ func (a *FeishuAdapter) Send(ctx context.Context, cfg channel.ChannelConfig, msg
|
||||
return err
|
||||
}
|
||||
|
||||
client := lark.NewClient(feishuCfg.AppID, feishuCfg.AppSecret)
|
||||
client := lark.NewClient(feishuCfg.AppID, feishuCfg.AppSecret, lark.WithOpenBaseUrl(feishuCfg.openBaseURL()))
|
||||
|
||||
if len(msg.Message.Attachments) > 0 {
|
||||
for _, att := range msg.Message.Attachments {
|
||||
if err := a.sendAttachment(ctx, client, receiveID, receiveType, att, msg.Message.Text); err != nil {
|
||||
if err := a.sendAttachment(ctx, client, receiveID, receiveType, cfg.BotID, att); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -576,7 +601,7 @@ func (a *FeishuAdapter) OpenStream(ctx context.Context, cfg channel.ChannelConfi
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client := lark.NewClient(feishuCfg.AppID, feishuCfg.AppSecret)
|
||||
client := lark.NewClient(feishuCfg.AppID, feishuCfg.AppSecret, lark.WithOpenBaseUrl(feishuCfg.openBaseURL()))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
@@ -644,7 +669,7 @@ func (a *FeishuAdapter) handleResponse(configID string, resp *larkim.CreateMessa
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *FeishuAdapter) sendAttachment(ctx context.Context, client *lark.Client, receiveID, receiveType string, att channel.Attachment, text string) error {
|
||||
func (a *FeishuAdapter) sendAttachment(ctx context.Context, client *lark.Client, receiveID, receiveType, botID string, att channel.Attachment) error {
|
||||
var msgType string
|
||||
var contentMap map[string]string
|
||||
sourcePlatform := strings.TrimSpace(att.SourcePlatform)
|
||||
@@ -658,32 +683,22 @@ func (a *FeishuAdapter) sendAttachment(ctx context.Context, client *lark.Client,
|
||||
contentMap = map[string]string{"file_key": platformKey}
|
||||
}
|
||||
} else {
|
||||
downloadURL := strings.TrimSpace(att.URL)
|
||||
if downloadURL == "" {
|
||||
return fmt.Errorf("failed to download attachment: url is required when platform key is unavailable")
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
|
||||
reader, resolvedMime, resolvedName, err := a.resolveAttachmentUploadReader(ctx, att, botID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to build download request: %w", err)
|
||||
return err
|
||||
}
|
||||
httpClient := &http.Client{Timeout: 60 * time.Second}
|
||||
resp, err := httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download attachment: %w", err)
|
||||
defer func() {
|
||||
_ = reader.Close()
|
||||
}()
|
||||
typeProbe := att
|
||||
if strings.TrimSpace(typeProbe.Mime) == "" {
|
||||
typeProbe.Mime = strings.TrimSpace(resolvedMime)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("failed to download attachment, status: %d", resp.StatusCode)
|
||||
}
|
||||
maxBytes := media.MaxAssetBytes
|
||||
if resp.ContentLength > maxBytes {
|
||||
return fmt.Errorf("%w: max %d bytes", media.ErrAssetTooLarge, maxBytes)
|
||||
}
|
||||
if strings.HasPrefix(att.Mime, "image/") || att.Type == channel.AttachmentImage {
|
||||
if isFeishuImageAttachment(typeProbe) {
|
||||
uploadReq := larkim.NewCreateImageReqBuilder().
|
||||
Body(larkim.NewCreateImageReqBodyBuilder().
|
||||
ImageType(larkim.ImageTypeMessage).
|
||||
Image(resp.Body).
|
||||
Image(reader).
|
||||
Build()).
|
||||
Build()
|
||||
uploadResp, err := client.Im.V1.Image.Create(ctx, uploadReq)
|
||||
@@ -700,8 +715,8 @@ func (a *FeishuAdapter) sendAttachment(ctx context.Context, client *lark.Client,
|
||||
msgType = larkim.MsgTypeImage
|
||||
contentMap = map[string]string{"image_key": *uploadResp.Data.ImageKey}
|
||||
} else {
|
||||
fileType := resolveFeishuFileType(att.Name, att.Mime)
|
||||
fileName := strings.TrimSpace(att.Name)
|
||||
fileType := resolveFeishuFileType(resolvedName, resolvedMime)
|
||||
fileName := strings.TrimSpace(resolvedName)
|
||||
if fileName == "" {
|
||||
fileName = "attachment"
|
||||
}
|
||||
@@ -709,7 +724,7 @@ func (a *FeishuAdapter) sendAttachment(ctx context.Context, client *lark.Client,
|
||||
Body(larkim.NewCreateFileReqBodyBuilder().
|
||||
FileType(fileType).
|
||||
FileName(fileName).
|
||||
File(resp.Body).
|
||||
File(reader).
|
||||
Build()).
|
||||
Build()
|
||||
uploadResp, err := client.Im.V1.File.Create(ctx, uploadReq)
|
||||
@@ -746,6 +761,76 @@ func (a *FeishuAdapter) sendAttachment(ctx context.Context, client *lark.Client,
|
||||
return a.handleResponse("", sendResp, err)
|
||||
}
|
||||
|
||||
func (a *FeishuAdapter) resolveAttachmentUploadReader(ctx context.Context, att channel.Attachment, fallbackBotID string) (io.ReadCloser, string, string, error) {
|
||||
assetID := strings.TrimSpace(att.ContentHash)
|
||||
botID := strings.TrimSpace(fallbackBotID)
|
||||
if botID == "" && att.Metadata != nil {
|
||||
if value, ok := att.Metadata["bot_id"].(string); ok {
|
||||
botID = strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
if assetID != "" && botID != "" && a.assets != nil {
|
||||
reader, asset, err := a.assets.Open(ctx, botID, assetID)
|
||||
if err == nil {
|
||||
resolvedMime := strings.TrimSpace(att.Mime)
|
||||
if resolvedMime == "" {
|
||||
resolvedMime = strings.TrimSpace(asset.Mime)
|
||||
}
|
||||
return reader, resolvedMime, strings.TrimSpace(att.Name), nil
|
||||
}
|
||||
if a.logger != nil {
|
||||
a.logger.Debug("feishu attachment storage open failed",
|
||||
slog.String("bot_id", botID),
|
||||
slog.String("content_hash", assetID),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
rawBase64 := strings.TrimSpace(att.Base64)
|
||||
downloadURL := strings.TrimSpace(att.URL)
|
||||
if rawBase64 == "" && strings.HasPrefix(strings.ToLower(downloadURL), "data:") {
|
||||
rawBase64 = downloadURL
|
||||
}
|
||||
if rawBase64 != "" {
|
||||
decoded, err := attachmentpkg.DecodeBase64(rawBase64, media.MaxAssetBytes)
|
||||
if err != nil {
|
||||
return nil, "", "", fmt.Errorf("failed to decode attachment base64: %w", err)
|
||||
}
|
||||
data, err := media.ReadAllWithLimit(decoded, media.MaxAssetBytes)
|
||||
if err != nil {
|
||||
return nil, "", "", fmt.Errorf("failed to read attachment base64: %w", err)
|
||||
}
|
||||
resolvedMime := strings.TrimSpace(att.Mime)
|
||||
if resolvedMime == "" {
|
||||
resolvedMime = strings.TrimSpace(attachmentpkg.MimeFromDataURL(rawBase64))
|
||||
}
|
||||
return io.NopCloser(bytes.NewReader(data)), resolvedMime, strings.TrimSpace(att.Name), nil
|
||||
}
|
||||
|
||||
if downloadURL == "" {
|
||||
return nil, "", "", fmt.Errorf("attachment reference is required: provide platform_key/content_hash/base64/url")
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
|
||||
if err != nil {
|
||||
return nil, "", "", fmt.Errorf("failed to build download request: %w", err)
|
||||
}
|
||||
httpClient := &http.Client{Timeout: 60 * time.Second}
|
||||
resp, err := httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, "", "", fmt.Errorf("failed to download attachment: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_ = resp.Body.Close()
|
||||
return nil, "", "", fmt.Errorf("failed to download attachment, status: %d", resp.StatusCode)
|
||||
}
|
||||
if resp.ContentLength > media.MaxAssetBytes {
|
||||
_ = resp.Body.Close()
|
||||
return nil, "", "", fmt.Errorf("%w: max %d bytes", media.ErrAssetTooLarge, media.MaxAssetBytes)
|
||||
}
|
||||
return resp.Body, strings.TrimSpace(att.Mime), strings.TrimSpace(att.Name), nil
|
||||
}
|
||||
|
||||
// ResolveAttachment resolves a Feishu attachment reference to a byte stream.
|
||||
// User-sent resources must be fetched via the message-resource API which
|
||||
// requires both message_id and file_key. The message_id is expected in
|
||||
@@ -768,7 +853,7 @@ func (a *FeishuAdapter) ResolveAttachment(ctx context.Context, cfg channel.Chann
|
||||
if err != nil {
|
||||
return channel.AttachmentPayload{}, err
|
||||
}
|
||||
client := lark.NewClient(feishuCfg.AppID, feishuCfg.AppSecret)
|
||||
client := lark.NewClient(feishuCfg.AppID, feishuCfg.AppSecret, lark.WithOpenBaseUrl(feishuCfg.openBaseURL()))
|
||||
|
||||
resourceType := "file"
|
||||
if isFeishuImageAttachment(attachment) {
|
||||
@@ -819,21 +904,24 @@ func isFeishuImageAttachment(att channel.Attachment) bool {
|
||||
// resolveFeishuFileType maps MIME type and filename to a Feishu file type constant.
|
||||
func resolveFeishuFileType(name, mime string) string {
|
||||
lower := strings.ToLower(mime)
|
||||
lowerName := strings.ToLower(strings.TrimSpace(name))
|
||||
switch {
|
||||
case strings.Contains(lower, "mp4") || strings.Contains(lower, "video"):
|
||||
return larkim.FileTypeMp4
|
||||
case strings.Contains(lower, "pdf"):
|
||||
return larkim.FileTypePdf
|
||||
case strings.Contains(lower, "word") || strings.Contains(lower, "msword") || strings.HasSuffix(strings.ToLower(name), ".doc") || strings.HasSuffix(strings.ToLower(name), ".docx"):
|
||||
case strings.Contains(lower, "word") || strings.Contains(lower, "msword") || strings.HasSuffix(lowerName, ".doc") || strings.HasSuffix(lowerName, ".docx"):
|
||||
return larkim.FileTypeDoc
|
||||
case strings.Contains(lower, "excel") || strings.Contains(lower, "spreadsheet") || strings.HasSuffix(strings.ToLower(name), ".xls") || strings.HasSuffix(strings.ToLower(name), ".xlsx"):
|
||||
case strings.Contains(lower, "excel") || strings.Contains(lower, "spreadsheet") || strings.HasSuffix(lowerName, ".xls") || strings.HasSuffix(lowerName, ".xlsx"):
|
||||
return larkim.FileTypeXls
|
||||
case strings.Contains(lower, "powerpoint") || strings.Contains(lower, "presentation") || strings.HasSuffix(strings.ToLower(name), ".ppt") || strings.HasSuffix(strings.ToLower(name), ".pptx"):
|
||||
case strings.Contains(lower, "powerpoint") || strings.Contains(lower, "presentation") || strings.HasSuffix(lowerName, ".ppt") || strings.HasSuffix(lowerName, ".pptx"):
|
||||
return larkim.FileTypePpt
|
||||
case strings.Contains(lower, "zip") || strings.Contains(lower, "compressed") || strings.Contains(lower, "archive"):
|
||||
return "zip"
|
||||
return larkim.FileTypeStream
|
||||
case strings.HasSuffix(lowerName, ".zip") || strings.HasSuffix(lowerName, ".tar") || strings.HasSuffix(lowerName, ".tgz") || strings.HasSuffix(lowerName, ".tar.gz") || strings.HasSuffix(lowerName, ".rar") || strings.HasSuffix(lowerName, ".7z") || strings.HasSuffix(lowerName, ".gz") || strings.HasSuffix(lowerName, ".bz2") || strings.HasSuffix(lowerName, ".xz"):
|
||||
return larkim.FileTypeStream
|
||||
default:
|
||||
return larkim.FileTypePdf
|
||||
return larkim.FileTypeStream
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -294,6 +294,34 @@ func TestIsFeishuImageAttachment(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFeishuFileType(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
file string
|
||||
mime string
|
||||
want string
|
||||
}{
|
||||
{name: "video mime", file: "clip.bin", mime: "video/mp4", want: larkim.FileTypeMp4},
|
||||
{name: "pdf mime", file: "doc.bin", mime: "application/pdf", want: larkim.FileTypePdf},
|
||||
{name: "doc ext", file: "a.docx", mime: "application/octet-stream", want: larkim.FileTypeDoc},
|
||||
{name: "xls ext", file: "a.xlsx", mime: "application/octet-stream", want: larkim.FileTypeXls},
|
||||
{name: "ppt ext", file: "a.pptx", mime: "application/octet-stream", want: larkim.FileTypePpt},
|
||||
{name: "zip mime", file: "a.bin", mime: "application/zip", want: larkim.FileTypeStream},
|
||||
{name: "tar gz ext", file: "backup.tar.gz", mime: "application/octet-stream", want: larkim.FileTypeStream},
|
||||
{name: "default stream", file: "notes.txt", mime: "text/plain", want: larkim.FileTypeStream},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := resolveFeishuFileType(tc.file, tc.mime); got != tc.want {
|
||||
t.Fatalf("resolveFeishuFileType(%q,%q)=%q want=%q", tc.file, tc.mime, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFeishuStreamCardContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -560,6 +588,38 @@ func TestExtractFeishuInboundPostMentionOtherIgnored(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfiguredBotOpenIDPrefersSelfIdentity(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := channel.ChannelConfig{
|
||||
SelfIdentity: map[string]any{
|
||||
"open_id": "ou_self_1",
|
||||
},
|
||||
ExternalIdentity: "open_id:ou_external_1",
|
||||
}
|
||||
if got := resolveConfiguredBotOpenID(cfg); got != "ou_self_1" {
|
||||
t.Fatalf("expected self identity open_id, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfiguredBotOpenIDFromExternalIdentity(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := channel.ChannelConfig{ExternalIdentity: "open_id:ou_external_2"}
|
||||
if got := resolveConfiguredBotOpenID(cfg); got != "ou_external_2" {
|
||||
t.Fatalf("expected external identity open_id, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfiguredBotOpenIDIgnoresNonOpenIDExternalIdentity(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := channel.ChannelConfig{ExternalIdentity: "chat_id:oc_group_1"}
|
||||
if got := resolveConfiguredBotOpenID(cfg); got != "" {
|
||||
t.Fatalf("expected empty open_id for non-open external identity, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddProcessingReactionFirstSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
package feishu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcontact "github.com/larksuite/oapi-sdk-go/v3/service/contact/v3"
|
||||
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
||||
|
||||
"github.com/memohai/memoh/internal/channel"
|
||||
)
|
||||
|
||||
type feishuSenderProfile struct {
|
||||
displayName string
|
||||
username string
|
||||
}
|
||||
|
||||
const feishuChatMembersPageSize = 100
|
||||
|
||||
// enrichSenderProfile fills sender display name / username for inbound messages.
|
||||
// It first tries Contact.User.Get (open_id/user_id), then falls back to group member
|
||||
// lookup when permissions are limited.
|
||||
func (a *FeishuAdapter) enrichSenderProfile(ctx context.Context, cfg channel.ChannelConfig, event *larkim.P2MessageReceiveV1, msg *channel.InboundMessage) {
|
||||
if msg == nil {
|
||||
return
|
||||
}
|
||||
needDisplay := strings.TrimSpace(msg.Sender.DisplayName) == "" &&
|
||||
strings.TrimSpace(msg.Sender.Attribute("display_name")) == "" &&
|
||||
strings.TrimSpace(msg.Sender.Attribute("name")) == ""
|
||||
needUsername := strings.TrimSpace(msg.Sender.Attribute("username")) == ""
|
||||
if !needDisplay && !needUsername {
|
||||
return
|
||||
}
|
||||
|
||||
openID := strings.TrimSpace(msg.Sender.Attribute("open_id"))
|
||||
userID := strings.TrimSpace(msg.Sender.Attribute("user_id"))
|
||||
if openID == "" && userID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
chatID := ""
|
||||
if event != nil && event.Event != nil && event.Event.Message != nil && event.Event.Message.ChatId != nil {
|
||||
chatID = strings.TrimSpace(*event.Event.Message.ChatId)
|
||||
}
|
||||
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
lookupCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
profile, err := a.lookupSenderProfile(lookupCtx, cfg, openID, userID, chatID)
|
||||
if err != nil {
|
||||
if a.logger != nil {
|
||||
a.logger.Debug("feishu sender profile lookup failed",
|
||||
"config_id", cfg.ID,
|
||||
"open_id", openID,
|
||||
"user_id", userID,
|
||||
"chat_id", chatID,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(profile.displayName) == "" && strings.TrimSpace(profile.username) == "" {
|
||||
profile = fallbackSenderProfile(openID, userID)
|
||||
}
|
||||
applySenderProfile(msg, profile)
|
||||
}
|
||||
|
||||
func (a *FeishuAdapter) lookupSenderProfile(ctx context.Context, cfg channel.ChannelConfig, openID, userID, chatID string) (feishuSenderProfile, error) {
|
||||
feishuCfg, err := parseConfig(cfg.Credentials)
|
||||
if err != nil {
|
||||
return feishuSenderProfile{}, err
|
||||
}
|
||||
client := lark.NewClient(feishuCfg.AppID, feishuCfg.AppSecret, lark.WithOpenBaseUrl(feishuCfg.openBaseURL()))
|
||||
|
||||
var lastErr error
|
||||
chatID = strings.TrimSpace(chatID)
|
||||
if strings.HasPrefix(chatID, "chat_id:") {
|
||||
chatID = strings.TrimPrefix(chatID, "chat_id:")
|
||||
}
|
||||
|
||||
// Group scene: chat members has the highest chance to return a human-readable name.
|
||||
if chatID != "" && openID != "" {
|
||||
if profile, err := lookupSenderProfileFromGroupMember(ctx, client, chatID, "open_id", openID); err == nil {
|
||||
if strings.TrimSpace(profile.displayName) != "" || strings.TrimSpace(profile.username) != "" {
|
||||
return profile, nil
|
||||
}
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
if chatID != "" && userID != "" {
|
||||
if profile, err := lookupSenderProfileFromGroupMember(ctx, client, chatID, "user_id", userID); err == nil {
|
||||
if strings.TrimSpace(profile.displayName) != "" || strings.TrimSpace(profile.username) != "" {
|
||||
return profile, nil
|
||||
}
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
|
||||
if profile, err := lookupSenderProfileFromContact(ctx, client, openID, userID); err == nil {
|
||||
if strings.TrimSpace(profile.displayName) != "" || strings.TrimSpace(profile.username) != "" {
|
||||
return profile, nil
|
||||
}
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
return feishuSenderProfile{}, lastErr
|
||||
}
|
||||
return feishuSenderProfile{}, nil
|
||||
}
|
||||
|
||||
func lookupSenderProfileFromContact(ctx context.Context, client *lark.Client, openID, userID string) (feishuSenderProfile, error) {
|
||||
lookupID := strings.TrimSpace(openID)
|
||||
idType := larkcontact.UserIdTypeOpenId
|
||||
if lookupID == "" {
|
||||
lookupID = strings.TrimSpace(userID)
|
||||
idType = larkcontact.UserIdTypeUserId
|
||||
}
|
||||
if lookupID == "" {
|
||||
return feishuSenderProfile{}, fmt.Errorf("empty sender id")
|
||||
}
|
||||
req := larkcontact.NewGetUserReqBuilder().
|
||||
UserIdType(idType).
|
||||
UserId(lookupID).
|
||||
Build()
|
||||
resp, err := client.Contact.User.Get(ctx, req)
|
||||
if err != nil {
|
||||
return feishuSenderProfile{}, err
|
||||
}
|
||||
if resp == nil || !resp.Success() {
|
||||
code := 0
|
||||
msg := ""
|
||||
if resp != nil {
|
||||
code = resp.Code
|
||||
msg = strings.TrimSpace(resp.Msg)
|
||||
}
|
||||
return feishuSenderProfile{}, fmt.Errorf("feishu get user failed: code=%d msg=%s", code, msg)
|
||||
}
|
||||
if resp.Data == nil || resp.Data.User == nil {
|
||||
return feishuSenderProfile{}, fmt.Errorf("feishu get user returned empty user")
|
||||
}
|
||||
displayName := ptrStr(resp.Data.User.Name)
|
||||
username := ptrStr(resp.Data.User.Nickname)
|
||||
if username == "" {
|
||||
username = displayName
|
||||
}
|
||||
return feishuSenderProfile{
|
||||
displayName: displayName,
|
||||
username: username,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func lookupSenderProfileFromGroupMember(ctx context.Context, client *lark.Client, chatID, memberIDType, memberID string) (feishuSenderProfile, error) {
|
||||
memberIDType = strings.TrimSpace(memberIDType)
|
||||
memberID = strings.TrimSpace(memberID)
|
||||
if memberIDType == "" || memberID == "" {
|
||||
return feishuSenderProfile{}, fmt.Errorf("empty member lookup input")
|
||||
}
|
||||
pageToken := ""
|
||||
for page := 0; page < 5; page++ {
|
||||
builder := larkim.NewGetChatMembersReqBuilder().
|
||||
ChatId(chatID).
|
||||
MemberIdType(memberIDType).
|
||||
PageSize(feishuChatMembersPageSize)
|
||||
if pageToken != "" {
|
||||
builder = builder.PageToken(pageToken)
|
||||
}
|
||||
resp, err := client.Im.ChatMembers.Get(ctx, builder.Build())
|
||||
if err != nil {
|
||||
return feishuSenderProfile{}, err
|
||||
}
|
||||
if resp == nil || !resp.Success() {
|
||||
code := 0
|
||||
msg := ""
|
||||
if resp != nil {
|
||||
code = resp.Code
|
||||
msg = strings.TrimSpace(resp.Msg)
|
||||
}
|
||||
return feishuSenderProfile{}, fmt.Errorf("feishu get chat members failed: code=%d msg=%s", code, msg)
|
||||
}
|
||||
if resp.Data == nil {
|
||||
return feishuSenderProfile{}, nil
|
||||
}
|
||||
for _, item := range resp.Data.Items {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(ptrStr(item.MemberId)) != memberID {
|
||||
continue
|
||||
}
|
||||
name := ptrStr(item.Name)
|
||||
username := firstNameFallback(name)
|
||||
if username == "" {
|
||||
username = name
|
||||
}
|
||||
return feishuSenderProfile{
|
||||
displayName: name,
|
||||
username: username,
|
||||
}, nil
|
||||
}
|
||||
hasMore := resp.Data.HasMore != nil && *resp.Data.HasMore
|
||||
if !hasMore || resp.Data.PageToken == nil {
|
||||
break
|
||||
}
|
||||
pageToken = strings.TrimSpace(*resp.Data.PageToken)
|
||||
if pageToken == "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
return feishuSenderProfile{}, nil
|
||||
}
|
||||
|
||||
func fallbackSenderProfile(openID, userID string) feishuSenderProfile {
|
||||
openID = strings.TrimSpace(openID)
|
||||
userID = strings.TrimSpace(userID)
|
||||
username := userID
|
||||
if username == "" {
|
||||
username = openID
|
||||
}
|
||||
if username == "" {
|
||||
return feishuSenderProfile{}
|
||||
}
|
||||
displayName := username
|
||||
return feishuSenderProfile{
|
||||
displayName: displayName,
|
||||
username: username,
|
||||
}
|
||||
}
|
||||
|
||||
func firstNameFallback(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Fields(name)
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(parts[0])
|
||||
}
|
||||
|
||||
func applySenderProfile(msg *channel.InboundMessage, profile feishuSenderProfile) {
|
||||
if msg == nil {
|
||||
return
|
||||
}
|
||||
displayName := strings.TrimSpace(profile.displayName)
|
||||
username := strings.TrimSpace(profile.username)
|
||||
if username == "" {
|
||||
username = displayName
|
||||
}
|
||||
if msg.Sender.Attributes == nil {
|
||||
msg.Sender.Attributes = map[string]string{}
|
||||
}
|
||||
if displayName != "" {
|
||||
if strings.TrimSpace(msg.Sender.DisplayName) == "" {
|
||||
msg.Sender.DisplayName = displayName
|
||||
}
|
||||
if strings.TrimSpace(msg.Sender.Attributes["display_name"]) == "" {
|
||||
msg.Sender.Attributes["display_name"] = displayName
|
||||
}
|
||||
if strings.TrimSpace(msg.Sender.Attributes["name"]) == "" {
|
||||
msg.Sender.Attributes["name"] = displayName
|
||||
}
|
||||
}
|
||||
if username != "" && strings.TrimSpace(msg.Sender.Attributes["username"]) == "" {
|
||||
msg.Sender.Attributes["username"] = username
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package feishu
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/memohai/memoh/internal/channel"
|
||||
)
|
||||
|
||||
func TestApplySenderProfileFillDisplayAndUsername(t *testing.T) {
|
||||
msg := &channel.InboundMessage{
|
||||
Sender: channel.Identity{
|
||||
SubjectID: "ou_test",
|
||||
Attributes: map[string]string{"open_id": "ou_test"},
|
||||
},
|
||||
}
|
||||
|
||||
applySenderProfile(msg, feishuSenderProfile{
|
||||
displayName: "张三",
|
||||
username: "zhangsan",
|
||||
})
|
||||
|
||||
if got := msg.Sender.DisplayName; got != "张三" {
|
||||
t.Fatalf("expected display name 张三, got %q", got)
|
||||
}
|
||||
if got := msg.Sender.Attribute("display_name"); got != "张三" {
|
||||
t.Fatalf("expected attribute display_name 张三, got %q", got)
|
||||
}
|
||||
if got := msg.Sender.Attribute("name"); got != "张三" {
|
||||
t.Fatalf("expected attribute name 张三, got %q", got)
|
||||
}
|
||||
if got := msg.Sender.Attribute("username"); got != "zhangsan" {
|
||||
t.Fatalf("expected attribute username zhangsan, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySenderProfileKeepExistingIdentityFields(t *testing.T) {
|
||||
msg := &channel.InboundMessage{
|
||||
Sender: channel.Identity{
|
||||
SubjectID: "ou_test",
|
||||
DisplayName: "原名",
|
||||
Attributes: map[string]string{
|
||||
"open_id": "ou_test",
|
||||
"display_name": "原显示名",
|
||||
"name": "原姓名",
|
||||
"username": "old_user",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
applySenderProfile(msg, feishuSenderProfile{
|
||||
displayName: "新名",
|
||||
username: "new_user",
|
||||
})
|
||||
|
||||
if got := msg.Sender.DisplayName; got != "原名" {
|
||||
t.Fatalf("expected original display name preserved, got %q", got)
|
||||
}
|
||||
if got := msg.Sender.Attribute("display_name"); got != "原显示名" {
|
||||
t.Fatalf("expected original attribute display_name preserved, got %q", got)
|
||||
}
|
||||
if got := msg.Sender.Attribute("name"); got != "原姓名" {
|
||||
t.Fatalf("expected original attribute name preserved, got %q", got)
|
||||
}
|
||||
if got := msg.Sender.Attribute("username"); got != "old_user" {
|
||||
t.Fatalf("expected original attribute username preserved, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package feishu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
larkevent "github.com/larksuite/oapi-sdk-go/v3/event"
|
||||
"github.com/larksuite/oapi-sdk-go/v3/event/dispatcher"
|
||||
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
||||
|
||||
"github.com/memohai/memoh/internal/channel"
|
||||
)
|
||||
|
||||
type webhookConfigStore interface {
|
||||
ListConfigsByType(ctx context.Context, channelType channel.ChannelType) ([]channel.ChannelConfig, error)
|
||||
}
|
||||
|
||||
type webhookInboundManager interface {
|
||||
HandleInbound(ctx context.Context, cfg channel.ChannelConfig, msg channel.InboundMessage) error
|
||||
}
|
||||
|
||||
const webhookMaxBodyBytes int64 = 1 << 20 // 1 MiB
|
||||
|
||||
// WebhookHandler receives Feishu/Lark event-subscription callbacks.
|
||||
type WebhookHandler struct {
|
||||
logger *slog.Logger
|
||||
store webhookConfigStore
|
||||
manager webhookInboundManager
|
||||
adapter *FeishuAdapter
|
||||
}
|
||||
|
||||
// NewWebhookHandler creates a public webhook handler for Feishu/Lark callbacks.
|
||||
func NewWebhookHandler(log *slog.Logger, store webhookConfigStore, manager webhookInboundManager) *WebhookHandler {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &WebhookHandler{
|
||||
logger: log.With(slog.String("handler", "feishu_webhook")),
|
||||
store: store,
|
||||
manager: manager,
|
||||
adapter: NewFeishuAdapter(log),
|
||||
}
|
||||
}
|
||||
|
||||
// NewWebhookServerHandler is a DI-friendly constructor for fx/dig, using concrete
|
||||
// channel types as parameters.
|
||||
func NewWebhookServerHandler(log *slog.Logger, store *channel.Store, manager *channel.Manager) *WebhookHandler {
|
||||
return NewWebhookHandler(log, store, manager)
|
||||
}
|
||||
|
||||
// Register registers webhook callback routes.
|
||||
func (h *WebhookHandler) Register(e *echo.Echo) {
|
||||
e.GET("/channels/feishu/webhook/:config_id", h.HandleProbe)
|
||||
e.POST("/channels/feishu/webhook/:config_id", h.Handle)
|
||||
}
|
||||
|
||||
// HandleProbe responds to health/probe requests on the webhook URL.
|
||||
func (h *WebhookHandler) HandleProbe(c echo.Context) error {
|
||||
return c.String(http.StatusOK, "ok")
|
||||
}
|
||||
|
||||
// Handle processes Feishu/Lark event-subscription webhook requests.
|
||||
func (h *WebhookHandler) Handle(c echo.Context) error {
|
||||
if h.store == nil || h.manager == nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "feishu webhook dependencies not configured")
|
||||
}
|
||||
configID := strings.TrimSpace(c.Param("config_id"))
|
||||
if configID == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "config id is required")
|
||||
}
|
||||
cfg, err := h.findConfigByID(c.Request().Context(), configID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.Disabled {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "channel config is disabled")
|
||||
}
|
||||
feishuCfg, err := parseConfig(cfg.Credentials)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
if feishuCfg.InboundMode != inboundModeWebhook {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "feishu inbound_mode is not webhook")
|
||||
}
|
||||
|
||||
payload, err := io.ReadAll(io.LimitReader(c.Request().Body, webhookMaxBodyBytes+1))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("read body: %v", err))
|
||||
}
|
||||
if int64(len(payload)) > webhookMaxBodyBytes {
|
||||
return echo.NewHTTPError(http.StatusRequestEntityTooLarge, fmt.Sprintf("payload too large: max %d bytes", webhookMaxBodyBytes))
|
||||
}
|
||||
if err := validateWebhookCallbackAuth(payload, feishuCfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
botOpenID := h.adapter.resolveBotOpenID(context.WithoutCancel(c.Request().Context()), cfg)
|
||||
|
||||
eventDispatcher := dispatcher.NewEventDispatcher(feishuCfg.VerificationToken, feishuCfg.EncryptKey)
|
||||
eventDispatcher.OnP2MessageReceiveV1(func(_ context.Context, event *larkim.P2MessageReceiveV1) error {
|
||||
msg := extractFeishuInbound(event, botOpenID)
|
||||
if strings.TrimSpace(msg.Message.PlainText()) == "" && len(msg.Message.Attachments) == 0 {
|
||||
return nil
|
||||
}
|
||||
h.adapter.enrichSenderProfile(context.WithoutCancel(c.Request().Context()), cfg, event, &msg)
|
||||
msg.BotID = cfg.BotID
|
||||
return h.manager.HandleInbound(context.WithoutCancel(c.Request().Context()), cfg, msg)
|
||||
})
|
||||
|
||||
resp := eventDispatcher.Handle(c.Request().Context(), &larkevent.EventReq{
|
||||
Header: c.Request().Header,
|
||||
Body: payload,
|
||||
RequestURI: c.Request().RequestURI,
|
||||
})
|
||||
if resp == nil {
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
for key, values := range resp.Header {
|
||||
for _, value := range values {
|
||||
c.Response().Header().Add(key, value)
|
||||
}
|
||||
}
|
||||
c.Response().WriteHeader(resp.StatusCode)
|
||||
if len(resp.Body) == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err = c.Response().Write(resp.Body)
|
||||
return err
|
||||
}
|
||||
|
||||
func validateWebhookCallbackAuth(payload []byte, cfg Config) error {
|
||||
if strings.TrimSpace(cfg.EncryptKey) != "" {
|
||||
// Lark SDK signature verification is enabled only when encryptKey is configured.
|
||||
return nil
|
||||
}
|
||||
var fuzzy larkevent.EventFuzzy
|
||||
if err := json.Unmarshal(payload, &fuzzy); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid feishu webhook payload: %v", err))
|
||||
}
|
||||
if larkevent.ReqType(strings.TrimSpace(fuzzy.Type)) == larkevent.ReqTypeChallenge {
|
||||
return nil
|
||||
}
|
||||
expectedToken := strings.TrimSpace(cfg.VerificationToken)
|
||||
if expectedToken == "" {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "feishu webhook requires verification_token when encrypt_key is empty")
|
||||
}
|
||||
requestToken := strings.TrimSpace(fuzzy.Token)
|
||||
if fuzzy.Header != nil && strings.TrimSpace(fuzzy.Header.Token) != "" {
|
||||
requestToken = strings.TrimSpace(fuzzy.Header.Token)
|
||||
}
|
||||
if requestToken == "" || requestToken != expectedToken {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "invalid feishu webhook token")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *WebhookHandler) findConfigByID(ctx context.Context, configID string) (channel.ChannelConfig, error) {
|
||||
items, err := h.store.ListConfigsByType(ctx, Type)
|
||||
if err != nil {
|
||||
return channel.ChannelConfig{}, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
for _, item := range items {
|
||||
if strings.TrimSpace(item.ID) == configID {
|
||||
return item, nil
|
||||
}
|
||||
}
|
||||
return channel.ChannelConfig{}, echo.NewHTTPError(http.StatusNotFound, "channel config not found")
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
package feishu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
|
||||
"github.com/memohai/memoh/internal/channel"
|
||||
)
|
||||
|
||||
type fakeWebhookStore struct {
|
||||
configs []channel.ChannelConfig
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *fakeWebhookStore) ListConfigsByType(ctx context.Context, channelType channel.ChannelType) ([]channel.ChannelConfig, error) {
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
return s.configs, nil
|
||||
}
|
||||
|
||||
type fakeWebhookManager struct {
|
||||
calls []struct {
|
||||
cfg channel.ChannelConfig
|
||||
msg channel.InboundMessage
|
||||
}
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *fakeWebhookManager) HandleInbound(ctx context.Context, cfg channel.ChannelConfig, msg channel.InboundMessage) error {
|
||||
m.calls = append(m.calls, struct {
|
||||
cfg channel.ChannelConfig
|
||||
msg channel.InboundMessage
|
||||
}{cfg: cfg, msg: msg})
|
||||
return m.err
|
||||
}
|
||||
|
||||
func TestWebhookHandler_URLVerification(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := &fakeWebhookStore{
|
||||
configs: []channel.ChannelConfig{
|
||||
{
|
||||
ID: "cfg-1",
|
||||
BotID: "bot-1",
|
||||
ChannelType: Type,
|
||||
Credentials: map[string]any{
|
||||
"app_id": "app",
|
||||
"app_secret": "secret",
|
||||
"verification_token": "verify-token",
|
||||
"inbound_mode": "webhook",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
manager := &fakeWebhookManager{}
|
||||
h := NewWebhookHandler(nil, store, manager)
|
||||
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodPost, "/channels/feishu/webhook/cfg-1", strings.NewReader(`{"schema":"2.0","header":{"event_type":"im.message.receive_v1","token":"verify-token"},"type":"url_verification","challenge":"hello"}`))
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
c.SetParamNames("config_id")
|
||||
c.SetParamValues("cfg-1")
|
||||
|
||||
if err := h.Handle(c); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status code: %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"challenge":"hello"`) {
|
||||
t.Fatalf("unexpected challenge response: %s", rec.Body.String())
|
||||
}
|
||||
if len(manager.calls) != 0 {
|
||||
t.Fatalf("expected no inbound calls, got %d", len(manager.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookHandler_Probe(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h := NewWebhookHandler(nil, &fakeWebhookStore{}, &fakeWebhookManager{})
|
||||
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/channels/feishu/webhook/cfg-1", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
c.SetParamNames("config_id")
|
||||
c.SetParamValues("cfg-1")
|
||||
|
||||
if err := h.HandleProbe(c); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status code: %d", rec.Code)
|
||||
}
|
||||
if strings.TrimSpace(rec.Body.String()) != "ok" {
|
||||
t.Fatalf("unexpected probe response: %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookHandler_EventCallbackDispatchesInbound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := &fakeWebhookStore{
|
||||
configs: []channel.ChannelConfig{
|
||||
{
|
||||
ID: "cfg-1",
|
||||
BotID: "bot-1",
|
||||
ChannelType: Type,
|
||||
SelfIdentity: map[string]any{
|
||||
"open_id": "ou_bot_1",
|
||||
},
|
||||
Credentials: map[string]any{
|
||||
"app_id": "app",
|
||||
"app_secret": "secret",
|
||||
"verification_token": "verify-token",
|
||||
"inbound_mode": "webhook",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
manager := &fakeWebhookManager{}
|
||||
h := NewWebhookHandler(nil, store, manager)
|
||||
|
||||
e := echo.New()
|
||||
body := `{"schema":"2.0","header":{"event_id":"evt_1","event_type":"im.message.receive_v1","token":"verify-token"},"event":{"sender":{"sender_id":{"open_id":"ou_user_1","user_id":"u_user_1"}},"message":{"message_id":"om_1","chat_id":"oc_1","chat_type":"p2p","message_type":"text","content":"{\"text\":\"hello\"}"}},"type":"event_callback"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/channels/feishu/webhook/cfg-1", strings.NewReader(body))
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
c.SetParamNames("config_id")
|
||||
c.SetParamValues("cfg-1")
|
||||
|
||||
if err := h.Handle(c); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status code: %d", rec.Code)
|
||||
}
|
||||
if len(manager.calls) != 1 {
|
||||
t.Fatalf("expected one inbound call, got %d", len(manager.calls))
|
||||
}
|
||||
got := manager.calls[0].msg
|
||||
if got.BotID != "bot-1" {
|
||||
t.Fatalf("unexpected bot id: %s", got.BotID)
|
||||
}
|
||||
if got.Message.PlainText() != "hello" {
|
||||
t.Fatalf("unexpected message text: %q", got.Message.PlainText())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookHandler_EventCallbackUsesExternalIdentityForMentionFilter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := &fakeWebhookStore{
|
||||
configs: []channel.ChannelConfig{
|
||||
{
|
||||
ID: "cfg-1",
|
||||
BotID: "bot-1",
|
||||
ChannelType: Type,
|
||||
ExternalIdentity: "open_id:ou_bot_1",
|
||||
Credentials: map[string]any{
|
||||
"app_id": "app",
|
||||
"app_secret": "secret",
|
||||
"verification_token": "verify-token",
|
||||
"inbound_mode": "webhook",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
manager := &fakeWebhookManager{}
|
||||
h := NewWebhookHandler(nil, store, manager)
|
||||
|
||||
e := echo.New()
|
||||
body := `{"schema":"2.0","header":{"event_id":"evt_2","event_type":"im.message.receive_v1","token":"verify-token"},"event":{"sender":{"sender_id":{"open_id":"ou_user_2","user_id":"u_user_2"}},"message":{"message_id":"om_2","chat_id":"oc_group_1","chat_type":"group","message_type":"text","content":"{\"text\":\"<at user_id=\\\"ou_other_user\\\"></at> hello\"}"}},"type":"event_callback"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/channels/feishu/webhook/cfg-1", strings.NewReader(body))
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
c.SetParamNames("config_id")
|
||||
c.SetParamValues("cfg-1")
|
||||
|
||||
if err := h.Handle(c); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status code: %d", rec.Code)
|
||||
}
|
||||
if len(manager.calls) != 1 {
|
||||
t.Fatalf("expected one inbound call, got %d", len(manager.calls))
|
||||
}
|
||||
mentioned, _ := manager.calls[0].msg.Metadata["is_mentioned"].(bool)
|
||||
if mentioned {
|
||||
t.Fatalf("expected mention flag=false when mentioning another user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookHandler_EventCallbackRejectsInvalidTokenWhenEncryptKeyMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := &fakeWebhookStore{
|
||||
configs: []channel.ChannelConfig{
|
||||
{
|
||||
ID: "cfg-1",
|
||||
BotID: "bot-1",
|
||||
ChannelType: Type,
|
||||
Credentials: map[string]any{
|
||||
"app_id": "app",
|
||||
"app_secret": "secret",
|
||||
"verification_token": "verify-token",
|
||||
"inbound_mode": "webhook",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
manager := &fakeWebhookManager{}
|
||||
h := NewWebhookHandler(nil, store, manager)
|
||||
|
||||
e := echo.New()
|
||||
body := `{"schema":"2.0","header":{"event_id":"evt_1","event_type":"im.message.receive_v1","token":"forged-token"},"event":{"sender":{"sender_id":{"open_id":"ou_user_1"}},"message":{"message_id":"om_1","chat_id":"oc_1","chat_type":"p2p","message_type":"text","content":"{\"text\":\"hello\"}"}},"type":"event_callback"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/channels/feishu/webhook/cfg-1", strings.NewReader(body))
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
c.SetParamNames("config_id")
|
||||
c.SetParamValues("cfg-1")
|
||||
|
||||
err := h.Handle(c)
|
||||
if err == nil {
|
||||
t.Fatal("expected unauthorized error")
|
||||
}
|
||||
he, ok := err.(*echo.HTTPError)
|
||||
if !ok {
|
||||
t.Fatalf("expected HTTPError, got %T", err)
|
||||
}
|
||||
if he.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unexpected status code: %d", he.Code)
|
||||
}
|
||||
if len(manager.calls) != 0 {
|
||||
t.Fatalf("expected no inbound calls, got %d", len(manager.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookHandler_EventCallbackRequiresVerificationTokenWhenEncryptKeyMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := &fakeWebhookStore{
|
||||
configs: []channel.ChannelConfig{
|
||||
{
|
||||
ID: "cfg-1",
|
||||
BotID: "bot-1",
|
||||
ChannelType: Type,
|
||||
Credentials: map[string]any{
|
||||
"app_id": "app",
|
||||
"app_secret": "secret",
|
||||
"inbound_mode": "webhook",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
manager := &fakeWebhookManager{}
|
||||
h := NewWebhookHandler(nil, store, manager)
|
||||
|
||||
e := echo.New()
|
||||
body := `{"schema":"2.0","header":{"event_id":"evt_1","event_type":"im.message.receive_v1","token":"verify-token"},"event":{"sender":{"sender_id":{"open_id":"ou_user_1"}},"message":{"message_id":"om_1","chat_id":"oc_1","chat_type":"p2p","message_type":"text","content":"{\"text\":\"hello\"}"}},"type":"event_callback"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/channels/feishu/webhook/cfg-1", strings.NewReader(body))
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
c.SetParamNames("config_id")
|
||||
c.SetParamValues("cfg-1")
|
||||
|
||||
err := h.Handle(c)
|
||||
if err == nil {
|
||||
t.Fatal("expected forbidden error")
|
||||
}
|
||||
he, ok := err.(*echo.HTTPError)
|
||||
if !ok {
|
||||
t.Fatalf("expected HTTPError, got %T", err)
|
||||
}
|
||||
if he.Code != http.StatusForbidden {
|
||||
t.Fatalf("unexpected status code: %d", he.Code)
|
||||
}
|
||||
if len(manager.calls) != 0 {
|
||||
t.Fatalf("expected no inbound calls, got %d", len(manager.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookHandler_RejectsOversizedBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := &fakeWebhookStore{
|
||||
configs: []channel.ChannelConfig{
|
||||
{
|
||||
ID: "cfg-1",
|
||||
BotID: "bot-1",
|
||||
ChannelType: Type,
|
||||
Credentials: map[string]any{
|
||||
"app_id": "app",
|
||||
"app_secret": "secret",
|
||||
"verification_token": "verify-token",
|
||||
"inbound_mode": "webhook",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
manager := &fakeWebhookManager{}
|
||||
h := NewWebhookHandler(nil, store, manager)
|
||||
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodPost, "/channels/feishu/webhook/cfg-1", strings.NewReader(strings.Repeat("x", int(webhookMaxBodyBytes)+1)))
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
c.SetParamNames("config_id")
|
||||
c.SetParamValues("cfg-1")
|
||||
|
||||
err := h.Handle(c)
|
||||
if err == nil {
|
||||
t.Fatal("expected payload-too-large error")
|
||||
}
|
||||
he, ok := err.(*echo.HTTPError)
|
||||
if !ok {
|
||||
t.Fatalf("expected HTTPError, got %T", err)
|
||||
}
|
||||
if he.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("unexpected status code: %d", he.Code)
|
||||
}
|
||||
if len(manager.calls) != 0 {
|
||||
t.Fatalf("expected no inbound calls, got %d", len(manager.calls))
|
||||
}
|
||||
}
|
||||
@@ -47,14 +47,7 @@ func NewServer(log *slog.Logger, addr string, jwtSecret string,
|
||||
},
|
||||
}))
|
||||
e.Use(auth.JWTMiddleware(jwtSecret, func(c echo.Context) bool {
|
||||
path := c.Request().URL.Path
|
||||
if path == "/ping" || path == "/health" || path == "/api/swagger.json" || path == "/auth/login" {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(path, "/api/docs") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
return shouldSkipJWT(c.Request().URL.Path)
|
||||
}))
|
||||
|
||||
for _, h := range handlers {
|
||||
@@ -77,3 +70,16 @@ func (s *Server) Start() error {
|
||||
func (s *Server) Stop(ctx context.Context) error {
|
||||
return s.echo.Shutdown(ctx)
|
||||
}
|
||||
|
||||
func shouldSkipJWT(path string) bool {
|
||||
if path == "/ping" || path == "/health" || path == "/api/swagger.json" || path == "/auth/login" {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(path, "/api/docs") {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(path, "/channels/feishu/webhook/") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package server
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestShouldSkipJWT_FeishuWebhookPaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{path: "/channels/feishu/webhook/cfg-1", want: true},
|
||||
{path: "/channels/feishu/webhook", want: false},
|
||||
{path: "/api/channels/feishu/webhook", want: false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
got := shouldSkipJWT(tc.path)
|
||||
if got != tc.want {
|
||||
t.Fatalf("path=%q want=%v got=%v", tc.path, tc.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { table } from 'table'
|
||||
|
||||
import { apiRequest } from '../core/api'
|
||||
import { ensureAuth, getErrorMessage, resolveBotId } from './shared'
|
||||
import { getBaseURL, readConfig } from '../utils/store'
|
||||
|
||||
type ChannelFieldSchema = {
|
||||
type: 'string' | 'secret' | 'bool' | 'number' | 'enum'
|
||||
@@ -54,6 +55,28 @@ type ChannelConfig = {
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
const readInboundMode = (credentials: Record<string, unknown>) => {
|
||||
const raw = credentials.inboundMode ?? credentials.inbound_mode
|
||||
if (typeof raw !== 'string') return ''
|
||||
return raw.trim().toLowerCase()
|
||||
}
|
||||
|
||||
const buildWebhookCallbackUrl = (configId: string) => {
|
||||
const baseUrl = getBaseURL(readConfig()).replace(/\/+$/, '')
|
||||
return `${baseUrl}/channels/feishu/webhook/${encodeURIComponent(configId)}`
|
||||
}
|
||||
|
||||
const printWebhookCallbackIfEnabled = (channelType: string, config: ChannelConfig) => {
|
||||
if (channelType !== 'feishu') return
|
||||
if (readInboundMode(config.credentials || {}) !== 'webhook') return
|
||||
const configId = String(config.id || '').trim()
|
||||
if (!configId) {
|
||||
console.log(chalk.yellow('Webhook is enabled, but config id is missing so callback URL cannot be generated yet.'))
|
||||
return
|
||||
}
|
||||
console.log(chalk.cyan(`Webhook callback URL: ${buildWebhookCallbackUrl(configId)}`))
|
||||
}
|
||||
|
||||
const renderChannelsTable = (items: ChannelMeta[]) => {
|
||||
const rows: string[][] = [['Type', 'Name', 'Configless']]
|
||||
for (const item of items) {
|
||||
@@ -100,6 +123,8 @@ const collectFeishuCredentials = async (opts: Record<string, unknown>) => {
|
||||
let appSecret = typeof opts.app_secret === 'string' ? opts.app_secret : undefined
|
||||
let encryptKey = typeof opts.encrypt_key === 'string' ? opts.encrypt_key : undefined
|
||||
let verificationToken = typeof opts.verification_token === 'string' ? opts.verification_token : undefined
|
||||
let region = typeof opts.region === 'string' ? opts.region : undefined
|
||||
let inboundMode = typeof opts.inbound_mode === 'string' ? opts.inbound_mode : undefined
|
||||
|
||||
const questions = []
|
||||
if (!appId) questions.push({ type: 'input', name: 'appId', message: 'Feishu App ID:' })
|
||||
@@ -110,16 +135,44 @@ const collectFeishuCredentials = async (opts: Record<string, unknown>) => {
|
||||
if (!verificationToken) {
|
||||
questions.push({ type: 'input', name: 'verificationToken', message: 'Verification Token (optional):', default: '' })
|
||||
}
|
||||
if (!region) {
|
||||
questions.push({
|
||||
type: 'list',
|
||||
name: 'region',
|
||||
message: 'Region:',
|
||||
choices: [
|
||||
{ name: 'Feishu (open.feishu.cn)', value: 'feishu' },
|
||||
{ name: 'Lark (open.larksuite.com)', value: 'lark' },
|
||||
],
|
||||
default: 'feishu',
|
||||
})
|
||||
}
|
||||
if (!inboundMode) {
|
||||
questions.push({
|
||||
type: 'list',
|
||||
name: 'inboundMode',
|
||||
message: 'Inbound mode:',
|
||||
choices: [
|
||||
{ name: 'WebSocket', value: 'websocket' },
|
||||
{ name: 'Webhook', value: 'webhook' },
|
||||
],
|
||||
default: 'websocket',
|
||||
})
|
||||
}
|
||||
const answers = questions.length ? await inquirer.prompt<Record<string, string>>(questions) : {}
|
||||
|
||||
appId = appId ?? answers.appId
|
||||
appSecret = appSecret ?? answers.appSecret
|
||||
encryptKey = encryptKey ?? answers.encryptKey
|
||||
verificationToken = verificationToken ?? answers.verificationToken
|
||||
region = region ?? answers.region
|
||||
inboundMode = inboundMode ?? answers.inboundMode
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
appId: String(appId).trim(),
|
||||
appSecret: String(appSecret).trim(),
|
||||
region: String(region || 'feishu').trim(),
|
||||
inboundMode: String(inboundMode || 'websocket').trim(),
|
||||
}
|
||||
if (String(encryptKey || '').trim()) payload.encryptKey = String(encryptKey).trim()
|
||||
if (String(verificationToken || '').trim()) payload.verificationToken = String(verificationToken).trim()
|
||||
@@ -200,6 +253,7 @@ export const registerChannelCommands = (program: Command) => {
|
||||
const channelType = await resolveChannelType(token, opts.type)
|
||||
const resp = await apiRequest<ChannelConfig>(`/bots/${encodeURIComponent(resolvedBotId)}/channel/${encodeURIComponent(channelType)}`, {}, token)
|
||||
console.log(JSON.stringify(resp, null, 2))
|
||||
printWebhookCallbackIfEnabled(channelType, resp)
|
||||
})
|
||||
|
||||
config
|
||||
@@ -211,6 +265,8 @@ export const registerChannelCommands = (program: Command) => {
|
||||
.option('--app_secret <app_secret>')
|
||||
.option('--encrypt_key <encrypt_key>')
|
||||
.option('--verification_token <verification_token>')
|
||||
.option('--region <region>', 'feishu|lark')
|
||||
.option('--inbound_mode <inbound_mode>', 'websocket|webhook')
|
||||
.action(async (botId, opts) => {
|
||||
const token = ensureAuth()
|
||||
const resolvedBotId = await resolveBotId(token, botId)
|
||||
@@ -222,11 +278,12 @@ export const registerChannelCommands = (program: Command) => {
|
||||
const credentials = await collectFeishuCredentials(opts)
|
||||
const spinner = ora('Updating channel config...').start()
|
||||
try {
|
||||
await apiRequest(`/bots/${encodeURIComponent(resolvedBotId)}/channel/${encodeURIComponent(channelType)}`, {
|
||||
const resp = await apiRequest<ChannelConfig>(`/bots/${encodeURIComponent(resolvedBotId)}/channel/${encodeURIComponent(channelType)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ credentials }),
|
||||
}, token)
|
||||
spinner.succeed('Channel config updated')
|
||||
printWebhookCallbackIfEnabled(channelType, resp)
|
||||
} catch (err: unknown) {
|
||||
spinner.fail(getErrorMessage(err) || 'Failed to update channel config')
|
||||
process.exit(1)
|
||||
@@ -273,4 +330,3 @@ export const registerChannelCommands = (program: Command) => {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -403,9 +403,13 @@
|
||||
"actionDisable": "Disable",
|
||||
"saveOnly": "Save only",
|
||||
"saveAndEnable": "Save and enable",
|
||||
"copyFailed": "Copy failed",
|
||||
"deleteConfirm": "Are you sure you want to remove this platform?",
|
||||
"deleteSuccess": "Platform removed",
|
||||
"deleteFailed": "Failed to remove platform",
|
||||
"webhookCallback": "WebHook Callback URL",
|
||||
"webhookCallbackHint": "Use this URL as the event subscription request URL in Feishu/Lark.",
|
||||
"webhookCallbackPending": "Save this platform configuration to generate the callback URL.",
|
||||
"noAvailableTypes": "All platform types have been configured",
|
||||
"types": {
|
||||
"feishu": "Feishu",
|
||||
|
||||
@@ -399,9 +399,13 @@
|
||||
"actionDisable": "停用",
|
||||
"saveOnly": "仅保存",
|
||||
"saveAndEnable": "立即启用",
|
||||
"copyFailed": "复制失败",
|
||||
"deleteConfirm": "确定要移除这个平台吗?",
|
||||
"deleteSuccess": "平台已移除",
|
||||
"deleteFailed": "移除平台失败",
|
||||
"webhookCallback": "WebHook 回调地址",
|
||||
"webhookCallbackHint": "将该地址配置到飞书/Lark 事件订阅的请求 URL。",
|
||||
"webhookCallbackPending": "保存平台配置后会生成回调地址。",
|
||||
"noAvailableTypes": "所有平台类型均已配置",
|
||||
"types": {
|
||||
"feishu": "飞书",
|
||||
|
||||
@@ -47,6 +47,40 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showWebhookCallback"
|
||||
class="space-y-2"
|
||||
>
|
||||
<h4 class="text-sm font-medium">
|
||||
{{ $t('bots.channels.webhookCallback') }}
|
||||
</h4>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ $t('bots.channels.webhookCallbackHint') }}
|
||||
</p>
|
||||
<template v-if="webhookCallbackUrl">
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
:model-value="webhookCallbackUrl"
|
||||
readonly
|
||||
class="font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="copyWebhookCallback"
|
||||
>
|
||||
{{ $t('common.copy') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
<p
|
||||
v-else
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ $t('bots.channels.webhookCallbackPending') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Credentials form (dynamic from config_schema) -->
|
||||
@@ -254,6 +288,7 @@ const { mutateAsync: updateChannelStatus, isLoading: isStatusLoading } = useMuta
|
||||
const action = ref<'save' | 'toggle' | 'delete' | ''>('')
|
||||
const isBusy = computed(() => isLoading.value || isStatusLoading.value || action.value !== '')
|
||||
const isEditMode = computed(() => props.channelItem.configured)
|
||||
const lastSavedConfigId = ref('')
|
||||
|
||||
// ---- Form state ----
|
||||
|
||||
@@ -279,6 +314,23 @@ const orderedFields = computed(() => {
|
||||
return Object.fromEntries(entries) as Record<string, ChannelFieldSchema>
|
||||
})
|
||||
|
||||
const currentInboundMode = computed(() => {
|
||||
const value = form.credentials.inboundMode ?? form.credentials.inbound_mode
|
||||
if (typeof value !== 'string') return ''
|
||||
return value.trim().toLowerCase()
|
||||
})
|
||||
|
||||
const showWebhookCallback = computed(() => {
|
||||
return props.channelItem.meta.type === 'feishu' && currentInboundMode.value === 'webhook'
|
||||
})
|
||||
|
||||
const webhookCallbackUrl = computed(() => {
|
||||
if (!showWebhookCallback.value) return ''
|
||||
const configId = String(props.channelItem.config?.id || lastSavedConfigId.value || '').trim()
|
||||
if (!configId) return ''
|
||||
return buildWebhookCallbackUrl(configId)
|
||||
})
|
||||
|
||||
function initForm() {
|
||||
const schema = props.channelItem.meta.config_schema?.fields ?? {}
|
||||
const existingCredentials = props.channelItem.config?.credentials ?? {}
|
||||
@@ -289,6 +341,7 @@ function initForm() {
|
||||
}
|
||||
form.credentials = creds
|
||||
form.disabled = props.channelItem.config?.disabled ?? false
|
||||
lastSavedConfigId.value = String(props.channelItem.config?.id || '').trim()
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -325,13 +378,14 @@ async function saveChannel(disabled: boolean, nextAction: 'save' | 'toggle') {
|
||||
if (!validateRequired()) return
|
||||
action.value = nextAction
|
||||
try {
|
||||
await upsertChannel({
|
||||
const result = await upsertChannel({
|
||||
platform: props.channelItem.meta.type,
|
||||
data: {
|
||||
credentials: buildCredentials(),
|
||||
disabled,
|
||||
},
|
||||
})
|
||||
lastSavedConfigId.value = String(result?.id || lastSavedConfigId.value || '').trim()
|
||||
form.disabled = disabled
|
||||
toast.success(t('bots.channels.saveSuccess'))
|
||||
emit('saved')
|
||||
@@ -384,6 +438,7 @@ async function handleDelete() {
|
||||
url: `/bots/${botIdRef.value}/channel/${props.channelItem.meta.type}`,
|
||||
throwOnError: true,
|
||||
})
|
||||
lastSavedConfigId.value = ''
|
||||
toast.success(t('bots.channels.deleteSuccess'))
|
||||
emit('saved')
|
||||
} catch (err) {
|
||||
@@ -393,4 +448,65 @@ async function handleDelete() {
|
||||
action.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function buildWebhookCallbackUrl(configId: string): string {
|
||||
const normalizedBase = resolveWebhookCallbackBaseUrl()
|
||||
if (!normalizedBase) return ''
|
||||
if (typeof window !== 'undefined') {
|
||||
const baseUrl = new URL(normalizedBase, window.location.origin)
|
||||
baseUrl.pathname = `${baseUrl.pathname.replace(/\/+$/, '')}/channels/feishu/webhook/${encodeURIComponent(configId)}`
|
||||
baseUrl.search = ''
|
||||
baseUrl.hash = ''
|
||||
return baseUrl.toString()
|
||||
}
|
||||
const base = normalizedBase.replace(/\/+$/, '')
|
||||
return `${base}/channels/feishu/webhook/${encodeURIComponent(configId)}`
|
||||
}
|
||||
|
||||
function resolveWebhookCallbackBaseUrl(): string {
|
||||
const explicitRaw = String(
|
||||
import.meta.env.VITE_WEBHOOK_PUBLIC_BASE_URL?.trim() ||
|
||||
import.meta.env.VITE_API_PUBLIC_URL?.trim() ||
|
||||
'',
|
||||
).trim()
|
||||
if (isAbsoluteHttpUrl(explicitRaw)) {
|
||||
return explicitRaw
|
||||
}
|
||||
|
||||
const apiBase = String(client.getConfig().baseUrl || import.meta.env.VITE_API_URL?.trim() || '').trim()
|
||||
if (isAbsoluteHttpUrl(apiBase)) {
|
||||
return apiBase
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return ''
|
||||
}
|
||||
const fallbackApiPort = String(import.meta.env.VITE_API_PORT || '8080').trim()
|
||||
const fallback = new URL(window.location.origin)
|
||||
if (fallbackApiPort) {
|
||||
fallback.port = fallbackApiPort
|
||||
}
|
||||
fallback.search = ''
|
||||
fallback.hash = ''
|
||||
return fallback.toString().replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function isAbsoluteHttpUrl(value: string): boolean {
|
||||
return /^https?:\/\//i.test(value)
|
||||
}
|
||||
|
||||
async function copyWebhookCallback() {
|
||||
if (!webhookCallbackUrl.value) return
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard) {
|
||||
await navigator.clipboard.writeText(webhookCallbackUrl.value)
|
||||
toast.success(t('common.copied'))
|
||||
return
|
||||
}
|
||||
toast.error(t('bots.channels.copyFailed'))
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : ''
|
||||
toast.error(detail ? `${t('bots.channels.copyFailed')}: ${detail}` : t('bots.channels.copyFailed'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user