From f2b59d27f3f78a2ed6e4bca7425ba2b2e8ac3747 Mon Sep 17 00:00:00 2001 From: Mike Date: Fri, 18 Sep 2026 21:17:25 -0500 Subject: [PATCH 1/3] fix: parse recipient and content from request text on Node turns CMS_match_message_phrase returns only kind when an sms or email vocab word matches; the extractors run only when no vocab word is present. The Node branch assumed extraction had already happened, so nearly every matched Node request arrived with an empty payload and spoke ErrorDialog. Fall back to the extractors on the request text, as the mobile path does. DraftEmailIntent, a direct Adapt match with no skill_data at all, takes the same fallback via message.data.utterance. Also fix _extract_content_sms returning two values on its early exit when the function's contract is three; every caller unpacks three. --- __init__.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/__init__.py b/__init__.py index df4c76b..182a07b 100644 --- a/__init__.py +++ b/__init__.py @@ -361,13 +361,17 @@ def handle_send_sms(self, message): def _handle_send_sms_node(self, message): """ Single-shot Node path for `text X that Y`. Does not use the mobile - draft state machine: extraction already ran in - `CMS_match_message_phrase`, so this dispatches straight to the - neon-utils helper. + draft state machine. `CMS_match_message_phrase` only extracts a + recipient and body when no `sms`/`email` vocab word matched, so a + vocab match arrives with `kind` alone and the request text is parsed + here, as the mobile path does. """ skill_data = message.data.get("skill_data") or {} recipient = skill_data.get("recipient") body = skill_data.get("message") + if not recipient or not body: + recipient, body, _ = self._extract_content_sms( + self._node_request_text(message)) if not recipient or not body: LOG.warning(f"Node SMS request missing recipient or body: " f"{skill_data}") @@ -385,6 +389,9 @@ def _handle_send_email_node(self, message): recipient = skill_data.get("recipient") subject = skill_data.get("subject") body = skill_data.get("body") + if not recipient or not (subject or body): + recipient, subject = self._extract_content_email( + self._node_request_text(message)) if not recipient or not (subject or body): LOG.warning(f"Node email request missing recipient or content: " f"{skill_data}") @@ -398,6 +405,15 @@ def _handle_send_email_node(self, message): invoke_native_action(self, message, NodeNativeAction.LAUNCH_EMAIL_APP, params=params) + @staticmethod + def _node_request_text(message) -> str: + """ + Raw request text for a Node turn: `request` on the Common Messaging + callback, `utterance` on a direct Adapt match (DraftEmailIntent). + """ + return message.data.get("request") or \ + message.data.get("utterance") or "" + def handle_place_call(self, message): if message.context.get("mobile"): user = get_message_user(message) @@ -592,7 +608,7 @@ def _extract_content_sms(utt): if "to" in utt.split(): remainder = utt.split(" to ", 1)[1] else: - return None, None + return None, None, None LOG.debug(remainder) recipient = remainder.split()[0] LOG.debug(recipient) From 66a979b582d419d79ce8e7771be686ab226abb15 Mon Sep 17 00:00:00 2001 From: Mike Date: Fri, 18 Sep 2026 21:17:33 -0500 Subject: [PATCH 2/3] test: cover vocab-only and Adapt-only Node requests The existing Node tests hand-build skill_data with recipient and content already filled in, which is why the empty-payload path went unnoticed. Add cases where skill_data carries only kind, and where DraftEmailIntent delivers no skill_data at all. --- test/test_skill.py | 70 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/test/test_skill.py b/test/test_skill.py index 2c70873..4568d3d 100644 --- a/test/test_skill.py +++ b/test/test_skill.py @@ -35,8 +35,8 @@ def _node_message(msg_type: str, skill_data: dict, action_key: str, action_supported: bool = True, - session_id: str = "node-test-1"): - return Message(msg_type, {"skill_data": skill_data}, { + session_id: str = "node-test-1", **data): + return Message(msg_type, {"skill_data": skill_data, **data}, { "node": { "node_id": "node-test-1", "node_name": "Test Node", @@ -131,6 +131,34 @@ def test_send_sms_node_missing_content_speaks_error(self): self.skill.speak_dialog.assert_called_once_with( "ErrorDialog", message=message) + def test_send_sms_node_vocab_match_extracts_from_request(self): + # A `sms` vocab hit in CMS_match_message_phrase returns only `kind`; + # recipient and body must then come from the request text. + message = _node_message( + "SendSMSIntent", {"kind": "sms"}, "launch_sms_app", + request="send a text to my wife that says I'm running late") + emitted = [] + self.skill.bus.once("node.invoke_native", + lambda m: emitted.append(m)) + _arm_node_reply(self.skill.bus, _response("launch_sms_app")) + + self.skill.handle_send_sms(message) + + self.assertEqual(len(emitted), 1) + self.assertEqual(emitted[0].data["params"], + {"to": "my wife", "body": "I'm running late"}) + self.skill.speak_dialog.assert_not_called() + + def test_send_sms_node_vocab_match_without_recipient_speaks_error(self): + message = _node_message( + "SendSMSIntent", {"kind": "sms"}, "launch_sms_app", + request="send a text message") + + self.skill.handle_send_sms(message) + + self.skill.speak_dialog.assert_called_once_with( + "ErrorDialog", message=message) + def test_send_email_node_dispatches_with_params(self): message = _node_message( "DraftEmailIntent", @@ -199,6 +227,44 @@ def test_send_email_node_unsupported_capability(self): {"action": "launch_email_app", "description": "the email app"}, message=message) + def test_send_email_node_vocab_match_extracts_from_request(self): + message = _node_message( + "DraftEmailIntent", {"kind": "email"}, "launch_email_app", + request="send an email to sarah at example dot com " + "subject the project") + emitted = [] + self.skill.bus.once("node.invoke_native", + lambda m: emitted.append(m)) + _arm_node_reply(self.skill.bus, _response("launch_email_app")) + + self.skill.handle_send_email(message) + + self.assertEqual(len(emitted), 1) + self.assertEqual(emitted[0].data["params"], + {"to": "sarah@example.com", + "subject": "the project"}) + self.skill.speak_dialog.assert_not_called() + + def test_send_email_node_draft_intent_extracts_from_utterance(self): + # DraftEmailIntent is a direct Adapt match: no `skill_data` at all, + # only `utterance`. + message = _node_message( + "DraftEmailIntent", None, "launch_email_app", + utterance="draft an email to sarah at example dot com " + "subject the project") + emitted = [] + self.skill.bus.once("node.invoke_native", + lambda m: emitted.append(m)) + _arm_node_reply(self.skill.bus, _response("launch_email_app")) + + self.skill.handle_send_email(message) + + self.assertEqual(len(emitted), 1) + self.assertEqual(emitted[0].data["params"], + {"to": "sarah@example.com", + "subject": "the project"}) + self.skill.speak_dialog.assert_not_called() + def test_send_email_node_missing_recipient_speaks_error(self): message = _node_message("DraftEmailIntent", {"subject": "The project"}, From 7a0ac3c47f4eb0312860a1919a4204fe63e8ac77 Mon Sep 17 00:00:00 2001 From: Mike Date: Fri, 18 Sep 2026 21:25:29 -0500 Subject: [PATCH 3/3] fix: parse a spoken body for Node email requests Seen on a Hub: "draft email to emily that says hi" matched DraftEmailIntent, but _extract_content_email only knows "subject", so it took "emily that says hi" as the recipient and found no content. _extract_content_sms already parses "that says" and "saying"; compose the two for Node email requests and share the spoken-address normalization so "sarah at example dot com" still resolves. --- __init__.py | 57 +++++++++++++++++++++++++++++++++------------- test/test_skill.py | 33 +++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 16 deletions(-) diff --git a/__init__.py b/__init__.py index 182a07b..b57201d 100644 --- a/__init__.py +++ b/__init__.py @@ -390,7 +390,7 @@ def _handle_send_email_node(self, message): subject = skill_data.get("subject") body = skill_data.get("body") if not recipient or not (subject or body): - recipient, subject = self._extract_content_email( + recipient, subject, body = self._extract_node_email_content( self._node_request_text(message)) if not recipient or not (subject or body): LOG.warning(f"Node email request missing recipient or content: " @@ -405,6 +405,22 @@ def _handle_send_email_node(self, message): invoke_native_action(self, message, NodeNativeAction.LAUNCH_EMAIL_APP, params=params) + @classmethod + def _extract_node_email_content(cls, utt): + """ + Recipient, subject, and body for a single-shot email request. + `_extract_content_email` only knows `subject`; a spoken body + (`that says`, `saying`) is parsed by `_extract_content_sms`. + @return: (str?, str?, str?) recipient, subject, body + """ + recipient, subject = cls._extract_content_email(utt) + if subject: + return recipient, subject, None + sms_recipient, body, conf = cls._extract_content_sms(utt) + if body and conf == CMSMatchLevel.MEDIA: + return cls._parse_email_address(sms_recipient), None, body + return recipient, None, None + @staticmethod def _node_request_text(message) -> str: """ @@ -660,22 +676,31 @@ def _extract_content_email(utt): recipient = remainder subject = None - # Parse out email words - if recipient: - if "dot" in recipient.split(): - recipient = recipient.replace(" dot ", ".") - if "at" in recipient.split(): - recipient = recipient.replace(" at ", "@").lower() - if "@" in recipient: - # Look at domain (i.e. .com, .co.uk) - recipient_prefix = recipient.split("@", 1)[0].replace(" ", "") - recipient_domain = recipient.split("@", 1)[1].split(".")[0].replace(" ", "") - tld_parts = recipient.split("@", 1)[1].split(".")[1:] - domain_parts = [part.split()[0] for part in tld_parts] - tld = ".".join(domain_parts) - recipient = f"{recipient_prefix}@{recipient_domain}.{tld}" - LOG.info(f"DM: {recipient}") + recipient = MessagingSkill._parse_email_address(recipient) return recipient, subject + @staticmethod + def _parse_email_address(recipient): + """ + Turn a spoken address (`sarah at example dot com`) into + `sarah@example.com`; a plain name is returned unchanged. + """ + if not recipient: + return recipient + if "dot" in recipient.split(): + recipient = recipient.replace(" dot ", ".") + if "at" in recipient.split(): + recipient = recipient.replace(" at ", "@").lower() + if "@" in recipient: + # Look at domain (i.e. .com, .co.uk) + recipient_prefix = recipient.split("@", 1)[0].replace(" ", "") + recipient_domain = recipient.split("@", 1)[1].split(".")[0].replace(" ", "") + tld_parts = recipient.split("@", 1)[1].split(".")[1:] + domain_parts = [part.split()[0] for part in tld_parts] + tld = ".".join(domain_parts) + recipient = f"{recipient_prefix}@{recipient_domain}.{tld}" + LOG.info(f"DM: {recipient}") + return recipient + def stop(self): pass diff --git a/test/test_skill.py b/test/test_skill.py index 4568d3d..e656344 100644 --- a/test/test_skill.py +++ b/test/test_skill.py @@ -265,6 +265,39 @@ def test_send_email_node_draft_intent_extracts_from_utterance(self): "subject": "the project"}) self.skill.speak_dialog.assert_not_called() + def test_send_email_node_that_says_becomes_body(self): + # Seen on a real Hub: DraftEmailIntent with "that says" and no + # subject. Adapt's utterance is the normalized one. + message = _node_message( + "DraftEmailIntent", None, "launch_email_app", + utterance="draft email to emily that says hi") + emitted = [] + self.skill.bus.once("node.invoke_native", + lambda m: emitted.append(m)) + _arm_node_reply(self.skill.bus, _response("launch_email_app")) + + self.skill.handle_send_email(message) + + self.assertEqual(len(emitted), 1) + self.assertEqual(emitted[0].data["params"], + {"to": "emily", "body": "hi"}) + self.skill.speak_dialog.assert_not_called() + + def test_send_email_node_saying_with_spoken_address(self): + message = _node_message( + "DraftEmailIntent", {"kind": "email"}, "launch_email_app", + request="send an email to sarah at example dot com " + "saying running late") + emitted = [] + self.skill.bus.once("node.invoke_native", + lambda m: emitted.append(m)) + _arm_node_reply(self.skill.bus, _response("launch_email_app")) + + self.skill.handle_send_email(message) + + self.assertEqual(emitted[0].data["params"], + {"to": "sarah@example.com", "body": "running late"}) + def test_send_email_node_missing_recipient_speaks_error(self): message = _node_message("DraftEmailIntent", {"subject": "The project"},