This commit is contained in:
@@ -1,5 +1,24 @@
|
||||
# Changelog
|
||||
|
||||
## alpha 0.6.6.1015
|
||||
|
||||
- Rebuild CS Major autochess around multi-message hosting: tournament
|
||||
welcomes, seed order, draft boards, status panels, training results, match
|
||||
reports, standings, brackets, and awards are emitted as separate bounded
|
||||
replies.
|
||||
- Replace instant full rosters with three draft rounds: round one selects from
|
||||
a fixed 10-coach pool, rounds two and three draft players, then each team is
|
||||
completed to five starters, one coach, and three bench slots.
|
||||
- Run the full event as training camp, BO3 single-round-robin group stage,
|
||||
one media/sponsor challenge after group round one or two, BO5 semifinals,
|
||||
BO5 final, and final awards.
|
||||
- Let readiness advance phases automatically. Group matches wait for all real
|
||||
players, while knockout matches only wait for real players still in the
|
||||
current pairing, so eliminated players are not prompted to prepare.
|
||||
- Add local multi-text reply support across the SDK, official extension
|
||||
adapter, Edge response contract, and recorder GUI sender while preserving
|
||||
single-message compatibility.
|
||||
|
||||
## alpha 0.6.5.1015
|
||||
|
||||
- Extend the AI test path for CS Major autochess to support two-human games:
|
||||
|
||||
@@ -8,7 +8,7 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC
|
||||
观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件,
|
||||
并通过带视觉确认的 GUI 发送链路回复。
|
||||
|
||||
当前版本:`alpha 0.6.5.1015`。
|
||||
当前版本:`alpha 0.6.6.1015`。
|
||||
|
||||
0.6.1 新增本地上下文总结、人物与别名索引、单人虚拟金币游戏,并增加
|
||||
两秒画面复核以补抓遗漏的消息变化。命令和游戏管理规则见
|
||||
@@ -20,14 +20,16 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC
|
||||
0.6.3 将彩票改为群内多人轮次,加入报名、状态、主持人开奖和取消;
|
||||
抽奖、彩票、21 点统一使用 `Jacky #N:...` 局号并支持 `/游戏记录`。
|
||||
完整玩法见[多人彩票与游戏记录](docs/group-lottery-0.6.3.md)。
|
||||
0.6.4 新增 2~8 人 CS Major 自走棋:固定五人首发与教练、每轮 6 人
|
||||
公开商店、属性/定位/战术克制、小组单循环、1v4 与 2v3 淘汰赛,以及
|
||||
BO3/BO5 的 MR15 加时赛。支持三名真人加入一支确定性 AI 测试战队;
|
||||
Taffy 负责赛制播报、顺序准备、交棒和全程剧情主持。所有选秀、战斗和
|
||||
加时剧情均在本地事务内落档。
|
||||
0.6.4 新增 2~8 人 CS Major 自走棋:职业选手卡池、属性/定位/战术
|
||||
克制、小组单循环、1v4 与 2v3 淘汰赛,以及 BO3/BO5 的 MR15 加时赛。
|
||||
完整规则见[CS Major 自走棋设计稿](docs/cs-major-autochess-design.md)。
|
||||
0.6.5 将 AI 测试模式扩展到双人:两名真人可一键补入 AI 战队 A、B,
|
||||
按四队种子运行 1v4、2v3 半决赛;两支 AI 独立运营且不占真人准备顺序。
|
||||
两支 AI 独立运营。
|
||||
0.6.6 重构自走棋流程:开赛后三轮选秀,第一轮从固定 10 人教练池中
|
||||
选教练,第二、三轮选选手;随后进入三场训练赛、BO3 单循环小组赛、
|
||||
一次媒体日或赞助挑战、BO5 半决赛和总决赛。主持消息会拆分为多条
|
||||
回复;所有需要推进的阶段由相关真人发送 `/结束回合` 自动进入下一步,
|
||||
已淘汰玩家不会再收到赛前准备提示。
|
||||
|
||||
### 运行流程
|
||||
|
||||
@@ -98,9 +100,10 @@ PATH="$PWD/.venv/bin:$PATH" ./tools/check-local.sh
|
||||
/21点 开始 [1~500];开局后 /21点 要牌|停牌|状态|结束
|
||||
/游戏记录 [页码](/gamehistory)
|
||||
/游戏帮助(/games)
|
||||
/自走棋开始|报名|开赛|状态|结算|档案
|
||||
/自走棋开始|报名|开赛|状态|档案
|
||||
/自走棋加AI战队(两名或三名真人测试模式)
|
||||
/选1~6;/战术 火力|稳定|残局;/阵容;/结束回合
|
||||
/选1~6(教练轮为 /选1~10);/刷新;/锁定
|
||||
/战术 火力|稳定|残局;/阵容;/上阵 备战编号 [首发位];/出售 备战编号;/结束回合
|
||||
/丢骰子 [NdM]
|
||||
/猜数字 开始 [上限]
|
||||
/石头剪刀布 [开始|加入|出|状态|结算|结束]
|
||||
@@ -269,7 +272,7 @@ visually confirmed GUI bridge.
|
||||
Current version:
|
||||
|
||||
```text
|
||||
alpha 0.6.5.1015
|
||||
alpha 0.6.6.1015
|
||||
```
|
||||
|
||||
## Runtime Flow
|
||||
|
||||
@@ -16,12 +16,13 @@
|
||||
|
||||
一句话定义:
|
||||
|
||||
`公开 6 人选秀 -> 凑羁绊与升星 -> 打 PvE 拿资源 -> 打 PvP 跑 Major 赛程 -> 保存每轮赛报与剧情`
|
||||
`三轮赛前选秀 -> 训练赛拿资源 -> D 牌与布阵 -> BO3 小组单循环 -> BO5 淘汰赛 -> 保存每轮赛报与剧情`
|
||||
|
||||
玩家感受到的乐趣应当主要来自:
|
||||
|
||||
- 抢 6 人选秀里的关键卡;
|
||||
- 决定要不要升教练等级、保经济还是强行 D 牌;
|
||||
- 第一轮从固定 10 人教练池里抢体系教练;
|
||||
- 第二、三轮公开选手池里抢关键卡;
|
||||
- 决定要不要保经济还是强行 D 牌;
|
||||
- 赌阵容成型时点;
|
||||
- 看小组单循环和淘汰赛赛报;
|
||||
- 看最终冠军剧情与本届 Major 档案。
|
||||
@@ -36,9 +37,11 @@
|
||||
- 主持人可在报名阶段发送 `/自走棋加AI战队`:
|
||||
- 两名真人时加入 AI 战队 A、B;
|
||||
- 三名真人时加入一支 AI 战队。
|
||||
- 两种测试模式都补满四队,直接生成 `1v4`、`2v3` 半决赛。
|
||||
- 两种测试模式都补满四队,先进行 BO3 小组单循环,再按排名生成
|
||||
`1v4`、`2v3` 半决赛。
|
||||
- AI 占一个参赛席位,但不对应群成员,也不调用在线模型。
|
||||
- AI 每回合以确定性规则买牌、升级教练、调整首发和选择克制战术。
|
||||
- AI 使用确定性规则选秀、买牌、调整首发和选择克制战术;为便于测试,
|
||||
AI 阵容战力会有明确折扣。
|
||||
|
||||
### 3.2 玩家初始状态
|
||||
|
||||
@@ -46,16 +49,15 @@
|
||||
|
||||
- 生命值:`100`
|
||||
- 金币:`10`
|
||||
- 教练等级:`1`
|
||||
- 完整首发:`5 名一星选手 + 1 名教练`
|
||||
- 教练:赛前第一轮选秀决定
|
||||
- 初始选手:赛前第二、三轮选秀决定,系统随后补齐阵容
|
||||
- 阵容位:固定 `5 个选手位 + 1 个教练位`,比赛时必须全部上场
|
||||
- 备战席:`8 格`
|
||||
- 备战席:`3 格`
|
||||
- 连胜/连败计数:`0`
|
||||
- 本局独立事件档案编号
|
||||
|
||||
开赛时系统从低费池为每位玩家确定性分配 5 名首发选手,保证第一场
|
||||
比赛就是完整的五人阵容。后续选秀用于替换、强化和升星,而不是解锁
|
||||
上场人口。
|
||||
三轮选秀完成后,系统按已选核心确定性补齐每队 `5 名首发 + 3 名备战`
|
||||
和常驻教练,保证训练赛开始时就是完整阵容。
|
||||
|
||||
## 4. 选手池设计
|
||||
|
||||
@@ -195,12 +197,11 @@
|
||||
- 4 级:全队战力 `+8%`
|
||||
- 5 级:全队战力 `+10%`
|
||||
|
||||
### 7.2 教练升级
|
||||
### 7.2 教练获取
|
||||
|
||||
玩家可花金币升级教练经验。
|
||||
|
||||
建议升级费用为 `6 / 10 / 16 / 24` 金币。升级只提高上述战术加成。
|
||||
高费选手的商店概率改为随赛事回合推进,不再绑定教练等级。
|
||||
教练由第一轮公共选秀决定,本届赛事内不能用金币升级教练。不同教练
|
||||
自带等级、属性倾向和全队加成。
|
||||
高费选手的商店概率随赛事进程推进,不再绑定教练等级。
|
||||
|
||||
## 8. 每轮 6 人选秀
|
||||
|
||||
@@ -242,12 +243,14 @@
|
||||
/选4
|
||||
/选5
|
||||
/选6
|
||||
/选7
|
||||
/选8
|
||||
/选9
|
||||
/选10
|
||||
/刷新
|
||||
/锁定
|
||||
/升级教练
|
||||
/战术 火力|稳定|残局
|
||||
/上阵 备战编号 [首发位]
|
||||
/下阵 首发位 备战编号
|
||||
/出售 备战编号
|
||||
/阵容
|
||||
/赛程
|
||||
@@ -258,15 +261,16 @@
|
||||
|
||||
### 8.3 刷新与锁定
|
||||
|
||||
- 每轮默认展示一组 6 人。
|
||||
- 第一轮教练选秀展示固定 10 名教练;第二、三轮选手选秀展示 6 名选手。
|
||||
- 训练赛和正赛运营阶段默认展示一组 6 人商店。
|
||||
- 玩家可支付金币执行刷新。
|
||||
- 玩家可锁定本轮 6 人,使下一轮仍保留同一组候选。
|
||||
|
||||
### 8.4 顺位
|
||||
|
||||
- 开局首轮选秀顺位随机。
|
||||
- 后续普通回合不再做蛇形公共抢位,而是每位玩家各自面对同一组 6 人池。
|
||||
- 如果后续想加竞技性,可在第二阶段引入“共享卡池抢空”机制。
|
||||
- 开赛后三轮公共选秀,第一轮选教练,第二、三轮选选手。
|
||||
- 第二轮使用反向顺序,避免种子靠前者连续先手。
|
||||
- AI 轮次会自动选择,真人只在轮到自己时使用 `/选N`。
|
||||
|
||||
第一期建议先做“公开同池、各自结算”,避免群聊交互过重。
|
||||
|
||||
@@ -558,22 +562,23 @@ PvE 对手是模板化敌方阵容或阈值型事件,不需要复杂 AI。
|
||||
|
||||
建议所有回复都遵循现有小游戏风格:
|
||||
|
||||
`用户名 #局号:内容`
|
||||
多段回复优先。一次主持动作可以拆成欢迎语、参赛名单、种子顺序、候选池、
|
||||
状态面板、操作指引等多条消息,每条保持在发送上限内。
|
||||
|
||||
例如:
|
||||
|
||||
```text
|
||||
Jacky #3:第 4 回合开始,你当前 32 金币,教练 3 级。
|
||||
本轮选秀如下:
|
||||
1. donk 5费 步枪手 Spirit 俄罗斯 火力96 稳定88 残局86
|
||||
2. apEX 3费 指挥 Vitality 法国 火力68 稳定81 指挥95
|
||||
欢迎来到第一轮选秀,本轮选教练。
|
||||
选择顺序:Jacky、Alice、AI 战队 A、AI 战队 B
|
||||
1. zonic|5级教练|冠军体系|火78 稳92 残88|全队+9%
|
||||
...
|
||||
发送“选1”到“选6”,或发送“刷新”“锁定”“升级教练”。
|
||||
现在轮到 @Jacky 做出选择,使用 /选1~/选10。
|
||||
```
|
||||
|
||||
### 16.1 Taffy 主持状态机
|
||||
|
||||
Taffy 必须承担完整赛事主持,而不是只在结算时出现。
|
||||
主持人必须承担完整赛事主持,而不是只在结算时出现;输出不需要每句都带
|
||||
`Taffy:` 前缀。
|
||||
|
||||
开赛时必须包含:
|
||||
|
||||
@@ -581,27 +586,29 @@ Taffy 必须承担完整赛事主持,而不是只在结算时出现。
|
||||
- 本届参赛者和种子
|
||||
- 当前人数对应的完整赛制
|
||||
- 小组赛 BO3、淘汰赛 BO5、单图 MR15 和加时规则
|
||||
- 当前回合内容
|
||||
- 第一位需要准备的玩家
|
||||
- 三轮选秀规则和当前选择玩家
|
||||
- 训练赛、小组赛和淘汰赛的当前阶段
|
||||
|
||||
每回合按真人玩家生成确定性的准备顺序。AI 自动准备,不占真人操作轮次。
|
||||
只有当前玩家可以购买、刷新、选择战术和调整阵容。提交后必须明确交棒:
|
||||
赛前选秀按顺序逐人选择。进入阵容准备、训练赛运营和小组赛运营后,
|
||||
所有相关真人发送 `/结束回合` 即可自动推进,不需要主持人手动结算。
|
||||
淘汰赛只等待当前对阵中的真人,已经淘汰的玩家不会再被提示准备。
|
||||
|
||||
```text
|
||||
Taffy:Jacky,请做好赛前准备;完成后发送 /结束回合。
|
||||
Taffy:Jacky 准备完成!到 Alice 做准备了。
|
||||
Taffy:Alice 准备完成!全员准备完毕,主持人可以结算。
|
||||
```
|
||||
Jacky 选择了 zonic!
|
||||
现在轮到 @Alice 做出选择,使用 /选1~/选10。
|
||||
|
||||
下一回合轮换首位准备者,避免固定由同一个人总是先操作。
|
||||
训练赛阶段结束。可以 /刷新、/选1~/选6、/上阵、/战术;
|
||||
所有真人发送 /结束回合 后进入小组赛。
|
||||
```
|
||||
|
||||
以下节点都必须由 Taffy 主持并落档:
|
||||
|
||||
- 报名开启和新选手加入
|
||||
- AI 测试战队加入
|
||||
- 赛制与种子公布
|
||||
- 每位玩家开始/完成准备
|
||||
- 每轮开始和结算
|
||||
- 三轮选秀候选池、选择结果和选秀完成
|
||||
- 三场训练赛结果
|
||||
- 媒体日或赞助挑战
|
||||
- 每场 BO3/BO5 对阵与结果
|
||||
- 每张加时地图
|
||||
- 小组排名和前四晋级
|
||||
|
||||
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
texts = response.get("texts")
|
||||
if isinstance(texts, list) and texts and all(
|
||||
isinstance(item, str) and item.strip() for item in texts
|
||||
):
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.multi_text(texts),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
|
||||
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
texts = response.get("texts")
|
||||
if isinstance(texts, list) and texts and all(
|
||||
isinstance(item, str) and item.strip() for item in texts
|
||||
):
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.multi_text(texts),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
|
||||
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
texts = response.get("texts")
|
||||
if isinstance(texts, list) and texts and all(
|
||||
isinstance(item, str) and item.strip() for item in texts
|
||||
):
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.multi_text(texts),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
|
||||
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
texts = response.get("texts")
|
||||
if isinstance(texts, list) and texts and all(
|
||||
isinstance(item, str) and item.strip() for item in texts
|
||||
):
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.multi_text(texts),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
|
||||
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
texts = response.get("texts")
|
||||
if isinstance(texts, list) and texts and all(
|
||||
isinstance(item, str) and item.strip() for item in texts
|
||||
):
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.multi_text(texts),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
|
||||
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
texts = response.get("texts")
|
||||
if isinstance(texts, list) and texts and all(
|
||||
isinstance(item, str) and item.strip() for item in texts
|
||||
):
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.multi_text(texts),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
|
||||
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
texts = response.get("texts")
|
||||
if isinstance(texts, list) and texts and all(
|
||||
isinstance(item, str) and item.strip() for item in texts
|
||||
):
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.multi_text(texts),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
|
||||
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
texts = response.get("texts")
|
||||
if isinstance(texts, list) and texts and all(
|
||||
isinstance(item, str) and item.strip() for item in texts
|
||||
):
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.multi_text(texts),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
|
||||
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
texts = response.get("texts")
|
||||
if isinstance(texts, list) and texts and all(
|
||||
isinstance(item, str) and item.strip() for item in texts
|
||||
):
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.multi_text(texts),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
|
||||
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
texts = response.get("texts")
|
||||
if isinstance(texts, list) and texts and all(
|
||||
isinstance(item, str) and item.strip() for item in texts
|
||||
):
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.multi_text(texts),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
|
||||
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
texts = response.get("texts")
|
||||
if isinstance(texts, list) and texts and all(
|
||||
isinstance(item, str) and item.strip() for item in texts
|
||||
):
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.multi_text(texts),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
|
||||
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
texts = response.get("texts")
|
||||
if isinstance(texts, list) and texts and all(
|
||||
isinstance(item, str) and item.strip() for item in texts
|
||||
):
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.multi_text(texts),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
|
||||
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
texts = response.get("texts")
|
||||
if isinstance(texts, list) and texts and all(
|
||||
isinstance(item, str) and item.strip() for item in texts
|
||||
):
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.multi_text(texts),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
|
||||
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
texts = response.get("texts")
|
||||
if isinstance(texts, list) and texts and all(
|
||||
isinstance(item, str) and item.strip() for item in texts
|
||||
):
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.multi_text(texts),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
|
||||
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
texts = response.get("texts")
|
||||
if isinstance(texts, list) and texts and all(
|
||||
isinstance(item, str) and item.strip() for item in texts
|
||||
):
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.multi_text(texts),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
|
||||
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
texts = response.get("texts")
|
||||
if isinstance(texts, list) and texts and all(
|
||||
isinstance(item, str) and item.strip() for item in texts
|
||||
):
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.multi_text(texts),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
|
||||
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
texts = response.get("texts")
|
||||
if isinstance(texts, list) and texts and all(
|
||||
isinstance(item, str) and item.strip() for item in texts
|
||||
):
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.multi_text(texts),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
|
||||
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
texts = response.get("texts")
|
||||
if isinstance(texts, list) and texts and all(
|
||||
isinstance(item, str) and item.strip() for item in texts
|
||||
):
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.multi_text(texts),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
|
||||
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
texts = response.get("texts")
|
||||
if isinstance(texts, list) and texts and all(
|
||||
isinstance(item, str) and item.strip() for item in texts
|
||||
):
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.multi_text(texts),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jwebot-config"
|
||||
version = "0.6.5a0+1015"
|
||||
version = "0.6.6a0+1015"
|
||||
description = "Profile-based configuration compiler for JWeBot"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
|
||||
@@ -36,4 +36,4 @@ __all__ = [
|
||||
"write_default_profile",
|
||||
]
|
||||
|
||||
__version__ = "0.6.5a0+1015"
|
||||
__version__ = "0.6.6a0+1015"
|
||||
|
||||
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jwebot-extension-host"
|
||||
version = "0.6.5a0+1015"
|
||||
version = "0.6.6a0+1015"
|
||||
description = "Single-release, single-invocation process host for JWeBot extensions"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"jwebot-sdk==0.6.5a0+1015",
|
||||
"jwebot-kernel==0.6.5a0+1015",
|
||||
"jwebot-sdk==0.6.6a0+1015",
|
||||
"jwebot-kernel==0.6.6a0+1015",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -16,4 +16,4 @@ __all__ = [
|
||||
"process_request",
|
||||
]
|
||||
|
||||
__version__ = "0.6.5a0+1015"
|
||||
__version__ = "0.6.6a0+1015"
|
||||
|
||||
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jwebot-kernel"
|
||||
version = "0.6.5a0+1015"
|
||||
version = "0.6.6a0+1015"
|
||||
description = "Stable routing and extension authority for JWeBot"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"jwebot-config==0.6.5a0+1015",
|
||||
"jwebot-sdk==0.6.5a0+1015",
|
||||
"jwebot-official-extensions==0.6.5a0+1015",
|
||||
"jwebot-config==0.6.6a0+1015",
|
||||
"jwebot-sdk==0.6.6a0+1015",
|
||||
"jwebot-official-extensions==0.6.6a0+1015",
|
||||
"tomli>=2; python_version < '3.11'",
|
||||
]
|
||||
|
||||
|
||||
@@ -107,4 +107,4 @@ __all__ = [
|
||||
"verify_installed_release_signature",
|
||||
]
|
||||
|
||||
__version__ = "0.6.5a0+1015"
|
||||
__version__ = "0.6.6a0+1015"
|
||||
|
||||
@@ -61,6 +61,8 @@ from jwebot_official_extensions import (
|
||||
extension_for_kind,
|
||||
local_command_parts,
|
||||
local_extension_command_token,
|
||||
pack_reply_parts,
|
||||
unpack_reply_parts,
|
||||
)
|
||||
from jwebot_official_extensions.steam import (
|
||||
DEFAULT_STEAM_MIHOMO_PROXY_URL,
|
||||
@@ -3760,7 +3762,16 @@ class Ledger:
|
||||
or not isinstance(state.get("players"), dict)
|
||||
):
|
||||
raise LocalCommandError("自走棋赛事结构无效")
|
||||
if status in {"active", "completed"}:
|
||||
phase = str(state.get("phase", ""))
|
||||
roster_required = (
|
||||
status == "completed"
|
||||
or (
|
||||
status == "active"
|
||||
and not phase.startswith("draft_")
|
||||
and phase != "registration"
|
||||
)
|
||||
)
|
||||
if roster_required:
|
||||
players = state["players"].values()
|
||||
if any(
|
||||
not isinstance(player, dict)
|
||||
@@ -4979,7 +4990,11 @@ class EdgeApplication:
|
||||
error_type=type(error).__name__,
|
||||
)
|
||||
raise EdgeError("隔离插件暂时不可用") from error
|
||||
if result.reply.kind is not PlanKind.TEXT:
|
||||
if result.reply.kind is PlanKind.MULTI_TEXT:
|
||||
reply_message = pack_reply_parts(list(result.reply.messages))
|
||||
elif result.reply.kind is PlanKind.TEXT:
|
||||
reply_message = result.reply.message
|
||||
else:
|
||||
raise EdgeError("隔离插件没有返回可见回复")
|
||||
self.telemetry.elapsed(
|
||||
"edge.extension.execute",
|
||||
@@ -4989,7 +5004,7 @@ class EdgeApplication:
|
||||
plugin_id=owner.extension_id,
|
||||
mode="host",
|
||||
)
|
||||
return result.reply.message
|
||||
return reply_message
|
||||
if (
|
||||
self.config.extension_mode == "host-only"
|
||||
or (
|
||||
@@ -5078,13 +5093,18 @@ class EdgeApplication:
|
||||
"duplicate": True,
|
||||
}
|
||||
if row["response_text"]:
|
||||
return {
|
||||
parts = unpack_reply_parts(row["response_text"])
|
||||
text = parts[0] if parts is not None else row["response_text"]
|
||||
response = {
|
||||
"status": "reply",
|
||||
"route": row["route"],
|
||||
"execution_path": stored_execution_path,
|
||||
"text": row["response_text"],
|
||||
"text": text,
|
||||
"duplicate": True,
|
||||
}
|
||||
if parts is not None:
|
||||
response["texts"] = parts
|
||||
return response
|
||||
return {
|
||||
"status": "fallback_cloud",
|
||||
"reason": "existing_incomplete_event",
|
||||
@@ -5206,6 +5226,13 @@ class EdgeApplication:
|
||||
status="local_completed",
|
||||
response_text=reply,
|
||||
)
|
||||
parts = unpack_reply_parts(reply)
|
||||
visible_reply = parts[0] if parts is not None else reply
|
||||
reply_chars = (
|
||||
sum(len(part) for part in parts)
|
||||
if parts is not None
|
||||
else len(reply)
|
||||
)
|
||||
self.telemetry.elapsed(
|
||||
"edge.local.complete",
|
||||
started,
|
||||
@@ -5214,14 +5241,18 @@ class EdgeApplication:
|
||||
route=route,
|
||||
reason=reason,
|
||||
execution_path=execution_path,
|
||||
chars=len(reply),
|
||||
chars=reply_chars,
|
||||
messages=len(parts) if parts is not None else 1,
|
||||
)
|
||||
return {
|
||||
response = {
|
||||
"status": "reply",
|
||||
"route": route,
|
||||
"execution_path": execution_path,
|
||||
"text": reply,
|
||||
"text": visible_reply,
|
||||
}
|
||||
if parts is not None:
|
||||
response["texts"] = parts
|
||||
return response
|
||||
alias_reply = self.ledger.local_alias_identity_reply(
|
||||
str(row["group_fingerprint"]),
|
||||
str(row["question"]),
|
||||
|
||||
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jwebot-official-extensions"
|
||||
version = "0.6.5a0+1015"
|
||||
version = "0.6.6a0+1015"
|
||||
description = "Reviewed local command adapters and broker implementation for JWeBot"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"jwebot-sdk==0.6.5a0+1015",
|
||||
"jwebot-sdk==0.6.6a0+1015",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from .cs_autochess import (
|
||||
CARD_BY_ID,
|
||||
COACH_BONUSES,
|
||||
COACH_POOL,
|
||||
MAX_PLAYERS,
|
||||
MIN_PLAYERS,
|
||||
PLAYER_POOL,
|
||||
@@ -32,6 +33,8 @@ from .local_commands import (
|
||||
LocalCommandError,
|
||||
deterministic_int,
|
||||
local_command_parts,
|
||||
pack_reply_parts,
|
||||
unpack_reply_parts,
|
||||
)
|
||||
from .runtime import (
|
||||
LocalExtensionError,
|
||||
@@ -49,6 +52,7 @@ from .steam import (
|
||||
__all__ = [
|
||||
"CARD_BY_ID",
|
||||
"COACH_BONUSES",
|
||||
"COACH_POOL",
|
||||
"CsMajorAutochess",
|
||||
"DICE_CHALLENGE_TIMEOUT_SECONDS",
|
||||
"DEFAULT_STEAM_MIHOMO_PROXY_URL",
|
||||
@@ -79,9 +83,11 @@ __all__ = [
|
||||
"local_extension_command_token",
|
||||
"lineup_power",
|
||||
"matchup_modifier",
|
||||
"pack_reply_parts",
|
||||
"parse_autochess_action",
|
||||
"unpack_reply_parts",
|
||||
"extension_for_kind",
|
||||
"render_feature_list",
|
||||
]
|
||||
|
||||
__version__ = "0.6.5a0+1015"
|
||||
__version__ = "0.6.6a0+1015"
|
||||
|
||||
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
texts = response.get("texts")
|
||||
if isinstance(texts, list) and texts and all(
|
||||
isinstance(item, str) and item.strip() for item in texts
|
||||
):
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.multi_text(texts),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
|
||||
+8
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
texts = response.get("texts")
|
||||
if isinstance(texts, list) and texts and all(
|
||||
isinstance(item, str) and item.strip() for item in texts
|
||||
):
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.multi_text(texts),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
|
||||
+8
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
texts = response.get("texts")
|
||||
if isinstance(texts, list) and texts and all(
|
||||
isinstance(item, str) and item.strip() for item in texts
|
||||
):
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.multi_text(texts),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
|
||||
+8
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
texts = response.get("texts")
|
||||
if isinstance(texts, list) and texts and all(
|
||||
isinstance(item, str) and item.strip() for item in texts
|
||||
):
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.multi_text(texts),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
|
||||
+8
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
texts = response.get("texts")
|
||||
if isinstance(texts, list) and texts and all(
|
||||
isinstance(item, str) and item.strip() for item in texts
|
||||
):
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.multi_text(texts),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
|
||||
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
texts = response.get("texts")
|
||||
if isinstance(texts, list) and texts and all(
|
||||
isinstance(item, str) and item.strip() for item in texts
|
||||
):
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.multi_text(texts),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
|
||||
+8
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
texts = response.get("texts")
|
||||
if isinstance(texts, list) and texts and all(
|
||||
isinstance(item, str) and item.strip() for item in texts
|
||||
):
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.multi_text(texts),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
|
||||
+8
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
texts = response.get("texts")
|
||||
if isinstance(texts, list) and texts and all(
|
||||
isinstance(item, str) and item.strip() for item in texts
|
||||
):
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.multi_text(texts),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
|
||||
+8
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
texts = response.get("texts")
|
||||
if isinstance(texts, list) and texts and all(
|
||||
isinstance(item, str) and item.strip() for item in texts
|
||||
):
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.multi_text(texts),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
|
||||
+8
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
texts = response.get("texts")
|
||||
if isinstance(texts, list) and texts and all(
|
||||
isinstance(item, str) and item.strip() for item in texts
|
||||
):
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.multi_text(texts),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
|
||||
+8
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
texts = response.get("texts")
|
||||
if isinstance(texts, list) and texts and all(
|
||||
isinstance(item, str) and item.strip() for item in texts
|
||||
):
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.multi_text(texts),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
|
||||
+8
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
texts = response.get("texts")
|
||||
if isinstance(texts, list) and texts and all(
|
||||
isinstance(item, str) and item.strip() for item in texts
|
||||
):
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.multi_text(texts),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
|
||||
+8
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
texts = response.get("texts")
|
||||
if isinstance(texts, list) and texts and all(
|
||||
isinstance(item, str) and item.strip() for item in texts
|
||||
):
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.multi_text(texts),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
|
||||
+8
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
texts = response.get("texts")
|
||||
if isinstance(texts, list) and texts and all(
|
||||
isinstance(item, str) and item.strip() for item in texts
|
||||
):
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.multi_text(texts),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
|
||||
+8
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
texts = response.get("texts")
|
||||
if isinstance(texts, list) and texts and all(
|
||||
isinstance(item, str) and item.strip() for item in texts
|
||||
):
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.multi_text(texts),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
|
||||
+8
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
texts = response.get("texts")
|
||||
if isinstance(texts, list) and texts and all(
|
||||
isinstance(item, str) and item.strip() for item in texts
|
||||
):
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.multi_text(texts),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
|
||||
+8
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
texts = response.get("texts")
|
||||
if isinstance(texts, list) and texts and all(
|
||||
isinstance(item, str) and item.strip() for item in texts
|
||||
):
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.multi_text(texts),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
|
||||
+8
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
texts = response.get("texts")
|
||||
if isinstance(texts, list) and texts and all(
|
||||
isinstance(item, str) and item.strip() for item in texts
|
||||
):
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.multi_text(texts),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
|
||||
+8
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
texts = response.get("texts")
|
||||
if isinstance(texts, list) and texts and all(
|
||||
isinstance(item, str) and item.strip() for item in texts
|
||||
):
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.multi_text(texts),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
|
||||
+1032
-472
File diff suppressed because it is too large
Load Diff
+36
-1
@@ -25,6 +25,7 @@ MAX_RPS_PLAYERS = 8
|
||||
BEIJING_TIMEZONE = ZoneInfo("Asia/Shanghai")
|
||||
DICE_PATTERN = re.compile(r"^(?:(\d{1,2})[dD](\d{1,3}))?$")
|
||||
RANDOM_NUMBER_PATTERN = re.compile(r"^(-?\d{1,6})\s+(-?\d{1,6})$")
|
||||
MULTI_REPLY_MARKER = "\x1eJWEBOT_MULTI_REPLY_V1\x1e"
|
||||
|
||||
|
||||
class LocalCommandError(RuntimeError):
|
||||
@@ -53,6 +54,36 @@ def deterministic_int(seed: str, lower: int, upper: int) -> int:
|
||||
return lower + value % (upper - lower + 1)
|
||||
|
||||
|
||||
def pack_reply_parts(parts: list[str] | tuple[str, ...]) -> str:
|
||||
cleaned = [str(part).strip() for part in parts if str(part).strip()]
|
||||
if not cleaned:
|
||||
raise LocalCommandError("本地命令没有可发送回复")
|
||||
if len(cleaned) == 1:
|
||||
return cleaned[0]
|
||||
return MULTI_REPLY_MARKER + json.dumps(
|
||||
cleaned,
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
||||
|
||||
def unpack_reply_parts(value: object) -> list[str] | None:
|
||||
text = str(value or "")
|
||||
if not text.startswith(MULTI_REPLY_MARKER):
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(text[len(MULTI_REPLY_MARKER) :])
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
if (
|
||||
not isinstance(payload, list)
|
||||
or not payload
|
||||
or any(not isinstance(item, str) or not item.strip() for item in payload)
|
||||
):
|
||||
return None
|
||||
return [item.strip() for item in payload]
|
||||
|
||||
|
||||
def local_command_parts(question: str) -> tuple[str, str]:
|
||||
normalized = question.lstrip()
|
||||
if not normalized.startswith("/"):
|
||||
@@ -131,7 +162,11 @@ class LocalCommandEngine:
|
||||
}:
|
||||
return self.reply_cleaner(self.players.handle(row, kind, arguments))
|
||||
if kind == "cs_autochess":
|
||||
return self.reply_cleaner(self.autochess.handle(row))
|
||||
parts = [
|
||||
self.reply_cleaner(part)
|
||||
for part in self.autochess.handle_parts(row)
|
||||
]
|
||||
return pack_reply_parts(parts)
|
||||
if kind == "dice":
|
||||
return self.reply_cleaner(self.dice(row, arguments))
|
||||
if kind == "dice_challenge":
|
||||
|
||||
@@ -14,6 +14,8 @@ from .local_commands import (
|
||||
LocalCommandEngine,
|
||||
local_command_parts,
|
||||
utc_now,
|
||||
pack_reply_parts,
|
||||
unpack_reply_parts,
|
||||
)
|
||||
|
||||
|
||||
@@ -74,7 +76,11 @@ class LocalServiceBroker:
|
||||
"question": f"{command} {arguments}".rstrip(),
|
||||
"created_at": str(event.get("observed_at") or utc_now()),
|
||||
}
|
||||
return {"text": self.engine.handle(row, event)}
|
||||
text = self.engine.handle(row, event)
|
||||
parts = unpack_reply_parts(text)
|
||||
if parts is not None:
|
||||
return {"text": parts[0], "texts": parts}
|
||||
return {"text": text}
|
||||
|
||||
|
||||
class _Context:
|
||||
@@ -162,6 +168,8 @@ class LocalExtensionRuntime:
|
||||
result = handler(sdk_event, arguments)
|
||||
if not isinstance(result, ExtensionResult):
|
||||
raise LocalExtensionError("local adapter returned invalid result")
|
||||
if result.reply.kind is PlanKind.MULTI_TEXT:
|
||||
return pack_reply_parts(list(result.reply.messages))
|
||||
if result.reply.kind is not PlanKind.TEXT:
|
||||
raise LocalExtensionError("local command returned no visible reply")
|
||||
return result.reply.message
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jwebot-sdk"
|
||||
version = "0.6.5a0+1015"
|
||||
version = "0.6.6a0+1015"
|
||||
description = "Typed, capability-limited extension API for JWeBot plugins"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = []
|
||||
|
||||
@@ -38,4 +38,4 @@ __all__ = [
|
||||
"SingleGroupContext",
|
||||
]
|
||||
|
||||
__version__ = "0.6.5a0+1015"
|
||||
__version__ = "0.6.6a0+1015"
|
||||
|
||||
@@ -20,6 +20,7 @@ class ContractViolation(ValueError):
|
||||
|
||||
class PlanKind(str, Enum):
|
||||
TEXT = "text"
|
||||
MULTI_TEXT = "multi_text"
|
||||
NO_REPLY = "no_reply"
|
||||
|
||||
|
||||
@@ -156,6 +157,7 @@ class ReplyPlan:
|
||||
|
||||
kind: PlanKind
|
||||
message: str = ""
|
||||
messages: tuple[str, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.kind is PlanKind.TEXT:
|
||||
@@ -168,13 +170,40 @@ class ReplyPlan:
|
||||
limit=MAX_REPLY_CHARS,
|
||||
),
|
||||
)
|
||||
if self.messages:
|
||||
raise ContractViolation("text plan cannot contain multiple messages")
|
||||
elif self.kind is PlanKind.MULTI_TEXT:
|
||||
if self.message:
|
||||
raise ContractViolation("multi_text plan cannot contain text")
|
||||
if not self.messages:
|
||||
raise ContractViolation("multi_text plan requires messages")
|
||||
if len(self.messages) > 8:
|
||||
raise ContractViolation("multi_text plan exceeds 8 messages")
|
||||
object.__setattr__(
|
||||
self,
|
||||
"messages",
|
||||
tuple(
|
||||
_required_text(
|
||||
message,
|
||||
name="reply message",
|
||||
limit=MAX_REPLY_CHARS,
|
||||
)
|
||||
for message in self.messages
|
||||
),
|
||||
)
|
||||
elif self.message:
|
||||
raise ContractViolation("no_reply plan cannot contain text")
|
||||
elif self.messages:
|
||||
raise ContractViolation("no_reply plan cannot contain messages")
|
||||
|
||||
@classmethod
|
||||
def text(cls, message: str) -> "ReplyPlan":
|
||||
return cls(kind=PlanKind.TEXT, message=message)
|
||||
|
||||
@classmethod
|
||||
def multi_text(cls, messages: tuple[str, ...] | list[str]) -> "ReplyPlan":
|
||||
return cls(kind=PlanKind.MULTI_TEXT, messages=tuple(messages))
|
||||
|
||||
@classmethod
|
||||
def no_reply(cls) -> "ReplyPlan":
|
||||
return cls(kind=PlanKind.NO_REPLY)
|
||||
@@ -189,14 +218,23 @@ class ReplyPlan:
|
||||
raise ContractViolation("reply plan kind is invalid") from error
|
||||
if kind is PlanKind.TEXT:
|
||||
return cls.text(str(value.get("text", "")))
|
||||
if kind is PlanKind.MULTI_TEXT:
|
||||
messages = value.get("texts", ())
|
||||
if not isinstance(messages, list):
|
||||
raise ContractViolation("multi_text plan messages must be a list")
|
||||
return cls.multi_text([str(message) for message in messages])
|
||||
if value.get("text"):
|
||||
raise ContractViolation("no_reply plan cannot contain text")
|
||||
if value.get("texts"):
|
||||
raise ContractViolation("no_reply plan cannot contain messages")
|
||||
return cls.no_reply()
|
||||
|
||||
def to_mapping(self) -> dict[str, str]:
|
||||
def to_mapping(self) -> dict[str, Any]:
|
||||
value = {"kind": self.kind.value}
|
||||
if self.kind is PlanKind.TEXT:
|
||||
value["text"] = self.message
|
||||
if self.kind is PlanKind.MULTI_TEXT:
|
||||
value["texts"] = list(self.messages)
|
||||
return value
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
[tool.jwebot]
|
||||
release = "alpha 0.6.5.1015"
|
||||
release = "alpha 0.6.6.1015"
|
||||
production_deployments_enabled_by_default = false
|
||||
|
||||
[tool.jwebot.workspace]
|
||||
|
||||
+106
-436
@@ -1,15 +1,11 @@
|
||||
"""Behavioral coverage for the CS Major autochess extension."""
|
||||
"""Behavioral coverage for the CS Major autochess 0.6.6 flow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from test_jwebot_edge import MODULE, config
|
||||
from jwebot_official_extensions import cs_autochess as AUTOCHESS
|
||||
@@ -18,31 +14,31 @@ from jwebot_official_extensions import cs_autochess as AUTOCHESS
|
||||
class Release064AutochessTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp.name)
|
||||
self.engine = MODULE.EdgeApplication(config(self.root))
|
||||
self.engine = MODULE.EdgeApplication(config(Path(self.temp.name)))
|
||||
self.counter = 0
|
||||
|
||||
def tearDown(self):
|
||||
self.engine.close()
|
||||
self.temp.cleanup()
|
||||
|
||||
def command(
|
||||
self,
|
||||
question: str,
|
||||
*,
|
||||
sender: str = "Alice",
|
||||
event_id: str | None = None,
|
||||
) -> str:
|
||||
def command(self, question, *, sender="Alice", event_id=None):
|
||||
self.counter += 1
|
||||
row = {
|
||||
"question": question,
|
||||
"sender": sender,
|
||||
"group_fingerprint": "group-a",
|
||||
"event_id": event_id or f"autochess-{self.counter}",
|
||||
}
|
||||
return self.engine.local.handle(row, {})
|
||||
response = self.engine.accept(
|
||||
{
|
||||
"event_id": event_id or f"autochess-{self.counter}",
|
||||
"group_fingerprint": "group-a",
|
||||
"sender": sender,
|
||||
"text": "@JWeBot " + question,
|
||||
"mention_bot": True,
|
||||
"confidence": 1.0,
|
||||
"sender_confidence": 1.0,
|
||||
"body_confidence": 1.0,
|
||||
}
|
||||
)
|
||||
self.assertEqual(response["status"], "reply")
|
||||
return list(response.get("texts") or [response["text"]])
|
||||
|
||||
def state(self) -> dict:
|
||||
def state(self):
|
||||
row = self.engine.ledger.connection.execute(
|
||||
"""
|
||||
SELECT state_json FROM autochess_runs
|
||||
@@ -54,56 +50,40 @@ class Release064AutochessTests(unittest.TestCase):
|
||||
self.assertIsNotNone(row)
|
||||
return json.loads(row[0])
|
||||
|
||||
def save_state(self, state: dict) -> None:
|
||||
self.engine.ledger.connection.execute(
|
||||
"""
|
||||
UPDATE autochess_runs SET state_json = ?, state = ?
|
||||
WHERE group_fingerprint = 'group-a' AND run_id = ?
|
||||
""",
|
||||
(
|
||||
json.dumps(state, ensure_ascii=False, separators=(",", ":")),
|
||||
state["status"],
|
||||
state["run_id"],
|
||||
),
|
||||
)
|
||||
def start_two_human_two_ai(self):
|
||||
self.command("/自走棋开始")
|
||||
self.command("/自走棋报名", sender="Bob")
|
||||
self.command("/自走棋加AI战队")
|
||||
return self.command("/自走棋开赛")
|
||||
|
||||
def launch(self, names: list[str]) -> dict:
|
||||
self.command("/自走棋开始", sender=names[0])
|
||||
for name in names[1:]:
|
||||
self.command("/自走棋报名", sender=name)
|
||||
self.command("/自走棋开赛", sender=names[0])
|
||||
return self.state()
|
||||
def finish_draft(self):
|
||||
self.command("/选1")
|
||||
self.command("/选1", sender="Bob")
|
||||
self.command("/选1", sender="Bob")
|
||||
self.command("/选1")
|
||||
self.command("/选1")
|
||||
return self.command("/选1", sender="Bob")
|
||||
|
||||
def settle_to_completion(self) -> dict:
|
||||
def ready_all(self):
|
||||
state = self.state()
|
||||
while state["status"] == "active":
|
||||
self.command("/自走棋强制结算")
|
||||
state = self.state()
|
||||
return state
|
||||
replies = []
|
||||
for key in list(state.get("ready_keys") or []):
|
||||
replies.extend(
|
||||
self.command(
|
||||
"/结束回合",
|
||||
sender=state["players"][key]["display_name"],
|
||||
)
|
||||
)
|
||||
return replies
|
||||
|
||||
def test_static_pool_parser_and_attribute_ranges(self):
|
||||
def test_static_pool_parser_and_coach_pool(self):
|
||||
self.assertEqual(len(AUTOCHESS.PLAYER_POOL), 60)
|
||||
self.assertEqual(len({card["id"] for card in AUTOCHESS.PLAYER_POOL}), 60)
|
||||
self.assertEqual(len(AUTOCHESS.COACH_POOL), 10)
|
||||
self.assertEqual(
|
||||
len({card["id"] for card in AUTOCHESS.PLAYER_POOL}),
|
||||
60,
|
||||
len({coach["id"] for coach in AUTOCHESS.COACH_POOL}),
|
||||
10,
|
||||
)
|
||||
ranges = {
|
||||
1: (52, 68),
|
||||
2: (60, 76),
|
||||
3: (68, 84),
|
||||
4: (78, 92),
|
||||
5: (88, 98),
|
||||
}
|
||||
for card in AUTOCHESS.PLAYER_POOL:
|
||||
with self.subTest(card=card["id"]):
|
||||
lower, upper = ranges[card["cost"]]
|
||||
for field in ("firepower", "stability", "clutch"):
|
||||
self.assertGreaterEqual(card[field], lower)
|
||||
self.assertLessEqual(card[field], upper)
|
||||
self.assertIn(card["role"], {"步枪手", "狙击手", "指挥"})
|
||||
self.assertLessEqual(len(card["teams"]), 3)
|
||||
if card["role"] == "指挥":
|
||||
self.assertGreater(card["leadership"], 0)
|
||||
self.assertEqual(
|
||||
AUTOCHESS.parse_autochess_action("/自走棋开始"),
|
||||
("create", ""),
|
||||
@@ -117,391 +97,81 @@ class Release064AutochessTests(unittest.TestCase):
|
||||
("tactic", "残局"),
|
||||
)
|
||||
|
||||
def test_round_plan_matches_player_count_without_duplicate_pairs(self):
|
||||
for size in range(2, 9):
|
||||
keys = [f"p{index}" for index in range(1, size + 1)]
|
||||
plan = AUTOCHESS.build_round_plan(keys)
|
||||
group_pairs = [
|
||||
tuple(sorted(pair))
|
||||
for stage in plan
|
||||
if stage["kind"] == "group"
|
||||
for pair in stage["pairs"]
|
||||
]
|
||||
with self.subTest(size=size):
|
||||
if size == 2:
|
||||
self.assertFalse(group_pairs)
|
||||
self.assertFalse(
|
||||
any(stage["kind"] == "semifinal" for stage in plan)
|
||||
)
|
||||
elif size == 4:
|
||||
semifinal = next(
|
||||
stage
|
||||
for stage in plan
|
||||
if stage["kind"] == "semifinal"
|
||||
)
|
||||
self.assertEqual(
|
||||
semifinal["pairs"],
|
||||
[["p1", "p4"], ["p2", "p3"]],
|
||||
)
|
||||
self.assertFalse(group_pairs)
|
||||
else:
|
||||
self.assertEqual(
|
||||
len(group_pairs),
|
||||
size * (size - 1) // 2,
|
||||
)
|
||||
self.assertEqual(len(group_pairs), len(set(group_pairs)))
|
||||
self.assertEqual(plan[-1]["kind"], "final")
|
||||
|
||||
def test_launch_has_fixed_five_coach_shared_shop_and_complete_data(self):
|
||||
response = self.command("/自走棋开始")
|
||||
self.assertTrue(response.startswith("alice #1:"))
|
||||
self.command("/自走棋报名", sender="Bob")
|
||||
self.command("/自走棋报名", sender="Carol")
|
||||
self.command("/自走棋报名", sender="Dave")
|
||||
response = self.command("/自走棋开赛")
|
||||
def test_launch_starts_multimessage_coach_draft_not_full_rosters(self):
|
||||
response = self.start_two_human_two_ai()
|
||||
state = self.state()
|
||||
|
||||
self.assertEqual(len(state["round_plan"]), 5)
|
||||
self.assertGreaterEqual(len(response), 5)
|
||||
self.assertIn("第一轮选秀", "\n".join(response))
|
||||
self.assertIn("1. zonic", "\n".join(response))
|
||||
self.assertEqual(state["phase"], "draft_coach_1")
|
||||
self.assertEqual(state["ai_test_variant"], "two_plus_two")
|
||||
self.assertEqual(
|
||||
[stage["kind"] for stage in state["round_plan"]],
|
||||
["pve", "pve", "pve", "semifinal", "final"],
|
||||
state["seed_order"][2:],
|
||||
[AUTOCHESS.AI_PERSON_KEY, AUTOCHESS.AI_SECOND_PERSON_KEY],
|
||||
)
|
||||
shops = []
|
||||
for player in state["players"].values():
|
||||
self.assertEqual(player["board"], [])
|
||||
self.assertEqual(player["coach_id"], "")
|
||||
|
||||
def test_three_round_draft_training_and_group_intro(self):
|
||||
self.start_two_human_two_ai()
|
||||
final_draft = self.finish_draft()
|
||||
state = self.state()
|
||||
|
||||
self.assertIn("三轮选秀结束", "\n".join(final_draft))
|
||||
self.assertEqual(state["phase"], "lineup_prep")
|
||||
for player in state["players"].values():
|
||||
self.assertEqual(len(player["board"]), 5)
|
||||
self.assertEqual(player["coach_level"], 1)
|
||||
self.assertEqual(player["tactic"], "稳定")
|
||||
self.assertEqual(len(player["shop"]), 6)
|
||||
shops.append(player["shop"])
|
||||
self.assertTrue(all(shop == shops[0] for shop in shops))
|
||||
for slot in range(1, 7):
|
||||
line = next(
|
||||
value
|
||||
for value in response.splitlines()
|
||||
if value.startswith(f"{slot}. ")
|
||||
)
|
||||
self.assertRegex(
|
||||
line,
|
||||
r"\d费 (?:步枪手|狙击手|指挥).*历史 .*火\d+ 稳\d+ 残\d+",
|
||||
)
|
||||
self.assertLessEqual(len(response), MODULE.MAX_REPLY_CHARS)
|
||||
self.assertEqual(len(player["bench"]), 3)
|
||||
self.assertTrue(player["coach_id"])
|
||||
|
||||
def test_tactics_coach_and_lineup_changes_preserve_five_starters(self):
|
||||
state = self.launch(["Alice", "Bob"])
|
||||
alice_key = state["seed_order"][0]
|
||||
before_board = copy.deepcopy(state["players"][alice_key]["board"])
|
||||
before_power = AUTOCHESS.lineup_power(
|
||||
state["players"][alice_key]
|
||||
)["power"]
|
||||
self.command("/结束回合")
|
||||
training = self.command("/结束回合", sender="Bob")
|
||||
self.assertIn("Team Falcons", "\n".join(training))
|
||||
self.assertIn("Natus Vincere", "\n".join(training))
|
||||
self.assertEqual(self.state()["phase"], "training_manage")
|
||||
|
||||
tactic = self.command("/战术残局")
|
||||
self.assertIn("战术设为 残局", tactic)
|
||||
coach = self.command("/升级教练")
|
||||
self.assertIn("教练升到 2 级", coach)
|
||||
self.command("/结束回合")
|
||||
group = self.command("/结束回合", sender="Bob")
|
||||
self.assertIn("正赛小组赛", "\n".join(group))
|
||||
state = self.state()
|
||||
alice = state["players"][alice_key]
|
||||
self.assertEqual(alice["board"], before_board)
|
||||
self.assertEqual(len(alice["board"]), 5)
|
||||
self.assertGreater(AUTOCHESS.lineup_power(alice)["power"], before_power)
|
||||
self.assertEqual(state["phase"], "group_ready")
|
||||
self.assertEqual(len(state["group_matches"]), 6)
|
||||
|
||||
def test_full_tournament_completes_and_only_active_playoff_players_ready(self):
|
||||
self.start_two_human_two_ai()
|
||||
self.finish_draft()
|
||||
seen_playoff_prompt = False
|
||||
|
||||
for _ in range(80):
|
||||
state = self.state()
|
||||
if state["status"] == "completed":
|
||||
break
|
||||
ready_keys = list(state.get("ready_keys") or [])
|
||||
self.assertTrue(ready_keys, state["phase"])
|
||||
if state["phase"] in {"semifinal_ready", "final_ready"}:
|
||||
pair = set(state["current_match"]["pair"])
|
||||
self.assertEqual(set(ready_keys), pair & set(AUTOCHESS._human_keys(state)))
|
||||
seen_playoff_prompt = True
|
||||
self.ready_all()
|
||||
else:
|
||||
self.fail("tournament did not complete")
|
||||
|
||||
starter_ids = {unit["card_id"] for unit in alice["board"]}
|
||||
slot = next(
|
||||
index
|
||||
for index, card_id in enumerate(alice["shop"], start=1)
|
||||
if card_id not in starter_ids
|
||||
)
|
||||
self.command(f"/选{slot}")
|
||||
self.assertIn("首发必须保持 5 人", self.command("/出售1"))
|
||||
swapped = self.command("/上阵 6 2")
|
||||
self.assertIn("首发仍为完整 5 人", swapped)
|
||||
state = self.state()
|
||||
self.assertEqual(len(state["players"][alice_key]["board"]), 5)
|
||||
self.assertTrue(seen_playoff_prompt)
|
||||
self.assertEqual(state["status"], "completed")
|
||||
self.assertEqual(state["phase"], "completed")
|
||||
self.assertTrue(state["champion_key"])
|
||||
|
||||
def test_replay_debits_one_purchase_and_invalid_archive_rolls_back(self):
|
||||
state = self.launch(["Alice", "Bob"])
|
||||
alice_key = state["seed_order"][0]
|
||||
card_id = state["players"][alice_key]["shop"][0]
|
||||
cost = AUTOCHESS.CARD_BY_ID[card_id]["cost"]
|
||||
old_gold = state["players"][alice_key]["gold"]
|
||||
old_pool = state["pool"][card_id]
|
||||
replies: list[str] = []
|
||||
errors: list[BaseException] = []
|
||||
|
||||
def buy() -> None:
|
||||
try:
|
||||
replies.append(
|
||||
self.command("/选1", event_id="same-autochess-buy")
|
||||
)
|
||||
except BaseException as error:
|
||||
errors.append(error)
|
||||
|
||||
threads = [threading.Thread(target=buy) for _ in range(8)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
self.assertFalse(errors)
|
||||
self.assertEqual(len(set(replies)), 1)
|
||||
state = self.state()
|
||||
self.assertEqual(state["players"][alice_key]["gold"], old_gold - cost)
|
||||
self.assertEqual(state["pool"][card_id], old_pool - 1)
|
||||
|
||||
before_json = self.engine.ledger.connection.execute(
|
||||
"""
|
||||
SELECT state_json FROM autochess_runs
|
||||
WHERE group_fingerprint = 'group-a' AND run_id = ?
|
||||
""",
|
||||
(state["run_id"],),
|
||||
).fetchone()[0]
|
||||
before_stories = self.engine.ledger.connection.execute(
|
||||
"SELECT COUNT(*) FROM autochess_story_logs"
|
||||
).fetchone()[0]
|
||||
|
||||
def invalid_archive(current, person, next_season):
|
||||
del person, next_season
|
||||
current["players"][alice_key]["gold"] += 999
|
||||
return {
|
||||
"state": current,
|
||||
"response": "invalid",
|
||||
"stories": [{"bad": True}],
|
||||
"battles": [],
|
||||
}
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
MODULE.LocalCommandError,
|
||||
"剧情归档无效",
|
||||
):
|
||||
self.engine.ledger.apply_autochess_action(
|
||||
"group-a",
|
||||
"Alice",
|
||||
"invalid-autochess-archive",
|
||||
"invalid",
|
||||
invalid_archive,
|
||||
)
|
||||
after_json = self.engine.ledger.connection.execute(
|
||||
"""
|
||||
SELECT state_json FROM autochess_runs
|
||||
WHERE group_fingerprint = 'group-a' AND run_id = ?
|
||||
""",
|
||||
(state["run_id"],),
|
||||
).fetchone()[0]
|
||||
after_stories = self.engine.ledger.connection.execute(
|
||||
"SELECT COUNT(*) FROM autochess_story_logs"
|
||||
).fetchone()[0]
|
||||
self.assertEqual(after_json, before_json)
|
||||
self.assertEqual(after_stories, before_stories)
|
||||
|
||||
def test_four_team_seed_bracket_is_bo5_with_mr15_scores(self):
|
||||
state = self.launch(["Alice", "Bob", "Carol", "Dave"])
|
||||
seed_order = list(state["seed_order"])
|
||||
completed = self.settle_to_completion()
|
||||
self.assertEqual(completed["status"], "completed")
|
||||
self.assertTrue(completed["champion_key"])
|
||||
rows = self.engine.ledger.connection.execute(
|
||||
"""
|
||||
SELECT battle_type, player_a, player_b, result_json
|
||||
FROM autochess_battles
|
||||
WHERE battle_type != 'pve'
|
||||
ORDER BY battle_id
|
||||
"""
|
||||
).fetchall()
|
||||
semifinals = [row for row in rows if row["battle_type"] == "semifinal_bo5"]
|
||||
finals = [row for row in rows if row["battle_type"] == "final_bo5"]
|
||||
self.assertEqual(
|
||||
{
|
||||
frozenset((row["player_a"], row["player_b"]))
|
||||
for row in semifinals
|
||||
},
|
||||
{
|
||||
frozenset((seed_order[0], seed_order[3])),
|
||||
frozenset((seed_order[1], seed_order[2])),
|
||||
},
|
||||
)
|
||||
self.assertEqual(len(finals), 1)
|
||||
for row in [*semifinals, *finals]:
|
||||
result = json.loads(row["result_json"])
|
||||
self.assertEqual(result["series_format"], "BO5")
|
||||
self.assertRegex(result["score"], r"^3-[0-2]$")
|
||||
self.assertTrue(3 <= len(result["maps"]) <= 5)
|
||||
for item in result["maps"]:
|
||||
self.assertRegex(item["score"], r"^(?:16-(?:6|9|12|14)|19-17|22-20)$")
|
||||
self.assertLessEqual(abs(item["fluctuation_a_bps"]), 200)
|
||||
self.assertLessEqual(abs(item["fluctuation_b_bps"]), 200)
|
||||
|
||||
def test_five_team_round_robin_bo3_then_top_four(self):
|
||||
completed = self.launch(["Alice", "Bob", "Carol", "Dave", "Eve"])
|
||||
self.assertEqual(
|
||||
sum(
|
||||
len(stage.get("pairs", []))
|
||||
for stage in completed["round_plan"]
|
||||
if stage["kind"] == "group"
|
||||
),
|
||||
10,
|
||||
)
|
||||
completed = self.settle_to_completion()
|
||||
self.assertEqual(len(completed["qualifiers"]), 4)
|
||||
rows = self.engine.ledger.connection.execute(
|
||||
"""
|
||||
SELECT battle_type, player_a, player_b, result_json
|
||||
FROM autochess_battles
|
||||
WHERE battle_type != 'pve'
|
||||
ORDER BY battle_id
|
||||
"""
|
||||
).fetchall()
|
||||
groups = [row for row in rows if row["battle_type"] == "group_bo3"]
|
||||
self.assertEqual(len(groups), 10)
|
||||
self.assertEqual(
|
||||
len(
|
||||
{
|
||||
frozenset((row["player_a"], row["player_b"]))
|
||||
for row in groups
|
||||
}
|
||||
),
|
||||
10,
|
||||
)
|
||||
for row in groups:
|
||||
result = json.loads(row["result_json"])
|
||||
self.assertEqual(result["series_format"], "BO3")
|
||||
self.assertRegex(result["score"], r"^2-[01]$")
|
||||
self.assertEqual(
|
||||
sum(row["battle_type"] == "semifinal_bo5" for row in rows),
|
||||
2,
|
||||
)
|
||||
self.assertEqual(
|
||||
sum(row["battle_type"] == "final_bo5" for row in rows),
|
||||
1,
|
||||
)
|
||||
|
||||
def test_three_humans_plus_ai_has_taffy_host_and_turn_handoffs(self):
|
||||
def test_multimessage_response_replays_identically(self):
|
||||
self.command("/自走棋开始")
|
||||
self.assertIn(
|
||||
"2 名或 3 名真人",
|
||||
self.command("/自走棋加AI战队"),
|
||||
)
|
||||
self.command("/自走棋报名", sender="Bob")
|
||||
self.command("/自走棋报名", sender="Carol")
|
||||
self.assertIn(
|
||||
"只有主持人",
|
||||
self.command("/自走棋加AI战队", sender="Bob"),
|
||||
)
|
||||
self.assertIn("第 4 种子", self.command("/自走棋加AI战队"))
|
||||
self.assertIn(
|
||||
"已经锁定参赛名单",
|
||||
self.command("/自走棋报名", sender="Dave"),
|
||||
)
|
||||
launch = self.command("/自走棋开赛")
|
||||
state = self.state()
|
||||
|
||||
self.assertIn("Taffy:欢迎大家来到第 1 届 IEM 盐田 Major", launch)
|
||||
self.assertIn("3 人+1 AI 测试赛", launch)
|
||||
self.assertIn("1v4、2v3", launch)
|
||||
self.assertIn("小组赛 BO3,淘汰赛 BO5", launch)
|
||||
self.assertIn("常规时间 15-15 进入加时", launch)
|
||||
self.assertIn("alice,请做好赛前准备", launch)
|
||||
self.assertTrue(state["ai_test_mode"])
|
||||
self.assertEqual(state["seed_order"][-1], AUTOCHESS.AI_PERSON_KEY)
|
||||
self.assertTrue(
|
||||
state["players"][AUTOCHESS.AI_PERSON_KEY]["is_ai"]
|
||||
)
|
||||
semifinal = state["round_plan"][-2]
|
||||
self.assertEqual(
|
||||
semifinal["pairs"],
|
||||
[
|
||||
[state["seed_order"][0], AUTOCHESS.AI_PERSON_KEY],
|
||||
[state["seed_order"][1], state["seed_order"][2]],
|
||||
],
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
"当前轮到 alice",
|
||||
self.command("/选1", sender="Bob"),
|
||||
)
|
||||
self.assertIn(
|
||||
"alice 准备完成!到 bob 做准备了",
|
||||
self.command("/结束回合"),
|
||||
)
|
||||
self.assertIn(
|
||||
"当前轮到 bob",
|
||||
self.command("/结束回合", sender="Carol"),
|
||||
)
|
||||
self.assertIn(
|
||||
"bob 准备完成!到 carol 做准备了",
|
||||
self.command("/结束回合", sender="Bob"),
|
||||
)
|
||||
self.assertIn(
|
||||
"全员准备完毕",
|
||||
self.command("/结束回合", sender="Carol"),
|
||||
)
|
||||
settled = self.command("/自走棋结算")
|
||||
self.assertIn("Taffy:AI 战队 自动运营", settled)
|
||||
self.assertIn("Taffy:bob,请做好赛前准备", settled)
|
||||
state = self.state()
|
||||
self.assertEqual(
|
||||
state["preparation_order"][0],
|
||||
state["seed_order"][1],
|
||||
)
|
||||
self.assertEqual(
|
||||
self.engine.ledger.connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*) FROM autochess_story_logs
|
||||
WHERE story_type = 'ai_operation'
|
||||
"""
|
||||
).fetchone()[0],
|
||||
1,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.engine.ledger.connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*) FROM autochess_story_logs
|
||||
WHERE story_text NOT LIKE 'Taffy:%'
|
||||
"""
|
||||
).fetchone()[0],
|
||||
0,
|
||||
)
|
||||
|
||||
def test_identical_final_creates_persisted_overtime_stories(self):
|
||||
state = self.launch(["Alice", "Bob"])
|
||||
for _ in range(3):
|
||||
self.command("/自走棋强制结算")
|
||||
state = self.state()
|
||||
self.assertEqual(AUTOCHESS._current_stage(state)["kind"], "final")
|
||||
first, second = state["seed_order"]
|
||||
state["players"][second]["board"] = copy.deepcopy(
|
||||
state["players"][first]["board"]
|
||||
)
|
||||
state["players"][first]["tactic"] = "稳定"
|
||||
state["players"][second]["tactic"] = "稳定"
|
||||
self.save_state(state)
|
||||
|
||||
with mock.patch.object(AUTOCHESS, "_hash_int", return_value=0):
|
||||
response = self.command(
|
||||
"/自走棋强制结算",
|
||||
event_id="forced-overtime-final",
|
||||
)
|
||||
self.assertIn("19-17", response)
|
||||
final = self.engine.ledger.connection.execute(
|
||||
"""
|
||||
SELECT result_json FROM autochess_battles
|
||||
WHERE battle_type = 'final_bo5'
|
||||
"""
|
||||
).fetchone()
|
||||
result = json.loads(final["result_json"])
|
||||
self.assertEqual(result["score"], "3-0")
|
||||
self.assertEqual(
|
||||
[item["score"] for item in result["maps"]],
|
||||
["19-17", "19-17", "19-17"],
|
||||
)
|
||||
overtime = self.engine.ledger.connection.execute(
|
||||
"""
|
||||
SELECT story_text, facts_json FROM autochess_story_logs
|
||||
WHERE story_type = 'overtime'
|
||||
ORDER BY story_id
|
||||
"""
|
||||
).fetchall()
|
||||
self.assertEqual(len(overtime), 3)
|
||||
for row in overtime:
|
||||
self.assertIn("常规时间战成 15-15", row["story_text"])
|
||||
self.assertEqual(json.loads(row["facts_json"])["overtime_count"], 1)
|
||||
self.command("/自走棋加AI战队")
|
||||
first = self.command("/自走棋开赛", event_id="same-launch")
|
||||
replay = self.command("/自走棋开赛", event_id="same-launch")
|
||||
self.assertEqual(first, replay)
|
||||
self.assertGreater(len(first), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+39
-72
@@ -1,4 +1,4 @@
|
||||
"""Behavioral coverage for the two-human plus two-AI test mode."""
|
||||
"""Behavioral coverage for AI team enrollment in CS autochess."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -11,7 +11,7 @@ from test_jwebot_edge import MODULE, config
|
||||
from jwebot_official_extensions import cs_autochess as AUTOCHESS
|
||||
|
||||
|
||||
class Release065TwoHumanAiTests(unittest.TestCase):
|
||||
class Release065AiEnrollmentTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.engine = MODULE.EdgeApplication(config(Path(self.temp.name)))
|
||||
@@ -23,15 +23,20 @@ class Release065TwoHumanAiTests(unittest.TestCase):
|
||||
|
||||
def command(self, question, *, sender="Alice", event_id=None):
|
||||
self.counter += 1
|
||||
return self.engine.local.handle(
|
||||
response = self.engine.accept(
|
||||
{
|
||||
"question": question,
|
||||
"sender": sender,
|
||||
"group_fingerprint": "group-a",
|
||||
"event_id": event_id or f"two-ai-{self.counter}",
|
||||
},
|
||||
{},
|
||||
"group_fingerprint": "group-a",
|
||||
"sender": sender,
|
||||
"text": "@JWeBot " + question,
|
||||
"mention_bot": True,
|
||||
"confidence": 1.0,
|
||||
"sender_confidence": 1.0,
|
||||
"body_confidence": 1.0,
|
||||
}
|
||||
)
|
||||
self.assertEqual(response["status"], "reply")
|
||||
return list(response.get("texts") or [response["text"]])
|
||||
|
||||
def state(self):
|
||||
row = self.engine.ledger.connection.execute(
|
||||
@@ -45,88 +50,50 @@ class Release065TwoHumanAiTests(unittest.TestCase):
|
||||
self.assertIsNotNone(row)
|
||||
return json.loads(row[0])
|
||||
|
||||
def test_two_humans_add_two_ai_and_use_four_seed_bracket(self):
|
||||
def test_two_humans_add_two_ai_and_lock_registration(self):
|
||||
self.command("/自走棋开始")
|
||||
self.command("/自走棋报名", sender="Bob")
|
||||
added = self.command("/自走棋加AI战队")
|
||||
added = "\n".join(self.command("/自走棋加AI战队"))
|
||||
|
||||
self.assertIn("AI 战队 A、AI 战队 B", added)
|
||||
self.assertIn("第 3、4 种子", added)
|
||||
self.assertIn("2 人+2 AI", added)
|
||||
self.assertIn(
|
||||
"已经锁定参赛名单",
|
||||
self.command("/自走棋报名", sender="Carol"),
|
||||
"已经锁定",
|
||||
"\n".join(self.command("/自走棋报名", sender="Carol")),
|
||||
)
|
||||
|
||||
launch = self.command("/自走棋开赛")
|
||||
launch = "\n".join(self.command("/自走棋开赛"))
|
||||
state = self.state()
|
||||
self.assertIn("2 人+2 AI 测试赛", launch)
|
||||
self.assertEqual(state["ai_test_variant"], "two_plus_two")
|
||||
self.assertEqual(
|
||||
state["seed_order"][2:],
|
||||
[
|
||||
AUTOCHESS.AI_PERSON_KEY,
|
||||
AUTOCHESS.AI_SECOND_PERSON_KEY,
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
state["preparation_order"],
|
||||
state["seed_order"][:2],
|
||||
)
|
||||
self.assertTrue(
|
||||
state["players"][AUTOCHESS.AI_PERSON_KEY]["is_ai"]
|
||||
)
|
||||
self.assertTrue(
|
||||
state["players"][AUTOCHESS.AI_SECOND_PERSON_KEY]["is_ai"]
|
||||
)
|
||||
semifinal = state["round_plan"][-2]
|
||||
self.assertEqual(
|
||||
semifinal["pairs"],
|
||||
[
|
||||
[state["seed_order"][0], state["seed_order"][3]],
|
||||
[state["seed_order"][1], state["seed_order"][2]],
|
||||
],
|
||||
[AUTOCHESS.AI_PERSON_KEY, AUTOCHESS.AI_SECOND_PERSON_KEY],
|
||||
)
|
||||
|
||||
self.assertIn("到 bob 做准备了", self.command("/结束回合"))
|
||||
self.assertIn(
|
||||
"全员准备完毕",
|
||||
self.command("/结束回合", sender="Bob"),
|
||||
)
|
||||
settled = self.command("/自走棋结算")
|
||||
self.assertIn("AI 战队 A 自动运营", settled)
|
||||
self.assertIn("AI 战队 B 自动运营", settled)
|
||||
self.assertEqual(
|
||||
self.engine.ledger.connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*) FROM autochess_story_logs
|
||||
WHERE story_type = 'ai_operation'
|
||||
"""
|
||||
).fetchone()[0],
|
||||
2,
|
||||
)
|
||||
|
||||
def test_add_two_ai_is_idempotent_and_cannot_be_repeated(self):
|
||||
def test_three_humans_add_one_ai(self):
|
||||
self.command("/自走棋开始")
|
||||
self.command("/自走棋报名", sender="Bob")
|
||||
first = self.command(
|
||||
"/自走棋加AI战队",
|
||||
event_id="same-add-two-ai",
|
||||
)
|
||||
replay = self.command(
|
||||
"/自走棋加AI战队",
|
||||
event_id="same-add-two-ai",
|
||||
)
|
||||
self.command("/自走棋报名", sender="Carol")
|
||||
added = "\n".join(self.command("/自走棋加AI战队"))
|
||||
self.assertIn("3 人+1 AI", added)
|
||||
|
||||
self.command("/自走棋开赛")
|
||||
state = self.state()
|
||||
self.assertEqual(state["ai_test_variant"], "three_plus_one")
|
||||
self.assertEqual(state["seed_order"][-1], AUTOCHESS.AI_PERSON_KEY)
|
||||
self.assertTrue(state["players"][AUTOCHESS.AI_PERSON_KEY]["is_ai"])
|
||||
|
||||
def test_add_ai_replay_is_idempotent(self):
|
||||
self.command("/自走棋开始")
|
||||
self.command("/自走棋报名", sender="Bob")
|
||||
first = self.command("/自走棋加AI战队", event_id="same-add-two-ai")
|
||||
replay = self.command("/自走棋加AI战队", event_id="same-add-two-ai")
|
||||
self.assertEqual(first, replay)
|
||||
self.assertIn(
|
||||
"已经加入",
|
||||
self.command("/自走棋加AI战队"),
|
||||
)
|
||||
self.assertIn("已经加入", "\n".join(self.command("/自走棋加AI战队")))
|
||||
|
||||
state = self.state()
|
||||
self.assertEqual(
|
||||
sum(
|
||||
bool(player.get("is_ai"))
|
||||
for player in state["players"].values()
|
||||
),
|
||||
sum(bool(player.get("is_ai")) for player in state["players"].values()),
|
||||
2,
|
||||
)
|
||||
|
||||
|
||||
@@ -61,6 +61,16 @@ class PlanContractTests(unittest.TestCase):
|
||||
self.assertEqual(plan.kind, PlanKind.TEXT)
|
||||
self.assertEqual(plan.to_mapping(), {"kind": "text", "text": "hello"})
|
||||
|
||||
def test_multi_text_plan_has_bounded_parts(self):
|
||||
plan = ReplyPlan.multi_text(["one", "two"])
|
||||
self.assertEqual(plan.kind, PlanKind.MULTI_TEXT)
|
||||
self.assertEqual(
|
||||
plan.to_mapping(),
|
||||
{"kind": "multi_text", "texts": ["one", "two"]},
|
||||
)
|
||||
restored = ReplyPlan.from_mapping(plan.to_mapping())
|
||||
self.assertEqual(restored, plan)
|
||||
|
||||
def test_no_reply_plan_cannot_contain_text(self):
|
||||
with self.assertRaisesRegex(ContractViolation, "cannot contain text"):
|
||||
ReplyPlan(kind=PlanKind.NO_REPLY, message="unexpected")
|
||||
|
||||
@@ -337,6 +337,37 @@ class LayoutScalingTests(unittest.TestCase):
|
||||
self.assertEqual(reply, "本地回复喵")
|
||||
legacy.assert_not_called()
|
||||
|
||||
def test_multi_edge_reply_is_sent_by_callback(self):
|
||||
legacy = mock.Mock(return_value="legacy")
|
||||
reply_sender = mock.Mock(return_value=True)
|
||||
incoming = {
|
||||
"event_id": "event-1",
|
||||
"group_fingerprint": "group-a",
|
||||
"text": "@JWeBot /自走棋开赛",
|
||||
}
|
||||
response = {
|
||||
"status": "reply",
|
||||
"route": "local",
|
||||
"execution_path": "local_command",
|
||||
"text": "第一段喵",
|
||||
"texts": ["第一段喵", "第二段喵"],
|
||||
"event_id": "event-1",
|
||||
}
|
||||
with mock.patch.object(
|
||||
MODULE.jwebot_edge,
|
||||
"request_edge",
|
||||
return_value=response,
|
||||
):
|
||||
reply = MODULE.edge_router_reply(
|
||||
incoming,
|
||||
legacy_router=legacy,
|
||||
reply_sender=reply_sender,
|
||||
)
|
||||
|
||||
self.assertEqual(reply, "NO_REPLY")
|
||||
reply_sender.assert_called_once_with(incoming, response)
|
||||
legacy.assert_not_called()
|
||||
|
||||
def test_fast_ack_is_sent_by_callback_without_upstream_duplicate(self):
|
||||
legacy = mock.Mock(return_value="legacy")
|
||||
fast_ack_sender = mock.Mock(return_value=True)
|
||||
@@ -400,7 +431,7 @@ class LayoutScalingTests(unittest.TestCase):
|
||||
side_effect=MODULE.jwebot_edge.EdgeError("unavailable"),
|
||||
):
|
||||
reply = MODULE.edge_router_reply(
|
||||
{"event_id": "event-1", "text": "@JWeBot #状态"},
|
||||
{"event_id": "event-1", "text": "#状态"},
|
||||
legacy_router=legacy,
|
||||
)
|
||||
|
||||
|
||||
@@ -89,6 +89,14 @@ def _handler(context: SingleGroupContext, command: str):
|
||||
"event": event.to_mapping(),
|
||||
}},
|
||||
)
|
||||
texts = response.get("texts")
|
||||
if isinstance(texts, list) and texts and all(
|
||||
isinstance(item, str) and item.strip() for item in texts
|
||||
):
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.multi_text(texts),
|
||||
metadata={{"local_extension": PROFILE, "author": AUTHOR}},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
|
||||
@@ -644,6 +644,7 @@ def edge_router_reply(
|
||||
*,
|
||||
legacy_router: Any,
|
||||
fast_ack_sender: Callable[[dict[str, Any], dict[str, Any]], bool] | None = None,
|
||||
reply_sender: Callable[[dict[str, Any], dict[str, Any]], bool] | None = None,
|
||||
) -> str:
|
||||
"""Use the local edge if healthy, otherwise preserve the existing cloud route."""
|
||||
|
||||
@@ -688,6 +689,34 @@ def edge_router_reply(
|
||||
return legacy_router(event)
|
||||
return "JWeBot Error: Local edge unavailable"
|
||||
if response.get("status") == "reply":
|
||||
texts = response.get("texts")
|
||||
if (
|
||||
isinstance(texts, list)
|
||||
and len(texts) > 1
|
||||
and all(isinstance(item, str) and 0 < len(item) <= 900 for item in texts)
|
||||
):
|
||||
if reply_sender is None:
|
||||
logging.warning(
|
||||
"multi reply sender is unavailable event_hash=%s",
|
||||
hashlib.sha256(
|
||||
str(event.get("event_id", "")).encode("utf-8", "replace")
|
||||
).hexdigest()[:12],
|
||||
)
|
||||
return "JWeBot Error: Multi reply unavailable"
|
||||
if not reply_sender(event, response):
|
||||
return "JWeBot Error: Multi reply delivery failed"
|
||||
TELEMETRY.elapsed(
|
||||
"recorder.edge.request",
|
||||
started,
|
||||
event_id=event_id,
|
||||
group_id=group_id,
|
||||
route=str(response.get("route", "")),
|
||||
execution_path=str(response.get("execution_path", "")),
|
||||
chars=sum(len(item) for item in texts),
|
||||
messages=len(texts),
|
||||
outcome="multi_reply",
|
||||
)
|
||||
return "NO_REPLY"
|
||||
reply = str(response.get("text", ""))
|
||||
if reply and len(reply) <= 900:
|
||||
if response.get("fast_ack") is True:
|
||||
@@ -986,10 +1015,62 @@ def run() -> None:
|
||||
)
|
||||
return True
|
||||
|
||||
def send_multi_reply(
|
||||
event: dict[str, Any],
|
||||
response: dict[str, Any],
|
||||
) -> bool:
|
||||
started = time.monotonic()
|
||||
event_id = str(response.get("event_id") or event.get("event_id", ""))
|
||||
expected_group = str(event.get("group_fingerprint", ""))
|
||||
texts = response.get("texts")
|
||||
if (
|
||||
not event_id
|
||||
or not expected_group
|
||||
or not isinstance(texts, list)
|
||||
or not texts
|
||||
or any(not isinstance(item, str) or not item or len(item) > 900 for item in texts)
|
||||
):
|
||||
logging.error("edge multi reply response was invalid")
|
||||
return False
|
||||
try:
|
||||
for index, text in enumerate(texts, start=1):
|
||||
module.send_reply(
|
||||
text,
|
||||
expected_group,
|
||||
event_id=event_id,
|
||||
source=f"local-multi-{index}",
|
||||
)
|
||||
except Exception as error:
|
||||
logging.warning(
|
||||
"multi reply GUI delivery failed event_hash=%s error_type=%s",
|
||||
hashlib.sha256(event_id.encode("utf-8", "replace")).hexdigest()[:12],
|
||||
type(error).__name__,
|
||||
)
|
||||
TELEMETRY.elapsed(
|
||||
"recorder.multi_reply.gui",
|
||||
started,
|
||||
status="error",
|
||||
event_id=event_id,
|
||||
group_id=expected_group,
|
||||
messages=len(texts),
|
||||
error_type=type(error).__name__,
|
||||
)
|
||||
return False
|
||||
TELEMETRY.elapsed(
|
||||
"recorder.multi_reply.gui",
|
||||
started,
|
||||
event_id=event_id,
|
||||
group_id=expected_group,
|
||||
messages=len(texts),
|
||||
delivered=True,
|
||||
)
|
||||
return True
|
||||
|
||||
module.route_event = lambda event: edge_router_reply(
|
||||
event,
|
||||
legacy_router=legacy_router,
|
||||
fast_ack_sender=send_fast_ack,
|
||||
reply_sender=send_multi_reply,
|
||||
)
|
||||
wait_for_ready(module)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user