---
name: feishu-digital-twin
description: "从飞书聊天记录构建个人数字分身 skill — 拉取你发出的消息，分析沟通风格、领域知识和判断模式，生成一份可供他人调用的纯静态个人 skill。触发：'把我做成skill'、'build skill of myself'、'制作我的数字分身'、'生成个人skill'。"
argument-hint: "[time-range: '3m' / '6m' / '1y' / '2025-06 to 2026-03']"
license: MIT
metadata:
  author: wangyufeng
  version: 1.1.0
  requires:
    bins: ["lark-cli", "python3"]
---

# 你自己.skill（飞书版）

从你的飞书聊天记录生成数字分身 skill — 别人可以通过这个 skill 来"问你"。

> **建议使用最强的长上下文模型运行此 skill。** 流程涉及分析数千条消息并提炼人物画像，模型的理解深度直接决定生成 skill 的质量。短上下文或轻量模型可能无法充分消化分析报告。

---

## 数据透明度

### 本 skill（构建过程）

**读取的数据：**

| 数据 | 来源 | 说明 |
|---|---|---|
| 用户身份 | `lark-cli contact +get-user` | 中文名、英文名、open_id |
| 发出的消息 | `lark-cli im +messages-search --sender` | 指定时间范围内，你自己发出的 text 和 post 类型消息的内容、时间、所属群聊名称 |

**不读取：** 他人发给你的消息、群聊中他人的消息、飞书文档 / 日历 / 任务 / 邮箱等任何其他数据。
**不发送：** 整个过程不向飞书发送任何消息，纯只读。

**输出的文件：**

| 文件 | 位置 | 说明 |
|---|---|---|
| 临时数据 | `/tmp/skill-build-{name}/` | 原始消息 JSONL + 分析报告；流程结束后可手动删除 |
| 生成的 skill | 当前工作目录下 `{name}/SKILL.md` | **纯静态**数字分身，不依赖任何外部 API |
| 刷新脚本 | 当前工作目录下 `{name}/scripts/` | 用于未来重新拉取消息、重新生成 |

### 生成的 skill（被他人使用时）

**读取：无。** 完全自包含，不调用 lark-cli 或任何外部服务，不需要任何认证。
**输出：** 纯文本回答，基于 SKILL.md 中的静态人物画像生成。

**SKILL.md 内容包含：** 聚合统计（消息量、长度分布）、角色与领域推断、沟通风格描述、脱敏后的代表性语录。
**不包含：** 原始消息、私聊对象信息、链接/URL、任何可直接关联到具体对话的内容。

---

## 环境检测

- lark-cli: !`which lark-cli 2>/dev/null && lark-cli --version 2>&1 | head -1 || echo "NOT_INSTALLED"`
- 认证: !`lark-cli auth status --as user 2>&1 | grep -oE '(logged_in|expired|user_id[^,]*)' | head -2 || echo "NOT_AUTHENTICATED"`
- im skill: !`lark-cli im +messages-search --help 2>&1 | head -1 || echo "MISSING"`

---

## 步骤

### 1. 环境准备

根据上方检测结果判断：

**lark-cli 未安装** — 官方仓库：https://github.com/larksuite/cli 。告知用户执行（npm 方式，需 Node.js 18+）：
```bash
npm install -g @larksuite/cli && npx skills add larksuite/cli -y -g
```
用户确认后，用 Bash 验证 `lark-cli --version`。

**未认证 / token 过期** — 引导用户：
```bash
lark-cli config init             # 首次：配置 App ID + Secret（交互式）
lark-cli auth login --recommend  # OAuth 登录，自动选常用权限
```
验证：`lark-cli auth status --as user`。

**im skill 缺失** — `npx skills add larksuite/cli -y -g`。

全部就绪后继续。

### 2. 获取用户身份

运行：
```bash
lark-cli contact +get-user --as user
```

