diff --git a/docs/assets/feishu-setup/01-credentials.png b/docs/assets/feishu-setup/01-credentials.png new file mode 100644 index 0000000..7a52d63 Binary files /dev/null and b/docs/assets/feishu-setup/01-credentials.png differ diff --git a/docs/assets/feishu-setup/02-permissions.png b/docs/assets/feishu-setup/02-permissions.png new file mode 100644 index 0000000..f6e978b Binary files /dev/null and b/docs/assets/feishu-setup/02-permissions.png differ diff --git a/docs/assets/feishu-setup/03-events.png b/docs/assets/feishu-setup/03-events.png new file mode 100644 index 0000000..5a1a3e2 Binary files /dev/null and b/docs/assets/feishu-setup/03-events.png differ diff --git a/docs/assets/feishu-setup/04-callbacks.png b/docs/assets/feishu-setup/04-callbacks.png new file mode 100644 index 0000000..ddbeabf Binary files /dev/null and b/docs/assets/feishu-setup/04-callbacks.png differ diff --git a/docs/assets/feishu-setup/05-security.png b/docs/assets/feishu-setup/05-security.png new file mode 100644 index 0000000..960fb9d Binary files /dev/null and b/docs/assets/feishu-setup/05-security.png differ diff --git a/docs/assets/feishu-setup/06-publish.png b/docs/assets/feishu-setup/06-publish.png new file mode 100644 index 0000000..addd926 Binary files /dev/null and b/docs/assets/feishu-setup/06-publish.png differ diff --git a/docs/para-26071100-feishu-setup.md b/docs/para-26071100-feishu-setup.md index 192c019..87ce463 100644 --- a/docs/para-26071100-feishu-setup.md +++ b/docs/para-26071100-feishu-setup.md @@ -1,81 +1,219 @@ -# para-26071100 飞书应用配置清单 +# Educraft 组织接入与飞书应用配置指南 -本文供 `para-26071100` 的飞书企业管理员操作。不要把 App Secret 粘贴到群聊、工单或本文档中;请通过约定的安全渠道交给平台部署人员。 +本文供准备接入 Educraft Alpha Silo 的学校、教培机构和组织管理员使用。完成本文后,请把末尾的“部署信息交付单”交给 Educraft 部署人员;我们会为组织创建独立的服务账号、数据库、运行目录和域名入口。 -## 1. 创建企业自建应用 +> **安全提醒:** App Secret、模型 Provider Token 属于密钥,禁止粘贴到飞书群、普通云文档、工单正文或截图中。请只通过双方约定的安全渠道传递。 -1. 打开飞书开放平台开发者后台。 -2. 在目标企业下创建“企业自建应用”。 -3. 应用名称可填写 `Educraft para-26071100`。 -4. 在“凭证与基础信息”记录: - - App ID(通常以 `cli_` 开头) - - App Secret -5. 添加并启用“机器人”能力。 +## 1. 双方分别负责什么 -Bot Open ID 不需要管理员手工寻找。平台部署人员会使用 App ID/App Secret 调用飞书 Bot Info API 获取,并在 bootstrap 时校验它确实属于这一个应用。 +| 角色 | 负责事项 | +| --- | --- | +| 组织管理员 | 创建企业自建应用、启用机器人、开通最小权限、配置事件、回调和 OAuth 重定向 URL、发布应用、提供 OWNER 身份 | +| Educraft 部署人员 | 分配组织 slug 和域名、部署独立 Silo、加密保存应用及模型密钥、初始化 OWNER、联调和验收 | +| 试点 OWNER | 把机器人加入试点群、创建或绑定项目、组织首轮验收 | -## 2. 开通权限 +## 2. 创建企业自建应用 -在“权限管理”中搜索并申请下列能力。飞书控制台的中文名称可能随版本调整;如控制台同时显示 scope,可优先核对括号中的 scope。 +1. 打开[飞书开放平台开发者后台](https://open.feishu.cn/app)。 +2. 在目标企业下点击“创建企业自建应用”。应用名称建议填写“Educraft + 组织简称”。 +3. 进入“凭证与基础信息”,记录 App ID 和 App Secret。 +4. 进入“添加应用能力”,添加并启用“机器人”。 -- 接收群聊中 @ 机器人的消息(`im:message.group_at_msg:readonly`) -- 以应用身份发送消息(`im:message:send_as_bot`) -- 获取消息内容,用于读取触发消息和线程上下文(`im:message:readonly`) -- 获取与上传图片或文件资源(`im:resource`) -- 添加、删除消息表情回复(`im:message.reactions:write_only`) -- 获取用户基本信息(`contact:user.base:readonly`、`contact:user.basic_profile:readonly`) +![凭证与基础信息页面;App Secret 默认以星号隐藏](assets/feishu-setup/01-credentials.png) -如果飞书 API 调试台提示某个上述操作缺少更细粒度权限,请把提示截图交给平台部署人员,不要直接勾选通讯录全量读取或其他超出清单的权限。 +App ID 通常以 `cli_` 开头,可以写入交付单。App Secret 必须通过安全渠道单独发送。Bot Open ID 不需要管理员手工查找;部署程序会用 App ID/App Secret 调用 Bot Info API 获取并校验归属。 -## 3. 配置事件与卡片回调 +## 3. 开通最小权限 -1. 进入“事件与回调”。 -2. 订阅方式选择“使用长连接接收事件”。 -3. 添加事件 `im.message.receive_v1`(接收消息)。 -4. 启用卡片交互回调 `card.action.trigger`,用于审批、中断运行和群聊建项目按钮。 -5. 不需要填写公网 Event Callback URL;Hub 使用飞书长连接。 +进入“权限管理”,点击“开通权限”,搜索并申请以下应用身份权限。控制台中文名称可能调整,请优先核对 scope。 -## 4. 配置 OAuth 回调 +| 用途 | Scope | +| --- | --- | +| 接收群聊中 @ 机器人的消息 | `im:message.group_at_msg:readonly` | +| 以应用身份发送消息 | `im:message:send_as_bot` | +| 读取触发消息和线程上下文 | `im:message:readonly` | +| 获取与上传图片或文件 | `im:resource` | +| 添加、删除消息表情回复 | `im:message.reactions:write_only` | +| 获取用户基本信息 | `contact:user.base:readonly` | +| 获取用户基本资料 | `contact:user.basic_profile:readonly` | +| 通过手机号或邮箱查询 OWNER Open ID | `contact:user.id:readonly` | -域名 DNS 和 TLS 生效后,在安全设置/重定向 URL 中添加: +### 批量导入权限(推荐) -```text -https://para-26071100.educraft.paradigm-edu.net/auth/feishu/callback +在“权限管理”页面点击“批量处理 → 导入”,粘贴以下 JSON 后确认。导入只会新增本次列出的权限,不会删除或影响应用已经申请、开通的其他权限。 + +```json +{ + "scopes": { + "tenant": [ + "im:message.group_at_msg:readonly", + "im:message:send_as_bot", + "im:message:readonly", + "im:resource", + "im:message.reactions:write_only", + "contact:user.base:readonly", + "contact:user.basic_profile:readonly", + "contact:user.id:readonly" + ], + "user": [] + } +} ``` -该回调用于 OWNER 登录受控的 Host Console。飞书群机器人长连接本身不依赖这个 URL。 +Educraft 机器人以应用身份调用上述 API,因此这些 scope 全部放在 `tenant`,不要为了省事把相同权限重复放进 `user`。 -## 5. 发布并安装应用 +![权限管理入口与已开通权限列表](assets/feishu-setup/02-permissions.png) -1. 创建应用版本并提交企业管理员审核。 -2. 将应用可用范围至少包含试点 OWNER 和试点群成员。 -3. 发布版本。 -4. 将机器人加入准备试用的飞书群。 +如果 API 调试台提示缺少更细粒度权限,请把错误提示和发生时间截图给部署人员。不要自行开通通讯录全量读取等超出本表的权限。 -## 6. 获取首位 OWNER 身份 +## 4. 配置事件与卡片回调 -平台 bootstrap 需要 OWNER 的飞书 Open ID 和显示名称。可通过飞书 API 调试台的用户信息接口查询;Open ID 通常以 `ou_` 开头。Union ID 可选,不影响首次部署。 +进入“事件与回调”。 -请把以下结果通过安全渠道交给平台部署人员: +1. 在“事件配置”中将订阅方式设为“使用长连接接收事件”。 +2. 添加事件“接收消息” `im.message.receive_v1`。 +3. 在“回调配置”中同样选择长连接。 +4. 添加回调“卡片回传交互” `card.action.trigger`,用于审批、运行中断和项目创建/绑定按钮。 + +![长连接与消息事件配置](assets/feishu-setup/03-events.png) + +![卡片交互回调配置](assets/feishu-setup/04-callbacks.png) + +这里不需要填写公网 Event Callback URL。Educraft Hub 使用飞书官方 SDK 的长连接模式。 + +## 5. 配置用户 OAuth 重定向 URL(必需) + +普通群成员首次使用前,需要通过飞书 OAuth 建立其在本应用下的用户身份。进入“安全设置 → 重定向 URL”,添加组织专属 callback: ```text -Organization: para-26071100 -App ID: cli_... -App Secret: (安全渠道发送) -OWNER Open ID: ou_... -OWNER 显示名称: -OWNER Union ID: (可选) -试点群名称: (可选,便于验收) +https://.educraft.paradigm-edu.net/auth/feishu/callback ``` -## 7. 验收动作 +例如组织 slug 为 `example-school`: -平台通知部署完成后: +```text +https://example-school.educraft.paradigm-edu.net/auth/feishu/callback +``` -1. OWNER 打开 Host Console,完成飞书 OAuth 登录。 -2. 在试点群中 @机器人发送一条纯文本消息。 -3. 如果群尚未绑定项目,机器人应返回项目创建/绑定卡片。 -4. 创建项目后再次 @机器人,确认出现处理状态、流式卡片和最终回答。 -5. 再测试一个小文件附件,以及运行中断按钮。 +![在安全设置中添加组织专属 OAuth 重定向 URL](assets/feishu-setup/05-security.png) -任何一步失败时,请保留发生时间、群名、消息截图和飞书 request/log ID;不要在截图中包含 App Secret 或 Provider token。 +必须使用 Educraft 部署人员最终确认的 slug;不要直接照抄示例。该 URL 用于 OAuth 返回并创建应用作用域下的飞书用户身份,不代表当前已经开放组织管理台。 + +身份登录和组织成员资格是两件事:OAuth 登录只建立用户身份;当前 Alpha 阶段,Educraft 部署人员还需要通过受控管理命令把该用户加入组织。首位 OWNER 在 bootstrap 时由部署人员完成,其他试点成员按需人工加入。 + +## 6. 发布并安装应用 + +1. 进入“版本管理与发布”,点击“创建版本”。 +2. 将应用可用范围至少覆盖试点 OWNER 和试点群成员。 +3. 提交企业管理员审核并发布。 +4. 发布成功后,将机器人加入准备试用的群。 + +![版本管理与发布页面](assets/feishu-setup/06-publish.png) + +仅保存开发配置但未发布时,新增权限、事件和可用范围通常不会对试点用户生效。 + +## 7. 获取首位 OWNER 身份 + +首次部署必须指定一位组织 OWNER。OWNER 是 Educraft 组织内的初始管理员,不等同于飞书应用所有者;两者可以是同一个人,也可以不同。部署所需的 Open ID 必须由本次创建的企业自建应用查询,因为同一用户在不同应用下的 Open ID 不同,不能复用其他应用查到的值。 + +### 7.1 确认 OWNER + +先确认哪一位企业成员将担任 OWNER。记录其飞书显示名称,并准备在飞书的成员选择器中按姓名找到本人。若企业内有同名成员,选择前须通过部门等信息核对身份。 + +### 7.2 开通查询权限和数据范围 + +确认应用已开通上文列出的用户基本信息和用户 ID 权限。如果使用上文的批量导入 JSON,这些权限已包含在内。 + +应用的通讯录数据范围还必须覆盖这位 OWNER。最小做法是把 OWNER 加入应用可用范围;不需要为此开放全企业通讯录。 + +### 7.3 在官方接口页面获取 Open ID + +1. 打开飞书开放平台的[“获取单个用户信息”接口页面](https://open.feishu.cn/document/server-docs/contact-v3/user/get)。如果使用带 `appId` 参数的页面链接,可以直接进入对应应用;本文不提供固定 App ID,请在页面顶部选择本组织刚创建的企业自建应用,并核对 App ID 与交付单一致。 +2. 找到路径参数 `user_id`,点击参数输入框旁的“获取”。 +3. 在成员选择器中找到并选择 OWNER;如有同名成员,依据部门等信息确认本人。 +4. ID 类型选择 `open_id`。将选择器返回的值填入 `user_id`,并保持查询参数 `user_id_type=open_id`。 +5. 以应用身份(`tenant_access_token`)调用接口,核对成功响应中 `data.user.name` 与 OWNER 本人一致。 +6. 复制完整的 `data.user.open_id` 交给 Educraft 部署人员。Open ID 通常以 `ou_` 开头。 + +参数旁的“获取”是飞书文档调试台提供的成员选择功能,不是要求管理员预先知道 Open ID。不要复用其他应用查到的 Open ID;同一用户在不同应用下的 Open ID 不同。若无法选择成员或接口调用失败,依次检查:页面当前选择的 App ID、应用可用范围和通讯录数据范围是否覆盖 OWNER、用户基本信息与用户 ID 权限是否已开通并随应用版本发布。 + +### 7.4 核对并交付 + +交付前完成以下检查: + +- 返回用户的姓名与 OWNER 本人一致; +- Open ID 来自本次组织的这一个 App ID; +- Open ID 完整复制,没有空格或省略号; +- 显示名称使用组织希望在 Educraft 中展示的姓名; +- Union ID 不是必填项,查不到可以留空。 + +最终向部署人员提供: + +```text +OWNER Open ID:ou_... +OWNER 显示名称: +OWNER Union ID:(可选) +用于查询的 App ID:cli_... +``` + +Open ID 和显示名称可以放在普通交付单中;不要把 App Secret 一起粘贴进去。 + +## 8. 部署信息交付单 + +请复制下面的模板填写。标注“安全渠道”的字段不要与普通字段放在同一条群消息或云文档中。 + +```text +【组织信息】 +组织正式名称: +组织简称: +期望 organization slug:(小写字母、数字和连字符,例如 example-school) +期望机器人显示名称: + +【飞书应用】 +App ID:cli_... +App Secret:(通过安全渠道单独发送) +应用已发布:是 / 否 +机器人能力已启用:是 / 否 +消息事件和卡片回调已配置:是 / 否 +OAuth 重定向 URL 已配置:是 / 否 + +【首位 OWNER】 +OWNER Open ID:ou_... +OWNER 显示名称: +OWNER Union ID:(可选) + +【试点范围】 +试点群名称:(可选,用于验收定位) +初始 Team 名称:(可选;没有 Team 不影响首次部署) +预计试用人数: + +【模型配置】 +Provider 名称:(例如 OpenRouter) +Provider Base URL: +Provider Token:(通过安全渠道单独发送) +启用的模型 ID: +``` + +Educraft 部署人员收到信息后,会回传最终 organization slug、访问域名、部署窗口和验收时间。若期望 slug 已被占用或不符合命名规则,会在部署前协调调整。 + +## 9. 上线验收 + +部署人员通知服务就绪后,由 OWNER 完成: + +1. OWNER 在试点群中 @机器人发送一条纯文本消息。 +2. 如果群尚未绑定项目,确认机器人返回项目创建/绑定卡片。 +3. 创建项目后再次 @机器人,确认出现处理状态、流式卡片和最终回答。 +4. 选择一位非 OWNER 试点成员完成 OAuth 登录,并由部署人员加入组织。 +5. 该成员在同一群中 @机器人,确认能够进入已绑定项目。 +6. 测试一个小文件附件、一次运行中断,以及一个需要生成文档的任务。 + +出现问题时,请保留发生时间、群名、消息截图和飞书 request/log ID。截图前确认其中不包含 App Secret、Provider Token 或其他密钥。 + +## 10. Alpha 阶段边界 + +- 每个组织运行在独立的系统用户、服务实例、数据库和持久化目录中。 +- 组织的 role、system prompt、tools 和 skills 是运行时配置,不需要跟随版本发布。 +- 同一项目同一时间只执行一个任务,避免并发修改同一个 workspace;组织级并发上限由部署配置决定。 +- 当前由 Educraft 人工创建组织、OWNER、Provider Connection 和初始 Team,并通过服务器上的受控管理命令运维;组织管理台尚未开放。 +- 非 OWNER 试点成员需要先完成飞书 OAuth 身份建立,再由 Educraft 部署人员人工加入组织;当前没有自助入组织入口。 +- Alpha 不提供开放注册、自助密钥管理或跨组织资源共享。 diff --git a/hub/package-lock.json b/hub/package-lock.json index bc26cdd..28cbf0d 100644 --- a/hub/package-lock.json +++ b/hub/package-lock.json @@ -1,12 +1,12 @@ { "name": "@paradigm/hub", - "version": "0.0.10", + "version": "0.0.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@paradigm/hub", - "version": "0.0.10", + "version": "0.0.11", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.202", "@fastify/cookie": "^11.0.2", diff --git a/hub/package.json b/hub/package.json index 98883a2..b360339 100644 --- a/hub/package.json +++ b/hub/package.json @@ -1,6 +1,6 @@ { "name": "@paradigm/hub", - "version": "0.0.10", + "version": "0.0.11", "private": true, "type": "module", "engines": { diff --git a/hub/src/feishu/trigger.ts b/hub/src/feishu/trigger.ts index 5c943cc..dd49786 100644 --- a/hub/src/feishu/trigger.ts +++ b/hub/src/feishu/trigger.ts @@ -85,6 +85,10 @@ interface TriggerDeps { readonly messageBatcherOptions?: MessageBatcherOptions | undefined; readonly triggerQueue?: TriggerQueue | undefined; readonly projectWorkspaceRoot: string; + /** Public origin used to build the Silo Organization's Feishu OAuth entrypoint. */ + readonly publicBaseUrl: string; + /** The single Organization this Alpha Silo is fail-closed to serve. */ + readonly siloOrganizationId: string; /** Alpha Silo policy: never acknowledge work into the process-local queue. */ readonly rejectWhenBusy?: boolean | undefined; readonly resourceLimits?: { @@ -133,6 +137,9 @@ export interface TriggerHandler { export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { const projectWorkspaceRoot = deps.projectWorkspaceRoot.trim(); if (projectWorkspaceRoot === "") throw new Error("projectWorkspaceRoot is required"); + const publicBaseUrl = parsePublicBaseUrl(deps.publicBaseUrl); + const siloOrganizationId = deps.siloOrganizationId.trim(); + if (siloOrganizationId === "") throw new Error("siloOrganizationId is required"); const authorizer = deps.authorizer ?? createPermissionAuthorizer(deps.prisma); const runAgent = deps.runAgent ?? defaultRunAgent; const approvalManager = new ApprovalManager(); @@ -936,6 +943,15 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { await sendText(rt, chatId, "无法识别发送者,拒绝触发。", sendOptionsForTriggerMessage(msg)); return; } + const organizationResolution = await resolveSingleActiveOrganizationForFeishuUser(senderOpenId); + if (organizationResolution.status === "error" && organizationResolution.reason !== "organization_unavailable") { + deps.logger.info( + { projectId, senderOpenId, reason: organizationResolution.reason }, + "feishu trigger: actor onboarding required before project authorization", + ); + await sendText(rt, chatId, organizationResolution.message, sendOptionsForTriggerMessage(msg)); + return; + } const actor = { feishuOpenId: senderOpenId, chatId }; const triggerDecision = await authorizer.can({ actor, @@ -1083,17 +1099,33 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { readonly organizationName: string; readonly role: "OWNER" | "ADMIN" | "MEMBER"; } - | { readonly status: "error"; readonly message: string } + | { + readonly status: "error"; + readonly reason: "identity_missing" | "membership_missing" | "organization_unavailable"; + readonly message: string; + } > { + const siloOrganization = await deps.prisma.organization.findFirst({ + where: { id: siloOrganizationId, status: "ACTIVE" }, + select: { id: true, slug: true }, + }); + if (siloOrganization === null) { + return { + status: "error", + reason: "organization_unavailable", + message: "组织当前不可用,请联系 Educraft 运维人员。", + }; + } const identity = await deps.prisma.feishuUserIdentity.findFirst({ where: { openId: feishuOpenId, - connection: { status: "ACTIVE", organization: { status: "ACTIVE" } }, + connection: { status: "ACTIVE", organizationId: siloOrganization.id }, }, select: { user: { select: { organizationMemberships: { where: { revokedAt: null, + organizationId: siloOrganization.id, organization: { status: "ACTIVE" }, }, select: { @@ -1109,19 +1141,34 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { where: { feishuOpenId }, select: { organizationMemberships: { - where: { revokedAt: null, organization: { status: "ACTIVE" } }, + where: { + revokedAt: null, + organizationId: siloOrganization.id, + organization: { status: "ACTIVE" }, + }, select: { role: true, organization: { select: { id: true, name: true } } }, orderBy: { createdAt: "asc" }, }, }, }) : null); - if (user === null) return { status: "error", message: "请先登录并加入组织后,再绑定项目。" }; - if (user.organizationMemberships.length === 0) { - return { status: "error", message: "你还不属于任何可用组织,请联系组织管理员。" }; + if (user === null) { + const loginUrl = new URL( + `/auth/feishu/${encodeURIComponent(siloOrganization.slug)}`, + publicBaseUrl, + ).toString(); + return { + status: "error", + reason: "identity_missing", + message: `请先通过飞书登录建立身份:${loginUrl}\n登录后仍需由组织管理员将你加入组织。`, + }; } - if (user.organizationMemberships.length > 1) { - return { status: "error", message: "你属于多个组织。请先从组织后台选择项目绑定,或等待多组织选择入口。" }; + if (user.organizationMemberships.length === 0) { + return { + status: "error", + reason: "membership_missing", + message: "你已完成飞书登录,但尚未加入该组织。请联系组织管理员为你开通成员权限。", + }; } const membership = user.organizationMemberships[0]!; return { @@ -1184,6 +1231,19 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { return Object.assign(onMessage, { onCardAction, approvalManager }); } +function parsePublicBaseUrl(raw: string): URL { + const value = raw.trim(); + if (value === "") throw new Error("publicBaseUrl is required"); + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("publicBaseUrl must use http or https"); + } + if (url.username !== "" || url.password !== "" || url.search !== "" || url.hash !== "" || url.pathname !== "/") { + throw new Error("publicBaseUrl must be an origin without credentials, path, query, or fragment"); + } + return url; +} + interface SessionMetadata { readonly claudeSessionId?: string | undefined; } diff --git a/hub/src/hub.ts b/hub/src/hub.ts index d809fcb..bc24897 100644 --- a/hub/src/hub.ts +++ b/hub/src/hub.ts @@ -160,6 +160,8 @@ export async function startHub(): Promise { settings: runtimeSettings, logger: app.log, projectWorkspaceRoot, + publicBaseUrl, + siloOrganizationId: siloOrganization.id, rejectWhenBusy: true, resourceLimits: { maxFilesPerMessage, maxBytesPerFile }, allowLegacyFeishuIdentity: false, diff --git a/hub/test/integration/trigger.test.ts b/hub/test/integration/trigger.test.ts index 2848cfb..cf0cc54 100644 --- a/hub/test/integration/trigger.test.ts +++ b/hub/test/integration/trigger.test.ts @@ -3,7 +3,15 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { Readable } from "node:stream"; import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest"; -import { DEFAULT_ORG_ID, prisma, resetDb, mockFeishuRuntime, seedProject, silentLogger } from "./helpers.js"; +import { + DEFAULT_ORG_ID, + prisma, + resetDb, + mockFeishuRuntime, + seedProject, + seedTestOrganization, + silentLogger, +} from "./helpers.js"; import { InMemoryModelRegistry } from "../../src/agent/models.js"; import { createSlashCommandRegistry } from "../../src/feishu/slashCommands.js"; import { makeTriggerHandler as makeProductionTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js"; @@ -24,6 +32,8 @@ type TestTriggerDeps = Omit[0], function makeTriggerHandler(deps: TestTriggerDeps): ReturnType { return makeProductionTriggerHandler({ projectWorkspaceRoot: "/tmp", + publicBaseUrl: "https://educraft.example.test", + siloOrganizationId: DEFAULT_ORG_ID, allowLegacyFeishuIdentity: true, ...deps, }); @@ -386,6 +396,18 @@ describe("trigger full lifecycle (integration)", () => { role: "EDIT", }, }); + await prisma.user.createMany({ + data: [ + { id: "u-speaker-alice", feishuOpenId: "ou_alice", displayName: "Alice" }, + { id: "u-speaker-bob", feishuOpenId: "ou_bob", displayName: "Bob" }, + ], + }); + await prisma.organizationMembership.createMany({ + data: [ + { organizationId: DEFAULT_ORG_ID, userId: "u-speaker-alice", role: "MEMBER" }, + { organizationId: DEFAULT_ORG_ID, userId: "u-speaker-bob", role: "MEMBER" }, + ], + }); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); await trigger(makeEvent("chat-speaker", "@_user_1 Alice 的需求", "ou_alice"), rt); @@ -626,6 +648,66 @@ describe("trigger full lifecycle (integration)", () => { expect(runs).toHaveLength(0); }); + it("gives an unknown user the scoped login URL in an already-bound chat", async () => { + await seedProject("proj-bound-unknown", "chat-bound-unknown"); + const trigger = makeTriggerHandler({ + prisma, + settings, + logger: silentLogger, + runAgent, + allowLegacyFeishuIdentity: false, + messageBatcherOptions: { maxMessages: 1 }, + }); + + await trigger(makeEvent("chat-bound-unknown", "@_user_1 写教案", "ou_bound_unknown"), rt); + + expect(rt.sentTexts).toContain( + "请先通过飞书登录建立身份:https://educraft.example.test/auth/feishu/test-default\n" + + "登录后仍需由组织管理员将你加入组织。", + ); + expect(rt.sentTexts).not.toContain("无权限触发。"); + expect(runAgentCalls).toHaveLength(0); + }); + + it("tells a logged-in non-member to contact the administrator in an already-bound chat", async () => { + await seedProject("proj-bound-non-member", "chat-bound-non-member"); + await seedScopedIdentityWithoutMembership("bound-non-member", "ou_bound_non_member"); + const trigger = makeTriggerHandler({ + prisma, + settings, + logger: silentLogger, + runAgent, + allowLegacyFeishuIdentity: false, + messageBatcherOptions: { maxMessages: 1 }, + }); + + await trigger(makeEvent("chat-bound-non-member", "@_user_1 写教案", "ou_bound_non_member"), rt); + + expect(rt.sentTexts).toContain( + "你已完成飞书登录,但尚未加入该组织。请联系组织管理员为你开通成员权限。", + ); + expect(rt.sentTexts).not.toContain("无权限触发。"); + expect(runAgentCalls).toHaveLength(0); + }); + + it("keeps generic project denial for an Organization member without project permission", async () => { + await seedProject("proj-bound-read-only", "chat-bound-read-only", { role: "READ" }); + const trigger = makeTriggerHandler({ + prisma, + settings, + logger: silentLogger, + runAgent, + messageBatcherOptions: { maxMessages: 1 }, + }); + + await trigger(makeEvent("chat-bound-read-only", "@_user_1 写教案"), rt); + + expect(rt.sentTexts).toContain("无权限触发。"); + expect(rt.sentTexts.join("\n")).not.toContain("/auth/feishu/"); + expect(rt.sentTexts.join("\n")).not.toContain("尚未加入该组织"); + expect(runAgentCalls).toHaveLength(0); + }); + it.each(["SUSPENDED", "ARCHIVED"] as const)( "rejects triggers and resume commands when the organization is %s", async (status) => { @@ -922,18 +1004,62 @@ describe("trigger full lifecycle (integration)", () => { await expect(readdir(join(workspaceRoot, ".cph-staging"))).resolves.toEqual([]); }); - it("does not create a run for unbound chats and asks unknown users to log in", async () => { + it("does not create a run for unbound chats and gives unknown users the scoped login URL", async () => { await seedProject("proj-4", "chat-4"); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); await trigger(makeEvent("chat-UNKNOWN", "@_user_1 写教案", "ou_unknown_user"), rt); - expect(rt.sentTexts).toContain("请先登录并加入组织后,再绑定项目。"); + expect(rt.sentTexts).toContain( + "请先通过飞书登录建立身份:https://educraft.example.test/auth/feishu/test-default\n" + + "登录后仍需由组织管理员将你加入组织。", + ); expect(rt.sentCards).toHaveLength(0); const runs = await prisma.agentRun.findMany(); expect(runs).toHaveLength(0); }); + it("distinguishes a scoped Feishu identity that has not joined the Silo Organization", async () => { + await seedScopedIdentityWithoutMembership("unbound-non-member", "ou_logged_in_not_member"); + const trigger = makeTriggerHandler({ + prisma, + settings, + logger: silentLogger, + runAgent, + allowLegacyFeishuIdentity: false, + messageBatcherOptions: { maxMessages: 1 }, + }); + + await trigger(makeEvent("chat-unbound", "@_user_1 写教案", "ou_logged_in_not_member"), rt); + + expect(rt.sentTexts).toContain( + "你已完成飞书登录,但尚未加入该组织。请联系组织管理员为你开通成员权限。", + ); + expect(rt.sentTexts.join("\n")).not.toContain("/auth/feishu/"); + await expect(prisma.agentRun.count()).resolves.toBe(0); + }); + + it("encodes the configured Organization slug in the OAuth login URL", async () => { + const encodedOrgId = "org_url_encoding"; + await seedTestOrganization(encodedOrgId, "school east/数学?"); + const trigger = makeTriggerHandler({ + prisma, + settings, + logger: silentLogger, + runAgent, + siloOrganizationId: encodedOrgId, + publicBaseUrl: "https://school.example.test/", + messageBatcherOptions: { maxMessages: 1 }, + }); + + await trigger(makeEvent("chat-unbound", "@_user_1 写教案", "ou_unknown_encoded"), rt); + + expect(rt.sentTexts.join("\n")).toContain( + "https://school.example.test/auth/feishu/school%20east%2F%E6%95%B0%E5%AD%A6%3F", + ); + expect(rt.sentTexts.join("\n")).not.toContain("school east/数学?"); + }); + it("ignores messages without @bot mention", async () => { await seedProject("proj-5", "chat-5"); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); @@ -1430,6 +1556,28 @@ async function seedOnboardingUser(id: string, feishuOpenId: string, role: "OWNER }); } +async function seedScopedIdentityWithoutMembership(id: string, openId: string): Promise { + const connectionId = `feishu-connection-${id}`; + await prisma.organizationFeishuApplicationConnection.create({ + data: { + id: connectionId, + organizationId: DEFAULT_ORG_ID, + appIdentityFingerprint: `fingerprint-${id}`, + status: "ACTIVE", + }, + }); + await prisma.user.create({ + data: { + id: `user-${id}`, + displayName: "Logged in user", + feishuOpenId: `legacy-${openId}`, + feishuIdentities: { + create: { connectionId, openId }, + }, + }, + }); +} + async function tempWorkspaceRoot(): Promise { const root = await mkdtemp(join(tmpdir(), "cph-trigger-onboarding-")); workspaceRoots.push(root); diff --git a/hub/test/unit/feishu-approval.test.ts b/hub/test/unit/feishu-approval.test.ts index 0536740..fecace2 100644 --- a/hub/test/unit/feishu-approval.test.ts +++ b/hub/test/unit/feishu-approval.test.ts @@ -110,6 +110,8 @@ describe("Feishu approval cards", () => { logger: silentLogger(), authorizer: allowAllAuthorizer(), projectWorkspaceRoot: "/tmp", + publicBaseUrl: "https://educraft.example.test", + siloOrganizationId: "org_test_default", runAgent: async () => ({ status: "completed", text: "", diff --git a/hub/test/unit/feishu-reactions.test.ts b/hub/test/unit/feishu-reactions.test.ts index d5ff71f..68656a0 100644 --- a/hub/test/unit/feishu-reactions.test.ts +++ b/hub/test/unit/feishu-reactions.test.ts @@ -103,6 +103,8 @@ async function triggerWithRunAgent( logger: rt.logger, authorizer: allowAllAuthorizer(), projectWorkspaceRoot: "/tmp", + publicBaseUrl: "https://educraft.example.test", + siloOrganizationId: "org_test_default", runAgent, messageBatcherOptions: { maxMessages: 1 }, }); @@ -254,6 +256,19 @@ function mockPrisma(): PrismaClient { const session = { id: "session-1", metadata: {} }; const client = { + organization: { + findFirst: vi.fn(async () => ({ id: "org_test_default", slug: "test-default" })), + }, + feishuUserIdentity: { + findFirst: vi.fn(async () => ({ + user: { + organizationMemberships: [{ + role: "OWNER", + organization: { id: "org_test_default", name: "Test Organization" }, + }], + }, + })), + }, feishuEventReceipt: { findUnique: vi.fn(async () => null), create: vi.fn(async () => ({ id: "receipt-1" })),