---
name: vrchat-social-queries
description: "VRChat social queries: online, companions, patterns, boops."
version: 1.0.0
metadata:
  hermes:
    tags: [vrchat, gaming, social, friends, queries, boop]
    related_skills: [vrc-monitor-agent]
---

# VRChat 好友/社交域 — 查询工作流与写操作

本 skill 覆盖 **vrc-monitor 的好友/社交域**：在线列表、同房/同屏、上线规律、常玩统计、社交画像、关系分析、昵称管理，以及全部社交写操作（boop / 上传 / 邀请 / 开房 / 好友管理）。

> ⚠️ **工具表唯一权威在 vrc-monitor-agent skill**（全部 MCP 工具及参数）。本 skill 只写工作流与域内细节，不复制工具表。通用 MCP 调用陷阱（`data:` 前缀、MCP 入参格式）见 vrc-monitor-agent「常见陷阱」。
> MCP 端点：`http://127.0.0.1:8799/mcp`；服务未启动处理见 vrc-monitor-agent「服务健康检查」。

## 1. 好友在线列表

用户问"现在有哪些好友在线 / XX 在线吗"时，**直接调 `get_online_friends`**，一条调用拿全要素：昵称（有本地昵称用昵称）｜所在世界｜房间类型｜在线时长｜房间停留时长。

返回的每条好友记录含：

| 字段 | 说明 |
|------|------|
| `nickname` | 本地昵称（null = 无；展示用 nickname \|\| displayName） |
| `locationParsed` | 结构化房型：worldId/instanceId/type（public/hidden/friends/group/private/local）/ownerId/region |
| `worldName` | 世界名（缓存优先，未缓存自动批量 API 查询并写 world_cache；查询失败/private 为 null） |
| `onlineMinutes` / `onlineSince` | **本次在线时长**：会话起点 = 最近 friend-offline 之后最早的一条 friend-online（WS 重连会重复推送 friend-online，取最新会严重低估，MIN(>last_off) 跳过重复推送）；无 offline 记录 = 取最早 friend-online |
| `durationMinutes` / `enteredAt` | **房间停留时长**：进入时间 = max(会话起点, 最新位置事件时间)（防跨会话污染）；null = 未知（traveling/无匹配事件） |

展示紧凑表格（**五列**）：`| 好友 | 世界 | 房型 | 在线 | 停留 |`；房型中文图标 🌐公开/🤫隐藏/👥好友房/🏷️群组房/🔒私密/💻本地；同房好友（完整 location 相同）额外高亮。⚠️ private 房停留时长可算但语义是"位置隐藏前最后一次更新"，参考价值低。

## 2. "XX 现在和谁一起？" / 同实例好友

```
1. get_friend_info(userId=目标) → 取 location 字段（如 "wrld_xxx:77182~hidden(usr_owner)~region(jp)"）
2. get_online_friends() → 所有在线好友的位置
3. 按完整 location 字符串匹配 → 同实例的好友
4. 从 location 解析 owner：hidden(usr_xxx)/private(usr_xxx)/friends(usr_xxx)/group(grp_xxx)
5. get_world_name(worldId) → 世界名（location.split(':')[0]）
```

- 只能看到你也是好友的人（API 限制）
- `~hidden(usr_A)` = A 的隐藏房；`~private(usr_B)` = B 的私密房；`~friends(usr_C)` = C 的好友房
- 直接读 `get_online_friends` 返回的 `locationParsed` 字段即可（type/ownerId/worldId/instanceId/region 已结构化），不需要手写解析

## 3. "今天和谁一起玩了？" / 同屏交叉查询 → `get_companions`

**⚠️ 不要委派子 agent 做同屏查询**——子 agent 只会查少量已知 userId，会漏掉其他人。直接用 MCP 工具：

```bash
curl -s http://127.0.0.1:8799/mcp -X POST \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_companions","arguments":{"startTime":"<UTC起>","endTime":"<UTC止>","userId":"<目标userId，可省略=当前账号>"}}}'
```

