Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 60 additions & 19 deletions __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand All @@ -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}")
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Comment on lines +690 to +693

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note for later that this should use resource files to support translation, possibly simplifying to regex instead of keyword matching/splitting

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
103 changes: 101 additions & 2 deletions test/test_skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"},
Expand Down
Loading