mattrguerra/meridian-skillsmainPrivate on GitHub, published here in full

bitrix_router.py

Language Python6.5 KB190 lines

skills/bitrix-task-router/scripts/bitrix_router.py

  1. #!/usr/bin/env python3
  2. """Bitrix24 task router.
  3. Pulls open tasks, decides an owner per task by a configurable rule, previews the
  4. changes, and (only with --commit) writes them back to Bitrix.
  5. Usage:
  6. python scripts/bitrix_router.py # dry run against live Bitrix
  7. python scripts/bitrix_router.py --mock # dry run against sample_tasks.json
  8. python scripts/bitrix_router.py --commit # write assignments to live Bitrix
  9. Config: config.json (copy from config.example.json).
  10. Secrets: .env with BITRIX_WEBHOOK_URL (copy from .env.example).
  11. Stdlib only, no pip installs.
  12. """
  13. import argparse
  14. import json
  15. import os
  16. import sys
  17. import urllib.parse
  18. import urllib.request
  19. from pathlib import Path
  20. ROOT = Path(__file__).resolve().parent.parent
  21. def load_env(root: Path) -> dict:
  22. env = {}
  23. f = root / ".env"
  24. if f.exists():
  25. for line in f.read_text().splitlines():
  26. line = line.strip()
  27. if not line or line.startswith("#") or "=" not in line:
  28. continue
  29. k, v = line.split("=", 1)
  30. env[k.strip()] = v.strip()
  31. # the real environment overrides the file
  32. if os.environ.get("BITRIX_WEBHOOK_URL"):
  33. env["BITRIX_WEBHOOK_URL"] = os.environ["BITRIX_WEBHOOK_URL"]
  34. return env
  35. def load_config(root: Path, mock: bool) -> dict:
  36. cfg = root / "config.json"
  37. if not cfg.exists():
  38. if not mock:
  39. sys.exit("config.json not found. Copy config.example.json to config.json and fill it in.")
  40. cfg = root / "config.example.json" # fine for a dry mock run
  41. return json.loads(cfg.read_text())
  42. def _flatten(obj, parent=""):
  43. """Bitrix expects PHP-style nested params: filter[!STATUS]=5, select[0]=ID."""
  44. items = []
  45. if isinstance(obj, dict):
  46. for k, v in obj.items():
  47. key = f"{parent}[{k}]" if parent else str(k)
  48. items.extend(_flatten(v, key))
  49. elif isinstance(obj, (list, tuple)):
  50. for i, v in enumerate(obj):
  51. key = f"{parent}[{i}]" if parent else str(i)
  52. items.extend(_flatten(v, key))
  53. else:
  54. items.append((parent, obj))
  55. return items
  56. def bitrix_call(webhook: str, method: str, params: dict) -> dict:
  57. url = webhook.rstrip("/") + "/" + method + ".json"
  58. data = urllib.parse.urlencode(_flatten(params), doseq=True).encode()
  59. req = urllib.request.Request(url, data=data)
  60. with urllib.request.urlopen(req, timeout=30) as resp:
  61. payload = json.loads(resp.read().decode())
  62. if "error" in payload:
  63. raise RuntimeError(f"{payload.get('error')}: {payload.get('error_description')}")
  64. return payload
  65. def fetch_tasks_live(webhook: str, cfg: dict) -> list:
  66. tasks, start = [], 0
  67. while True:
  68. resp = bitrix_call(webhook, "tasks.task.list", {
  69. "filter": cfg.get("task_filter", {}),
  70. "select": cfg.get("task_select", ["ID", "TITLE", "RESPONSIBLE_ID"]),
  71. "start": start,
  72. })
  73. tasks.extend(resp.get("result", {}).get("tasks", []))
  74. nxt = resp.get("next")
  75. if not nxt:
  76. break
  77. start = nxt
  78. return tasks
  79. def fetch_tasks_mock(root: Path) -> list:
  80. return json.loads((root / "sample_tasks.json").read_text())
  81. def decide_owner(task: dict, rule: dict):
  82. """Return (owner_key, reason) for a task, or (None, reason) if unroutable."""
  83. src = rule.get("parity_source", "id")
  84. raw = task.get("ID") if src == "id" else task.get(src.upper())
  85. try:
  86. n = int(str(raw).strip())
  87. except (TypeError, ValueError):
  88. return None, f"can't read parity from {src}={raw!r}"
  89. parity = "even" if n % 2 == 0 else "odd"
  90. keyword = str(rule.get("keyword", "")).lower()
  91. field = rule.get("keyword_field", "title").upper()
  92. field_val = str(task.get(field, "")).lower()
  93. has_kw = bool(keyword) and keyword in field_val
  94. table = rule["with_keyword"] if has_kw else rule["without_keyword"]
  95. owner = table.get(parity)
  96. kw_note = f"{keyword.upper()} + " if has_kw else ""
  97. return owner, f"{kw_note}{parity} -> {owner}"
  98. def main():
  99. ap = argparse.ArgumentParser(description="Route Bitrix24 tasks by rule.")
  100. ap.add_argument("--mock", action="store_true", help="use bundled sample_tasks.json, no Bitrix call")
  101. ap.add_argument("--commit", action="store_true", help="write assignments back to Bitrix")
  102. args = ap.parse_args()
  103. cfg = load_config(ROOT, args.mock)
  104. rule = cfg["rule"]
  105. user_ids = cfg["user_ids"]
  106. if args.mock:
  107. tasks = fetch_tasks_mock(ROOT)
  108. webhook = None
  109. else:
  110. env = load_env(ROOT)
  111. webhook = env.get("BITRIX_WEBHOOK_URL")
  112. if not webhook:
  113. sys.exit("BITRIX_WEBHOOK_URL not set. Copy .env.example to .env and fill it in.")
  114. tasks = fetch_tasks_live(webhook, cfg)
  115. print(f"{'ID':>6} {'TITLE':<44} {'CURRENT':>8} {'-> NEW':>8} REASON")
  116. print("-" * 96)
  117. planned = []
  118. for t in tasks:
  119. owner_key, reason = decide_owner(t, rule)
  120. title = (t.get("TITLE") or "")[:44]
  121. cur = str(t.get("RESPONSIBLE_ID", ""))
  122. if owner_key is None:
  123. print(f"{t.get('ID',''):>6} {title:<44} {cur:>8} {'SKIP':>8} {reason}")
  124. continue
  125. new_id = str(user_ids.get(owner_key, ""))
  126. if not new_id or new_id.startswith("REPLACE_WITH"):
  127. print(f"{t.get('ID',''):>6} {title:<44} {cur:>8} {'??':>8} {reason} (no user id for {owner_key})")
  128. continue
  129. change = new_id != cur
  130. marker = new_id if change else "(same)"
  131. print(f"{t.get('ID',''):>6} {title:<44} {cur:>8} {marker:>8} {reason}")
  132. if change:
  133. planned.append((t.get("ID"), new_id, reason))
  134. print("-" * 96)
  135. print(f"{len(planned)} task(s) would be reassigned.")
  136. if not args.commit:
  137. print("\nDry run. Re-run with --commit to write these assignments to Bitrix.")
  138. return
  139. if args.mock:
  140. print("\n--mock and --commit together: nothing written (mock has no live connection).")
  141. return
  142. if not planned:
  143. print("\nNothing to write.")
  144. return
  145. print("\nCommitting...")
  146. ok = fail = 0
  147. for task_id, new_id, reason in planned:
  148. try:
  149. bitrix_call(webhook, "tasks.task.update", {
  150. "taskId": task_id,
  151. "fields": {"RESPONSIBLE_ID": new_id},
  152. })
  153. ok += 1
  154. print(f" task {task_id} -> {new_id} ({reason})")
  155. except Exception as e: # noqa: BLE001
  156. fail += 1
  157. print(f" task {task_id} FAILED: {e}")
  158. print(f"\nDone. {ok} written, {fail} failed.")
  159. if __name__ == "__main__":
  160. main()