- `startTime`/`endTime`：ISO 8601 UTC（北京时间 -8h），窗口 ≤24h
- `userId`：可查自己（默认）或任意好友——传好友 ID = "XX 和谁一起玩过"
- 原理：查目标用户的 location 事件（自己=`user-location`，好友=`friend-location`）→ 提取 worldId:instanceId → 全量比对好友 location 事件 → 排除目标本人 → 按 userId 分组
- **companions 条目字段**：`userId / displayName / firstSeen / lastSeen / matchCount / worlds[]`。⚠️ **`worlds` 是字符串数组**（元素 = `world_name || world_id`），不是对象数组；同屏频率看 `matchCount`（同屏次数），没有 totalDuration 字段
- 解析响应：SSE `data:` 前缀剥掉后 `result.content[0].text` 再 json.loads；MCP 入参直接传参数对象，不要包两层

## 4. "某天活动时间线" / "XX 和 YY 昨晚同房吗"

```
1. get_friend_events(userId=A, types="friend-location", limit=5) → 最近位置变化
   - created_at 是 UTC，+8 转北京时间
   - location 格式 "wrld_xxx:instanceId~hidden(usr_owner)~region(jp)"；traveling 时看 travelingToLocation
2. 比对 worldId + instanceId：相同 = 同房；确认时间重叠
3. get_world_name(worldId) → 世界名
```

**陷阱：** `get_friend_events` 每个事件嵌完整用户 JSON（~50KB），limit 大会爆响应。用 `limit=5` + `offset` 分页；或只读顶层 `created_at`/`world_id`。同一世界不同 instance ID = 不同房。

## 5. "XX 几点上线 / 什么时候最容易碰到 TA" → `get_online_pattern`

一次调用拿全部规律，不要逐条翻事件：

```bash
curl -s http://127.0.0.1:8799/mcp -X POST \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_online_pattern","arguments":{"userId":"<目标userId>"}}}'
```

- `userId`：必填。`days`：可选，默认 30（北京时间自然日，含今天）。`startTime`/`endTime`：可选 UTC 窗口，优先于 days
- 返回：`hourly`（上线/下线/位置活跃按北京小时分桶）、`activeDates`（活跃日期）、`frequency`（windowDays/activeDays/activityRatio/avgGapDays/longestGapDays）、`peak`（loginPeakHour/activePeakHour/offlinePeakHour/suggestedWindow）
- `suggestedWindow` 可直接作"最佳相遇时段"；展示分布用紧凑小时柱状图；频率看 `activityRatio` 和 `last30ActiveDays`
- ⚠️ **查自己时 `online/offline` 恒为 0**（VRChat WS 不推送自己的上下线事件，只有位置变化）——自己的"上线规律"= 活跃分布推断，报告要标注口径

## 6. "经常一起玩的好友现在谁在线" / 常玩好友统计

三件套：在线列表 + 逐日同屏 + 交叉。**在线 ≠ 一起玩过**——在线列表里几天内真正同屏过的往往只有少数，别把整个在线列表当"常玩"。

```
1. get_online_friends → 当前在线好友
2. 逐日调 get_companions（自己 userId；窗口 ≤24h，北京时间自然日 = UTC 16:00 日界，N 天 = N 次调用）
3. 与 get_nicknames 结果交叉 → 带昵称展示
```

- 统计口径：同屏 ≥2 天 = 核心圈；1 天 = 偶尔碰到。展示用紧凑表格 + 一行小结
- 批量查询建议在脚本里用 urllib 循环调 MCP（30 次调用约 6s），比逐次 curl 高效

## 7. 昵称管理

好友昵称映射存本地库（`nicknames` 表），**不维护在 skill 文件里**：

- 查询：`get_nicknames`（无参返回全部；`userId` 精确查；`query` 按昵称或显示名模糊查）
- 写入：`set_nickname {userId, nickname, displayName?}`（upsert 幂等）
- 建议工作流：用户给好友取中文昵称 → `search_users` 找 userId → `set_nickname` 写入 → 后续查询结果用昵称展示
- 用户报音近名（语音识别歪）时，先 `get_nicknames` 模糊查再搜，不要直接搜

## 8. "XX 什么时候去过 Y 世界？" / 访问时间线

直查 SQLite（只读）比 MCP 分页高效（数据库文件在项目目录，WAL 模式可并发读）：

