diff --git a/__init__.py b/__init__.py index df4c76b..b57201d 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, 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: " f"{skill_data}") @@ -398,6 +405,31 @@ 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: + """ + 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 +624,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) @@ -644,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 2c70873..e656344 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,77 @@ 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_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"},