-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtools.py
More file actions
861 lines (790 loc) · 31.6 KB
/
Copy pathtools.py
File metadata and controls
861 lines (790 loc) · 31.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
import json
import logging
from typing import Any
from agentphone_client import send_message
from browser_agent import check_d2l_grades, create_d2l_session, stream_until_done
from config import (
KID_DEFAULT_PAYOUT_DESTINATION,
PAYMENT_DEFAULT_CHAIN,
PUBLIC_URL,
)
from db import (
create_family_with_users,
delete_family,
get_kid_for_parent,
get_parent_for_kid,
get_user_by_phone,
set_onboarding_state,
set_payout_destination,
)
import memory
from payment_service import (
approve_payment_request,
create_payment_request,
decline_payment_request,
get_payment_request_status,
)
from sponge_client import validate_payout_destination
# Names the LLM sometimes invents when the user hasn't actually given one.
# Reject these to prevent garbage onboarding records.
_PLACEHOLDER_NAMES = {
"unknown", "n/a", "na", "none", "null", "parent", "kid", "child",
"user", "anon", "anonymous", "guest", "test", "tbd", "?", "",
}
def _is_placeholder(name: str) -> bool:
return name.strip().lower() in _PLACEHOLDER_NAMES
log = logging.getLogger(__name__)
TOOL_SCHEMAS = [
{
"type": "function",
"function": {
"name": "register_family",
"description": (
"Register a new family. Call this when a sender unknown to the system "
"provides their own first name, their kid's first name, and their kid's "
"phone number. The tool creates the family record and texts the kid for "
"verification."
),
"parameters": {
"type": "object",
"properties": {
"parent_name": {
"type": "string",
"description": "First name of the sender (the parent).",
},
"kid_name": {
"type": "string",
"description": "First name of the kid.",
},
"kid_phone": {
"type": "string",
"description": "Kid's phone number. Accept any format; will be normalized to E.164.",
},
},
"required": ["parent_name", "kid_name", "kid_phone"],
"additionalProperties": False,
},
},
},
{
"type": "function",
"function": {
"name": "confirm_kid",
"description": (
"Mark a kid as verified. Call this when a kid whose onboarding_state is "
"'pending_verification' replies affirmatively (yes, yeah, yep, sure, ok, etc.)."
),
"parameters": {
"type": "object",
"properties": {
"kid_phone": {
"type": "string",
"description": "The kid's phone number — i.e. the current sender's phone.",
}
},
"required": ["kid_phone"],
"additionalProperties": False,
},
},
},
{
"type": "function",
"function": {
"name": "check_d2l_grades",
"description": (
"Look up the kid's current grades on the school portal (Waterloo D2L). "
"Call this only when a VERIFIED parent asks about their kid's grades, "
"assignments, or school performance. The browser run takes ~20-40 seconds."
),
"parameters": {
"type": "object",
"properties": {
"student_name": {
"type": "string",
"description": "First name of the kid whose grades to check.",
}
},
"required": ["student_name"],
"additionalProperties": False,
},
},
},
{
"type": "function",
"function": {
"name": "get_browser_history",
"description": (
"Look up the kid's recent browser history. "
"Call this only when a VERIFIED parent asks about what their kid "
"has been browsing, their internet activity, or screen time."
),
"parameters": {
"type": "object",
"properties": {
"kid_name": {
"type": "string",
"description": "First name of the kid whose history to check.",
}
},
"required": ["kid_name"],
"additionalProperties": False,
},
},
},
{
"type": "function",
"function": {
"name": "add_calendar_event",
"description": (
"Add an event to the kid's calendar. Call this only when a VERIFIED "
"parent asks to schedule, add, or put something on their kid's calendar."
),
"parameters": {
"type": "object",
"properties": {
"event_name": {
"type": "string",
"description": "Name of the event (e.g. 'Dentist appointment').",
},
"date": {
"type": "string",
"description": "Date of the event (e.g. 'Thursday', 'May 22').",
},
"time": {
"type": "string",
"description": "Time of the event (e.g. '3pm', '15:00').",
},
"reminder": {
"type": "string",
"description": "Optional reminder timing (e.g. '1 hour before').",
},
},
"required": ["event_name", "date", "time"],
"additionalProperties": False,
},
},
},
{
"type": "function",
"function": {
"name": "remember_fact",
"description": (
"Store a durable fact about this family for future conversations. "
"Use when the user shares info worth remembering: school/program, "
"current courses, tutors, recurring schedules, preferences, etc. "
"Do NOT use for ephemeral things ('I'm tired today')."
),
"parameters": {
"type": "object",
"properties": {
"content": {
"type": "string",
"description": "The fact to remember, written as a complete sentence.",
},
"category": {
"type": "string",
"enum": ["school_info", "preference", "relationship", "approval", "other"],
"description": "What kind of fact this is.",
},
"kid_name": {
"type": "string",
"description": "Name of the kid this fact is about, if any.",
},
},
"required": ["content", "category"],
"additionalProperties": False,
},
},
},
{
"type": "function",
"function": {
"name": "recall",
"description": (
"Search memories for past facts about this family. Use when the user "
"asks about something you might have heard before."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Short natural-language description of what to look up.",
}
},
"required": ["query"],
"additionalProperties": False,
},
},
},
{
"type": "function",
"function": {
"name": "unregister_family",
"description": (
"Delete the sender's family record entirely (parent + kid rows). "
"ONLY call this when a verified parent explicitly asks to start over, "
"unregister, delete their account, or remove their kid. After this, "
"the sender becomes UNKNOWN again and can re-register from scratch."
),
"parameters": {
"type": "object",
"properties": {},
"additionalProperties": False,
},
},
},
{
"type": "function",
"function": {
"name": "create_payment_request",
"description": (
"Create a parent-approval payment request. Call this only for a "
"VERIFIED kid asking to pay for one specific service. The amount "
"must be integer cents; never pass a float amount."
),
"parameters": {
"type": "object",
"properties": {
"service_name": {
"type": "string",
"description": "Specific service or thing the kid wants to pay for.",
},
"amount_cents": {
"type": "integer",
"minimum": 1,
"description": "Requested amount in integer cents, e.g. $2.00 -> 200.",
},
"currency": {
"type": "string",
"description": "Currency code. Default USD.",
},
"reason": {
"type": "string",
"description": "Kid's short reason or context for the request.",
},
},
"required": ["service_name", "amount_cents"],
"additionalProperties": False,
},
},
},
{
"type": "function",
"function": {
"name": "approve_payment_request",
"description": (
"Approve a pending payment request. Call this only for a VERIFIED "
"parent in the same family. If the parent omits a code, the tool "
"will approve only when exactly one pending request exists."
),
"parameters": {
"type": "object",
"properties": {
"request_code": {
"type": "string",
"description": "6-digit payment request code, if provided.",
}
},
"additionalProperties": False,
},
},
},
{
"type": "function",
"function": {
"name": "decline_payment_request",
"description": (
"Decline a pending payment request. Call this only for a VERIFIED "
"parent in the same family."
),
"parameters": {
"type": "object",
"properties": {
"request_code": {
"type": "string",
"description": "6-digit payment request code, if provided.",
}
},
"additionalProperties": False,
},
},
},
{
"type": "function",
"function": {
"name": "get_payment_request_status",
"description": (
"Get the status of a payment request for a verified parent or the "
"kid who created it."
),
"parameters": {
"type": "object",
"properties": {
"request_code": {
"type": "string",
"description": "6-digit payment request code, if provided.",
}
},
"additionalProperties": False,
},
},
},
{
"type": "function",
"function": {
"name": "send_message_to_kid",
"description": (
"Send a text message to the kid on behalf of the parent. "
"Only a VERIFIED parent can use this. Compose the message "
"naturally based on what the parent asked you to say."
),
"parameters": {
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "The message to send to the kid.",
}
},
"required": ["message"],
"additionalProperties": False,
},
},
},
{
"type": "function",
"function": {
"name": "send_message_to_parent",
"description": (
"Send a text message to the parent on behalf of the kid. "
"Only a VERIFIED kid can use this. Compose the message "
"naturally based on what the kid asked you to say."
),
"parameters": {
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "The message to send to the parent.",
}
},
"required": ["message"],
"additionalProperties": False,
},
},
},
]
async def dispatch_tool(
name: str, args: dict[str, Any], *, sender_phone: str, ctx: dict
) -> str:
"""Run the named tool and return a string result for the LLM."""
log.info("Tool call name=%s args=%s sender=%s", name, args, sender_phone)
if name == "register_family":
return await _register_family(
sender_phone=sender_phone,
parent_name=str(args.get("parent_name", "")).strip(),
kid_name=str(args.get("kid_name", "")).strip(),
kid_phone=normalize_phone(str(args.get("kid_phone", ""))),
ctx=ctx,
)
if name == "confirm_kid":
return await _confirm_kid(
kid_phone=normalize_phone(str(args.get("kid_phone", "")))
)
if name == "check_d2l_grades":
student_name = str(args.get("student_name", "")).strip()
result = await _dispatch_check_grades(sender_phone, student_name, ctx)
return result
if name == "get_browser_history":
return await _get_browser_history(
sender_phone=sender_phone,
kid_name=str(args.get("kid_name", "")).strip(),
)
if name == "add_calendar_event":
return await _add_calendar_event(
sender_phone=sender_phone,
event_name=str(args.get("event_name", "")).strip(),
date=str(args.get("date", "")).strip(),
time=str(args.get("time", "")).strip(),
reminder=str(args.get("reminder") or "").strip() or None,
)
if name == "remember_fact":
content = str(args.get("content", "")).strip()
category = str(args.get("category", "other")).strip() or "other"
kid_name = str(args.get("kid_name", "")).strip() or None
family_id = ctx.get("family_id")
if family_id is None:
return "ERROR: cannot remember — sender has no family registered yet."
if not content:
return "ERROR: content is empty."
md: dict = {"category": category, "source": "user_message"}
if kid_name:
md["kid_name"] = kid_name
ok = await memory.remember(family_id, content, md)
return "stored" if ok else "memory currently unavailable (continuing without)"
if name == "recall":
query = str(args.get("query", "")).strip()
family_id = ctx.get("family_id")
if family_id is None:
return "no memories — sender has no family yet."
if not query:
return "ERROR: query is empty."
results = await memory.recall(family_id, query)
if not results:
return "no relevant memories found."
return memory.format_memories_block(results) or "no relevant memories found."
if name == "unregister_family":
sender = get_user_by_phone(sender_phone)
if not sender:
return "ERROR: you're not registered. Nothing to delete."
if sender["role"] != "parent":
return "ERROR: only the parent can unregister the family."
deleted = delete_family(sender["family_id"])
ctx["family_id"] = None
log.info(
"unregister_family deleted %d rows for family=%s (parent=%s)",
deleted,
sender["family_id"],
sender_phone,
)
return (
f"Family deleted ({deleted} rows). The parent and kid records are "
f"both gone. The sender can now register from scratch."
)
if name == "create_payment_request":
amount_cents = args.get("amount_cents")
if isinstance(amount_cents, str) and amount_cents.isdigit():
amount_cents = int(amount_cents)
return await create_payment_request(
sender_phone=sender_phone,
service_name=str(args.get("service_name", "")).strip(),
amount_cents=amount_cents,
currency=str(args.get("currency") or "USD").strip(),
reason=str(args.get("reason", "")).strip(),
)
if name == "approve_payment_request":
return await approve_payment_request(
sender_phone=sender_phone,
request_code=str(args.get("request_code") or "").strip() or None,
)
if name == "decline_payment_request":
return await decline_payment_request(
sender_phone=sender_phone,
request_code=str(args.get("request_code") or "").strip() or None,
)
if name == "get_payment_request_status":
return await get_payment_request_status(
sender_phone=sender_phone,
request_code=str(args.get("request_code") or "").strip() or None,
)
if name == "send_message_to_kid":
return await _send_message_to_kid(
sender_phone=sender_phone,
message=str(args.get("message", "")).strip(),
)
if name == "send_message_to_parent":
return await _send_message_to_parent(
sender_phone=sender_phone,
message=str(args.get("message", "")).strip(),
)
return f"ERROR: unknown tool '{name}'"
async def _register_family(
*,
sender_phone: str,
parent_name: str,
kid_name: str,
kid_phone: str,
ctx: dict,
) -> str:
if not parent_name or not kid_name or not kid_phone:
return "ERROR: missing parent_name, kid_name, or kid_phone. Ask the user for the missing field."
if _is_placeholder(parent_name):
return (
f"ERROR: '{parent_name}' is not a real first name. Ask the user "
f"for their actual first name before calling register_family."
)
if _is_placeholder(kid_name):
return (
f"ERROR: '{kid_name}' is not a real kid's name. Ask the user "
f"for the kid's actual first name."
)
if kid_phone == sender_phone:
return "ERROR: the kid's phone number can't be the same as the parent's."
existing = get_user_by_phone(sender_phone)
if existing:
return f"ERROR: sender is already registered as {existing['name']} ({existing['role']})."
existing_kid = get_user_by_phone(kid_phone)
if existing_kid:
return f"ERROR: phone {kid_phone} is already registered as {existing_kid['name']}."
family_id, parent_id, kid_id = create_family_with_users(
parent_name=parent_name,
parent_phone=sender_phone,
kid_name=kid_name,
kid_phone=kid_phone,
)
# Make memory tools work for subsequent tool calls in the same message.
ctx["family_id"] = family_id
# Auto-set kid's payout destination if configured in env.
if KID_DEFAULT_PAYOUT_DESTINATION:
if not validate_payout_destination(
KID_DEFAULT_PAYOUT_DESTINATION,
chain=PAYMENT_DEFAULT_CHAIN,
):
set_payout_destination(kid_id, KID_DEFAULT_PAYOUT_DESTINATION.strip())
# Seed an initial family memory (no-op if Supermemory disabled).
from datetime import datetime, timezone
memory.fire_and_forget(
memory.remember(
family_id,
f"Family registered on {datetime.now(timezone.utc).date().isoformat()}: "
f"parent={parent_name}, kid={kid_name}.",
{"category": "school_info", "kid_name": kid_name, "source": "registration"},
)
)
try:
await send_message(
to_number=kid_phone,
body=(
f"Hey {kid_name}, your parent {parent_name} just set you up with "
f"Riley so they can help with school stuff like checking your grades. "
f"Is this you? Just let me know and you're all set."
),
)
except Exception as e:
log.exception("Failed to send verification text to kid")
return (
f"Family created (family_id={family_id}), but failed to send the "
f"verification text to {kid_phone}: {e}"
)
return (
f"Family registered (family_id={family_id}). Parent {parent_name} "
f"(id={parent_id}) auto-verified. Kid {kid_name} (id={kid_id}) is now "
f"pending verification — once {kid_name} confirms via text, they're all set."
)
async def _confirm_kid(*, kid_phone: str) -> str:
kid = get_user_by_phone(kid_phone)
if not kid:
return f"ERROR: no user found with phone {kid_phone}."
if kid["role"] != "kid":
return f"ERROR: {kid['name']} is registered as {kid['role']}, not kid."
if kid["onboarding_state"] == "verified":
return f"{kid['name']} is already verified."
set_onboarding_state(kid["id"], "verified")
parent = get_parent_for_kid(kid["id"])
if parent:
try:
await send_message(
to_number=parent["phone"],
body=(
f"{kid['name']} confirmed — you're all set! You can ask me things like "
f"\"how are {kid['name']}'s grades looking?\""
),
)
except Exception:
log.exception("Failed to notify parent of verification")
return f"{kid['name']} verified. Parent has been notified."
MAX_STEP_MESSAGES = 5
async def _dispatch_check_grades(sender_phone: str, student_name: str, ctx: dict) -> str:
"""Create cloud browser session, send live link, stream steps, return grades."""
live_sessions = ctx.get("live_sessions")
# 1. Create session + task — returns instantly with live_url + task_response.
# task_response is what stream_until_done needs to emit step updates;
# discarding it (as the previous code did) silently kills on_step.
task_id, session_id, live_url, task_response = await create_d2l_session(student_name)
# 2. Register for /live/{session_id} route
if live_sessions is not None and live_url:
live_sessions[session_id] = live_url
# 3. Build the live view link
live_view_url = f"{PUBLIC_URL}/live/{session_id}" if live_url else None
# 4. Send live link to parent immediately (best-effort, don't block on failure)
if live_view_url:
try:
await send_message(
to_number=sender_phone,
body=f"Checking now — watch live: {live_view_url}",
)
except Exception:
log.exception("Failed to send live link")
# 4b. Notify kid BEFORE grades load
parent = get_user_by_phone(sender_phone)
if parent:
kid = get_kid_for_parent(parent["id"])
if kid and kid["onboarding_state"] == "verified":
try:
await send_message(
to_number=kid["phone"],
body=f"heads up, {parent['name']} is checking your grades",
)
except Exception:
log.exception("Failed to notify kid about grades check")
# 5. Stream steps as iMessages — rewrite robotic browser-agent plans
# into natural first-person updates, and scrub credentials.
steps_sent = 0
_sensitive = {
v.lower()
for v in (
__import__("config").D2L_USERNAME,
__import__("config").D2L_PASSWORD,
)
if v
}
def _humanize_step(raw: str) -> str:
"""Turn browser-agent goal text into a natural status update."""
lower = raw.lower()
# Scrub anything with credentials
if any(secret in lower for secret in _sensitive):
return "Signing in now..."
# Rewrite common robotic patterns
if "login" in lower or "sign in" in lower or "password" in lower or "log in" in lower:
return "Signing in now..."
if "2fa" in lower or "two-factor" in lower or "duo" in lower or "authenticat" in lower:
return "Handling two-factor auth..."
if "stay signed in" in lower or "keep me signed" in lower:
return "Almost through login..."
if "navigate" in lower and "grade" in lower:
return "Heading to the grades page..."
if "grade" in lower and ("click" in lower or "open" in lower or "select" in lower):
return "Opening grades..."
if "scroll" in lower:
return "Looking through the page..."
if "course" in lower and ("click" in lower or "select" in lower or "switch" in lower):
return "Checking the next course..."
if "extract" in lower or "read" in lower or "copy" in lower:
return "Reading the grades..."
# Fallback: strip robotic language but keep it short
return raw[:80] if len(raw) <= 80 else raw[:77] + "..."
async def on_step(summary: str):
nonlocal steps_sent
if steps_sent >= MAX_STEP_MESSAGES:
return
summary = _humanize_step(summary)
if getattr(on_step, "last_summary", None) == summary:
return
on_step.last_summary = summary
try:
await send_message(to_number=sender_phone, body=summary)
steps_sent += 1
except Exception:
log.exception("Failed to send step update")
# 5. Block until done, streaming intermediate steps via on_step.
result = await stream_until_done(
task_id, task_response=task_response, on_step=on_step
)
# Fire-and-forget grade snapshot to memory (no-op if Supermemory disabled).
family_id = ctx.get("family_id")
if family_id is not None:
memory.fire_and_forget(
memory.snapshot_grades(family_id, student_name, result)
)
# Include live link in tool result so the LLM can mention it in its reply
if live_view_url:
return f"LIVE VIEW: {live_view_url}\n\n{result}"
return result
async def _send_message_to_kid(*, sender_phone: str, message: str) -> str:
if not message:
return "ERROR: message is empty."
parent = get_user_by_phone(sender_phone)
if not parent:
return "ERROR: sender is not registered."
if parent["role"] != "parent":
return "ERROR: only a parent can send messages to the kid."
if parent["onboarding_state"] != "verified":
return "ERROR: parent is not verified yet."
kid = get_kid_for_parent(parent["id"])
if not kid:
return "ERROR: no kid registered for this parent."
try:
await send_message(
to_number=kid["phone"],
body=f"From {parent['name']} via Riley: {message}",
)
except Exception as e:
log.exception("Failed to send message to kid")
return f"ERROR: couldn't send the message: {e}"
return f"Sent to {kid['name']}."
async def _send_message_to_parent(*, sender_phone: str, message: str) -> str:
if not message:
return "ERROR: message is empty."
kid = get_user_by_phone(sender_phone)
if not kid:
return "ERROR: sender is not registered."
if kid["role"] != "kid":
return "ERROR: only a kid can send messages to the parent."
if kid["onboarding_state"] != "verified":
return "ERROR: kid is not verified yet."
parent = get_parent_for_kid(kid["id"])
if not parent:
return "ERROR: no parent registered for this kid."
try:
await send_message(
to_number=parent["phone"],
body=f"From {kid['name']} via Riley: {message}",
)
except Exception as e:
log.exception("Failed to send message to parent")
return f"ERROR: couldn't send the message: {e}"
return f"Sent to {parent['name']}."
async def _get_browser_history(*, sender_phone: str, kid_name: str) -> str:
"""Return hardcoded fake browser history for demo."""
parent = get_user_by_phone(sender_phone)
if not parent or parent["role"] != "parent" or parent["onboarding_state"] != "verified":
return "ERROR: only a verified parent can check browser history."
kid = get_kid_for_parent(parent["id"])
if not kid:
return "ERROR: no kid registered for this parent."
# Notify kid BEFORE returning results
if kid["onboarding_state"] == "verified":
try:
await send_message(
to_number=kid["phone"],
body=f"heads up, {parent['name']} is looking at your browser history",
)
except Exception:
log.exception("Failed to notify kid about browser history check")
return (
f"browser history for {kid['name']} (last 3 days):\n"
f"- may 15, 2:34pm - learn.uwaterloo.ca (D2L course page, ~25 min)\n"
f"- may 15, 3:10pm - youtube.com (MrBeast 'Ages 1-100' video, ~18 min)\n"
f"- may 15, 4:45pm - discord.com (Discord chat, ~40 min)\n"
f"- may 16, 10:15am - learn.uwaterloo.ca (D2L assignment submission, ~15 min)\n"
f"- may 16, 11:02am - youtube.com (MrBeast '$1 vs $1,000,000 Hotel' video, ~22 min)\n"
f"- may 16, 11:58pm - explicit-content-site.com (adult content, ~8 min)\n"
f"- may 17, 9:00am - learn.uwaterloo.ca (D2L quiz page, ~30 min)\n"
f"- may 17, 10:15am - discord.com (Discord, ~20 min)"
)
async def _add_calendar_event(
*, sender_phone: str, event_name: str, date: str, time: str, reminder: str | None
) -> str:
"""Hardcoded calendar event creation for demo."""
parent = get_user_by_phone(sender_phone)
if not parent or parent["role"] != "parent" or parent["onboarding_state"] != "verified":
return "ERROR: only a verified parent can add calendar events."
kid = get_kid_for_parent(parent["id"])
if not kid:
return "ERROR: no kid registered for this parent."
# Notify kid about the new calendar event
if kid["onboarding_state"] == "verified":
try:
await send_message(
to_number=kid["phone"],
body=f"{parent['name']} added {event_name} to your calendar on {date} at {time}",
)
except Exception:
log.exception("Failed to notify kid about calendar event")
reminder_text = f" reminder set for {reminder}." if reminder else " reminder set for 1 hour before."
return (
f"added to {kid['name']}'s calendar: {event_name}, {date} at {time}.{reminder_text}"
)
def normalize_phone(phone: str) -> str:
"""Crude E.164 normalization for North American numbers."""
if not phone:
return phone
cleaned = "".join(c for c in phone if c.isdigit() or c == "+")
if cleaned.startswith("+"):
return cleaned
if len(cleaned) == 10:
return "+1" + cleaned
if len(cleaned) == 11 and cleaned.startswith("1"):
return "+" + cleaned
return cleaned