bitrix_router.py
Language Python6.5 KB190 lines
#!/usr/bin/env python3"""Bitrix24 task router.Pulls open tasks, decides an owner per task by a configurable rule, previews thechanges, and (only with --commit) writes them back to Bitrix.Usage:python scripts/bitrix_router.py # dry run against live Bitrixpython scripts/bitrix_router.py --mock # dry run against sample_tasks.jsonpython scripts/bitrix_router.py --commit # write assignments to live BitrixConfig: config.json (copy from config.example.json).Secrets: .env with BITRIX_WEBHOOK_URL (copy from .env.example).Stdlib only, no pip installs."""import argparseimport jsonimport osimport sysimport urllib.parseimport urllib.requestfrom pathlib import PathROOT = Path(__file__).resolve().parent.parentdef load_env(root: Path) -> dict:env = {}f = root / ".env"if f.exists():for line in f.read_text().splitlines():line = line.strip()if not line or line.startswith("#") or "=" not in line:continuek, v = line.split("=", 1)env[k.strip()] = v.strip()# the real environment overrides the fileif os.environ.get("BITRIX_WEBHOOK_URL"):env["BITRIX_WEBHOOK_URL"] = os.environ["BITRIX_WEBHOOK_URL"]return envdef load_config(root: Path, mock: bool) -> dict:cfg = root / "config.json"if not cfg.exists():if not mock:sys.exit("config.json not found. Copy config.example.json to config.json and fill it in.")cfg = root / "config.example.json" # fine for a dry mock runreturn json.loads(cfg.read_text())def _flatten(obj, parent=""):"""Bitrix expects PHP-style nested params: filter[!STATUS]=5, select[0]=ID."""items = []if isinstance(obj, dict):for k, v in obj.items():key = f"{parent}[{k}]" if parent else str(k)items.extend(_flatten(v, key))elif isinstance(obj, (list, tuple)):for i, v in enumerate(obj):key = f"{parent}[{i}]" if parent else str(i)items.extend(_flatten(v, key))else:items.append((parent, obj))return itemsdef bitrix_call(webhook: str, method: str, params: dict) -> dict:url = webhook.rstrip("/") + "/" + method + ".json"data = urllib.parse.urlencode(_flatten(params), doseq=True).encode()req = urllib.request.Request(url, data=data)with urllib.request.urlopen(req, timeout=30) as resp:payload = json.loads(resp.read().decode())if "error" in payload:raise RuntimeError(f"{payload.get('error')}: {payload.get('error_description')}")return payloaddef fetch_tasks_live(webhook: str, cfg: dict) -> list:tasks, start = [], 0while True:resp = bitrix_call(webhook, "tasks.task.list", {"filter": cfg.get("task_filter", {}),"select": cfg.get("task_select", ["ID", "TITLE", "RESPONSIBLE_ID"]),"start": start,})tasks.extend(resp.get("result", {}).get("tasks", []))nxt = resp.get("next")if not nxt:breakstart = nxtreturn tasksdef fetch_tasks_mock(root: Path) -> list:return json.loads((root / "sample_tasks.json").read_text())def decide_owner(task: dict, rule: dict):"""Return (owner_key, reason) for a task, or (None, reason) if unroutable."""src = rule.get("parity_source", "id")raw = task.get("ID") if src == "id" else task.get(src.upper())try:n = int(str(raw).strip())except (TypeError, ValueError):return None, f"can't read parity from {src}={raw!r}"parity = "even" if n % 2 == 0 else "odd"keyword = str(rule.get("keyword", "")).lower()field = rule.get("keyword_field", "title").upper()field_val = str(task.get(field, "")).lower()has_kw = bool(keyword) and keyword in field_valtable = rule["with_keyword"] if has_kw else rule["without_keyword"]owner = table.get(parity)kw_note = f"{keyword.upper()} + " if has_kw else ""return owner, f"{kw_note}{parity} -> {owner}"def main():ap = argparse.ArgumentParser(description="Route Bitrix24 tasks by rule.")ap.add_argument("--mock", action="store_true", help="use bundled sample_tasks.json, no Bitrix call")ap.add_argument("--commit", action="store_true", help="write assignments back to Bitrix")args = ap.parse_args()cfg = load_config(ROOT, args.mock)rule = cfg["rule"]user_ids = cfg["user_ids"]if args.mock:tasks = fetch_tasks_mock(ROOT)webhook = Noneelse:env = load_env(ROOT)webhook = env.get("BITRIX_WEBHOOK_URL")if not webhook:sys.exit("BITRIX_WEBHOOK_URL not set. Copy .env.example to .env and fill it in.")tasks = fetch_tasks_live(webhook, cfg)print(f"{'ID':>6} {'TITLE':<44} {'CURRENT':>8} {'-> NEW':>8} REASON")print("-" * 96)planned = []for t in tasks:owner_key, reason = decide_owner(t, rule)title = (t.get("TITLE") or "")[:44]cur = str(t.get("RESPONSIBLE_ID", ""))if owner_key is None:print(f"{t.get('ID',''):>6} {title:<44} {cur:>8} {'SKIP':>8} {reason}")continuenew_id = str(user_ids.get(owner_key, ""))if not new_id or new_id.startswith("REPLACE_WITH"):print(f"{t.get('ID',''):>6} {title:<44} {cur:>8} {'??':>8} {reason} (no user id for {owner_key})")continuechange = new_id != curmarker = new_id if change else "(same)"print(f"{t.get('ID',''):>6} {title:<44} {cur:>8} {marker:>8} {reason}")if change:planned.append((t.get("ID"), new_id, reason))print("-" * 96)print(f"{len(planned)} task(s) would be reassigned.")if not args.commit:print("\nDry run. Re-run with --commit to write these assignments to Bitrix.")returnif args.mock:print("\n--mock and --commit together: nothing written (mock has no live connection).")returnif not planned:print("\nNothing to write.")returnprint("\nCommitting...")ok = fail = 0for task_id, new_id, reason in planned:try:bitrix_call(webhook, "tasks.task.update", {"taskId": task_id,"fields": {"RESPONSIBLE_ID": new_id},})ok += 1print(f" task {task_id} -> {new_id} ({reason})")except Exception as e: # noqa: BLE001fail += 1print(f" task {task_id} FAILED: {e}")print(f"\nDone. {ok} written, {fail} failed.")if __name__ == "__main__":main()