refactor(wecom): 撤除欢迎推送机制,聊天优先布局下不需要(bump 0.46.1)

实际部署改为聊天优先布局(不配应用主页 + 自定义菜单「工作台」按钮指向
/v1/wecom/entry),点应用直进会话,冷启动问题不复存在,0.46.0 的欢迎
推送成了死重:

- 删 wecom_welcomes 表(migration 0021 撤下;DB 已 downgrade 回 0020,
  表建后无一行写入)、maybe_send_wecom_welcome、免登回调欢迎钩子、
  scripts/wecom_welcome_broadcast.py、wecom.list_visible_userids
- 保留:未绑定入站回绑定指引(聊天优先下必需)、core/branding.py 品牌单一源
- RUN.md 企微段只留推荐布局 + 未绑定指引;PROGRESS 同步

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-07-07 11:26:13 +08:00
parent f3be3d5016
commit a47a7799e7
9 changed files with 7 additions and 242 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.46.0) 最后更新:2026-07-07(撤除企微欢迎推送机制,聊天优先布局下不需要,bump 0.46.1)
--- ---
@ -23,6 +23,7 @@
### 2026-07 ### 2026-07
- **07-07 / 0.46.1**:撤除企微欢迎推送机制——实际部署采用聊天优先布局(不配应用主页 + 菜单「工作台」按钮)后,点应用直进会话,冷启动问题不复存在,0.46.0 的欢迎推送成了死重:删 `wecom_welcomes` 表(migration 0021 撤下,DB 已 downgrade 回 0020,表建后无一行写入)、`maybe_send_wecom_welcome`、免登回调欢迎钩子、群发脚本、`list_visible_userids`。**保留**:未绑定入站回绑定指引(聊天优先下必需)、`core/branding.py` 品牌单一源。RUN.md 企微段只留推荐布局 + 未绑定指引两条。
- **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.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 企微段同步。

5
RUN.md
View File

@ -98,9 +98,8 @@
- 绑定后简报/结果**无条件主动推**(不挑活跃度、无 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`——免登链路照常用,控制台从菜单进。配了主页则点应用只进主页,会话要等应用发过第一条消息才出现在消息列表(冷启动)。 - **推荐布局(聊天优先)**:企微后台**不配「应用主页」**(点应用直进会话,新员工上来就能打字),应用「自定义菜单」放「工作台」view 按钮指向 `<公网 base>/v1/wecom/entry`——免登链路照常用,控制台从菜单进。配了主页则点应用只进主页,会话要等应用发过第一条消息才出现在消息列表(冷启动),不推荐。
- **未绑定成员发消息 → 回绑定指引**(不再静默):聊天优先布局下新员工第一动作就是打字,回调对未绑定成员的 text/图片/文件消息每条回一句"先去控制台绑定"(事件不回)。 - **未绑定成员发消息 → 回绑定指引**(不再静默):聊天优先布局下新员工第一动作就是打字,回调对未绑定成员的 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.46.0" __version__ = "0.46.1"

View File

@ -288,19 +288,3 @@ 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,11 +17,10 @@ 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 delete, func, select, update from sqlalchemy import 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, WecomWelcome from core.storage.models import ChannelBinding, Message, Task
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
@ -280,62 +279,6 @@ 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,44 +186,6 @@ 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

@ -1,38 +0,0 @@
"""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

@ -1,80 +0,0 @@
"""企业微信欢迎消息一次性群发:给可见范围内还没有应用会话的成员各推一条欢迎语。
背景(会话冷启动):应用配了应用主页,点应用只进主页;应用会话要等应用发过
第一条消息才会出现在成员的消息列表里本脚本给**存量成员**""会话入口;增量新
成员由免登入口(/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

@ -1493,12 +1493,6 @@ 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}控制台登录,"