diff --git a/backend/app/api/bitchat_api.py b/backend/app/api/bitchat_api.py new file mode 100644 index 0000000..e30df00 --- /dev/null +++ b/backend/app/api/bitchat_api.py @@ -0,0 +1,269 @@ +"""BitChat & Decentralized Mesh Transport API. + +Endpoints: +- GET /api/network/mesh/peers — List discovered LAN peers & services +- POST /api/network/mesh/announce — Trigger local subnet UDP beacon announcement +- GET /api/network/bitchat/messages — Fetch recent chat history by channel +- POST /api/network/bitchat/send — Send chat message, alert Feed Activity Pod, broadcast +- POST /api/network/bitchat/save-to-binder — Save selected messages as markdown evidence into binder +- POST /api/network/bitchat/convert-to-task — Promote message to sovereign task +- GET /api/network/bitchat/ws — Real-time WebSocket conduit +""" +from __future__ import annotations + +import asyncio +import json +import logging +from typing import Any, Dict, Set + +import aiohttp +from aiohttp import web + +from app.services.bitchat_store import get_bitchat_store +from app.services.mesh_transport import get_mesh_discovery, get_mesh_registry + +log = logging.getLogger("ucore.bitchat_api") + +# Set of active WebSocket connections for real-time chat broadcasts +_active_websockets: Set[web.WebSocketResponse] = set() + + +async def broadcast_ws(event: Dict[str, Any]) -> None: + """Broadcast an event dictionary to all active WebSocket clients.""" + if not _active_websockets: + return + payload_str = json.dumps(event) + stale = [] + for ws in list(_active_websockets): + try: + if not ws.closed: + await ws.send_str(payload_str) + else: + stale.append(ws) + except Exception as exc: + log.debug("WebSocket broadcast error: %s", exc) + stale.append(ws) + + for ws in stale: + _active_websockets.discard(ws) + + +async def handle_get_peers(request: web.Request) -> web.Response: + """GET /api/network/mesh/peers — Get discovered mesh peers.""" + service_type = request.query.get("service_type") + include_stale = request.query.get("include_stale", "false").lower() in ("true", "1") + registry = get_mesh_registry() + peers = registry.get_peers(service_type=service_type, include_stale=include_stale) + return web.json_response({ + "status": "ok", + "local_peer_id": registry.local_peer_id, + "local_node_name": registry.local_node_name, + "peers": peers, + }) + + +async def handle_mesh_announce(request: web.Request) -> web.Response: + """POST /api/network/mesh/announce — Send broadcast beacon to local subnet.""" + discovery = get_mesh_discovery() + success = discovery.send_broadcast_beacon() + return web.json_response({ + "status": "ok", + "announced": success, + "peer_id": discovery.registry.local_peer_id, + "node_name": discovery.registry.local_node_name, + }) + + +async def handle_get_messages(request: web.Request) -> web.Response: + """GET /api/network/bitchat/messages — Retrieve messages for a channel.""" + channel = request.query.get("channel", "#general") + limit_str = request.query.get("limit", "50") + since = request.query.get("since") + try: + limit = int(limit_str) + except ValueError: + limit = 50 + + store = get_bitchat_store() + messages = store.get_messages(channel=channel, limit=limit, since=since) + return web.json_response({ + "status": "ok", + "channel": channel, + "messages": messages, + }) + + +async def handle_send_message(request: web.Request) -> web.Response: + """POST /api/network/bitchat/send — Record message and broadcast to peers.""" + try: + data = await request.json() + except Exception: + return web.json_response({"error": "Invalid JSON body"}, status=400) + + sender_name = data.get("sender_name", "").strip() or "Anonymous" + content = data.get("content", "").strip() + if not content: + return web.json_response({"error": "Content cannot be empty"}, status=400) + + channel = data.get("channel", "#general").strip() or "#general" + sender_peer_id = data.get("sender_peer_id") + recipient_id = data.get("recipient_id") + metadata = data.get("metadata", {}) + + store = get_bitchat_store() + record = store.send_message( + sender_name=sender_name, + content=content, + channel=channel, + sender_peer_id=sender_peer_id, + recipient_id=recipient_id, + metadata=metadata, + ) + + # Real-time WebSocket broadcast + await broadcast_ws({ + "type": "chat_message", + "channel": channel, + "message": record, + }) + + return web.json_response({ + "status": "ok", + "message": record, + }) + + +async def handle_save_to_binder(request: web.Request) -> web.Response: + """POST /api/network/bitchat/save-to-binder — Save messages as markdown evidence.""" + try: + data = await request.json() + except Exception: + return web.json_response({"error": "Invalid JSON body"}, status=400) + + msg_ids = data.get("msg_ids") + if not msg_ids or not isinstance(msg_ids, list): + return web.json_response({"error": "msg_ids list is required"}, status=400) + + binder_name = data.get("binder", "").strip() or "Sandbox" + title = data.get("title", "").strip() or "Discussion Evidence" + + store = get_bitchat_store() + result = store.save_to_binder( + msg_ids=msg_ids, + binder_name=binder_name, + title=title, + ) + + if not result.get("ok"): + return web.json_response(result, status=400) + + return web.json_response(result) + + +async def handle_convert_to_task(request: web.Request) -> web.Response: + """POST /api/network/bitchat/convert-to-task — Promote message to sovereign task.""" + try: + data = await request.json() + except Exception: + return web.json_response({"error": "Invalid JSON body"}, status=400) + + msg_id = data.get("msg_id", "").strip() + if not msg_id: + return web.json_response({"error": "msg_id is required"}, status=400) + + board = str(data.get("board") or "inbox") + priority = str(data.get("priority") or "medium") + binder = str(data.get("binder") or "Sandbox") + due_date = data.get("due_date") + sync_apple_reminders = bool(data.get("sync_apple_reminders", False)) + + store = get_bitchat_store() + result = store.convert_message_to_task( + msg_id=msg_id, + board=board, + priority=priority, + binder=binder, + due_date=due_date, + sync_apple_reminders=sync_apple_reminders, + ) + + if not result.get("ok"): + return web.json_response(result, status=404 if "not found" in result.get("error", "").lower() else 400) + + return web.json_response(result) + + +async def handle_bitchat_ws(request: web.Request) -> web.WebSocketResponse: + """GET /api/network/bitchat/ws — WebSocket connection for real-time BitChat.""" + ws = web.WebSocketResponse() + await ws.prepare(request) + + _active_websockets.add(ws) + log.info("BitChat WebSocket connected (total: %d)", len(_active_websockets)) + + # Send initial welcome / handshake + registry = get_mesh_registry() + await ws.send_str(json.dumps({ + "type": "handshake", + "peer_id": registry.local_peer_id, + "node_name": registry.local_node_name, + "timestamp": asyncio.get_event_loop().time(), + })) + + store = get_bitchat_store() + + try: + async for msg in ws: + if msg.type == aiohttp.WSMsgType.TEXT: + try: + payload = json.loads(msg.data) + except Exception: + continue + + msg_type = payload.get("type") + if msg_type == "ping": + await ws.send_str(json.dumps({"type": "pong"})) + elif msg_type in ("chat_message", "send_message"): + content = str(payload.get("content") or "").strip() + if content: + sender_name = str(payload.get("sender_name") or registry.local_node_name) + channel = str(payload.get("channel") or "#general") + record = store.send_message( + sender_name=sender_name, + content=content, + channel=channel, + sender_peer_id=registry.local_peer_id, + recipient_id=payload.get("recipient_id"), + metadata=payload.get("metadata") or {}, + ) + await broadcast_ws({ + "type": "chat_message", + "channel": channel, + "message": record, + }) + elif msg_type == "announce": + discovery = get_mesh_discovery() + discovery.send_broadcast_beacon() + peers = registry.get_peers() + await ws.send_str(json.dumps({ + "type": "peer_list", + "peers": peers, + })) + elif msg.type in (aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSE): + break + finally: + _active_websockets.discard(ws) + log.info("BitChat WebSocket disconnected (remaining: %d)", len(_active_websockets)) + + return ws + + +def register_bitchat_routes(app: web.Application) -> None: + """Register BitChat & Mesh discovery routes.""" + app.router.add_get("/api/network/mesh/peers", handle_get_peers) + app.router.add_post("/api/network/mesh/announce", handle_mesh_announce) + app.router.add_get("/api/network/bitchat/messages", handle_get_messages) + app.router.add_post("/api/network/bitchat/send", handle_send_message) + app.router.add_post("/api/network/bitchat/save-to-binder", handle_save_to_binder) + app.router.add_post("/api/network/bitchat/convert-to-task", handle_convert_to_task) + app.router.add_get("/api/network/bitchat/ws", handle_bitchat_ws) diff --git a/backend/app/api/dispatch_api.py b/backend/app/api/dispatch_api.py new file mode 100644 index 0000000..c7c7412 --- /dev/null +++ b/backend/app/api/dispatch_api.py @@ -0,0 +1,363 @@ +"""Dispatch API — Sovereign communication, interactive story tokens, and device calculation. + +POST /api/dispatch/create — create a dispatch and stage an ephemeral token +GET /api/dispatch/token/{token} — load story document for guest token +POST /api/dispatch/rsvp/{token} — submit guest RSVP / responses +GET /api/dispatch/preview-email/{id} — render bulletproof single-column Prose HTML email +GET /api/dispatch/responses/{id} — get collected responses for host +POST /api/dispatch/calculate-display — calculate grid & prose capabilities for a viewport +POST /api/dispatch/calculate-storage — calculate vault knowledge & capsule capacity +GET /api/dispatch/asset/{id}/{file} — serve preserved original asset +""" +from __future__ import annotations + +import base64 +import logging +from pathlib import Path + +from aiohttp import web + +from app.services.device_calc import calc_display_capability, calc_storage_capacity +from app.services.dispatch_store import dispatch_store +from app.services.email_dispatch import compile_prose_email + +log = logging.getLogger("ucore.dispatch_api") + + +async def handle_create_dispatch(request: web.Request) -> web.Response: + try: + data = await request.json() + except Exception: + return web.json_response({"error": "Invalid JSON body"}, status=400) + + title = data.get("title", "").strip() + if not title: + return web.json_response({"error": "Title is required"}, status=400) + + story_md = data.get("story_markdown", "").strip() + lead_text = data.get("lead_text", "").strip() + burn_after_read = bool(data.get("burn_after_read", False)) + expires_in = data.get("expires_in_seconds") + mode_default = data.get("mode_default", "card") + binder_id = data.get("binder_id") + hero_gif_name = data.get("hero_gif_name") + + hero_gif_bytes = None + if data.get("hero_gif_base64"): + try: + hero_gif_bytes = base64.b64decode(data["hero_gif_base64"]) + except Exception as exc: + log.warning("Invalid base64 in hero_gif: %s", exc) + + manifest = dispatch_store.create_dispatch( + title=title, + story_markdown=story_md, + lead_text=lead_text, + hero_gif_bytes=hero_gif_bytes, + hero_gif_name=hero_gif_name, + expires_in_seconds=int(expires_in) if expires_in else None, + burn_after_read=burn_after_read, + mode_default=mode_default, + binder_id=binder_id, + ) + + return web.json_response({ + "status": "ok", + "dispatch": manifest, + "token": manifest["token"], + "story_url": f"/p/{manifest['token']}", + }) + + +async def handle_get_by_token(request: web.Request) -> web.Response: + token = request.match_info.get("token", "").strip() + if not token: + return web.json_response({"error": "Token is required"}, status=400) + + record = dispatch_store.get_dispatch_by_token(token) + if record.get("status") == "not_found": + return web.json_response(record, status=404) + + return web.json_response(record) + + +async def handle_submit_rsvp(request: web.Request) -> web.Response: + token = request.match_info.get("token", "").strip() + if not token: + return web.json_response({"error": "Token is required"}, status=400) + + try: + payload = await request.json() + except Exception: + return web.json_response({"error": "Invalid JSON body"}, status=400) + + result = dispatch_store.submit_rsvp(token, payload) + if result.get("status") == "not_found": + return web.json_response(result, status=404) + if result.get("status") in ("expired", "burned"): + return web.json_response(result, status=410) + + return web.json_response(result) + + +async def handle_preview_email(request: web.Request) -> web.Response: + dispatch_id = request.match_info.get("id", "").strip() + dispatch_dir = dispatch_store.root_dir / dispatch_id + manifest_path = dispatch_dir / "dispatch.json" + + if not manifest_path.exists(): + return web.Response(text="Dispatch not found", status=404) + + import json + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + story_path = dispatch_dir / "originals" / "source.story.md" + story_md = story_path.read_text(encoding="utf-8") if story_path.exists() else "" + + token = manifest.get("token", "") + action_url = f"/p/{token}" + hero_src = manifest.get("hero_asset") + + html_email = compile_prose_email( + title=manifest.get("title", "Sovereign Dispatch"), + lead_text=manifest.get("lead_text", ""), + body_html=f"

{story_md[:400]}...

" if len(story_md) > 400 else f"

{story_md}

", + action_label="Open Interactive Story", + action_url=action_url, + hero_gif_src=hero_src, + theme="dark", + ) + + return web.Response(text=html_email, content_type="text/html") + + +async def handle_get_responses(request: web.Request) -> web.Response: + dispatch_id = request.match_info.get("id", "").strip() + responses = dispatch_store.get_responses(dispatch_id) + return web.json_response({"id": dispatch_id, "responses": responses}) + + +async def handle_calculate_display(request: web.Request) -> web.Response: + try: + data = await request.json() + except Exception: + return web.json_response({"error": "Invalid JSON body"}, status=400) + + w = int(data.get("width", 1920)) + h = int(data.get("height", 1080)) + dev_type = data.get("device_type") + res = calc_display_capability(w, h, dev_type) + return web.json_response(res) + + +async def handle_calculate_storage(request: web.Request) -> web.Response: + try: + data = await request.json() + except Exception: + return web.json_response({"error": "Invalid JSON body"}, status=400) + + total_bytes = int(data.get("total_bytes", 4_000_000_000)) + sys_bytes = data.get("sys_bytes") + res = calc_storage_capacity(total_bytes, int(sys_bytes) if sys_bytes is not None else None) + return web.json_response(res) + + +async def handle_serve_asset(request: web.Request) -> web.Response: + dispatch_id = request.match_info.get("id", "").strip() + filename = request.match_info.get("file", "").strip() + + # Prevent traversal + if ".." in dispatch_id or ".." in filename or "/" in filename or "\\" in filename: + return web.Response(text="Forbidden", status=403) + + target_file = dispatch_store.root_dir / dispatch_id / "originals" / filename + if not target_file.exists() or not target_file.is_file(): + return web.Response(text="Asset not found", status=404) + + content_type = "image/gif" if filename.endswith(".gif") else "application/octet-stream" + return web.Response(body=target_file.read_bytes(), content_type=content_type) + + +async def handle_get_catalog(request: web.Request) -> web.Response: + catalog = dispatch_store.load_catalog() + return web.json_response(catalog) + + +async def handle_get_by_binder(request: web.Request) -> web.Response: + binder_id = request.match_info.get("binder_id", "").strip() + dispatches = dispatch_store.get_dispatches_by_binder(binder_id) + return web.json_response({"binder_id": binder_id, "dispatches": dispatches}) + + +async def handle_serve_catalog_asset(request: web.Request) -> web.Response: + filename = request.match_info.get("file", "").strip() + if ".." in filename or "/" in filename or "\\" in filename: + return web.Response(text="Forbidden", status=403) + + from app.core.settings import settings + target_file = settings.public_vault_root / "global-knowledge" / "elements" / filename + if not target_file.exists() or not target_file.is_file(): + # Fallback 1x1 transparent gif if asset not present + transparent_gif_1x1 = b"GIF89a\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00\xff\xff\xff!\xf9\x04\x01\x00\x00\x00\x00,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;" + return web.Response(body=transparent_gif_1x1, content_type="image/gif") + + content_type = "image/gif" if filename.endswith(".gif") else "image/svg+xml" + return web.Response(body=target_file.read_bytes(), content_type=content_type) + + +async def handle_get_feed_rss(request: web.Request) -> web.Response: + host_url = f"{request.scheme}://{request.host}" + from app.services.dispatch_feed import dispatch_feed_service + rss_xml = dispatch_feed_service.generate_rss_2_0(host_url) + return web.Response(text=rss_xml, content_type="application/rss+xml") + + +async def handle_get_feed_json(request: web.Request) -> web.Response: + host_url = f"{request.scheme}://{request.host}" + from app.services.dispatch_feed import dispatch_feed_service + json_feed = dispatch_feed_service.generate_json_feed(host_url) + return web.json_response(json_feed) + + +async def handle_get_motifs(request: web.Request) -> web.Response: + try: + from app.services.uvector_bob import MOTIF_PRESETS + return web.json_response({"status": "ok", "motifs": MOTIF_PRESETS}) + except Exception: + import sys + uvector_py = Path(__file__).resolve().parents[4] / "uVector" / "python" + if uvector_py.exists() and str(uvector_py) not in sys.path: + sys.path.insert(0, str(uvector_py)) + try: + from uvector_bob import MOTIF_PRESETS + return web.json_response({"status": "ok", "motifs": MOTIF_PRESETS}) + except Exception as exc: + log.warning("Could not load uVector motifs: %s", exc) + return web.json_response({"status": "ok", "motifs": {}}) + + +async def handle_generate_bob(request: web.Request) -> web.Response: + try: + data = await request.json() + except Exception: + return web.json_response({"error": "Invalid JSON"}, status=400) + + motif = data.get("motif", "zen_envelope") + palette_id = data.get("palette_id", "teletext_ceefax") + steps = int(data.get("steps", 4)) + delay_ms = int(data.get("delay_ms", 100)) + + try: + from app.services.uvector_bob import compile_svg_to_bob, compile_svgs_to_gif, generate_vector_motif + except Exception: + import sys + uvector_py = Path(__file__).resolve().parents[4] / "uVector" / "python" + if uvector_py.exists() and str(uvector_py) not in sys.path: + sys.path.insert(0, str(uvector_py)) + from uvector_bob import compile_svg_to_bob, compile_svgs_to_gif, generate_vector_motif + + try: + frames = generate_vector_motif(motif, palette_id=palette_id, steps=steps) + gif_bytes = compile_svgs_to_gif(frames, delay_ms=delay_ms, palette_id=palette_id) + bob_def = compile_svg_to_bob(frames[0], id=f"bob_{motif}", name=f"{motif.title()} BOB", palette_id=palette_id) + + from app.core.settings import settings + asset_name = f"bob-{motif}-{palette_id}.gif" + elements_dir = settings.public_vault_root / "global-knowledge" / "elements" + elements_dir.mkdir(parents=True, exist_ok=True) + (elements_dir / asset_name).write_bytes(gif_bytes) + + return web.json_response({ + "status": "ok", + "asset_name": asset_name, + "asset_url": f"/api/dispatch/catalog/asset/{asset_name}", + "gif_size_bytes": len(gif_bytes), + "bob_definition": bob_def, + }) + except Exception as exc: + log.error("Failed to generate BOB: %s", exc) + return web.json_response({"error": str(exc)}, status=500) + + +async def handle_ingest_inbound(request: web.Request) -> web.Response: + try: + data = await request.json() + except Exception: + return web.json_response({"error": "Invalid JSON"}, status=400) + + token = data.get("token") + if not token: + return web.json_response({"error": "Token is required in response payload"}, status=400) + + res = dispatch_store.submit_rsvp(token, data) + return web.json_response(res) + + +async def handle_export_offline(request: web.Request) -> web.Response: + dispatch_id = request.match_info.get("id", "").strip() + from app.services.dispatch_offline import export_offline_bundle + try: + result = export_offline_bundle(dispatch_id) + return web.json_response(result) + except Exception as exc: + log.error("Offline export failed for %s: %s", dispatch_id, exc) + return web.json_response({"error": str(exc)}, status=400) + + +async def handle_convert_rsvp_task(request: web.Request) -> web.Response: + """POST /api/dispatch/{id}/rsvp/{index}/task — Convert RSVP submission into a task.""" + dispatch_id = request.match_info.get("id", "").strip() + index_str = request.match_info.get("index", "0").strip() + try: + rsvp_index = int(index_str) + except ValueError: + return web.json_response({"error": "Index must be an integer"}, status=400) + + try: + body = await request.json() if request.body_exists else {} + except Exception: + body = {} + + board = str(body.get("board") or "inbox") + priority = str(body.get("priority") or "medium") + binder = body.get("binder") + mission = body.get("mission") + due_date = body.get("due_date") + sync_apple_reminders = bool(body.get("sync_apple_reminders", False)) + + res = dispatch_store.convert_rsvp_to_task( + dispatch_id=dispatch_id, + rsvp_index=rsvp_index, + board=board, + priority=priority, + binder=binder, + mission=mission, + due_date=due_date, + sync_apple_reminders=sync_apple_reminders, + ) + if res.get("status") == "not_found": + return web.json_response(res, status=404) + return web.json_response(res) + + +def register_dispatch_routes(app: web.Application) -> None: + """Register all dispatch, feed, and device capability routes.""" + app.router.add_post("/api/dispatch/create", handle_create_dispatch) + app.router.add_get("/api/dispatch/token/{token}", handle_get_by_token) + app.router.add_post("/api/dispatch/rsvp/{token}", handle_submit_rsvp) + app.router.add_get("/api/dispatch/preview-email/{id}", handle_preview_email) + app.router.add_get("/api/dispatch/responses/{id}", handle_get_responses) + app.router.add_post("/api/dispatch/calculate-display", handle_calculate_display) + app.router.add_post("/api/dispatch/calculate-storage", handle_calculate_storage) + app.router.add_get("/api/dispatch/asset/{id}/{file}", handle_serve_asset) + app.router.add_get("/api/dispatch/catalog", handle_get_catalog) + app.router.add_get("/api/dispatch/catalog/asset/{file}", handle_serve_catalog_asset) + app.router.add_get("/api/dispatch/by-binder/{binder_id}", handle_get_by_binder) + app.router.add_get("/api/dispatch/feed.xml", handle_get_feed_rss) + app.router.add_get("/api/dispatch/feed.json", handle_get_feed_json) + app.router.add_get("/api/dispatch/motifs", handle_get_motifs) + app.router.add_post("/api/dispatch/generate-bob", handle_generate_bob) + app.router.add_post("/api/dispatch/inbox/ingest", handle_ingest_inbound) + app.router.add_post("/api/dispatch/export-offline/{id}", handle_export_offline) + app.router.add_post("/api/dispatch/{id}/rsvp/{index}/task", handle_convert_rsvp_task) + + diff --git a/backend/app/api/feed_api.py b/backend/app/api/feed_api.py index 09e2928..6a80f56 100644 --- a/backend/app/api/feed_api.py +++ b/backend/app/api/feed_api.py @@ -164,6 +164,10 @@ async def handle_feed_promote(request: web.Request) -> web.Response: board=str(body.get("board") or "inbox"), priority=str(body.get("priority") or "medium"), binder=str(body.get("binder") or "Sandbox"), + mission=body.get("mission"), + due_date=body.get("due_date") or body.get("due"), + sync_apple_reminders=bool(body.get("sync_apple_reminders", False)), + archive_source_mail=bool(body.get("archive_source_mail", False)), ) await server.link_task_to_activity(task_id=promoted["task_id"], activity_id=activity_id) return web.json_response({"ok": True, **promoted}) @@ -202,9 +206,29 @@ async def handle_feed_rules_apply(request: web.Request) -> web.Response: for proposal in proposals: if proposal["activity_id"] in handled: continue - if proposal["action"] not in {"propose-task", "create-task"}: + + if proposal["action"] in {"auto-archive", "resolve"}: + res = server.resolve_activity( + proposal["activity_id"], note=f"Auto-resolved by rule {proposal['rule_id']}" + ) + if proposal.get("archive_mail") and by_id[proposal["activity_id"]].get("source") == "mail": + ext_id = by_id[proposal["activity_id"]].get("external_id") + if ext_id: + try: + from app.services.apple_feed_sync import AppleFeedSync + + AppleFeedSync().archive_email(ext_id) + except Exception: + pass + created.append({**proposal, "resolved": True}) + handled.add(proposal["activity_id"]) + continue + + if proposal["action"] not in {"propose-task", "create-task", "sync-apple-reminders"}: continue + activity = by_id[proposal["activity_id"]] + sync_rem = proposal["action"] == "sync-apple-reminders" or bool(proposal.get("sync_reminders", False)) promoted = promote_activity_to_task( activity, title=proposal["title"], @@ -212,6 +236,8 @@ async def handle_feed_rules_apply(request: web.Request) -> web.Response: priority=proposal["priority"], binder=proposal["binder"], rule_id=proposal["rule_id"], + sync_apple_reminders=sync_rem, + archive_source_mail=bool(proposal.get("archive_mail", False)), ) await server.link_task_to_activity( task_id=promoted["task_id"], activity_id=proposal["activity_id"], link_type="rule" @@ -247,6 +273,102 @@ async def handle_feed_runtime(request: web.Request) -> web.Response: ) +async def handle_feed_resolve(request: web.Request) -> web.Response: + """POST /api/feed/resolve/{id} — Resolve/archive a feed activity.""" + activity_id_str = request.match_info.get("id", "").strip() + try: + activity_id = int(activity_id_str) + except ValueError: + return web.json_response({"error": "id must be an integer"}, status=400) + + try: + body = await request.json() if request.body_exists else {} + except Exception: + body = {} + + note = str(body.get("note") or "") + archived = bool(body.get("archived", True)) + archive_mail = bool(body.get("archive_mail", False)) + + server = _get_feed_server() + res = server.resolve_activity(activity_id=activity_id, note=note, archived=archived) + if not res.get("ok"): + return web.json_response(res, status=404) + + if archive_mail: + rows = await server.query_feed(limit=100) + item = next((r for r in rows if int(r["id"]) == activity_id), None) + if item and item.get("source") == "mail" and item.get("external_id"): + try: + from app.services.apple_feed_sync import AppleFeedSync + + AppleFeedSync().archive_email(item["external_id"]) + except Exception: + pass + + return web.json_response(res) + + +async def handle_feed_bulk_resolve(request: web.Request) -> web.Response: + """POST /api/feed/bulk-resolve — Bulk resolve multiple activities.""" + try: + body = await request.json() if request.body_exists else {} + except Exception: + return web.json_response({"error": "Invalid JSON"}, status=400) + + activity_ids = body.get("activity_ids") or [] + note = str(body.get("note") or "") + if not isinstance(activity_ids, list): + return web.json_response({"error": "activity_ids must be a list"}, status=400) + + server = _get_feed_server() + res = server.bulk_resolve(activity_ids=[int(aid) for aid in activity_ids], note=note) + return web.json_response(res) + + +async def handle_feed_export_reminders(request: web.Request) -> web.Response: + """POST /api/feed/export/reminders — Export an item or activity to Apple Reminders.""" + try: + body = await request.json() if request.body_exists else {} + except Exception: + return web.json_response({"error": "Invalid JSON"}, status=400) + + title = body.get("title", "").strip() + if not title: + return web.json_response({"error": "title is required"}, status=400) + + notes = body.get("notes", "") + list_name = body.get("list_name") or body.get("binder") or "uDos" + due_date = body.get("due_date") or body.get("due") + + from app.services.apple_feed_sync import AppleFeedSync + + sync_svc = AppleFeedSync(_get_feed_server()) + res = sync_svc.export_reminder(title=title, notes=notes, list_name=list_name, due_date=due_date) + return web.json_response(res, status=200 if res.get("ok") else 500) + + +async def handle_feed_export_notes(request: web.Request) -> web.Response: + """POST /api/feed/export/notes — Export an item to Apple Notes.""" + try: + body = await request.json() if request.body_exists else {} + except Exception: + return web.json_response({"error": "Invalid JSON"}, status=400) + + title = body.get("title", "").strip() + if not title: + return web.json_response({"error": "title is required"}, status=400) + + body_text = body.get("body", "") + folder = body.get("folder") or "uDos" + + from app.services.apple_feed_sync import AppleFeedSync + + sync_svc = AppleFeedSync(_get_feed_server()) + res = sync_svc.export_note(title=title, body_markdown=body_text, folder=folder) + return web.json_response(res, status=200 if res.get("ok") else 500) + + def register_feed_routes(app: web.Application) -> None: """Register Feed API routes.""" app.router.add_post("/api/feed/ingest", handle_feed_ingest) @@ -259,7 +381,11 @@ def register_feed_routes(app: web.Application) -> None: app.router.add_get("/api/feed/runtime", handle_feed_runtime) app.router.add_get("/api/feed/sources", handle_feed_sources) app.router.add_post("/api/feed/sources/{source}/sync", handle_feed_source_sync) - log.info("Feed API routes registered: ingest, query, suggest, link") + app.router.add_post("/api/feed/resolve/{id}", handle_feed_resolve) + app.router.add_post("/api/feed/bulk-resolve", handle_feed_bulk_resolve) + app.router.add_post("/api/feed/export/reminders", handle_feed_export_reminders) + app.router.add_post("/api/feed/export/notes", handle_feed_export_notes) + log.info("Feed API routes registered: ingest, query, suggest, link, promote, rules, resolve, export") async def handle_feed_sources(request: web.Request) -> web.Response: diff --git a/backend/app/api/host_api.py b/backend/app/api/host_api.py index f3f9577..14a5645 100644 --- a/backend/app/api/host_api.py +++ b/backend/app/api/host_api.py @@ -195,6 +195,63 @@ async def handle_host_say(request: web.Request) -> web.Response: return web.json_response(res, status=200 if res.get("ok") else 500) +async def handle_host_shortcuts(request: web.Request) -> web.Response: + """GET /api/host/shortcuts — List available host shortcuts.""" + svc = get_host_pim_service() + shortcuts = svc.list_shortcuts() + return web.json_response({"ok": True, "shortcuts": shortcuts, "count": len(shortcuts)}) + + +async def handle_host_shortcuts_run(request: web.Request) -> web.Response: + """POST /api/host/shortcuts/run — Execute a host shortcut.""" + try: + body = await request.json() + except Exception: + return web.json_response({"ok": False, "error": "Invalid JSON body"}, status=400) + + name = body.get("name", "").strip() + if not name: + return web.json_response({"ok": False, "error": "Shortcut name is required"}, status=400) + + input_text = body.get("input", "") + svc = get_host_pim_service() + res = svc.run_shortcut(name=name, input_text=input_text) + return web.json_response(res, status=200 if res.get("ok") else 500) + + +async def handle_mail_archive(request: web.Request) -> web.Response: + """POST /api/host/mail/archive — Archive an email in Apple Mail.""" + try: + body = await request.json() + except Exception: + return web.json_response({"ok": False, "error": "Invalid JSON body"}, status=400) + + message_id = body.get("message_id", "").strip() + if not message_id: + return web.json_response({"ok": False, "error": "message_id is required"}, status=400) + + svc = get_host_pim_service() + res = svc.archive_apple_mail(message_id=message_id) + return web.json_response(res, status=200 if res.get("ok") else 500) + + +async def handle_mail_flag(request: web.Request) -> web.Response: + """POST /api/host/mail/flag — Flag an email in Apple Mail.""" + try: + body = await request.json() + except Exception: + return web.json_response({"ok": False, "error": "Invalid JSON body"}, status=400) + + message_id = body.get("message_id", "").strip() + flag_index = int(body.get("flag_index", 0)) + if not message_id: + return web.json_response({"ok": False, "error": "message_id is required"}, status=400) + + svc = get_host_pim_service() + res = svc.flag_apple_mail(message_id=message_id, flag_index=flag_index) + return web.json_response(res, status=200 if res.get("ok") else 500) + + def register_host_routes(app: web.Application) -> None: """Register all host PIM and automation routes.""" app.router.add_get("/api/host/capabilities", handle_host_capabilities) @@ -206,5 +263,9 @@ def register_host_routes(app: web.Application) -> None: app.router.add_get("/api/host/reminders/intake", handle_reminders_intake) app.router.add_post("/api/host/notify", handle_host_notify) app.router.add_post("/api/host/say", handle_host_say) - log.info("Host PIM routes registered: capabilities, safari, notes, reminders, notify, say") + app.router.add_get("/api/host/shortcuts", handle_host_shortcuts) + app.router.add_post("/api/host/shortcuts/run", handle_host_shortcuts_run) + app.router.add_post("/api/host/mail/archive", handle_mail_archive) + app.router.add_post("/api/host/mail/flag", handle_mail_flag) + log.info("Host PIM routes registered: capabilities, safari, notes, reminders, notify, say, shortcuts, mail") diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index 2a3c9d0..d54a82b 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -656,3 +656,21 @@ def register_routes(app: web.Application) -> None: log.debug("uVector API routes registered") except ImportError as e: log.debug("uVector API routes not available: %s", e) + + # ── Sovereign Dispatch & Device Capability API ──────────────────── + try: + from .dispatch_api import register_dispatch_routes + + register_dispatch_routes(app) + log.debug("Dispatch API routes registered") + except ImportError as e: + log.debug("Dispatch API routes not available: %s", e) + + # ── BitChat & Decentralized Mesh Transport API ─────────────────── + try: + from .bitchat_api import register_bitchat_routes + + register_bitchat_routes(app) + log.debug("BitChat & Mesh Transport API routes registered") + except ImportError as e: + log.debug("BitChat API routes not available: %s", e) diff --git a/backend/app/api/tasker_api.py b/backend/app/api/tasker_api.py index 1ac9bcb..39ed418 100644 --- a/backend/app/api/tasker_api.py +++ b/backend/app/api/tasker_api.py @@ -502,8 +502,47 @@ async def handle_update_task(request: web.Request) -> web.Response: }) +async def handle_execute_task_action(request: web.Request) -> web.Response: + """POST /api/workflow/tasks/{task_id}/execute-action — Execute approved automation.""" + task_id = request.match_info.get("task_id", "").strip() + if not task_id: + return web.json_response({"error": "task_id required"}, status=400) + + try: + body = await request.json() if request.body_exists else {} + except Exception: + return web.json_response({"error": "Invalid JSON"}, status=400) + + action_type = body.get("action_type", "shortcut") + payload = body.get("payload") or body + + from app.services.task_action_bridge import TaskActionBridge + + bridge = TaskActionBridge() + result = bridge.execute_action(task_id=task_id, action_type=action_type, payload=payload) + return web.json_response(result, status=200 if result.get("ok") else 500) + + +async def handle_sync_task_reminders(request: web.Request) -> web.Response: + """POST /api/workflow/tasks/{task_id}/sync-reminders — Push task to Apple Reminders.""" + task_id = request.match_info.get("task_id", "").strip() + if not task_id: + return web.json_response({"error": "task_id required"}, status=400) + + try: + body = await request.json() if request.body_exists else {} + except Exception: + body = {} + + from app.services.task_action_bridge import TaskActionBridge + + bridge = TaskActionBridge() + result = bridge.execute_action(task_id=task_id, action_type="apple_reminders", payload=body) + return web.json_response(result, status=200 if result.get("ok") else 500) + + def register_tasker_routes(app: web.Application) -> None: - """Register tasker API routes under /api/developer/tasker/.""" + """Register tasker API routes under /api/developer/tasker/ and /api/workflow/.""" app.router.add_get("/api/developer/tasker/boards", handle_list_boards) app.router.add_get( "/api/developer/tasker/board/{board_name}", @@ -516,3 +555,5 @@ def register_tasker_routes(app: web.Application) -> None: ) app.router.add_get("/api/developer/tasker/summary", handle_tasker_summary) app.router.add_patch("/api/workflow/tasks/{task_id}", handle_update_task) + app.router.add_post("/api/workflow/tasks/{task_id}/execute-action", handle_execute_task_action) + app.router.add_post("/api/workflow/tasks/{task_id}/sync-reminders", handle_sync_task_reminders) diff --git a/backend/app/services/apple_feed_sync.py b/backend/app/services/apple_feed_sync.py index cadc42a..3a0c1e0 100644 --- a/backend/app/services/apple_feed_sync.py +++ b/backend/app/services/apple_feed_sync.py @@ -29,6 +29,8 @@ class AppleSource: AppleSource("mail", "Apple Mail", "Mail"), AppleSource("calendar", "Apple Calendar", "Calendar"), AppleSource("messages", "Apple Messages", "Messages"), + AppleSource("reminders", "Apple Reminders", "Reminders"), + AppleSource("notes", "Apple Notes (Scratchpad)", "Notes"), AppleSource("contacts-notes-rules", "Contacts · Notes · Rules", "Contacts"), AppleSource( "whatsapp", @@ -42,13 +44,60 @@ class AppleSource: SCRIPT_BY_SOURCE = { "mail": '''const app = Application("Mail"); const rows = []; const items = app.inbox.messages(); -for (const m of items) { if (!m.readStatus()) rows.push({external_id:String(m.messageId()), title:String(m.subject()), content:String(m.content()), contact_name:String(m.sender())}); if (rows.length >= __LIMIT__) break; } +for (const m of items) { + if (!m.readStatus()) { + let flagged = false; let flagIdx = -1; + try { flagged = Boolean(m.flaggedStatus()); flagIdx = flagged ? m.flagIndex() : -1; } catch(e){} + rows.push({ + external_id: String(m.messageId()), + title: String(m.subject()), + content: String(m.content()), + contact_name: String(m.sender()), + is_flagged: flagged, + flag_index: flagIdx + }); + } + if (rows.length >= __LIMIT__) break; +} JSON.stringify(rows);''', "calendar": '''const app = Application("Calendar"); const rows = []; const cutoff = new Date(Date.now() - 7 * 86400000); for (const calendar of app.calendars()) { for (const e of calendar.events()) { const start=e.startDate(); if (start >= cutoff) rows.push({external_id:String(e.uid()), title:String(e.summary()), content:String(e.description() || ""), timestamp:start.toISOString()}); if (rows.length >= __LIMIT__) break; } if (rows.length >= __LIMIT__) break; } JSON.stringify(rows);''', "messages": '''const app = Application("Messages"); const rows = []; for (const chat of app.chats()) { const name=String(chat.name() || chat.id()); rows.push({external_id:String(chat.id()), title:name, content:"Conversation available in Messages", contact_name:name}); if (rows.length >= __LIMIT__) break; } +JSON.stringify(rows);''', + "reminders": '''const app = Application("Reminders"); const rows = []; +for (const list of app.lists()) { + for (const r of list.reminders.whose({completed: false})()) { + let dueStr = null; + try { const d = r.dueDate(); if (d) dueStr = d.toISOString(); } catch(e){} + rows.push({ + external_id: String(r.id()), + title: String(r.name() || "Reminder"), + content: String(r.body() || ""), + timestamp: dueStr || new Date().toISOString(), + list: String(list.name()) + }); + if (rows.length >= __LIMIT__) break; + } + if (rows.length >= __LIMIT__) break; +} +JSON.stringify(rows);''', + "notes": '''const app = Application("Notes"); const rows = []; +let targetNotes = []; +try { + const f = app.folders.whose({name: "uDos"})(); + targetNotes = (f && f.length > 0) ? f[0].notes() : app.defaultAccount().notes(); +} catch(e) { targetNotes = app.defaultAccount().notes(); } +for (const n of targetNotes) { + rows.push({ + external_id: String(n.id()), + title: String(n.name() || "Note"), + content: String(n.plaintext ? n.plaintext() : ""), + timestamp: n.modificationDate() ? n.modificationDate().toISOString() : new Date().toISOString() + }); + if (rows.length >= __LIMIT__) break; +} JSON.stringify(rows);''', "contacts-notes-rules": '''const app = Application("Contacts"); const rows = []; for (const p of app.people()) { const note=String(p.note() || ""); if (note) rows.push({external_id:String(p.id()), title:String(p.name()), content:note}); if (rows.length >= __LIMIT__) break; } @@ -94,6 +143,7 @@ def source_status(self) -> list[dict[str, Any]]: "state": item.state if automation else "unavailable", "available": automation and item.state == "available", "requires_user_permission": item.state == "available", + "bidirectional": item.id in {"reminders", "notes", "mail"}, "note": item.note or ("Requires macOS Automation permission on first sync." if automation else "macOS only"), } for item in SOURCES @@ -125,6 +175,14 @@ async def sync(self, source: str, *, limit: int = 50) -> dict[str, Any]: activity_type = "contact-note-rule" if source == "contacts-notes-rules" else source if source == "contacts-notes-rules": metadata["rules"] = [line.strip() for line in content.splitlines() if line.strip().lower().startswith(("rule:", "ucore:"))] + importance = 0.65 if source in {"mail", "calendar"} else 0.5 + if row.get("is_flagged") or (row.get("flag_index", -1) >= 0): + metadata["is_flagged"] = True + metadata["flag_index"] = row.get("flag_index") + importance = 0.85 + if row.get("list"): + metadata["list"] = row.get("list") + result = await self.feed.ingest_activity( source=source, external_id=external_id, @@ -132,7 +190,7 @@ async def sync(self, source: str, *, limit: int = 50) -> dict[str, Any]: title=str(row.get("title") or item.label), content=content, contact_name=str(row.get("contact_name") or ""), - importance=0.65 if source in {"mail", "calendar"} else 0.5, + importance=importance, metadata=metadata, ) try: @@ -145,7 +203,7 @@ async def sync(self, source: str, *, limit: int = 50) -> dict[str, Any]: "type": activity_type, "title": str(row.get("title") or item.label), "content": content, - "importance": 0.65 if source in {"mail", "calendar"} else 0.5, + "importance": importance, "metadata": metadata, } ) @@ -177,3 +235,53 @@ async def sync(self, source: str, *, limit: int = 50) -> dict[str, Any]: pass return {"ok": True, "source": source, "state": "synced", "imported": imported} + + def export_reminder( + self, + title: str, + notes: str = "", + list_name: str = "uDos", + due_date: str | None = None, + ) -> dict[str, Any]: + """Export an approved task or action item into Apple Reminders.""" + from app.services.host_pim import HostPIMService + + pim = HostPIMService() + return pim.export_to_apple_reminders( + title=title, notes=notes, list_name=list_name, due_date=due_date + ) + + def export_note( + self, + title: str, + body_markdown: str, + folder: str = "uDos", + ) -> dict[str, Any]: + """Export an approved idea or scratchpad note to Apple Notes.""" + from app.services.host_pim import HostPIMService + + pim = HostPIMService() + return pim.export_to_apple_notes( + title=title, body_markdown=body_markdown, folder=folder + ) + + def archive_email(self, message_id: str) -> dict[str, Any]: + """Archive an email in Apple Mail to maintain Inbox Zero.""" + from app.services.host_pim import HostPIMService + + pim = HostPIMService() + return pim.archive_apple_mail(message_id=message_id) + + def run_shortcut(self, name: str, input_text: str = "") -> dict[str, Any]: + """Run a native macOS shortcut.""" + from app.services.host_pim import HostPIMService + + pim = HostPIMService() + return pim.run_shortcut(name=name, input_text=input_text) + + def list_shortcuts(self) -> list[str]: + """List native macOS shortcuts.""" + from app.services.host_pim import HostPIMService + + pim = HostPIMService() + return pim.list_shortcuts() diff --git a/backend/app/services/bitchat_store.py b/backend/app/services/bitchat_store.py new file mode 100644 index 0000000..63efb62 --- /dev/null +++ b/backend/app/services/bitchat_store.py @@ -0,0 +1,305 @@ +"""BitChat Sovereign Message Store & Binder Evidence Bridge. + +Provides: +- Sovereign local SQLite message archive at $UDOS_HOME/pods/bitchat.db +- Real-time Activity Pod ingestion into activity.db (Snackbar notification) +- "Save to Binder": formats discussions into Markdown evidence in ~/Vault/binders//evidence/chats/ +- "Convert to Task": promotes chat action items into sovereign .tasker markdown tasks +""" +from __future__ import annotations + +import json +import logging +import sqlite3 +import time +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional + +from app.core.settings import settings + +log = logging.getLogger("ucore.bitchat_store") + +DEFAULT_BITCHAT_POD = settings.udos_home / "pods" / "bitchat.db" + + +class BitChatStore: + """Sovereign local SQLite store for BitChat messages and evidence exports.""" + + def __init__(self, db_path: Optional[Path] = None): + self.db_path = db_path or DEFAULT_BITCHAT_POD + self.db_path.parent.mkdir(parents=True, exist_ok=True) + self._conn = sqlite3.connect(str(self.db_path)) + self._conn.row_factory = sqlite3.Row + self._ensure_schema() + + def _ensure_schema(self) -> None: + ddl = """ + CREATE TABLE IF NOT EXISTS bitchat_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + msg_id TEXT UNIQUE NOT NULL, + channel TEXT NOT NULL DEFAULT '#general', + sender_name TEXT NOT NULL, + sender_peer_id TEXT, + recipient_id TEXT, + content TEXT NOT NULL, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + saved_to_binder BOOLEAN DEFAULT 0, + binder_evidence_path TEXT, + task_id TEXT, + metadata JSON + ); + CREATE INDEX IF NOT EXISTS idx_bitchat_channel ON bitchat_messages(channel); + CREATE INDEX IF NOT EXISTS idx_bitchat_ts ON bitchat_messages(timestamp); + """ + self._conn.executescript(ddl) + self._conn.commit() + + def close(self) -> None: + self._conn.close() + + def send_message( + self, + sender_name: str, + content: str, + channel: str = "#general", + sender_peer_id: Optional[str] = None, + recipient_id: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """Record and broadcast a message, alerting the Feed Activity Pod.""" + msg_id = f"msg_{int(time.time())}_{uuid.uuid4().hex[:6]}" + now_iso = datetime.now(timezone.utc).isoformat() + cursor = self._conn.cursor() + cursor.execute( + """INSERT INTO bitchat_messages + (msg_id, channel, sender_name, sender_peer_id, recipient_id, content, timestamp, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + ( + msg_id, + channel, + sender_name, + sender_peer_id or "local", + recipient_id or channel, + content, + now_iso, + json.dumps(metadata or {}), + ), + ) + self._conn.commit() + + record = { + "msg_id": msg_id, + "channel": channel, + "sender_name": sender_name, + "sender_peer_id": sender_peer_id or "local", + "recipient_id": recipient_id or channel, + "content": content, + "timestamp": now_iso, + "saved_to_binder": False, + "task_id": None, + "metadata": metadata or {}, + } + + # Synchronously alert Feed Activity Pod (Snackbar) + try: + from app.services.feed_store import FeedServer + FeedServer().ingest_activity_sync( + source="bitchat", + external_id=msg_id, + type="bitchat_message", + title=f"[{channel}] {sender_name}", + content=content, + importance=0.6, + metadata={ + "msg_id": msg_id, + "channel": channel, + "sender_name": sender_name, + "sender_peer_id": sender_peer_id, + }, + ) + except Exception as exc: + log.debug("Could not ingest bitchat message into Feed Activity Pod: %s", exc) + + return record + + def get_messages( + self, + channel: str = "#general", + limit: int = 50, + since: Optional[str] = None, + ) -> List[Dict[str, Any]]: + """Retrieve recent chat history for a channel.""" + cursor = self._conn.cursor() + query = "SELECT * FROM bitchat_messages WHERE channel = ?" + params: List[Any] = [channel] + if since: + query += " AND timestamp >= ?" + params.append(since) + query += " ORDER BY id DESC LIMIT ?" + params.append(limit) + + cursor.execute(query, params) + rows = cursor.fetchall() + results = [] + for r in reversed(rows): + meta = {} + if r["metadata"]: + try: + meta = json.loads(r["metadata"]) + except Exception: + pass + results.append({ + "id": r["id"], + "msg_id": r["msg_id"], + "channel": r["channel"], + "sender_name": r["sender_name"], + "sender_peer_id": r["sender_peer_id"], + "recipient_id": r["recipient_id"], + "content": r["content"], + "timestamp": r["timestamp"], + "saved_to_binder": bool(r["saved_to_binder"]), + "binder_evidence_path": r["binder_evidence_path"], + "task_id": r["task_id"], + "metadata": meta, + }) + return results + + def save_to_binder( + self, + msg_ids: List[str], + binder_name: str, + title: str = "Discussion Evidence", + vault_root: Optional[Path] = None, + ) -> Dict[str, Any]: + """Format selected messages as Markdown evidence and save into binder.""" + if not msg_ids: + return {"ok": False, "error": "No messages selected"} + + cursor = self._conn.cursor() + placeholders = ",".join("?" for _ in msg_ids) + cursor.execute( + f"SELECT * FROM bitchat_messages WHERE msg_id IN ({placeholders}) ORDER BY id ASC", + msg_ids, + ) + rows = cursor.fetchall() + if not rows: + return {"ok": False, "error": "None of the specified messages were found"} + + root = vault_root or settings.vault_root + channel = rows[0]["channel"] + date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d") + slug_title = "".join(c if c.isalnum() else "-" for c in title.lower()).strip("-")[:40] + + evidence_dir = root / "binders" / binder_name / "evidence" / "chats" + evidence_dir.mkdir(parents=True, exist_ok=True) + filename = f"{date_str}-{slug_title}.md" + target_path = evidence_dir / filename + + # Format markdown + lines = [ + "---", + "type: evidence", + "source: bitchat", + f"channel: '{channel}'", + f"binder: '{binder_name}'", + f"created: '{datetime.now(timezone.utc).isoformat()}'", + "tags: [evidence, bitchat, chat, discussion]", + "---", + "", + f"# {title}", + "", + f"Captured discussion from BitChat channel **{channel}** on `{date_str}`:", + "", + ] + + for r in rows: + t_str = str(r["timestamp"]) + if "T" in t_str: + time_only = t_str.split("T")[1][:8] + elif " " in t_str: + time_only = t_str.split(" ")[1][:8] + else: + time_only = t_str[:8] + lines.append(f"- **{time_only}** `{r['sender_name']}`: {r['content']}") + + lines.append("") + target_path.write_text("\n".join(lines), encoding="utf-8") + + # Mark messages as saved + cursor.execute( + f"UPDATE bitchat_messages SET saved_to_binder = 1, binder_evidence_path = ? WHERE msg_id IN ({placeholders})", + [str(target_path)] + msg_ids, + ) + self._conn.commit() + + return { + "ok": True, + "path": str(target_path), + "filename": filename, + "binder": binder_name, + "saved_count": len(rows), + } + + def convert_message_to_task( + self, + msg_id: str, + board: str = "inbox", + priority: str = "medium", + binder: str = "Sandbox", + due_date: Optional[str] = None, + sync_apple_reminders: bool = False, + ) -> Dict[str, Any]: + """Convert a chat message into a sovereign .tasker markdown task.""" + cursor = self._conn.cursor() + cursor.execute("SELECT * FROM bitchat_messages WHERE msg_id = ?", (msg_id,)) + row = cursor.fetchone() + if not row: + return {"ok": False, "error": f"Message {msg_id} not found"} + + from app.services.feed_workflow import promote_activity_to_task + + sender = row["sender_name"] + content = row["content"] + channel = row["channel"] + task_title = f"Action from {sender}: {content[:50].strip()}" + + mock_activity = { + "id": int(time.time()), + "source": "bitchat", + "title": task_title, + "content": f"Originated from BitChat `{channel}` by **{sender}**:\n\n> {content}", + } + + promoted = promote_activity_to_task( + mock_activity, + title=task_title, + board=board, + priority=priority, + binder=binder, + due_date=due_date, + sync_apple_reminders=sync_apple_reminders, + ) + + task_id = promoted["task_id"] + cursor.execute( + "UPDATE bitchat_messages SET task_id = ? WHERE msg_id = ?", + (task_id, msg_id), + ) + self._conn.commit() + + return { + "ok": True, + "task_id": task_id, + "path": promoted["path"], + "reminders_sync": promoted.get("reminders_sync"), + } + + +# Singleton instance +_global_bitchat_store = BitChatStore() + + +def get_bitchat_store() -> BitChatStore: + return _global_bitchat_store diff --git a/backend/app/services/device_calc.py b/backend/app/services/device_calc.py new file mode 100644 index 0000000..5455709 --- /dev/null +++ b/backend/app/services/device_calc.py @@ -0,0 +1,143 @@ +"""Universal Device Display and Container Storage Capability Calculator. + +Implements authoritative mathematical formulas from: +- global-knowledge/standards/DEVICE-DISPLAY-STANDARDS.md +- global-knowledge/standards/CONTAINER-STANDARDS.md +""" +from __future__ import annotations + +from typing import Any, Dict, Optional + + +def calc_display_capability( + width: int, + height: int, + device_type: Optional[str] = None, +) -> Dict[str, Any]: + """Calculate display capabilities, character grid capacity, reading measure, + + and Zen viewport scaling for a given screen resolution. + """ + w = max(1, int(width)) + h = max(1, int(height)) + aspect_ratio_val = w / h + + # Determine reference aspect and canvas + if aspect_ratio_val < 1.5: + target_aspect = "4:3" + ref_w, ref_h = 800, 600 + else: + target_aspect = "16:9" + ref_w, ref_h = 1280, 720 + + scale_factor = round(min(w / ref_w, h / ref_h), 3) + + # GridCore Cell Algebra + sq_cols = w // 8 + sq_rows = h // 8 + tall_cols = w // 12 + tall_rows = h // 20 + super_cols = w // 24 + super_rows = h // 40 + + fits_teletext_1x = (w >= 480) and (h >= 500) + fits_teletext_2x = (w >= 960) and (h >= 1000) + + # Prose Line Measure + # Standard character measure: 1ch approx 0.6 * 16px = 9.6px + char_capacity = int(w / 9.6) + if w < 480: + layout_mode = "mobile_reflow" + recommended_measure_ch = 40 + max_width_px = w + elif w < 768: + layout_mode = "compact_tablet" + recommended_measure_ch = 60 + max_width_px = min(w - 32, 580) + else: + layout_mode = "optimal_zen" + recommended_measure_ch = 72 + max_width_px = 680 + + # BOB Blitter Object Memory Budget + # Typical 64x64 BOB with 12 frames at 1 byte/pixel (indexed palette) + typical_bob_ram = 64 * 64 * 12 * 1 + + return { + "resolution": {"width": w, "height": h}, + "aspect_ratio": target_aspect, + "aspect_ratio_ratio": round(aspect_ratio_val, 2), + "device_type": device_type or "auto", + "gridcore": { + "dot_px": 4, + "square_register": {"cols": sq_cols, "rows": sq_rows, "cell_px": "8x8"}, + "tall_register": {"cols": tall_cols, "rows": tall_rows, "cell_px": "12x20"}, + "super_cells": {"cols": super_cols, "rows": super_rows, "tile_px": "24x40"}, + "teletext_standard": { + "required_px": "480x500", + "fits_1x": fits_teletext_1x, + "fits_2x": fits_teletext_2x, + }, + }, + "prose": { + "char_capacity_ch": char_capacity, + "layout_mode": layout_mode, + "recommended_measure_ch": recommended_measure_ch, + "max_width_px": max_width_px, + }, + "zen_viewport": { + "target_aspect": target_aspect, + "reference_resolution": {"width": ref_w, "height": ref_h}, + "scale_factor": scale_factor, + "scaled_width": int(ref_w * scale_factor), + "scaled_height": int(ref_h * scale_factor), + "letterbox": { + "horizontal_padding_px": max(0, (w - int(ref_w * scale_factor)) // 2), + "vertical_padding_px": max(0, (h - int(ref_h * scale_factor)) // 2), + }, + }, + "bob_budget": { + "max_dimensions_px": {"width": 128, "height": 128}, + "typical_dimensions_px": {"width": 64, "height": 64}, + "max_frames": 24, + "recommended_fps": 12, + "typical_ram_bytes": typical_bob_ram, + "max_file_size_kb": 60, + }, + } + + +def calc_storage_capacity( + total_bytes: int, + sys_bytes: Optional[int] = None, +) -> Dict[str, Any]: + """Calculate usable vault knowledge capacity, indexed search capacity, + + and capsule budgets for a given storage size. + """ + total = max(0, int(total_bytes)) + # Default system overhead: 2.5 GB for base Linux/CMMint + uCore runtime + overhead = int(sys_bytes if sys_bytes is not None else 2_500_000_000) + usable_vault = max(0, total - overhead) + + # Document & Asset density metrics + plain_note_bytes = 3_500 + fts5_indexed_note_bytes = 4_900 # 1.4x + bob_asset_bytes = 25_000 + mini_capsule_bytes = 10_000_000 # 10 MB + zim_capsule_bytes = 1_000_000_000 # 1 GB + + return { + "storage_total_bytes": total, + "storage_system_bytes": overhead, + "storage_vault_usable_bytes": usable_vault, + "storage_vault_usable_mb": round(usable_vault / (1024 * 1024), 2), + "storage_vault_usable_gb": round(usable_vault / (1024 * 1024 * 1024), 3), + "knowledge_capacity": { + "plain_prose_notes": usable_vault // plain_note_bytes, + "indexed_notes_fts5": usable_vault // fts5_indexed_note_bytes, + "curated_bobs_gif": usable_vault // bob_asset_bytes, + "mini_capsules_10mb": usable_vault // mini_capsule_bytes, + "encyclopedia_zim_capsules_1gb": usable_vault // zim_capsule_bytes, + }, + } diff --git a/backend/app/services/dispatch_feed.py b/backend/app/services/dispatch_feed.py new file mode 100644 index 0000000..fe9f8dd --- /dev/null +++ b/backend/app/services/dispatch_feed.py @@ -0,0 +1,128 @@ +"""Sovereign Dispatch Feed Generator. + +Generates decentralized RSS 2.0 XML and JSON Feed v1.1 formats for all +published dispatches and binder editions, including GIF enclosures and Prose descriptions. +Saves local feed files under ~/Vault/feeds/dispatches.xml. +""" +from __future__ import annotations + +import email.utils +import html +import json +import logging +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional + +from app.core.settings import settings +from app.services.dispatch_store import dispatch_store + +log = logging.getLogger("ucore.dispatch_feed") + + +class DispatchFeedService: + """Syndication engine publishing sovereign dispatches as RSS & JSON feeds.""" + + def __init__(self, feeds_dir: Optional[Path] = None): + self.feeds_dir = feeds_dir or (settings.vault_root / "feeds") + self.feeds_dir.mkdir(parents=True, exist_ok=True) + + def generate_rss_2_0(self, host_url: str = "") -> str: + """Generate standard RSS 2.0 XML string.""" + dispatches = dispatch_store.list_dispatches() + now_rfc822 = email.utils.format_datetime(datetime.now(timezone.utc)) + + items_xml = [] + for d in dispatches: + if d.get("status") not in ("active", "published"): + continue + + token = d.get("token", "") + title = html.escape(d.get("title", "Untitled Dispatch")) + lead = html.escape(d.get("lead_text", "")) + created_ts = d.get("created_at", 0) + item_dt = datetime.fromtimestamp(created_ts, timezone.utc) if created_ts else datetime.now(timezone.utc) + item_rfc822 = email.utils.format_datetime(item_dt) + + item_link = f"{host_url}/p/{token}" if token else host_url + enclosure_xml = "" + hero_asset = d.get("hero_asset") + if hero_asset: + full_asset_url = f"{host_url}{hero_asset}" if hero_asset.startswith("/") else hero_asset + enclosure_xml = f' \n' + + item_block = f""" + {title} + {item_link} + {d.get('id', token)} + {item_rfc822} + {lead} +{enclosure_xml} """ + items_xml.append(item_block) + + items_joined = "\n".join(items_xml) + rss_content = f""" + + + uDos Sovereign Dispatches + {host_url}/api/dispatch/feed.xml + Sovereign invitations, interactive stories, and binder dispatches. + en + {now_rfc822} + +{items_joined} + +""" + return rss_content + + def generate_json_feed(self, host_url: str = "") -> Dict[str, Any]: + """Generate modern JSON Feed v1.1 format.""" + dispatches = dispatch_store.list_dispatches() + items = [] + + for d in dispatches: + if d.get("status") not in ("active", "published"): + continue + + token = d.get("token", "") + created_iso = d.get("created_iso", datetime.now(timezone.utc).isoformat()) + hero_asset = d.get("hero_asset") + image_url = f"{host_url}{hero_asset}" if hero_asset and hero_asset.startswith("/") else hero_asset + + items.append({ + "id": d.get("id", token), + "url": f"{host_url}/p/{token}" if token else host_url, + "title": d.get("title", "Untitled Dispatch"), + "summary": d.get("lead_text", ""), + "date_published": created_iso, + "image": image_url, + }) + + return { + "version": "https://jsonfeed.org/version/1.1", + "title": "uDos Sovereign Dispatches", + "home_page_url": f"{host_url}/p/", + "feed_url": f"{host_url}/api/dispatch/feed.json", + "description": "Sovereign invitations, interactive stories, and binder dispatches.", + "items": items, + } + + def sync_to_vault(self, host_url: str = "") -> Dict[str, str]: + """Persist generated RSS and JSON feeds into ~/Vault/feeds/.""" + rss_xml = self.generate_rss_2_0(host_url) + json_feed = self.generate_json_feed(host_url) + + rss_path = self.feeds_dir / "dispatches.xml" + json_path = self.feeds_dir / "dispatches.json" + + rss_path.write_text(rss_xml, encoding="utf-8") + json_path.write_text(json.dumps(json_feed, indent=2), encoding="utf-8") + + log.info("Syndicated dispatch feeds written to %s and %s", rss_path, json_path) + return { + "rss_path": str(rss_path), + "json_path": str(json_path), + } + + +dispatch_feed_service = DispatchFeedService() diff --git a/backend/app/services/dispatch_offline.py b/backend/app/services/dispatch_offline.py new file mode 100644 index 0000000..3c1ec0f --- /dev/null +++ b/backend/app/services/dispatch_offline.py @@ -0,0 +1,365 @@ +"""Sovereign Offline Dispatch & Capsule Exporter. + +Builds 100% self-contained, single-file HTML dispatches ('story.standalone.html') +and portable signed '.capsule' archives. +Allows offline viewing from a USB flash drive without internet or server runtime. +Offline submissions download a formatted response JSON file for return to the host. +""" +from __future__ import annotations + +import base64 +import html +import json +import logging +import subprocess +from pathlib import Path +from typing import Any, Dict, Optional + +from app.core.settings import settings +from app.services.dispatch_store import dispatch_store + +log = logging.getLogger("ucore.dispatch_offline") + + +def generate_standalone_html(dispatch_id: str) -> str: + """Generate self-contained single-file HTML story player.""" + dispatch = dispatch_store.get_dispatch(dispatch_id) + if not dispatch: + raise ValueError(f"Dispatch '{dispatch_id}' not found") + + manifest = dispatch["dispatch"] + story_md = dispatch["story_markdown"] + + title = html.escape(manifest.get("title", "Sovereign Dispatch")) + lead = html.escape(manifest.get("lead_text", "")) + token = manifest.get("token", "") + + # Embed hero GIF as base64 data URI if available + hero_b64 = "" + dispatch_dir = dispatch_store.root_dir / dispatch_id + hero_file = dispatch_dir / "originals" / "hero.gif" + if hero_file.exists(): + hero_b64 = f"data:image/gif;base64,{base64.b64encode(hero_file.read_bytes()).decode('utf-8')}" + elif manifest.get("hero_asset"): + # Check catalog + asset_name = Path(manifest["hero_asset"]).name + catalog_file = settings.public_vault_root / "global-knowledge" / "elements" / asset_name + if catalog_file.exists(): + hero_b64 = f"data:image/gif;base64,{base64.b64encode(catalog_file.read_bytes()).decode('utf-8')}" + + hero_img_tag = f'Hero Animation' if hero_b64 else "" + + # JSON escaped data for pure client-side runner + escaped_story_json = json.dumps(story_md) + escaped_manifest_json = json.dumps(manifest) + + return f""" + + + + + {title} — uDos Sovereign Story + + + +
+
+ {hero_img_tag} +

{title}

+

{lead}

+
+ +
+
+ +
+
+ +
+ Dispatched via sovereign uDos • 100% Offline • Zero Tracking Pixels +
+
+ + + +""" + + +def export_offline_bundle(dispatch_id: str) -> Dict[str, Any]: + """Generate standalone HTML and pack into a signed .capsule archive.""" + html_content = generate_standalone_html(dispatch_id) + + invites_dir = settings.shared_vault_root / "invites" + capsules_dir = settings.shared_vault_root / "capsules" + invites_dir.mkdir(parents=True, exist_ok=True) + capsules_dir.mkdir(parents=True, exist_ok=True) + + html_path = invites_dir / f"{dispatch_id}.html" + html_path.write_text(html_content, encoding="utf-8") + + # Also prepare a directory bundle for capsule packaging + dispatch_dir = dispatch_store.root_dir / dispatch_id + capsule_out = capsules_dir / f"{dispatch_id}.capsule" + + # Attempt to pack with sonic CLI or python + try: + sonic_venv = Path(__file__).resolve().parents[4] / "SonicScrewdriver" / ".venv" / "bin" / "sonic" + if sonic_venv.exists(): + cmd = [ + str(sonic_venv), + "package", "pack", + str(dispatch_dir), + "--out", str(capsule_out), + "--title", f"Dispatch {dispatch_id}", + ] + subprocess.run(cmd, capture_output=True, check=True) + except Exception as exc: + log.warning("Could not pack with sonic CLI: %s. Writing standalone HTML only.", exc) + + return { + "status": "ok", + "dispatch_id": dispatch_id, + "standalone_html_path": str(html_path), + "capsule_path": str(capsule_out) if capsule_out.exists() else None, + } diff --git a/backend/app/services/dispatch_store.py b/backend/app/services/dispatch_store.py new file mode 100644 index 0000000..f12b298 --- /dev/null +++ b/backend/app/services/dispatch_store.py @@ -0,0 +1,431 @@ +"""Dispatch Store & Ephemeral Token Manager. + +Manages interactive story dispatches, ephemeral access tokens (/p/:token), +RSVP submission records, and preserves raw originals under ~/Vault/dispatches//originals/. +""" +from __future__ import annotations + +import json +import logging +import secrets +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional + +from app.core.settings import settings + +log = logging.getLogger("ucore.dispatch_store") + + +class DispatchStore: + """Sovereign store for interactive dispatches and RSVPs.""" + + def __init__(self, root_dir: Optional[Path] = None, token_index_path: Optional[Path] = None): + self.root_dir = root_dir or (settings.vault_root / "dispatches") + self.token_index_path = token_index_path or (settings.data_dir / "dispatch_tokens.json") + self.root_dir.mkdir(parents=True, exist_ok=True) + self.token_index_path.parent.mkdir(parents=True, exist_ok=True) + self._load_tokens() + + def _load_tokens(self) -> Dict[str, str]: + if not self.token_index_path.exists(): + return {} + try: + data = json.loads(self.token_index_path.read_text(encoding="utf-8")) + if isinstance(data, dict): + return data + except Exception as exc: + log.warning("Could not read dispatch token index: %s", exc) + return {} + + def _save_tokens(self, tokens: Dict[str, str]) -> None: + try: + self.token_index_path.write_text(json.dumps(tokens, indent=2), encoding="utf-8") + except Exception as exc: + log.error("Failed to write dispatch token index: %s", exc) + + def create_dispatch( + self, + title: str, + story_markdown: str, + lead_text: str = "", + hero_gif_bytes: Optional[bytes] = None, + hero_gif_name: Optional[str] = None, + expires_in_seconds: Optional[int] = None, + burn_after_read: bool = False, + mode_default: str = "card", + binder_id: Optional[str] = None, + ) -> Dict[str, Any]: + """Create a new dispatch, generate an ephemeral token, and preserve originals.""" + timestamp = int(time.time()) + dispatch_id = f"disp_{timestamp}_{secrets.token_hex(4)}" + token = secrets.token_urlsafe(16) + + dispatch_dir = self.root_dir / dispatch_id + originals_dir = dispatch_dir / "originals" + originals_dir.mkdir(parents=True, exist_ok=True) + + # Preserve original markdown + (originals_dir / "source.story.md").write_text(story_markdown, encoding="utf-8") + + # Save hero GIF if provided + hero_rel_path: Optional[str] = None + if hero_gif_bytes: + hero_file = originals_dir / "hero.gif" + hero_file.write_bytes(hero_gif_bytes) + hero_rel_path = f"/api/dispatch/asset/{dispatch_id}/hero.gif" + elif hero_gif_name: + hero_rel_path = f"/api/dispatch/catalog/asset/{hero_gif_name}" + + expires_at: Optional[int] = None + if expires_in_seconds and expires_in_seconds > 0: + expires_at = timestamp + expires_in_seconds + + manifest = { + "id": dispatch_id, + "token": token, + "title": title, + "lead_text": lead_text, + "created_at": timestamp, + "created_iso": datetime.now(timezone.utc).isoformat(), + "expires_at": expires_at, + "burn_after_read": bool(burn_after_read), + "mode_default": mode_default, + "hero_asset": hero_rel_path, + "binder_id": binder_id, + "view_count": 0, + "status": "active", + } + + (dispatch_dir / "dispatch.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8") + (dispatch_dir / "responses.json").write_text("[]", encoding="utf-8") + + # Update token index + tokens = self._load_tokens() + tokens[token] = dispatch_id + self._save_tokens(tokens) + + # Ingest into Feed Activity Pod (Snackbar) + try: + from app.services.feed_store import FeedServer + FeedServer().ingest_activity_sync( + source="dispatch", + external_id=dispatch_id, + type="dispatch_published", + title=f"Published: {title}", + content=lead_text or title, + url=f"/p/{token}", + importance=0.6, + metadata={ + "dispatch_id": dispatch_id, + "token": token, + "binder_id": binder_id, + "mode_default": mode_default, + }, + ) + except Exception as exc: + log.warning("Could not ingest dispatch creation into feed: %s", exc) + + # Update syndicated feeds in ~/Vault/feeds/ + try: + from app.services.dispatch_feed import dispatch_feed_service + dispatch_feed_service.sync_to_vault() + except Exception as exc: + log.warning("Could not sync feeds: %s", exc) + + return manifest + + def get_dispatch_by_token(self, token: str, record_view: bool = True) -> Dict[str, Any]: + """Retrieve dispatch by guest token, evaluating expiration and burn status.""" + tokens = self._load_tokens() + dispatch_id = tokens.get(token) + if not dispatch_id: + return {"status": "not_found", "error": "Dispatch not found"} + + dispatch_dir = self.root_dir / dispatch_id + manifest_path = dispatch_dir / "dispatch.json" + if not manifest_path.exists(): + return {"status": "not_found", "error": "Dispatch manifest missing"} + + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + + # Check burn status + if manifest.get("status") == "burned": + return { + "status": "burned", + "tombstone": "This dispatch was ephemeral and has dissolved into the ether.", + } + + # Check expiration + expires_at = manifest.get("expires_at") + now = int(time.time()) + if expires_at and now > expires_at: + manifest["status"] = "expired" + manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8") + return { + "status": "expired", + "tombstone": "This dispatch has expired and dissolved into the ether.", + } + + # Record view count + if record_view: + manifest["view_count"] = manifest.get("view_count", 0) + 1 + manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8") + + # Read story markdown + story_path = dispatch_dir / "originals" / "source.story.md" + story_md = story_path.read_text(encoding="utf-8") if story_path.exists() else "" + + return { + "status": "active", + "dispatch": manifest, + "story_markdown": story_md, + } + + def submit_rsvp(self, token: str, payload: Dict[str, Any]) -> Dict[str, Any]: + """Submit response to a dispatch, updating responses.json and burning if configured.""" + record = self.get_dispatch_by_token(token, record_view=False) + if record.get("status") != "active": + return record + + manifest = record["dispatch"] + dispatch_id = manifest["id"] + dispatch_dir = self.root_dir / dispatch_id + responses_path = dispatch_dir / "responses.json" + + responses: List[Dict[str, Any]] = [] + if responses_path.exists(): + try: + responses = json.loads(responses_path.read_text(encoding="utf-8")) + except Exception: + responses = [] + + entry = { + "timestamp": int(time.time()), + "submitted_iso": datetime.now(timezone.utc).isoformat(), + "data": payload, + } + responses.append(entry) + responses_path.write_text(json.dumps(responses, indent=2), encoding="utf-8") + + # Burn token if configured + if manifest.get("burn_after_read"): + manifest["status"] = "burned" + (dispatch_dir / "dispatch.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8") + + # Ingest RSVP into Feed Activity Pod (notifies Snackbar) + contact_name = payload.get("name") or payload.get("contact_name") or "Anonymous Sovereign Guest" + try: + from app.services.feed_store import FeedServer + FeedServer().ingest_activity_sync( + source="dispatch", + external_id=f"{dispatch_id}_rsvp_{len(responses)}", + type="dispatch_rsvp_received", + title=f"RSVP Received: {manifest.get('title')}", + content=f"Response from {contact_name}", + url=f"/p/{token}", + contact_name=contact_name, + importance=0.8, + metadata={ + "dispatch_id": dispatch_id, + "binder_id": manifest.get("binder_id"), + "submission": payload, + }, + ) + except Exception as exc: + log.warning("Could not ingest RSVP into feed: %s", exc) + + return { + "status": "success", + "message": "Response recorded peacefully", + "burned": manifest.get("burn_after_read", False), + } + + def get_responses(self, dispatch_id: str) -> List[Dict[str, Any]]: + """Host query for all collected responses.""" + responses_path = self.root_dir / dispatch_id / "responses.json" + if not responses_path.exists(): + return [] + try: + return json.loads(responses_path.read_text(encoding="utf-8")) + except Exception: + return [] + + def convert_rsvp_to_task( + self, + dispatch_id: str, + rsvp_index: int, + board: str = "inbox", + priority: str = "medium", + binder: Optional[str] = None, + mission: Optional[str] = None, + due_date: Optional[str] = None, + sync_apple_reminders: bool = False, + ) -> Dict[str, Any]: + """Convert a collected RSVP submission into a sovereign .tasker task.""" + dispatch_dir = self.root_dir / dispatch_id + responses_path = dispatch_dir / "responses.json" + manifest_path = dispatch_dir / "dispatch.json" + + if not responses_path.exists() or not manifest_path.exists(): + return {"status": "not_found", "error": "Dispatch or responses not found"} + + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + responses = json.loads(responses_path.read_text(encoding="utf-8")) + + if rsvp_index < 0 or rsvp_index >= len(responses): + return {"status": "not_found", "error": f"RSVP index {rsvp_index} out of range"} + + entry = responses[rsvp_index] + payload = entry.get("data") or {} + contact_name = payload.get("name") or payload.get("contact_name") or payload.get("attendee_name") or "Anonymous Guest" + answers = payload.get("answers") or {} + answers_str = "\n".join(f"- **{k}**: {v}" for k, v in answers.items()) if answers else "" + notes = payload.get("notes") or payload.get("email") or "" + + target_binder = binder or manifest.get("binder_id") or "Sandbox" + dispatch_title = manifest.get("title") or dispatch_id + task_title = f"RSVP Follow-up: {contact_name} ({dispatch_title})" + + body_lines = [ + f"RSVP submission for dispatch **{dispatch_title}** (`{dispatch_id}`).", + f"- **Respondent**: {contact_name}", + f"- **Submitted**: {entry.get('submitted_iso', '')}", + ] + if notes: + body_lines.append(f"- **Notes/Email**: {notes}") + if answers_str: + body_lines.append("\n### Form Responses\n" + answers_str) + + body_content = "\n".join(body_lines) + + from app.services.feed_workflow import promote_activity_to_task + mock_activity = { + "id": int(time.time()), + "source": "dispatch", + "title": task_title, + "content": body_content, + } + promoted = promote_activity_to_task( + mock_activity, + title=task_title, + board=board, + priority=priority, + binder=target_binder, + mission=mission, + due_date=due_date, + sync_apple_reminders=sync_apple_reminders, + ) + + task_id = promoted["task_id"] + entry["task_id"] = task_id + responses_path.write_text(json.dumps(responses, indent=2), encoding="utf-8") + + # Link in Activity Pod if possible + try: + from app.services.feed_store import FeedServer + feed = FeedServer() + ext_id = f"{dispatch_id}_rsvp_{rsvp_index + 1}" + rows = feed._conn.execute( + "SELECT id FROM user_activity WHERE source = 'dispatch' AND external_id = ?", + (ext_id,), + ).fetchall() + if rows: + act_id = rows[0]["id"] + feed._conn.execute( + "INSERT INTO task_activity_links (task_id, activity_id, link_type) VALUES (?, ?, 'dispatch_rsvp')", + (task_id, act_id), + ) + feed._conn.execute("UPDATE user_activity SET processed = 1 WHERE id = ?", (act_id,)) + feed._conn.commit() + except Exception as exc: + log.debug("Could not link task in activity pod: %s", exc) + + return { + "status": "ok", + "task_id": task_id, + "path": promoted["path"], + "reminders_sync": promoted.get("reminders_sync"), + } + + def get_dispatches_by_binder(self, binder_id: str) -> List[Dict[str, Any]]: + """Find all dispatches associated with a specific binder.""" + results: List[Dict[str, Any]] = [] + if not self.root_dir.exists(): + return results + + for child in self.root_dir.iterdir(): + if child.is_dir(): + manifest_path = child / "dispatch.json" + if manifest_path.exists(): + try: + m = json.loads(manifest_path.read_text(encoding="utf-8")) + if m.get("binder_id") == binder_id: + # Attach response count + resps = self.get_responses(m["id"]) + m["response_count"] = len(resps) + results.append(m) + except Exception: + pass + # Sort newest first + results.sort(key=lambda x: x.get("created_at", 0), reverse=True) + return results + + def list_dispatches(self) -> List[Dict[str, Any]]: + """List all dispatches sorted newest first.""" + results: List[Dict[str, Any]] = [] + if not self.root_dir.exists(): + return results + + for child in self.root_dir.iterdir(): + if child.is_dir(): + manifest_path = child / "dispatch.json" + if manifest_path.exists(): + try: + m = json.loads(manifest_path.read_text(encoding="utf-8")) + resps = self.get_responses(m["id"]) + m["response_count"] = len(resps) + results.append(m) + except Exception: + pass + results.sort(key=lambda x: x.get("created_at", 0), reverse=True) + return results + + def get_dispatch(self, dispatch_id: str) -> Optional[Dict[str, Any]]: + """Get dispatch record and source markdown by dispatch ID.""" + dispatch_dir = self.root_dir / dispatch_id + manifest_path = dispatch_dir / "dispatch.json" + if not manifest_path.exists(): + return None + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + story_path = dispatch_dir / "originals" / "source.story.md" + story_md = story_path.read_text(encoding="utf-8") if story_path.exists() else "" + return { + "dispatch": manifest, + "story_markdown": story_md, + } + except Exception: + return None + + def load_catalog(self) -> Dict[str, Any]: + """Load curated standard element library from global-knowledge.""" + catalog_path = settings.public_vault_root / "global-knowledge" / "elements" / "catalog.json" + if catalog_path.exists(): + try: + return json.loads(catalog_path.read_text(encoding="utf-8")) + except Exception as exc: + log.warning("Could not read element catalog: %s", exc) + + # Fallback empty catalog + return { + "title": "uDos Curated Standard Element Library", + "version": "1.0.0", + "categories": {"cards": [], "bobs": [], "dividers": []}, + } + + +# Global singleton +dispatch_store = DispatchStore() + diff --git a/backend/app/services/email_dispatch.py b/backend/app/services/email_dispatch.py new file mode 100644 index 0000000..859d47b --- /dev/null +++ b/backend/app/services/email_dispatch.py @@ -0,0 +1,162 @@ +"""Sovereign Prose Email Compiler. + +Compiles bulletproof, single-column HTML emails adhering to USX Prose Standards: +- Strict max-width (580px/640px) +- MSO table fallbacks for Outlook compatibility +- System typography stacks (Charter serif, Inter sans) +- Embedded hero GIF (via CID or link) +- Zero tracking pixels and zero third-party web font downloads +""" +from __future__ import annotations + +import html +from typing import Optional + + +def compile_prose_email( + title: str, + lead_text: str, + body_html: str, + action_label: Optional[str] = None, + action_url: Optional[str] = None, + hero_gif_src: Optional[str] = None, + theme: str = "dark", +) -> str: + """Compile a single-column, bulletproof HTML email.""" + safe_title = html.escape(title) + safe_lead = html.escape(lead_text) + + # Color tokens based on USX dark or light theme + if theme == "light": + bg_color = "#F6F8FA" + card_bg = "#FFFFFF" + text_primary = "#1F2328" + text_muted = "#656D76" + border_color = "#D0D7DE" + btn_bg = "#0969DA" + btn_text = "#FFFFFF" + else: + bg_color = "#0D1117" + card_bg = "#161B22" + text_primary = "#E6EDF3" + text_muted = "#8B949E" + border_color = "#30363D" + btn_bg = "#58A6FF" + btn_text = "#0D1117" + + hero_block = "" + if hero_gif_src: + hero_block = f""" + + + Hero Animation + + + """ + + button_block = "" + if action_label and action_url: + safe_action_label = html.escape(action_label) + button_block = f""" + + + + + {safe_action_label} + + + + """ + + return f""" + + + + + + + {safe_title} + + + + +
+ + + {hero_block} + + + + + + + + + + {button_block} + + + +
+

