feat(wecom): 会话冷启动欢迎消息 + 未绑定入站回绑定指引(bump 0.46.0)

背景:应用配「应用主页」后点应用只进主页,应用会话要等应用发过第一条消息
才出现在成员消息列表,首次登录的员工没入口和 bot 打字。最终采用聊天优先
布局(不配主页、自定义菜单放「工作台」按钮指向 /v1/wecom/entry)。

- 回调:未绑定成员的 text/图片/文件消息不再静默,每条回绑定指引
  (聊天优先下新员工上来就打字,静默会被当成 bot 坏了;事件不回)
- 新表 wecom_welcomes(0021)+ service.maybe_send_wecom_welcome:
  免登入口回调给首次进入的成员推欢迎语"种"会话入口;两层去重
  (已推过 / 已绑定且有 chat task),claim-first 并发安全
- scripts/wecom_welcome_broadcast.py:配主页布局下的存量批量种入口
  (可选工具,--dry-run 先看名单);wecom.list_visible_userids 枚举可见范围
- 品牌名单一事实源挪 core/branding.py(app.py BRAND 与欢迎语共用)
- RUN.md 企微段补推荐布局;PROGRESS 同步

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-07-07 11:11:10 +08:00
parent 7f77e5aca9
commit f3be3d5016
10 changed files with 268 additions and 6 deletions

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9` > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-07-07(跳秒指示覆盖刷新重连 + 发送后空窗,bump 0.43.1) 最后更新:2026-07-07(企业微信欢迎消息种会话入口,bump 0.46.0)
--- ---
@ -23,6 +23,7 @@
### 2026-07 ### 2026-07
- **07-07 / 0.46.0**:企业微信会话冷启动 + 未绑定入站回指引——背景:应用配了「应用主页」后点应用只进主页,应用会话要等应用发过第一条消息才出现在成员消息列表(首次登录的员工没入口和 bot 打字)。最终采用**聊天优先布局**(企微后台不配主页、菜单放「工作台」按钮指向 `/v1/wecom/entry`,点应用直进会话),代码侧:① 回调对**未绑定成员**的 text/图片/文件消息不再静默,每条回一句绑定指引(聊天优先下新员工上来就打字,静默会被当成 bot 坏了;事件不回);② 新表 `wecom_welcomes`(0021,wecom_userid PK,未绑定成员也能去重)+ `service.maybe_send_wecom_welcome`(两层去重:已推过 / 已绑定且有 chat task;claim-first INSERT 并发安全,发送失败释放占坑),免登入口回调对首次进入的成员自动推欢迎语(菜单「工作台」/主页两条路都触发);③ 可选工具 `scripts/wecom_welcome_broadcast.py`:配主页布局下给存量成员批量"种"会话入口(`--dry-run` 先看名单;可见范围枚举 `wecom.list_visible_userids`:agent/get + 部门/标签展开);④ 品牌名单一事实源挪到 `core/branding.py`(web 层外的欢迎语也要用,app.py BRAND 引它)。RUN.md 企微段同步推荐布局。
- **07-07 / 0.45.0**:移动端显示修复 + 对外品牌名 env——① 浮层菜单(⋯ 任务菜单 `dom.js`、⚙ 媒体模型弹层 `chat.js`)原只做右对齐,手机上触发钮靠左时菜单冲出屏幕左缘盖住正文,改成"右对齐放不下→左对齐 + 双向钳视口";手机断点补:菜单项加大触控目标、模型下拉限宽 48vw、「管理」入口统一挪到 任务/对话/文件 tabs 行右侧小按钮(embed 与普通模式同款,靠 CSS order 实现不动 DOM)、header 品牌名单行省略。② 新 env `ZCBOT_BRAND_NAME`(默认「总院科研辅助助手」,zcbot 只是内部代号):`/healthz` 返 brand 字段,前端 boot 拉取覆盖页面标题/顶栏/登录卡(静态页默认值也已改为默认品牌);微信/企微测试推送、OAuth 提示页、绑定页等用户可见文案全部改用品牌名或中性"助手",不再露 zcbot。RUN.md env 段同步。 - **07-07 / 0.45.0**:移动端显示修复 + 对外品牌名 env——① 浮层菜单(⋯ 任务菜单 `dom.js`、⚙ 媒体模型弹层 `chat.js`)原只做右对齐,手机上触发钮靠左时菜单冲出屏幕左缘盖住正文,改成"右对齐放不下→左对齐 + 双向钳视口";手机断点补:菜单项加大触控目标、模型下拉限宽 48vw、「管理」入口统一挪到 任务/对话/文件 tabs 行右侧小按钮(embed 与普通模式同款,靠 CSS order 实现不动 DOM)、header 品牌名单行省略。② 新 env `ZCBOT_BRAND_NAME`(默认「总院科研辅助助手」,zcbot 只是内部代号):`/healthz` 返 brand 字段,前端 boot 拉取覆盖页面标题/顶栏/登录卡(静态页默认值也已改为默认品牌);微信/企微测试推送、OAuth 提示页、绑定页等用户可见文案全部改用品牌名或中性"助手",不再露 zcbot。RUN.md env 段同步。
- **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.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.1**:跳秒指示补两处空窗——① 刷新/重连接管后 phaseSince 随页面内存丢失,退回静态"思考中"不跳秒(恰是用户因疑似卡死而刷新的场景),重建直播卡时立即按"思考中"起跳,下一个阶段事件校正;② 发消息 POST 返回即起跳,覆盖 build_agent + 首轮 TTFT(原先要等 llm_start 才开始计时)。重连后秒数从重连时刻起算(非真实累计),可接受。

3
RUN.md
View File

@ -98,6 +98,9 @@
- 绑定后简报/结果**无条件主动推**(不挑活跃度、无 24h 窗口),适合必达。 - 绑定后简报/结果**无条件主动推**(不挑活跃度、无 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)**:企微后台「应用 → 接收消息 → 设置 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 扫码。 - **应用主页免登(可选,要公网 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 扫码。
- **推荐布局(聊天优先)**:企微后台**不配「应用主页」**(点应用直进会话,新员工上来就能打字),应用「自定义菜单」放「工作台」view 按钮指向 `<公网 base>/v1/wecom/entry`——免登链路照常用,控制台从菜单进。配了主页则点应用只进主页,会话要等应用发过第一条消息才出现在消息列表(冷启动)。
- **未绑定成员发消息 → 回绑定指引**(不再静默):聊天优先布局下新员工第一动作就是打字,回调对未绑定成员的 text/图片/文件消息每条回一句"先去控制台绑定"(事件不回)。
- **欢迎消息(会话冷启动兜底,0021)**:免登入口回调会给**首次进入**的成员自动推一条欢迎语(`wecom_welcomes` 表去重,每人一次;未绑定文案带绑定指引)——菜单「工作台」/主页两条路都会触发。可选工具 `scripts/wecom_welcome_broadcast.py`:给可见范围内还没会话的存量成员批量"种"会话入口(仅配了应用主页的布局需要;先 `--dry-run` 看名单,去重同一套重复跑安全;报 60011/48002 = secret 无通讯录读权限,把可见范围改成按成员勾选再跑)。需 `main.py db upgrade head` 带上 `0021`
- **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` - **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)。 - **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))"`。 - **Auth env**:`PLATFORM_KEY` + `JWT_SECRET` 任一缺失 web 启动 fail-fast。生成随机串:`python -c "import secrets; print(secrets.token_urlsafe(48))"`。

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。 # 改版本只动这一行。
__version__ = "0.45.0" __version__ = "0.46.0"