从返回中提取并记录：
- `USER_NAME_CN` = `data.user.name`（中文名）
- `USER_NAME_EN` = `data.user.en_name`（英文名）
- `SENDER_ID` = `data.user.open_id`
- `SKILL_NAME` = 英文名的 kebab-case（如 `yufeng-wang`）

向用户确认："你是 XXX 对吗？skill 名称将使用 `xxx`。"

### 3. 确定时间范围

解析 `$ARGUMENTS`（如有）：
- `3m` → 近 3 个月
- `6m` 或无参数 → 近 6 个月（默认）
- `1y` → 近 1 年
- `2025-06 to 2026-03` → 精确范围

向用户确认范围。计算 ISO 8601 格式的 `START` 和 `END`（`+08:00` 时区）。

### 4. 预估并确认

先用一次小查询估算消息量（`--page-size 1`），告知用户：

- 时间范围：START ~ END
- 预估条数：通过对每月采样（`--page-size 1`）判断各月是否有数据，粗估总量级
- 将拉取的数据类型：仅你发出的 text/post 消息

**必须等用户明确确认后才开始拉取。** 如果用户希望调整范围，回到 Step 3。

### 5. 拉取消息

设置工作目录并用 Write 工具将下方脚本写入 `${WORK_DIR}/fetch.sh`，然后执行：

```bash
WORK_DIR="/tmp/skill-build-${SKILL_NAME}"
mkdir -p "$WORK_DIR"
# 写入后执行：bash "${WORK_DIR}/fetch.sh" "${SENDER_ID}" "${START}" "${END}" "${WORK_DIR}/messages_raw.jsonl"
```

#### fetch.sh

```bash
#!/bin/bash
set -euo pipefail
SENDER="${1:?}" ; START="${2:?}" ; END="${3:?}" ; OUT="${4:?}"
mkdir -p "$(dirname "$OUT")" ; > "$OUT"
pt="" ; total=0 ; pg=0
echo "Fetching messages sent by $SENDER ($START ~ $END) ..."
while true; do
  pg=$((pg+1))
  args=(--sender "$SENDER" --start "$START" --end "$END" --page-size 50 --as user --format json)
  [ -n "$pt" ] && args+=(--page-token "$pt")
  res=$(lark-cli im +messages-search "${args[@]}" 2>/dev/null)
  cnt=$(echo "$res" | python3 -c "
import sys,json
d=json.load(sys.stdin)
for m in d.get('data',{}).get('messages',[]):
    print(json.dumps(m,ensure_ascii=False))
print(len(d.get('data',{}).get('messages',[])),file=sys.stderr)
" 2>&1 1>>"$OUT")
  total=$((total+cnt)); echo "  page $pg: +$cnt (total: $total)"
  hm=$(echo "$res" | python3 -c "import sys,json;print(json.load(sys.stdin)['data'].get('has_more',False))")
  [ "$hm" != "True" ] && break
  pt=$(echo "$res" | python3 -c "import sys,json;print(json.load(sys.stdin)['data'].get('page_token',''))")
  [ -z "$pt" ] && break
  sleep 0.3
done
echo "Done. $total messages -> $OUT"
```

向用户报告总数。若 < 100 条，建议扩大时间范围。

### 6. 量化分析

用 Write 工具将下方脚本写入 `${WORK_DIR}/analyze.py`，然后执行：

```bash
python3 "${WORK_DIR}/analyze.py" "${WORK_DIR}/messages_raw.jsonl" "${WORK_DIR}"
```

执行后用 Read 工具读取 `${WORK_DIR}/analysis_report.txt`。

#### analyze.py

