Skill: ilove323/outlook-calendar (version 1.0.2)

--- name: outlook-calendar description: "读取企业 Microsoft 365 Outlook 日历。当用户问任何涉及日程、会议、安排、工作、任务、事情的问题时触发，例如：今天有什么安排、这周有什么会、本月会议多少小时、明天要做什么、下周有啥工作、这周有什么事、有啥任务等。" metadata: { "openclaw": { "emoji": "📅", "requires": { "bins": ["python3"] } } } --- # Outlook 日历技能 ## 触发条件 **只要用户问任何涉及日程、会议、安排、日历、工作、任务、事情的问题，必须调用此技能。** - "明天有什么安排" / "这周有什么会" / "本月日程" - "帮我看看日历" / "我今天几点有会" - "这个月会议一共多少小时" - "我下周有啥工作" / "明天要做啥" / "这周有什么事" - "下周安排" / "有啥任务" / "日程表看一下" - 任何时间 + 安排/会议/事件/工作/任务/事情的组合问法 ## 首次配置 敏感信息（账号密码、Cookie、Token）存放在 `~/.outlook/`，**不在 skill 目录内**。 ### 1. 创建配置文件 创建 `~/.outlook/config.json`，内容如下： ```json { "email": "[REDACTED_SECRET]", "password": "your_password", "cookie_file": "/root/.outlook/cookies.json", "cookie_max_age_days": 7, "mfa_type": "authenticator_number_match" } ``` ### 2. 安装依赖 ```bash pip install playwright requests playwright install chromium ``` ### 3. 首次登录（MFA） ```bash cd ~/.agents/skills/outlook-calendar python login.py ``` 脚本输出 `[NUMBER:XX]` 时，在 Microsoft Authenticator App 输入数字 XX 并批准，Cookie 自动保存到 `~/.outlook/cookies.json`。 ## 调用步骤 ### 第一步：读取日历 ```bash cd ~/.agents/skills/outlook-calendar python owa_calendar.py --today # 今天 python owa_calendar.py --tomorrow # 明天 python owa_calendar.py --week # 本周 python owa_calendar.py --month 2026-03 # 指定月份 python owa_calendar.py --range 2026-03-01 2026-03-31 # 自定义范围 ``` ### 第二步：处理 AUTH_FAILED 若输出 `[AUTH_FAILED]`，Cookie 或 Token 过期，重新登录： ```bash python login.py ``` ### 第三步：整理输出 按用户问题整理回答：列出事件、统计总时长、按周分组等。 注意：API 返回时间为 UTC，需 +8 转换为上海时间。 ## 文件结构 ``` skill 目录（无敏感信息）： ~/.agents/skills/outlook-calendar/ ├── SKILL.md ├── login.py # MFA 登录，保存 Cookie 到 ~/.outlook/ └── owa_calendar.py # 日历读取（Token 模式） 敏感数据目录： ~/.outlook/ ├── config.json # 账号密码（自行创建） ├── cookies.json # 登录 Cookie（login.py 自动生成） └── token.json # Bearer Token 缓存（自动生成，1h 有效） ``` ## MFA 说明 - 类型：Authenticator 数字匹配 - Cookie 有效期：通常 1-7 天，过期重跑 login.py - 多次失败会触发微软限速，等 15 分钟再试

path: login.py
content: #!/usr/bin/env python3
"""
Outlook 日历登录脚本 - MFA 数字匹配版
用法：python login.py
登录成功后自动保存 Cookie 到 data/cookies.json
"""
import json, time, os, re
from pathlib import Path
from playwright.sync_api import sync_playwright

BASE_DIR = Path(__file__).parent
OUTLOOK_DIR = Path.home() / ".outlook"
with open(OUTLOOK_DIR / "config.json") as f:
    cfg = json.load(f)

EMAIL = cfg["email"]
PASSWORD = cfg["password"]
COOKIE_FILE = OUTLOOK_DIR / "cookies.json"
COOKIE_FILE.parent.mkdir(exist_ok=True)

STATUS_FILE = OUTLOOK_DIR / "login_status.txt"
CMD_FILE = OUTLOOK_DIR / "login_cmd.txt"


def log(msg):
    print(msg, flush=True)
    with open(STATUS_FILE, "a") as f:
        f.write(f"{msg}\n")


def shot(page, name):
    try:
        path = OUTLOOK_DIR / f"debug_{name}.png"
        page.screenshot(path=str(path))
    except:
        pass


def safe_content(page):
    try:
        return page.content()
    except:
        return ""


def find_number(page):
    """从页面提取数字匹配的数字"""
    for sel in ["#idRichContext_DisplaySign", ".displaySign"]:
        try:
            el = page.query_selector(sel)
            if el:
                t = el.inner_text().strip()
                if t.isdigit():
                    return t
        except:
            pass
    content = safe_content(page)
    m = re.search(r'displaySign["\s:]+(\d+)', content)
    if m:
        return m.group(1)
    return None


def get_url(page):
    try:
        return page.url
    except:
        return ""


open(STATUS_FILE, "w").close()