10
core/branding.py Normal file
View File

@ -0,0 +1,10 @@
"""对外品牌名单一事实源:zcbot 只是内部项目代号,所有用户可见文案(页面标题 /
推送消息 / OAuth 提示页 / 欢迎语)统一引 `brand_name()`,改名只动 ZCBOT_BRAND_NAME env
web/app.py BRAND 常量与 core/wechat/service.py 的欢迎语都取自这里"""
from __future__ import annotations
import os
def brand_name() -> str:
return os.getenv("ZCBOT_BRAND_NAME", "").strip() or "总院科研辅助助手"

View File

@ -288,3 +288,19 @@ class ChannelBinding(Base):
) )
class WecomWelcome(Base):
"""企业微信欢迎消息去重标记(0021)。
会话冷启动问题:应用配了应用主页后点应用只进主页,应用会话要等应用发过
第一条消息才出现在成员的消息列表 欢迎消息把会话入口""进去,本表保证
每人只推一次 wecom_userid(而非 user_id)建键:未绑定成员也要去重
"""
__tablename__ = "wecom_welcomes"
wecom_userid: Mapped[str] = mapped_column(Text, primary_key=True)
welcomed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)

View File

@ -17,10 +17,11 @@ from datetime import datetime, timedelta, timezone
from typing import Optional from typing import Optional
from uuid import UUID from uuid import UUID
from sqlalchemy import func, select, update from sqlalchemy import delete, func, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from core.storage import session_scope from core.storage import session_scope
from core.storage.models import ChannelBinding, Message, Task from core.storage.models import ChannelBinding, Message, Task, WecomWelcome
from core.wechat import crypto from core.wechat import crypto
from core.wechat.ilink import DEFAULT_BASE, ILinkClient from core.wechat.ilink import DEFAULT_BASE, ILinkClient
@ -279,6 +280,62 @@ def push_wecom(user_id: UUID, text: str = "", file_path: Optional[str] = None) -
return PushResult(True, channel="wecom", reason="sent") return PushResult(True, channel="wecom", reason="sent")
def maybe_send_wecom_welcome(
wecom_userid: str, user_id: Optional[UUID] = None, *, dry_run: bool = False
) -> str:
"""首次见到该企业成员时推一条欢迎消息,把应用会话""进其消息列表(冷启动)。
背景:应用配了应用主页后点应用只进主页;应用会话要等应用发过第一条消息
才出现在成员的消息列表 没有会话入口就没法打字和 bot 调用点:免登入口
回调(增量新成员)+ scripts/wecom_welcome_broadcast.py(存量群发),两处共用
本函数的去重,重复调用安全
去重两层:wecom_welcomes (已推过) 已绑定且有 chat task(已有对话,只补
标记不推)真正发送前先 INSERT 占坑(ON CONFLICT DO NOTHING),并发下只有
claim 成功的一方发;发送失败释放占坑,下次进入再试
返回 reason: sent | already_welcomed | has_conversation | would_send(dry_run)
| error:<...>
"""
if not wecom_userid:
return "error: empty wecom_userid"
with session_scope() as s:
if s.get(WecomWelcome, wecom_userid) is not None:
return "already_welcomed"
if user_id is not None and get_wecom_chat_task(user_id) is not None:
if not dry_run:
with session_scope() as s:
s.execute(pg_insert(WecomWelcome)
.values(wecom_userid=wecom_userid).on_conflict_do_nothing())
return "has_conversation"
if dry_run:
return "would_send"
with session_scope() as s:
claimed = s.execute(
pg_insert(WecomWelcome)
.values(wecom_userid=wecom_userid).on_conflict_do_nothing()
).rowcount == 1
if not claimed:
return "already_welcomed"
from core.branding import brand_name
from core.wechat import wecom
brand = brand_name()
if user_id is not None:
text = (f"你好,我是{brand}。之后直接回复本条消息即可随时对话;"
"在工作台点应用图标可进入控制台。")
else:
text = (f"你好,我是{brand}。你还未绑定{brand}账号:请先在电脑端浏览器登录"
f"{brand}控制台,点左栏「企业微信」渠道卡片完成绑定;"
"绑定后直接回复本条消息即可对话。")
try:
wecom.send_text(wecom_userid, text)
except Exception as e: # noqa: BLE001 —— 释放占坑,下次进入再试
with session_scope() as s:
s.execute(delete(WecomWelcome)
.where(WecomWelcome.wecom_userid == wecom_userid))
return f"error: {str(e)[:200]}"
return "sent"
@dataclass @dataclass
class DeliveryReport: class DeliveryReport:
results: list[PushResult] = field(default_factory=list) results: list[PushResult] = field(default_factory=list)