```python
con = sqlite3.connect(r'file:<项目目录>/vrc-monitor.sqlite3?mode=ro', uri=True)
cur.execute("SELECT created_at, content_json FROM events WHERE user_id=? AND type='friend-location' AND world_id=? ORDER BY created_at", (UID, WID))
```

- 先按 `GROUP BY world_id` 数次数 + world_name 确认目标世界
- **访问次数 ≠ 事件条数**：同一实例连续多条事件合并成一次访问——相邻事件间隔 >2h 切分为新访问。⚠️ **不能按 instanceId 合并**：隐藏/群组房 instanceId 是房主 userId，跨天会复用
- 展示：北京时间 +8，紧凑表格 `| # | 进入时间 | 停留 |` + 一行小结

## 9. 好友社交画像报告

用户要"TA 最近和谁玩 / 常去哪些世界 / 上线规律"时，四步拼装：

1. `get_online_pattern(userId)` → 上线规律（活跃率/峰值/建议时段）
2. SQLite 世界统计：`friend-location` 按 world_id GROUP BY 计数 + 时间范围（**private 单独计数**——私密房占比高说明近期活动不可见，报告注明）
3. 逐日 `get_companions(userId=<TA>)`（窗口 ≤24h）→ 同屏天数/总次数/最近日期，昵称从 nicknames 表匹配
4. 活动明细：查某天 friend-location 顺序 → 时间线（案例模式：一晚巡游多个世界、多人同屏）
5. 生成 markdown 报告 → 文件发送（聊天里只给核心摘要 + 紧凑表格）

## 10. "我和 XX 的关系分析"

1. `get_friend_info(userId=XX)` → 好友状态/bio/lastLogin/当前 location（bio 常含羁绊名单，与共同好友交叉 = 介绍人线索）
2. `get_mutual_friends(userId=XX)` → 共同好友（自动带本地昵称），重叠度 = 圈层接近度
3. 逐日 `get_companions(自己 userId)` → 过滤目标 userId，累计同屏天数/matchCount/worlds
4. 综合：同屏频率与近期趋势（升温/降温）、共同好友里的核心圈成员、时段重合度

- **实时情报**：`get_friend_info` 的 location 能看出 TA 此刻在谁房里
- **群组画像辅助**：`get_user_groups` + 批量 `get_group_info` 拿描述 → 按群规模分层（大社区/亲友群/技术核心组）判断融入深度（群组域见 vrchat-group-queries）
- 展示：好友信息卡片 + 共同好友表 + 同屏时间线表 + 关系小结

## 11. 社交写操作（boop / 上传 / 邀请 / 好友 / 开房）

> 写操作参数见 vrc-monitor-agent 工具表。⚠️ **不可逆操作（remove_friend / remove_print / remove_gallery_image）必须 `confirm: true` 才执行**，否则只返回预览。

### 11.1 戳一戳（boop）与 emoji
- `send_boop {userId, emojiId?}`：戳好友。⚠️ **MCP 响应空文本≠失败**（content[0].text 偶发空串）——空响应不代表失败，看 raw 响应确认 `booped:true` 即成功。发出的 boop 不落库，只能以 raw 响应为准
- `get_boop_emojis`：内置 emoji 列表 + emojiId 格式（`default_<name 小写下划线>`）
- `upload_emoji {imagePath}`：自定义 emoji 上传（需 VRC+）；⚠️ **必须正方形**，先裁方再传

### 11.2 照片→自定义 emoji→戳人 流水线
1. 取图（聊天图片缓存）→ 留底
2. 裁方：`prepare_image.py --mode square --mode-detail fit --size 1024`（⚠️ Windows 下脚本路径用引号包裹的绝对路径，防 MSYS 路径转换）
3. `upload_emoji {imagePath: <方形图>}` → `fileId`
4. `send_boop {userId, emojiId: fileId}`

**⚠️ upload_emoji multipart 坑**：① JSON 参数必须拆成独立 multipart 字段（`tag`/`maskTag`/`animationStyle` 各占一个 form-data 字段，打包成单个 JSON → 400 `tag is required`）；② 文件字段名固定 `file`；③ **`animationStyle` 必填**（静态图传 `'stop'`）；④ 成功标准 = 返回 `fileId`，`GET /files?tag=emoji&n=20` 可见

