From 24469372fb1e37581ea74c36bdcf8d0e8f6d33a3 Mon Sep 17 00:00:00 2001 From: ar51 Date: Fri, 4 Sep 2026 15:46:18 +0800 Subject: [PATCH] fix: use message reaction for query acknowledgement --- .../docs/acceptance_samples.md | 6 +- .../docs/deployment_checklist.md | 2 + .../am516-delivery-prediction/docs/runbook.md | 4 +- .../joint-module-leadtime-report/SKILL.md | 6 +- shared/runtime/README.md | 3 +- shared/runtime/feishu_bot_bridge.py | 57 ++++---- tests/test_feishu_bot_bridge.py | 122 +++++++++++++++--- 7 files changed, 151 insertions(+), 49 deletions(-) diff --git a/capabilities/am516-delivery-prediction/docs/acceptance_samples.md b/capabilities/am516-delivery-prediction/docs/acceptance_samples.md index 56628fc..206715f 100644 --- a/capabilities/am516-delivery-prediction/docs/acceptance_samples.md +++ b/capabilities/am516-delivery-prediction/docs/acceptance_samples.md @@ -14,7 +14,7 @@ Expected: - model parses successfully; - quantity is accepted; -- the bot immediately replies with `πŸ‘€` after the valid trigger is accepted and before the API query starts; +- the bot immediately adds the Feishu `OnIt` (typing) reaction to the original message after the valid trigger is accepted and before the API query starts; - the controlled script uses the macOS system trust store and keeps TLS verification enabled; - no temporary TLS-bypass parameter is required for the normal happy path; - API script runs; @@ -72,7 +72,7 @@ is not required. Expected: -- the bot immediately replies with `πŸ‘€` after the continuation and active-context gates pass; +- the bot immediately adds the Feishu `OnIt` (typing) reaction to the original message after the continuation and active-context gates pass; - the bot reuses the active model and quantity only for that same sender and chat; - the bot queries candidate positions 5-8 in the unchanged rule order; - the original model and candidate positions 1-4 are not queried or displayed again; @@ -97,7 +97,7 @@ eRob110H160I-FHM-18ET[V6] Expected: - bot stays silent; -- no acknowledgement emoji is sent; +- no acknowledgement reaction is added; - no API call is made; - no record is appended. diff --git a/capabilities/am516-delivery-prediction/docs/deployment_checklist.md b/capabilities/am516-delivery-prediction/docs/deployment_checklist.md index df47a5f..aa78034 100644 --- a/capabilities/am516-delivery-prediction/docs/deployment_checklist.md +++ b/capabilities/am516-delivery-prediction/docs/deployment_checklist.md @@ -36,6 +36,7 @@ - [ ] If that domain-and-permission record is absent, outbound access remains blocked; a general AR51 functional-test approval is not sufficient by itself. - [ ] Only after approval is recorded, network can reach the AR836 delivery prediction API. - [ ] Feishu bot bridge or local entry is configured. +- [ ] The Feishu app has `im:message.reactions:write_only` so the bot can add the `OnIt` received reaction to valid query messages. - [ ] Logs are stored outside Git or in an approved non-secret location. ## 5. Acceptance @@ -47,6 +48,7 @@ - [ ] The initial response queries no more than four recommended candidates in addition to the original-model baseline; same-sender, same-chat continuation queries the next four without duplication. - [ ] Candidate continuation without matching active context stays silent; a bridge restart or six-hour expiry requires the complete model and quantity again. - [ ] Output includes the fixed internal-candidate disclaimer. +- [ ] A real valid query shows the `OnIt` typing reaction below the original message rather than creating a reply bubble or reply thread. - [ ] Records append to `records/delivery_prediction_records.md`. - [ ] A direct AR51 task in Mac Studio Codex can modify a test project file or perform the intended Git update without being blocked by the Feishu read-only rule. - [ ] A Feishu request to modify a rule or other project file is refused and leaves all project files unchanged. diff --git a/capabilities/am516-delivery-prediction/docs/runbook.md b/capabilities/am516-delivery-prediction/docs/runbook.md index eb7e4da..fe4a12c 100644 --- a/capabilities/am516-delivery-prediction/docs/runbook.md +++ b/capabilities/am516-delivery-prediction/docs/runbook.md @@ -8,7 +8,7 @@ 4. Confirm a separate trusted AR51 approval record explicitly covers `api.zeroerr-agent.com`, the AM516 use case, and API-key use. 5. If that domain-and-permission record is absent, stop before any outbound request; a general functional-test approval is insufficient by itself. 6. Use model recommendation rules to generate the complete ordered internal candidate pool. -7. Immediately reply with `πŸ‘€` to acknowledge the valid request. +7. Immediately add the Feishu `OnIt` (typing) reaction to the original message to acknowledge the valid request. 8. Run the controlled script for the original model baseline and only the first four candidate models. 9. Convert each numeric natural-day lead time to workdays with the approved quotient-and-remainder rule, then count those workdays from the local query date through the configured China statutory workday calendar. The query date itself is excluded. 10. Summarize the current batch, including the original natural days, converted workdays, and calculated delivery date; state how many candidates remain and invite the same sender to ask naturally for more when candidates remain. @@ -19,7 +19,7 @@ 1. Accept a clear request for more candidates only from the same sender in the same Feishu chat as the active initial query. Equivalent expressions such as `θΏ˜ζœ‰ε—`、`ζœ‰ζ²‘ζœ‰εˆ«ηš„`、`ε†ζŽ¨θε‡ δΈͺ`、`η»§η»­`、`δΈ‹δΈ€ζ‰Ή` or `more candidates` are valid; no exact phrase is required. 2. The in-memory continuation context expires after six hours and is cleared by a bridge restart. If no matching context exists, stay silent and require a new complete model-and-quantity query. -3. Immediately reply with `πŸ‘€`, then query the next four unqueried rule candidates; do not re-query the original baseline or any earlier candidate. +3. Immediately add the Feishu `OnIt` (typing) reaction to the original message, then query the next four unqueried rule candidates; do not re-query the original baseline or any earlier candidate. 4. Missing or unsupported candidates still consume their rule-order positions and remain under `不可用候选`. 5. Append one record block for the models queried in this continuation batch, then state whether more candidates remain. 6. A new complete model-and-quantity request replaces the earlier continuation context for that sender and chat. diff --git a/capabilities/am516-delivery-prediction/skills/joint-module-leadtime-report/SKILL.md b/capabilities/am516-delivery-prediction/skills/joint-module-leadtime-report/SKILL.md index 6fe8851..d4cd6cc 100644 --- a/capabilities/am516-delivery-prediction/skills/joint-module-leadtime-report/SKILL.md +++ b/capabilities/am516-delivery-prediction/skills/joint-module-leadtime-report/SKILL.md @@ -45,10 +45,10 @@ Stop if: - Enter the query workflow only when one message contains exactly one complete, rule-valid eRob model and exactly one positive quantity. - After a valid initial query or valid same-conversation candidate continuation - passes this gate, immediately reply with `πŸ‘€` before starting the API query so - the sender knows the request was received. + passes this gate, immediately add the Feishu `OnIt` (typing) reaction to the + original message before starting the API query so the sender knows it was received. - If the model or quantity is missing, invalid, multiple, or ambiguous, stay - silent: send no acknowledgement or other Feishu reply, make no API request, + silent: add no reaction, send no Feishu reply, make no API request, and append no record. - An @mention by itself does not bypass this content gate. - After a valid message passes the gate, API or runtime failures may still return diff --git a/shared/runtime/README.md b/shared/runtime/README.md index f80ee69..4d41dce 100644 --- a/shared/runtime/README.md +++ b/shared/runtime/README.md @@ -30,7 +30,8 @@ Do not start the bridge or the delivery-prediction API request until AR51 has cr connection, then publish the app version. For this bridge, request only these scopes: `im:message` for reading and -replying, plus `im:message.p2p_msg` for direct messages and/or +replying, `im:message.reactions:write_only` for adding the `OnIt` received +reaction, plus `im:message.p2p_msg` for direct messages and/or `im:message.group_at_msg` for messages that @ the bot in a group. Do not grant the group-wide message scope unless the business case requires it. diff --git a/shared/runtime/feishu_bot_bridge.py b/shared/runtime/feishu_bot_bridge.py index 46ec582..5abb4a9 100644 --- a/shared/runtime/feishu_bot_bridge.py +++ b/shared/runtime/feishu_bot_bridge.py @@ -79,7 +79,7 @@ ALLOWED_VERSIONS = { } CANDIDATE_BATCH_SIZE = 4 CANDIDATE_CONTINUATION_TTL_SECONDS = 6 * 60 * 60 -RECEIVED_ACKNOWLEDGEMENT = "πŸ‘€" +RECEIVED_REACTION_TYPE = "OnIt" def _inclusive_date_range(start: date, end: date) -> frozenset[date]: @@ -1160,33 +1160,40 @@ def reply(client: lark.Client, message_id: str, text: str) -> None: logging.info("Feishu reply sent: message_id=%s parts=%s", message_id, len(post_contents)) -def reply_received_acknowledgement(client: lark.Client, message_id: str) -> None: - request = ( - lark.im.v1.ReplyMessageRequest.builder() - .message_id(message_id) - .request_body( - lark.im.v1.ReplyMessageRequestBody.builder() - .msg_type("text") - .content( - json.dumps( - {"text": RECEIVED_ACKNOWLEDGEMENT}, - ensure_ascii=False, - ) - ) - .uuid(f"{message_id[:44]}-ack") +def add_received_reaction(client: lark.Client, message_id: str) -> bool: + try: + request_body = ( + lark.im.v1.CreateMessageReactionRequestBody.builder() + .reaction_type({"emoji_type": RECEIVED_REACTION_TYPE}) .build() ) - .build() - ) - response = client.im.v1.message.reply(request) - if not response.success(): - logging.error( - "Feishu acknowledgement failed: code=%s message_id=%s", - response.code, + request = ( + lark.im.v1.CreateMessageReactionRequest.builder() + .message_id(message_id) + .request_body(request_body) + .build() + ) + response = client.im.v1.message_reaction.create(request) + except Exception: + logging.exception( + "Feishu received reaction raised; continuing query: message_id=%s", message_id, ) - return - logging.info("Feishu acknowledgement sent: message_id=%s", message_id) + return False + if not response.success(): + logging.error( + "Feishu received reaction failed: code=%s msg=%s message_id=%s", + response.code, + response.msg, + message_id, + ) + return False + logging.info( + "Feishu received reaction added: type=%s message_id=%s", + RECEIVED_REACTION_TYPE, + message_id, + ) + return True _seen_message_ids: set[str] = set() @@ -1215,7 +1222,7 @@ def handle_message(client: lark.Client, data: lark.im.v1.P2ImMessageReceiveV1) - ) return - reply_received_acknowledgement(client, message_id) + add_received_reaction(client, message_id) if continuation_state is None: clear_candidate_continuation(key) diff --git a/tests/test_feishu_bot_bridge.py b/tests/test_feishu_bot_bridge.py index 2b4a18e..86d7936 100644 --- a/tests/test_feishu_bot_bridge.py +++ b/tests/test_feishu_bot_bridge.py @@ -106,14 +106,14 @@ class TriggerGateTests(unittest.TestCase): mock.patch.object(bridge, "build_report") as build_report, mock.patch.object( bridge, - "reply_received_acknowledgement", - ) as acknowledge, + "add_received_reaction", + ) as react, mock.patch.object(bridge, "reply") as reply, ): bridge.handle_message(object(), data) build_report.assert_not_called() - acknowledge.assert_not_called() + react.assert_not_called() reply.assert_not_called() def test_valid_message_builds_and_replies(self) -> None: @@ -129,19 +129,111 @@ class TriggerGateTests(unittest.TestCase): ), mock.patch.object( bridge, - "reply_received_acknowledgement", - ) as acknowledge, + "add_received_reaction", + return_value=False, + ) as react, mock.patch.object(bridge, "build_report", return_value="report") as build_report, mock.patch.object(bridge, "reply") as reply, ): bridge.handle_message("client", data) - acknowledge.assert_called_once_with("client", "om_valid") + react.assert_called_once_with("client", "om_valid") build_report.assert_called_once_with( bridge.RequestInput(model="eRob70H50I-BHM-18CTC[V5]", quantity=10) ) reply.assert_called_once_with("client", "om_valid", "report") + def test_received_reaction_targets_original_message_with_onit(self) -> None: + class ReactionBodyBuilder: + def __init__(self) -> None: + self.value: dict[str, str] | None = None + + def reaction_type(self, value: dict[str, str]) -> "ReactionBodyBuilder": + self.value = value + return self + + def build(self) -> SimpleNamespace: + return SimpleNamespace(reaction_type=self.value) + + class ReactionRequestBuilder: + def __init__(self) -> None: + self.message_id_value: str | None = None + self.body: SimpleNamespace | None = None + + def message_id(self, value: str) -> "ReactionRequestBuilder": + self.message_id_value = value + return self + + def request_body(self, value: SimpleNamespace) -> "ReactionRequestBuilder": + self.body = value + return self + + def build(self) -> SimpleNamespace: + return SimpleNamespace( + message_id=self.message_id_value, + request_body=self.body, + ) + + body_type = SimpleNamespace(builder=ReactionBodyBuilder) + request_type = SimpleNamespace(builder=ReactionRequestBuilder) + create_reaction = mock.Mock( + return_value=SimpleNamespace( + success=lambda: True, + code=0, + msg="ok", + ) + ) + client = SimpleNamespace( + im=SimpleNamespace( + v1=SimpleNamespace( + message_reaction=SimpleNamespace(create=create_reaction), + ) + ) + ) + + with ( + mock.patch.object( + bridge.lark.im.v1, + "CreateMessageReactionRequestBody", + body_type, + create=True, + ), + mock.patch.object( + bridge.lark.im.v1, + "CreateMessageReactionRequest", + request_type, + create=True, + ), + ): + result = bridge.add_received_reaction(client, "om_original") + + self.assertTrue(result) + sent_request = create_reaction.call_args.args[0] + self.assertEqual(sent_request.message_id, "om_original") + self.assertEqual( + sent_request.request_body.reaction_type, + {"emoji_type": "OnIt"}, + ) + + def test_received_reaction_exception_does_not_escape(self) -> None: + broken_body_type = SimpleNamespace( + builder=mock.Mock(side_effect=RuntimeError("reaction unavailable")) + ) + + with ( + mock.patch.object( + bridge.lark.im.v1, + "CreateMessageReactionRequestBody", + broken_body_type, + create=True, + ), + mock.patch.object(bridge.logging, "exception") as log_exception, + ): + result = bridge.add_received_reaction(object(), "om_original") + + self.assertFalse(result) + log_exception.assert_called_once() + def test_candidate_continuation_phrase_is_recognized(self) -> None: valid_phrases = ( "θΏ˜ζœ‰ζ²‘ζœ‰ε…Άδ»–ε€™ι€‰εž‹ε·οΌŸ", @@ -190,14 +282,14 @@ class TriggerGateTests(unittest.TestCase): mock.patch.object(bridge, "build_report") as build_report, mock.patch.object( bridge, - "reply_received_acknowledgement", - ) as acknowledge, + "add_received_reaction", + ) as react, mock.patch.object(bridge, "reply") as reply, ): bridge.handle_message("client", data) build_report.assert_not_called() - acknowledge.assert_not_called() + react.assert_not_called() reply.assert_not_called() def test_follow_up_continues_from_fifth_candidate_in_same_conversation(self) -> None: @@ -219,8 +311,8 @@ class TriggerGateTests(unittest.TestCase): ) as build_report, mock.patch.object( bridge, - "reply_received_acknowledgement", - ) as acknowledge, + "add_received_reaction", + ) as react, mock.patch.object(bridge, "reply") as reply, ): bridge.handle_message("client", initial_data) @@ -231,7 +323,7 @@ class TriggerGateTests(unittest.TestCase): [mock.call(request), mock.call(request, candidate_offset=4)], ) self.assertEqual( - acknowledge.call_args_list, + react.call_args_list, [ mock.call("client", "om_initial"), mock.call("client", "om_follow_up"), @@ -258,8 +350,8 @@ class TriggerGateTests(unittest.TestCase): ), mock.patch.object( bridge, - "reply_received_acknowledgement", - ) as acknowledge, + "add_received_reaction", + ) as react, mock.patch.object(bridge, "build_report", return_value="report") as build_report, mock.patch.object(bridge, "reply") as reply, ): @@ -269,7 +361,7 @@ class TriggerGateTests(unittest.TestCase): build_report.assert_called_once_with( bridge.RequestInput(model="eRob70H50I-BHM-18CTC[V5]", quantity=10) ) - acknowledge.assert_called_once_with("client", "om_small") + react.assert_called_once_with("client", "om_small") reply.assert_called_once_with("client", "om_small", "report") def test_expired_candidate_context_is_removed(self) -> None: