Birthday

ClawSkills 作者 ifeychan702 v1.0.1

管理組織成員(家人、同事、親戚等)的個人資料、農曆/新曆生日及紀念日,並用 OpenClaw cron 設置自動提醒。Use when: (1) 添加/編輯成員資料, (2) 設置生日或紀念日提醒, (3) 更新農曆對應的新曆日期, (4) 查看即將到來的重要日子, (5) 管理多個組織的成員。NOT for: 一般日程安排(用 calendar skill)。

源码 ↗

安装 / 下载方式

TotalClaw CLI推荐
totalclaw install clawskills:ifeychan702~mybirthday
cURL直接下载,无需登录
curl -fsSL https://skills.taituai.com/api/skills/clawskills%3Aifeychan702~mybirthday/file -o mybirthday.md
Git 仓库获取源码
git clone https://github.com/openclaw/skills/commit/12ec4b2d852512ec38864dc893c13bd987a914c7
# member-manager — 成員資料與生日紀念日管理

管理多個組織(家庭、同事、親戚等)中成員的個人資料、農曆/新曆生日及各類紀念日,並透過 OpenClaw scheduled-tasks 自動發送提醒。

---

## 核心原則

1. **先讀後寫**:任何修改操作前,先讀取現有 JSON 檔案內容
2. **原子寫入**:每次寫入完整 JSON,不做部分更新
3. **用戶確認**:添加/刪除成員前向用戶確認資訊是否正確
4. **農曆自動轉換**:用戶提供農曆日期時,自動計算當年新曆日期
5. **提醒同步**:刪除成員時同步清理提醒;修改生日時同步更新提醒

---

## 多用戶數據隔離

此 skill 支持多用戶共享同一個 OpenClaw(例如 Telegram bot 場景)。每個用戶的數據存放在以**用戶 session key 命名的獨立目錄**,互不干擾。

### 數據路徑規則

```
~/.openclaw/workspace/users/{SESSION_KEY}/members.json
~/.openclaw/workspace/users/{SESSION_KEY}/reminders.json
```

`{SESSION_KEY}` 是 OpenClaw 的當前 session 標識符(Telegram DM 場景即為 Telegram user ID)。

**在使用任何成員數據前,必須先確定當前用戶的路徑。**

### 如何確定當前用戶的數據目錄

讀取環境變數或 session context 取得用戶 ID,**必須先清洗以防止目錄遍歷攻擊**,然後初始化數據目錄:

```python
import os, json, re

def sanitize_session_key(raw_key: str) -> str:
    """清洗 session key,只允許字母數字、連字號和底線,防止目錄遍歷"""
    sanitized = re.sub(r'[^a-zA-Z0-9_-]', '', raw_key)
    if not sanitized:
        raise ValueError(f"Invalid session key: {raw_key!r}")
    return sanitized

# 方法 1:從環境變數取得
raw_id = os.environ.get("OPENCLAW_SESSION_KEY", "default")

# 方法 2:如果是在 Telegram bot 場景,嘗試讀取 session 檔案
session_file = os.path.expanduser("~/.openclaw/session.json")
if os.path.exists(session_file):
    with open(session_file) as f:
        session = json.load(f)
    raw_id = session.get("user_id", session.get("session_key", "default"))

user_id = sanitize_session_key(str(raw_id))

WORKSPACE_ROOT = os.path.expanduser("~/.openclaw/workspace/users")
base_dir = os.path.join(WORKSPACE_ROOT, user_id)

# 二次驗證:確保解析後的路徑仍在 workspace 目錄內
real_base = os.path.realpath(base_dir)
real_root = os.path.realpath(WORKSPACE_ROOT)
if not real_base.startswith(real_root + os.sep):
    raise ValueError(f"Path traversal detected: {base_dir}")

members_path = os.path.join(base_dir, "members.json")
reminders_path = os.path.join(base_dir, "reminders.json")

# 初始化新用戶
os.makedirs(base_dir, exist_ok=True)
if not os.path.exists(members_path):
    with open(members_path, "w") as f:
        json.dump([], f)
if not os.path.exists(reminders_path):
    with open(reminders_path, "w") as f:
        json.dump([], f)
```

---

## 數據結構

### members.json

```json
[
  {
    "id": "uuid-v4",
    "name": "張三",
    "group": "家庭",
    "relationship": "父親",
    "phone": "0912345678",
    "birthday_solar": "1955-03-15",
    "birthday_lunar": {
      "month": 2,
      "day": 13,
      "is_leap_month": false
    },
    "birthday_lunar_solar_this_year": "2025-03-12",
    "anniversaries": [
      {
        "id": "uuid-v4",
        "label": "結婚紀念日",
        "date_solar": "1980-06-20",
        "recurring": true
      }
    ],
    "notes": "喜歡喝普洱茶",
    "created_at": "2025-01-01T00:00:00Z",
    "updated_at": "2025-01-01T00:00:00Z"
  }
]
```

欄位說明:
- `id`:UUID v4,成員唯一標識
- `name`:姓名(必填)
- `group`:組別/組織名稱,如「家庭」「公司」「大學同學」(必填)
- `relationship`:與用戶的關係,如「父親」「主管」「同學」
- `phone`:電話號碼
- `birthday_solar`:新曆生日,格式 `YYYY-MM-DD`
- `birthday_lunar`:農曆生日,含 month/day/is_leap_month
- `birthday_lunar_solar_this_year`:農曆生日在**當年**對應的新曆日期(每年需更新)
- `anniversaries`:紀念日列表,每個紀念日有獨立 id
- `notes`:備註
- `created_at` / `updated_at`:時間戳

