diff --git a/docs/compositions/reach/operator.md b/docs/compositions/reach/operator.md index 3b886fee..534a77dc 100644 --- a/docs/compositions/reach/operator.md +++ b/docs/compositions/reach/operator.md @@ -285,6 +285,12 @@ procedure below first. ## Start Reachy and try its commands +The edge refers to itself by name in fixed operational notices such as refusals, timeouts and +permission errors. Set `agent_display_name` in the private policy to match the bot's Discord name; +it defaults to `Nadir`. It is presentation only: it is excluded from the compiled Reach policy and +its digest, and grants no identity or authority. Notices about the model say "the model" rather +than naming a particular one, because the model binding is configured separately. + On the first launch, explicitly register commands for **this policy's guild** and allow your admitted user to run the read-only inspection command: diff --git a/examples/reach/policy.toml b/examples/reach/policy.toml index 5a0ad2d1..a681d13a 100644 --- a/examples/reach/policy.toml +++ b/examples/reach/policy.toml @@ -10,6 +10,8 @@ event_ttl_seconds = 300 max_question_chars = 2000 context_message_limit = 0 context_max_age_seconds = 600 +# Name the bot uses for itself in fixed notices (errors, refusals). Presentation only. +agent_display_name = "Nadir" # First matching user-or-role rule wins, including denial of a missing action. [[rules]] diff --git a/scripts/reach_operator.py b/scripts/reach_operator.py index d10097bf..e6d2b719 100644 --- a/scripts/reach_operator.py +++ b/scripts/reach_operator.py @@ -366,6 +366,11 @@ def load_images(path: Path) -> dict[str, str]: return {"core": record["core"], "edge": record["edge"]} +def _prefixed_image_id(observed: str) -> str: + """Podman prints a bare hex image id where Docker prints the ``sha256:`` form.""" + return observed if observed.startswith("sha256:") else f"sha256:{observed}" + + def _retain_image(engine: str, role: Literal["core", "edge"], identity: str, *, root: Path) -> None: """Keep the selected image addressable when a later build replaces its build tag.""" if IMAGE_ID.fullmatch(identity) is None: @@ -381,6 +386,7 @@ def _retain_image(engine: str, role: Literal["core", "edge"], identity: str, *, .decode("ascii") .strip() ) + observed = _prefixed_image_id(observed) if observed != identity: _fail("operator_image_retention") @@ -404,6 +410,7 @@ def build_images(engine: str, output: Path, *, root: Path = ROOT) -> None: .decode() .strip() ) + core = _prefixed_image_id(core) if IMAGE_ID.fullmatch(core) is None: _fail("operator_image_identity") _retain_image(engine, "core", core, root=root) @@ -415,6 +422,10 @@ def build_images(engine: str, output: Path, *, root: Path = ROOT) -> None: _fail("operator_build_input") target.parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(source, target) + # COPY keeps these modes, and the edge runs as the operator's UID rather than the image's + # owner, so a private caller umask would leave its source unreadable inside the image. + for path in (context, *context.rglob("*")): + path.chmod(0o755 if path.is_dir() else 0o644) (context / ".dockerignore").write_text("") (context / ".containerignore.build").write_text("") iidfile = context / "image.id" @@ -498,7 +509,8 @@ def container_arguments(engine: str, image: str, cidfile: Path, *, offline: bool "--cpus", "1", "--tmpfs", - "/tmp:rw,nosuid,nodev,size=256m", # noqa: S108 - isolated, size-limited container tmpfs + # Podman would otherwise copy the image's own /tmp into this bounded tmpfs and overflow it. + "/tmp:rw,nosuid,nodev,size=256m" + (",notmpcopyup" if engine == "podman" else ""), # noqa: S108 "--user", f"{os.getuid()}:{os.getgid()}", ] diff --git a/src/lychd/interface/discord/delivery.py b/src/lychd/interface/discord/delivery.py index 216cac37..01ec69f8 100644 --- a/src/lychd/interface/discord/delivery.py +++ b/src/lychd/interface/discord/delivery.py @@ -19,75 +19,85 @@ from lychd.interface.discord.messages import split_message from lychd.interface.discord.ports import TurnEffects from lychd.interface.discord.rest import DiscordRestError -from lychd.interface.discord.schemas import DiscordPolicy +from lychd.interface.discord.schemas import DEFAULT_AGENT_DISPLAY_NAME, DiscordPolicy from lychd.interface.observation import observation_context +_AGENT = "{agent}" + + +def _named(text: str, agent_name: str) -> str: + """Insert the configured display name into one fixed notice; it is never a format string.""" + return text.replace(_AGENT, agent_name) + + +# Templates: ``{agent}`` is replaced by the policy's display name when a notice is rendered. WORKER_FAILURES = { "budget": ( "My configured context budget was too small for this request. I read the available chat context, " "but couldn't fit the instructions and selected sources needed to answer. Please narrow the question." ), - "model_unavailable": "The Qwen model service is unavailable. Please try again later; a maintainer should check it.", - "model_timeout": "The Qwen model timed out while preparing the answer. Please try again with a shorter request.", - "model_refused": "The Qwen model refused this request. A maintainer can check its diagnostics.", + "model_unavailable": "The model service is unavailable. Please try again later; a maintainer should check it.", + "model_timeout": "The model timed out while preparing the answer. Please try again with a shorter request.", + "model_refused": "The model refused this request. A maintainer can check its diagnostics.", "model_protocol": ( - "The Qwen model returned a response Nadir couldn't safely use. A maintainer should check the model adapter." + "The model returned a response {agent} couldn't safely use. A maintainer should check the model adapter." ), - "model_failed": ("The Qwen model failed while preparing the answer. A maintainer should check its diagnostics."), - "run_timeout": "Nadir timed out while preparing the answer. Please try again with a shorter request.", + "model_failed": ("The model failed while preparing the answer. A maintainer should check its diagnostics."), + "run_timeout": "{agent} timed out while preparing the answer. Please try again with a shorter request.", "run_missing": ( - "Nadir couldn't find the answer run it started (run_missing). A maintainer should check diagnostics." + "{agent} couldn't find the answer run it started (run_missing). A maintainer should check diagnostics." ), - "run_incomplete": "Nadir's answer run didn't complete (run_incomplete). A maintainer should check diagnostics.", + "run_incomplete": "{agent}'s answer run didn't complete (run_incomplete). A maintainer should check diagnostics.", "result_binding": ( - "Nadir received a mismatched answer result (result_binding). A maintainer should check diagnostics." + "{agent} received a mismatched answer result (result_binding). A maintainer should check diagnostics." ), "run_failed": ( "An internal processing step failed (run_failed). Please try again later; a maintainer can check diagnostics." ), - "worker_unavailable": "Nadir's answer worker is unavailable or busy. Please try again shortly.", + "worker_unavailable": "{agent}'s answer worker is unavailable or busy. Please try again shortly.", "permission_denied": ( - "Discord denied access to earlier channel messages (403). Ask a maintainer to check Nadir's " + "Discord denied access to earlier channel messages (403). Ask a maintainer to check {agent}'s " "View Channel and Read Message History permissions." ), "conversation_not_allowed": ( - "Nadir isn't enabled in this channel. Ask a maintainer to add it to the allowed channels." + "{agent} isn't enabled in this channel. Ask a maintainer to add it to the allowed channels." ), "subject_not_allowed": ( - "You're not included in Nadir's allowed user or role list. Ask a maintainer to check your access." + "You're not included in {agent}'s allowed user or role list. Ask a maintainer to check your access." ), "action_not_allowed": "This type of request isn't enabled for your account or this channel.", - "action_not_supported": "Nadir doesn't support this request type.", - "trigger_not_allowed": "Nadir isn't configured to answer this kind of message here.", - "missing_agent_mention": "Please mention Nadir directly in this message.", + "action_not_supported": "{agent} doesn't support this request type.", + "trigger_not_allowed": "{agent} isn't configured to answer this kind of message here.", + "missing_agent_mention": "Please mention {agent} directly in this message.", "wrong_platform": "This request came from an unsupported chat platform.", - "wrong_account": "This request came from an account Nadir isn't configured to serve.", - "wrong_space": "Nadir isn't configured for this server or workspace.", - "self_event": "Nadir can't answer a message sent by the bot itself.", - "automated_subject": "Nadir can't answer messages from another bot account.", - "event_expired": "This request expired before Nadir could process it. Please send it again.", + "wrong_account": "This request came from an account {agent} isn't configured to serve.", + "wrong_space": "{agent} isn't configured for this server or workspace.", + "self_event": "{agent} can't answer a message sent by the bot itself.", + "automated_subject": "{agent} can't answer messages from another bot account.", + "event_expired": "This request expired before {agent} could process it. Please send it again.", "event_from_future": "This request has an invalid future timestamp. Please send it again.", - "question_empty": "Please include a question for Nadir.", - "question_too_large": "This question is over Nadir's configured input limit. Please shorten it.", - "question_invalid": "This question contains text Nadir can't safely process. Please edit and resend it.", - "context_disabled": "Nadir's chat history access isn't enabled for this request.", - "relationship_invalid": "Nadir couldn't verify that this reply belongs to the recent conversation.", + "question_empty": "Please include a question for {agent}.", + "question_too_large": "This question is over {agent}'s configured input limit. Please shorten it.", + "question_invalid": "This question contains text {agent} can't safely process. Please edit and resend it.", + "context_disabled": "{agent}'s chat history access isn't enabled for this request.", + "relationship_invalid": "{agent} couldn't verify that this reply belongs to the recent conversation.", } -def worker_problem(reason: str | None, *, refused: bool = False) -> str: +def worker_problem(reason: str | None, *, refused: bool = False, agent_name: str = DEFAULT_AGENT_DISPLAY_NAME) -> str: """Render only closed worker reasons; never display exception or model text.""" if reason in WORKER_FAILURES: - return WORKER_FAILURES[reason] + return _named(WORKER_FAILURES[reason], agent_name) if refused: - return ( + return _named( "I can't perform this request (request_refused). " - "Ask a maintainer to check Nadir's channel and access rules." + "Ask a maintainer to check {agent}'s channel and access rules.", + agent_name, ) - return WORKER_FAILURES["run_failed"] + return _named(WORKER_FAILURES["run_failed"], agent_name) -def failure_text(error: Exception) -> str: +def failure_text(error: Exception, *, agent_name: str = DEFAULT_AGENT_DISPLAY_NAME) -> str: """Translate known edge failures without exposing provider bodies or exception text.""" if isinstance(error, discord.Forbidden): code = "http_403" @@ -110,9 +120,9 @@ def failure_text(error: Exception) -> str: code = error_code(error) messages = { - "http_401": "Discord rejected Nadir's bot authentication (401). A maintainer should check the bot token.", + "http_401": "Discord rejected {agent}'s bot authentication (401). A maintainer should check the bot token.", "http_403": ( - "Discord denied one of Nadir's channel operations (403). Ask a maintainer to check View Channel, " + "Discord denied one of {agent}'s channel operations (403). Ask a maintainer to check View Channel, " "Read Message History, and Send Messages permissions." ), "http_404": ( @@ -120,38 +130,38 @@ def failure_text(error: Exception) -> str: "Ask a maintainer to check the channel binding." ), "network_error": ( - "Nadir couldn't reach Discord. Please try again later; a maintainer should check connectivity." + "{agent} couldn't reach Discord. Please try again later; a maintainer should check connectivity." ), - "timeout": "Nadir timed out while preparing the answer. Please try again with a shorter request.", + "timeout": "{agent} timed out while preparing the answer. Please try again with a shorter request.", "budget": WORKER_FAILURES["budget"], "worker_unavailable": WORKER_FAILURES["worker_unavailable"], } - return messages.get( - code, + fallback = ( f"I couldn't prepare an answer. Please try again later (diagnostic code: {code}). " - "A maintainer can check Nadir's logs.", + "A maintainer can check {agent}'s logs." ) + return _named(messages.get(code, fallback), agent_name) -def result_text(result: ReachWorkerResult) -> str: +def result_text(result: ReachWorkerResult, *, agent_name: str = DEFAULT_AGENT_DISPLAY_NAME) -> str: """Render worker answers and precise safe reasons without attempting an empty message.""" if result.text.strip(): return result.text if result.status == "refused": - return worker_problem(result.reason, refused=True) + return worker_problem(result.reason, refused=True, agent_name=agent_name) if result.status == "error": - return worker_problem(result.reason) + return worker_problem(result.reason, agent_name=agent_name) return "I couldn't prepare an answer. Please try again later (diagnostic code: empty_result)." -def context_text(outcome: ContextTurnOutcome) -> str: +def context_text(outcome: ContextTurnOutcome, *, agent_name: str = DEFAULT_AGENT_DISPLAY_NAME) -> str: """Render a driver stop here, preserving unrelated native clarifications exactly.""" if outcome.stop is None: - return result_text(outcome.result) + return result_text(outcome.result, agent_name=agent_name) notices = { "permission_denied": ( "I couldn't read earlier messages in this channel because Discord denied history access (403). " - "Ask a maintainer to check Nadir's View Channel and Read Message History permissions, then try again." + "Ask a maintainer to check {agent}'s View Channel and Read Message History permissions, then try again." ), "unavailable": "Earlier messages are unavailable right now. Please quote the relevant message or clarify your question.", "disabled": "Chat history isn't enabled here. Please quote the relevant message or clarify your question.", @@ -160,7 +170,7 @@ def context_text(outcome: ContextTurnOutcome) -> str: "round_limit": "I've reached the history limit. Please quote the relevant part of the conversation.", "no_progress": "I couldn't load more relevant messages. Please quote the missing material or clarify your question.", } - return notices[outcome.stop] + return _named(notices[outcome.stop], agent_name) class DiscordTurnDelivery: diff --git a/src/lychd/interface/discord/live.py b/src/lychd/interface/discord/live.py index f62fdf38..937de8c6 100644 --- a/src/lychd/interface/discord/live.py +++ b/src/lychd/interface/discord/live.py @@ -41,7 +41,6 @@ from lychd.compositions.reach.schemas import ReachEvent from lychd.interface.discord.admin import ChannelSnapshot, GuildSnapshot, snapshot_guild from lychd.interface.discord.delivery import ( - WORKER_FAILURES, DiscordTurnDelivery, context_text, failure_text, @@ -213,8 +212,9 @@ def event_from_message( # noqa: PLR0911 - cheap DM, mention and continuation re or str(message.author.id) not in policy.allowed_dm_user_ids or message.author.bot or message.webhook_id is not None - or channel.recipient is None - or channel.recipient.id != message.author.id + # Without a member cache the SDK builds an uncached DM channel with no recipient. + # A non-bot author of a one-to-one DMChannel is then that recipient by construction. + or (channel.recipient is not None and channel.recipient.id != message.author.id) ): return None return ReachEvent( @@ -593,7 +593,12 @@ async def _request(self, interaction: discord.Interaction, question: str, action if not self.service.eligible(event): decision = prepare_turn(self.service.shared_policy, event, now=datetime.now(UTC)) await interaction.response.send_message( - worker_problem(decision.reason if not decision.allowed else None, refused=True), ephemeral=True + worker_problem( + decision.reason if not decision.allowed else None, + refused=True, + agent_name=self.service.policy.agent_display_name, + ), + ephemeral=True, ) return session = self.service.session(event) @@ -606,11 +611,14 @@ async def _request(self, interaction: discord.Interaction, question: str, action try: outcome = await self.service.run_event(session, interaction.channel, progress=delivery.plan) if outcome is not None and session.visible(): - await delivery.final(context_text(outcome), result=outcome.result) + await delivery.final( + context_text(outcome, agent_name=self.service.policy.agent_display_name), + result=outcome.result, + ) except (ValueError, OSError, TimeoutError, discord.HTTPException, DiscordRestError) as error: diagnostic("request_failed") if session.can_report_failure(): - await delivery.final(failure_text(error)) + await delivery.final(failure_text(error, agent_name=self.service.policy.agent_display_name)) except TimeoutError: diagnostic("request_expired") finally: @@ -689,7 +697,12 @@ async def on_message(self, message: discord.Message) -> None: if not session.visible(): return result = outcome.result if outcome is not None else None - text = context_text(outcome) if outcome is not None else WORKER_FAILURES["run_failed"] + agent_name = self.service.policy.agent_display_name + text = ( + context_text(outcome, agent_name=agent_name) + if outcome is not None + else worker_problem(None, agent_name=agent_name) + ) delivered = await delivery.final(text, result=result) if delivered and result is not None: await delivery.expressions(result) @@ -699,7 +712,7 @@ async def on_message(self, message: discord.Message) -> None: if session.can_report_failure(): if event.trigger == "message": await self.service.replies.bind_conversation(event) - await delivery.final(failure_text(error)) + await delivery.final(failure_text(error, agent_name=self.service.policy.agent_display_name)) except TimeoutError: diagnostic("request_expired") finally: diff --git a/src/lychd/interface/discord/schemas.py b/src/lychd/interface/discord/schemas.py index 40aaae99..1d322830 100644 --- a/src/lychd/interface/discord/schemas.py +++ b/src/lychd/interface/discord/schemas.py @@ -23,6 +23,9 @@ class _Value(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True, strict=True, hide_input_in_errors=True) +DEFAULT_AGENT_DISPLAY_NAME = "Nadir" + + class DiscordRule(_Value): """Friendly Discord selectors; rule evaluation belongs to Reach.""" @@ -56,6 +59,16 @@ class DiscordPolicy(_Value): max_question_chars: int = Field(default=2000, ge=1, le=2000) context_message_limit: int = Field(default=0, ge=0, le=50) context_max_age_seconds: int = Field(default=600, ge=1, le=172800) + agent_display_name: str = Field(default=DEFAULT_AGENT_DISPLAY_NAME, min_length=1, max_length=80) + """Name the edge uses for itself in fixed operational notices; presentation only, never authority.""" + + @field_validator("agent_display_name") + @classmethod + def single_line_display_name(cls, value: str) -> str: + if not value.isprintable() or value != value.strip(): + msg = "Discord agent display name must be printable single-line text without surrounding whitespace." + raise ValueError(msg) + return value @field_validator("schema_version", mode="before") @classmethod diff --git a/tests/unit/cli/test_reach_operator.py b/tests/unit/cli/test_reach_operator.py index a5cb932d..92ea1740 100644 --- a/tests/unit/cli/test_reach_operator.py +++ b/tests/unit/cli/test_reach_operator.py @@ -4,6 +4,7 @@ import asyncio import json +import os import subprocess from datetime import UTC, datetime, timedelta from pathlib import Path @@ -129,6 +130,31 @@ def test_container_boundary_has_no_host_socket_ports_or_environment(tmp_path: Pa ) +def test_podman_tmpfs_does_not_copy_the_image_tmp_into_its_bounded_mount(tmp_path: Path) -> None: + podman = operator.container_arguments("podman", IMAGE, tmp_path / "cid", offline=True) + docker = operator.container_arguments("docker", IMAGE, tmp_path / "cid", offline=True) + assert podman[podman.index("--tmpfs") + 1] == "/tmp:rw,nosuid,nodev,size=256m,notmpcopyup" # noqa: S108 + assert docker[docker.index("--tmpfs") + 1] == "/tmp:rw,nosuid,nodev,size=256m" # noqa: S108 + + +@pytest.mark.parametrize("prefixed", [False, True]) +def test_image_retention_accepts_bare_and_prefixed_engine_ids( + monkeypatch: pytest.MonkeyPatch, *, prefixed: bool +) -> None: + # Podman reports ``{{.Id}}`` as bare hex; Docker reports the ``sha256:`` form. + observed = IMAGE if prefixed else IMAGE.removeprefix("sha256:") + calls: list[list[str]] = [] + + def command(arguments: Any, **_kwargs: Any) -> bytes: + calls.append(list(arguments)) + return observed.encode() + b"\n" if list(arguments)[1:3] == ["image", "inspect"] else b"" + + monkeypatch.setattr(operator, "command", command) + operator._retain_image("podman", "core", IMAGE, root=Path.cwd()) # pyright: ignore[reportPrivateUsage] + assert calls[0][1:3] == ["image", "tag"] + assert calls[0][3] == IMAGE + + def test_host_environment_excludes_provider_and_discord_secrets(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("DISCORD_BOT_TOKEN", SECRET) monkeypatch.setenv("OPENAI_API_KEY", SECRET) @@ -584,3 +610,34 @@ def command(arguments: Any, **_kwargs: Any) -> bytes: assert shared == replacement assert operator.load_images(output) == {"core": core, "edge": edge} assert tags == {core_tag: core, edge_tag: edge} + + +def test_edge_build_context_is_readable_regardless_of_the_caller_umask( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + root = tmp_path / "source" + (root / "clients/discord").mkdir(parents=True) + (root / "clients/discord/Containerfile").write_text("FROM fixture\n") + monkeypatch.setattr(operator, "EDGE_INPUTS", ("clients/discord/Containerfile",)) + observed: dict[str, int] = {} + + def command(arguments: Any, **_kwargs: Any) -> bytes: + args = list(arguments) + if args[1:3] == ["image", "inspect"]: + return IMAGE.removeprefix("sha256:").encode() + b"\n" + if len(args) > 1 and args[1] == "build": + context = Path(args[-1]) + observed["file"] = (context / "clients/discord/Containerfile").stat().st_mode & 0o777 + observed["dir"] = (context / "clients/discord").stat().st_mode & 0o777 + message = "stop" + raise ValueError(message) + return b"" + + monkeypatch.setattr(operator, "command", command) + previous = os.umask(0o077) + try: + with pytest.raises(ValueError, match="stop"): + operator.build_images("podman", tmp_path / "images.json", root=root) + finally: + os.umask(previous) + assert observed == {"file": 0o644, "dir": 0o755} diff --git a/tests/unit/interface/test_discord_live.py b/tests/unit/interface/test_discord_live.py index 1d9d195e..1a16a4ae 100644 --- a/tests/unit/interface/test_discord_live.py +++ b/tests/unit/interface/test_discord_live.py @@ -439,3 +439,21 @@ async def test_busy_worker_refuses_before_history() -> None: with pytest.raises(ValueError, match="worker_busy_or_unavailable"): await configured.run_event(configured.session(observed), channel) channel.history.assert_not_called() + + +def test_fixed_notices_use_the_configured_display_name_and_name_no_model() -> None: + from lychd.interface.discord.delivery import WORKER_FAILURES, failure_text, worker_problem + + for reason in WORKER_FAILURES: + default, named = worker_problem(reason), worker_problem(reason, agent_name="Anvesha") + assert "{agent}" not in default + assert "{agent}" not in named + assert "Nadir" not in named + assert "Qwen" not in default + assert named == default.replace("Nadir", "Anvesha") + assert "Anvesha's channel" in worker_problem("foreign_failure", refused=True, agent_name="Anvesha") + assert "Nadir's channel" in worker_problem("foreign_failure", refused=True) + assert failure_text(TimeoutError(), agent_name="Anvesha").startswith("Anvesha timed out") + assert "Anvesha's logs" in failure_text(RuntimeError("private detail"), agent_name="Anvesha") + # The name is inserted literally and is never interpreted as a template itself. + assert "{agent} timed out" in failure_text(TimeoutError(), agent_name="{agent}") diff --git a/tests/unit/interface/test_discord_policy.py b/tests/unit/interface/test_discord_policy.py index 5ac27c47..8b92101a 100644 --- a/tests/unit/interface/test_discord_policy.py +++ b/tests/unit/interface/test_discord_policy.py @@ -60,3 +60,18 @@ def test_discord_only_adapter_refuses_non_snowflake_ids(identity: object) -> Non def test_bad_provider_configuration_never_expands_shared_policy(changes: dict[str, Any]) -> None: with pytest.raises(ValidationError): policy(**changes) + + +def test_agent_display_name_defaults_and_never_enters_the_shared_policy() -> None: + default, named = policy(), policy(agent_display_name="Anvesha") + assert default.agent_display_name == "Nadir" + assert named.agent_display_name == "Anvesha" + # Presentation only: the compiled Reach policy and its worker binding digest are unchanged. + assert named.to_reach_policy() == default.to_reach_policy() + assert named.digest == default.digest + + +@pytest.mark.parametrize("name", ["", " padded ", "two\nlines", "bell\x07", "x" * 81]) +def test_agent_display_name_refuses_unprintable_padded_or_oversized_text(name: str) -> None: + with pytest.raises(ValidationError): + policy(agent_display_name=name) diff --git a/tests/unit/interface/test_nadir_dialogue.py b/tests/unit/interface/test_nadir_dialogue.py index 4b442d81..8f1b0a2f 100644 --- a/tests/unit/interface/test_nadir_dialogue.py +++ b/tests/unit/interface/test_nadir_dialogue.py @@ -84,6 +84,17 @@ async def test_dm_denial_precedes_acknowledgement_and_native_request(denial: str native.request.assert_not_awaited() +def test_dm_from_an_uncached_channel_without_a_recipient_is_admitted() -> None: + # Without a member cache the SDK builds the DM channel from the message alone, leaving + # ``recipient`` unset. The allowlisted non-bot author is still the only possible recipient; + # the reply path separately verifies the channel's recipients before sending. + configured, observed = policy(), direct_message() + observed.channel.recipient = None + event = event_from_message(observed, configured, conversation=True) + assert event is not None + assert (event.subject_id, event.conversation_id, event.action) == ("105", "204", "converse") + + @pytest.mark.asyncio @pytest.mark.parametrize("fail_second_part", [False, True]) async def test_dm_starts_with_native_reply_and_multipart_delivery_stops_on_unknown(