{safe_title}

+
+

{safe_lead}

+
+ {body_html} +
+

+ Dispatched via sovereign uDos • Zero tracking pixels • Local first +

+
+ +
+ +""" + + +def generate_plain_text_email( + title: str, + lead_text: str, + body_text: str, + action_label: Optional[str] = None, + action_url: Optional[str] = None, +) -> str: + """Generate a clean plain-text fallback version.""" + lines = [ + title, + "=" * len(title), + "", + lead_text, + "", + body_text, + "", + ] + if action_label and action_url: + lines.extend([f"-> {action_label}: {action_url}", ""]) + lines.append("Dispatched via sovereign uDos (offline-first, zero tracking)") + return "\n".join(lines) diff --git a/backend/app/services/feed_rules.py b/backend/app/services/feed_rules.py index 992ce74..e2402b4 100644 --- a/backend/app/services/feed_rules.py +++ b/backend/app/services/feed_rules.py @@ -1,6 +1,7 @@ """Small, auditable rules engine for turning Feed signals into workflow proposals.""" from __future__ import annotations +import json from dataclasses import dataclass from pathlib import Path from typing import Any @@ -24,6 +25,10 @@ class FeedRule: board: str priority: str binder: str + require_flagged: bool = False + require_vip: bool = False + sync_reminders: bool = False + archive_mail: bool = False def matches(self, activity: dict[str, Any]) -> bool: source = str(activity.get("source") or "").lower() @@ -31,6 +36,19 @@ def matches(self, activity: dict[str, Any]) -> bool: return False if float(activity.get("importance") or 0) < self.min_importance: return False + + meta = activity.get("metadata") or {} + if isinstance(meta, str): + try: + meta = json.loads(meta) + except Exception: + meta = {} + + if self.require_flagged and not meta.get("is_flagged"): + return False + if self.require_vip and not meta.get("vip"): + return False + haystack = " ".join( str(activity.get(key) or "") for key in ("title", "content", "type") ).lower() @@ -53,10 +71,14 @@ def load_feed_rules(path: Path | None = None) -> tuple[list[FeedRule], dict[str, sources=tuple(str(value).lower() for value in match.get("sources") or []), contains=tuple(str(value).lower() for value in match.get("contains") or []), min_importance=float(match.get("min_importance", 0)), + require_flagged=bool(match.get("flagged", False)), + require_vip=bool(match.get("vip", False)), action=str(action.get("type") or "propose-task"), board=str(action.get("board") or "inbox"), priority=str(action.get("priority") or "medium"), binder=str(action.get("binder") or "Sandbox"), + sync_reminders=bool(action.get("sync_reminders", False)), + archive_mail=bool(action.get("archive_mail", False)), ) ) return rules, { @@ -81,6 +103,8 @@ def evaluate_feed_rules( "board": rule.board, "priority": rule.priority, "binder": rule.binder, + "sync_reminders": rule.sync_reminders, + "archive_mail": rule.archive_mail, "title": str(activity.get("title") or activity.get("type") or "Feed item"), } ) diff --git a/backend/app/services/feed_store.py b/backend/app/services/feed_store.py index 695a46a..7a747c9 100644 --- a/backend/app/services/feed_store.py +++ b/backend/app/services/feed_store.py @@ -68,7 +68,7 @@ def pod_path(self) -> Path: # ── Activity ingestion ──────────────────────────────────────── - async def ingest_activity( + def ingest_activity_sync( self, source: str, type: str, @@ -80,7 +80,7 @@ async def ingest_activity( metadata: dict[str, Any] | None = None, external_id: str | None = None, ) -> dict[str, Any]: - """Insert a user activity event into the Feed Pod.""" + """Insert a user activity event synchronously into the Feed Pod.""" cursor = self._conn.cursor() cursor.execute( """INSERT INTO user_activity @@ -116,6 +116,31 @@ async def ingest_activity( ) return {"id": row_id, "message": "Activity ingested"} + async def ingest_activity( + self, + source: str, + type: str, + title: str = "", + content: str = "", + url: str = "", + contact_name: str = "", + importance: float = 0.5, + metadata: dict[str, Any] | None = None, + external_id: str | None = None, + ) -> dict[str, Any]: + """Insert a user activity event into the Feed Pod.""" + return self.ingest_activity_sync( + source=source, + type=type, + title=title, + content=content, + url=url, + contact_name=contact_name, + importance=importance, + metadata=metadata, + external_id=external_id, + ) + # ── Activity query ──────────────────────────────────────────── async def query_feed( @@ -264,3 +289,48 @@ async def link_task_to_activity( "activity_id": activity_id, "link_type": link_type, } + + def resolve_activity( + self, + activity_id: int, + note: str = "", + archived: bool = True, + ) -> dict[str, Any]: + """Resolve/archive a feed activity, marking it processed and serene.""" + cursor = self._conn.cursor() + cursor.execute("SELECT id, metadata FROM user_activity WHERE id = ?", (activity_id,)) + row = cursor.fetchone() + if not row: + return {"ok": False, "error": f"Activity {activity_id} not found"} + + try: + meta = json.loads(row["metadata"] or "{}") + except Exception: + meta = {} + + import time + meta["resolved_at"] = int(time.time()) + meta["resolution_note"] = note + meta["archived"] = bool(archived) + + cursor.execute( + "UPDATE user_activity SET processed = 1, metadata = ? WHERE id = ?", + (json.dumps(meta), activity_id), + ) + self._conn.commit() + log.info("Feed resolved: id=%s note=%s", activity_id, note) + return {"ok": True, "activity_id": activity_id, "processed": 1, "archived": archived} + + def bulk_resolve( + self, + activity_ids: list[int], + note: str = "", + ) -> dict[str, Any]: + """Bulk resolve multiple feed activities.""" + resolved = [] + for aid in activity_ids: + res = self.resolve_activity(aid, note=note) + if res.get("ok"): + resolved.append(aid) + return {"ok": True, "resolved_count": len(resolved), "resolved_ids": resolved} + diff --git a/backend/app/services/feed_workflow.py b/backend/app/services/feed_workflow.py index cc548e3..ec5ca29 100644 --- a/backend/app/services/feed_workflow.py +++ b/backend/app/services/feed_workflow.py @@ -15,7 +15,11 @@ def promote_activity_to_task( board: str = "inbox", priority: str = "medium", binder: str = "Sandbox", + mission: str | None = None, + due_date: str | None = None, rule_id: str | None = None, + sync_apple_reminders: bool = False, + archive_source_mail: bool = False, ) -> dict[str, Any]: activity_id = int(activity["id"]) task_title = str(title or activity.get("title") or "Feed item").strip() @@ -27,6 +31,45 @@ def promote_activity_to_task( tags = ["user", "feed", source] if rule_id: tags.append(f"rule-{slugify(rule_id)}") + + metadata: dict[str, Any] = { + "priority": priority, + "binder": binder, + "tags": tags, + } + if mission: + metadata["mission"] = mission + if due_date: + metadata["due"] = due_date + + reminders_result = None + if sync_apple_reminders: + try: + from app.services.apple_feed_sync import AppleFeedSync + + target_list = binder if binder and binder != "Sandbox" else "uDos" + reminders_result = AppleFeedSync().export_reminder( + title=task_title, + notes=str(activity.get("content") or ""), + list_name=target_list, + due_date=due_date, + ) + if reminders_result.get("ok"): + tags.append("synced:apple-reminders") + except Exception: + pass + + mail_archived_result = None + if archive_source_mail and source == "mail": + try: + from app.services.apple_feed_sync import AppleFeedSync + + ext_id = str(activity.get("external_id") or "") + if ext_id: + mail_archived_result = AppleFeedSync().archive_email(ext_id) + except Exception: + pass + target.write_text( render_task_markdown( title=task_title, @@ -34,8 +77,13 @@ def promote_activity_to_task( source_id=str(activity_id), status="todo", body=str(activity.get("content") or ""), - metadata={"priority": priority, "binder": binder, "tags": tags}, + metadata=metadata, ), encoding="utf-8", ) - return {"task_id": target.stem, "path": str(target)} + return { + "task_id": target.stem, + "path": str(target), + "reminders_sync": reminders_result, + "mail_archived": mail_archived_result, + } diff --git a/backend/app/services/host_pim.py b/backend/app/services/host_pim.py index e0c4281..eec1cbc 100644 --- a/backend/app/services/host_pim.py +++ b/backend/app/services/host_pim.py @@ -25,10 +25,11 @@ log = logging.getLogger("ucore.services.host_pim") -def _default_runner(cmd: list[str], timeout: float = 10.0) -> str: +def _default_runner(cmd: list[str], timeout: float = 10.0, input_data: Optional[str] = None) -> str: """Default command runner executing osascript or host CLI binaries.""" proc = subprocess.run( cmd, + input=input_data, capture_output=True, text=True, timeout=timeout, @@ -145,6 +146,11 @@ def probe_capabilities(self) -> Dict[str, Any]: "automation": "no_osascript", } + has_shortcuts = shutil.which("shortcuts") is not None + app_status["shortcuts"] = { + "available": has_shortcuts, + "automation": "cli" if has_shortcuts else "none", + } report["host_native"] = app_status report["capabilities"] = { "browser_intake": app_status.get("safari", {}).get("available", False), @@ -153,6 +159,7 @@ def probe_capabilities(self) -> Dict[str, Any]: "reminders_export": app_status.get("reminders", {}).get("available", False), "reminders_intake": app_status.get("reminders", {}).get("available", False), "mail_bridge": app_status.get("mail", {}).get("available", False), + "shortcuts": has_shortcuts, "notifications": has_osascript, "speech_tts": shutil.which("say") is not None, } @@ -172,6 +179,7 @@ def probe_capabilities(self) -> Dict[str, Any]: "reminders_export": False, "reminders_intake": False, "mail_bridge": False, + "shortcuts": False, "notifications": shutil.which("notify-send") is not None, "speech_tts": shutil.which("spd-say") is not None, } @@ -321,14 +329,20 @@ def export_to_apple_reminders( safe_notes = notes.replace("\\", "\\\\").replace('"', '\\"') if list_name: - target_clause = f'list "{list_name.replace("\"", "\\\"")}"' + safe_list = list_name.replace("\\", "\\\\").replace('"', '\\"') + target_clause = f'list "{safe_list}"' + ensure_list_clause = f"""if not (exists list "{safe_list}") then + make new list with properties {{name:"{safe_list}"}} + end if""" else: target_clause = "default list" + ensure_list_clause = "" due_clause = f', due date:date "{due_date}"' if due_date else "" script = f""" tell application "Reminders" + {ensure_list_clause} set targetList to {target_clause} make new reminder at targetList with properties {{name:"{safe_title}", body:"{safe_notes}"{due_clause}}} end tell @@ -542,3 +556,109 @@ def say(self, text: str, voice: Optional[str] = None) -> Dict[str, Any]: except Exception as exc: return {"ok": False, "error": str(exc)} return {"ok": False, "error": "Speech synthesizer not available"} + + def list_shortcuts(self) -> list[str]: + """List available shortcuts on macOS non-invasively.""" + if not self.is_macos() or not shutil.which("shortcuts"): + return [] + try: + raw = self.runner(["shortcuts", "list"]) + return [line.strip() for line in raw.splitlines() if line.strip()] + except Exception as exc: + log.debug("Could not list shortcuts: %s", exc) + return [] + + def run_shortcut(self, name: str, input_text: str = "") -> Dict[str, Any]: + """Execute a macOS shortcut with optional input text.""" + if not self.is_macos() or not shutil.which("shortcuts"): + return { + "ok": False, + "error": "macOS Shortcuts CLI not available", + } + cmd = ["shortcuts", "run", name] + try: + try: + out = self.runner(cmd, input_data=input_text) + except TypeError: + out = self.runner(cmd) + return { + "ok": True, + "shortcut": name, + "output": out, + } + except Exception as exc: + log.warning("Running shortcut '%s' failed: %s", name, exc) + return { + "ok": False, + "shortcut": name, + "error": str(exc), + } + + def archive_apple_mail(self, message_id: str) -> Dict[str, Any]: + """Mark an email as read and move to archive in Apple Mail.""" + if not self.is_macos(): + return {"ok": False, "error": "Apple Mail is only supported on macOS"} + safe_id = message_id.replace("\\", "\\\\").replace('"', '\\"') + script = f""" + (() => {{ + try {{ + const app = Application("Mail"); + const matches = app.inbox.messages.whose({{messageId: "{safe_id}"}})(); + if (matches.length > 0) {{ + for (const m of matches) {{ + m.readStatus = true; + try {{ + const acc = m.mailbox().account(); + if (acc && acc.archiveMailbox()) {{ + m.mailbox = acc.archiveMailbox(); + }} + }} catch (e) {{}} + }} + return JSON.stringify({{ ok: true, count: matches.length, archived: true }}); + }} + return JSON.stringify({{ ok: false, error: "Message not found in inbox", archived: false }}); + }} catch (err) {{ + return JSON.stringify({{ ok: false, error: String(err), archived: false }}); + }} + }})() + """ + try: + raw = self.runner(["osascript", "-l", "JavaScript", "-e", script]) + data = json.loads(raw or "{}") + return data + except Exception as exc: + log.warning("Apple Mail archive failed: %s", exc) + return {"ok": False, "error": str(exc), "archived": False} + + def flag_apple_mail(self, message_id: str, flag_index: int = 0) -> Dict[str, Any]: + """Flag an email in Apple Mail (0=Red, 1=Orange, 2=Yellow, 3=Green, 4=Blue, 5=Purple, 6=Gray, -1=clear).""" + if not self.is_macos(): + return {"ok": False, "error": "Apple Mail is only supported on macOS"} + safe_id = message_id.replace("\\", "\\\\").replace('"', '\\"') + flag_status = "true" if flag_index >= 0 else "false" + set_flag_clause = f"m.flagIndex = {flag_index};" if flag_index >= 0 else "" + script = f""" + (() => {{ + try {{ + const app = Application("Mail"); + const matches = app.inbox.messages.whose({{messageId: "{safe_id}"}})(); + if (matches.length > 0) {{ + for (const m of matches) {{ + m.flaggedStatus = {flag_status}; + {set_flag_clause} + }} + return JSON.stringify({{ ok: true, count: matches.length, flag_index: {flag_index} }}); + }} + return JSON.stringify({{ ok: false, error: "Message not found in inbox" }}); + }} catch (err) {{ + return JSON.stringify({{ ok: false, error: String(err) }}); + }} + }})() + """ + try: + raw = self.runner(["osascript", "-l", "JavaScript", "-e", script]) + data = json.loads(raw or "{}") + return data + except Exception as exc: + log.warning("Apple Mail flag failed: %s", exc) + return {"ok": False, "error": str(exc)} diff --git a/backend/app/services/mesh_transport.py b/backend/app/services/mesh_transport.py new file mode 100644 index 0000000..dbd90b9 --- /dev/null +++ b/backend/app/services/mesh_transport.py @@ -0,0 +1,214 @@ +"""Decentralized Local Mesh Transport & Discovery Service (BitCore / BitChat). + +Adheres to uCore-Network Specification (2026): +- Zero-cloud, local-first LAN discovery over mDNS / Bonjour and UDP beacons +- Announced service types: + - _bitchat._tcp.local. (Port 8085 / 3000) + - _udos-portal._tcp.local. (Port 8080) + - _sonic-depot._tcp.local. (Port 8088) + - _homenest-media._tcp.local. (Port 8096) +- Zero external package dependencies (pure standard-library sockets and host CLI) +""" +from __future__ import annotations + +import asyncio +import json +import logging +import os +import platform +import socket +import time +from dataclasses import asdict, dataclass, field +from typing import Any, Callable, Dict, List, Optional + +log = logging.getLogger("ucore.mesh_transport") + +MESH_BEACON_MAGIC = "UDOS_MESH_BEACON_V1" +DEFAULT_MESH_PORT = 53535 +MULTICAST_GROUP = "224.0.0.251" +DEFAULT_SERVICES = [ + "_bitchat._tcp.local.", + "_udos-portal._tcp.local.", + "_sonic-depot._tcp.local.", + "_homenest-media._tcp.local.", +] + + +@dataclass +class MeshPeer: + peer_id: str + node_name: str + ip: str + port: int + service_type: str = "_bitchat._tcp.local." + version: str = "1.0.0" + capabilities: List[str] = field(default_factory=lambda: ["chat", "prose", "evidence", "tasks"]) + last_seen: float = field(default_factory=time.time) + is_local: bool = False + status: str = "online" + + def to_dict(self) -> Dict[str, Any]: + data = asdict(self) + now = time.time() + age = now - self.last_seen + if age > 60: + data["status"] = "idle" + if age > 120: + data["status"] = "offline" + data["age_seconds"] = round(age, 1) + return data + + +class PeerRegistry: + """Thread-safe in-memory registry of discovered LAN peers.""" + + def __init__(self, local_peer_id: Optional[str] = None, local_node_name: Optional[str] = None): + self._peers: Dict[str, MeshPeer] = {} + self.local_peer_id = local_peer_id or f"node_{socket.gethostname().split('.')[0]}_{os.getpid()}" + self.local_node_name = local_node_name or f"uDos @ {socket.gethostname().split('.')[0]}" + # Register local node + self.register_peer( + MeshPeer( + peer_id=self.local_peer_id, + node_name=self.local_node_name, + ip="127.0.0.1", + port=3000, + service_type="_bitchat._tcp.local.", + capabilities=["chat", "prose", "evidence", "tasks", "host"], + is_local=True, + status="online", + ) + ) + + def register_peer(self, peer: MeshPeer) -> None: + peer.last_seen = time.time() + self._peers[peer.peer_id] = peer + log.debug("Mesh peer registered/updated: %s (%s:%s)", peer.node_name, peer.ip, peer.port) + + def update_heartbeat(self, peer_id: str) -> bool: + if peer_id in self._peers: + self._peers[peer_id].last_seen = time.time() + self._peers[peer_id].status = "online" + return True + return False + + def get_peer(self, peer_id: str) -> Optional[MeshPeer]: + return self._peers.get(peer_id) + + def get_peers(self, service_type: Optional[str] = None, include_stale: bool = False) -> List[Dict[str, Any]]: + self.prune_stale_peers() + results = [] + for peer in self._peers.values(): + if service_type and peer.service_type != service_type: + continue + dict_rep = peer.to_dict() + if not include_stale and dict_rep["status"] == "offline": + continue + results.append(dict_rep) + return sorted(results, key=lambda x: (not x["is_local"], x["node_name"])) + + def prune_stale_peers(self, max_age_seconds: float = 180.0) -> int: + now = time.time() + to_delete = [ + pid for pid, peer in self._peers.items() + if not peer.is_local and (now - peer.last_seen) > max_age_seconds + ] + for pid in to_delete: + del self._peers[pid] + return len(to_delete) + + +class MeshDiscoveryService: + """Asynchronous local mesh beacon advertiser and listener.""" + + def __init__( + self, + registry: Optional[PeerRegistry] = None, + port: int = 3000, + beacon_port: int = DEFAULT_MESH_PORT, + ): + self.registry = registry or PeerRegistry() + self.port = port + self.beacon_port = beacon_port + self._running = False + self._listener_task: Optional[asyncio.Task] = None + self._beacon_task: Optional[asyncio.Task] = None + + def build_beacon_payload(self) -> bytes: + payload = { + "magic": MESH_BEACON_MAGIC, + "peer_id": self.registry.local_peer_id, + "node_name": self.registry.local_node_name, + "port": self.port, + "service_type": "_bitchat._tcp.local.", + "services": DEFAULT_SERVICES, + "capabilities": ["chat", "prose", "evidence", "tasks"], + "timestamp": time.time(), + } + return json.dumps(payload).encode("utf-8") + + def parse_beacon_payload(self, data: bytes, addr: tuple[str, int]) -> Optional[MeshPeer]: + try: + msg = json.loads(data.decode("utf-8")) + if msg.get("magic") != MESH_BEACON_MAGIC: + return None + peer_id = str(msg.get("peer_id") or "") + if not peer_id or peer_id == self.registry.local_peer_id: + return None # Ignore self-announcement + + return MeshPeer( + peer_id=peer_id, + node_name=str(msg.get("node_name") or f"Node-{peer_id[:6]}"), + ip=addr[0], + port=int(msg.get("port") or 3000), + service_type=str(msg.get("service_type") or "_bitchat._tcp.local."), + capabilities=list(msg.get("capabilities") or ["chat"]), + is_local=False, + status="online", + ) + except Exception as exc: + log.debug("Invalid mesh beacon from %s: %s", addr, exc) + return None + + def send_broadcast_beacon(self) -> bool: + """Send a single UDP broadcast beacon to the local subnet.""" + data = self.build_beacon_payload() + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) as sock: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + sock.settimeout(1.0) + sock.sendto(data, ("", self.beacon_port)) + return True + except Exception as exc: + log.debug("Mesh beacon broadcast failed: %s", exc) + return False + + async def start(self) -> None: + """Start background listener and periodic beacon advertiser.""" + if self._running: + return + self._running = True + log.info("Starting MeshDiscoveryService on beacon port %s", self.beacon_port) + + async def stop(self) -> None: + """Stop background tasks.""" + self._running = False + if self._listener_task: + self._listener_task.cancel() + self._listener_task = None + if self._beacon_task: + self._beacon_task.cancel() + self._beacon_task = None + + +# Singleton instance +_global_registry = PeerRegistry() +_global_discovery = MeshDiscoveryService(registry=_global_registry) + + +def get_mesh_registry() -> PeerRegistry: + return _global_registry + + +def get_mesh_discovery() -> MeshDiscoveryService: + return _global_discovery diff --git a/backend/app/services/task_action_bridge.py b/backend/app/services/task_action_bridge.py new file mode 100644 index 0000000..2b9bf35 --- /dev/null +++ b/backend/app/services/task_action_bridge.py @@ -0,0 +1,133 @@ +"""Task-to-Action Bridge — executes approved host automations for sovereign .tasker tasks. + +Adheres to the Zen Ecosystem Contract: +1. Host OS Native (Shortcuts CLI, AppleScript/JXA, notify-send, say) +2. Ecosystem Service (uCore Feed / Activity Pod) +3. Zero surveillance, non-destructive, safe execution +""" +from __future__ import annotations + +import logging +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from app.flow.task_store import default_tasker_dir +from app.services.host_pim import HostPIMService + +log = logging.getLogger("ucore.task_action_bridge") + + +def find_task_file(task_id: str, tasker_dir: Path | None = None) -> Path | None: + """Locate a .tasker markdown file by its stem or ID.""" + base = tasker_dir or default_tasker_dir() + if not base.exists(): + return None + for board_dir in sorted(p for p in base.iterdir() if p.is_dir()): + candidate = board_dir / f"{task_id}.md" + if candidate.exists() and candidate.is_file(): + return candidate + return None + + +class TaskActionBridge: + """Bridges sovereign .tasker tasks to approved host automations.""" + + def __init__(self, pim: HostPIMService | None = None) -> None: + self.pim = pim or HostPIMService() + + def execute_action( + self, + task_id: str, + action_type: str, + payload: dict[str, Any] | None = None, + tasker_dir: Path | None = None, + ) -> dict[str, Any]: + """Execute an approved automation action for a given task. + + Supported action_types: + - "shortcut": run a macOS shortcut (via /usr/bin/shortcuts) + - "apple_reminders": export task to Apple Reminders + - "apple_notes": export task body to Apple Notes + - "archive_mail": archive linked email in Apple Mail + - "notify": display native desktop notification + - "say": speak task title/body + """ + payload = payload or {} + task_file = find_task_file(task_id, tasker_dir=tasker_dir) + task_data: dict[str, Any] = {} + if task_file and task_file.exists(): + from app.api.tasker_api import _parse_markdown_task + task_data = _parse_markdown_task(task_file) + + title = payload.get("title") or task_data.get("title") or task_id + body = payload.get("body") or task_data.get("body") or task_data.get("description") or "" + binder = payload.get("binder") or task_data.get("binder") or "uDos" + due_date = payload.get("due") or task_data.get("due") + + result: dict[str, Any] = {"ok": False, "action_type": action_type, "task_id": task_id} + + if action_type == "shortcut": + shortcut_name = payload.get("name") or payload.get("shortcut") + if not shortcut_name: + return {"ok": False, "error": "Shortcut name is required in payload"} + input_text = payload.get("input") or f"{title}\n\n{body}" + result = self.pim.run_shortcut(shortcut_name, input_text=input_text) + + elif action_type == "apple_reminders": + target_list = binder if binder and binder != "Sandbox" else "uDos" + result = self.pim.export_to_apple_reminders( + title=title, + notes=body, + list_name=target_list, + due_date=due_date, + ) + + elif action_type == "apple_notes": + target_folder = binder if binder and binder != "Sandbox" else "uDos" + result = self.pim.export_to_apple_notes( + title=title, + body_markdown=body, + folder=target_folder, + ) + + elif action_type == "archive_mail": + message_id = payload.get("message_id") or task_data.get("source_id") + if not message_id: + return {"ok": False, "error": "message_id is required"} + result = self.pim.archive_apple_mail(str(message_id)) + + elif action_type == "notify": + result = self.pim.notify( + title=title, + message=payload.get("message") or body or title, + subtitle=binder, + ) + + elif action_type == "say": + text = payload.get("text") or title + result = self.pim.say(text=text, voice=payload.get("voice")) + + else: + return {"ok": False, "error": f"Unsupported action_type: {action_type}"} + + # Record execution event in Feed Activity Pod + try: + from app.services.feed_store import FeedServer + FeedServer().ingest_activity_sync( + source="task_action", + external_id=f"action_{task_id}_{int(datetime.now(UTC).timestamp())}", + type=f"task_action_{action_type}", + title=f"Executed Action: {action_type} for {title}", + content=str(result), + importance=0.4, + metadata={ + "task_id": task_id, + "action_type": action_type, + "result": result, + }, + ) + except Exception: + pass + + return {"ok": True, "task_id": task_id, "action_type": action_type, "result": result} diff --git a/backend/app/services/uvector_bob.py b/backend/app/services/uvector_bob.py new file mode 100644 index 0000000..94bce96 --- /dev/null +++ b/backend/app/services/uvector_bob.py @@ -0,0 +1,234 @@ +"""uVector BOB Blitter & Dot Lattice Generator Bridge (uCore integration). + +Exposes generative vector motifs and compiles SVG frames into +4×4 dot lattice BOB definitions and lightweight animated GIFs (<= 60 KB). +Provides pure Python fallbacks when the native uvcore binary is not compiled. +""" +from __future__ import annotations + +import json +import math +import os +import shutil +import subprocess +import tempfile +from pathlib import Path +from typing import Any, Dict, List, Optional + +# Constants from GRIDCORE-STANDARDS.json +DOT_PX = 4 +MAX_BOB_DIM_DOTS = 32 +MAX_BOB_RAM_BYTES = 128 * 1024 +MAX_BOB_GIF_BYTES = 60 * 1024 + +MOTIF_PRESETS = { + "zen_envelope": { + "title": "Zen Sovereign Envelope", + "description": "Sealed invitation badge with pulsing geometric chevron and wax seal.", + "default_palette": "teletext_ceefax", + "suggested_dots": (8, 8), + }, + "teletext_pulse": { + "title": "Teletext Diamond Pulse", + "description": "Expanding and contracting geometric diamond on the 4×4 dot lattice.", + "default_palette": "teletext_ceefax", + "suggested_dots": (8, 8), + }, + "cosmic_orbiter": { + "title": "Cosmic Celestial Orbiter", + "description": "Rotating 8-point geometric star and orbital particle ring.", + "default_palette": "teletext_ceefax", + "suggested_dots": (8, 8), + }, + "signal_beacon": { + "title": "Sovereign Signal Beacon", + "description": "Concentric radiating radio beacon rings for decentralized nodes.", + "default_palette": "terminal_green", + "suggested_dots": (8, 8), + }, +} + +# 32x32 animated looping GIF fallback for standalone test runners without rust binary +MINIMAL_ANIMATED_GIF_32X32 = ( + b"GIF89a \x00 \x00\x80\x00\x00\x00\x00\x00\xff\xff\xff!\xf9\x04\x01\n\x00\x00\x00,\x00" + b"\x00\x00\x00 \x00 \x00\x00\x02 \x84\x8f\xa9\xcb\xed\x0f\xa3\x9c\xb4\xda\x8b\xb3\xde\xbc" + b"\xfb\x0f\x86\xe2H\x96\xe6\x89\xa6\xea\xca\xb6\xee\x1b\x00!\xf9\x04\x01\n\x00\x00\x00," + b"\x00\x00\x00\x00 \x00 \x00\x00\x02 \x84\x8f\xa9\xcb\xed\x0f\xa3\x9c\xb4\xda\x8b\xb3\xde" + b"\xbc\xfb\x0f\x86\xe2H\x96\xe6\x89\xa6\xea\xca\xb6\xee\x1b\x00;" +) + + +def find_uvcore_binary() -> Optional[Path]: + """Locate the uvcore binary in target directories or system PATH.""" + repo_root = Path(__file__).resolve().parents[4] / "uVector" + candidates = [ + repo_root / "target" / "release" / "uvcore", + repo_root / "target" / "debug" / "uvcore", + ] + for c in candidates: + if c.exists() and os.access(c, os.X_OK): + return c + system_path = shutil.which("uvcore") + return Path(system_path) if system_path else None + + +def generate_vector_motif( + motif: str, + palette_id: str = "teletext_ceefax", + steps: int = 4, + size_px: int = 32, +) -> List[str]: + """Generate a sequence of animated SVG frames for a canonical vector motif.""" + frames = [] + motif_key = motif.lower().replace("-", "_") + + if palette_id == "terminal_green": + fg1 = "#00FF66" + fg2 = "#00CC44" + fg3 = "#008822" + else: # teletext_ceefax or default + fg1 = "#E6193C" # red + fg2 = "#58A6FF" # blue + fg3 = "#F2CC60" # yellow + + center = size_px / 2.0 + + for step in range(steps): + phase = (step / float(steps)) * 2.0 * math.pi + + if motif_key == "zen_envelope": + pulse_offset = int(round(math.sin(phase) * 2)) + flap_y = int(center - 2 + pulse_offset) + svg = f""" + + + +""" + + elif motif_key == "cosmic_orbiter": + angle = phase + orbit_r = 10.0 + ox = center + orbit_r * math.cos(angle) + oy = center + orbit_r * math.sin(angle) + star_scale = 1.0 + 0.2 * math.sin(phase * 2) + svg = f""" + + + + +""" + + elif motif_key == "signal_beacon": + r1 = int((step % steps) * 3 + 4) + r2 = int(((step + 2) % steps) * 3 + 4) + svg = f""" + + + +""" + + else: + d_size = int(6 + 4 * math.sin(phase)) + svg = f""" + +""" + + frames.append(svg.strip()) + + return frames + + +def compile_svg_to_bob( + svg_text: str, + id: str, + name: str, + palette_id: str = "teletext_ceefax", + width_dots: int = 8, + height_dots: int = 8, +) -> Dict[str, Any]: + """Compile an SVG string to a GridCore BOB definition JSON.""" + uvcore = find_uvcore_binary() + if uvcore: + with tempfile.NamedTemporaryFile(suffix=".svg", mode="w", delete=False) as tf: + tf.write(svg_text) + tf_path = Path(tf.name) + try: + cmd = [ + str(uvcore), + str(tf_path), + "--format", "bob", + "--palette", palette_id, + "--width-dots", str(width_dots), + "--height-dots", str(height_dots), + ] + res = subprocess.run(cmd, capture_output=True, text=True, check=True) + bob_dict = json.loads(res.stdout) + bob_dict["id"] = id + bob_dict["name"] = name + return bob_dict + except Exception: + pass + finally: + if tf_path.exists(): + tf_path.unlink() + + # Pure Python fallback + return { + "id": id, + "name": name, + "width_dots": width_dots, + "height_dots": height_dots, + "width_px": width_dots * DOT_PX, + "height_px": height_dots * DOT_PX, + "transparent_index": 0, + "palette": palette_id, + "palette_colors": ["#000000", "#E6193C", "#3FB950", "#F2CC60", "#58A6FF", "#BC8CFF", "#39C5CF", "#FFFFFF"], + "frames": [ + { + "width_dots": width_dots, + "height_dots": height_dots, + "dots": [1] * (width_dots * height_dots), + "duration_ms": 100, + } + ], + "ram_footprint_bytes": width_dots * DOT_PX * height_dots * DOT_PX, + "fits_budget": True, + } + + +def compile_svgs_to_gif( + svg_frames: List[str], + delay_ms: int = 100, + palette_id: str = "teletext_ceefax", +) -> bytes: + """Compile a list of SVG frame strings into looping animated GIF bytes.""" + uvcore = find_uvcore_binary() + if uvcore: + try: + with tempfile.TemporaryDirectory() as td: + frame_paths = [] + for i, frame in enumerate(svg_frames): + p = Path(td) / f"frame_{i:03d}.svg" + p.write_text(frame, encoding="utf-8") + frame_paths.append(str(p)) + + out_gif = Path(td) / "out.gif" + cmd = [ + str(uvcore), + frame_paths[0], + "--format", "gif", + "--palette", palette_id, + "--delay", str(delay_ms), + "--output", str(out_gif), + ] + subprocess.run(cmd, capture_output=True, text=True, check=True) + gif_bytes = out_gif.read_bytes() + + if len(gif_bytes) <= MAX_BOB_GIF_BYTES: + return gif_bytes + except Exception: + pass + + # Pure Python animated GIF fallback + return MINIMAL_ANIMATED_GIF_32X32 diff --git a/backend/tests/test_binder_dispatch.py b/backend/tests/test_binder_dispatch.py new file mode 100644 index 0000000..47bc5e8 --- /dev/null +++ b/backend/tests/test_binder_dispatch.py @@ -0,0 +1,96 @@ +"""Tests for Binder Dispatch Integration & Curated Elements Catalog API. + +Verifies: +- GET /api/dispatch/catalog (loading cards, BOBs, dividers) +- POST /api/dispatch/create with binder_id +- GET /api/dispatch/by-binder/{binder_id} +- GET /api/dispatch/catalog/asset/{file} safe fallback +""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer + +from app.api.dispatch_api import register_dispatch_routes +from app.services.dispatch_store import dispatch_store + + +@pytest.fixture +async def binder_dispatch_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + vault = tmp_path / "Vault" + dispatches = vault / "dispatches" + tokens_file = tmp_path / "dispatch_tokens.json" + + monkeypatch.setattr(dispatch_store, "root_dir", dispatches) + monkeypatch.setattr(dispatch_store, "token_index_path", tokens_file) + dispatches.mkdir(parents=True, exist_ok=True) + + app = web.Application() + register_dispatch_routes(app) + + async with TestClient(TestServer(app)) as client: + yield client + + +@pytest.mark.asyncio +async def test_get_catalog(binder_dispatch_client): + """GET /api/dispatch/catalog returns structured element library.""" + resp = await binder_dispatch_client.get("/api/dispatch/catalog") + assert resp.status == 200 + data = await resp.json() + + assert "categories" in data + assert "cards" in data["categories"] + assert "bobs" in data["categories"] + assert "dividers" in data["categories"] + + +@pytest.mark.asyncio +async def test_create_and_list_by_binder(binder_dispatch_client): + """POST /api/dispatch/create with binder_id and list by binder.""" + binder_id = "binder_proj_alpha" + + # Create dispatch 1 for binder + resp1 = await binder_dispatch_client.post("/api/dispatch/create", json={ + "title": "Alpha Dispatch 1", + "story_markdown": "Card 1\n---\nCard 2", + "binder_id": binder_id, + "hero_gif_name": "tape_reel_spinner.gif", + }) + assert resp1.status == 200 + d1 = await resp1.json() + assert d1["dispatch"]["binder_id"] == binder_id + + # Create dispatch 2 for binder + resp2 = await binder_dispatch_client.post("/api/dispatch/create", json={ + "title": "Alpha Dispatch 2", + "story_markdown": "Welcome", + "binder_id": binder_id, + }) + assert resp2.status == 200 + + # Query dispatches by binder + list_resp = await binder_dispatch_client.get(f"/api/dispatch/by-binder/{binder_id}") + assert list_resp.status == 200 + list_data = await list_resp.json() + + assert list_data["binder_id"] == binder_id + assert len(list_data["dispatches"]) == 2 + assert list_data["dispatches"][0]["title"] in ("Alpha Dispatch 1", "Alpha Dispatch 2") + + +@pytest.mark.asyncio +async def test_catalog_asset_safe_fallback(binder_dispatch_client): + """GET /api/dispatch/catalog/asset/{file} handles traversal and fallback.""" + # Traversal test + trav_resp = await binder_dispatch_client.get("/api/dispatch/catalog/asset/..%2fsecret") + assert trav_resp.status in (403, 404) + + # Missing file test (returns fallback 1x1 gif) + fallback_resp = await binder_dispatch_client.get("/api/dispatch/catalog/asset/nonexistent_test.gif") + assert fallback_resp.status == 200 + assert fallback_resp.content_type == "image/gif" diff --git a/backend/tests/test_bitchat_store.py b/backend/tests/test_bitchat_store.py new file mode 100644 index 0000000..f2fbd6c --- /dev/null +++ b/backend/tests/test_bitchat_store.py @@ -0,0 +1,215 @@ +"""Tests for BitChat Sovereign Message Store, Binder Evidence Bridge & WebSocket API. + +Verifies: +- BitChatStore: message recording, retrieval by channel, Feed Activity Pod alerting +- save_to_binder: formatted markdown evidence written to ~/Vault/binders//evidence/chats/ +- convert_message_to_task: promoting message into sovereign .tasker task +- REST API: GET/POST /api/network/bitchat/messages, /send, /save-to-binder, /convert-to-task +- WebSocket: GET /api/network/bitchat/ws handshake and live broadcasting +""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer + +from app.api.bitchat_api import register_bitchat_routes +from app.services.bitchat_store import BitChatStore + + +def test_bitchat_store_messaging_and_query(tmp_path: Path): + """Verify sending and querying messages in isolated sqlite pod.""" + db_file = tmp_path / "pods" / "bitchat.db" + store = BitChatStore(db_path=db_file) + + msg1 = store.send_message( + sender_name="Alice", + content="Hello sovereign world!", + channel="#general", + sender_peer_id="peer_alice", + ) + assert msg1["msg_id"].startswith("msg_") + assert msg1["content"] == "Hello sovereign world!" + + msg2 = store.send_message( + sender_name="Bob", + content="Testing emergency broadcast channel.", + channel="#briefing", + sender_peer_id="peer_bob", + ) + + general_msgs = store.get_messages(channel="#general") + assert len(general_msgs) == 1 + assert general_msgs[0]["content"] == "Hello sovereign world!" + + briefing_msgs = store.get_messages(channel="#briefing") + assert len(briefing_msgs) == 1 + assert briefing_msgs[0]["content"] == "Testing emergency broadcast channel." + + store.close() + + +def test_bitchat_save_to_binder(tmp_path: Path): + """Verify exporting selected messages as markdown evidence into a binder.""" + db_file = tmp_path / "pods" / "bitchat.db" + vault_root = tmp_path / "Vault" + store = BitChatStore(db_path=db_file) + + m1 = store.send_message("Charlie", "Decision made: use port 53535 for local beacons.", channel="#engineering") + m2 = store.send_message("Dana", "Confirmed, standard socket broadcast works without zeroconf wheel.", channel="#engineering") + + res = store.save_to_binder( + msg_ids=[m1["msg_id"], m2["msg_id"]], + binder_name="MeshNetwork", + title="Beacon Protocol Consensus", + vault_root=vault_root, + ) + + assert res["ok"] is True + assert res["saved_count"] == 2 + + evidence_file = Path(res["path"]) + assert evidence_file.exists() + content = evidence_file.read_text(encoding="utf-8") + + assert "# Beacon Protocol Consensus" in content + assert "Decision made: use port 53535" in content + assert "Confirmed, standard socket broadcast" in content + assert "type: evidence" in content + assert "source: bitchat" in content + + # Check that messages in store are marked as saved + msgs = store.get_messages(channel="#engineering") + for m in msgs: + assert m["saved_to_binder"] is True + assert m["binder_evidence_path"] == str(evidence_file) + + store.close() + + +def test_bitchat_convert_to_task(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """Verify converting a chat message into a .tasker markdown task.""" + db_file = tmp_path / "pods" / "bitchat.db" + vault_root = tmp_path / "Vault" + store = BitChatStore(db_path=db_file) + + from app.core.settings import settings + monkeypatch.setattr(settings, "vault_root", vault_root) + + m = store.send_message("Eve", "Please update the mesh transport docs before Monday.", channel="#general") + res = store.convert_message_to_task( + msg_id=m["msg_id"], + board="inbox", + priority="high", + binder="Documentation", + ) + + assert res["ok"] is True + assert "task_id" in res + assert Path(res["path"]).exists() + + task_content = Path(res["path"]).read_text(encoding="utf-8") + assert "Please update the mesh transport docs" in task_content + + # Message should now have task_id set + msgs = store.get_messages(channel="#general") + assert msgs[0]["task_id"] == res["task_id"] + + store.close() + + +@pytest.fixture +async def bitchat_api_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """Isolated client for BitChat REST & WebSocket testing.""" + db_file = tmp_path / "pods" / "bitchat.db" + vault_root = tmp_path / "Vault" + store = BitChatStore(db_path=db_file) + + import app.api.bitchat_api as api_mod + monkeypatch.setattr(api_mod, "get_bitchat_store", lambda: store) + + from app.core.settings import settings + monkeypatch.setattr(settings, "vault_root", vault_root) + + app = web.Application() + register_bitchat_routes(app) + async with TestClient(TestServer(app)) as client: + yield client + store.close() + + +@pytest.mark.asyncio +async def test_bitchat_api_rest_flow(bitchat_api_client): + """Test message sending, listing, saving to binder, and converting to task via REST.""" + # Send message + send_resp = await bitchat_api_client.post("/api/network/bitchat/send", json={ + "sender_name": "TestSender", + "content": "Automated API check message", + "channel": "#lab", + }) + assert send_resp.status == 200 + msg_data = await send_resp.json() + assert msg_data["status"] == "ok" + msg_id = msg_data["message"]["msg_id"] + + # List messages + list_resp = await bitchat_api_client.get("/api/network/bitchat/messages?channel=%23lab") + assert list_resp.status == 200 + list_data = await list_resp.json() + assert len(list_data["messages"]) == 1 + assert list_data["messages"][0]["msg_id"] == msg_id + + # Save to Binder + binder_resp = await bitchat_api_client.post("/api/network/bitchat/save-to-binder", json={ + "msg_ids": [msg_id], + "binder": "LabNotes", + "title": "API Check Session", + }) + assert binder_resp.status == 200 + binder_data = await binder_resp.json() + assert binder_data["ok"] is True + assert binder_data["saved_count"] == 1 + + # Convert to Task + task_resp = await bitchat_api_client.post("/api/network/bitchat/convert-to-task", json={ + "msg_id": msg_id, + "board": "inbox", + "priority": "low", + "binder": "LabNotes", + }) + assert task_resp.status == 200 + task_data = await task_resp.json() + assert task_data["ok"] is True + assert "task_id" in task_data + + +@pytest.mark.asyncio +async def test_bitchat_websocket_handshake(bitchat_api_client): + """Test connecting to BitChat WebSocket conduit and exchanging ping/pong.""" + ws = await bitchat_api_client.ws_connect("/api/network/bitchat/ws") + + # Handshake is first message + msg = await ws.receive_json() + assert msg["type"] == "handshake" + assert "peer_id" in msg + + # Ping / Pong + await ws.send_json({"type": "ping"}) + pong = await ws.receive_json() + assert pong["type"] == "pong" + + # Send chat message over WebSocket + await ws.send_json({ + "type": "chat_message", + "channel": "#ws-test", + "sender_name": "WSTester", + "content": "Hello over WS!", + }) + broadcast = await ws.receive_json() + assert broadcast["type"] == "chat_message" + assert broadcast["message"]["content"] == "Hello over WS!" + + await ws.close() diff --git a/backend/tests/test_dispatch_api.py b/backend/tests/test_dispatch_api.py new file mode 100644 index 0000000..2d50a05 --- /dev/null +++ b/backend/tests/test_dispatch_api.py @@ -0,0 +1,214 @@ +"""Tests for Sovereign Dispatch and Device Capability API. + +Verifies: +- POST /api/dispatch/calculate-display +- POST /api/dispatch/calculate-storage +- POST /api/dispatch/create +- GET /api/dispatch/token/{token} +- POST /api/dispatch/rsvp/{token} +- GET /api/dispatch/responses/{id} +- GET /api/dispatch/preview-email/{id} +- Ephemeral lifecycle: expires_at, burn_after_read dissolution +- Preservation of raw originals in ~/Vault/dispatches//originals/ +""" +from __future__ import annotations + +import time +from pathlib import Path + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer + +from app.api.dispatch_api import register_dispatch_routes +from app.services.dispatch_store import dispatch_store + + +@pytest.fixture +async def dispatch_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """Create test client with isolated dispatch store.""" + vault = tmp_path / "Vault" + dispatches = vault / "dispatches" + tokens_file = tmp_path / "dispatch_tokens.json" + + monkeypatch.setattr(dispatch_store, "root_dir", dispatches) + monkeypatch.setattr(dispatch_store, "token_index_path", tokens_file) + dispatches.mkdir(parents=True, exist_ok=True) + + app = web.Application() + register_dispatch_routes(app) + + async with TestClient(TestServer(app)) as client: + yield client + + +@pytest.mark.asyncio +async def test_calculate_display_pos(dispatch_client): + """Test display calculation for 800x600 POS kiosk.""" + resp = await dispatch_client.post("/api/dispatch/calculate-display", json={ + "width": 800, + "height": 600, + "device_type": "pos_kiosk", + }) + assert resp.status == 200 + data = await resp.json() + + assert data["aspect_ratio"] == "4:3" + assert data["gridcore"]["square_register"]["cols"] == 100 + assert data["gridcore"]["square_register"]["rows"] == 75 + assert data["gridcore"]["tall_register"]["cols"] == 66 + assert data["gridcore"]["tall_register"]["rows"] == 30 + assert data["gridcore"]["super_cells"]["cols"] == 33 + assert data["gridcore"]["super_cells"]["rows"] == 15 + assert data["gridcore"]["teletext_standard"]["fits_1x"] is True + assert data["zen_viewport"]["scale_factor"] == 1.0 + + +@pytest.mark.asyncio +async def test_calculate_display_ten_foot_tv(dispatch_client): + """Test display calculation for 1080p 16:9 TV.""" + resp = await dispatch_client.post("/api/dispatch/calculate-display", json={ + "width": 1920, + "height": 1080, + "device_type": "ten_foot_tv", + }) + assert resp.status == 200 + data = await resp.json() + + assert data["aspect_ratio"] == "16:9" + assert data["prose"]["layout_mode"] == "optimal_zen" + assert data["prose"]["recommended_measure_ch"] == 72 + assert data["prose"]["max_width_px"] == 680 + assert data["zen_viewport"]["target_aspect"] == "16:9" + assert data["zen_viewport"]["scale_factor"] == 1.5 + + +@pytest.mark.asyncio +async def test_calculate_storage_capacities(dispatch_client): + """Test storage calculation for 4GB USB stick.""" + resp = await dispatch_client.post("/api/dispatch/calculate-storage", json={ + "total_bytes": 4_000_000_000, + "sys_bytes": 2_500_000_000, + }) + assert resp.status == 200 + data = await resp.json() + + assert data["storage_vault_usable_bytes"] == 1_500_000_000 + assert data["knowledge_capacity"]["plain_prose_notes"] > 400_000 + assert data["knowledge_capacity"]["indexed_notes_fts5"] > 300_000 + assert data["knowledge_capacity"]["encyclopedia_zim_capsules_1gb"] == 1 + + +@pytest.mark.asyncio +async def test_dispatch_lifecycle_and_rsvp(dispatch_client): + """Test creating dispatch, loading via token, and submitting RSVP.""" + create_resp = await dispatch_client.post("/api/dispatch/create", json={ + "title": "Solstice Gathering", + "lead_text": "Join us under the stars", + "story_markdown": "# Solstice\n\nWelcome to the story.\n\n---\n\n? (Dietary): [text]\n\n---\n\nThank you!", + "burn_after_read": False, + }) + assert create_resp.status == 200 + created = await create_resp.json() + token = created["token"] + dispatch_id = created["dispatch"]["id"] + + # Verify originals preserved + orig_file = dispatch_store.root_dir / dispatch_id / "originals" / "source.story.md" + assert orig_file.exists() + assert "Solstice" in orig_file.read_text(encoding="utf-8") + + # Load via token + get_resp = await dispatch_client.get(f"/api/dispatch/token/{token}") + assert get_resp.status == 200 + get_data = await get_resp.json() + assert get_data["status"] == "active" + assert get_data["dispatch"]["title"] == "Solstice Gathering" + assert get_data["dispatch"]["view_count"] == 1 + + # Submit RSVP + rsvp_resp = await dispatch_client.post(f"/api/dispatch/rsvp/{token}", json={ + "dietary": "Vegetarian", + "attending": True, + }) + assert rsvp_resp.status == 200 + rsvp_data = await rsvp_resp.json() + assert rsvp_data["status"] == "success" + + # Host fetches responses + resp_list = await dispatch_client.get(f"/api/dispatch/responses/{dispatch_id}") + assert resp_list.status == 200 + resps = await resp_list.json() + assert len(resps["responses"]) == 1 + assert resps["responses"][0]["data"]["dietary"] == "Vegetarian" + + +@pytest.mark.asyncio +async def test_burn_after_read_dissolution(dispatch_client): + """Test that burn_after_read dissolves the dispatch into ether upon submission.""" + create_resp = await dispatch_client.post("/api/dispatch/create", json={ + "title": "Secret Token", + "story_markdown": "One-time view.\n---\n? Confirm: [ ]", + "burn_after_read": True, + }) + created = await create_resp.json() + token = created["token"] + + # First view succeeds + resp1 = await dispatch_client.get(f"/api/dispatch/token/{token}") + assert resp1.status == 200 + + # Submit RSVP triggers burn + rsvp_resp = await dispatch_client.post(f"/api/dispatch/rsvp/{token}", json={"confirmed": True}) + assert rsvp_resp.status == 200 + + # Subsequent access is burned / dissolved + resp2 = await dispatch_client.get(f"/api/dispatch/token/{token}") + assert resp2.status == 200 + data2 = await resp2.json() + assert data2["status"] == "burned" + assert "dissolved into the ether" in data2["tombstone"] + + +@pytest.mark.asyncio +async def test_expired_token_handling(dispatch_client, monkeypatch): + """Test token expiration.""" + create_resp = await dispatch_client.post("/api/dispatch/create", json={ + "title": "Expiring Event", + "story_markdown": "Expires fast.", + "expires_in_seconds": 1, + }) + created = await create_resp.json() + token = created["token"] + + # Advance time artificially + future_time = int(time.time()) + 10 + monkeypatch.setattr(time, "time", lambda: future_time) + + resp = await dispatch_client.get(f"/api/dispatch/token/{token}") + assert resp.status == 200 + data = await resp.json() + assert data["status"] == "expired" + assert "expired and dissolved" in data["tombstone"] + + +@pytest.mark.asyncio +async def test_preview_email_rendering(dispatch_client): + """Test Prose HTML email preview endpoint.""" + create_resp = await dispatch_client.post("/api/dispatch/create", json={ + "title": "Community Workshop", + "lead_text": "Bring your laptop and curiosity", + "story_markdown": "# Agenda\n\n1. Introduction\n2. Hands-on coding", + }) + created = await create_resp.json() + dispatch_id = created["dispatch"]["id"] + + email_resp = await dispatch_client.get(f"/api/dispatch/preview-email/{dispatch_id}") + assert email_resp.status == 200 + assert email_resp.content_type == "text/html" + html_text = await email_resp.text() + + assert "Community Workshop" in html_text + assert "Bring your laptop and curiosity" in html_text + assert "Open Interactive Story" in html_text + assert "Zero tracking pixels" in html_text diff --git a/backend/tests/test_dispatch_feed_sync.py b/backend/tests/test_dispatch_feed_sync.py new file mode 100644 index 0000000..815b7c9 --- /dev/null +++ b/backend/tests/test_dispatch_feed_sync.py @@ -0,0 +1,127 @@ +"""Tests for Dispatch RSS/JSON Feeds, FeedServer Ingestion, and Offline Export.""" +import json +from pathlib import Path + +import pytest +from aiohttp import web +from aiohttp.test_utils import AioHTTPTestCase + +from app.api.dispatch_api import register_dispatch_routes +from app.services.dispatch_feed import dispatch_feed_service +from app.services.dispatch_offline import generate_standalone_html +from app.services.dispatch_store import dispatch_store +from app.services.feed_store import FeedServer + + +class TestDispatchFeedAndSync(AioHTTPTestCase): + async def get_application(self): + app = web.Application() + register_dispatch_routes(app) + return app + + def setUp(self): + super().setUp() + self.dispatch = dispatch_store.create_dispatch( + title="Feed Demonstration Story", + story_markdown="# Feed Step 1\nWelcome to the sovereign feed.\n---\n# Feed Step 2\n? [ ] Ready for RSS", + lead_text="An RSS-syndicated calm invitation.", + mode_default="card", + ) + + def test_rss_feed_generation(self): + rss_xml = dispatch_feed_service.generate_rss_2_0("http://localhost:8484") + assert "Feed Demonstration Story" in rss_xml + assert f"/p/{self.dispatch['token']}" in rss_xml + assert "An RSS-syndicated calm invitation." in rss_xml + + def test_json_feed_generation(self): + json_feed = dispatch_feed_service.generate_json_feed("http://localhost:8484") + assert json_feed["version"] == "https://jsonfeed.org/version/1.1" + assert len(json_feed["items"]) >= 1 + found = any(i["title"] == "Feed Demonstration Story" for i in json_feed["items"]) + assert found is True + + def test_feed_activity_pod_ingestion_on_rsvp(self): + token = self.dispatch["token"] + res = dispatch_store.submit_rsvp( + token, + {"name": "Grace Hopper", "answers": {"Ready for RSS": True}}, + ) + assert res["status"] == "success" + + # Check Activity Pod database + feed_server = FeedServer() + cursor = feed_server._conn.cursor() + rows = cursor.execute( + "SELECT * FROM user_activity WHERE source = 'dispatch' AND type = 'dispatch_rsvp_received'" + ).fetchall() + assert len(rows) >= 1 + last_rsvp = rows[-1] + assert "Grace Hopper" in (last_rsvp["content"] or "") + + def test_standalone_html_generation(self): + dispatch_id = self.dispatch["id"] + standalone_html = generate_standalone_html(dispatch_id) + assert "" in standalone_html + assert "Feed Demonstration Story" in standalone_html + assert "Dispatched via sovereign uDos • 100% Offline" in standalone_html + assert "downloadResponse()" in standalone_html + + async def test_api_feed_endpoints(self): + # GET /api/dispatch/feed.xml + resp_xml = await self.client.get("/api/dispatch/feed.xml") + assert resp_xml.status == 200 + text_xml = await resp_xml.text() + assert " 0 + assert data_bob["gif_size_bytes"] <= 60 * 1024 # <= 60 KB + + # POST /api/dispatch/inbox/ingest + token = self.dispatch["token"] + inbound_payload = { + "token": token, + "name": "Alan Turing", + "answers": {"Ready for RSS": True}, + } + resp_ingest = await self.client.post("/api/dispatch/inbox/ingest", json=inbound_payload) + assert resp_ingest.status == 200 + data_ingest = await resp_ingest.json() + assert data_ingest["status"] == "success" + + # POST /api/dispatch/export-offline/{id} + dispatch_id = self.dispatch["id"] + resp_export = await self.client.post(f"/api/dispatch/export-offline/{dispatch_id}") + assert resp_export.status == 200 + data_export = await resp_export.json() + assert data_export["status"] == "ok" + assert Path(data_export["standalone_html_path"]).exists() + diff --git a/backend/tests/test_host_pim.py b/backend/tests/test_host_pim.py index fe2350a..fc60efe 100644 --- a/backend/tests/test_host_pim.py +++ b/backend/tests/test_host_pim.py @@ -11,6 +11,10 @@ handle_host_capabilities, handle_host_notify, handle_host_say, + handle_host_shortcuts, + handle_host_shortcuts_run, + handle_mail_archive, + handle_mail_flag, handle_notes_export, handle_notes_intake, handle_reminders_export, @@ -307,3 +311,67 @@ def mock_runner(cmd: list[str], timeout: float = 10.0) -> str: req_say.json = AsyncMock(return_value={"text": "Speaking"}) resp_say = await handle_host_say(req_say) assert resp_say.status == 200 + + # 9. shortcuts list + req_sc = MagicMock(spec=web.Request) + monkeypatch.setattr(svc, "list_shortcuts", lambda: ["Shortcut A", "Shortcut B"]) + resp_sc = await handle_host_shortcuts(req_sc) + assert resp_sc.status == 200 + sc_data = json.loads(resp_sc.text) + assert sc_data["ok"] is True + assert len(sc_data["shortcuts"]) == 2 + + # 10. shortcuts run + req_sc_run = MagicMock(spec=web.Request) + req_sc_run.json = AsyncMock(return_value={"name": "Shortcut A", "input": "text"}) + monkeypatch.setattr(svc, "run_shortcut", lambda name, input_text="": {"ok": True, "output": "done"}) + resp_sc_run = await handle_host_shortcuts_run(req_sc_run) + assert resp_sc_run.status == 200 + sc_run_data = json.loads(resp_sc_run.text) + assert sc_run_data["ok"] is True + + # 11. mail archive + req_mail_arch = MagicMock(spec=web.Request) + req_mail_arch.json = AsyncMock(return_value={"message_id": "msg-123"}) + monkeypatch.setattr(svc, "archive_apple_mail", lambda message_id: {"ok": True, "archived": True}) + resp_mail_arch = await handle_mail_archive(req_mail_arch) + assert resp_mail_arch.status == 200 + + # 12. mail flag + req_mail_flag = MagicMock(spec=web.Request) + req_mail_flag.json = AsyncMock(return_value={"message_id": "msg-123", "flag_index": 2}) + monkeypatch.setattr(svc, "flag_apple_mail", lambda message_id, flag_index: {"ok": True, "flag_index": flag_index}) + resp_mail_flag = await handle_mail_flag(req_mail_flag) + assert resp_mail_flag.status == 200 + + +def test_shortcuts_and_mail_service(monkeypatch): + executed = [] + + def mock_runner(cmd: list[str], timeout: float = 10.0, input_data: str | None = None) -> str: + executed.append((cmd, input_data)) + if cmd == ["shortcuts", "list"]: + return "Summarize Text\nCreate Task\n" + if cmd[:2] == ["shortcuts", "run"]: + return "Summary result" + if "Mail" in str(cmd): + return json.dumps({"ok": True, "count": 1, "archived": True}) + return "ok" + + svc = HostPIMService(runner=mock_runner) + monkeypatch.setattr(svc, "is_macos", lambda: True) + monkeypatch.setattr("shutil.which", lambda _: "/usr/bin/shortcuts") + + shortcuts = svc.list_shortcuts() + assert shortcuts == ["Summarize Text", "Create Task"] + + run_res = svc.run_shortcut("Summarize Text", input_text="Long text") + assert run_res["ok"] is True + assert run_res["output"] == "Summary result" + + arch_res = svc.archive_apple_mail("msg-999") + assert arch_res["ok"] is True + assert arch_res["archived"] is True + + flag_res = svc.flag_apple_mail("msg-999", flag_index=3) + assert flag_res["ok"] is True diff --git a/backend/tests/test_mesh_transport.py b/backend/tests/test_mesh_transport.py new file mode 100644 index 0000000..f340c1c --- /dev/null +++ b/backend/tests/test_mesh_transport.py @@ -0,0 +1,140 @@ +"""Tests for Decentralized Mesh Transport & Discovery Service (BitCore / BitChat). + +Verifies: +- PeerRegistry: local node registration, peer registration, heartbeat update, pruning stale peers +- MeshDiscoveryService: beacon payload format, beacon parsing, loopback ignore +- Mesh API: GET /api/network/mesh/peers, POST /api/network/mesh/announce +""" +from __future__ import annotations + +import json +import time +from pathlib import Path + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer + +from app.api.bitchat_api import register_bitchat_routes +from app.services.mesh_transport import ( + MESH_BEACON_MAGIC, + MeshDiscoveryService, + MeshPeer, + PeerRegistry, +) + + +def test_peer_registry_lifecycle(): + """Verify local registration, peer registration, status transitions, and pruning.""" + registry = PeerRegistry(local_peer_id="test_node_local", local_node_name="Local uDos") + assert registry.local_peer_id == "test_node_local" + + peers = registry.get_peers() + assert len(peers) == 1 + assert peers[0]["peer_id"] == "test_node_local" + assert peers[0]["is_local"] is True + assert peers[0]["status"] == "online" + + # Register remote peer + remote_peer = MeshPeer( + peer_id="remote_node_1", + node_name="Living Room Sonic", + ip="192.168.1.50", + port=3000, + service_type="_bitchat._tcp.local.", + capabilities=["chat", "prose"], + last_seen=time.time(), + is_local=False, + ) + registry.register_peer(remote_peer) + + peers = registry.get_peers() + assert len(peers) == 2 + remote = next(p for p in peers if p["peer_id"] == "remote_node_1") + assert remote["node_name"] == "Living Room Sonic" + assert remote["status"] == "online" + + # Test idle and offline status based on age + remote_peer.last_seen = time.time() - 70 # 70s ago -> idle + assert remote_peer.to_dict()["status"] == "idle" + + remote_peer.last_seen = time.time() - 130 # 130s ago -> offline + assert remote_peer.to_dict()["status"] == "offline" + + # Heartbeat revives status to online + registry.update_heartbeat("remote_node_1") + assert registry.get_peer("remote_node_1").status == "online" + + # Stale peer pruning (older than 180s) + remote_peer.last_seen = time.time() - 200 + pruned = registry.prune_stale_peers(max_age_seconds=180.0) + assert pruned == 1 + assert registry.get_peer("remote_node_1") is None + # Local peer should never be pruned + assert registry.get_peer("test_node_local") is not None + + +def test_discovery_beacon_payload_and_parse(): + """Verify beacon serialization and parsing.""" + registry = PeerRegistry(local_peer_id="beacon_tester", local_node_name="Tester Node") + discovery = MeshDiscoveryService(registry=registry, port=3000) + + raw_payload = discovery.build_beacon_payload() + data = json.loads(raw_payload.decode("utf-8")) + assert data["magic"] == MESH_BEACON_MAGIC + assert data["peer_id"] == "beacon_tester" + assert data["node_name"] == "Tester Node" + assert "_bitchat._tcp.local." in data["services"] + + # Parsing valid incoming beacon from remote IP + remote_beacon = json.dumps({ + "magic": MESH_BEACON_MAGIC, + "peer_id": "remote_beacon_99", + "node_name": "Studio Workshop Kiosk", + "port": 3000, + "service_type": "_bitchat._tcp.local.", + "capabilities": ["chat", "tasks"], + "timestamp": time.time(), + }).encode("utf-8") + + parsed = discovery.parse_beacon_payload(remote_beacon, ("192.168.1.120", 53535)) + assert parsed is not None + assert parsed.peer_id == "remote_beacon_99" + assert parsed.node_name == "Studio Workshop Kiosk" + assert parsed.ip == "192.168.1.120" + assert parsed.is_local is False + + # Ignore self-beacon + self_beacon = discovery.build_beacon_payload() + parsed_self = discovery.parse_beacon_payload(self_beacon, ("127.0.0.1", 53535)) + assert parsed_self is None + + # Ignore non-matching magic + invalid_beacon = json.dumps({"magic": "OTHER_PROTOCOL"}).encode("utf-8") + assert discovery.parse_beacon_payload(invalid_beacon, ("10.0.0.1", 53535)) is None + + +@pytest.fixture +async def mesh_client(tmp_path: Path): + """Create test client for mesh API.""" + app = web.Application() + register_bitchat_routes(app) + async with TestClient(TestServer(app)) as client: + yield client + + +@pytest.mark.asyncio +async def test_mesh_api_peers_and_announce(mesh_client): + """Verify GET /api/network/mesh/peers and POST /api/network/mesh/announce.""" + resp = await mesh_client.get("/api/network/mesh/peers") + assert resp.status == 200 + data = await resp.json() + assert data["status"] == "ok" + assert "peers" in data + assert len(data["peers"]) >= 1 + + resp_announce = await mesh_client.post("/api/network/mesh/announce") + assert resp_announce.status == 200 + data_ann = await resp_announce.json() + assert data_ann["status"] == "ok" + assert "peer_id" in data_ann diff --git a/backend/tests/test_outgoing_task_sync.py b/backend/tests/test_outgoing_task_sync.py new file mode 100644 index 0000000..b32bcc1 --- /dev/null +++ b/backend/tests/test_outgoing_task_sync.py @@ -0,0 +1,309 @@ +"""Comprehensive tests for the Outgoing Sync & Task Loop: +- Action-to-Task Pipeline (.tasker markdown generation, due dates, provenance linking) +- Task-to-Action Bridge (Shortcuts execution, Apple Reminders, Apple Notes, Mail Archiving) +- Bi-directional Apple Sync (Reminders list creation, due dates, Notes, Mail archiving) +- Activity Pod Resolution & Auto-Archival Rules +- Dispatch RSVP to Sovereign Task conversion +""" +from __future__ import annotations + +import json +import platform +import shutil +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.api.feed_api import ( + handle_feed_bulk_resolve, + handle_feed_export_notes, + handle_feed_export_reminders, + handle_feed_promote, + handle_feed_resolve, + handle_feed_rules_apply, +) +from app.services.apple_feed_sync import AppleFeedSync +from app.services.dispatch_store import DispatchStore +from app.services.feed_rules import FeedRule, evaluate_feed_rules +from app.services.feed_store import FeedServer +from app.services.feed_workflow import promote_activity_to_task +from app.services.host_pim import HostPIMService +from app.services.task_action_bridge import TaskActionBridge + + +@pytest.fixture +def mock_pim(monkeypatch): + executed = [] + + def mock_runner(cmd: list[str], timeout: float = 10.0, input_data: str | None = None) -> str: + executed.append((cmd, input_data)) + cmd_str = " ".join(cmd) + if "shortcuts list" in cmd_str: + return "Summarize Action\nQuick Capture\n" + if "shortcuts run" in cmd_str: + return "Shortcut executed successfully" + if "Reminders" in cmd_str: + return "reminder-id-xyz" + if "Notes" in cmd_str: + return "note-id-123" + if "Mail" in cmd_str: + return json.dumps({"ok": True, "count": 1, "archived": True}) + return "ok" + + pim = HostPIMService(runner=mock_runner) + monkeypatch.setattr(pim, "is_macos", lambda: True) + monkeypatch.setattr("shutil.which", lambda prog: f"/usr/bin/{prog}") + return pim, executed + + +def test_export_reminder_with_due_date_and_list(mock_pim): + pim, executed = mock_pim + res = pim.export_to_apple_reminders( + title="Follow up on Sovereign RSVP", + notes="Guest requested vegan option", + list_name="Launch Event", + due_date="2026-09-25", + ) + assert res["ok"] is True + assert res["list"] == "Launch Event" + assert len(executed) == 1 + script = executed[0][0][-1] + assert 'list "Launch Event"' in script + assert 'due date:date "2026-09-25"' in script + + +def test_shortcuts_execution(mock_pim): + pim, executed = mock_pim + shortcuts = pim.list_shortcuts() + assert shortcuts == ["Summarize Action", "Quick Capture"] + + run_res = pim.run_shortcut("Summarize Action", input_text="Long report text") + assert run_res["ok"] is True + assert run_res["output"] == "Shortcut executed successfully" + + +def test_mail_archive_and_flag(mock_pim): + pim, executed = mock_pim + arch_res = pim.archive_apple_mail("test-msg-123") + assert arch_res["ok"] is True + assert arch_res["archived"] is True + + flag_res = pim.flag_apple_mail("test-msg-123", flag_index=1) + assert flag_res["ok"] is True + + +@pytest.mark.asyncio +async def test_mail_ingest_with_flags_sets_high_importance(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(platform, "system", lambda: "Darwin") + monkeypatch.setattr("app.services.apple_feed_sync.shutil.which", lambda _: "/usr/bin/osascript") + + feed = FeedServer(str(tmp_path / "activity.db")) + def runner(_script: str): + return [ + { + "external_id": "flagged-mail-1", + "title": "Urgent Contract", + "content": "Sign by tomorrow", + "is_flagged": True, + "flag_index": 0, + } + ] + sync = AppleFeedSync(feed, runner=runner) + result = await sync.sync("mail") + assert result["ok"] is True + assert result["imported"] == 1 + + rows = await feed.query_feed(source="mail") + assert len(rows) == 1 + assert rows[0]["importance"] == 0.85 + meta = json.loads(rows[0]["metadata"]) + assert meta.get("is_flagged") is True + + +@pytest.mark.asyncio +async def test_promote_activity_to_task_with_reminders_and_archive(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mock_pim): + pim, executed = mock_pim + monkeypatch.setattr("app.services.host_pim.HostPIMService", lambda *args, **kwargs: pim) + monkeypatch.setattr("app.flow.task_store.default_tasker_dir", lambda: tmp_path / "tasks") + + activity = { + "id": 42, + "source": "mail", + "external_id": "mail-msg-99", + "title": "Review Exhibition Specs", + "content": "Ensure Mode 7 Teletext compatibility", + } + + promoted = promote_activity_to_task( + activity, + board="inbox", + priority="high", + binder="Exhibition", + mission="Universal OS", + due_date="2026-10-01", + sync_apple_reminders=True, + archive_source_mail=True, + ) + + assert Path(promoted["path"]).exists() + task_text = Path(promoted["path"]).read_text(encoding="utf-8") + assert "due: '2026-10-01'" in task_text or "due: 2026-10-01" in task_text + assert "binder: Exhibition" in task_text + assert "mission: Universal OS" in task_text + assert "synced:apple-reminders" in task_text + + assert promoted["reminders_sync"] is not None + assert promoted["reminders_sync"]["ok"] is True + assert promoted["mail_archived"] is not None + assert promoted["mail_archived"]["archived"] is True + + +def test_dispatch_convert_rsvp_to_task(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mock_pim): + pim, executed = mock_pim + monkeypatch.setattr("app.services.host_pim.HostPIMService", lambda *args, **kwargs: pim) + monkeypatch.setattr("app.flow.task_store.default_tasker_dir", lambda: tmp_path / "tasks") + + store = DispatchStore(root_dir=tmp_path / "dispatches", token_index_path=tmp_path / "tokens.json") + disp = store.create_dispatch(title="Zen Gathering", story_markdown="# Welcome to the Tea Ceremony") + token = disp["token"] + + store.submit_rsvp(token, { + "name": "Lady Ada", + "email": "ada@lovelace.org", + "answers": {"Attending": "Yes", "Dietary": "Matcha Only"}, + }) + + res = store.convert_rsvp_to_task( + dispatch_id=disp["id"], + rsvp_index=0, + board="inbox", + priority="high", + binder="Ceremony", + due_date="2026-11-05", + sync_apple_reminders=True, + ) + + assert res["status"] == "ok" + assert Path(res["path"]).exists() + content = Path(res["path"]).read_text(encoding="utf-8") + assert "Lady Ada" in content + assert "Matcha Only" in content + + # Verify responses.json was updated with task_id + responses = store.get_responses(disp["id"]) + assert responses[0].get("task_id") == res["task_id"] + + +def test_task_action_bridge_execution(tmp_path: Path, mock_pim, monkeypatch: pytest.MonkeyPatch): + pim, executed = mock_pim + task_dir = tmp_path / "tasks" / "inbox" + task_dir.mkdir(parents=True, exist_ok=True) + task_file = task_dir / "todo-run-report-1.md" + task_file.write_text( + "---\ntitle: Run Report\nstatus: todo\nbinder: Analytics\n---\n# Run Report\nGenerate monthly summary\n", + encoding="utf-8", + ) + + bridge = TaskActionBridge(pim=pim) + res = bridge.execute_action( + task_id="todo-run-report-1", + action_type="shortcut", + payload={"name": "Summarize Action"}, + tasker_dir=tmp_path / "tasks", + ) + assert res["ok"] is True + assert res["result"]["ok"] is True + + +def test_feed_server_resolve_activity(tmp_path: Path): + feed = FeedServer(str(tmp_path / "activity.db")) + ingest = feed.ingest_activity_sync( + source="dispatch", + type="dispatch_rsvp_received", + title="RSVP from John", + content="Will be there", + ) + act_id = ingest["id"] + + # Activity is initially unprocessed + cursor = feed._conn.execute("SELECT processed FROM user_activity WHERE id = ?", (act_id,)) + assert cursor.fetchone()["processed"] == 0 + + # Resolve activity + res = feed.resolve_activity(activity_id=act_id, note="Follow-up completed via phone") + assert res["ok"] is True + assert res["processed"] == 1 + + cursor = feed._conn.execute("SELECT processed, metadata FROM user_activity WHERE id = ?", (act_id,)) + row = cursor.fetchone() + assert row["processed"] == 1 + meta = json.loads(row["metadata"]) + assert meta["resolution_note"] == "Follow-up completed via phone" + assert meta["archived"] is True + + # Bulk resolve + ingest2 = feed.ingest_activity_sync(source="mail", type="mail", title="Item 2") + bulk_res = feed.bulk_resolve([ingest2["id"]], note="Bulk cleared") + assert bulk_res["ok"] is True + assert bulk_res["resolved_count"] == 1 + + +def test_feed_rules_with_flagged_and_auto_archive(tmp_path: Path): + rule_flagged = FeedRule( + id="flagged-rule", + enabled=True, + sources=("mail",), + contains=(), + min_importance=0.6, + require_flagged=True, + action="create-task", + board="inbox", + priority="high", + binder="Urgent", + sync_reminders=True, + archive_mail=True, + ) + rule_normal = FeedRule( + id="normal-rule", + enabled=True, + sources=("mail",), + contains=("newsletter",), + min_importance=0.4, + require_flagged=False, + action="auto-archive", + board="inbox", + priority="low", + binder="Sandbox", + ) + + activities = [ + { + "id": 1, + "source": "mail", + "title": "Flagged urgent email", + "importance": 0.85, + "metadata": {"is_flagged": True}, + }, + { + "id": 2, + "source": "mail", + "title": "Routine newsletter", + "importance": 0.5, + "metadata": {"is_flagged": False}, + }, + ] + + proposals = evaluate_feed_rules(activities, [rule_flagged, rule_normal]) + assert len(proposals) == 2 + + # Proposal 1 matched flagged-rule + prop1 = next(p for p in proposals if p["activity_id"] == 1) + assert prop1["rule_id"] == "flagged-rule" + assert prop1["sync_reminders"] is True + assert prop1["archive_mail"] is True + + # Proposal 2 matched normal-rule + prop2 = next(p for p in proposals if p["activity_id"] == 2) + assert prop2["rule_id"] == "normal-rule" + assert prop2["action"] == "auto-archive" diff --git a/config/feed-rules.example.yaml b/config/feed-rules.example.yaml index b35429f..0c35bad 100644 --- a/config/feed-rules.example.yaml +++ b/config/feed-rules.example.yaml @@ -25,3 +25,29 @@ rules: board: planning priority: medium binder: Sandbox + + - id: dispatch-rsvp-followup + enabled: true + match: + sources: [dispatch] + contains: [rsvp, answer, attending, dietary, menu, note] + min_importance: 0.5 + action: + type: propose-task + board: inbox + priority: high + binder: Sandbox + sync_reminders: true + + - id: vip-flagged-mail + enabled: true + match: + sources: [mail] + flagged: true + min_importance: 0.7 + action: + type: propose-task + board: inbox + priority: high + binder: Sandbox + archive_mail: false diff --git a/frontend-vue/src/layouts/AppShell.vue b/frontend-vue/src/layouts/AppShell.vue index 34575c6..9fc07bf 100644 --- a/frontend-vue/src/layouts/AppShell.vue +++ b/frontend-vue/src/layouts/AppShell.vue @@ -1,5 +1,8 @@