"""助手进群:对话记录分得清房间,群内应答有可见的开关 ## 这是在解决什么 2026-09-08 生产上被用户当面撞见:Helix 自己为「转人工」建了群,把员工和负责人 拉了进去,然后员工在群里 @ 机器人问了两句 —— 机器人一声不吭。原因不是权限,是 入口代码在 `parse_message_event` 第一行就把 `chat_type != "p2p"` 的消息丢掉了。 ## 这条迁移做两件事 **1. `assistant_conversations.chat_kind`** —— 这段对话发生在哪种房间: `dm` 私聊 / `handoff` Helix 建的工单群 / `group` 别人的群。 为什么不能靠 join 推:工单群的 `chat_id` 在 `assistant_handoffs` 里查得到,但 **任意群压根不在任何表里**,于是它和私聊在数据上长得一模一样。而两者不是一回事 —— 一句私聊的回答只有一个人看见,一句群里的回答有一屋子人看见。审计面必须分得开。 历史行全部回填成 `dm`,这是事实而不是默认值:这条迁移之前,群消息**从来没有** 产生过一行对话记录。 **2. 三个平台设置的种子行。** `platform_settings` 是「有行才看得见」的表,而设置 页列的就是这张表。不种下来,管理员在页面上找不到这几个开关,只能靠读文档知道 它们存在 —— 一个找不到的开关不是开关。 取值按 2026-09-08 与用户确认的产品决定: * `assistant.group_chat_enabled = true` —— 工单群里应答。平台把两个人拉进一个 房间然后自己在里面装哑巴,是这条链路最难向员工解释的行为。 * `assistant.group_chat_any_enabled = false` —— 任意群默认**关**。多一个群能 @ 它就是多一片触达面,该由管理员显式打开,而不是由一次发布顺带打开。 * `assistant.group_turns_per_hour = 120` —— 每个群每小时的回合上限,和每人上限 各自独立:十个人在一个群里各问六句,每人都没超,群超了。 """ from __future__ import annotations import json import sqlalchemy as sa from alembic import op revision = "632" down_revision = "631" branch_labels = None depends_on = None CONVERSATIONS = "assistant_conversations" SETTINGS = "platform_settings" #: ``(key, JSON 值, 说明)`` —— 说明是管理员在设置页读到的那句话。 _SEED_SETTINGS = [ ( "assistant.group_chat_enabled", True, "助手在 Helix 自己建的「转人工」工单群里是否应答(@机器人 触发)。" "关闭后群里只会回一句「群内应答已关闭」,工单处理不受影响。", ), ( "assistant.group_chat_any_enabled", False, "助手在其它群(有人把机器人拉进去的群)里是否应答。默认关闭:" "打开等于让全公司任何群都能 @ 它。", ), ( "assistant.group_turns_per_hour", 120, "每个群每小时的助手回合上限(5–2000)。与「每人每小时上限」各自独立。", ), ] def upgrade() -> None: bind = op.get_bind() inspector = sa.inspect(bind) columns = {c["name"] for c in inspector.get_columns(CONVERSATIONS)} if "chat_kind" not in columns: op.add_column( CONVERSATIONS, sa.Column( "chat_kind", sa.String(length=16), nullable=False, server_default="dm", ), ) # 历史行是事实,不是默认值:群消息在这条迁移之前从未被处理过,所以 # 每一行都确实是私聊。显式写一次,让「这一列的历史值是怎么来的」在 # 迁移里有答案,而不是留给读者去推断 server_default 的语义。 op.execute(sa.text(f"UPDATE {CONVERSATIONS} SET chat_kind = 'dm'")) for key, value, description in _SEED_SETTINGS: # 幂等:管理员可能在这条迁移之前就手工建过其中某一行(比如提前关掉 # 任意群)。已经存在的行**不覆盖** —— 一次发布把管理员的决定改回默认值, # 是这类种子最典型也最难发现的伤害。 exists = bind.execute( sa.text(f"SELECT 1 FROM {SETTINGS} WHERE `key` = :k"), {"k": key}, ).first() if exists: continue bind.execute( sa.text( f"INSERT INTO {SETTINGS} (`key`, `value`, `description`) " "VALUES (:k, :v, :d)", ), {"k": key, "v": json.dumps(value), "d": description}, ) def downgrade() -> None: bind = op.get_bind() for key, _value, _description in _SEED_SETTINGS: bind.execute(sa.text(f"DELETE FROM {SETTINGS} WHERE `key` = :k"), {"k": key}) inspector = sa.inspect(bind) columns = {c["name"] for c in inspector.get_columns(CONVERSATIONS)} if "chat_kind" in columns: op.drop_column(CONVERSATIONS, "chat_kind")