with sync_playwright() as p:
    browser = p.chromium.launch(
        headless=True,
        args=["--no-sandbox", "--disable-dev-shm-usage"]
    )
    ctx = browser.new_context(
        user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
        viewport={"width": 1280, "height": 800},
    )
    page = ctx.new_page()

    log("[1] 登录 Microsoft 365...")
    page.goto("https://login.microsoftonline.com", timeout=30000)
    page.wait_for_load_state("load", timeout=20000)
    page.fill('input[type="email"]', EMAIL)
    page.click('input[type="submit"]')
    page.wait_for_load_state("load", timeout=20000)
    page.wait_for_selector('input[type="password"]', timeout=10000)
    page.fill('input[type="password"]', PASSWORD)
    page.click('input[type="submit"]')
    page.wait_for_load_state("load", timeout=20000)
    log(f"[1] 完成，URL: {get_url(page)}")

    # 等待并提取 MFA 数字
    time.sleep(3)
    log("[2] 等待 MFA 数字匹配页面...")
    for i in range(20):
        time.sleep(2)
        n = find_number(page)
        if n:
            log(f"[2] 屏幕数字: 【{n}】")
            log(f"[NUMBER:{n}]")
            break
        shot(page, f"wait_{i:02d}")

    # 等用户在 Authenticator 批准（最多 3 分钟）
    log("[3] 等待 Authenticator 批准...（最多 180 秒）")
    for i in range(90):
        time.sleep(2)
        url = get_url(page)

        # 成功离开登录页
        if "login.microsoftonline.com" not in url and url.startswith("https://"):
            log(f"[3] 成功！URL: {url}")
            break

        # KMSI 页面
        try:
            btn = page.query_selector("#idSIButton9")
            if btn:
                log("[3] 点击保持登录...")
                page.evaluate("document.querySelector('#idSIButton9').click()")
                time.sleep(3)
                new_url = get_url(page)
                if "login.microsoftonline.com" not in new_url:
                    log(f"[3] 保持登录成功！URL: {new_url}")
                    break
        except:
            pass

        if i % 10 == 0:
            shot(page, f"poll_{i:02d}")
            log(f"[3/{i*2}s] 等待中...")

    # 导航到 Outlook 获取完整 Cookie
    log("[4] 导航到 Outlook 日历...")
    page.goto("https://outlook.office.com/calendar/", timeout=30000)
    page.wait_for_load_state("load", timeout=30000)
    time.sleep(8)
    log(f"[4] URL: {get_url(page)}")

    # 保存 Cookie
    cookies = ctx.cookies()
    with open(COOKIE_FILE, "w") as f:
        json.dump(cookies, f, indent=2)

    outlook_count = len([c for c in cookies if "outlook" in c.get("domain", "")])
    log(f"[✓] Cookie 保存完毕：共 {len(cookies)} 条，outlook 域 {outlook_count} 条")
    log(f"[DONE]")
    browser.close()


path: owa_calendar.py
content: #!/usr/bin/env python3
"""
Outlook 日历读取脚本 - Token 模式
1. 用 Playwright + Cookie 打开 Outlook Web，拦截 Bearer Token
2. 用 requests + Bearer Token 调 OWA REST API
3. Token 缓存到 data/token.json，有效期内复用
"""
import json, time, sys, argparse, requests
from pathlib import Path
from datetime import datetime, timedelta
from playwright.sync_api import sync_playwright

BASE_DIR = Path(__file__).parent
OUTLOOK_DIR = Path.home() / ".outlook"
with open(OUTLOOK_DIR / "config.json") as f:
    cfg = json.load(f)

COOKIE_FILE = OUTLOOK_DIR / "cookies.json"
TOKEN_FILE = OUTLOOK_DIR / "token.json"
TOKEN_TTL = 3600  # Token 复用 1 小时


def get_date_range(args):
    today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
    if args.today:
        return today.strftime("%Y-%m-%d"), (today + timedelta(days=1)).strftime("%Y-%m-%d")
    elif args.tomorrow:
        t = today + timedelta(days=1)
        return t.strftime("%Y-%m-%d"), (t + timedelta(days=1)).strftime("%Y-%m-%d")
    elif args.week:
        monday = today - timedelta(days=today.weekday())
        sunday = monday + timedelta(days=7)
        return monday.strftime("%Y-%m-%d"), sunday.strftime("%Y-%m-%d")
    elif args.month:
        year, month = map(int, args.month.split("-"))
        start = datetime(year, month, 1)
        end = datetime(year + 1, 1, 1) if month == 12 else datetime(year, month + 1, 1)
        return start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d")
    elif args.range:
        return args.range[0], args.range[1]
    else:
        return today.strftime("%Y-%m-%d"), (today + timedelta(days=1)).strftime("%Y-%m-%d")


def load_cached_token():
    if not TOKEN_FILE.exists():
        return None
    with open(TOKEN_FILE) as f:
        data = json.load(f)
    if time.time() - data.get("saved_at", 0) < TOKEN_TTL:
        return data.get("bearer")
    return None


