n8n 連携: 受け口を呼ぶクライアントと設定の保持を追加
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+114
@@ -1330,6 +1330,120 @@ def mark_blocks(blocks, rows, deleted_sources, now=None):
|
||||
return blocks
|
||||
|
||||
|
||||
class N8n:
|
||||
"""n8n の work-tracker--import を呼ぶ最小のクライアント (標準ライブラリのみ)。
|
||||
|
||||
LAN からは n8n.yfox.jp の証明書名が一致しないので、verify=False なら検証を外す。
|
||||
合言葉は X-WT-Token ヘッダーで送り、ログにも例外の文言にも出さない。
|
||||
"""
|
||||
|
||||
def __init__(self, url=None, token=None, verify=False):
|
||||
self.url = (url or "").rstrip("/")
|
||||
self.token = token or ""
|
||||
self.ctx = None
|
||||
if not verify:
|
||||
self.ctx = ssl.create_default_context()
|
||||
self.ctx.check_hostname = False
|
||||
self.ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
def call(self, path, method="GET", payload=None):
|
||||
if not self.url:
|
||||
raise N8nError("n8n の URL が未設定です")
|
||||
if not self.token:
|
||||
raise N8nError("合言葉が未設定です")
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else None
|
||||
req = urllib.request.Request(self.url + path, data=body, method=method)
|
||||
req.add_header("X-WT-Token", self.token)
|
||||
req.add_header("Accept", "application/json")
|
||||
if body is not None:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60, context=self.ctx) as res:
|
||||
raw = res.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 403:
|
||||
raise N8nError("合言葉が違います (HTTP 403)") from None
|
||||
detail = one_line(exc.read().decode("utf-8", "replace"), 200)
|
||||
raise N8nError(f"HTTP {exc.code}: {detail or exc.reason}") from None
|
||||
except urllib.error.URLError as exc:
|
||||
raise N8nError(f"接続できません: {exc.reason}") from None
|
||||
except OSError as exc:
|
||||
raise N8nError(f"接続できません: {exc}") from None
|
||||
try:
|
||||
data = json.loads(raw or b"{}")
|
||||
except ValueError:
|
||||
raise N8nError("JSON として読めない応答が返りました") from None
|
||||
if isinstance(data, dict) and data.get("ok") is False and data.get("error"):
|
||||
raise N8nError(str(data["error"]))
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
def rows(self, start, end):
|
||||
return self.call(f"/webhook/wt-import-rows?from={start.isoformat()}&to={end.isoformat()}")
|
||||
|
||||
def import_rows(self, rows):
|
||||
return self.call("/webhook/wt-import", "POST", {"rows": rows})
|
||||
|
||||
|
||||
class N8nHolder:
|
||||
"""n8n 連携の設定ファイルと、そこから作ったクライアントを保持する。合言葉は UI へ返さない。"""
|
||||
|
||||
LIMITS = {"gapMinutes": (1, 240), "minMinutes": (0, 240)}
|
||||
|
||||
def __init__(self, path):
|
||||
self.path = Path(path)
|
||||
self.lock = threading.Lock()
|
||||
self.cfg = load_redmine_config(self.path) # 中身は汎用の JSON 読み込み
|
||||
self.client = self._build()
|
||||
|
||||
def _build(self):
|
||||
return N8n(self.cfg.get("url"), self.cfg.get("token"), self.cfg.get("verifySsl", False))
|
||||
|
||||
def settings(self):
|
||||
with self.lock:
|
||||
return int(self.cfg.get("gapMinutes", N8N_GAP_MIN)), int(self.cfg.get("minMinutes", N8N_MIN_MIN))
|
||||
|
||||
def public(self):
|
||||
with self.lock:
|
||||
cfg = self.cfg
|
||||
return {
|
||||
"url": cfg.get("url", ""),
|
||||
"hasToken": bool(cfg.get("token")),
|
||||
"verifySsl": bool(cfg.get("verifySsl", False)),
|
||||
"gapMinutes": int(cfg.get("gapMinutes", N8N_GAP_MIN)),
|
||||
"minMinutes": int(cfg.get("minMinutes", N8N_MIN_MIN)),
|
||||
"configPath": str(self.path),
|
||||
}
|
||||
|
||||
def update(self, patch):
|
||||
"""URL・合言葉・区間の設定を保存する。token が空文字なら既存の合言葉を残す。"""
|
||||
with self.lock:
|
||||
cfg = dict(self.cfg)
|
||||
if "url" in patch:
|
||||
cfg["url"] = str(patch["url"] or "").strip()
|
||||
if "verifySsl" in patch:
|
||||
cfg["verifySsl"] = bool(patch["verifySsl"])
|
||||
for key, (low, high) in self.LIMITS.items():
|
||||
if key in patch:
|
||||
try:
|
||||
value = int(patch[key])
|
||||
except (TypeError, ValueError):
|
||||
raise N8nError(f"{key} は整数で指定してください") from None
|
||||
if not low <= value <= high:
|
||||
raise N8nError(f"{key} は {low}〜{high} で指定してください")
|
||||
cfg[key] = value
|
||||
if patch.get("clearToken"):
|
||||
cfg.pop("token", None)
|
||||
elif patch.get("token"):
|
||||
cfg["token"] = str(patch["token"]).strip()
|
||||
self.cfg = cfg
|
||||
self.client = self._build()
|
||||
try:
|
||||
self.path.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
except OSError as exc:
|
||||
raise N8nError(f"設定を保存できません: {exc}") from None
|
||||
return self.public()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- HTTP
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user