Skip to content

Commit

Permalink
new PR for updates to teddy, charity, and hotline (#214)
Browse files Browse the repository at this point in the history
* teddy greets the world

* rearrange order of checks

* teddy rollup

* first pass on giving

* re-add self.first_messages

* donation logging and add back in bits that went missing

* rename to charity.py

* bugfix and rename

* bugfix and rename

* small typo fixes

* teddy tweaks

* stash

* make charity first greeting optional; add status polling to outgoing payments, add do_set_dialog / do_dialog to the hotline (admin-only)

* fix typing

* typing fixes

* bumps

* breakout yes/no defaults, add rewards / levels / fulfillment info, lowercase multiple choice if possible

* workflow rehash 0322

* dedust

* docker bumps and minor adjustment to FIRST_GREETING

* dialog tuning, dialogkeys endpoint, bump to 16 dust fragments

* use full_text for talkback, drop do_yes/do_no

* typing fixes

* small lints hat-tip sylv

* fix hotline lint

* with better payment_result handling

* lots of comments, a bit of cleanup, and some linting

* okay, no hide do_help

* drop last clangat, disable pylint too-many-public-methods

* run_bot(Teddy)

* last teddy nits

* readd followup flow

* Dialog / DialogBot, nitfixes; this commit dedicated to @technillogue

* finish dropping old timing

* lint/typing

* last run_bot

* drop unused import

* lint / dead code

* the rest of the owl

* yeesh

* dump/load/state/followup

* lint

* send typing indicators before payments (#184)

* add send_typing method

* update evilbot

* unused import

* raise NotImplementedError

* add auxin typing messages

* fix group

* restore apparently dropped message.typing and add some logging

* fix AuxinMessage.typing and drop debugs

* fix up send_typing so it can work without messages and add typing to hotline and charity

* add note about how long typing lasts

* msg/message

* fix too-many-return-statements,too-many-branches,too-many-locals and too-many-statements 😅

* Update CHANGELOG.md

Co-authored-by: itdaniher <itdaniher@gmail.com>

* auxin supports groups now

* refactor into check_valid_recipient

* rollup mobfriend, hotline, charity

* typelint

* extra typelint

* use send_typing to indicate long-running tasks

* replace msg.source -> msg.uuid

* dialogdump messages can be forwarded, admins can reset other users

* typo

* bump

* simplify

* Revert "if there isn't a txo big enough to split into 15, split into however many we can (#182)"

This reverts commit f517040.

* fix sending how-to-donate

* missing None

* caching

* add int_map, do_intset, make payment_amount and allowed_claims runtime adjustable

* allow negative priced events

* drop el-

* no spaces in display names, add raisehand, loud flag for out of funds.

* mute default help dialog on Charity

* refactoring to allow list / event admins to reset signal session state for others

* +Pipfile.lock

* okay, reset it twice, then tell them we reset.

* admins can reset anyone

* semantic slip

* fix mobfriend

* fix checks

* fixes for new mypy version (will be needed everywhere)

Co-authored-by: infra <infra@sterile.solutions>
Co-authored-by: cxloe <cxl0e@protonmail.com>
Co-authored-by: itdaniher <itdaniher@gmail.com>
  • Loading branch information
4 people authored May 25, 2022
1 parent b3642ae commit 5cc197c
Show file tree
Hide file tree
Showing 23 changed files with 4,183 additions and 313 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,10 @@ jobs:
run: poetry run black . --check
if: always()
- name: Mypy
run: poetry run mypy sample_bots mc_util forest mobfriend contact imogen hotline
run: poetry run mypy sample_bots mc_util forest mobfriend contact imogen hotline teddy
if: always()
- name: Pylint
run: poetry run pylint --version; poetry run pylint sample_bots mc_util forest mobfriend/mobfriend.py contact imogen/imogen.py hotline/hotline.py
run: poetry run pylint --version; poetry run pylint sample_bots mc_util forest mobfriend/mobfriend.py contact imogen/imogen.py hotline/hotline.py teddy/teddy.py teddy/charity.py
if: always()
- name: Pytest
run: poetry run python -m pytest --cov forest
24 changes: 7 additions & 17 deletions forest/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@
from textwrap import dedent
from typing import (
Any,
Awaitable,
Callable,
Coroutine,
Mapping,
Expand Down Expand Up @@ -64,10 +63,10 @@

JSON = dict[str, Any]
Response = Union[str, list, dict[str, str], None]
AsyncFunc = Callable[..., Awaitable]
AsyncFunc = Callable[..., Coroutine[Any, Any, Any]]
Command = Callable[["Bot", Message], Coroutine[Any, Any, Response]]

roundtrip_histogram = Histogram("roundtrip_h", "Roundtrip message response time") # type: ignore
roundtrip_histogram = Histogram("roundtrip_h", "Roundtrip message response time")
roundtrip_summary = Summary("roundtrip_s", "Roundtrip message response time")

MessageParser = AuxinMessage if utils.AUXIN else StdioMessage
Expand Down Expand Up @@ -826,8 +825,8 @@ async def respond_and_collect_metrics(self, message: Message) -> None:
self.signal_roundtrip_latency.append(
(message.timestamp, note, roundtrip_delta)
)
roundtrip_summary.observe(roundtrip_delta) # type: ignore
roundtrip_histogram.observe(roundtrip_delta) # type: ignore
roundtrip_summary.observe(roundtrip_delta)
roundtrip_histogram.observe(roundtrip_delta)
logging.info("noted roundtrip time: %s", roundtrip_delta)
if utils.get_secret("ADMIN_METRICS"):
await self.admin(
Expand Down Expand Up @@ -1051,16 +1050,6 @@ async def do_uptime(self, _: Message) -> str:


class PayBot(ExtrasBot):
PAYMENTS_HELPTEXT = """Enable Signal Pay:
1. In Signal, tap “⬅️“ & tap on your profile icon in the top left & tap *Settings*
2. Tap *Payments* & tap *Activate Payments*
For more information on Signal Payments visit:
https://support.signal.org/hc/en-us/articles/360057625692-In-app-Payments"""

@requires_admin
async def do_fsr(self, msg: Message) -> Response:
"""
Expand Down Expand Up @@ -1435,9 +1424,10 @@ async def ask_floatable_question(

# 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()
# does the same for negative signs
if answer_text and not (
answer_text.replace(".", "1", 1).isnumeric()
or answer_text.replace(",", "1", 1).isnumeric()
answer_text.replace("-", "1", 1).replace(".", "1", 1).isnumeric()
or answer_text.replace("-", "1", 1).replace(",", "1", 1).isnumeric()
):
# cancel if user replies with any of the terminal answers "stop, cancel, quit, etc. defined above"
if answer.lower() in self.TERMINAL_ANSWERS:
Expand Down
236 changes: 236 additions & 0 deletions forest/extra.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
# the alternative strategy is like, invent an intermediate representation and notation (and visual frontend), or parse Python and hope folks don't use layers of abstraction that one needs runtime introspection to destructure
import ast
import sys
import json
import string

from typing import Optional, Any
from forest import utils
from forest.core import (
QuestionBot,
is_admin,
Message,
Response,
requires_admin,
get_uid,
)
from forest.pdictng import aPersistDict


class GetStr(ast.NodeTransformer):
source = open(sys.argv[-1]).read()
dialogs: list[dict[str, Any]] = []

def get_source(self, node: ast.AST) -> Optional[str]:
"""Get the code fragments that correspond to a provided AST node."""
return ast.get_source_segment(self.source, node)

def get_dialog_fragments(self) -> list[dict[str, Any]]:
"""Wrapper function which abstracts over most of the work.
Returns the generated set of calls to fetch dialog tidbits."""
node = ast.parse(self.source)
self.visit(node)
return self.dialogs

def visit_Call(self, node: ast.Call) -> None:
"""Visit ast.Call objects, recursively, looking for calls that match
self.dialog.get(fragment, default), and recording the metadata."""
for child in ast.iter_child_nodes(node):
self.visit(child)
if isinstance(node.func, ast.Attribute):
# pylint: disable=too-many-boolean-expressions
if (
hasattr(node.func, "attr")
and node.func.attr == "get"
and getattr(node.func, "value", False)
and not isinstance(node.func.value, ast.Name)
and not isinstance(node.func.value, ast.Subscript)
and getattr(node.func.value, "attr", "") == "dialog"
):
vals = [
c.value
if isinstance(c, ast.Constant)
else f"(python) `{self.get_source(c)}`"
for c in node.args
if c
]
if len(vals) == 2:
output_vals = {"key": vals[0], "fallback": vals[1]}
else:
output_vals = {"key": vals[0]}
self.dialogs += [{"line_number": node.lineno, **output_vals}]


class Dialog(aPersistDict[str]):
dialog_keys = GetStr().get_dialog_fragments()

def __init__(self) -> None:
super().__init__(self, tag="dialog")


class TalkBack(QuestionBot):
def __init__(self) -> None:
self.profile_cache: aPersistDict[dict[str, str]] = aPersistDict("profile_cache")
self.displayname_cache: aPersistDict[str] = aPersistDict("displayname_cache")
self.displayname_lookup_cache: aPersistDict[str] = aPersistDict(
"displayname_lookup_cache"
)
super().__init__()

async def handle_message(self, message: Message) -> Response:
if message.quoted_text and is_admin(message):
maybe_displayname = message.quoted_text.split()[0]
maybe_id = await self.displayname_lookup_cache.get(maybe_displayname)
if maybe_id:
await self.send_message(maybe_id, message.full_text)
return f"Sent reply to {maybe_displayname}!"
return await super().handle_message(message)

@requires_admin
async def do_send(self, msg: Message) -> Response:
"""Send <recipient> <message>
Sends a message as MOBot."""
obj = msg.arg1
param = msg.arg2
if not is_admin(msg):
await self.send_message(
utils.get_secret("ADMIN"), f"Someone just used send:\n {msg}"
)
if obj and param:
if obj in await self.displayname_lookup_cache.keys():
obj = await self.displayname_lookup_cache.get(obj)
try:
result = await self.send_message(obj, param)
return result
except Exception as err: # pylint: disable=broad-except
return str(err)
if not obj:
msg.arg1 = await self.ask_freeform_question(
msg.uuid, "Who would you like to message?"
)
if param and param.strip(string.punctuation).isalnum():
param = (
(msg.full_text or "")
.lstrip("/")
.replace(f"send {msg.arg1} ", "", 1)
.replace(f"Send {msg.arg1} ", "", 1)
) # thanks mikey :)
if not param:
msg.arg2 = await self.ask_freeform_question(
msg.uuid, "What would you like to say?"
)
return await self.do_send(msg)

async def get_displayname(self, uuid: str) -> str:
"""Retrieves a display name from a UUID, stores in the cache, handles error conditions."""
uuid = uuid.strip("\u2068\u2069")
# displayname provided, not uuid or phone
if uuid.count("-") != 4 and not uuid.startswith("+"):
uuid = await self.displayname_lookup_cache.get(uuid, uuid)
# phone number, not uuid provided
if uuid.startswith("+"):
uuid = self.get_uuid_by_phone(uuid) or uuid
maybe_displayname = await self.displayname_cache.get(uuid)
if (
maybe_displayname
and "givenName" not in maybe_displayname
and " " not in maybe_displayname
):
return maybe_displayname
maybe_user_profile = await self.profile_cache.get(uuid)
# if no luck, but we have a valid uuid
user_given = ""
if (
not maybe_user_profile or not maybe_user_profile.get("givenName", "")
) and uuid.count("-") == 4:
try:
maybe_user_profile = (
await self.signal_rpc_request("getprofile", peer_name=uuid)
).blob or {}
user_given = maybe_user_profile.get("givenName", "")
await self.profile_cache.set(uuid, maybe_user_profile)
except AttributeError:
# this returns a Dict containing an error key
user_given = "[error]"
elif maybe_user_profile and "givenName" in maybe_user_profile:
user_given = maybe_user_profile["givenName"]
if not user_given:
user_given = "givenName"
user_given = user_given.replace(" ", "_")
if uuid and ("+" not in uuid and "-" in uuid):
user_short = f"{user_given}_{uuid.split('-')[1]}"
else:
user_short = user_given + uuid
await self.displayname_cache.set(uuid, user_short)
await self.displayname_lookup_cache.set(user_short, uuid)
return user_short

async def talkback(self, msg: Message) -> Response:
source = msg.uuid or msg.source
await self.admin(f"{await self.get_displayname(source)} says: {msg.full_text}")
return None


class DialogBot(TalkBack):
def __init__(self) -> None:
self.dialog = Dialog()
super().__init__()

@requires_admin
async def do_dialogset(self, msg: Message) -> Response:
"""Let's do it live.
Privileged editing of dialog blurbs, because."""
user = msg.uuid
fragment_to_set = msg.arg1 or await self.ask_freeform_question(
user, "What fragment would you like to change?"
)
if fragment_to_set in self.TERMINAL_ANSWERS:
return "OK, nvm"
blurb = msg.arg2 or await self.ask_freeform_question(
user, "What dialog would you like to use?"
)
if fragment_to_set in self.TERMINAL_ANSWERS:
return "OK, nvm"
if old_blurb := await self.dialog.get(fragment_to_set):
await self.send_message(user, "overwriting:")
await self.send_message(user, old_blurb)
await self.dialog.set(fragment_to_set, blurb)
return "updated blurb!"

@requires_admin
async def do_dialogdump(self, msg: Message) -> Response:
dialog_json = json.dumps(self.dialog.dict_, indent=2)
sendfilepath = f"/tmp/Dialog_{get_uid()}.json"
open(sendfilepath, "w").write(dialog_json)
await self.send_message(
msg.uuid, f"dialogload {dialog_json}", attachments=[sendfilepath]
)
return "You can forward this message to a compatible bot to load the dialog!"

@requires_admin
async def do_dialogload(self, msg: Message) -> Response:
dialog = json.loads(msg.full_text.lstrip("dialogload "))
unresolved = []
valid_keys = {dk.get("key") for dk in self.dialog.dialog_keys if "key" in dk}
for key, value in dialog.items():
await self.dialog.set(key, value)
if key not in valid_keys:
unresolved += [key]
if unresolved:
return f"Found some unresolved keys in this load: {unresolved}"
return "All good!"

@requires_admin
async def do_dialog(self, _: Message) -> Response:
return "\n\n".join(
[f"{k}: {v}\n------\n" for (k, v) in self.dialog.dict_.items()]
)

@requires_admin
async def do_dialogkeys(self, _: Message) -> Response:
return "\n\n".join(
[
"\n".join([f"{k}: {v}" for (k, v) in dialogkey.items()])
for dialogkey in self.dialog.dialog_keys
]
)
38 changes: 17 additions & 21 deletions forest/payments_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import random
import ssl
import time

from typing import Any, Optional
import aiohttp
import asyncpg
Expand All @@ -32,19 +33,18 @@
ssl_context.verify_mode = ssl.CERT_REQUIRED
ssl_context.load_cert_chain(certfile="client.full.pem")

MICROMOB_TO_PICOMOB = 1_000_000 # that's 1e12/1e6
MILLIMOB_TO_PICOMOB = 1_000_000_000 # that's 1e12/1e3
FEE_PMOB = int(1e12 * 0.0004) # mobilecoin transaction fee in picomob.
MICROMOB_TO_PICOMOB = 1_000_000
MILLIMOB_TO_PICOMOB = 1_000_000_000

DATABASE_URL = utils.get_secret("DATABASE_URL")
LedgerPGExpressions = PGExpressions(
table=utils.get_secret("LEDGER_NAME") or "ledger",
create_table="""CREATE TABLE IF NOT EXISTS {self.table} (
tx_id SERIAL PRIMARY KEY,
create_table="""CREATE TABLE IF NOT EXISTS {self.table} (
tx_id SERIAL PRIMARY KEY,
account TEXT,
amount_usd_cents BIGINT NOT NULL,
amount_pmob BIGINT,
memo TEXT,
amount_usd_cents BIGINT NOT NULL,
amount_pmob BIGINT,
memo TEXT,
ts TIMESTAMP);""",
put_usd_tx="INSERT INTO {self.table} (account, amount_usd_cents, memo, ts) \
VALUES($1, $2, $3, CURRENT_TIMESTAMP);",
Expand Down Expand Up @@ -152,28 +152,25 @@ async def get_utxos(self) -> dict[str, int]:
async def split_txos_slow(
self, output_millimob: int = 100, target_quantity: int = 200
) -> str:
output_pmob = output_millimob * MILLIMOB_TO_PICOMOB + FEE_PMOB
output_millimob = int(output_millimob)
built = 0
i = 0
utxos: list[tuple[str, int]] = list(reversed((await self.get_utxos()).items()))
if sum([value for _, value in utxos]) < output_pmob * target_quantity:
if sum([value for _, value in utxos]) < output_millimob * 1e9 * target_quantity:
return "insufficient MOB"
while built < (target_quantity + 3):
if len(utxos) < 1:
# if we have few big txos, we can have a lot of change we don't see yet
# so if we've run out of txos we check for change from previous iterations
utxos = list(reversed((await self.get_utxos()).items()))
txo_id, value = utxos.pop(0)
if value / output_pmob < 2:
continue
utxos = list(reversed(await self.get_utxos())) # type: ignore
split_transaction = await self.req_(
"build_split_txo_transaction",
**dict(
txo_id=txo_id,
txo_id=utxos.pop(0),
output_values=[
# if we can't split into 15, split into however much possible
str(output_pmob)
for _ in range(min(value // output_pmob, 15))
str(
output_millimob * MILLIMOB_TO_PICOMOB
+ 400 * MICROMOB_TO_PICOMOB
)
for _ in range(15)
],
),
)
Expand All @@ -183,7 +180,6 @@ async def split_txos_slow(
results = await self.req_("submit_transaction", **params)
else:
results = {}
# not only did we have params, submitting also had a result
if results.get("results"):
await asyncio.sleep(2)
built += 15
Expand Down
Loading

0 comments on commit 5cc197c

Please sign in to comment.