fix: use message reaction for query acknowledgement
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
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 = (
|
||||
lark.im.v1.ReplyMessageRequest.builder()
|
||||
lark.im.v1.CreateMessageReactionRequest.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")
|
||||
.request_body(request_body)
|
||||
.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,
|
||||
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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user