feat(wecom): 企业微信应用主页免登进 embed 控制台(bump 0.44.0)
- 新端点 GET /v1/wecom/entry:企微客户端内走网页授权 snsapi_base 静默 无感,外部浏览器退 wwlogin 扫码,同一 URL 两端可用;state 签 login 哨兵防 CSRF(绑定回调显式拒绝该哨兵,两条流程不串) - 新端点 GET /v1/wecom/entry/callback:code→userid→反查绑定→签 JWT→ 302 dev.html?embed=1&wecom=1,token 走 URL fragment 不进日志; 未绑定成员提示先在控制台绑定,不自动建号(避免重复账号) - 前端 wecom 变体:fragment 吃 token 即清 hash,不需要 parent_origin; 401/logout 回 entry 静默重签;iframe embed 与 postMessage 协议零改动 - wecom.py 新增 oauth_inclient_url();提示页 helper 提出共用 - EMBED.md §9 + RUN.md 企微段(应用主页 + 网页授权可信域名两项设置) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
50e1d57ed5
commit
b0a8802d35
29
EMBED.md
29
EMBED.md
|
|
@ -25,6 +25,7 @@ embed 模式下:左上 brand 隐藏 · 退出登录隐藏 · 登录页不显示
|
|||
| `embed` | 是 | 固定 `1`,触发 embed 模式 |
|
||||
| `parent_origin` | 是 | platform 主页 origin(scheme + host + 端口),postMessage 白名单。**没传 / 不匹配 → iframe 显错误占位、所有 message 都被丢** |
|
||||
| `task_id` | 否 | task UUID。首次签发 token 后自动选中该 task 并加载其消息。仅在初次进入生效;后续用户在 UI 内切 task 或 401 重签都不会再回到此 task |
|
||||
| `wecom` | 否 | 固定 `1`,企业微信免登变体(见 §9):token 来自 `/v1/wecom/entry` 回调的 URL fragment,**不走 postMessage、不需要 `parent_origin`**。platform iframe 对接用不到这个参数 |
|
||||
|
||||
示例:
|
||||
```
|
||||
|
|
@ -228,3 +229,31 @@ curl -X POST http://127.0.0.1:8765/v1/auth/login \
|
|||
| 一动作就跳回等待页 | JWT 过期 / 后端校验失败,触发 `zcbot-401` | platform 重新换 token 回推;或检查 zcbot 端 JWT_SECRET 是否变过 |
|
||||
| 嵌入页面 modal(新建任务 / 文件预览)被挤瘪 | iframe 高度不够 | iframe `height` 改 `100vh` 或更高,zcbot modal 是 `position:fixed; inset:0` |
|
||||
| 带 `task_id` 进来后没自动定位 / 报"加载失败" | task_id 不属于当前签发 token 的 user_id,或 UUID 拼写错 | 校验 platform 传的 task_id 归属对的 user;chat 区错误信息会显具体 message |
|
||||
|
||||
---
|
||||
|
||||
## 9. 企业微信免登变体(`?embed=1&wecom=1`)
|
||||
|
||||
> 这一节是 embed 模式的**另一条 token 来路**,面向 zcbot 运维 / 企微管理员;platform iframe 对接(§1–§4)不涉及。
|
||||
|
||||
用途:把 zcbot 配成**企业微信自建应用的「应用主页」**,成员在企微客户端里点开应用即自动登录,无需邮箱密码。
|
||||
|
||||
流程:
|
||||
|
||||
```
|
||||
企微客户端点开应用
|
||||
→ GET /v1/wecom/entry (302 到企微网页授权,snsapi_base 静默无感)
|
||||
→ GET /v1/wecom/entry/callback (code→userid→反查绑定→签 JWT)
|
||||
→ 302 /static/dev.html?embed=1&wecom=1#token=...&user_id=...
|
||||
```
|
||||
|
||||
与 iframe embed 的差异:
|
||||
|
||||
| | iframe embed | wecom 免登 |
|
||||
|---|---|---|
|
||||
| token 来路 | 父页面 postMessage | URL fragment(前端读完立即 `history.replaceState` 清掉) |
|
||||
| `parent_origin` | 必填 | 不需要 |
|
||||
| 401 处理 | 发 `zcbot-401` 等父页面重签 | `location.replace("/v1/wecom/entry")` 静默重签 |
|
||||
| 身份映射 | platform 维护 user_id | `channel_bindings` 里的企微绑定(**未绑定不自动建号**,提示先在控制台绑定) |
|
||||
|
||||
企微后台配置 + 可信域名要求见 `RUN.md` 企业微信段「应用主页免登」。外部浏览器(非企微客户端)打开 `/v1/wecom/entry` 会自动退到 wwlogin 扫码,同一 URL 两端可用。
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
|
||||
### 2026-07
|
||||
|
||||
- **07-07 / 0.44.0**:企业微信应用主页免登——企微后台「应用主页」填 `/v1/wecom/entry`,成员点开应用即静默授权(snsapi_base)登进 embed 控制台。后端两端点(entry 302 授权、callback code→userid→反查绑定→签 JWT→fragment 带 token 302 进 `dev.html?embed=1&wecom=1`),UA 分支让外部浏览器退 wwlogin 扫码;前端 wecom 变体(fragment 吃 token 即清 hash,401 回 entry 静默重签,不需要 parent_origin);未绑定成员提示先在控制台绑定(不自动建号,避免重复账号)。EMBED.md §9 + RUN.md 企微段同步。
|
||||
- **07-07 / 0.43.1**:跳秒指示补两处空窗——① 刷新/重连接管后 phaseSince 随页面内存丢失,退回静态"思考中"不跳秒(恰是用户因疑似卡死而刷新的场景),重建直播卡时立即按"思考中"起跳,下一个阶段事件校正;② 发消息 POST 返回即起跳,覆盖 build_agent + 首轮 TTFT(原先要等 llm_start 才开始计时)。重连后秒数从重连时刻起算(非真实累计),可接受。
|
||||
- **07-07 / 0.43.0**:run 长耗时可视化——修"前端一直静止显示思考中被当成卡死":① 新增 SSE `reasoning{delta}` 事件(loop 提取 `delta.reasoning_content`,thinking 模型分钟级推理原先整段丢弃、前端零反馈),前端渲染灰色折叠"思考过程"卡,历史消息里持久化的 reasoning_content 同款渲染;② 前端活跃状态指示:TTFT/推理期占位段每秒跳"思考中/深度思考中 · Ns"(CSS attr(data-status)),工具卡运行中加 spinner + 跳秒、结果到达定格为执行耗时;每轮 llm_start 重建占位段,工具轮之后不再空窗。
|
||||
- **07-06 / 0.42.5**:497 跳转示例 301→308——平台走 http 入口 POST login 被客户端按 301 语义降级成 GET 报 405;308 保留方法/body。治本仍是调用方直配 https(明文首跳暴露 platform_key)。
|
||||
|
|
|
|||
3
RUN.md
3
RUN.md
|
|
@ -80,7 +80,7 @@
|
|||
# WECOM_CORPID=ww... # 企业 ID(管理员:我的企业→企业信息)
|
||||
# WECOM_AGENTID=1000002 # 自建应用 AgentId
|
||||
# WECOM_SECRET=... # 自建应用 Secret
|
||||
# ZCBOT_PUBLIC_BASE_URL=https://zcbot.example.com # 可选,OAuth 回调主机(须在应用「企业微信授权登录」可信域名内;缺则取请求 base)
|
||||
# ZCBOT_PUBLIC_BASE_URL=https://zcbot.example.com # 可选,OAuth 回调主机(扫码绑定→「企业微信授权登录」可信域名;应用主页免登→「网页授权及JS-SDK可信域名」;缺则取请求 base)
|
||||
# 入站对话(可选,要公网 HTTPS):企微后台「应用→接收消息→设置 API 接收」填回调 URL + 下面两项,
|
||||
# 用户即可在企业微信里直接和 zcbot 对话(回调 URL = <公网 base>/v1/wecom/callback)。
|
||||
# WECOM_CALLBACK_TOKEN=... # 接收消息 Token(企微后台生成)
|
||||
|
|
@ -94,6 +94,7 @@
|
|||
- **扫码授权登录(要 HTTPS 域名)**:管理员在应用→**「企业微信授权登录」**里把 zcbot 域名配进可信域名(注意不是「网页授权可信域名」,是另一项)+ 设 `ZCBOT_PUBLIC_BASE_URL`;用户点「扫码绑定」→ 桌面浏览器出二维码 → 企业微信 App 扫码确认。回调 `/v1/wecom/oauth/callback` 公开(身份从 HMAC state 验)。链接走 `login.work.weixin.qq.com/wwlogin/sso/login`(不是网页授权 `oauth2/authorize`,后者只能在企微客户端内打开 → 桌面浏览器会报「请在企业微信客户端打开链接」)。
|
||||
- 绑定后简报/结果**无条件主动推**(不挑活跃度、无 24h 窗口),适合必达。
|
||||
- **入站对话(可选,要公网 HTTPS)**:企微后台「应用 → 接收消息 → 设置 API 接收」填回调 URL `<公网 base>/v1/wecom/callback` + 自动生成的 Token / EncodingAESKey → 写进 env `WECOM_CALLBACK_TOKEN` / `WECOM_CALLBACK_AESKEY` → 保存时企微 GET 验 URL(`/v1/wecom/callback` GET 自动回 echostr)。配好后用户在企业微信里直接给应用发消息即走 zcbot 对话(与个人微信各一张会话上下文)。agent 跑完走 message/send 主动推回(非被动同步,故无 5s 限制)。**支持文本 + 图片 + 文件**(图片/文件走 media/get 下载,落盘进会话目录 inbound/);语音/视频/位置等暂不处理;未绑定/空消息静默。
|
||||
- **应用主页免登(可选,要公网 HTTPS)**:企微后台应用「应用主页」填 `<公网 base>/v1/wecom/entry`,并在**「网页授权及 JS-SDK 可信域名」**登记 zcbot 域名(与上面扫码的「企业微信授权登录」可信域名是**两项不同设置**,都要配才能两端通;域名校验文件放 `ZCBOT_WECOM_VERIFY_DIR`,同扫码路径)。之后成员在企业微信里点开应用 → snsapi_base 静默授权 → 已绑定者直接进 embed 控制台(`dev.html?embed=1&wecom=1`,token 走 URL fragment,401 自动回 entry 静默重签);**未绑定者提示先在电脑端控制台完成绑定**(不自动建号,避免重复账号)。外部浏览器打开同一 URL 会退到 wwlogin 扫码。
|
||||
- **channel 长会话上下文(微信/企业微信通用,0019)**:常驻会话不再无限膨胀。① **自动分段**——入站时距上次消息超过 `config.json` 的 `channel.session_gap_hours`(默 **6** 小时,设 `<=0` 关闭)→ 软重置:只把「最后一条 user 消息起」喂模型(保留上一轮做续聊锚点),之前的历史仍全留 DB,网页端照旧翻完整记录;② **手动新话题**——用户在微信/企业微信里直接发「新话题 / 新会话 / `/new` / 清空上下文」→ 硬重置,彻底从零(回执提示已归档)。两者都**不删任何消息**,只移动「喂给模型的窗口起点」`tasks.context_base_idx`。网页端「清空对话」(`POST /v1/tasks/{id}/clear`)仍整清并把 base 归 0。需 `main.py db upgrade head` 带上 `0019`。
|
||||
- **PG**:`ZCBOT_DB_URL` 必填。本地 docker compose / 远端 dev / 生产任选;未设置时启动清晰报错,不引导 docker(§7.4)。
|
||||
- **Auth env**:`PLATFORM_KEY` + `JWT_SECRET` 任一缺失 web 启动 fail-fast。生成随机串:`python -c "import secrets; print(secrets.token_urlsafe(48))"`。
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.43.1"
|
||||
__version__ = "0.44.0"
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ QYAPI = "https://qyapi.weixin.qq.com/cgi-bin"
|
|||
# 不能用 open.weixin.qq.com/connect/oauth2/authorize —— 那条是「网页授权」,只能在
|
||||
# 企业微信客户端内打开,桌面浏览器会报「请在企业微信客户端打开链接」。
|
||||
WWLOGIN_SSO = "https://login.work.weixin.qq.com/wwlogin/sso/login"
|
||||
# 网页授权(snsapi_base 静默):只能在企业微信客户端内打开(应用主页 / 会话内链接),
|
||||
# 用户无感直接回跳带 code。与上面 WWLOGIN_SSO 互补:客户端内用这条,外部浏览器用扫码。
|
||||
OPEN_OAUTH = "https://open.weixin.qq.com/connect/oauth2/authorize"
|
||||
MAX_FILE_BYTES = 20 * 1024 * 1024
|
||||
|
||||
# access_token 进程内缓存
|
||||
|
|
@ -158,6 +161,23 @@ def oauth_authorize_url(redirect_uri: str, state: str) -> str:
|
|||
)
|
||||
|
||||
|
||||
def oauth_inclient_url(redirect_uri: str, state: str) -> str:
|
||||
"""造**客户端内网页授权**链接(snsapi_base 静默授权):企业微信客户端里打开(应用主页
|
||||
入口)无感回跳带 code,后续 auth/getuserinfo 换 userid 与扫码路径一致。
|
||||
|
||||
注意:redirect_uri 域名须登记在应用「网页授权及 JS-SDK 可信域名」,与扫码登录的
|
||||
「企业微信授权登录可信域名」是两项不同设置;`#wechat_redirect` 锚点是协议要求,必须带。"""
|
||||
from urllib.parse import quote
|
||||
return (
|
||||
f"{OPEN_OAUTH}?appid={_corpid()}"
|
||||
f"&redirect_uri={quote(redirect_uri, safe='')}"
|
||||
f"&response_type=code&scope=snsapi_base"
|
||||
f"&agentid={_agentid()}"
|
||||
f"&state={quote(state, safe='')}"
|
||||
f"#wechat_redirect"
|
||||
)
|
||||
|
||||
|
||||
def get_user_id(code: str) -> Optional[str]:
|
||||
"""OAuth 回调用 code 换企业成员 userid(非成员返回 None)。"""
|
||||
d = _api_get("auth/getuserinfo", {"code": code})
|
||||
|
|
|
|||
87
web/app.py
87
web/app.py
|
|
@ -1397,6 +1397,16 @@ def create_app() -> FastAPI:
|
|||
|
||||
# ───────────── 企业微信接入(渠道 B,纯推送,§8.7)─────────────
|
||||
|
||||
def _wecom_page(msg: str, ok: bool):
|
||||
"""OAuth 回跳落地提示页(无 JWT 场景:绑定 / 免登失败都用)。"""
|
||||
from fastapi.responses import HTMLResponse
|
||||
color = "#1a7f37" if ok else "#cf222e"
|
||||
return HTMLResponse(
|
||||
f"<!doctype html><meta charset=utf-8><meta name=viewport content='width=device-width,initial-scale=1'>"
|
||||
f"<div style='font:16px system-ui;max-width:420px;margin:80px auto;text-align:center;color:{color}'>"
|
||||
f"{msg}</div><div style='text-align:center;color:#888;font:13px system-ui'>可关闭本页返回 zcbot</div>"
|
||||
)
|
||||
|
||||
@app.get("/v1/wecom/oauth/url", tags=["wecom"])
|
||||
def wecom_oauth_url(request: Request, user_id: UUID = Depends(require_user)):
|
||||
"""生成企业微信 OAuth 网页授权链接(前端打开 → 扫码授权)。回调用 state 带回身份。
|
||||
|
|
@ -1413,31 +1423,78 @@ def create_app() -> FastAPI:
|
|||
@app.get("/v1/wecom/oauth/callback", include_in_schema=False)
|
||||
async def wecom_oauth_callback(code: str = "", state: str = ""):
|
||||
"""企业微信授权后浏览器回跳到这(无 JWT;身份从 state 验)。换 userid → 写绑定 → 回提示页。"""
|
||||
from fastapi.responses import HTMLResponse
|
||||
from core.wechat import service as _wx
|
||||
from core.wechat import wecom
|
||||
|
||||
def _page(msg: str, ok: bool) -> HTMLResponse:
|
||||
color = "#1a7f37" if ok else "#cf222e"
|
||||
return HTMLResponse(
|
||||
f"<!doctype html><meta charset=utf-8><meta name=viewport content='width=device-width,initial-scale=1'>"
|
||||
f"<div style='font:16px system-ui;max-width:420px;margin:80px auto;text-align:center;color:{color}'>"
|
||||
f"{msg}</div><div style='text-align:center;color:#888;font:13px system-ui'>可关闭本页返回 zcbot</div>"
|
||||
)
|
||||
|
||||
uid = wecom.verify_state(state)
|
||||
if not uid:
|
||||
return _page("绑定失败:授权已过期或无效,请回 zcbot 重试。", False)
|
||||
if not uid or uid == _WECOM_LOGIN_STATE:
|
||||
return _wecom_page("绑定失败:授权已过期或无效,请回 zcbot 重试。", False)
|
||||
if not code:
|
||||
return _page("绑定失败:未拿到授权码。", False)
|
||||
return _wecom_page("绑定失败:未拿到授权码。", False)
|
||||
try:
|
||||
wecom_userid = await asyncio.to_thread(wecom.get_user_id, code)
|
||||
except Exception as e:
|
||||
return _page(f"绑定失败:{type(e).__name__}", False)
|
||||
return _wecom_page(f"绑定失败:{type(e).__name__}", False)
|
||||
if not wecom_userid:
|
||||
return _page("绑定失败:你不是该企业成员(只支持企业内成员)。", False)
|
||||
return _wecom_page("绑定失败:你不是该企业成员(只支持企业内成员)。", False)
|
||||
await asyncio.to_thread(_wx.upsert_wecom_binding, UUID(uid), wecom_userid)
|
||||
return _page("✅ 企业微信绑定成功!以后简报 / 结果会推到你的企业微信。", True)
|
||||
return _wecom_page("✅ 企业微信绑定成功!以后简报 / 结果会推到你的企业微信。", True)
|
||||
|
||||
# 免登入口 state 里的哨兵值:登录流程没有 user_id,用它占位 + 防 CSRF。
|
||||
# 真实 user_id 都是 UUID 串,不会与之撞;绑定回调那侧也显式拒绝这个值。
|
||||
_WECOM_LOGIN_STATE = "login"
|
||||
|
||||
@app.get("/v1/wecom/entry", include_in_schema=False)
|
||||
def wecom_entry(request: Request):
|
||||
"""企业微信应用主页免登入口(无 JWT):302 到 OAuth 授权,回调换 JWT 直进 embed 控制台。
|
||||
|
||||
企微后台「应用主页」填 `<公网 base>/v1/wecom/entry`。企微客户端内(UA 带 wxwork)
|
||||
走网页授权 snsapi_base 静默无感;外部浏览器退到 wwlogin 扫码,同一 URL 两端可用。
|
||||
网页授权要求域名登记在应用「网页授权及 JS-SDK 可信域名」(与扫码的授权登录域名是两项)。"""
|
||||
from fastapi.responses import RedirectResponse
|
||||
from core.wechat import wecom
|
||||
if not wecom.wecom_configured():
|
||||
raise HTTPException(404, "企业微信未配置(需 WECOM_CORPID/AGENTID/SECRET)")
|
||||
base = (os.getenv("ZCBOT_PUBLIC_BASE_URL", "").strip()
|
||||
or str(request.base_url).rstrip("/"))
|
||||
redirect_uri = f"{base}/v1/wecom/entry/callback"
|
||||
state = wecom.sign_state(_WECOM_LOGIN_STATE)
|
||||
ua = (request.headers.get("user-agent") or "").lower()
|
||||
url = (wecom.oauth_inclient_url(redirect_uri, state) if "wxwork" in ua
|
||||
else wecom.oauth_authorize_url(redirect_uri, state))
|
||||
return RedirectResponse(url, status_code=302)
|
||||
|
||||
@app.get("/v1/wecom/entry/callback", include_in_schema=False)
|
||||
async def wecom_entry_callback(code: str = "", state: str = ""):
|
||||
"""免登回跳:code → wecom_userid → 反查绑定 → 签 JWT → 302 进 embed 控制台。
|
||||
|
||||
token 放 URL fragment(不进服务端日志、不随后续请求发出,前端读完立即清 hash)。
|
||||
未绑定成员不自动建号 —— 提示先在控制台完成绑定,避免同一人产生重复账号。"""
|
||||
from fastapi.responses import RedirectResponse
|
||||
from core.wechat import service as _wx
|
||||
from core.wechat import wecom
|
||||
|
||||
if wecom.verify_state(state) != _WECOM_LOGIN_STATE:
|
||||
return _wecom_page("登录失败:授权已过期或无效,请重新打开应用。", False)
|
||||
if not code:
|
||||
return _wecom_page("登录失败:未拿到授权码。", False)
|
||||
try:
|
||||
wecom_userid = await asyncio.to_thread(wecom.get_user_id, code)
|
||||
except Exception as e:
|
||||
return _wecom_page(f"登录失败:{type(e).__name__}", False)
|
||||
if not wecom_userid:
|
||||
return _wecom_page("登录失败:你不是该企业成员(只支持企业内成员)。", False)
|
||||
uid = await asyncio.to_thread(_wx.get_user_by_wecom_userid, wecom_userid)
|
||||
if uid is None:
|
||||
return _wecom_page(
|
||||
"该企业微信还未绑定 zcbot 账号:请先在电脑端 zcbot 控制台登录,"
|
||||
"点左栏「企业微信」渠道卡片完成绑定,再回来打开本应用。", False)
|
||||
token, _exp = mint_token(auth_cfg, uid)
|
||||
# JWT 是 base64url + '.',UUID 是 hex + '-',都无需再转义,fragment 安全
|
||||
return RedirectResponse(
|
||||
f"/static/dev.html?embed=1&wecom=1#token={token}&user_id={uid}",
|
||||
status_code=302,
|
||||
)
|
||||
|
||||
@app.get("/v1/wecom/bind", tags=["wecom"])
|
||||
def wecom_bind_get(user_id: UUID = Depends(require_user)):
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@
|
|||
// (logout 供全局 401 处理,closeChpwModal 供 main 的 Esc 统一关弹窗栈)。
|
||||
// 反向依赖 main 的 glue:enterApp(登录成功进入)、embedPostToParent/embedShowWaiting
|
||||
// (logout 在 embed 模式通知父页面)——均运行时(点击/401)才调,ES 环 live binding 安全。
|
||||
import { state, LS_TOKEN, LS_UID, LS_NAME, LS_USERNAME, LS_EMAIL, EMBED, setIdentity } from "./state.js";
|
||||
import { state, LS_TOKEN, LS_UID, LS_NAME, LS_USERNAME, LS_EMAIL, EMBED, EMBED_WECOM, setIdentity } from "./state.js";
|
||||
import { $ } from "./dom.js";
|
||||
import { api } from "./api.js";
|
||||
import { enterApp } from "./main.js";
|
||||
import { embedPostToParent, embedShowWaiting } from "./embed.js";
|
||||
import { embedPostToParent, embedShowWaiting, embedWecomRelogin } from "./embed.js";
|
||||
|
||||
// ───── login ─────
|
||||
let loginTab = "pw"; // "pw" | "key";持久化 last-used tab 在 LS,刷新后默认那个
|
||||
|
|
@ -100,6 +100,11 @@ export function logout() {
|
|||
// 清身份(state + LS_UID/NAME/USERNAME/EMAIL),不残留上一个用户
|
||||
setIdentity({ user_id: "" });
|
||||
if (state.evtSrc) state.evtSrc.close();
|
||||
if (EMBED_WECOM) {
|
||||
// 企微免登:401 直接回 entry 静默重签,客户端内用户无感
|
||||
embedWecomRelogin();
|
||||
return;
|
||||
}
|
||||
if (EMBED) {
|
||||
embedPostToParent({ type: "zcbot-401" });
|
||||
embedShowWaiting("登录已失效,等待父页面重新签发…", false);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
// embed(iframe)模式:父页面经 postMessage 推送 token → 进入应用;401 后重签。
|
||||
// wecom 变体(?embed=1&wecom=1):token 来自 /v1/wecom/entry 回调的 URL fragment,
|
||||
// 401 时 embedWecomRelogin 重定向回 entry 静默重签(企微客户端内无感)。
|
||||
// 顶层无副作用,boot 决定是否调 embedInit。导出 embedInit(boot 调)+
|
||||
// embedPostToParent / embedShowWaiting(auth 的 logout 在 embed 下通知父页面/显示等待态)。
|
||||
import { state, LS_TOKEN, EMBED_PARENT_ORIGIN, EMBED_INITIAL_TASK_ID, setIdentity } from "./state.js";
|
||||
// embedPostToParent / embedShowWaiting / embedWecomRelogin(auth 的 logout 用)。
|
||||
import { state, LS_TOKEN, EMBED_WECOM, EMBED_PARENT_ORIGIN, EMBED_INITIAL_TASK_ID, setIdentity } from "./state.js";
|
||||
import { $ } from "./dom.js";
|
||||
import { enterApp } from "./main.js";
|
||||
import { loadTaskList, selectTask } from "./chat.js";
|
||||
|
|
@ -49,7 +51,35 @@ function embedHandleMessage(e) {
|
|||
}
|
||||
}
|
||||
}
|
||||
// ───── wecom 免登变体 ─────
|
||||
export function embedWecomRelogin() {
|
||||
// 回 entry 重走 OAuth(snsapi_base 静默,企微客户端内用户无感);replace 不留历史,
|
||||
// 避免返回键回到已失效的页面
|
||||
location.replace("/v1/wecom/entry");
|
||||
}
|
||||
function embedWecomInit() {
|
||||
document.body.classList.add("embed-mode");
|
||||
// fragment 里的 token 优先于 localStorage 缓存(每次从 entry 进来都是新签的)
|
||||
const h = new URLSearchParams(location.hash.replace(/^#/, ""));
|
||||
const tok = h.get("token"), uid = h.get("user_id");
|
||||
if (tok && uid) {
|
||||
state.token = tok;
|
||||
localStorage.setItem(LS_TOKEN, tok);
|
||||
// name/email 由 enterApp → loadRole 拉 /v1/me 补齐
|
||||
setIdentity({ user_id: uid });
|
||||
// 读完立即清 hash,token 不留在地址栏 / 浏览历史
|
||||
history.replaceState(null, "", location.pathname + location.search);
|
||||
}
|
||||
if (!state.token) { embedWecomRelogin(); return; }
|
||||
enterApp();
|
||||
if (EMBED_INITIAL_TASK_ID && !_embedInitialTaskHandled) {
|
||||
_embedInitialTaskHandled = true;
|
||||
selectTask(EMBED_INITIAL_TASK_ID);
|
||||
}
|
||||
}
|
||||
|
||||
export function embedInit() {
|
||||
if (EMBED_WECOM) { embedWecomInit(); return; }
|
||||
if (!EMBED_PARENT_ORIGIN) {
|
||||
document.body.classList.add("embed-mode", "embed-waiting");
|
||||
embedShowWaiting("embed 模式缺少 parent_origin 参数 (URL 必须形如 ?embed=1&parent_origin=https://your-portal.com)", true);
|
||||
|
|
|
|||
|
|
@ -13,9 +13,12 @@ export const LS_RIGHT_WIDTH = "zcbot.right-width";
|
|||
export const LS_TASK_FILTERS_COLLAPSED = "zcbot.task-filters-collapsed"; // 左栏筛选区折叠偏好(默认折叠)
|
||||
|
||||
// ?embed=1&parent_origin=https://... → iframe 模式;父页面用 postMessage 推 token
|
||||
// ?embed=1&wecom=1 → 企业微信免登模式;token 由 /v1/wecom/entry 回调经 URL fragment 带入,
|
||||
// 401 时重定向回 entry 静默重签(不走 postMessage,不需要 parent_origin)
|
||||
// 可选 task_id=<uuid>:首次签发 token 后自动定位到该 task 并加载消息
|
||||
const _embedQS = new URLSearchParams(location.search);
|
||||
export const EMBED = _embedQS.get("embed") === "1";
|
||||
export const EMBED_WECOM = EMBED && _embedQS.get("wecom") === "1";
|
||||
export const EMBED_PARENT_ORIGIN = (_embedQS.get("parent_origin") || "").trim();
|
||||
export const EMBED_INITIAL_TASK_ID = (_embedQS.get("task_id") || "").trim();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue