fix: use message reaction for query acknowledgement

This commit is contained in:
ar51
2026-09-04 15:46:18 +08:00
parent c7c5c3259c
commit 24469372fb
7 changed files with 151 additions and 49 deletions

View File

@@ -14,7 +14,7 @@ Expected:
- model parses successfully; - model parses successfully;
- quantity is accepted; - 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; - 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; - no temporary TLS-bypass parameter is required for the normal happy path;
- API script runs; - API script runs;
@@ -72,7 +72,7 @@ is not required.
Expected: 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 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 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; - the original model and candidate positions 1-4 are not queried or displayed again;
@@ -97,7 +97,7 @@ eRob110H160I-FHM-18ET[V6]
Expected: Expected:
- bot stays silent; - bot stays silent;
- no acknowledgement emoji is sent; - no acknowledgement reaction is added;
- no API call is made; - no API call is made;
- no record is appended. - no record is appended.

View File

@@ -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. - [ ] 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. - [ ] Only after approval is recorded, network can reach the AR836 delivery prediction API.
- [ ] Feishu bot bridge or local entry is configured. - [ ] 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. - [ ] Logs are stored outside Git or in an approved non-secret location.
## 5. Acceptance ## 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. - [ ] 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. - [ ] 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. - [ ] 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`. - [ ] 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 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. - [ ] A Feishu request to modify a rule or other project file is refused and leaves all project files unchanged.

View File

@@ -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. 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. 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. 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. 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. 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. 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. 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. 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 `不可用候选`. 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. 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. 6. A new complete model-and-quantity request replaces the earlier continuation context for that sender and chat.

View File

@@ -45,10 +45,10 @@ Stop if:
- Enter the query workflow only when one message contains exactly one complete, - Enter the query workflow only when one message contains exactly one complete,
rule-valid eRob model and exactly one positive quantity. rule-valid eRob model and exactly one positive quantity.
- After a valid initial query or valid same-conversation candidate continuation - After a valid initial query or valid same-conversation candidate continuation
passes this gate, immediately reply with `👀` before starting the API query so passes this gate, immediately add the Feishu `OnIt` (typing) reaction to the
the sender knows the request was received. 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 - 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. and append no record.
- An @mention by itself does not bypass this content gate. - An @mention by itself does not bypass this content gate.
- After a valid message passes the gate, API or runtime failures may still return - After a valid message passes the gate, API or runtime failures may still return

View File

