From 593d13d8a13514918a51c01ab9858dbd9fc65ec8 Mon Sep 17 00:00:00 2001 From: Akira Date: Mon, 2 Mar 2026 15:23:50 +0900 Subject: [PATCH] =?UTF-8?q?=E3=83=AD=E3=83=BC=E3=82=AB=E3=83=ABLLM?= =?UTF-8?q?=E3=81=AB=E3=83=AF=E3=83=BC=E3=82=AF=E3=83=95=E3=83=AD=E3=83=BC?= =?UTF-8?q?=E3=82=92=E4=BD=9C=E3=82=89=E3=81=9B=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 9 ++ CLAUDE.md | 31 +++++- autonomous_windmill/.env.example | 15 +++ autonomous_windmill/config.py | 22 ++++ autonomous_windmill/controller.py | 148 +++++++++++++++++++++++++ autonomous_windmill/job_poller.py | 48 ++++++++ autonomous_windmill/llm_interface.py | 99 +++++++++++++++++ autonomous_windmill/requirements.txt | 3 + autonomous_windmill/run_gui.py | 81 ++++++++++++++ autonomous_windmill/state_manager.py | 33 ++++++ autonomous_windmill/validator.py | 57 ++++++++++ autonomous_windmill/windmill_client.py | 50 +++++++++ flows/mail_filter.flow.json | 27 +++++ test_imap.py | 83 ++++++++++++++ 14 files changed, 705 insertions(+), 1 deletion(-) create mode 100644 autonomous_windmill/.env.example create mode 100644 autonomous_windmill/config.py create mode 100644 autonomous_windmill/controller.py create mode 100644 autonomous_windmill/job_poller.py create mode 100644 autonomous_windmill/llm_interface.py create mode 100644 autonomous_windmill/requirements.txt create mode 100644 autonomous_windmill/run_gui.py create mode 100644 autonomous_windmill/state_manager.py create mode 100644 autonomous_windmill/validator.py create mode 100644 autonomous_windmill/windmill_client.py create mode 100644 flows/mail_filter.flow.json create mode 100644 test_imap.py diff --git a/.gitignore b/.gitignore index e709289..7a89fc3 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,15 @@ variables/ resources/ +# Environment / secrets +.env +.env.local + +# Python +.venv/ +__pycache__/ +*.pyc + # wmill CLI wmill-lock.yaml diff --git a/CLAUDE.md b/CLAUDE.md index fc6baa1..4c9e2e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,8 @@ windmill_workflow/ ├── flows/ # フロー定義JSON │ ├── system_heartbeat.flow.json # Windmill自己診断フロー -│ └── shiraou_notification.flow.json # 白皇集落 変更通知フロー +│ ├── shiraou_notification.flow.json # 白皇集落 変更通知フロー +│ └── mail_filter.flow.json # メールフィルタリングフロー ├── docs/ │ └── shiraou/ # 白皇集落営農組合関連ドキュメント │ ├── 19_windmill_通知ワークフロー連携仕様.md # API仕様書 @@ -43,6 +44,7 @@ windmill_workflow/ |------|------|-------------| | `f/app_custom/system_heartbeat` | Windmill自己診断 | なし(手動) | | `f/shiraou/shiraou_notification` | 白皇集落営農 変更通知 | 5分毎(JST) | +| `f/mail/mail_filter` | メールフィルタリング(IMAP→LLM→LINE) | 10分毎(JST)予定 | | `u/antigravity/git_sync` | Git同期 | 30分毎 | ## wm-api.sh コマンド一覧 @@ -88,7 +90,34 @@ git push origin main | `u/admin/LINE_CHANNEL_ACCESS_TOKEN` | ✅ | LINE Messaging APIトークン | | `u/admin/LINE_TO` | ✅ | LINE通知先ID(ユーザーまたはグループ) | | `u/admin/SHIRAOU_LAST_CHECKED_AT` | ❌ | 前回確認時刻(ワークフローが自動更新) | +| `u/admin/KEINASYSTEM_API_KEY` | ✅ | Keinasystem MAIL_API_KEY(.envと同じ値) | +| `u/admin/KEINASYSTEM_API_URL` | ❌ | `https://keinafarm.net` | +| `u/admin/GEMINI_API_KEY` | ✅ | Google Gemini API キー(LLM判定用) | +| `u/admin/GMAIL_IMAP_USER` | ✅ | GmailアカウントのIMAPユーザー名(メールアドレス) | +| `u/admin/GMAIL_IMAP_PASSWORD` | ✅ | GmailのアプリパスワードIMAPパスワード) | +| `u/admin/MAIL_FILTER_GMAIL_LAST_UID` | ❌ | Gmail最終処理UID(ワークフローが自動更新) | +| `u/admin/HOTMAIL_IMAP_USER` | ✅ | Hotmail IMAPユーザー名(有効化時に登録) | +| `u/admin/HOTMAIL_IMAP_PASSWORD` | ✅ | Hotmail IMAPパスワード(有効化時に登録) | +| `u/admin/MAIL_FILTER_HOTMAIL_LAST_UID` | ❌ | Hotmail最終処理UID(有効化時に登録) | +| `u/admin/XSERVER_IMAP_USER` | ✅ | Xserver IMAPユーザー名(有効化時に登録) | +| `u/admin/XSERVER_IMAP_PASSWORD` | ✅ | Xserver IMAPパスワード(有効化時に登録) | +| `u/admin/MAIL_FILTER_XSERVER_LAST_UID` | ❌ | Xserver最終処理UID(有効化時に登録) | ## マスタードキュメント - [白皇集落 Windmill通知ワークフロー](docs/shiraou/20_マスタードキュメント_Windmill通知ワークフロー編.md) + +## メールフィルタリング — アカウント有効化手順 + +Gmail → Hotmail → Xserver の順で段階的に有効化する。 + +### Gmail 初期設定 +1. GoogleアカウントでIMAPを有効化(Googleアカウント設定 → セキュリティ → アプリパスワード) +2. Windmill Variables に `GMAIL_IMAP_USER`, `GMAIL_IMAP_PASSWORD` を登録 +3. フローを手動実行(初回: 既存メールスキップ、最大UIDを記録) +4. スケジュール登録(10分毎) + +### Hotmail/Xserver 追加時 +1. Windmill Variables に対応する変数を登録 +2. `flows/mail_filter.flow.json` の該当アカウントの `"enabled": false` を `true` に変更 +3. フローを DELETE → POST で再デプロイ diff --git a/autonomous_windmill/.env.example b/autonomous_windmill/.env.example new file mode 100644 index 0000000..a1a7e8f --- /dev/null +++ b/autonomous_windmill/.env.example @@ -0,0 +1,15 @@ +# Windmill 接続設定 +WINDMILL_URL=https://windmill.keinafarm.net +WINDMILL_TOKEN=your_token_here +WINDMILL_WORKSPACE=admins + +# Ollama 設定 +OLLAMA_URL=http://localhost:11434 +OLLAMA_MODEL=qwen2.5-coder:14b + +# 動作設定 +DEV_PATH_PREFIX=f/dev +MAX_RETRIES=3 +MAX_JSON_RETRIES=2 +POLL_INTERVAL=5 +POLL_MAX_COUNT=30 diff --git a/autonomous_windmill/config.py b/autonomous_windmill/config.py new file mode 100644 index 0000000..7e45709 --- /dev/null +++ b/autonomous_windmill/config.py @@ -0,0 +1,22 @@ +"""設定値の一元管理。環境変数 or .env ファイルから読み込む。""" +import os +from pathlib import Path +from dotenv import load_dotenv + +load_dotenv(Path(__file__).parent / ".env") + +WINDMILL_URL = os.environ.get("WINDMILL_URL", "https://windmill.keinafarm.net") +WINDMILL_TOKEN = os.environ.get("WINDMILL_TOKEN", "") +WINDMILL_WORKSPACE = os.environ.get("WINDMILL_WORKSPACE", "admins") + +OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434") +OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "qwen2.5-coder:14b") + +DEV_PATH_PREFIX = os.environ.get("DEV_PATH_PREFIX", "f/dev") +MAX_RETRIES = int(os.environ.get("MAX_RETRIES", "3")) +MAX_JSON_RETRIES = int(os.environ.get("MAX_JSON_RETRIES", "2")) +POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", "5")) +POLL_MAX_COUNT = int(os.environ.get("POLL_MAX_COUNT", "30")) + +if not WINDMILL_TOKEN: + raise EnvironmentError("WINDMILL_TOKEN が設定されていません。.env ファイルを確認してください。") diff --git a/autonomous_windmill/controller.py b/autonomous_windmill/controller.py new file mode 100644 index 0000000..9c12541 --- /dev/null +++ b/autonomous_windmill/controller.py @@ -0,0 +1,148 @@ +""" +自律ループ制御。全体のオーケストレーションのみを行う。 + +Usage: + python controller.py + +Example: + python controller.py hello_world "print Hello World in Python" +""" +import json +import sys +from config import DEV_PATH_PREFIX, MAX_RETRIES, MAX_JSON_RETRIES +from state_manager import State, is_duplicate, register_hash +from validator import validate +from windmill_client import create_flow, update_flow, flow_exists, run_flow +from job_poller import poll_until_done, JobTimeout +from llm_interface import generate_flow, fix_flow + + +def _log(prefix: str, msg: str) -> None: + print(f"{prefix} {msg}", flush=True) + + +def run(task_description: str, flow_name: str) -> bool: + """ + 自律ループを実行する。 + + Returns: + True = 成功, False = 失敗 + """ + # パス制限: f/dev/* のみ(controller 側で強制) + flow_path = f"{DEV_PATH_PREFIX}/{flow_name}" + state = State(retry_count=MAX_RETRIES) + is_first = True + json_fail_count = 0 + + _log("[START]", f"タスク: {task_description}") + _log("[START]", f"フローパス: {flow_path}") + + while state.retry_count > 0: + attempt = MAX_RETRIES - state.retry_count + 1 + prefix = f"[試行 {attempt}/{MAX_RETRIES}]" + + # ── 1. LLM 生成 ───────────────────────────────────────── + _log(prefix, "フロー生成中...") + if is_first: + raw = generate_flow(task_description) + else: + prev_json = json.dumps(state.current_flow, ensure_ascii=False) + raw = fix_flow(prev_json, state.last_error or "") + + # ── 2 & 3. JSON 検証 ───────────────────────────────────── + try: + flow_dict = validate(raw) + json_fail_count = 0 + _log(prefix, "JSON検証: OK") + except ValueError as e: + _log(prefix, f"JSON検証: NG - {e}") + json_fail_count += 1 + if json_fail_count >= MAX_JSON_RETRIES: + _log(prefix, f"JSON検証 {MAX_JSON_RETRIES} 回連続失敗 → リトライ消費") + state.retry_count -= 1 + state.last_error = str(e) + json_fail_count = 0 + is_first = False + continue + + # ── 5. ハッシュ比較 ────────────────────────────────────── + if is_duplicate(state, flow_dict): + _log("[STOP]", "同一JSON検出 → 即停止") + return False + + register_hash(state, flow_dict) + + # ── 6. create / update ─────────────────────────────────── + summary = flow_dict["summary"] + value = flow_dict["value"] + try: + if flow_exists(flow_path): + update_flow(flow_path, summary, value) + _log(prefix, f"フロー更新: {flow_path}") + else: + create_flow(flow_path, summary, value) + _log(prefix, f"フロー作成: {flow_path}") + except Exception as e: + _log(prefix, f"フロー送信エラー: {e}") + state.retry_count -= 1 + state.last_error = str(e) + is_first = False + continue + + # API 送信成功直後に current_flow を更新(run 前・失敗時は更新しない) + state.current_flow = flow_dict + + # ── 7. run ────────────────────────────────────────────── + try: + job_id = run_flow(flow_path) + state.job_id = job_id + _log(prefix, f"ジョブ実行: {job_id}") + except Exception as e: + _log(prefix, f"ジョブ起動エラー: {e}") + state.retry_count -= 1 + state.last_error = str(e) + is_first = False + continue + + # ── 8. ジョブ完了待ち ──────────────────────────────────── + _log(prefix, "ジョブ完了待ち...") + try: + success, logs = poll_until_done(job_id) + except JobTimeout as e: + _log(prefix, f"タイムアウト: {e}") + state.retry_count -= 1 + state.last_error = "タイムアウト" + state.last_logs = None + is_first = False + continue + + state.last_logs = logs + + # ── 9. ステータス判定 ──────────────────────────────────── + if success: + _log(prefix, "実行結果: SUCCESS") + _log("[最終]", "状態: 成功") + return True + else: + excerpt = logs[:300] if logs else "(ログなし)" + _log(prefix, "実行結果: FAILURE") + _log(prefix, f"エラー内容: {excerpt}") + state.retry_count -= 1 + state.last_error = logs or "不明なエラー" + is_first = False + + _log("[最終]", f"状態: {MAX_RETRIES} 回失敗で停止") + return False + + +if __name__ == "__main__": + if len(sys.argv) < 3: + print("Usage: python controller.py ") + print("Example: python controller.py hello_world 'print Hello World in Python'") + sys.exit(1) + + _flow_name = sys.argv[1] + _task = " ".join(sys.argv[2:]) + + ok = run(_task, _flow_name) + sys.exit(0 if ok else 1) diff --git a/autonomous_windmill/job_poller.py b/autonomous_windmill/job_poller.py new file mode 100644 index 0000000..7d1777e --- /dev/null +++ b/autonomous_windmill/job_poller.py @@ -0,0 +1,48 @@ +"""ジョブ完了待ちポーリング。Windmill の success フィールドで判定する。""" +import time +from windmill_client import get_job, get_job_logs +from config import POLL_INTERVAL, POLL_MAX_COUNT + + +class JobTimeout(Exception): + pass + + +def poll_until_done(job_id: str) -> tuple[bool, str]: + """ + ジョブが完了するまでポーリングする。 + + 判定優先順位: + 1. success is False → 失敗(即返却) + 2. success is True → 成功(即返却) + 3. それ以外 → 継続待機 + + ログ文字列は主判定に使わない(誤検知防止)。 + + Returns: + (success: bool, logs: str) + Raises: + JobTimeout: POLL_MAX_COUNT * POLL_INTERVAL 秒以内に完了しなかった場合 + """ + for _ in range(POLL_MAX_COUNT): + job = get_job(job_id) + success = job.get("success") + + if success is False: + logs = get_job_logs(job_id) + # result.error があればログに付加(ログが空でもエラー詳細を取得できる) + result_error = job.get("result", {}) or {} + error_detail = result_error.get("error", {}) or {} + error_msg = error_detail.get("message", "") + if error_msg and error_msg not in (logs or ""): + logs = f"{logs}\n[result.error] {error_msg}".strip() + return False, logs + + if success is True: + logs = get_job_logs(job_id) + return True, logs + + time.sleep(POLL_INTERVAL) + + timeout_sec = POLL_MAX_COUNT * POLL_INTERVAL + raise JobTimeout(f"ジョブ {job_id} が {timeout_sec} 秒以内に完了しませんでした") diff --git a/autonomous_windmill/llm_interface.py b/autonomous_windmill/llm_interface.py new file mode 100644 index 0000000..951b224 --- /dev/null +++ b/autonomous_windmill/llm_interface.py @@ -0,0 +1,99 @@ +"""Ollama へのプロンプト送信と JSON 抽出。""" +import json +import re +import httpx +from config import OLLAMA_URL, OLLAMA_MODEL + +_SYSTEM_PROMPT = """\ +あなたはWindmillフロー生成AIです。 +以下のルールを必ず守ってください: +- JSONのみ出力すること +- Markdownのコードブロック(```)は使わない +- 説明文・コメントは一切出力しない +- フィールド順は必ず summary → value の順にすること +- 出力するJSONは必ず以下のスキーマに従うこと: + +{ + "summary": "<タスクを一言で表す英語の説明>", + "value": { + "modules": [ + { + "id": "a", + "value": { + "type": "rawscript", + "language": "python3", + "content": "<タスクを実行するPython3コード>", + "input_transforms": {} + } + } + ] + } +} + +【必須ルール】 +- content のコードは必ず def main(): で始めること(Windmillのエントリーポイント) +- main() がない場合は AttributeError になるため絶対に省略しないこと +- content の内容はユーザーのタスク説明に従って書くこと(テンプレートをそのままコピーしないこと) +- content 内の改行は \\n でエスケープすること(リテラル改行を入れると JSON パースエラーになる) +- modules.id は a, b, c... の連番。追加フィールド禁止。 + +【出力例1】タスク: 「おはよう」と表示する +{"summary":"Print greeting","value":{"modules":[{"id":"a","value":{"type":"rawscript","language":"python3","content":"def main():\\n print('おはよう')","input_transforms":{}}}]}} + +【出力例2】タスク: 1から5までの数字を表示する +{"summary":"Print numbers 1 to 5","value":{"modules":[{"id":"a","value":{"type":"rawscript","language":"python3","content":"def main():\\n for i in range(1, 6):\\n print(i)","input_transforms":{}}}]}} + +【出力例3】タスク: 現在の日時を表示する +{"summary":"Display current datetime","value":{"modules":[{"id":"a","value":{"type":"rawscript","language":"python3","content":"def main():\\n from datetime import datetime\\n print(datetime.now().strftime('%Y-%m-%d %H:%M:%S'))","input_transforms":{}}}]}}\ +""" + + +def _chat(messages: list[dict]) -> str: + resp = httpx.post( + f"{OLLAMA_URL}/api/chat", + json={ + "model": OLLAMA_MODEL, + "messages": messages, + "stream": False, + "options": {"temperature": 0.1, "top_p": 0.9}, + }, + timeout=120, + ) + resp.raise_for_status() + raw = resp.json()["message"]["content"].strip() + return _extract_json(raw) + + +def _extract_json(raw: str) -> str: + """LLM がコードブロックで囲んでしまった場合でも JSON 部分を取り出す。""" + # ```json ... ``` または ``` ... ``` を除去 + match = re.search(r"```(?:json)?\s*([\s\S]+?)\s*```", raw) + if match: + return match.group(1).strip() + return raw + + +def generate_flow(task_description: str) -> str: + """初回生成:タスク説明からフロー JSON を生成する。""" + messages = [ + {"role": "system", "content": _SYSTEM_PROMPT}, + {"role": "user", "content": f"以下のフローをJSON形式で生成してください。\n要件: {task_description}"}, + ] + return _chat(messages) + + +def fix_flow(previous_flow_json: str, error_log: str) -> str: + """リトライ生成:前回の JSON + エラーログから修正版を生成する。""" + messages = [ + {"role": "system", "content": _SYSTEM_PROMPT}, + {"role": "user", "content": ( + "前回のフロー実行でエラーが発生しました。修正したフローをJSON形式で出力してください。\n\n" + f"--- 前回のフローJSON ---\n{previous_flow_json}\n\n" + f"--- エラーログ ---\n{error_log}\n\n" + "--- 修正指示 ---\n" + "- 前回と同一のJSONは絶対に出力しないこと\n" + "- エラーの原因箇所のみ修正すること\n" + "- スキーマは変えないこと" + )}, + ] + return _chat(messages) diff --git a/autonomous_windmill/requirements.txt b/autonomous_windmill/requirements.txt new file mode 100644 index 0000000..d9b43ff --- /dev/null +++ b/autonomous_windmill/requirements.txt @@ -0,0 +1,3 @@ +httpx +jsonschema +python-dotenv diff --git a/autonomous_windmill/run_gui.py b/autonomous_windmill/run_gui.py new file mode 100644 index 0000000..ea105c4 --- /dev/null +++ b/autonomous_windmill/run_gui.py @@ -0,0 +1,81 @@ +""" +GUIダイアログでフロー名とタスク説明を入力してから controller.py を起動する。 +VS Code タスクから呼び出す用。 +""" +import os +import sys +import tkinter as tk +from tkinter import messagebox + + +def main() -> None: + root = tk.Tk() + root.title("Windmill フロー生成") + root.resizable(True, True) + + pad = {"padx": 12, "pady": 4} + + # ── フロー名 ────────────────────────────────── + tk.Label(root, text="フロー名", anchor="w").grid( + row=0, column=0, sticky="ew", **pad + ) + flow_var = tk.StringVar() + flow_entry = tk.Entry(root, textvariable=flow_var, width=46) + flow_entry.grid(row=1, column=0, sticky="ew", padx=12, pady=(0, 8)) + + # ── タスク説明 ──────────────────────────────── + tk.Label(root, text="タスク説明", anchor="w").grid( + row=2, column=0, sticky="ew", **pad + ) + task_text = tk.Text(root, width=46, height=8, wrap=tk.WORD) + task_text.grid(row=3, column=0, sticky="nsew", padx=12, pady=(0, 8)) + + # ── ボタン ──────────────────────────────────── + btn_frame = tk.Frame(root) + btn_frame.grid(row=4, column=0, sticky="e", padx=12, pady=(4, 12)) + + def on_cancel(): + root.destroy() + sys.exit(0) + + def on_run(): + flow_name = flow_var.get().strip() + task_desc = task_text.get("1.0", tk.END).strip() + + if not flow_name: + messagebox.showwarning("入力エラー", "フロー名を入力してください。", parent=root) + flow_entry.focus() + return + if not task_desc: + messagebox.showwarning("入力エラー", "タスク説明を入力してください。", parent=root) + task_text.focus() + return + + root.destroy() + + # controller.py をカレントプロセスと置き換えて実行 + # → ターミナルにそのままログが流れる + script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "controller.py") + os.execv(sys.executable, [sys.executable, script, flow_name, task_desc]) + + tk.Button(btn_frame, text="キャンセル", width=10, command=on_cancel).pack( + side=tk.LEFT, padx=(0, 6) + ) + tk.Button(btn_frame, text="実行", width=10, command=on_run, default=tk.ACTIVE).pack( + side=tk.LEFT + ) + + # ── レイアウト調整 ──────────────────────────── + root.columnconfigure(0, weight=1) + root.rowconfigure(3, weight=1) + + # Enter キーで実行、Escape でキャンセル + root.bind("", lambda _: on_run()) + root.bind("", lambda _: on_cancel()) + + flow_entry.focus() + root.mainloop() + + +if __name__ == "__main__": + main() diff --git a/autonomous_windmill/state_manager.py b/autonomous_windmill/state_manager.py new file mode 100644 index 0000000..7401dcd --- /dev/null +++ b/autonomous_windmill/state_manager.py @@ -0,0 +1,33 @@ +"""State オブジェクトの管理とハッシュ比較。""" +import json +import hashlib +from dataclasses import dataclass, field +from typing import Optional, Set + + +@dataclass +class State: + retry_count: int = 3 + current_flow: Optional[dict] = None + last_logs: Optional[str] = None + last_error: Optional[str] = None + flow_hashes: Set[str] = field(default_factory=set) + job_id: Optional[str] = None + + +def _canonical(flow_dict: dict) -> str: + """キー順を固定した JSON 文字列を返す(ハッシュ安定化)。""" + return json.dumps(flow_dict, sort_keys=True, separators=(',', ':')) + + +def compute_hash(flow_dict: dict) -> str: + return hashlib.sha256(_canonical(flow_dict).encode()).hexdigest() + + +def is_duplicate(state: State, flow_dict: dict) -> bool: + """過去に同一の JSON を出力済みかどうかを判定する。""" + return compute_hash(flow_dict) in state.flow_hashes + + +def register_hash(state: State, flow_dict: dict) -> None: + state.flow_hashes.add(compute_hash(flow_dict)) diff --git a/autonomous_windmill/validator.py b/autonomous_windmill/validator.py new file mode 100644 index 0000000..b87bb42 --- /dev/null +++ b/autonomous_windmill/validator.py @@ -0,0 +1,57 @@ +"""LLM 出力の JSON 構文・スキーマ検証。""" +import json +import jsonschema + +# LLM が出力すべき JSON の最小スキーマ +_FLOW_SCHEMA = { + "type": "object", + "required": ["summary", "value"], + "additionalProperties": False, + "properties": { + "summary": {"type": "string", "minLength": 1}, + "value": { + "type": "object", + "required": ["modules"], + "properties": { + "modules": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["id", "value"], + "properties": { + "id": {"type": "string"}, + "value": { + "type": "object", + "required": ["type", "language", "content"], + "properties": { + "type": {"type": "string"}, + "language": {"type": "string"}, + "content": {"type": "string"}, + "input_transforms": {"type": "object"}, + }, + }, + }, + }, + } + }, + }, + }, +} + + +def validate(raw: str) -> dict: + """JSON 文字列を構文・スキーマ検証して dict を返す。失敗時は ValueError を投げる。""" + # 構文チェック + try: + data = json.loads(raw) + except json.JSONDecodeError as e: + raise ValueError(f"JSON構文エラー: {e}") + + # スキーマチェック + try: + jsonschema.validate(data, _FLOW_SCHEMA) + except jsonschema.ValidationError as e: + raise ValueError(f"JSONスキーマ不正: {e.message}") + + return data diff --git a/autonomous_windmill/windmill_client.py b/autonomous_windmill/windmill_client.py new file mode 100644 index 0000000..571a830 --- /dev/null +++ b/autonomous_windmill/windmill_client.py @@ -0,0 +1,50 @@ +"""Windmill REST API の薄いラッパー。MCP は使わず直接 HTTPS で叩く。""" +import httpx +from config import WINDMILL_URL, WINDMILL_TOKEN, WINDMILL_WORKSPACE + + +def _headers() -> dict: + return {"Authorization": f"Bearer {WINDMILL_TOKEN}"} + + +def _api(path: str) -> str: + return f"{WINDMILL_URL}/api/w/{WINDMILL_WORKSPACE}/{path}" + + +def flow_exists(path: str) -> bool: + resp = httpx.get(_api(f"flows/get/{path}"), headers=_headers(), timeout=30) + return resp.status_code == 200 + + +def create_flow(path: str, summary: str, value: dict) -> None: + payload = {"path": path, "summary": summary, "description": "", "value": value} + resp = httpx.post(_api("flows/create"), headers=_headers(), json=payload, timeout=30) + resp.raise_for_status() + + +def update_flow(path: str, summary: str, value: dict) -> None: + # 正しいエンドポイントは /flows/update/{path}(/flows/edit/ は404になる) + payload = {"path": path, "summary": summary, "description": "", "value": value} + resp = httpx.post(_api(f"flows/update/{path}"), headers=_headers(), json=payload, timeout=30) + resp.raise_for_status() + + +def run_flow(path: str) -> str: + """フローを実行して job_id を返す。""" + resp = httpx.post(_api(f"jobs/run/f/{path}"), headers=_headers(), json={}, timeout=30) + resp.raise_for_status() + return resp.text.strip().strip('"') + + +def get_job(job_id: str) -> dict: + """ジョブの状態を取得する。success フィールド: True=成功, False=失敗, None=実行中。""" + resp = httpx.get(_api(f"jobs_u/get/{job_id}"), headers=_headers(), timeout=30) + resp.raise_for_status() + return resp.json() + + +def get_job_logs(job_id: str) -> str: + resp = httpx.get(_api(f"jobs_u/getlogs/{job_id}"), headers=_headers(), timeout=30) + if resp.status_code == 200: + return resp.text + return "" diff --git a/flows/mail_filter.flow.json b/flows/mail_filter.flow.json new file mode 100644 index 0000000..b70eb42 --- /dev/null +++ b/flows/mail_filter.flow.json @@ -0,0 +1,27 @@ +{ + "path": "f/mail/mail_filter", + "summary": "メールフィルタリング", + "description": "IMAPで新着メールを受信し、送信者ルール確認→LLM判定→LINE通知を行う。Keinasystemと連携。Gmail→Hotmail→Xserverの順で段階的に有効化する。", + "value": { + "modules": [ + { + "id": "a", + "summary": "メール取得・判定・通知", + "value": { + "type": "rawscript", + "language": "python3", + "content": "import imaplib\nimport email\nimport email.header\nimport json\nimport re\nimport ssl\nimport urllib.request\nimport urllib.parse\nfrom datetime import datetime, timezone, timedelta\nfrom email.utils import parsedate_to_datetime\nimport wmill\n\nJST = timezone(timedelta(hours=9))\n\n# ============================================================\n# アカウント設定\n# 新しいアカウントを追加する際は enabled: True にする\n# ============================================================\nACCOUNTS = [\n {\n \"name\": \"gmail\",\n \"account_code\": \"gmail\",\n \"host\": \"imap.gmail.com\",\n \"port\": 993,\n \"user_var\": \"u/admin/GMAIL_IMAP_USER\",\n \"pass_var\": \"u/admin/GMAIL_IMAP_PASSWORD\",\n \"last_uid_var\": \"u/admin/MAIL_FILTER_GMAIL_LAST_UID\",\n \"mailbox\": \"[Gmail]/All Mail\",\n \"enabled\": True,\n },\n {\n \"name\": \"gmail_service\",\n \"account_code\": \"gmail_service\",\n \"host\": \"imap.gmail.com\",\n \"port\": 993,\n \"user_var\": \"u/admin/GMAIL2_IMAP_USER\",\n \"pass_var\": \"u/admin/GMAIL2_IMAP_PASSWORD\",\n \"last_uid_var\": \"u/admin/MAIL_FILTER_GMAIL2_LAST_UID\",\n \"mailbox\": \"[Gmail]/All Mail\",\n \"enabled\": True,\n },\n # Hotmail テスト後に有効化\n # {\n # \"name\": \"hotmail\",\n # \"account_code\": \"hotmail\",\n # \"host\": \"outlook.office365.com\",\n # \"port\": 993,\n # \"user_var\": \"u/admin/HOTMAIL_IMAP_USER\",\n # \"pass_var\": \"u/admin/HOTMAIL_IMAP_PASSWORD\",\n # \"last_uid_var\": \"u/admin/MAIL_FILTER_HOTMAIL_LAST_UID\",\n # \"enabled\": False,\n # },\n # Xserver (keinafarm.com) 6アカウント\n {\n \"name\": \"xserver_akiracraftwork\",\n \"account_code\": \"xserver\",\n \"host\": \"sv579.xserver.jp\",\n \"port\": 993,\n \"user_var\": \"u/admin/XSERVER1_IMAP_USER\",\n \"pass_var\": \"u/admin/XSERVER1_IMAP_PASSWORD\",\n \"last_uid_var\": \"u/admin/MAIL_FILTER_XSERVER1_LAST_UID\",\n \"enabled\": True,\n },\n {\n \"name\": \"xserver_service\",\n \"account_code\": \"xserver\",\n \"host\": \"sv579.xserver.jp\",\n \"port\": 993,\n \"user_var\": \"u/admin/XSERVER2_IMAP_USER\",\n \"pass_var\": \"u/admin/XSERVER2_IMAP_PASSWORD\",\n \"last_uid_var\": \"u/admin/MAIL_FILTER_XSERVER2_LAST_UID\",\n \"enabled\": True,\n },\n {\n \"name\": \"xserver_midori\",\n \"account_code\": \"xserver\",\n \"host\": \"sv579.xserver.jp\",\n \"port\": 993,\n \"user_var\": \"u/admin/XSERVER3_IMAP_USER\",\n \"pass_var\": \"u/admin/XSERVER3_IMAP_PASSWORD\",\n \"last_uid_var\": \"u/admin/MAIL_FILTER_XSERVER3_LAST_UID\",\n \"enabled\": True,\n },\n {\n \"name\": \"xserver_kouseiren\",\n \"account_code\": \"xserver\",\n \"host\": \"sv579.xserver.jp\",\n \"port\": 993,\n \"user_var\": \"u/admin/XSERVER4_IMAP_USER\",\n \"pass_var\": \"u/admin/XSERVER4_IMAP_PASSWORD\",\n \"last_uid_var\": \"u/admin/MAIL_FILTER_XSERVER4_LAST_UID\",\n \"enabled\": True,\n },\n {\n \"name\": \"xserver_post\",\n \"account_code\": \"xserver\",\n \"host\": \"sv579.xserver.jp\",\n \"port\": 993,\n \"user_var\": \"u/admin/XSERVER5_IMAP_USER\",\n \"pass_var\": \"u/admin/XSERVER5_IMAP_PASSWORD\",\n \"last_uid_var\": \"u/admin/MAIL_FILTER_XSERVER5_LAST_UID\",\n \"enabled\": True,\n },\n {\n \"name\": \"xserver_sales\",\n \"account_code\": \"xserver\",\n \"host\": \"sv579.xserver.jp\",\n \"port\": 993,\n \"user_var\": \"u/admin/XSERVER6_IMAP_USER\",\n \"pass_var\": \"u/admin/XSERVER6_IMAP_PASSWORD\",\n \"last_uid_var\": \"u/admin/MAIL_FILTER_XSERVER6_LAST_UID\",\n \"enabled\": True,\n },\n]\n\n\ndef main():\n # 共通変数取得\n api_key = wmill.get_variable(\"u/admin/KEINASYSTEM_API_KEY\")\n api_url = wmill.get_variable(\"u/admin/KEINASYSTEM_API_URL\").rstrip(\"/\")\n gemini_key = wmill.get_variable(\"u/admin/GEMINI_API_KEY\")\n line_token = wmill.get_variable(\"u/admin/LINE_CHANNEL_ACCESS_TOKEN\")\n line_to = wmill.get_variable(\"u/admin/LINE_TO\")\n\n total_processed = 0\n total_notified = 0\n\n for account in ACCOUNTS:\n if not account[\"enabled\"]:\n continue\n print(f\"[{account['name']}] 処理開始\")\n try:\n processed, notified = process_account(\n account, api_key, api_url, gemini_key, line_token, line_to\n )\n total_processed += processed\n total_notified += notified\n print(f\"[{account['name']}] 処理完了: {processed}件処理, {notified}件通知\")\n except Exception as e:\n print(f\"[{account['name']}] エラー: {e}\")\n # 1アカウントが失敗しても他のアカウントは継続\n\n return {\n \"total_processed\": total_processed,\n \"total_notified\": total_notified,\n }\n\n\ndef process_account(account, api_key, api_url, gemini_key, line_token, line_to):\n user = wmill.get_variable(account[\"user_var\"])\n password = wmill.get_variable(account[\"pass_var\"])\n\n # 前回の最終UID取得\n try:\n last_uid_str = wmill.get_variable(account[\"last_uid_var\"])\n last_uid = int(last_uid_str) if last_uid_str and last_uid_str != \"0\" else None\n except Exception:\n last_uid = None\n\n # IMAP接続\n ssl_ctx = ssl.create_default_context()\n mail = imaplib.IMAP4_SSL(account[\"host\"], account[\"port\"], ssl_context=ssl_ctx)\n mail.login(user, password)\n mailbox = account.get(\"mailbox\", \"INBOX\")\n imap_mailbox = resolve_mailbox(mail, mailbox)\n\n try:\n if last_uid is None:\n # 初回実行: 現在の最大UIDを記録して終了(既存メールは処理しない)\n _, data = mail.uid(\"SEARCH\", None, \"ALL\")\n all_uids = data[0].split() if data[0] else []\n max_uid = int(all_uids[-1]) if all_uids else 0\n wmill.set_variable(account[\"last_uid_var\"], str(max_uid))\n print(f\"[{account['name']}] 初回実行: 最大UID={max_uid} を記録、既存メールはスキップ\")\n return 0, 0\n\n # last_uid より大きい UID を検索\n search_criterion = f\"UID {last_uid + 1}:*\"\n _, data = mail.uid(\"SEARCH\", None, search_criterion)\n raw_uids = data[0].split() if data[0] else []\n new_uids = [u for u in raw_uids if int(u) > last_uid]\n\n if not new_uids:\n print(f\"[{account['name']}] 新着メールなし\")\n return 0, 0\n\n print(f\"[{account['name']}] 新着{len(new_uids)}件\")\n\n processed = 0\n notified = 0\n max_processed_uid = last_uid\n\n for uid_bytes in new_uids:\n uid = int(uid_bytes)\n try:\n result = process_message(\n mail, uid, account,\n api_key, api_url, gemini_key, line_token, line_to\n )\n processed += 1\n if result == \"notified\":\n notified += 1\n max_processed_uid = max(max_processed_uid, uid)\n except Exception as e:\n print(f\"[{account['name']}] UID={uid} 処理エラー: {e}\")\n # 個別メッセージのエラーは継続、UIDは進めない\n\n # 処理済み最大UIDを保存(正常完了時のみ)\n if max_processed_uid > last_uid:\n wmill.set_variable(account[\"last_uid_var\"], str(max_processed_uid))\n\n return processed, notified\n finally:\n mail.logout()\n\n\ndef process_message(mail, uid, account, api_key, api_url, gemini_key, line_token, line_to):\n \"\"\"メッセージを1通処理。戻り値: 'skipped' / 'not_important' / 'notified'\"\"\"\n account_code = account[\"account_code\"]\n forwarding_map = account.get(\"forwarding_map\", {})\n\n # メール取得\n _, data = mail.uid(\"FETCH\", str(uid), \"(RFC822)\")\n if not data or not data[0]:\n return \"skipped\"\n\n raw_email = data[0][1]\n msg = email.message_from_bytes(raw_email)\n\n # ヘッダー解析\n message_id = msg.get(\"Message-ID\", \"\").strip()\n if not message_id:\n message_id = f\"{account_code}-uid-{uid}\"\n\n sender_raw = msg.get(\"From\", \"\")\n sender_email_addr = extract_email_address(sender_raw)\n sender_domain = sender_email_addr.split(\"@\")[-1] if \"@\" in sender_email_addr else \"\"\n\n subject = decode_header_value(msg.get(\"Subject\", \"(件名なし)\"))\n\n date_str = msg.get(\"Date\", \"\")\n try:\n received_at = parsedate_to_datetime(date_str).isoformat()\n except Exception:\n received_at = datetime.now(JST).isoformat()\n\n body_preview = extract_body_preview(msg, max_chars=500)\n\n # 転送検出: To:ヘッダーのドメインが forwarding_map に存在する場合は account_code を上書き\n if forwarding_map:\n to_raw = msg.get(\"To\", \"\")\n if to_raw:\n to_addr = extract_email_address(to_raw)\n to_domain = to_addr.split(\"@\")[-1] if \"@\" in to_addr else \"\"\n if to_domain in forwarding_map:\n account_code = forwarding_map[to_domain]\n print(f\" [転送検出] To:{to_addr} → account: {account_code}\")\n\n print(f\" From: {sender_email_addr}, Subject: {subject[:50]}\")\n\n # --- ステップ1: 送信者ルール確認 ---\n rule_result = call_api_get(api_key, api_url, \"/api/mail/sender-rule/\", {\n \"email\": sender_email_addr,\n \"domain\": sender_domain,\n })\n\n if rule_result.get(\"matched\"):\n rule = rule_result[\"rule\"]\n\n if rule == \"never_notify\":\n print(f\" → never_notify ルール一致、スキップ\")\n return \"skipped\"\n\n elif rule == \"always_notify\":\n print(f\" → always_notify ルール一致、即通知\")\n result = post_email(api_key, api_url, {\n \"account\": account_code,\n \"message_id\": message_id,\n \"sender_email\": sender_email_addr,\n \"sender_domain\": sender_domain,\n \"subject\": subject,\n \"body_preview\": body_preview,\n \"received_at\": received_at,\n \"llm_verdict\": \"important\",\n })\n if result.get(\"feedback_url\"):\n send_line_notification(line_token, line_to, account_code, sender_email_addr, subject, result[\"feedback_url\"])\n return \"notified\"\n return \"skipped\"\n\n # --- ステップ2: LLM判定 ---\n context = call_api_get(api_key, api_url, \"/api/mail/sender-context/\", {\n \"email\": sender_email_addr,\n \"domain\": sender_domain,\n })\n verdict = judge_with_llm(gemini_key, sender_email_addr, subject, body_preview, context)\n print(f\" → LLM判定: {verdict}\")\n\n # --- ステップ3: Keinasystemに記録 ---\n result = post_email(api_key, api_url, {\n \"account\": account_code,\n \"message_id\": message_id,\n \"sender_email\": sender_email_addr,\n \"sender_domain\": sender_domain,\n \"subject\": subject,\n \"body_preview\": body_preview,\n \"received_at\": received_at,\n \"llm_verdict\": verdict,\n })\n\n if verdict == \"important\" and result.get(\"feedback_url\"):\n send_line_notification(line_token, line_to, account_code, sender_email_addr, subject, result[\"feedback_url\"])\n return \"notified\"\n\n return \"not_important\"\n\n\n# ============================================================\n# メールボックス解決\n# ============================================================\n\ndef resolve_mailbox(mail, mailbox):\n \"\"\"メールボックスを選択し SELECT する。\n INBOX はそのまま、それ以外は指定名 -> \\\\All 属性でフォールバック。\n \"\"\"\n if mailbox == \"INBOX\":\n typ, data = mail.select(\"INBOX\")\n if typ != 'OK':\n raise Exception(f\"SELECT INBOX failed: {data}\")\n return \"INBOX\"\n\n # まず指定名で試行\n imap_name = '\"' + mailbox + '\"'\n typ, data = mail.select(imap_name)\n if typ == 'OK':\n return imap_name\n\n # 失敗した場合: \\\\All 属性を持つメールボックスを自動検出\n print(f\" [INFO] {mailbox} not found, searching for \\\\\\\\All mailbox...\")\n typ2, mboxes = mail.list()\n if typ2 == 'OK':\n for mb in mboxes:\n if not mb:\n continue\n mb_str = mb.decode() if isinstance(mb, bytes) else mb\n if '\\\\\\\\All' in mb_str or '\\\\All' in mb_str:\n # \"(attrs) \\\".\\\" \\\"name\\\"\" 形式から名前を抽出\n parts = mb_str.rsplit('\"', 2)\n if len(parts) >= 2 and parts[-2]:\n found = parts[-2]\n else:\n found = mb_str.split()[-1].strip('\"')\n print(f\" [INFO] Found All Mail mailbox: {found}\")\n imap_found = '\"' + found + '\"'\n typ3, data3 = mail.select(imap_found)\n if typ3 == 'OK':\n return imap_found\n raise Exception(f\"Could not select any All Mail mailbox (tried: {mailbox})\")\n\n\n# ============================================================\n# APIヘルパー\n# ============================================================\n\ndef _make_ssl_ctx():\n ctx = ssl.create_default_context()\n ctx.check_hostname = False\n ctx.verify_mode = ssl.CERT_NONE\n return ctx\n\n\ndef call_api_get(api_key, api_url, path, params):\n qs = urllib.parse.urlencode(params)\n url = f\"{api_url}{path}?{qs}\"\n req = urllib.request.Request(url, headers={\"X-API-Key\": api_key})\n with urllib.request.urlopen(req, context=_make_ssl_ctx(), timeout=10) as resp:\n return json.loads(resp.read().decode(\"utf-8\"))\n\n\ndef post_email(api_key, api_url, data):\n url = f\"{api_url}/api/mail/emails/\"\n payload = json.dumps(data).encode(\"utf-8\")\n req = urllib.request.Request(\n url,\n data=payload,\n headers={\"X-API-Key\": api_key, \"Content-Type\": \"application/json\"},\n method=\"POST\",\n )\n try:\n with urllib.request.urlopen(req, context=_make_ssl_ctx(), timeout=10) as resp:\n return json.loads(resp.read().decode(\"utf-8\"))\n except urllib.error.HTTPError as e:\n body = e.read().decode(\"utf-8\")\n if e.code == 400 and \"message_id\" in body:\n # 重複message_idは正常(再実行時の冦殁)\n print(f\" 重複メール、スキップ\")\n return {}\n raise\n\n\nACCOUNT_LABELS = {\n \"gmail\": \"Gmail (メイン)\",\n \"gmail_service\": \"Gmail (サービス用)\",\n \"hotmail\": \"Hotmail\",\n \"xserver\": \"Xserver\",\n}\n\ndef send_line_notification(line_token, line_to, account_code, sender_email_addr, subject, feedback_url):\n account_label = ACCOUNT_LABELS.get(account_code, account_code)\n message = (\n f\"📧 重要なメールが届きました\\n\\n\"\n f\"宛先: {account_label}\\n\"\n f\"差出人: {sender_email_addr}\\n\"\n f\"件名: {subject}\\n\\n\"\n f\"フィードバック:\\n{feedback_url}\"\n )\n payload = json.dumps({\n \"to\": line_to,\n \"messages\": [{\"type\": \"text\", \"text\": message}],\n }).encode(\"utf-8\")\n req = urllib.request.Request(\n \"https://api.line.me/v2/bot/message/push\",\n data=payload,\n headers={\n \"Authorization\": f\"Bearer {line_token}\",\n \"Content-Type\": \"application/json\",\n },\n method=\"POST\",\n )\n with urllib.request.urlopen(req, timeout=30) as resp:\n resp.read()\n\n\n# ============================================================\n# LLM判定(Gemini API)\n# ============================================================\n\ndef judge_with_llm(gemini_key, sender_email_addr, subject, body_preview, context):\n \"\"\"農家にとって重要なメールか判定。'important' または 'not_important' を返す。\"\"\"\n\n context_text = \"\"\n total = context.get(\"total_notified\", 0)\n if total > 0:\n context_text = (\n f\"\\n\\n[この送信者の過去データ] \"\n f\"通知済み{total}件: \"\n f\"重要{context.get('important', 0)}件 / \"\n f\"普通{context.get('not_important', 0)}件 / \"\n f\"通知不要{context.get('never_notify', 0)}件 / \"\n f\"未評価{context.get('no_feedback', 0)}件\"\n )\n\n user_message = (\n f\"送信者: {sender_email_addr}\\n\"\n f\"件名: {subject}\\n\"\n f\"本文冠頭:\\n{body_preview}\"\n f\"{context_text}\\n\\n\"\n f\"このメールは農家にとって重要ですか?\\n\"\n f\"1: 重要(要確認)\\n\"\n f\"2: 重要でない(営業・通知等)\\n\"\n f\"数字1文字のみで答えてください。\"\n )\n\n payload = json.dumps({\n \"system_instruction\": {\n \"parts\": [{\"text\": \"あなたは農家のメールフィルタリングアシスタントです。メールが重要かどうかを判定してください。\"}]\n },\n \"contents\": [{\n \"role\": \"user\",\n \"parts\": [{\"text\": user_message}]\n }],\n \"generationConfig\": {\n \"maxOutputTokens\": 10,\n \"temperature\": 0\n }\n }).encode(\"utf-8\")\n\n url = f\"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={gemini_key}\"\n req = urllib.request.Request(\n url,\n data=payload,\n headers={\"Content-Type\": \"application/json\"},\n method=\"POST\",\n )\n with urllib.request.urlopen(req, timeout=30) as resp:\n result = json.loads(resp.read().decode(\"utf-8\"))\n answer = result[\"candidates\"][0][\"content\"][\"parts\"][0][\"text\"].strip()\n\n return \"important\" if answer.startswith(\"1\") else \"not_important\"\n\n\n# ============================================================\n# メール解析ヘルパー\n# ============================================================\n\ndef extract_email_address(raw):\n \"\"\"'Name ' または 'email@example.com' からアドレスを抽出\"\"\"\n match = re.search(r'<([^>]+)>', raw)\n if match:\n return match.group(1).strip().lower()\n return raw.strip().lower()\n\n\ndef decode_header_value(value):\n \"\"\"MIMEエンコードされたヘッダー値をデコード\"\"\"\n if not value:\n return \"\"\n parts = email.header.decode_header(value)\n decoded = []\n for part, charset in parts:\n if isinstance(part, bytes):\n decoded.append(part.decode(charset or \"utf-8\", errors=\"replace\"))\n else:\n decoded.append(part)\n return \"\".join(decoded)\n\n\ndef extract_body_preview(msg, max_chars=500):\n \"\"\"メール本文の冠頭を抽出(テキスト優先、HTMLフォールバック)\"\"\"\n text_content = \"\"\n html_content = \"\"\n\n if msg.is_multipart():\n for part in msg.walk():\n ctype = part.get_content_type()\n if ctype == \"text/plain\" and not text_content:\n charset = part.get_content_charset() or \"utf-8\"\n try:\n text_content = part.get_payload(decode=True).decode(charset, errors=\"replace\")\n except Exception:\n pass\n elif ctype == \"text/html\" and not html_content:\n charset = part.get_content_charset() or \"utf-8\"\n try:\n html_content = part.get_payload(decode=True).decode(charset, errors=\"replace\")\n except Exception:\n pass\n else:\n charset = msg.get_content_charset() or \"utf-8\"\n try:\n content = msg.get_payload(decode=True).decode(charset, errors=\"replace\")\n if msg.get_content_type() == \"text/html\":\n html_content = content\n else:\n text_content = content\n except Exception:\n pass\n\n if text_content:\n # フッター・区切り線を除去\n text = re.sub(r'\\n[-_=]{10,}\\n.*', '', text_content, flags=re.DOTALL)\n text = re.sub(r'\\s+', ' ', text).strip()\n return text[:max_chars]\n\n if html_content:\n # HTMLタグを除去\n text = re.sub(r'<[^>]+>', ' ', html_content)\n text = re.sub(r'\\s+', ' ', text).strip()\n return text[:max_chars]\n\n return \"\"\n", + "input_transforms": {}, + "lock": "" + } + } + ] + }, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "order": [], + "properties": {}, + "required": [] + } +} \ No newline at end of file diff --git a/test_imap.py b/test_imap.py new file mode 100644 index 0000000..d6adca3 --- /dev/null +++ b/test_imap.py @@ -0,0 +1,83 @@ +""" +IMAP接続診断スクリプト +使い方: python test_imap.py +""" +import imaplib +import ssl +import getpass + +HOST = "outlook.office365.com" +PORT = 993 +USER = "akiracraftworl@infoseek.jp" + +print(f"IMAP診断: {HOST}:{PORT}") +print(f"ユーザー: {USER}") +print() + +# --- Step 1: SSL接続テスト(認証なし)--- +print("[1] SSL接続テスト...") +try: + ssl_ctx = ssl.create_default_context() + mail = imaplib.IMAP4_SSL(HOST, PORT, ssl_context=ssl_ctx) + print(" ✓ SSL接続成功") +except Exception as e: + print(f" ❌ SSL接続失敗: {e}") + exit(1) + +# --- Step 2: サーバーの認証方式を確認 --- +print("[2] サーバー対応認証方式を確認...") +try: + typ, caps_data = mail.capability() + caps = caps_data[0].decode() if caps_data and caps_data[0] else "" + print(f" CAPABILITY: {caps}") + + if "AUTH=PLAIN" in caps or "AUTH=LOGIN" in caps: + print(" ✓ 基本認証(パスワード)が使えます") + basic_auth_supported = True + else: + basic_auth_supported = False + + if "AUTH=XOAUTH2" in caps or "AUTH=OAUTHBEARER" in caps: + print(" ⚠ モダン認証(OAuth2)が必要な可能性があります") + + if not basic_auth_supported: + print(" ❌ 基本認証は対応していません → OAuth2が必要です") + mail.logout() + exit(1) +except Exception as e: + print(f" CAPABILITY取得エラー: {e}") + +# --- Step 3: ログインテスト --- +print("[3] ログインテスト...") +password = getpass.getpass(" パスワードを入力: ") + +try: + mail.login(USER, password) + print(" ✓ ログイン成功!") + + mail.select("INBOX") + _, data = mail.uid("SEARCH", None, "ALL") + uids = data[0].split() if data[0] else [] + print(f" ✓ INBOX: {len(uids)}件") + mail.logout() + print() + print("✅ 成功!Windmillに登録できます。") + +except imaplib.IMAP4.error as e: + err = str(e) + print(f" ❌ ログイン失敗: {err}") + print() + if "disabled" in err.lower() or "imap" in err.lower(): + print(" 原因: IMAPが無効化されています") + print(" 対処: https://outlook.live.com → 設定 → メールの同期 → IMAPを有効化") + elif "AUTHENTICATE" in err or "XOAUTH" in err: + print(" 原因: モダン認証(OAuth2)が必要です") + else: + print(" 原因1: パスワードが間違っている") + print(" 原因2: IMAPが無効(Outlook.com設定を再確認)") + print(" 原因3: モダン認証が必要") + print() + print(" 確認してください:") + print(" → https://outlook.live.com にこのメアドとパスワードでログインできますか?") +except Exception as e: + print(f" ❌ エラー: {type(e).__name__}: {e}")