import asyncio import json import os import random import shutil import subprocess from datetime import datetime, timezone from pathlib import Path from core.msg_builder import build_messages_for_targets from utils.config import get_userData, normalize_unique_id, repo_root, save_userData from utils.logger import setup_logger logger = setup_logger() PROTOCOL_SCRIPT = repo_root() / "core" / "protocol_sender.mjs" NODE_HELPER_IMAGE = "node:22-alpine" def _coerce_non_negative_int(value, default): try: return max(0, int(value)) except (TypeError, ValueError): return max(0, int(default)) def _normalize_send_strategy(config): raw = config.get("sendStrategy", {}) or {} start_min = _coerce_non_negative_int(raw.get("accountStartDelaySecondsMin", 0), 0) start_max = _coerce_non_negative_int(raw.get("accountStartDelaySecondsMax", start_min), start_min) if start_max < start_min: start_max = start_min message_min = _coerce_non_negative_int(raw.get("messageIntervalSecondsMin", 0), 0) message_max = _coerce_non_negative_int(raw.get("messageIntervalSecondsMax", message_min), message_min) if message_max < message_min: message_max = message_min strategy = { "shuffleTargets": bool(raw.get("shuffleTargets", True)), "accountStartDelaySecondsMin": start_min, "accountStartDelaySecondsMax": start_max, "messageIntervalSecondsMin": message_min, "messageIntervalSecondsMax": message_max, "messageVariants": [str(item).strip() for item in raw.get("messageVariants", []) if str(item).strip()], } if os.getenv("SPARKFLOW_MANUAL_RUN") == "1": strategy["accountStartDelaySecondsMin"] = 0 strategy["accountStartDelaySecondsMax"] = 0 strategy["messageIntervalSecondsMin"] = min(strategy["messageIntervalSecondsMin"], 3) strategy["messageIntervalSecondsMax"] = min(strategy["messageIntervalSecondsMax"], 6) return strategy def _account_identity_key(account): normalized_unique_id = normalize_unique_id(account.get("unique_id")) if normalized_unique_id: return f"uid:{normalized_unique_id}" username = str(account.get("username", "")).strip() if username: return f"user:{username}" return "" def _merge_protocol_runtime_state(accounts, result_by_username): changed = False now_iso = datetime.now(timezone.utc).isoformat(timespec="seconds") all_accounts = get_userData(force_reload=True) accounts_by_identity = { identity: account for account in all_accounts for identity in [_account_identity_key(account)] if identity } for account in accounts: target_account = accounts_by_identity.get(_account_identity_key(account)) if not target_account: continue result = result_by_username.get(account.get("username")) if not result: continue protocol_cache = result.get("protocol_targets_cache") if protocol_cache is not None: target_account["protocol_targets_cache"] = protocol_cache target_account["protocol_user_id"] = result.get("userId", "") changed = True history = dict(target_account.get("message_history") or {}) for entry in result.get("sent", []): if entry.get("dryRun") or not entry.get("success", True): continue target = str(entry.get("target", "")).strip() message = str(entry.get("message", "")).strip() if not target or not message: continue history[target] = { "message": message, "sentAt": str(entry.get("sentAt", now_iso)), } changed = True if history: target_account["message_history"] = history if changed: save_userData(all_accounts) def _host_repo_root(): candidates = [ Path("/opt/douyin-sparkflow/DouYinSparkFlow"), repo_root(), ] for candidate in candidates: if (candidate / "core" / "protocol_sender.mjs").exists(): return candidate return repo_root() def _build_protocol_command(): node_path = shutil.which("node") if node_path: return [node_path, str(PROTOCOL_SCRIPT)], repo_root(), "local-node", str(repo_root()) docker_path = shutil.which("docker") if docker_path: host_repo = _host_repo_root() return ( [ docker_path, "run", "--rm", "-i", "--network", "host", "-v", f"{host_repo}:/workspace", "-w", "/workspace", NODE_HELPER_IMAGE, "node", "core/protocol_sender.mjs", ], repo_root(), "docker-node-helper", "/workspace", ) raise RuntimeError("Neither node nor docker is available for the protocol sender") def _run_protocol_for_user(user, messages_by_target, dry_run, send_strategy): command, cwd, runner_label, runtime_repo_root = _build_protocol_command() payload = { "repoRoot": runtime_repo_root, "dryRun": dry_run, "account": user, "messagesByTarget": messages_by_target, "sendStrategy": send_strategy, } process = subprocess.run( command, input=json.dumps(payload, ensure_ascii=False), text=True, capture_output=True, cwd=str(cwd), check=False, ) stdout = (process.stdout or "").strip() if not stdout: raise RuntimeError( f"protocol sender returned no output for {user.get('username', 'unknown')}: {process.stderr}" ) try: data = json.loads(stdout) except json.JSONDecodeError as exc: raise RuntimeError( f"protocol sender produced invalid JSON for {user.get('username', 'unknown')}: {stdout}" ) from exc if process.returncode != 0 or not data.get("ok"): error_message = data.get("error") or process.stderr or "protocol sender failed" raise RuntimeError( f"{user.get('username', 'unknown')} protocol sender failed: {error_message}" ) data["runner"] = runner_label return data async def run_protocol_tasks(config, accounts, message_builder): del message_builder dry_run = bool(config.get("protocolDryRun", False)) multi_task = bool(config.get("multiTask", True)) concurrency = int(config.get("taskCount", 1)) if multi_task else 1 semaphore = asyncio.Semaphore(max(concurrency, 1)) send_strategy = _normalize_send_strategy(config) async def _worker(user): async with semaphore: start_delay = random.randint( send_strategy["accountStartDelaySecondsMin"], send_strategy["accountStartDelaySecondsMax"], ) if start_delay > 0: logger.info( "Delaying protocol sender for %s by %ss to avoid synchronized bursts", user.get("username", "unknown"), start_delay, ) await asyncio.sleep(start_delay) logger.info("Starting protocol sender for %s", user.get("username", "unknown")) messages_by_target = build_messages_for_targets( user.get("targets", []), previous_messages=user.get("message_history", {}), config=config, ) logger.info( "Prepared %s protocol messages for %s with shuffleTargets=%s interval=%s-%ss manual_run=%s", len(messages_by_target), user.get("username", "unknown"), send_strategy["shuffleTargets"], send_strategy["messageIntervalSecondsMin"], send_strategy["messageIntervalSecondsMax"], os.getenv("SPARKFLOW_MANUAL_RUN") == "1", ) result = await asyncio.to_thread( _run_protocol_for_user, user, messages_by_target, dry_run, send_strategy, ) logger.info( "Protocol sender finished for %s resolved=%s unresolved=%s sent=%s", user.get("username", "unknown"), len(result.get("resolved", [])), len(result.get("unresolved", [])), len(result.get("sent", [])), ) return result gathered = await asyncio.gather(*(_worker(user) for user in accounts), return_exceptions=True) result_by_username = {} failures = [] for user, item in zip(accounts, gathered): if isinstance(item, Exception): failures.append(str(item)) logger.error("Protocol sender failed for %s: %s", user.get("username", "unknown"), item) continue result_by_username[user.get("username")] = item unresolved = item.get("unresolved", []) if unresolved: logger.warning( "Protocol sender could not resolve %s targets for %s: %s", len(unresolved), user.get("username", "unknown"), [entry.get("target") for entry in unresolved], ) _merge_protocol_runtime_state(accounts, result_by_username) if failures and not result_by_username: raise RuntimeError("; ".join(failures)) return [result_by_username[user.get("username")] for user in accounts if user.get("username") in result_by_username]