View File

@ -186,6 +186,44 @@ def get_user_id(code: str) -> Optional[str]:
return d.get("userid") # 外部联系人/非成员只有 openid → None return d.get("userid") # 外部联系人/非成员只有 openid → None
def list_visible_userids() -> list[str]:
"""应用可见范围内全部成员 userid(agent/get → 部门/标签展开,去重保序)。
群发欢迎消息用(scripts/wecom_welcome_broadcast.py)自建应用 secret 默认可读
可见范围内的通讯录;任一环节 errcode 0 直接抛 RuntimeError( errcode),
调用方决定兜底,不静默漏人
"""
d = _api_get("agent/get", {"agentid": _agentid()})
if d.get("errcode", 0) != 0:
raise RuntimeError(f"agent/get 失败: {d.get('errcode')} {d.get('errmsg')}")
out: list[str] = []
seen: set[str] = set()
def _add(uid: Optional[str]) -> None:
if uid and uid not in seen:
seen.add(uid)
out.append(uid)
for u in (d.get("allow_userinfos") or {}).get("user") or []:
_add(u.get("userid"))
partys: list[int] = list((d.get("allow_partys") or {}).get("partyid") or [])
for tagid in (d.get("allow_tags") or {}).get("tagid") or []:
t = _api_get("tag/get", {"tagid": tagid})
if t.get("errcode", 0) != 0:
raise RuntimeError(f"tag/get({tagid}) 失败: {t.get('errcode')} {t.get('errmsg')}")
for u in t.get("userlist") or []:
_add(u.get("userid"))
partys.extend(t.get("partylist") or [])
for pid in partys:
r = _api_get("user/simplelist", {"department_id": pid, "fetch_child": 1})
if r.get("errcode", 0) != 0:
raise RuntimeError(
f"user/simplelist({pid}) 失败: {r.get('errcode')} {r.get('errmsg')}")
for u in r.get("userlist") or []:
_add(u.get("userid"))
return out
# ─────────────────────────── 发送 ─────────────────────────── # ─────────────────────────── 发送 ───────────────────────────
def _send(touser: str, msgtype: str, body_field: dict) -> None: def _send(touser: str, msgtype: str, body_field: dict) -> None:

View File

@ -0,0 +1,38 @@
"""wecom_welcomes 表(企业微信欢迎消息去重).
Revision ID: 0021
Revises: 0020
Create Date: 2026-07-07
会话冷启动:应用配了应用主页后点应用只进主页,应用会话要等应用发过第一条
消息才出现在成员消息列表免登入口 / 群发脚本会给首次见到的成员推一条欢迎消息
""会话入口,本表按 wecom_userid 记录"已推过",保证每人只推一次(未绑定成员
没有 user_id,所以键是 wecom_userid 而非绑定表)additive,无数据迁移
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0021"
down_revision: Union[str, None] = "0020"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"wecom_welcomes",
sa.Column("wecom_userid", sa.Text(), primary_key=True),
sa.Column(
"welcomed_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
def downgrade() -> None:
op.drop_table("wecom_welcomes")

View File

@ -0,0 +1,80 @@
"""企业微信欢迎消息一次性群发:给可见范围内还没有应用会话的成员各推一条欢迎语。
背景(会话冷启动):应用配了应用主页,点应用只进主页;应用会话要等应用发过
第一条消息才会出现在成员的消息列表里本脚本给**存量成员**""会话入口;增量新
成员由免登入口(/v1/wecom/entry 回调)自动补推,不需要重复跑本脚本
用法(服务器上,.env 同目录, WECOM_CORPID/AGENTID/SECRET + ZCBOT_DB_URL 可达):
.venv/Scripts/python.exe scripts/wecom_welcome_broadcast.py --dry-run # 只看名单不发送
.venv/Scripts/python.exe scripts/wecom_welcome_broadcast.py # 实际发送
去重(已推过 / 已有对话的跳过) service.maybe_send_wecom_welcome + wecom_welcomes
表保证,重复跑安全ASCII 输出(Windows 控制台 GBK)
"""
import os
import sys
# 仓库根加入 sys.path(脚本在 scripts/ 下,直跑时 core 在上一级)
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _ROOT not in sys.path:
sys.path.insert(0, _ROOT)
def _load_env(path: str) -> None:
"""加载 .env:优先 python-dotenv,没装则手动解析(只填未设置的 key)。"""
try:
from dotenv import load_dotenv
load_dotenv(path)
return
except Exception:
pass
try:
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
except FileNotFoundError:
pass
_load_env(os.path.join(_ROOT, ".env"))
from core.wechat import service, wecom # noqa: E402 —— 须在 _load_env 之后 import
def main() -> int:
dry_run = "--dry-run" in sys.argv[1:]
if not wecom.wecom_configured():
print("[FAIL] WECOM_CORPID/AGENTID/SECRET 没读到(确认 .env 在仓库根、值已填)")
return 1
print("[step1] list visible userids (agent/get + expand) ...")
try:
userids = wecom.list_visible_userids()
except Exception as e:
print(f"[step1] FAIL: {e}")
print(" -> 60011/48002 = 应用 secret 无通讯录读权限;可在企微后台把可见"
"范围改成按成员勾选(agent/get 直接给 userid,不用展开部门)后重跑")
return 2
print(f"[step1] OK: {len(userids)} member(s) in visible range")
counts: dict[str, int] = {}
for u in userids:
uid = service.get_user_by_wecom_userid(u)
reason = service.maybe_send_wecom_welcome(u, uid, dry_run=dry_run)
key = reason.split(":", 1)[0]
counts[key] = counts.get(key, 0) + 1
bound = "bound" if uid else "unbound"
print(f" [{reason}] userid={u} ({bound})")
print("[done]", " | ".join(f"{k}={v}" for k, v in sorted(counts.items())))
if dry_run:
print("[note] dry-run: nothing sent. Re-run without --dry-run to send.")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -36,6 +36,7 @@ from sqlalchemy import BigInteger, cast, func, or_, select, update
from starlette.background import BackgroundTask from starlette.background import BackgroundTask
from core import __version__ from core import __version__
from core.branding import brand_name
from core.paths import from_db_path, to_db_path from core.paths import from_db_path, to_db_path
from core.storage import ( from core.storage import (
NoSubtaskError, NoSubtaskError,
@ -71,7 +72,8 @@ INSTANCE = os.getenv("ZCBOT_INSTANCE", "").strip()
# 对外品牌名:zcbot 只是内部项目代号,所有用户可见文案(页面标题 / 推送消息 / OAuth # 对外品牌名:zcbot 只是内部项目代号,所有用户可见文案(页面标题 / 推送消息 / OAuth
# 提示页)统一用这个。/healthz 也返回,前端 boot 时拉取覆盖静态页里的默认值。 # 提示页)统一用这个。/healthz 也返回,前端 boot 时拉取覆盖静态页里的默认值。
BRAND = os.getenv("ZCBOT_BRAND_NAME", "").strip() or "总院科研辅助助手" # 单一事实源在 core/branding.py(企业微信欢迎语等 web 层之外的文案也要用)。
BRAND = brand_name()
STATUS_FILTERS = ("active", "completed", "abandoned") STATUS_FILTERS = ("active", "completed", "abandoned")
STATUS_WRITABLE = ("completed", "abandoned") # web 不让从 web 端切回 active(走 CLI) STATUS_WRITABLE = ("completed", "abandoned") # web 不让从 web 端切回 active(走 CLI)
@ -1491,6 +1493,12 @@ def create_app() -> FastAPI:
if not wecom_userid: if not wecom_userid:
return _wecom_page("登录失败:你不是该企业成员(只支持企业内成员)。", False) return _wecom_page("登录失败:你不是该企业成员(只支持企业内成员)。", False)
uid = await asyncio.to_thread(_wx.get_user_by_wecom_userid, wecom_userid) uid = await asyncio.to_thread(_wx.get_user_by_wecom_userid, wecom_userid)
# 首次进入的成员消息列表里还没有本应用会话(点应用只会进主页)——推一条欢迎
# 消息把会话入口"种"进消息列表;已推过 / 已有对话的自动跳过(wecom_welcomes 去重)。
try:
await asyncio.to_thread(_wx.maybe_send_wecom_welcome, wecom_userid, uid)
except Exception as e: # noqa: BLE001 —— 欢迎推送失败不影响登录主流程
print(f"[wecom] welcome push failed: {type(e).__name__}: {e}")
if uid is None: if uid is None:
return _wecom_page( return _wecom_page(
f"该企业微信还未绑定{BRAND}账号:请先在电脑端{BRAND}控制台登录," f"该企业微信还未绑定{BRAND}账号:请先在电脑端{BRAND}控制台登录,"
@ -1586,7 +1594,18 @@ def create_app() -> FastAPI:
wuid = msg.get("FromUserName") or "" wuid = msg.get("FromUserName") or ""
uid = await asyncio.to_thread(_wx.get_user_by_wecom_userid, wuid) uid = await asyncio.to_thread(_wx.get_user_by_wecom_userid, wuid)
if uid is None: if uid is None:
return PlainTextResponse("success") # 未绑定 → 静默 # 未绑定成员发真实消息(菜单/事件不算)→ 回绑定指引,不再静默:聊天优先布局
# (不配应用主页)下新员工点应用直进会话就打字,静默会被当成 bot 坏了。
# 这是对入站消息的应答,每条都回(不走 wecom_welcomes 去重),永远有反馈。
if wuid and msgtype in ("text", "image", "file"):
try:
await asyncio.to_thread(
wecom.send_text, wuid,
f"你还未绑定{BRAND}账号:请先在电脑端浏览器登录{BRAND}控制台,"
"点左栏「企业微信」渠道卡片完成绑定;绑定后在这里发消息即可对话。")
except Exception as e: # noqa: BLE001 —— 指引推送失败只打日志
print(f"[wecom] unbound guidance push failed: {type(e).__name__}: {e}")
return PlainTextResponse("success")
# 文本取 Content;图片/文件走 media/get 下载,构造 InboundAttachment(与个人微信同结构, # 文本取 Content;图片/文件走 media/get 下载,构造 InboundAttachment(与个人微信同结构,
# 仅 kind/file_name/data 三字段被 _run_channel_conversation 用到)。其余类型(语音/视频/ # 仅 kind/file_name/data 三字段被 _run_channel_conversation 用到)。其余类型(语音/视频/