def fetch_token_via_playwright():
    if not COOKIE_FILE.exists():
        print("[AUTH_FAILED] Cookie 文件不存在，请先运行 login.py")
        sys.exit(1)

    with open(COOKIE_FILE) as f:
        saved_cookies = json.load(f)

    owa_token = None

    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True, args=["--no-sandbox", "--disable-dev-shm-usage"])
        ctx = browser.new_context(
            user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36"
        )
        ctx.add_cookies(saved_cookies)

        def handle_request(req):
            nonlocal owa_token
            if owa_token:
                return
            auth = req.headers.get("authorization", "")
            if auth.startswith("Bearer ") and "outlook.office.com" in req.url:
                owa_token = auth[7:]

        ctx.on("request", handle_request)
        page = ctx.new_page()
        page.goto("https://outlook.office.com/calendar/view/month", timeout=30000)
        page.wait_for_load_state("load", timeout=20000)
        time.sleep(10)

        if "login.microsoftonline.com" in page.url:
            print("[AUTH_FAILED] Cookie 已过期，请重新运行 login.py")
            browser.close()
            sys.exit(1)

        browser.close()

    if not owa_token:
        print("[AUTH_FAILED] 未能获取 Bearer Token，请重新运行 login.py", file=sys.stderr)
        sys.exit(1)

    TOKEN_FILE.parent.mkdir(exist_ok=True)
    with open(TOKEN_FILE, "w") as f:
        json.dump({"bearer": owa_token, "saved_at": time.time()}, f)

    return owa_token


def get_token():
    [REDACTED_SECRET])
    if [REDACTED_SECRET] token
    return fetch_token_via_playwright()


def fetch_calendar_api(start_date, end_date, token):
    headers = {
        "Authorization": f"Bearer {token}",
        "Accept": "application/json",
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
    }
    params = {
        "startDateTime": f"{start_date}T00:00:00",
        "endDateTime": f"{end_date}T23:59:59",
        "$select": "Subject,Start,End,Organizer,ShowAs,IsAllDay",
        "$orderby": "Start/DateTime",
        "$top": 100,
    }
    resp = requests.get(
        "https://outlook.office.com/api/v2.0/me/CalendarView",
        headers=headers,
        params=params,
        timeout=15,
    )
    if resp.status_code == 401:
        return None, "token_expired"
    if not resp.ok:
        return None, f"api_error:{resp.status_code}:{resp.text[:300]}"
    return resp.json().get("value", []), None


def parse_events(raw_events):
    events = []
    for e in raw_events:
        start_str = e.get("Start", {}).get("DateTime", "")[:16].replace("T", " ")
        end_str = e.get("End", {}).get("DateTime", "")[:16].replace("T", " ")
        try:
            start_dt = datetime.strptime(start_str, "%Y-%m-%d %H:%M")
            end_dt = datetime.strptime(end_str, "%Y-%m-%d %H:%M")
            duration = round((end_dt - start_dt).total_seconds() / 3600, 2)
        except:
            duration = 0
        organizer = e.get("Organizer", {}).get("EmailAddress", {}).get("Name", "")
        show_as = e.get("ShowAs", "Unknown")
        status_map = {"Busy": "Busy", "Tentative": "Tentative", "Free": "Free", "Oof": "Out of Office"}
        events.append({
            "subject": e.get("Subject", "无标题"),
            "start": start_str,
            "end": end_str,
            "duration_hours": duration,
            "status": status_map.get(show_as, show_as),
            "organizer": organizer,
            "is_all_day": e.get("IsAllDay", False),
        })
    return events


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--today", action="store_true")
    parser.add_argument("--tomorrow", action="store_true")
    parser.add_argument("--week", action="store_true")
    parser.add_argument("--month", type=str)
    parser.add_argument("--range", nargs=2, metavar=("START", "END"))
    parser.add_argument("--json", action="store_true")
    args = parser.parse_args()

    start_date, end_date = get_date_range(args)

    [REDACTED_SECRET])
    raw_events, err = fetch_calendar_api(start_date, end_date, token)

    if err == "token_expired":
        if TOKEN_FILE.exists():
            TOKEN_FILE.unlink()
        [REDACTED_SECRET])
        raw_events, err = fetch_calendar_api(start_date, end_date, token)

    if err:
        print(f"[ERROR] {err}", file=sys.stderr)
        sys.exit(1)

    events = parse_events(raw_events)

    if args.json:
        print(json.dumps(events, ensure_ascii=False, indent=2))
    else:
        print(f"\n📅 {start_date} ~ {end_date} 日历事件（共 {len(events)} 个）\n")
        total_hours = sum(e["duration_hours"] for e in events if e["status"] in ["Busy", "Tentative"])
        for e in events:
            icon = {"Busy": "🔵", "Tentative": "🟡", "Free": "⚪", "Out of Office": "🔴"}.get(e["status"], "❓")
            tag = "（全天）" if e["is_all_day"] else f"({e['duration_hours']}h)"
            print(f"{icon} {e['start']} {tag} | {e['subject']}")
            if e["organizer"]:
                print(f"   组织者: {e['organizer']}")
        print(f"\n⏱ 会议总时长（Busy+Tentative）: {total_hours:.1f} 小时")


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)