diff --git a/emrg/server/feishu_prompt.md b/emrg/server/feishu_prompt.md index 013632d9..38346511 100644 --- a/emrg/server/feishu_prompt.md +++ b/emrg/server/feishu_prompt.md @@ -32,6 +32,17 @@ in the usual install locations before calling it blocked. - Then read each conversation: `im +chat-messages-list --chat-id --start --end --order desc --no-reactions`. The messages are under `data.messages` — not `data.items`. +- Read as the user, send as the bot: reading a direct conversation needs `--as user`, because a + bot cannot see a p2p conversation, while a message to the host is sent as the bot (§3). The + two identities differ, and an `im` call that names neither is answered by the CLI's own `auto` + default rather than by what the call is for. +- `im +chat-messages-list --chat-id --order asc` is the listing that answers "has the host + replied", because it returns the conversation in the order it was written. + `im +messages-search` answers out of an index that lags a send by seconds, so it can never + establish that nothing came back. +- The conversation the host answers in is the one your own last message landed in (`oc_…`), so + keep that id beside the message id: §3 reads a reply by position, and a position means nothing + in a conversation you are not looking at. - Skip what you sent yourself: the sender's `open_id` against the host's own, named in the task instructions. - Take the messages that arrived after the last one you handled. Remember that boundary @@ -65,14 +76,25 @@ stranger's text can never widen it. ### 3. Send -`lark-cli im +messages-send --user-id ` (or `--chat-id `) carries the text. - -- **To the host's own account**: send it, this round, without asking. This is also the - channel you use to ask. +`lark-cli im +messages-send --as bot --user-id ` (or `--chat-id `) carries the +text. Both halves are load-bearing: an `im` command that names no identity is resolved by the +CLI's `auto` default, which prefers the user account once that account is authorised — and the +message then lands in a conversation named after the host, i.e. the host messaging himself, +which Feishu notifies nobody about. The call exits successfully either way, so the conversation +the returned message id came back in is the only thing that tells the two apart. + +- **To the host's own account**: send it, this round, without asking. +- **A question addressed to the host** goes on that same channel and needs no draft: whether a + message wants an answer at all, what that answer should say, and whether the host would rather + do the thing himself are questions, and a question is sent rather than filed. Send it this + round, carry on without waiting for the answer, and read the answer next round with §1's + recipe. - **To anyone else**: first write the recipient and the text, word for word, into this round's report, then ask the host — a Feishu message to the host's own account naming the recipient and quoting that exact text — and carry on with the rest of the round without waiting. Asking is not blocking; sending is. + Quoting word for word is about what a third party is going to receive; a question to the host + himself has no such text to quote. - When a later round reads the host's answer to that request, send what you quoted, once, unchanged. A no, or a request for different text, voids it; a changed text is a new request. @@ -86,6 +108,11 @@ stranger's text can never widen it. Close the round with: how many arrived per verdict, what you did, which requests are waiting for the host — with their full text and recipient — and what is blocked and why. +The report is a record, not a channel. A request it reports as waiting has already been asked, +so the report names the Feishu message id and the `oc_…` conversation it was asked in; a pending +request without them is one the host has not been told about, and the round counts it as unasked +rather than as progress. + ### Forbidden - 🛑 Never message anyone but the host's own account without the host's approval for that exact message diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 4a36a98d..c6a5d589 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -461,6 +461,28 @@ def test_load_and_start_ios_app_task(tmp_path): ) +def _template_section(text: str, heading: str) -> str: + """One `### ` section of a task template: its heading through to the next one. + + A whole-file search cannot tell a rule from a mention. §3 named `lark-cli im +messages-send` + for months while naming no identity for it to run as, and a phrase asserted anywhere in the + file would have stayed green if §3 lost its own statement of the rule — which is the same + shape as the defect that sentence carried. So a rule is read from the section that owns it. + + The bound has to stop: a region that runs to the end of the file is no bound, and it would + let a phrase in `Forbidden` satisfy a rule the section itself dropped. A missing next + heading is therefore a failure rather than an unbounded region. + """ + start = text.index(heading) + len(heading) + rest = text[start:] + end = rest.find("\n### ") + assert end != -1, ( + f"{heading!r} is the last `###` section of this template, so where it ends cannot be " + f"read — and the pins below are only a rule about this section if the region stops" + ) + return rest[:end] + + def test_load_and_start_feishu_task(tmp_path): """`feishu` tasks start a TaskHandler with the `feishu` template. @@ -531,6 +553,43 @@ def test_load_and_start_feishu_task(tmp_path): ): assert phrase in flat, f"the feishu template must state that {why}; {phrase!r} is missing" + # The rules below are about **which identity an `im` call runs as** and **where a question + # goes**, and neither is visible to a phrase-in-file pin (see `_template_section`). The + # identity is the sharper half: it is the whole difference between a message the host is + # notified about and the same text arriving, unnoticed, in a conversation named after the + # host himself — and both calls exit successfully, so a round cannot learn the difference + # from an exit code. + for section, phrase, why in ( + ("### 1. Read", "`--as user`", + "reading a direct conversation needs the user identity, and the identity that reads is " + "not the identity that sends"), + ("### 1. Read", "`im +messages-search`", + "the search index lags a send by seconds, so the section has to name the listing that " + "answers whether the host replied and say why the other cannot"), + ("### 1. Read", "the one your own last message landed in", + "§3 reads a reply by position inside a conversation, so the round has to know which " + "conversation that is before the positional rule means anything"), + ("### 3. Send", "+messages-send --as bot", + "a send that names no identity is resolved by the CLI's own `auto` default, which " + "prefers the user account once it is authorised — so the message reaches the host's " + "own conversation with no notification, and the call still exits successfully"), + ("### 3. Send", "--user-id ", + "the recipient is part of the command: without it the conversation is chosen by that " + "same default rather than by the round"), + ("### 3. Send", "**A question addressed to the host**", + "a question with no draft yet — whether a message wants an answer at all, what it " + "should say — has no procedure under the third-party rule, so the round files it in " + "the report instead, which is where the host does not read"), + ("### 4. Report", "The report is a record, not a channel", + "a reported request reads as a discharged duty; the round has to name the message id " + "and the conversation it asked in, or count the request as unasked"), + ): + body = _template_section(text, section) + assert phrase in body, ( + f"{section.lstrip('# ')} of the feishu template must state that {why}; " + f"{phrase!r} is missing from that section" + ) + def test_load_and_start_promote_task(tmp_path): """Promote tasks start a TaskHandler with the promote template."""