#!/usr/bin/env python3 from __future__ import annotations import json import os import hashlib import subprocess import urllib.error import urllib.parse import urllib.request from functools import partial from http import HTTPStatus from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from datetime import datetime from pathlib import Path from zoneinfo import ZoneInfo ROOT = Path(__file__).resolve().parents[1] PUBLIC = ROOT / 'public' DIGEST_ROOT = Path(os.environ.get('DORIS_DIGEST_DIR', '/home/fizzlepoof/.openclaw/workspace/doris-digest')) DIGEST_FEEDBACK = DIGEST_ROOT / 'data' / 'feedback.json' PAPERLESS_STATE = ROOT / 'data' / 'paperless_review_state.json' DONETICK_SECRET = Path('/home/fizzlepoof/.openclaw/workspace/.secrets/donetick_secrets.json.enc') DONETICK_KEY = Path('/home/fizzlepoof/.openclaw/workspace/.secrets/encryption.key') GENERATOR = ROOT / 'bin' / 'generate_dashboard.py' HOST = os.environ.get('HOST', '0.0.0.0') PORT = int(os.environ.get('PORT', '8787')) LOCAL_TZ = ZoneInfo('America/Chicago') def load_json(path: Path, default): try: return json.loads(path.read_text()) if path.exists() else default except Exception: return default def save_json(path: Path, value) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(value, indent=2) + '\n') def decrypt_donetick() -> dict | None: if not DONETICK_SECRET.exists() or not DONETICK_KEY.exists(): return None try: res = subprocess.run([ 'openssl','enc','-aes-256-cbc','-d','-pbkdf2', '-in',str(DONETICK_SECRET), '-k',DONETICK_KEY.read_text().strip(), ], capture_output=True, text=True, timeout=8, check=True) return json.loads(res.stdout).get('donetick') except Exception: return None def donetick_headers() -> tuple[dict[str, str] | None, dict | None]: sec = decrypt_donetick() if not sec: return None, None try: login = urllib.request.Request( sec['instance'] + sec['jwt_endpoint'], data=json.dumps({ 'username': sec['service_account']['username'], 'password': sec['service_account']['password'], }).encode(), headers={'Content-Type': 'application/json', 'Accept': 'application/json'}, method='POST', ) with urllib.request.urlopen(login, timeout=12) as r: token = json.loads(r.read())['access_token'] return ({ 'Authorization': 'Bearer ' + token, 'Accept': 'application/json', 'Content-Type': 'application/json', }, sec) except Exception: return None, sec def refresh_dashboard() -> bool: try: subprocess.run(['python3', str(GENERATOR)], cwd=str(ROOT), timeout=60, check=True) return True except Exception: return False def parse_dt(value: str | None): if not value: return None try: return datetime.fromisoformat(value.replace('Z', '+00:00')) except Exception: return None def due_state(due_raw: str | None) -> str: dt = parse_dt(due_raw) if not dt: return 'nodue' local = dt.astimezone(LOCAL_TZ) now = datetime.now(LOCAL_TZ) days = (local.date() - now.date()).days if days < 0: return 'overdue' if days == 0: return 'today' if days == 1: return 'tomorrow' return 'upcoming' def fetch_donetick_focus_signature() -> tuple[str, int]: headers, sec = donetick_headers() if not headers or not sec: return 'unavailable', 0 try: req = urllib.request.Request(f"{sec['instance'].rstrip('/')}/api/v1/chores", headers=headers) with urllib.request.urlopen(req, timeout=12) as r: chores = json.loads(r.read() or b'{}').get('res', []) urgent = [] for chore in chores: if chore.get('status') not in (0, None): continue state = due_state(chore.get('nextDueDate')) if state not in {'overdue', 'today'}: continue urgent.append({ 'id': str(chore.get('id') or ''), 'name': str(chore.get('name') or ''), 'due': str(chore.get('nextDueDate') or ''), 'state': state, }) urgent.sort(key=lambda item: (item['due'] or '9999', item['name'].lower(), item['id'])) payload = json.dumps(urgent, separators=(',', ':'), sort_keys=True) return hashlib.sha1(payload.encode('utf-8')).hexdigest()[:16], len(urgent) except Exception: return 'error', 0 class Handler(SimpleHTTPRequestHandler): def __init__(self, *args, directory=None, **kwargs): super().__init__(*args, directory=str(PUBLIC), **kwargs) def do_GET(self): parsed = urllib.parse.urlparse(self.path) if parsed.path == '/api/donetick-focus-state': return self._donetick_focus_state(parsed) self.path = parsed.path return super().do_GET() def do_POST(self): length = int(self.headers.get('Content-Length', '0') or '0') raw = self.rfile.read(length) if length else b'{}' try: payload = json.loads(raw.decode('utf-8') or '{}') except Exception: return self._json(HTTPStatus.BAD_REQUEST, {'ok': False, 'error': 'invalid_json'}) if self.path == '/api/topic-feedback': return self._topic_feedback(payload) if self.path == '/api/topic-feedback/reset': return self._topic_reset() if self.path == '/api/paperless-action': return self._paperless_action(payload) if self.path == '/api/donetick-complete': return self._donetick_complete(payload) return self._json(HTTPStatus.NOT_FOUND, {'ok': False, 'error': 'not_found'}) def _topic_feedback(self, payload: dict): term = str(payload.get('term', '')).strip() vote = str(payload.get('vote', '')).strip().lower() if not term or vote not in {'up', 'down'}: return self._json(HTTPStatus.BAD_REQUEST, {'ok': False, 'error': 'bad_payload'}) items = load_json(DIGEST_FEEDBACK, []) items = [x for x in items if not (str(x.get('origin', '')) == 'dashboard' and str(x.get('term', '')).strip().lower() == term.lower())] items.append({ 'term': term, 'vote': vote, 'note': f"Dashboard {'hide' if vote == 'down' else 'prefer'} topic: {term}", 'origin': 'dashboard', 'source': str(payload.get('source', '')).strip(), 'domain': str(payload.get('domain', '')).strip(), }) save_json(DIGEST_FEEDBACK, items) return self._json(HTTPStatus.OK, {'ok': True, 'count': len(items)}) def _topic_reset(self): items = load_json(DIGEST_FEEDBACK, []) items = [x for x in items if str(x.get('origin', '')) != 'dashboard'] save_json(DIGEST_FEEDBACK, items) return self._json(HTTPStatus.OK, {'ok': True, 'count': len(items)}) def _paperless_action(self, payload: dict): item_id = str(payload.get('id', '')).strip() action = str(payload.get('action', '')).strip().lower() if not item_id or action not in {'needs_attention', 'good_to_go'}: return self._json(HTTPStatus.BAD_REQUEST, {'ok': False, 'error': 'bad_payload'}) state = load_json(PAPERLESS_STATE, {}) current = state.get(item_id, {}) if isinstance(state, dict) else {} current_status = str(current.get('status') or '').lower() if action == 'needs_attention': new_status = 'active' if current_status == 'needs_attention' else 'needs_attention' else: new_status = 'good_to_go' state[item_id] = {'status': new_status} save_json(PAPERLESS_STATE, state) return self._json(HTTPStatus.OK, {'ok': True, 'id': item_id, 'status': new_status}) def _donetick_complete(self, payload: dict): item_id = str(payload.get('id') or payload.get('choreId') or '').strip() if not item_id: return self._json(HTTPStatus.BAD_REQUEST, {'ok': False, 'error': 'bad_payload', 'detail': 'expected id or choreId'}) headers, sec = donetick_headers() if not headers or not sec: return self._json(HTTPStatus.BAD_GATEWAY, {'ok': False, 'error': 'donetick_auth_failed'}) try: req = urllib.request.Request( f"{sec['instance'].rstrip('/')}/api/v1/chores/{item_id}/do", data=b'{}', headers=headers, method='POST', ) with urllib.request.urlopen(req, timeout=15) as r: response = json.loads(r.read() or b'{}') if (r.headers.get('Content-Type') or '').startswith('application/json') else {'status': r.status} refreshed = refresh_dashboard() return self._json(HTTPStatus.OK, {'ok': True, 'id': item_id, 'dashboard_refreshed': refreshed, 'response': response}) except urllib.error.HTTPError as e: detail = e.read().decode('utf-8', 'ignore')[:500] return self._json(HTTPStatus.BAD_GATEWAY, {'ok': False, 'error': 'donetick_complete_failed', 'status': e.code, 'detail': detail}) except Exception as e: return self._json(HTTPStatus.BAD_GATEWAY, {'ok': False, 'error': 'donetick_complete_failed', 'detail': str(e)}) def _donetick_focus_state(self, parsed): current = urllib.parse.parse_qs(parsed.query).get('current', [''])[0] signature, urgent_count = fetch_donetick_focus_signature() changed = bool(current and signature not in {'error', 'unavailable'} and current != signature) refreshed = refresh_dashboard() if changed else False return self._json(HTTPStatus.OK, { 'ok': True, 'signature': signature, 'urgent_count': urgent_count, 'changed': changed, 'dashboard_refreshed': refreshed, }) def _json(self, status: HTTPStatus, payload: dict): body = json.dumps(payload).encode('utf-8') self.send_response(status) self.send_header('Content-Type', 'application/json; charset=utf-8') self.send_header('Content-Length', str(len(body))) self.end_headers() self.wfile.write(body) def log_message(self, fmt, *args): print('%s - - [%s] %s' % (self.address_string(), self.log_date_time_string(), fmt % args)) if __name__ == '__main__': handler = partial(Handler, directory=str(PUBLIC)) ThreadingHTTPServer((HOST, PORT), handler).serve_forever()