sophhub 0.4.63 → 0.4.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,79 @@
1
+ # 创建频道(Agent 扩展能力)
2
+
3
+ **调用方是机器人:** `POST /api/v1/claw/agent.channels.create`
4
+ 鉴权:MCP JWT。Scope:`channels:write`(`scopes.request` → myAgent 卡片)。
5
+ 服务端按创建者权限落库,但对 Agent 而言仍是机器人调接口,无用户 authToken。
6
+
7
+ ---
8
+
9
+ ## 主入口
10
+
11
+ ```
12
+ POST /api/v1/claw/agent.channels.create
13
+ ```
14
+
15
+ | Body | 必填 | 说明 |
16
+ |------|------|------|
17
+ | `userId` / `agentId` | 是 | MCP 上下文 |
18
+ | `members` | 建议 | username 字符串数组;按需加人,**不必**含 Claw Agent 创建者 |
19
+ | `name` | 否 | 可空;空时服务端可按成员生成 |
20
+ | `depIds` | 否 | 部门 id,整部门加入 |
21
+ | `extraData` | 否 | 见下 |
22
+ | `readOnly` | 否 | 默认 false |
23
+
24
+ **`extraData` 常见字段:**
25
+
26
+ ```json
27
+ {
28
+ "all": false,
29
+ "employeeType": [],
30
+ "topic": "",
31
+ "description": "",
32
+ "rt": "p",
33
+ "broadcast": false,
34
+ "appiaUsage": []
35
+ }
36
+ ```
37
+
38
+ 会议专用频道可用 `"appiaUsage": ["Room_Sort_Meeting"]`。
39
+
40
+ ### 示例
41
+
42
+ ```bash
43
+ curl -X POST "$SITE_URL/api/v1/claw/agent.channels.create" \
44
+ -H "Authorization: Bearer $MCP_JWT" -H "Content-Type: application/json" \
45
+ -d '{
46
+ "userId": "'"$USER_ID"'",
47
+ "agentId": "'"$AGENT_ID"'",
48
+ "name": "产品周会群",
49
+ "members": ["zhangsan", "lisi"],
50
+ "extraData": { "topic": "产品" }
51
+ }'
52
+ ```
53
+
54
+ 成功返回 `data.rid` / `data.channel`。可用作后续 Bot 入群发消息的 rid。
55
+
56
+ ---
57
+
58
+ ## 推荐链路
59
+
60
+ ```
61
+ 用户:建一个有张三李四的群
62
+ → users.search 解析 username
63
+ → members = [zhangsan, lisi](按需;不要默认加 Claw 创建者)
64
+ → 若提问者本人也要进群 → 再 search 其 username 加入
65
+ → 确认 name
66
+ → 确认 Agent 已有 channels:write(否则 scopes.request)
67
+ → POST claw/agent.channels.create
68
+ → 返回 rid
69
+ ```
70
+
71
+ > 预定会议(`agent.meeting.book`)暂未纳入本 skill;需要时引导用户在 Appia 网页操作。
72
+
73
+ **注意:**
74
+
75
+ - **不要默认把 Claw Agent 创建者加进 `members`**;只加用户明确要的人。
76
+ - 提问者要进群时单独加入其 username。
77
+ - 成员用 **username**,不是显示名;先用 `agent.users.search`。
78
+ - 缺 `channels:write` → `agent.scopes.request`,请创建者在 myAgent 点授权。
79
+ - 私有群 `groups.create` / 讨论组等尚未挂接;当前 Agent 只支持上述 `channels.create`。
@@ -0,0 +1,61 @@
1
+ # Session 启动与轮询(MCP)
2
+
3
+ 业务见 [../SKILL.md](../SKILL.md)。鉴权见 [auth.md](auth.md)。
4
+
5
+ ---
6
+
7
+ ## 启动顺序
8
+
9
+ ```
10
+ 1. Read USER.md → SITE_URL、userId、agentId、MCP_JWT
11
+ 2. 缺配置 → 请用户在 Appia 创建 Claw Agent(不要向用户要登录态 / 短信)
12
+ 3. GET agent.groups.get → 已入群频道
13
+ 4. 可选:GET agent.todos.list / agent.scopes.get 探测扩展能力
14
+ 5. 写接口若 SCOPE_DENIED → scopes.request,请用户去 myAgent 点「授权」给本机器人,再重试
15
+ ```
16
+
17
+ ---
18
+
19
+ ## USER.md
20
+
21
+ ```markdown
22
+ ## Appia Claw
23
+
24
+ | 项目 | 值 |
25
+ |------|-----|
26
+ | SITE_URL | `https://ssc.appia.cn` |
27
+ | userId | `<创建者 RC ID>` |
28
+ | agentId | `<OpenClaw agentId>` |
29
+ | MCP JWT | `<Bearer token>` |
30
+
31
+ ### 企业识别码 → 地址(选型时用,大小写不敏感)
32
+
33
+ | 识别码 | SITE_URL |
34
+ |--------|----------|
35
+ | SSC | https://ssc.appia.cn |
36
+ | BITMAIN | https://appia.cn |
37
+ | SOPHGO | https://sophgo.appia.cn |
38
+ ```
39
+
40
+ ---
41
+
42
+ ## sender_id → rid
43
+
44
+ | 格式 | rid |
45
+ |------|-----|
46
+ | `SSC-{rid}-{YYYYMMDD}` | 中间段 |
47
+ | `openclaw-control-ui` | 无 rid,问用户 |
48
+
49
+ 同条 stream 可能带 **`requestUserId`**(提问者)。总览行接口未落地;有该字段时也勿冒充去调不存在的 `overview.read`。
50
+
51
+ ---
52
+
53
+ ## 轮询(cron)
54
+
55
+ `groups.get` → `messages.get` → 识别 @我 → 入群回复 / 扩展能力 API → `message.send`。
56
+
57
+ ---
58
+
59
+ ## 相关
60
+
61
+ - [auth.md](auth.md) · [creator-delegate-api.md](creator-delegate-api.md) · [claw-api.md](claw-api.md)
@@ -0,0 +1,38 @@
1
+ # 写操作
2
+
3
+ 调用方是机器人,MCP JWT。
4
+ **缺 write scope:** `scopes.request` → 用户在 myAgent 点「授权」→ **同一机器人**重试。不要要 authToken。
5
+ 鉴权见 [auth.md](auth.md)。
6
+
7
+ 写前向用户确认。只读见 [query-api.md](query-api.md)。
8
+
9
+ ---
10
+
11
+ ## 1. 更新待办状态
12
+
13
+ ```
14
+ POST /api/v1/claw/agent.todos.update-status
15
+ ```
16
+
17
+ | Body | 说明 |
18
+ |------|------|
19
+ | `userId` / `agentId` | 必填 |
20
+ | `id` | 待办 id |
21
+ | `status` | 状态 |
22
+ | `reminderTime` | 可选 |
23
+
24
+ Scope:`todos:write`。
25
+
26
+ ---
27
+
28
+ ## 2. 创建频道
29
+
30
+ 见 [rooms-api.md](rooms-api.md)。需要 `channels:write`。
31
+
32
+ > 预定会议(`agent.meeting.book`)暂未纳入本 skill;需要时引导用户在 Appia 网页操作或用独立 skill。
33
+
34
+ ---
35
+
36
+ ## 3. 尚未包装
37
+
38
+ 清通知、从消息创建待办、取消会议等**没有** claw 封装。缺能力时告知用户用网页操作,不要引导 RC 登录。
@@ -309,7 +309,7 @@ def _chunk_text(text: str, size: int) -> list[str]:
309
309
 
310
310
 
311
311
  def cmd_send(args: argparse.Namespace) -> int:
312
- """发送纯文本消息(支持分段)。"""
312
+ """发送纯文本消息(支持分段与 @提及)。"""
313
313
  rid = args.rid or _cfg("TARGET_RID")
314
314
  if not rid:
315
315
  print("⚠️ send 需要 TARGET_RID 或 --rid", file=sys.stderr)
@@ -324,6 +324,10 @@ def cmd_send(args: argparse.Namespace) -> int:
324
324
  else:
325
325
  print("⚠️ 需要 --file 或 --text", file=sys.stderr)
326
326
  return 1
327
+ # 解析 @提及(传入 rid 以校验群内成员)
328
+ mentions = _resolve_mentions(getattr(args, "mention", "") or "", args.timeout, rid=rid)
329
+ if mentions:
330
+ text = " ".join(mentions) + " " + text
327
331
  chunk_raw = _cfg("APPIA_MSG_CHUNK_SIZE")
328
332
  chunk_size = int(chunk_raw) if chunk_raw else DEFAULT_CHUNK_SIZE
329
333
  parts = _chunk_text(text, chunk_size)
@@ -364,11 +368,16 @@ def cmd_send_md(args: argparse.Namespace) -> int:
364
368
  print(f"⚠️ 读取 --md-file 失败: {exc}", file=sys.stderr)
365
369
  return 1
366
370
  elif args.text:
371
+ # 解析 @提及(传入 rid 以校验群内成员)
372
+ text = args.text
373
+ mentions = _resolve_mentions(getattr(args, "mention", "") or "", args.timeout, rid=rid)
374
+ if mentions:
375
+ text = " ".join(mentions) + " " + text
367
376
  # 从纯文本自动构造简单 md 结构
368
377
  md = [
369
378
  {
370
379
  "type": "PARAGRAPH",
371
- "value": [{"type": "PLAIN_TEXT", "value": args.text}]
380
+ "value": [{"type": "PLAIN_TEXT", "value": text}]
372
381
  }
373
382
  ]
374
383
  else:
@@ -415,6 +424,253 @@ def cmd_send_file(args: argparse.Namespace) -> int:
415
424
  return 0
416
425
 
417
426
 
427
+ # ------------------------------------------------------------------
428
+ # 入群扩展子命令:messages.get / announcements.get / file.fetch / recall / leave
429
+ # 复用上方 _cfg / _jwt / _agent_id / _user_id / _base_url,不改动既有子命令。
430
+ # ------------------------------------------------------------------
431
+
432
+
433
+ def _claw_get(path: str, params: dict[str, Any], timeout: float) -> dict[str, Any]:
434
+ base = _base_url()
435
+ q = parse.urlencode({k: v for k, v in params.items() if v is not None and v != ""})
436
+ url = f"{base}/api/v1/claw/{path}?{q}" if q else f"{base}/api/v1/claw/{path}"
437
+ req = request.Request(
438
+ url,
439
+ headers={"Authorization": f"Bearer {_jwt()}", "Accept": "application/json"},
440
+ method="GET",
441
+ )
442
+ try:
443
+ with request.urlopen(req, timeout=timeout) as resp:
444
+ body = resp.read().decode("utf-8")
445
+ except error.HTTPError as exc:
446
+ body = exc.read().decode("utf-8", errors="replace")
447
+ raise RuntimeError(f"HTTP {exc.code}: {body}") from exc
448
+ data = json.loads(body)
449
+ if not data.get("success"):
450
+ raise RuntimeError(json.dumps(data, ensure_ascii=False))
451
+ return data
452
+
453
+
454
+ def _claw_post(path: str, body_obj: dict[str, Any], timeout: float) -> dict[str, Any]:
455
+ base = _base_url()
456
+ url = f"{base}/api/v1/claw/{path}"
457
+ raw = json.dumps(body_obj, ensure_ascii=False).encode("utf-8")
458
+ req = request.Request(
459
+ url,
460
+ data=raw,
461
+ headers={
462
+ "Authorization": f"Bearer {_jwt()}",
463
+ "Content-Type": "application/json; charset=utf-8",
464
+ "Accept": "application/json",
465
+ },
466
+ method="POST",
467
+ )
468
+ try:
469
+ with request.urlopen(req, timeout=timeout) as resp:
470
+ out = resp.read().decode("utf-8")
471
+ except error.HTTPError as exc:
472
+ out = exc.read().decode("utf-8", errors="replace")
473
+ raise RuntimeError(f"HTTP {exc.code}: {out}") from exc
474
+ data = json.loads(out)
475
+ if not data.get("success"):
476
+ raise RuntimeError(json.dumps(data, ensure_ascii=False))
477
+ return data
478
+
479
+
480
+ def _resolve_mentions(names: str, timeout: float, rid: str | None = None) -> list[str]:
481
+ """将逗号分隔的显示名称(或 username)通过 agent.users.search 解析为 @mention 列表。
482
+
483
+ 规则:
484
+ 1. 先精确匹配 name 或 username,否则取搜索结果第一条。找不到则 stderr 警告并跳过。
485
+ 2. 若指定 rid,则拉取频道成员,检查用户是否在群内:在群内用 @username,不在群内用 @显示名称。
486
+ """
487
+ if not names or not names.strip():
488
+ return []
489
+
490
+ # 有 rid 时预取频道成员 username 集合(一次拉取,全员复用)
491
+ member_usernames: set[str] = set()
492
+ if rid:
493
+ try:
494
+ mparams: dict[str, Any] = {
495
+ "userId": _user_id(),
496
+ "agentId": _agent_id(),
497
+ "rid": rid,
498
+ }
499
+ mdata = _claw_get("agent.channel.members.list", mparams, timeout)
500
+ members = (mdata.get("data") or {}).get("members") or []
501
+ for m in members:
502
+ if isinstance(m, dict) and m.get("username"):
503
+ member_usernames.add(m["username"])
504
+ except Exception as exc:
505
+ print(f"⚠️ 获取频道成员失败(将跳过群内校验): {exc}", file=sys.stderr)
506
+
507
+ result: list[str] = []
508
+ for raw_name in names.split(","):
509
+ name = raw_name.strip()
510
+ if not name:
511
+ continue
512
+ params: dict[str, Any] = {
513
+ "userId": _user_id(),
514
+ "agentId": _agent_id(),
515
+ "q": name,
516
+ "limit": 5,
517
+ }
518
+ data = _claw_get("agent.users.search", params, timeout)
519
+ users = (data.get("data") or {}).get("users") or []
520
+ if not users:
521
+ # 查不到用户,直接用显示名称
522
+ print(f"⚠️ 未找到用户: {name},将直接 @显示名称", file=sys.stderr)
523
+ result.append(f"@{name}")
524
+ continue
525
+ # 精确匹配优先
526
+ matched: dict[str, Any] | None = None
527
+ for u in users:
528
+ if u.get("name") == name or u.get("username") == name:
529
+ matched = u
530
+ break
531
+ if matched is None:
532
+ matched = users[0]
533
+ matched_name = (matched.get("name") or "").strip()
534
+ username = (matched.get("username") or "").strip()
535
+ if not matched_name:
536
+ matched_name = name
537
+ if not username:
538
+ print(f"⚠️ 用户 {name} 缺少 username 字段,将直接 @显示名称", file=sys.stderr)
539
+ result.append(f"@{matched_name}")
540
+ continue
541
+ # 有 rid 时校验是否在群内
542
+ if rid and member_usernames and username not in member_usernames:
543
+ result.append(f"@{matched_name}")
544
+ else:
545
+ result.append(f"@{username}")
546
+ return result
547
+
548
+
549
+ def cmd_get_messages(args: argparse.Namespace) -> int:
550
+ """读取频道历史消息(倒序,nextLatest 翻页)。"""
551
+ rid = args.rid or _cfg("TARGET_RID")
552
+ if not rid:
553
+ print("⚠️ get-messages 需要 TARGET_RID 或 --rid", file=sys.stderr)
554
+ return 1
555
+ params: dict[str, Any] = {
556
+ "userId": _user_id(),
557
+ "agentId": _agent_id(),
558
+ "rid": rid,
559
+ "count": args.count,
560
+ "offset": args.offset,
561
+ "latest": args.latest,
562
+ "showThreadMessages": "true" if args.show_thread else "false",
563
+ }
564
+ data = _claw_get("agent.messages.get", params, args.timeout)
565
+ if args.json:
566
+ print(json.dumps(data, ensure_ascii=False, indent=2))
567
+ return 0
568
+ payload = data.get("data") or {}
569
+ msgs = payload.get("messages") or []
570
+ print(f"rid={rid} count={len(msgs)} nextLatest={payload.get('nextLatest', '')}")
571
+ for m in msgs:
572
+ if not isinstance(m, dict):
573
+ continue
574
+ print(f"- ts={m.get('ts')} from={m.get('u', {}).get('username', '')} msg={m.get('msg', '')}")
575
+ return 0
576
+
577
+
578
+ _ANN_TYPE_MAP = {"0": "普通", "1": "会议", "2": "纪要", "3": "HORS"}
579
+
580
+
581
+ def cmd_get_announcements(args: argparse.Namespace) -> int:
582
+ """读取频道公告,支持类型过滤。"""
583
+ rid = args.rid or _cfg("TARGET_RID")
584
+ if not rid:
585
+ print("⚠️ get-announcements 需要 TARGET_RID 或 --rid", file=sys.stderr)
586
+ return 1
587
+ params: dict[str, Any] = {
588
+ "userId": _user_id(),
589
+ "agentId": _agent_id(),
590
+ "rid": rid,
591
+ "type": args.type,
592
+ "announcementType": args.announcement_type,
593
+ }
594
+ data = _claw_get("agent.announcements.get", params, args.timeout)
595
+ if args.json:
596
+ print(json.dumps(data, ensure_ascii=False, indent=2))
597
+ return 0
598
+ payload = data.get("data") or {}
599
+ anns = payload.get("announcements") or payload.get("items") or []
600
+ print(f"rid={rid} total={len(anns)}")
601
+ for a in anns:
602
+ if not isinstance(a, dict):
603
+ continue
604
+ t = str(a.get("type", a.get("announcementType", "")))
605
+ tname = _ANN_TYPE_MAP.get(t, t)
606
+ print(f"- type={tname} ts={a.get('ts', a.get('createdAt', ''))} msg={a.get('message', a.get('msg', ''))}")
607
+ return 0
608
+
609
+
610
+ def cmd_fetch_file(args: argparse.Namespace) -> int:
611
+ """下载频道附件(agent.file.fetch,二进制流)。"""
612
+ if not args.file_id:
613
+ print("⚠️ fetch-file 需要 --file-id", file=sys.stderr)
614
+ return 1
615
+ params: dict[str, Any] = {
616
+ "userId": _user_id(),
617
+ "agentId": _agent_id(),
618
+ "fileId": args.file_id,
619
+ "rid": args.rid or _cfg("TARGET_RID"),
620
+ }
621
+ base = _base_url()
622
+ q = parse.urlencode({k: v for k, v in params.items() if v is not None and v != ""})
623
+ url = f"{base}/api/v1/claw/agent.file.fetch?{q}"
624
+ req = request.Request(
625
+ url,
626
+ headers={"Authorization": f"Bearer {_jwt()}"},
627
+ method="GET",
628
+ )
629
+ try:
630
+ with request.urlopen(req, timeout=args.timeout) as resp:
631
+ content = resp.read()
632
+ except error.HTTPError as exc:
633
+ body = exc.read().decode("utf-8", errors="replace")
634
+ print(f"⚠️ HTTP {exc.code}: {body}", file=sys.stderr)
635
+ return 1
636
+ out_path = os.path.expanduser(args.output)
637
+ with open(out_path, "wb") as fh:
638
+ fh.write(content)
639
+ print(f"saved: {out_path} bytes={len(content)}")
640
+ return 0
641
+
642
+
643
+ def cmd_recall(args: argparse.Namespace) -> int:
644
+ """撤回 bot 自己发送的消息。"""
645
+ if not args.message_id:
646
+ print("⚠️ recall 需要 --message-id", file=sys.stderr)
647
+ return 1
648
+ body_obj: dict[str, Any] = {
649
+ "userId": _user_id(),
650
+ "agentId": _agent_id(),
651
+ "messageId": args.message_id,
652
+ }
653
+ data = _claw_post("agent.message.recall", body_obj, args.timeout)
654
+ print(json.dumps(data, ensure_ascii=False, indent=2))
655
+ return 0
656
+
657
+
658
+ def cmd_leave(args: argparse.Namespace) -> int:
659
+ """bot 退出指定群聊。"""
660
+ rid = args.rid or _cfg("TARGET_RID")
661
+ if not rid:
662
+ print("⚠️ leave 需要 TARGET_RID 或 --rid", file=sys.stderr)
663
+ return 1
664
+ body_obj: dict[str, Any] = {
665
+ "userId": _user_id(),
666
+ "agentId": _agent_id(),
667
+ "rid": rid,
668
+ }
669
+ data = _claw_post("agent.group.leave", body_obj, args.timeout)
670
+ print(json.dumps(data, ensure_ascii=False, indent=2))
671
+ return 0
672
+
673
+
418
674
  def main() -> int:
419
675
  global _CRED
420
676
  p = argparse.ArgumentParser(description="Appia Claw (groups.get / message.send)")
@@ -438,6 +694,7 @@ def main() -> int:
438
694
  sp.add_argument("--file", "-f", help="长文本文件")
439
695
  sp.add_argument("--text", "-t", help="短文本")
440
696
  sp.add_argument("--rid", help="覆盖 TARGET_RID")
697
+ sp.add_argument("--mention", "-m", help="要 @ 的用户显示名称或 username,逗号分隔(如:张三,李四)。先通过 API 查用户名再 @")
441
698
  sp.add_argument("--json", action="store_true")
442
699
  sp.set_defaults(_run=cmd_send)
443
700
 
@@ -446,6 +703,7 @@ def main() -> int:
446
703
  smp.add_argument("--md-file", "-f", help="md JSON 文件路径")
447
704
  smp.add_argument("--text", "-t", help="纯文本(自动转为简单 md 结构)")
448
705
  smp.add_argument("--rid", help="覆盖 TARGET_RID")
706
+ smp.add_argument("--mention", "-m", help="要 @ 的用户显示名称或 username,逗号分隔(如:张三,李四)。先通过 API 查用户名再 @")
449
707
  smp.add_argument("--json", action="store_true")
450
708
  smp.set_defaults(_run=cmd_send_md)
451
709
 
@@ -456,6 +714,36 @@ def main() -> int:
456
714
  sfp.add_argument("--json", action="store_true")
457
715
  sfp.set_defaults(_run=cmd_send_file)
458
716
 
717
+ gmp = sub.add_parser("get-messages", help="GET agent.messages.get(频道历史,倒序,nextLatest 翻页)")
718
+ gmp.add_argument("--rid", help="覆盖 TARGET_RID")
719
+ gmp.add_argument("--count", type=int, default=20)
720
+ gmp.add_argument("--offset", type=int, default=0)
721
+ gmp.add_argument("--latest", help="ISO 时间,返回此时间之前的消息(翻页用 nextLatest)")
722
+ gmp.add_argument("--show-thread", dest="show_thread", action="store_true", default=True)
723
+ gmp.add_argument("--json", action="store_true")
724
+ gmp.set_defaults(_run=cmd_get_messages)
725
+
726
+ gap = sub.add_parser("get-announcements", help="GET agent.announcements.get(频道公告,可按类型过滤)")
727
+ gap.add_argument("--rid", help="覆盖 TARGET_RID")
728
+ gap.add_argument("--type", help="normal/meeting/summary/hors 或 0-3")
729
+ gap.add_argument("--announcement-type", dest="announcement_type", help="同 --type 的另一字段名")
730
+ gap.add_argument("--json", action="store_true")
731
+ gap.set_defaults(_run=cmd_get_announcements)
732
+
733
+ ffp = sub.add_parser("fetch-file", help="GET agent.file.fetch(下载频道附件,二进制流)")
734
+ ffp.add_argument("--file-id", required=True, help="附件 fileId")
735
+ ffp.add_argument("--rid", help="覆盖 TARGET_RID(建议传,校验文件属于该频道)")
736
+ ffp.add_argument("-o", "--output", required=True, help="保存到本地路径")
737
+ ffp.set_defaults(_run=cmd_fetch_file)
738
+
739
+ rp = sub.add_parser("recall", help="POST agent.message.recall(撤回 bot 自己发的消息)")
740
+ rp.add_argument("--message-id", required=True, help="消息 id")
741
+ rp.set_defaults(_run=cmd_recall)
742
+
743
+ lp = sub.add_parser("leave", help="POST agent.group.leave(bot 退出群聊)")
744
+ lp.add_argument("--rid", help="覆盖 TARGET_RID")
745
+ lp.set_defaults(_run=cmd_leave)
746
+
459
747
  args = p.parse_args()
460
748
  if args.cred_file:
461
749
  _CRED = load_cred_file(args.cred_file)