Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions docs/content/docs/channels/feishu.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<union_id[:8]>` 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.
Expand Down Expand Up @@ -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",
Expand All @@ -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` |
43 changes: 43 additions & 0 deletions docs/content/docs/channels/feishu.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<union_id[:8]>`。用户名冲突时加 `-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 状态。
Expand Down Expand Up @@ -102,6 +141,8 @@ Feishu 支持标准聊天命令:
"verification_token": "",
"group_mode": "mention",
"enable_notify": false,
"tenant_key": "",
"auto_provision": false,
"groups": {
"oc_example": {
"group_mode": "always",
Expand All @@ -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` 配置的群级覆盖项 |
11 changes: 11 additions & 0 deletions internal/admin/ui/pages/channels.templ
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,17 @@ templ dedicatedInstanceFields(model string) {
</select>
}
</div>
<div>
@ui.FormField("Tenant Key") {
<input type="text" x-model={ model + ".tenant_key" } placeholder="optional, auto-detected at startup" class="input input-bordered w-full text-sm font-mono"/>
}
</div>
</div>
<div class="form-control">
<label class="label cursor-pointer gap-3 justify-start">
<input type="checkbox" x-model={ model + ".auto_provision" } class="toggle toggle-primary toggle-sm"/>
<span class="label-text text-sm">Auto-provision accounts for tenant members</span>
</label>
</div>
</div>

Expand Down
64 changes: 58 additions & 6 deletions internal/admin/ui/pages/channels_templ.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions internal/admin/ui/static/js/pages/channels.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ const platformMeta = {
encrypt_key: '',
verification_token: '',
group_mode: '',
tenant_key: '',
auto_provision: false,
},
},
weixin: {
Expand Down
112 changes: 112 additions & 0 deletions internal/auth/provision.go
Original file line number Diff line number Diff line change
@@ -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 {
Comment on lines +30 to +31
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Return identity lookup errors before provisioning

ProvisionIdentityUser currently treats every GetIdentityByPlatform error as a cache miss and proceeds to create a user. If that lookup fails for reasons other than not-found (for example transient DB/context failures), this path can perform unintended writes and then surface misleading downstream errors from create/rollback attempts. The provisioning flow should only continue on sql.ErrNoRows and immediately return other lookup errors.

Useful? React with 👍 / 👎.

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]
}
Loading
Loading