```python
#!/usr/bin/env python3
import json,sys,os
from collections import Counter,defaultdict

def main():
    inp=sys.argv[1]; outd=sys.argv[2] if len(sys.argv)>2 else os.path.dirname(inp) or "."
    msgs=[]
    with open(inp) as f:
        for l in f:
            l=l.strip()
            if l: msgs.append(json.loads(l))
    if not msgs: print("ERROR: No messages"); sys.exit(1)
    total=len(msgs)
    ct=Counter(m.get("chat_type") for m in msgs)
    mt=Counter(m.get("msg_type") for m in msgs)
    mo=Counter(m.get("create_time","")[:7] for m in msgs)
    txt=[m for m in msgs if m.get("msg_type") in("text","post") and m.get("content")]
    lens=[len(m["content"]) for m in txt] if txt else [0]
    avg=sum(lens)/len(lens); lng=[m for m in txt if len(m["content"])>50]
    stk=sum(1 for m in msgs if m.get("msg_type")=="sticker")
    lnk=[m for m in txt if "http" in m.get("content","")]
    grp=defaultdict(list); p2p=[]
    for m in txt:
        if m.get("chat_type")=="group": grp[m.get("chat_name","?")].append(m)
        else: p2p.append(m)
    top=sorted(grp.items(),key=lambda x:-len(x[1]))[:20]
    # opinion extraction
    markers=["我觉得","我认为","感觉","应该","其实","本质上","核心","关键是",
             "问题是","不过","但是","我的理解","倾向于","I think","imo",
             "没必要","不建议","大概率没","没啥用","不靠谱","不太行","算了","坦白说"]
    ops=[{"time":m.get("create_time",""),"chat":m.get("chat_name","P2P"),
          "text":m["content"][:400]} for m in txt
         if len(m.get("content",""))>30 and any(k in m["content"] for k in markers)]
    # text report
    rp=os.path.join(outd,"analysis_report.txt")
    ear=min((m.get("create_time","") for m in msgs),default="?")
    lat=max((m.get("create_time","") for m in msgs),default="?")
    with open(rp,"w") as f:
        w=f.write
        w(f"{'='*60}\n  MESSAGE ANALYSIS REPORT  ({total} messages)\n{'='*60}\n\n")
        w(f"Time range : {ear} ~ {lat}\nChat type  : {dict(ct)}\nMsg type   : {dict(mt)}\n")
        w(f"Monthly    : {dict(sorted(mo.items()))}\n\n")
        w(f"Text msgs  : {len(txt)}\nAvg length : {avg:.0f} chars\n")
        w(f"Long (>50) : {len(lng)} ({len(lng)/max(len(txt),1)*100:.0f}%)\n")
        w(f"Stickers   : {stk} ({stk/max(total,1)*100:.0f}%)\n")
        w(f"With links : {len(lnk)} ({len(lnk)/max(len(txt),1)*100:.0f}%)\n")
        w(f"P2P msgs   : {len(p2p)} across {len(set(m.get('chat_id') for m in p2p))} partners\n\n")
        w(f"{'-'*60}\n  TOP GROUPS (with representative samples)\n{'-'*60}\n")
        for name,items in top:
            sub=[m for m in items if len(m.get("content",""))>15]
            w(f"\n## {name}  ({len(sub)} substantive / {len(items)} total)\n")
            step=max(1,len(sub)//8)
            for m in sub[::step][:8]:
                w(f"  [{m.get('create_time','')[:10]}] {m['content'][:220].replace(chr(10),' ')}\n")
        w(f"\n{'-'*60}\n  P2P SAMPLES (longest / most substantive)\n{'-'*60}\n")
        for m in sorted(p2p,key=lambda m:-len(m.get("content","")))[:25]:
            w(f"  [{m.get('create_time','')[:10]}] {m['content'][:250].replace(chr(10),' ')}\n")
        w(f"\n{'-'*60}\n  OPINION STATEMENTS ({len(ops)} found)\n{'-'*60}\n")
        seen=set(); c=0
        for o in ops:
            k=o["text"][:60]
            if k in seen: continue
            seen.add(k); c+=1
            w(f"  [{o['time'][:10]}] [{o['chat']}] {o['text'][:250].replace(chr(10),' ')}\n")
            if c>=30: break
    # json report
    jp=os.path.join(outd,"analysis_report.json")
    with open(jp,"w") as f:
        json.dump({"total":total,"time_range":{"earliest":ear,"latest":lat},
            "chat_type":dict(ct),"msg_type":dict(mt),"monthly":dict(sorted(mo.items())),
            "text_stats":{"count":len(txt),"avg_length":round(avg,1),"long_count":len(lng),
                "sticker_ratio":round(stk/max(total,1),3),"link_count":len(lnk)},
            "top_groups":[{"name":n,"total":len(i),"substantive":len([m for m in i if len(m.get("content",""))>15])} for n,i in top],
            "p2p":{"count":len(p2p),"partners":len(set(m.get("chat_id") for m in p2p))},
            "opinion_count":len(ops)},f,ensure_ascii=False,indent=2)
    print(f"Analysis complete: {total} msgs, {len(top)} groups, {len(ops)} opinions")
    print(f"  {rp}\n  {jp}")

if __name__=="__main__": main()
```