@@ -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. connection, then publish the app version.
For this bridge, request only these scopes: `im:message` for reading and 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 `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. the group-wide message scope unless the business case requires it.

View File

@@ -79,7 +79,7 @@ ALLOWED_VERSIONS = {
} }
CANDIDATE_BATCH_SIZE = 4 CANDIDATE_BATCH_SIZE = 4
CANDIDATE_CONTINUATION_TTL_SECONDS = 6 * 60 * 60 CANDIDATE_CONTINUATION_TTL_SECONDS = 6 * 60 * 60
RECEIVED_ACKNOWLEDGEMENT = "👀" RECEIVED_REACTION_TYPE = "OnIt"
def _inclusive_date_range(start: date, end: date) -> frozenset[date]: 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)) 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: 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()
)
request = ( request = (
lark.im.v1.ReplyMessageRequest.builder() lark.im.v1.CreateMessageReactionRequest.builder()
.message_id(message_id) .message_id(message_id)
.request_body( .request_body(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")
.build() .build()
) )
.build() response = client.im.v1.message_reaction.create(request)
) except Exception:
response = client.im.v1.message.reply(request) logging.exception(
if not response.success(): "Feishu received reaction raised; continuing query: message_id=%s",
logging.error(
"Feishu acknowledgement failed: code=%s message_id=%s",
response.code,
message_id, message_id,
) )
return return False
logging.info("Feishu acknowledgement sent: message_id=%s", message_id) 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() _seen_message_ids: set[str] = set()
@@ -1215,7 +1222,7 @@ def handle_message(client: lark.Client, data: lark.im.v1.P2ImMessageReceiveV1) -
) )
return return
reply_received_acknowledgement(client, message_id) add_received_reaction(client, message_id)
if continuation_state is None: if continuation_state is None:
clear_candidate_continuation(key) clear_candidate_continuation(key)

View File

@@ -106,14 +106,14 @@ class TriggerGateTests(unittest.TestCase):
mock.patch.object(bridge, "build_report") as build_report, mock.patch.object(bridge, "build_report") as build_report,
mock.patch.object( mock.patch.object(
bridge, bridge,
"reply_received_acknowledgement", "add_received_reaction",
) as acknowledge, ) as react,
mock.patch.object(bridge, "reply") as reply, mock.patch.object(bridge, "reply") as reply,
): ):
bridge.handle_message(object(), data) bridge.handle_message(object(), data)
build_report.assert_not_called() build_report.assert_not_called()
acknowledge.assert_not_called() react.assert_not_called()
reply.assert_not_called() reply.assert_not_called()
def test_valid_message_builds_and_replies(self) -> None: def test_valid_message_builds_and_replies(self) -> None:
@@ -129,19 +129,111 @@ class TriggerGateTests(unittest.TestCase):
), ),
mock.patch.object( mock.patch.object(
bridge, bridge,
"reply_received_acknowledgement", "add_received_reaction",
) as acknowledge, return_value=False,
) as react,
mock.patch.object(bridge, "build_report", return_value="report") as build_report, mock.patch.object(bridge, "build_report", return_value="report") as build_report,
mock.patch.object(bridge, "reply") as reply, mock.patch.object(bridge, "reply") as reply,
): ):
bridge.handle_message("client", data) 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( build_report.assert_called_once_with(
bridge.RequestInput(model="eRob70H50I-BHM-18CTC[V5]", quantity=10) bridge.RequestInput(model="eRob70H50I-BHM-18CTC[V5]", quantity=10)
) )
reply.assert_called_once_with("client", "om_valid", "report") 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: def test_candidate_continuation_phrase_is_recognized(self) -> None:
valid_phrases = ( valid_phrases = (
"还有没有其他候选型号?", "还有没有其他候选型号?",
@@ -190,14 +282,14 @@ class TriggerGateTests(unittest.TestCase):
mock.patch.object(bridge, "build_report") as build_report, mock.patch.object(bridge, "build_report") as build_report,
mock.patch.object( mock.patch.object(
bridge, bridge,
"reply_received_acknowledgement", "add_received_reaction",
) as acknowledge, ) as react,
mock.patch.object(bridge, "reply") as reply, mock.patch.object(bridge, "reply") as reply,
): ):
bridge.handle_message("client", data) bridge.handle_message("client", data)
build_report.assert_not_called() build_report.assert_not_called()
acknowledge.assert_not_called() react.assert_not_called()
reply.assert_not_called() reply.assert_not_called()
def test_follow_up_continues_from_fifth_candidate_in_same_conversation(self) -> None: def test_follow_up_continues_from_fifth_candidate_in_same_conversation(self) -> None:
@@ -219,8 +311,8 @@ class TriggerGateTests(unittest.TestCase):
) as build_report, ) as build_report,
mock.patch.object( mock.patch.object(
bridge, bridge,
"reply_received_acknowledgement", "add_received_reaction",
) as acknowledge, ) as react,
mock.patch.object(bridge, "reply") as reply, mock.patch.object(bridge, "reply") as reply,
): ):
bridge.handle_message("client", initial_data) bridge.handle_message("client", initial_data)
@@ -231,7 +323,7 @@ class TriggerGateTests(unittest.TestCase):
[mock.call(request), mock.call(request, candidate_offset=4)], [mock.call(request), mock.call(request, candidate_offset=4)],
) )
self.assertEqual( self.assertEqual(
acknowledge.call_args_list, react.call_args_list,
[ [
mock.call("client", "om_initial"), mock.call("client", "om_initial"),
mock.call("client", "om_follow_up"), mock.call("client", "om_follow_up"),
@@ -258,8 +350,8 @@ class TriggerGateTests(unittest.TestCase):
), ),
mock.patch.object( mock.patch.object(
bridge, bridge,
"reply_received_acknowledgement", "add_received_reaction",
) as acknowledge, ) as react,
mock.patch.object(bridge, "build_report", return_value="report") as build_report, mock.patch.object(bridge, "build_report", return_value="report") as build_report,
mock.patch.object(bridge, "reply") as reply, mock.patch.object(bridge, "reply") as reply,
): ):
@@ -269,7 +361,7 @@ class TriggerGateTests(unittest.TestCase):
build_report.assert_called_once_with( build_report.assert_called_once_with(
bridge.RequestInput(model="eRob70H50I-BHM-18CTC[V5]", quantity=10) 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") reply.assert_called_once_with("client", "om_small", "report")
def test_expired_candidate_context_is_removed(self) -> None: def test_expired_candidate_context_is_removed(self) -> None: