Skip to content

Commit

Permalink
Faeries/refactor answers (#212)
Browse files Browse the repository at this point in the history
* auxin supports groups now

* refactor into check_valid_recipient

* tests pass and it's mostly working as intended

* refactor get_source_or_uuid_from_dict

* switch to early return

* readded attachments

Co-authored-by: technillogue <[email protected]>
Co-authored-by: transfaeries <[email protected]>
  • Loading branch information
3 people authored Apr 28, 2022
1 parent d44ea6e commit bee98f1
Show file tree
Hide file tree
Showing 5 changed files with 123 additions and 82 deletions.
145 changes: 77 additions & 68 deletions forest/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@
Coroutine,
Mapping,
Optional,
Tuple,
Type,
TypeVar,
Union,
Expand Down Expand Up @@ -110,6 +109,17 @@ async def get_attachment_paths(message: Message) -> list[str]:
return attachments


def check_valid_recipient(recipient: str) -> bool:
try:
assert recipient == utils.signal_format(recipient)
except (AssertionError, NumberParseException):
try:
assert recipient == str(uuid.UUID(recipient))
except (AssertionError, ValueError):
return False
return True


ActivityQueries = pghelp.PGExpressions(
table="user_activity",
create_table="""CREATE TABLE user_activity (
Expand All @@ -123,6 +133,10 @@ async def get_attachment_paths(message: Message) -> list[str]:
ON CONFLICT ON CONSTRAINT user_activity_account_bot_key1 DO UPDATE SET last_seen=now()""",
)

# This software is intended to promote growth. Like a well managed forest, it grows, it nurtures, it kills.
# Attempts to use this software in a destructive matter, or attempts to harm the forest will be thwarted.
########################################°–_⛤_–°#########################################################


class Signal:
"""
Expand Down Expand Up @@ -458,24 +472,17 @@ async def send_message( # pylint: disable=too-many-arguments
params["attachments"] = attachments
if content:
params["content"] = content
if group:
if utils.AUXIN:
logging.error("setting a group message, but auxin doesn't support this")
if group and not utils.AUXIN:
params["group-id"] = group
elif recipient:
try:
assert recipient == utils.signal_format(recipient)
except (AssertionError, NumberParseException):
try:
assert recipient == str(uuid.UUID(recipient))
except (AssertionError, ValueError) as e:
logging.error(
"not sending message to invalid recipient %s. error: %s",
recipient,
e,
)
return ""
params["destination" if utils.AUXIN else "recipient"] = str(recipient)
if recipient and not utils.AUXIN:
if not check_valid_recipient(recipient):
logging.error("not sending message to invalid recipient %s", recipient)
return ""
params["recipient"] = str(recipient)
if recipient and utils.AUXIN:
params["destination"] = str(recipient)
if group and utils.AUXIN:
params["group"] = group
# maybe use rpc() instead
rpc_id = f"send-{get_uid()}"
json_command: JSON = {
Expand All @@ -491,7 +498,7 @@ async def send_message( # pylint: disable=too-many-arguments

async def admin(self, msg: Response, **other_params: Any) -> None:
"send a message to admin"
if (group := utils.get_secret("ADMIN_GROUP")) and not utils.AUXIN:
if group := utils.get_secret("ADMIN_GROUP"):
await self.send_message(None, msg, group=group, **other_params)
else:
await self.send_message(utils.get_secret("ADMIN"), msg, **other_params)
Expand All @@ -503,7 +510,7 @@ async def respond(
logging.debug("responding to %s", target_msg.source)
if not target_msg.source:
logging.error(json.dumps(target_msg.blob))
if not utils.AUXIN and target_msg.group:
if target_msg.group:
return await self.send_message(
None, msg, group=target_msg.group, **other_params
)
Expand Down Expand Up @@ -1302,16 +1309,17 @@ async def send_payment( # pylint: disable=too-many-locals


def get_source_or_uuid_from_dict(
msg: Message, dict_: Mapping[str, V]
) -> Tuple[bool, Optional[V]]:
msg: Message, dict_: Union[Mapping[str, V], Mapping[tuple[str, str], V]]
) -> tuple[bool, Optional[V]]:
"""A common pattern is to store intermediate state for individual users as a dictionary.
Users can be referred to by some combination of source (a phone number) or uuid (underlying user ID)
This abstracts over the possibility space, returning a boolean indicator of whether the sender of a Message
is referenced in a dict, and the value pointed at (if any)."""
return (
(msg.source in dict_ or msg.uuid in dict_),
dict_.get(msg.uuid) or dict_.get(msg.source),
)
group = msg.group or ""
for key in [(msg.source, group), (msg.uuid, group), msg.source, msg.uuid]:
if value := dict_.get(key): # type: ignore
return True, value
return False, None


def is_first_device(msg: Message) -> bool:
Expand All @@ -1324,7 +1332,7 @@ class QuestionBot(PayBot):
"""Class of Bots that have methods for asking questions and awaiting answers"""

def __init__(self, bot_number: Optional[str] = None) -> None:
self.pending_answers: dict[str, asyncio.Future[Message]] = {}
self.pending_answers: dict[tuple[str, str], asyncio.Future[Message]] = {}
self.requires_first_device: dict[str, bool] = {}
self.failed_user_challenges: dict[str, int] = {}
self.TERMINAL_ANSWERS = "0 no none stop quit exit break cancel abort".split()
Expand All @@ -1336,12 +1344,15 @@ def __init__(self, bot_number: Optional[str] = None) -> None:
super().__init__(bot_number)

async def handle_message(self, message: Message) -> Response:

# import pdb;pdb.set_trace()
pending_answer, probably_future = get_source_or_uuid_from_dict(
message, self.pending_answers
)
_, requires_first_device = get_source_or_uuid_from_dict(
message, self.requires_first_device
)

if message.full_text and pending_answer:
if requires_first_device and not is_first_device(message):
return self.FIRST_DEVICE_PLEASE
Expand All @@ -1354,17 +1365,25 @@ async def handle_message(self, message: Message) -> Response:

async def ask_freeform_question(
self,
recipient: str,
question_text: str = "What's your favourite colour?",
recipient: Union[str, tuple[str, str]],
question_text: Optional[str] = "What's your favourite colour?",
require_first_device: bool = False,
) -> str:
"""Asks a question fulfilled by a sentence or short answer."""
await self.send_message(recipient, question_text)
answer_future = self.pending_answers[recipient] = asyncio.Future()
"""UrQuestion that all other questions use. Asks a question fulfilled by a sentence or short answer."""
group = ""
if isinstance(recipient, tuple):
recipient, group = recipient
answer_future = self.pending_answers[recipient, group] = asyncio.Future()
if require_first_device:
self.requires_first_device[recipient] = True

if question_text:
if group:
await self.send_message(None, question_text, group=group)
else:
await self.send_message(recipient, question_text)
answer = await answer_future
self.pending_answers.pop(recipient)
self.pending_answers.pop((recipient, group))
return answer.full_text or ""

async def ask_floatable_question(
Expand All @@ -1376,14 +1395,11 @@ async def ask_floatable_question(
"""Asks a question answered with a floating point or decimal number.
Asks user clarifying questions if an invalid number is provided.
Returns None if user says any of the terminal answers."""
if question_text:
await self.send_message(recipient, question_text)
answer_future = self.pending_answers[recipient] = asyncio.Future()
if require_first_device:
self.requires_first_device[recipient] = True
answer = await answer_future
self.pending_answers.pop(recipient)
answer_text = answer.full_text

answer = await self.ask_freeform_question(
recipient, question_text, require_first_device
)
answer_text = answer

# This checks to see if the answer is a valid candidate for float by replacing
# the first comma or decimal point with a number to see if the resulting string .isnumeric()
Expand All @@ -1392,7 +1408,7 @@ async def ask_floatable_question(
or answer_text.replace(",", "1", 1).isnumeric()
):
# cancel if user replies with any of the terminal answers "stop, cancel, quit, etc. defined above"
if answer.full_text.lower() in self.TERMINAL_ANSWERS:
if answer.lower() in self.TERMINAL_ANSWERS:
return None

# Check to see if the original question already specified wanting the answer as a decimal.
Expand All @@ -1403,7 +1419,7 @@ async def ask_floatable_question(
recipient, (question_text or "") + " (as a decimal, ie 1.01 or 2,02)"
)
if answer_text:
return float(answer.full_text.replace(",", ".", 1))
return float(answer.replace(",", ".", 1))
return None

async def ask_intable_question(
Expand All @@ -1415,17 +1431,14 @@ async def ask_intable_question(
"""Asks a question answered with an integer or whole number.
Asks user clarifying questions if an invalid number is provided.
Returns None if user says any of the terminal answers."""
if require_first_device:
self.requires_first_device[recipient] = True
if question_text:
await self.send_message(recipient, question_text)
answer_future = self.pending_answers[recipient] = asyncio.Future()
answer = await answer_future
self.pending_answers.pop(recipient)
if answer.full_text and not answer.full_text.isnumeric():

answer = await self.ask_freeform_question(
recipient, question_text, require_first_device
)
if answer and not answer.isnumeric():

# cancel if user replies with any of the terminal answers "stop, cancel, quit, etc. defined above"
if answer.full_text.lower() in self.TERMINAL_ANSWERS:
if answer.lower() in self.TERMINAL_ANSWERS:
return None

# Check to see if the original question already specified wanting the answer as a decimal.
Expand All @@ -1436,8 +1449,8 @@ async def ask_intable_question(
recipient,
(question_text or "") + " (as a whole number, ie '1' or '2000')",
)
if answer.full_text:
return int(answer.full_text)
if answer:
return int(answer)
return None

async def ask_yesno_question(
Expand Down Expand Up @@ -1587,22 +1600,21 @@ async def ask_multiple_choice_question( # pylint: disable=too-many-arguments
if len(lower_dict_options) != len(dict_options):
raise ValueError("Need to ensure unique options when lower-cased!")

# send user the formatted question and await their response
await self.send_message(recipient, question_text + "\n" + options_text)
answer_future = self.pending_answers[recipient] = asyncio.Future()
answer = await answer_future
self.pending_answers.pop(recipient)
# send user the formatted question as a freeform question and process their response
answer = await self.ask_freeform_question(
recipient, question_text + "\n" + options_text, require_first_device
)

# when there is a match
if answer.full_text and answer.full_text.lower() in lower_dict_options.keys():
if answer and answer.lower() in lower_dict_options.keys():

# if confirmation is required ask for it as a yes/no question
if require_confirmation:
confirmation_text = (
"You picked: \n"
+ answer.full_text
+ answer
+ spacer
+ lower_dict_options[answer.full_text.lower()]
+ lower_dict_options[answer.lower()]
+ "\n\nIs this correct? (yes/no)"
)
confirmation = await self.ask_yesno_question(
Expand All @@ -1619,12 +1631,9 @@ async def ask_multiple_choice_question( # pylint: disable=too-many-arguments
require_first_device,
)
# if the answer given does not match a label
if (
answer.full_text
and not answer.full_text.lower() in lower_dict_options.keys()
):
if answer and not answer.lower() in lower_dict_options.keys():
# return none and exit if user types cancel, stop, exit, etc...
if answer.full_text.lower() in self.TERMINAL_ANSWERS:
if answer.lower() in self.TERMINAL_ANSWERS:
return None
# otherwise reminder to type the label exactly as it appears and restate the question
if "Please reply" not in question_text:
Expand All @@ -1640,7 +1649,7 @@ async def ask_multiple_choice_question( # pylint: disable=too-many-arguments
require_first_device,
)
# finally return the option that matches the answer, or if empty the answer itself
return lower_dict_options[answer.full_text.lower()] or answer.full_text
return lower_dict_options[answer.lower()] or answer

async def ask_email_question(
self,
Expand Down
2 changes: 1 addition & 1 deletion forest/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ def __init__(self, outer_blob: dict, _id: Optional[str] = None) -> None:
self.attachments: list[dict[str, str]] = msg.get("attachments", [])
# "bodyRanges":[{"associatedValue":{"mentionUuid":"fc4457f0-c683-44fe-b887-fe3907d7762e"},"length":1,"start":0}] ... no groups anyway
self.mentions = []
self.group = msg.get("group") or msg.get("groupV2") or ""
self.group = blob.get("group_id") or ""
maybe_quote = msg.get("quote")
self.address = blob.get("Address", {})
self.quoted_text = "" if not maybe_quote else maybe_quote.get("text")
Expand Down
5 changes: 4 additions & 1 deletion tests/mockbot.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
# Sample bot number alice
BOT_NUMBER = "+11111111111"
USER_NUMBER = "+22222222222"
USER_UUID = "11111111-1111-1111-1111-111111111111"


os.environ["ENV"] = "test"

Expand All @@ -17,7 +19,8 @@ def __init__(self, text: str) -> None:
self.text = text
self.full_text = text
self.source = USER_NUMBER
self.uuid = "cf3d7d34-2dcd-4fcd-b193-cbc6a666758b"
self.uuid = USER_UUID
self.group = ""
self.mentions: list[dict[str, str]] = []
super().__init__({})

Expand Down
Loading

0 comments on commit bee98f1

Please sign in to comment.