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:
parent
7f77e5aca9
commit
f3be3d5016
|
|
@ -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
3
RUN.md
|
|
@ -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))"`。
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 "总院科研辅助助手"
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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())
|
||||||
23
web/app.py
23
web/app.py
|
|
@ -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 用到)。其余类型(语音/视频/
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue