diff --git a/docs/content/docs/channels/feishu.md b/docs/content/docs/channels/feishu.md index d82d4d11..055586e8 100644 --- a/docs/content/docs/channels/feishu.md +++ b/docs/content/docs/channels/feishu.md @@ -39,6 +39,45 @@ lark-cli auth status A user-installed `lark-cli` skill can map the retired `feishu_calendar`, `feishu_task`, `feishu_im`, `feishu_doc`, `feishu_wiki`, `feishu_sheets`, `feishu_drive`, `feishu_bitable`, `feishu_user`, and `feishu_search` workflows to `lark-cli` services. +## Auto-Provisioning + +When enabled, anna automatically creates an Anna account for any employee of your configured Feishu tenant the first time they message the bot. No manual registration or `/link` step is needed. + +### How it works + +1. A user messages the bot. +2. Anna checks the event's `tenant_key` against `cfg.tenant_key`. If they do not match (external guest), the message proceeds normally and the user receives an access-denied response. +3. If they match, anna calls the Feishu Contact API (`contact.v3.user.get`) to retrieve the user's `union_id`, display name, and email. +4. A new Anna user is created with the email local-part as username (`alice` from `alice@corp.com`), falling back to `feishu-` if no email is available. Username collisions get a `-2`, `-3`, … suffix. +5. The provisioned user has no password — they can chat with the bot immediately but cannot log into the admin UI until an admin sets a password for them. +6. Provisioned users are assigned the `user` role and the system default agent. + +### Required app scopes + +Add these scopes to your Feishu app under **Permissions & Scopes**: + +- `contact:user.base:readonly` +- `contact:user.id:readonly` + +### Finding your tenant key + +In the Feishu Admin Console, go to **Enterprise Information** (企业信息). The tenant key is labeled **企业标识** or **Tenant Key**. + +### Configuration + +```json +{ + "app_id": "FEISHU_APP_ID", + "app_secret": "FEISHU_APP_SECRET", + "tenant_key": "YOUR_TENANT_KEY", + "auto_provision": true +} +``` + +> **Warning:** External guests in shared groups will receive an access-denied response per message — their tenant key differs from your configured one. This is by design. + +> **Note:** If no admin user exists yet, auto-provisioning is refused until the first admin registers via the admin UI. This prevents stranding a fresh deployment with zero admins. + ## Multi-User Support Each Feishu user is resolved from platform identity automatically. anna prefers Feishu `union_id` when the event payload includes it, and falls back to `open_id` for older links. That makes multi-instance Feishu setups work across multiple Feishu apps owned by the same developer account, because `union_id` is stable across those apps while `open_id` is app-scoped. @@ -109,6 +148,8 @@ Feishu supports the standard chat commands: "verification_token": "", "group_mode": "mention", "enable_notify": false, + "tenant_key": "", + "auto_provision": false, "groups": { "oc_example": { "group_mode": "always", @@ -126,4 +167,6 @@ Feishu supports the standard chat commands: | `verification_token` | Optional event verification token | | `group_mode` | Default group behavior: `mention`, `always`, or `disabled` | | `enable_notify` | Allow scheduler and notify output to target Feishu | +| `tenant_key` | Your enterprise tenant key. Required when `auto_provision` is `true` | +| `auto_provision` | Automatically create Anna accounts for users of the configured tenant | | `groups` | Optional per-chat overrides keyed by Feishu `chat_id` | diff --git a/docs/content/docs/channels/feishu.zh.md b/docs/content/docs/channels/feishu.zh.md index 3c2dc72a..1e5411df 100644 --- a/docs/content/docs/channels/feishu.zh.md +++ b/docs/content/docs/channels/feishu.zh.md @@ -37,6 +37,45 @@ lark-cli auth status 用户自行安装的 `lark-cli` skill 可以覆盖原来的 `feishu_calendar`、`feishu_task`、`feishu_im`、`feishu_doc`、`feishu_wiki`、`feishu_sheets`、`feishu_drive`、`feishu_bitable`、`feishu_user` 和 `feishu_search` 等工作流。 +## 自动注册用户 + +开启后,anna 会在飞书租户的员工第一次给机器人发消息时,自动为其创建 Anna 账号,无需手动注册或执行 `/link`。 + +### 工作原理 + +1. 用户发送消息。 +2. Anna 对比事件中的 `tenant_key` 与配置的 `tenant_key`。若不匹配(外部访客),消息正常流转,但用户会收到"无权访问"的回复。 +3. 若匹配,anna 调用飞书联系人 API(`contact.v3.user.get`)获取用户的 `union_id`、显示名称和邮箱。 +4. 以邮箱本地部分作为用户名创建 Anna 账号(例如 `alice@corp.com` → `alice`),无邮箱时回退到 `feishu-`。用户名冲突时加 `-2`、`-3` 等后缀。 +5. 自动创建的用户没有密码,可以立即与机器人对话,但在管理员设置密码前无法登录管理面板。 +6. 自动注册的用户角色为 `user`,默认使用系统默认 agent。 + +### 所需应用权限 + +在飞书开放平台的 **权限管理** 中添加以下权限: + +- `contact:user.base:readonly` +- `contact:user.id:readonly` + +### 如何获取 tenant_key + +登录飞书管理后台,进入 **企业信息**,找到 **企业标识(Tenant Key)**。 + +### 配置示例 + +```json +{ + "app_id": "FEISHU_APP_ID", + "app_secret": "FEISHU_APP_SECRET", + "tenant_key": "YOUR_TENANT_KEY", + "auto_provision": true +} +``` + +> **注意:** 共享群中的外部访客每次发消息都会收到"无权访问"的回复——因为他们的 tenant_key 与配置不符,这是预期行为。 + +> **注意:** 若系统中尚无管理员账号,自动注册会被拒绝,直到第一个管理员通过管理面板完成注册。这样可以防止全新部署陷入无管理员的困境。 + ## 多用户支持 每个 Feishu 用户都会通过平台身份自动解析。会话按用户和 agent 隔离,因此不同用户拥有各自独立的记忆和默认 agent 状态。 @@ -102,6 +141,8 @@ Feishu 支持标准聊天命令: "verification_token": "", "group_mode": "mention", "enable_notify": false, + "tenant_key": "", + "auto_provision": false, "groups": { "oc_example": { "group_mode": "always", @@ -119,4 +160,6 @@ Feishu 支持标准聊天命令: | `verification_token` | 可选的事件校验 token | | `group_mode` | 默认群聊行为:`mention`、`always` 或 `disabled` | | `enable_notify` | 允许调度器和 `notify` 输出发送到 Feishu | +| `tenant_key` | 企业 Tenant Key,`auto_provision` 为 `true` 时必填 | +| `auto_provision` | 自动为配置租户的员工创建 Anna 账号 | | `groups` | 按 Feishu `chat_id` 配置的群级覆盖项 | diff --git a/internal/admin/ui/pages/channels.templ b/internal/admin/ui/pages/channels.templ index ed0b2ccb..9a6a40f7 100644 --- a/internal/admin/ui/pages/channels.templ +++ b/internal/admin/ui/pages/channels.templ @@ -341,6 +341,17 @@ templ dedicatedInstanceFields(model string) { } +
+ @ui.FormField("Tenant Key") { + + } +
+ +
+
diff --git a/internal/admin/ui/pages/channels_templ.go b/internal/admin/ui/pages/channels_templ.go index e8a18664..6559e2ad 100644 --- a/internal/admin/ui/pages/channels_templ.go +++ b/internal/admin/ui/pages/channels_templ.go @@ -599,20 +599,72 @@ func dedicatedInstanceFields(model string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var30 string - templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(model + ".type === 'weixin'") + templ_7745c5c3_Var30 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = ui.FormField("Tenant Key").Render(templ.WithChildren(ctx, templ_7745c5c3_Var30), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "

Weixin dedicated instances currently only expose notification settings here.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\" x-cloak class=\"space-y-2\">

Weixin dedicated instances currently only expose notification settings here.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/internal/admin/ui/static/js/pages/channels.js b/internal/admin/ui/static/js/pages/channels.js index b4e7f056..ed64cbf3 100644 --- a/internal/admin/ui/static/js/pages/channels.js +++ b/internal/admin/ui/static/js/pages/channels.js @@ -29,6 +29,8 @@ const platformMeta = { encrypt_key: '', verification_token: '', group_mode: '', + tenant_key: '', + auto_provision: false, }, }, weixin: { diff --git a/internal/auth/provision.go b/internal/auth/provision.go new file mode 100644 index 00000000..44a9ba24 --- /dev/null +++ b/internal/auth/provision.go @@ -0,0 +1,112 @@ +package auth + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" +) + +const maxUsernameAttempts = 20 + +// ProvisionRequest carries the information needed to provision a new user. +type ProvisionRequest struct { + Platform string + ExternalID string + Name string + EmailHint string +} + +// ProvisionIdentityUser creates a new user + identity pair atomically. +// It is idempotent: if the (platform, externalID) identity already exists, +// the existing user is returned without creating anything new. +// +// On a concurrent race where two callers both miss the initial identity lookup +// and one loses the unique-constraint insert, the loser re-reads the winning +// identity/user and returns it rather than propagating an error. +func ProvisionIdentityUser(ctx context.Context, store AuthStore, req ProvisionRequest) (AuthUser, error) { + // Fast path: identity already exists. + existing, err := store.GetIdentityByPlatform(ctx, req.Platform, req.ExternalID) + if err == nil { + user, err := store.GetUser(ctx, existing.UserID) + if err != nil { + return AuthUser{}, fmt.Errorf("provision: get existing user: %w", err) + } + return user, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return AuthUser{}, fmt.Errorf("provision: check identity: %w", err) + } + + username, err := deriveUsername(ctx, store, req.EmailHint, req.ExternalID, req.Platform) + if err != nil { + return AuthUser{}, err + } + + user, err := store.CreateUser(ctx, username, "") // empty hash = no web login + if err != nil { + return AuthUser{}, fmt.Errorf("provision: create user: %w", err) + } + + _, identErr := store.CreateIdentity(ctx, Identity{ + UserID: user.ID, + Platform: req.Platform, + ExternalID: req.ExternalID, + Name: req.Name, + }) + if identErr != nil { + _ = store.DeleteUser(ctx, user.ID) + + // A concurrent provision may have won the race on the unique constraint. + if winner, rerr := store.GetIdentityByPlatform(ctx, req.Platform, req.ExternalID); rerr == nil { + if winUser, rerr := store.GetUser(ctx, winner.UserID); rerr == nil { + return winUser, nil + } + } + + return AuthUser{}, fmt.Errorf("provision: create identity: %w", identErr) + } + + return user, nil +} + +// deriveUsername produces a unique username from an email hint or external ID. +// It tries the base name, then base-2, base-3, … up to maxUsernameAttempts. +// Returns an error if all candidates are taken. +func deriveUsername(ctx context.Context, store AuthStore, emailHint, externalID, platform string) (string, error) { + base := localPart(emailHint) + if base == "" { + id := externalID + if len(id) > 8 { + id = id[:8] + } + base = platform + "-" + id + } + + for i := range maxUsernameAttempts { + candidate := base + if i > 0 { + candidate = fmt.Sprintf("%s-%d", base, i+1) + } + _, err := store.GetUserByUsername(ctx, candidate) + if errors.Is(err, sql.ErrNoRows) { + return candidate, nil + } + if err != nil { + return "", fmt.Errorf("provision: probe username %q: %w", candidate, err) + } + } + + return "", fmt.Errorf("provision: no unique username after %d attempts for base %q", maxUsernameAttempts, base) +} + +// localPart returns the portion of an email address before the @ sign. +// Returns empty string if there is no @ or the local part is empty. +func localPart(email string) string { + at := strings.Index(email, "@") + if at <= 0 { + return "" + } + return email[:at] +} diff --git a/internal/auth/provision_test.go b/internal/auth/provision_test.go new file mode 100644 index 00000000..57555086 --- /dev/null +++ b/internal/auth/provision_test.go @@ -0,0 +1,165 @@ +package auth_test + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "github.com/vaayne/anna/internal/auth" + appdb "github.com/vaayne/anna/internal/db" +) + +func setupProvisionStore(t *testing.T) auth.AuthStore { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "provision.db") + db, err := appdb.OpenDB(dbPath) + if err != nil { + t.Fatalf("OpenDB: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + return appdb.NewAuthStore(db) +} + +func TestProvisionIdentityUserNew(t *testing.T) { + store := setupProvisionStore(t) + ctx := context.Background() + + req := auth.ProvisionRequest{ + Platform: "feishu", + ExternalID: "on_abc123", + Name: "Alice", + EmailHint: "alice@example.com", + } + + user, err := auth.ProvisionIdentityUser(ctx, store, req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if user.ID == 0 { + t.Fatal("expected non-zero user ID") + } + if user.Username != "alice" { + t.Errorf("username = %q, want %q", user.Username, "alice") + } + if user.PasswordHash != "" { + t.Errorf("password hash should be empty for provisioned users") + } + if user.Role != auth.RoleUser { + t.Errorf("role = %q, want %q", user.Role, auth.RoleUser) + } +} + +func TestProvisionIdentityUserIdempotent(t *testing.T) { + store := setupProvisionStore(t) + ctx := context.Background() + + req := auth.ProvisionRequest{ + Platform: "feishu", + ExternalID: "on_dup", + Name: "Bob", + EmailHint: "bob@example.com", + } + + u1, err := auth.ProvisionIdentityUser(ctx, store, req) + if err != nil { + t.Fatalf("first call: %v", err) + } + u2, err := auth.ProvisionIdentityUser(ctx, store, req) + if err != nil { + t.Fatalf("second call: %v", err) + } + if u1.ID != u2.ID { + t.Errorf("idempotency: got different user IDs %d vs %d", u1.ID, u2.ID) + } + + users, _ := store.ListUsers(ctx) + count := 0 + for _, u := range users { + if strings.HasPrefix(u.Username, "bob") { + count++ + } + } + if count != 1 { + t.Errorf("expected 1 user with username prefix 'bob', got %d", count) + } +} + +func TestProvisionIdentityUserUsernameCollision(t *testing.T) { + store := setupProvisionStore(t) + ctx := context.Background() + + // Two different external IDs that share the same email local-part. + req1 := auth.ProvisionRequest{ + Platform: "feishu", + ExternalID: "on_carol_1", + Name: "Carol 1", + EmailHint: "carol@example.com", + } + req2 := auth.ProvisionRequest{ + Platform: "feishu", + ExternalID: "on_carol_2", + Name: "Carol 2", + EmailHint: "carol@example.com", + } + + u1, err := auth.ProvisionIdentityUser(ctx, store, req1) + if err != nil { + t.Fatalf("first: %v", err) + } + u2, err := auth.ProvisionIdentityUser(ctx, store, req2) + if err != nil { + t.Fatalf("second: %v", err) + } + + if u1.Username != "carol" { + t.Errorf("u1.Username = %q, want %q", u1.Username, "carol") + } + if u2.Username != "carol-2" { + t.Errorf("u2.Username = %q, want %q", u2.Username, "carol-2") + } + if u1.ID == u2.ID { + t.Error("collision: same user ID for different external IDs") + } +} + +func TestProvisionIdentityUserEmptyEmail(t *testing.T) { + store := setupProvisionStore(t) + ctx := context.Background() + + req := auth.ProvisionRequest{ + Platform: "feishu", + ExternalID: "on_xyz12345", + Name: "Dave", + EmailHint: "", + } + + user, err := auth.ProvisionIdentityUser(ctx, store, req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Should fall back to feishu-. + if user.Username != "feishu-on_xyz12" { + t.Errorf("username = %q, want %q", user.Username, "feishu-on_xyz12") + } +} + +func TestProvisionIdentityUserShortExternalID(t *testing.T) { + store := setupProvisionStore(t) + ctx := context.Background() + + req := auth.ProvisionRequest{ + Platform: "feishu", + ExternalID: "short", + Name: "Eve", + EmailHint: "", + } + + user, err := auth.ProvisionIdentityUser(ctx, store, req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if user.Username != "feishu-short" { + t.Errorf("username = %q, want %q", user.Username, "feishu-short") + } +} diff --git a/internal/channel/coordinator.go b/internal/channel/coordinator.go index d8733dd6..ace2b635 100644 --- a/internal/channel/coordinator.go +++ b/internal/channel/coordinator.go @@ -2,6 +2,8 @@ package channel import ( "context" + "errors" + "fmt" "strings" "github.com/vaayne/anna/internal/agent" @@ -261,5 +263,32 @@ func convertEvent(evt runner.Event) pkgchannel.Event { return out } -// compile-time check. -var _ pkgchannel.Handler = (*Coordinator)(nil) +// ProvisionUser creates or returns an existing user+identity for the given +// channel sender. Returns an error if auth is not configured or the user +// count is zero (no admin exists yet — provisioning is refused until the +// first admin registers to avoid stranding a deployment with zero admins). +func (c *Coordinator) ProvisionUser(ctx context.Context, req pkgchannel.ProvisionRequest) error { + if c.authStore == nil { + return errors.New("provision: auth not configured") + } + count, err := c.authStore.CountUsers(ctx) + if err != nil { + return fmt.Errorf("provision: count users: %w", err) + } + if count == 0 { + return errors.New("provision: no admin exists yet; register the first admin before enabling auto-provisioning") + } + _, err = auth.ProvisionIdentityUser(ctx, c.authStore, auth.ProvisionRequest{ + Platform: req.Platform, + ExternalID: req.ExternalID, + Name: req.Name, + EmailHint: req.EmailHint, + }) + return err +} + +// compile-time checks. +var ( + _ pkgchannel.Handler = (*Coordinator)(nil) + _ pkgchannel.Provisioner = (*Coordinator)(nil) +) diff --git a/pkg/channel/channel.go b/pkg/channel/channel.go index 722dcb47..0468cd60 100644 --- a/pkg/channel/channel.go +++ b/pkg/channel/channel.go @@ -130,3 +130,18 @@ type AgentInfo struct { ID string Name string } + +// ProvisionRequest carries the information needed to auto-provision a channel user. +type ProvisionRequest struct { + Platform string + ExternalID string + Name string + EmailHint string +} + +// Provisioner is an optional capability that a Handler may implement. +// Channel plugins assert for this interface when they want to auto-provision +// users on first contact, without adding the method to every channel's Handler. +type Provisioner interface { + ProvisionUser(ctx context.Context, req ProvisionRequest) error +} diff --git a/pkg/channel/config.go b/pkg/channel/config.go index 4cba3bca..83b104b2 100644 --- a/pkg/channel/config.go +++ b/pkg/channel/config.go @@ -36,6 +36,8 @@ type FeishuConfig struct { GroupMode string `json:"group_mode"` Groups map[string]FeishuGroup `json:"groups"` EnableNotify bool `json:"enable_notify"` + TenantKey string `json:"tenant_key"` + AutoProvision bool `json:"auto_provision"` } // WeixinConfig is the persisted Weixin channel plugin configuration. diff --git a/plugins/channels/feishu/feishu.go b/plugins/channels/feishu/feishu.go index 9afd3e43..c2612fc1 100644 --- a/plugins/channels/feishu/feishu.go +++ b/plugins/channels/feishu/feishu.go @@ -49,6 +49,8 @@ type Config struct { VerificationToken string `json:"verification_token"` GroupMode string `json:"group_mode"` // "mention" | "always" | "disabled" Groups map[string]GroupConfig `json:"groups"` // per-group overrides keyed by chat_id + TenantKey string `json:"tenant_key"` + AutoProvision bool `json:"auto_provision"` } // Bot wraps a Feishu bot with agent pool integration. @@ -64,6 +66,12 @@ type Bot struct { seenMsgs map[string]time.Time // message ID -> first seen time lastSeenSweep time.Time // last time seenMsgs was swept + provisionedMu sync.Mutex + provisioned map[string]time.Time // union_id -> last provision time (1h TTL) + + learnedTenantKeyMu sync.RWMutex + learnedTenantKey string // tenant_key auto-detected at startup via tenant API + cfg Config ctx context.Context cancel context.CancelFunc @@ -80,10 +88,11 @@ func New(cfg Config, handler channel.Handler) (*Bot, error) { } b := &Bot{ - handler: handler, - chatModels: make(map[string]channel.ModelOption), - seenMsgs: make(map[string]time.Time), - cfg: cfg, + handler: handler, + chatModels: make(map[string]channel.ModelOption), + seenMsgs: make(map[string]time.Time), + provisioned: make(map[string]time.Time), + cfg: cfg, } return b, nil @@ -103,6 +112,12 @@ func (b *Bot) Start(ctx context.Context) error { logger().Warn("failed to fetch bot open_id, self-message filtering disabled", "error", err) } + if b.cfg.AutoProvision && b.cfg.TenantKey == "" { + if err := b.fetchBotTenantKey(b.ctx); err != nil { + logger().Warn("auto-provision: failed to detect tenant_key at startup, configure it explicitly", "error", err) + } + } + eventHandler := dispatcher.NewEventDispatcher(b.cfg.VerificationToken, b.cfg.EncryptKey). OnP2MessageReceiveV1(b.onMessage). OnP2MessageReactionCreatedV1(b.onReaction) diff --git a/plugins/channels/feishu/handler.go b/plugins/channels/feishu/handler.go index 718c5a11..58702fb6 100644 --- a/plugins/channels/feishu/handler.go +++ b/plugins/channels/feishu/handler.go @@ -65,6 +65,17 @@ func (b *Bot) onReaction(ctx context.Context, event *larkim.P2MessageReactionCre return nil } + // Auto-provision the reacting user. TenantKey is not available in reaction + // events; the contact API failure acts as the implicit tenant filter. + if data.UserId != nil { + unionID := derefStr(data.UserId.UnionId) + go func() { + provCtx, provCancel := b.apiContext() + defer provCancel() + b.maybeAutoProvision(provCtx, openID, unionID, "") + }() + } + reactionText := fmt.Sprintf("[User reacted with %s on message %s]", emojiType, messageID) msg := b.incomingMsg(senderIDs, chatID, chatType, channel.TextContent(reactionText)) @@ -122,6 +133,19 @@ func (b *Bot) onMessage(ctx context.Context, event *larkim.P2MessageReceiveV1) e text = stripMentions(text, mentions) } + // Auto-provision the sender if enabled. Done after dedup+group checks so + // duplicate events and bots in disabled groups never trigger provisioning. + // Skip for /link so the existing manual-link flow stays authoritative. + if cmd0, _ := channel.ParseSlashCommand(text); cmd0 != "/link" { + unionID := derefStr(sender.SenderId.UnionId) + tenantKey := derefStr(sender.TenantKey) + go func() { + provCtx, provCancel := b.apiContext() + defer provCancel() + b.maybeAutoProvision(provCtx, openID, unionID, tenantKey) + }() + } + content := b.buildMessageContent(msg) if content == nil { return nil diff --git a/plugins/channels/feishu/plugin.go b/plugins/channels/feishu/plugin.go index 8bcc915e..8b5a44fc 100644 --- a/plugins/channels/feishu/plugin.go +++ b/plugins/channels/feishu/plugin.go @@ -39,6 +39,8 @@ var newRuntime = func(platform pkgplugins.Platform) (pkgplugins.Runtime, error) VerificationToken: cfg.VerificationToken, GroupMode: cfg.GroupMode, Groups: groupsToPluginConfig(cfg.Groups), + TenantKey: cfg.TenantKey, + AutoProvision: cfg.AutoProvision, }, handler) }, }), nil diff --git a/plugins/channels/feishu/provision.go b/plugins/channels/feishu/provision.go new file mode 100644 index 00000000..a0f35290 --- /dev/null +++ b/plugins/channels/feishu/provision.go @@ -0,0 +1,178 @@ +package feishu + +import ( + "context" + "fmt" + "time" + + larkcontact "github.com/larksuite/oapi-sdk-go/v3/service/contact/v3" + pkgchannel "github.com/vaayne/anna/pkg/channel" +) + +const provisionCacheTTL = time.Hour + +func (b *Bot) isCachedProvision(key string) bool { + b.provisionedMu.Lock() + defer b.provisionedMu.Unlock() + t, ok := b.provisioned[key] + return ok && time.Since(t) < provisionCacheTTL +} + +// effectiveTenantKey returns the configured tenant key, or the one auto-detected +// at startup from the Feishu tenant API. +func (b *Bot) effectiveTenantKey() string { + if b.cfg.TenantKey != "" { + return b.cfg.TenantKey + } + b.learnedTenantKeyMu.RLock() + defer b.learnedTenantKeyMu.RUnlock() + return b.learnedTenantKey +} + +// fetchBotTenantKey queries the Feishu tenant API at startup to auto-detect the +// bot's home tenant key. Called only when auto_provision=true and tenant_key is +// not explicitly configured. +func (b *Bot) fetchBotTenantKey(ctx context.Context) error { + if b.client == nil { + return fmt.Errorf("client not initialised") + } + resp, err := b.client.Tenant.Tenant.Query(ctx) + if err != nil { + return fmt.Errorf("tenant query: %w", err) + } + if !resp.Success() || resp.Data == nil || resp.Data.Tenant == nil || resp.Data.Tenant.TenantKey == nil { + return fmt.Errorf("tenant query: unexpected response (code=%d)", resp.Code) + } + key := *resp.Data.Tenant.TenantKey + b.learnedTenantKeyMu.Lock() + b.learnedTenantKey = key + b.learnedTenantKeyMu.Unlock() + logger().Info("auto-provision: detected tenant_key from Feishu API", "tenant_key", key) + return nil +} + +// TenantProfile holds the information fetched from the Feishu contact API. +type TenantProfile struct { + UnionID string + OpenID string + Name string + Email string +} + +// fetchTenantProfile calls contact.v3.user.get with open_id to get profile info. +// Returns nil if the API call fails or returns no data. +func (b *Bot) fetchTenantProfile(ctx context.Context, openID string) *TenantProfile { + if b.client == nil { + return nil + } + resp, err := b.client.Contact.User.Get(ctx, + larkcontact.NewGetUserReqBuilder(). + UserId(openID). + UserIdType(larkcontact.UserIdTypeGetUserOpenId). + Build()) + if err != nil { + logger().Debug("contact api: get user failed", "open_id", openID, "error", err) + return nil + } + if !resp.Success() || resp.Data == nil || resp.Data.User == nil { + logger().Debug("contact api: get user unsuccessful", "open_id", openID, "code", resp.Code) + return nil + } + + u := resp.Data.User + profile := &TenantProfile{ + OpenID: openID, + } + if u.UnionId != nil { + profile.UnionID = *u.UnionId + } + if u.Name != nil { + profile.Name = *u.Name + } + if u.Email != nil { + profile.Email = *u.Email + } + return profile +} + +// maybeAutoProvision provisions an Anna user for the sender if auto-provisioning +// is enabled and the sender belongs to the configured tenant. +// +// tenantKey is the sender's tenant key from the event (empty string if unavailable, +// e.g. in reaction events). When non-empty it is checked against cfg.TenantKey +// before any API call; when empty the contact API failure acts as an implicit filter. +// +// It is called after dedup+group-eligibility checks in onMessage/onReaction. +// On any error it logs and returns silently — provisioning failure must never +// block the normal message flow. +// +// Cache key is union_id so users messaging from multiple devices share one entry. +func (b *Bot) maybeAutoProvision(ctx context.Context, openID, unionID, tenantKey string) { + if !b.cfg.AutoProvision { + return + } + effective := b.effectiveTenantKey() + if effective == "" { + return + } + + // Explicit tenant check when the event carries a tenant_key. + if tenantKey != "" && tenantKey != effective { + logger().Debug("auto-provision: skipping external tenant user", "tenant_key", tenantKey) + return + } + + provisioner, ok := b.handler.(pkgchannel.Provisioner) + if !ok { + return + } + + // Check cache using union_id when available, falling back to open_id. + cacheKey := unionID + if cacheKey == "" { + cacheKey = openID + } + + if b.isCachedProvision(cacheKey) { + return + } + + // Fetch profile from contact API for email hint and authoritative union_id. + // This also acts as an implicit tenant filter: the API rejects external users. + profile := b.fetchTenantProfile(ctx, openID) + if profile == nil { + return + } + + // Use union_id from profile as the canonical external ID. + if profile.UnionID != "" { + cacheKey = profile.UnionID + unionID = profile.UnionID + } + + // Refuse to provision with an empty external ID: every user missing union_id + // would be stored as (feishu, "") and resolve to the same Anna account. + if unionID == "" { + logger().Warn("auto-provision: skipping user with empty union_id", "open_id", openID) + return + } + + // Re-check cache with the authoritative union_id. + if b.isCachedProvision(cacheKey) { + return + } + + if err := provisioner.ProvisionUser(ctx, pkgchannel.ProvisionRequest{ + Platform: pkgchannel.PlatformFeishu, + ExternalID: unionID, + Name: profile.Name, + EmailHint: profile.Email, + }); err != nil { + logger().Debug("auto-provision failed", "open_id", openID, "error", err) + return + } + + b.provisionedMu.Lock() + b.provisioned[cacheKey] = time.Now() + b.provisionedMu.Unlock() +} diff --git a/plugins/channels/feishu/provision_test.go b/plugins/channels/feishu/provision_test.go new file mode 100644 index 00000000..ca963be9 --- /dev/null +++ b/plugins/channels/feishu/provision_test.go @@ -0,0 +1,111 @@ +package feishu + +import ( + "context" + "testing" + "time" + + pkgchannel "github.com/vaayne/anna/pkg/channel" +) + +// mockProvisioner records ProvisionUser calls. +type mockProvisioner struct { + mockHandler + calls []pkgchannel.ProvisionRequest + err error +} + +func (m *mockProvisioner) ProvisionUser(_ context.Context, req pkgchannel.ProvisionRequest) error { + m.calls = append(m.calls, req) + return m.err +} + +func newProvisionBot(cfg Config, p *mockProvisioner) *Bot { + b := &Bot{ + handler: p, + cfg: cfg, + provisioned: make(map[string]time.Time), + chatModels: make(map[string]pkgchannel.ModelOption), + seenMsgs: make(map[string]time.Time), + } + return b +} + +func TestMaybeAutoProvisionDisabled(t *testing.T) { + p := &mockProvisioner{} + b := newProvisionBot(Config{AppID: "a", AppSecret: "s", AutoProvision: false, TenantKey: "t1"}, p) + // With a nil client, fetchTenantProfile would panic — but it should never be + // reached when AutoProvision is false. + b.maybeAutoProvision(context.Background(), "ou_open1", "on_union1", "t1") + if len(p.calls) != 0 { + t.Errorf("expected 0 calls, got %d", len(p.calls)) + } +} + +func TestMaybeAutoProvisionNoTenantKeyKnown(t *testing.T) { + // Neither cfg.TenantKey nor learnedTenantKey is set — should skip silently. + p := &mockProvisioner{} + b := newProvisionBot(Config{AppID: "a", AppSecret: "s", AutoProvision: true, TenantKey: ""}, p) + b.maybeAutoProvision(context.Background(), "ou_open1", "on_union1", "t1") + if len(p.calls) != 0 { + t.Errorf("expected 0 calls, got %d", len(p.calls)) + } +} + +func TestMaybeAutoProvisionLearnedTenantKey(t *testing.T) { + // learnedTenantKey set (simulates startup fetch) — wrong-tenant event skips. + p := &mockProvisioner{} + b := newProvisionBot(Config{AppID: "a", AppSecret: "s", AutoProvision: true, TenantKey: ""}, p) + b.learnedTenantKey = "t1" + // Wrong tenant in event: should skip. + b.maybeAutoProvision(context.Background(), "ou_open1", "on_union1", "wrong") + if len(p.calls) != 0 { + t.Errorf("wrong tenant with learned key: expected 0 calls, got %d", len(p.calls)) + } +} + +func TestMaybeAutoProvisionNoProvisioner(t *testing.T) { + // handler does not implement Provisioner. + b := &Bot{ + handler: &mockHandler{}, + cfg: Config{AppID: "a", AppSecret: "s", AutoProvision: true, TenantKey: "t1"}, + provisioned: make(map[string]time.Time), + } + // Should return silently without panic. + b.maybeAutoProvision(context.Background(), "ou_open1", "on_union1", "t1") +} + +func TestMaybeAutoProvisionCacheHit(t *testing.T) { + p := &mockProvisioner{} + b := newProvisionBot(Config{AppID: "a", AppSecret: "s", AutoProvision: true, TenantKey: "t1"}, p) + // Pre-populate the cache so it looks like this user was recently provisioned. + b.provisioned["on_union1"] = time.Now() + + b.maybeAutoProvision(context.Background(), "ou_open1", "on_union1", "t1") + if len(p.calls) != 0 { + t.Errorf("cache hit: expected 0 calls, got %d", len(p.calls)) + } +} + +func TestMaybeAutoProvisionWrongTenantSkips(t *testing.T) { + p := &mockProvisioner{} + b := newProvisionBot(Config{AppID: "a", AppSecret: "s", AutoProvision: true, TenantKey: "t1"}, p) + // Pass a different tenant key — should return before any API call. + b.maybeAutoProvision(context.Background(), "ou_open1", "on_union1", "wrong_tenant") + if len(p.calls) != 0 { + t.Errorf("wrong tenant: expected 0 calls, got %d", len(p.calls)) + } +} + +func TestMaybeAutoProvisionNilProfileSkips(t *testing.T) { + // Bot with a nil lark client — fetchTenantProfile will fail and return nil. + // maybeAutoProvision should skip silently. + p := &mockProvisioner{} + b := newProvisionBot(Config{AppID: "a", AppSecret: "s", AutoProvision: true, TenantKey: "t1"}, p) + // client is nil → fetchTenantProfile returns nil + b.maybeAutoProvision(context.Background(), "ou_open1", "on_union1", "t1") + // No panic, no provision call (profile was nil). + if len(p.calls) != 0 { + t.Errorf("nil profile: expected 0 calls, got %d", len(p.calls)) + } +} diff --git a/plugins/channels/feishu/runtime.go b/plugins/channels/feishu/runtime.go index a6332275..f956d19f 100644 --- a/plugins/channels/feishu/runtime.go +++ b/plugins/channels/feishu/runtime.go @@ -29,6 +29,8 @@ func NewFeishuManagedRuntime(deps FeishuRuntimeDeps) pkgplugins.Runtime { VerificationToken: cfg.VerificationToken, GroupMode: cfg.GroupMode, Groups: groupsToPluginConfig(cfg.Groups), + TenantKey: cfg.TenantKey, + AutoProvision: cfg.AutoProvision, }, handler) } } @@ -135,6 +137,15 @@ func configSchema() map[string]any { "description": "Whether scheduler and system notifications are delivered to Feishu.", "default": false, }, + "tenant_key": map[string]any{ + "type": "string", + "description": "Feishu tenant key (enterprise ID). Optional — auto-detected at startup via the Feishu tenant API. Set explicitly to override.", + }, + "auto_provision": map[string]any{ + "type": "boolean", + "description": "Automatically create Anna accounts for members of the bot's Feishu tenant.", + "default": false, + }, }, "required": []any{"app_id", "app_secret"}, } diff --git a/plugins/channels/feishu/runtime_test.go b/plugins/channels/feishu/runtime_test.go index 8bb9d7f9..90fd7677 100644 --- a/plugins/channels/feishu/runtime_test.go +++ b/plugins/channels/feishu/runtime_test.go @@ -230,3 +230,25 @@ func waitClosed(t *testing.T, ch <-chan struct{}, label string) { t.Fatalf("timeout waiting for %s", label) } } + +func TestValidateConfigAutoProvisionNoTenantKey(t *testing.T) { + // tenant_key is optional; auto-detected at startup via the Feishu tenant API. + cfg := pkgchannel.FeishuConfig{AppID: "a", AppSecret: "s", AutoProvision: true, TenantKey: ""} + if got := validateConfig(cfg); got != "" { + t.Errorf("unexpected validation error: %q", got) + } +} + +func TestValidateConfigAutoProvisionWithTenantKey(t *testing.T) { + cfg := pkgchannel.FeishuConfig{AppID: "a", AppSecret: "s", AutoProvision: true, TenantKey: "tenant123"} + if got := validateConfig(cfg); got != "" { + t.Errorf("unexpected validation error: %q", got) + } +} + +func TestValidateConfigNoAutoProvision(t *testing.T) { + cfg := pkgchannel.FeishuConfig{AppID: "a", AppSecret: "s"} + if got := validateConfig(cfg); got != "" { + t.Errorf("unexpected validation error: %q", got) + } +} diff --git a/plugins/sandbox/docker/dockerclient/container.go b/plugins/sandbox/docker/dockerclient/container.go index 38481a4e..e05392f2 100644 --- a/plugins/sandbox/docker/dockerclient/container.go +++ b/plugins/sandbox/docker/dockerclient/container.go @@ -3,6 +3,7 @@ package dockerclient import ( "context" "fmt" + "log/slog" "sort" "github.com/containerd/errdefs" @@ -42,7 +43,19 @@ type CreateOptions struct { // CreateAndStart creates a container with an always-up sentinel entrypoint // (`sh -c 'tail -f /dev/null'`), starts it, and returns the container ID. +// If the image is not present locally it is pulled automatically. func (c *Client) CreateAndStart(ctx context.Context, opts CreateOptions) (string, error) { + exists, err := c.ImageExists(ctx, opts.Image) + if err != nil { + return "", fmt.Errorf("dockerclient: image check %s: %w", opts.Image, err) + } + if !exists { + slog.Info("dockerclient: image not found locally, pulling", "image", opts.Image) + if err := c.PullImage(ctx, opts.Image); err != nil { + return "", err + } + } + createOpts := buildContainerCreateOptions(opts) created, err := c.api.ContainerCreate(ctx, createOpts)