### 11.3 相册/图库
- `upload_print {imagePath, note?}` / `upload_gallery_image {imagePath}`（需 VRC+）；⚠️ gallery 上传必须显式 `contentType: image/png`（默认 blob 无扩展名会报 "must be an image"）
- `get_prints` / `get_gallery_images`：列表即带 downloadUrl
- `remove_print` / `remove_gallery_image`：⚠️ 不可逆，必须 confirm: true
- `download_print` / `download_gallery_image`：下载到本地（可 MEDIA 发送）；⚠️ **VRChat 文件 URL 会 302 到 CDN 签名 URL——必须跟随重定向**
- **上传前图片处理**：Prints/Gallery 用 `prepare_image.py --mode landscape --ratio 16:9|4:3`——**竖图自动旋转 90°** 变横图（内容跟着转）；`--strategy auto` 比较裁剪损失 vs 填充白边，损失小的优先

### 11.4 邀请与开房
- `send_invite {userId, worldId, instanceId, message?}`：拉人进房；`request_invite {userId, message?}`：请求被邀请
- `create_instance {worldId, type?, region?}`：创建实例（默认 hidden/jp）；⚠️ **非 public 必须显式带 ownerId=当前用户**（不带 400 "Invalid owner ID"）；返回 location 可直接给 invite_myself
- `invite_myself` / `open_world`：同一引擎（core/vrchat-launch.js openInstance）——**管道直发优先**（Windows 游戏内静默弹加入菜单），探测失败**静默回退** API 邀请（`method: "api"` = 回退态，非失败）。⚠️ open_world 的 id 必须是完整 location（含实例号），只传 worldId 游戏内无反应
- 开图后验证：**直接问用户，别截图**（给「进了世界/弹错误/没反应」选项）
- 链路：`scan_new_worlds`（候选）→ `create_instance`（建 jp 房）→ `invite_myself`（传送）；API 创建实例不改变 presence，传送必须由客户端执行

### 11.5 好友管理
- `send_friend_request {userId|displayName}`：加好友（精确匹配不区分大小写）
- `remove_friend {userId|displayName}`：⚠️ 不可逆，必须 confirm: true，否则只返回预览。测试只走零副作用路径
- `get_mutual_friends {userId}`：共同好友（自动带本地昵称）

### 11.6 隐私位置场景（boop 目标定位）
- **好友位置显示 `private` ≠ 不在线/不在你房间**：VRChat 隐私设置可让位置对好友隐藏，且该好友可能不出现在 `get_online_friends` 列表。定位流程：`search_users` 按 displayName 子串搜 → 确认 `isFriend: true` → `get_friend_info` 确认 `state: online` → 直接 boop
- 同房判断别只靠 location 字段：隐私设置下只能用用户口述 + boop 回执验证
- **对方没收到戳戳 → 重发一次**（boop 通知 24h 内有效，可能被顶掉/漏显示，重发是标准补救）
- 中文名搜索不可靠时，若目标在线可先扫 `get_online_friends` 的 displayName 找音近名

## 12. 域内陷阱与已知缺陷

### boop 通知在 notification-v2 里
boop 通知落库的顶层事件类型是 `notification-v2`（不是 `boop`），boop 在 `content_json.type` 里。`get_recent_events(typeFilter="boop")` 查不到 → 用 `typeFilter="notification-v2"`。

### get_companions 查好友返回空（历史缺陷，已修复）
早期版本只查 `type='user-location'` 事件（那是登录账号自己才有的类型），好友的位置事件是 `friend-location`，导致传好友 userId 查询永远空。已修复：目标用户事件类型扩展为 `IN ('user-location','friend-location')` + 交叉匹配排除目标本人。**再遇"查好友同屏为空"**：确认 storage.js 的 findCompanions 带 `type IN` 条件；改代码后必须重启服务才生效（进程常驻，不热加载）。

### 用户自己的位置事件没有独立 worldId 字段（已修复）
自己的位置事件 content 只有 `location` 字符串，没有 `worldId` 字段（好友的 friend-location 才有）。已加 `_handleUserLocation` 从 location 字符串解析落库。查自己世界访问历史：`SELECT world_id, COUNT(*), MAX(created_at) FROM events WHERE type='user-location' AND world_id!='' GROUP BY world_id`。