### 7. 深度理解（核心步骤）

**这是决定 skill 质量的关键步骤。** 生成的 skill 是纯静态的，不会做任何实时查询，所有知识必须在这一步充分提炼。

仔细阅读分析报告，必要时从 `messages_raw.jsonl` 补充阅读原始消息，提炼以下要素：

1. **角色定位** — 此人的职责、团队、日常工作是什么？从 top groups 名称和消息内容推断
2. **领域知识** — 最常讨论的 3-6 个领域，每个领域此人掌握的关键概念和术语
3. **沟通风格** — 至少 5 个有数据支撑的特征：
   - 消息长度（avg_length 和 long_count 比例）
   - 表情使用（sticker_ratio）
   - 链接分享习惯（link_count）
   - 群聊 vs 私聊的表达差异
   - 语言风格（直接/委婉、正式/口语、是否有幽默感）
4. **典型观点** — 10-15 条反复出现的判断逻辑，每条引用原话并解读背后的思维方式
5. **代表性语录** — 8-12 句最能体现此人特质的原话，涵盖不同领域和场景
6. **常见话题的立场** — 此人在核心话题上的一贯态度（从 opinion statements 和群聊上下文推断）

**注意正面偏差：** 模型天然倾向于生成过度积极的人物画像。必须主动寻找并保留此人表达质疑、拒绝、不满的例子（如"没必要"、"大概率没人用"、"不建议"），这些比正面表达更能体现真实性格。观点列表中，正面和负面/质疑类应大致均衡。

**第三方信息脱敏：** 消息中可能包含他人姓名、内部项目名、内部 URL 等。写入生成的 SKILL.md 时：
- 引用语录中的他人姓名替换为角色描述（如"同事"、"上游团队"）
- 移除所有内部 URL 和 token
- 群聊名称可以保留（仅用于描述此人的活动领域）

### 8. 生成 Skill

在当前工作目录下 `./${SKILL_NAME}/` 中创建以下文件。用户可自行决定将生成的目录复制到任何 skill 加载路径（如 `~/.claude/skills/`、`~/.openclaw/skills/` 或项目的 `.claude/skills/`）。

#### 8a. SKILL.md

用第 7 步分析结果填充以下模板中的所有 `{{PLACEHOLDER}}`。

要求：
- `description` ≤ 250 字符，含姓名、昵称、职能关键词
- 沟通风格每条必须有数据支撑（如"平均消息 38 字"）
- 观点必须引用原话，不能只写抽象总结
- 总行数 ≤ 150 行（因为是纯静态 skill，内容需要比动态版更丰富）