### reminders.json

```json
[
  {
    "id": "uuid-v4",
    "member_id": "對應 member 的 id",
    "type": "birthday_solar",
    "label": "張三 新曆生日",
    "advance_days": 3,
    "scheduled_task_id": "member-birthday-張三-solar",
    "enabled": true
  }
]
```

欄位說明:
- `type`:`birthday_solar` | `birthday_lunar` | `anniversary`
- `advance_days`:提前幾天提醒(默認 3 天)
- `scheduled_task_id`:對應 OpenClaw scheduled-task 的 ID
- `enabled`:是否啟用

---

## 農曆處理

農曆轉新曆每年都會變,因此需要**每年更新**農曆生日對應的新曆日期。

### 農曆轉新曆

```python
# pip install lunardate
from lunardate import LunarDate
from datetime import date, timedelta

def lunar_to_solar(year: int, lunar_month: int, lunar_day: int, is_leap: bool = False) -> date:
    """將農曆日期轉換為新曆日期"""
    try:
        lunar = LunarDate(year, lunar_month, lunar_day, is_leap)
        return lunar.toSolarDate()
    except ValueError:
        # 若該年無此農曆日期(例如閏月不存在),嘗試非閏月
        if is_leap:
            lunar = LunarDate(year, lunar_month, lunar_day, False)
            return lunar.toSolarDate()
        raise
```

### 計算提醒日期(正確處理跨月)

```python
def calculate_reminder_date(target_date: date, advance_days: int) -> date:
    """計算提前提醒的日期,正確處理跨月情況"""
    return target_date - timedelta(days=advance_days)
```

### 年度更新所有農曆生日

```python
def update_all_lunar_birthdays(base_dir: str, year: int):
    """更新所有成員農曆生日在指定年份的新曆日期"""
    members_path = os.path.join(base_dir, "members.json")
    with open(members_path) as f:
        members = json.load(f)

    updated = []
    for m in members:
        lunar = m.get("birthday_lunar")
        if lunar:
            try:
                solar = lunar_to_solar(
                    year, lunar["month"], lunar["day"],
                    lunar.get("is_leap_month", False)
                )
                m["birthday_lunar_solar_this_year"] = str(solar)
                updated.append(m["name"])
            except ValueError as e:
                # 記錄錯誤但繼續處理其他成員
                pass
        m["updated_at"] = datetime.utcnow().isoformat() + "Z"

    with open(members_path, "w") as f:
        json.dump(members, f, ensure_ascii=False, indent=2)

    return updated
```

---

## 功能操作指南

### 1. 添加成員

當用戶要求添加成員時:
1. 解析用戶提供的信息(姓名、組別、關係、生日等)
2. 如果用戶提供農曆生日,自動轉換為當年新曆日期
3. 向用戶確認信息後寫入

```python
import json, os, uuid
from datetime import datetime

def add_member(base_dir, name, group, relationship="", birthday_solar=None,
               birthday_lunar=None, phone="", notes=""):
    path = os.path.join(base_dir, "members.json")
    with open(path) as f:
        members = json.load(f)

    # 檢查是否已有同名同組成員
    for existing in members:
        if existing["name"] == name and existing["group"] == group:
            return None, f"已存在同名成員:{name}({group})"

    now = datetime.utcnow().isoformat() + "Z"
    member = {
        "id": str(uuid.uuid4()),
        "name": name,
        "group": group,
        "relationship": relationship,
        "phone": phone,
        "birthday_solar": birthday_solar,
        "birthday_lunar": birthday_lunar,
        "birthday_lunar_solar_this_year": None,
        "anniversaries": [],
        "notes": notes,
        "created_at": now,
        "updated_at": now
    }

    # 如果有農曆生日,計算當年新曆日期
    if birthday_lunar:
        from lunardate import LunarDate
        from datetime import date
        try:
            solar = lunar_to_solar(
                date.today().year,
                birthday_lunar["month"],
                birthday_lunar["day"],
                birthday_lunar.get("is_leap_month", False)
            )
            member["birthday_lunar_solar_this_year"] = str(solar)
        except ValueError:
            pass

    members.append(member)
    with open(path, "w") as f:
        json.dump(members, f, ensure_ascii=False, indent=2)

    return member, None
```

### 2. 編輯成員

```python
def update_member(base_dir, member_id, **updates):
    """更新成員資料。支持更新任意欄位。"""
    path = os.path.join(base_dir, "members.json")
    with open(path) as f:
        members = json.load(f)

    for m in members:
        if m["id"] == member_id:
            for key, value in updates.items():
                if key in m and key not in ("id", "created_at"):
                    m[key] = value
            m["updated_at"] = datetime.utcnow().isoformat() + "Z"

            # 如果更新了農曆生日,重新計算新曆日期
            if "birthday_lunar" in updates and updates["birthday_lunar"]:
                from datetime import date
                try:
                    lunar = updates["birthday_lunar"]
                    solar = lunar_to_solar(
                        date.today().year,
                        lunar["month"], lunar["day"],
                        lunar.get("is_leap_month", False)
                    )
                    m["birthday_lunar_solar_this_year"] = str(solar)
                except ValueError:
                    pass

            with open(path, "w") as f:
                json.dump(memb