```markdown
---
name: {{SKILL_NAME}}
description: "{{USER_NAME_CN}} ({{USER_NAME_EN}}) 的数字分身 — {{ROLE_ONE_LINER}}。可以问关于 {{TOP_3_DOMAINS}} 的问题。触发：'问一下{{SHORT_NAME}}'、'{{SHORT_NAME}}怎么看'。"
argument-hint: "[question or topic]"
---

# {{USER_NAME_CN}} 的数字分身

你是{{USER_NAME_CN}}的数字分身，基于其在飞书上 {{MSG_COUNT}} 条真实消息记录构建（{{TIME_RANGE}}）。

本 skill 完全自包含，不依赖任何外部 API 或认证。所有知识来自对飞书消息的静态分析。

## 局限性

本数字分身基于有限的消息样本构建，存在固有局限：
- 只能反映消息中明确表达过的观点，不能推断未说过的立场
- 超出已知领域时**必须拒绝回答**，说"这个我没上下文"，不要猜测或编造
- 回答"不知道"永远比给出不准确的回答更符合此人的风格

## 身份

- **姓名**: {{USER_NAME_CN}} ({{USER_NAME_EN}})
- **角色**: {{ROLE_DESCRIPTION}}
- **技术栈/专业**: {{从消息中的技术术语推断}}

## 职责领域

{{3-6 个领域，每个一行附简短说明}}

## 沟通风格

基于 {{MSG_COUNT}} 条历史消息（{{TIME_RANGE}}）的分析：

{{5-8 个风格特征，每个用加粗关键词+解释+数据支撑}}

## 典型观点和判断模式

{{10-15 条，每条格式："引用原话或概括" → 解读判断倾向}}

## 代表性语录

以下是此人在不同场景下的真实表达，用于把握其语气和思维方式：

{{8-12 条，每条格式：}}
{{- [场景/话题] "原话" }}

## 回答问题的步骤

1. 回顾上述身份、领域、观点，判断此人会如何看待这个问题
2. 用此人的沟通风格（语气、长度、用词习惯）生成回答
3. 涉及此人的核心领域时，参考"典型观点"中的已知立场
4. 超出已知领域时说"这个我没上下文，得确认一下"

### 回答格式

- 用中文回答，技术术语保留英文
- 简洁直接，像本人在飞书上回消息一样
- 适当引用"典型观点"和"代表性语录"中的原话
- 保持此人特有的语气（如幽默、务实、直白等）
```

#### 8b. scripts/refresh.sh — 数据刷新脚本

将 Step 5 中 fetch.sh 的内容写入 `./${SKILL_NAME}/scripts/fetch.sh`，并创建 `refresh.sh`（替换为实际值）：

```bash
#!/bin/bash
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILL_DIR="$(dirname "$SCRIPT_DIR")"
SENDER_ID="{{实际open_id}}"
START="{{实际开始时间}}"
END="{{实际结束时间}}"
mkdir -p "$SKILL_DIR/data"
bash "$SCRIPT_DIR/fetch.sh" "$SENDER_ID" "$START" "$END" "$SKILL_DIR/data/messages_raw.jsonl"
echo "数据已刷新。如需更新 skill，请重新运行 /build-skill-of-yourself"
```

### 9. 完成

向用户报告：
- Skill 位置：`./${SKILL_NAME}/`（当前目录下）
- 使用方式：将该目录复制或 symlink 到任意支持 SKILL.md 的工具的 skills 路径下即可
- 特点：纯静态，不需要 lark-cli，任何人拿到 SKILL.md 就能用
- 调用方式：`/${SKILL_NAME} <问题>` 或对话中提到此人名字时自动触发
- 更新方式：运行 `scripts/refresh.sh` 拉取新消息后重新执行 `/build-skill-of-yourself`

询问用户是否要删除 `/tmp/skill-build-${SKILL_NAME}/` 中的临时数据。

---

## 注意事项

- 仅分析 text 和 post 类型消息，不处理图片 / 文件 / 卡片等富媒体消息
- 消息量 500+ 效果最佳，100~500 可用但画像较粗，< 100 建议扩大时间范围
- 生成的 skill 是一次性快照；如需反映最新消息，需重新执行本 skill
