sophhub 0.4.63 → 0.4.64

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.
@@ -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)
@@ -0,0 +1,258 @@
1
+ #!/usr/bin/env python3
2
+ """Appia Claw 共享 helper:凭证解析 + JWT + 通用 HTTP(GET/POST JSON/multipart/raw)。
3
+
4
+ 供 appia_extensions.py 复用。appia_claw.py 保留自己的实现不动。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import mimetypes
11
+ import os
12
+ import sys
13
+ from typing import Any
14
+ from urllib import error, parse, request
15
+
16
+ DEFAULT_APPIA_BASE_URL = "https://sophgo.appia.cn"
17
+
18
+ # 凭证文件解析后的规范字段;未用凭证文件时为 None。
19
+ CRED: dict[str, str] | None = None
20
+
21
+ # (规范名, JSON/文件中允许的别名)。任一别名非空即写入规范键。
22
+ CRED_CANON_ALIASES: tuple[tuple[str, tuple[str, ...]], ...] = (
23
+ ("APPIA_BASE_URL", ("APPIA_BASE_URL", "appia_base_url", "base_url", "APPIA_HOST", "appia_host")),
24
+ ("CLAW_JWT", ("CLAW_JWT", "claw_jwt", "jwt", "APPIA_CLAW_JWT", "appia_claw_jwt", "MCP_JWT", "mcp_jwt")),
25
+ ("APP_AGENT_ID", ("APP_AGENT_ID", "app_agent_id", "agent_id", "AGENT_ID")),
26
+ (
27
+ "CLAW_USER_ID",
28
+ (
29
+ "CLAW_USER_ID",
30
+ "claw_user_id",
31
+ "user_id",
32
+ "USER_ID",
33
+ "creator_user_id",
34
+ "APPIA_USER_ID",
35
+ "appia_user_id",
36
+ ),
37
+ ),
38
+ ("TARGET_RID", ("TARGET_RID", "target_rid")),
39
+ ("TARGET_GROUP_NAME", ("TARGET_GROUP_NAME", "target_group_name")),
40
+ ("TARGET_GROUP_MATCH", ("TARGET_GROUP_MATCH", "target_group_match")),
41
+ )
42
+
43
+
44
+ def _scalar_from_raw(raw: dict[str, Any], aliases: tuple[str, ...]) -> str:
45
+ for key in aliases:
46
+ if key not in raw:
47
+ continue
48
+ val = raw[key]
49
+ if val is None:
50
+ continue
51
+ s = str(val).strip()
52
+ if s:
53
+ return s
54
+ return ""
55
+
56
+
57
+ def load_cred_file(path: str) -> dict[str, str]:
58
+ exp = os.path.expanduser(path)
59
+ try:
60
+ with open(exp, encoding="utf-8") as f:
61
+ raw_any: Any = json.load(f)
62
+ except OSError as exc:
63
+ sys.exit(f"无法读取凭证文件 {exp}: {exc}")
64
+ except json.JSONDecodeError as exc:
65
+ sys.exit(f"凭证文件 JSON 无效 {exp}: {exc}")
66
+ if not isinstance(raw_any, dict):
67
+ sys.exit("凭证文件顶层必须是 JSON 对象")
68
+ out: dict[str, str] = {}
69
+ for canon, aliases in CRED_CANON_ALIASES:
70
+ v = _scalar_from_raw(raw_any, aliases)
71
+ if v:
72
+ out[canon] = v
73
+ return out
74
+
75
+
76
+ def init_cred(cred_file: str | None) -> None:
77
+ """在 main() 解析完 --cred-file 后调用,设置模块级 CRED。"""
78
+ global CRED
79
+ if cred_file:
80
+ CRED = load_cred_file(cred_file)
81
+
82
+
83
+ def _env(name: str, *fallbacks: str) -> str:
84
+ for key in (name,) + fallbacks:
85
+ v = os.environ.get(key, "").strip()
86
+ if v:
87
+ return v
88
+ return ""
89
+
90
+
91
+ def cfg(canon: str, *env_fallbacks: str) -> str:
92
+ """凭证文件非空字段优先,否则回退环境变量。"""
93
+ if CRED:
94
+ v = CRED.get(canon, "").strip()
95
+ if v:
96
+ return v
97
+ return _env(canon, *env_fallbacks)
98
+
99
+
100
+ def base_url() -> str:
101
+ base = cfg("APPIA_BASE_URL", "APPIA_HOST").rstrip("/")
102
+ if not base:
103
+ base = DEFAULT_APPIA_BASE_URL.rstrip("/")
104
+ return base
105
+
106
+
107
+ def jwt() -> str:
108
+ tok = cfg("CLAW_JWT", "APPIA_CLAW_JWT", "MCP_JWT")
109
+ if not tok:
110
+ sys.exit("缺少配置:凭证文件或环境变量 CLAW_JWT(或 MCP_JWT)")
111
+ return tok
112
+
113
+
114
+ def agent_id() -> str:
115
+ aid = cfg("APP_AGENT_ID", "AGENT_ID")
116
+ if not aid:
117
+ sys.exit("缺少配置:凭证文件或环境变量 APP_AGENT_ID(或 AGENT_ID)")
118
+ return aid
119
+
120
+
121
+ def user_id() -> str:
122
+ uid = cfg("CLAW_USER_ID", "USER_ID", "APPIA_USER_ID")
123
+ if not uid:
124
+ sys.exit(
125
+ "缺少配置:凭证文件或环境变量 CLAW_USER_ID(或 USER_ID、APPIA_USER_ID、creator_user_id)"
126
+ )
127
+ return uid
128
+
129
+
130
+ def _auth_headers() -> dict[str, str]:
131
+ return {"Authorization": f"Bearer {jwt()}", "Accept": "application/json"}
132
+
133
+
134
+ def claw_url(path: str) -> str:
135
+ """path 形如 'agent.users.search',返回 {base}/api/v1/claw/{path}。"""
136
+ return f"{base_url()}/api/v1/claw/{path}"
137
+
138
+
139
+ def api_get(path: str, params: dict[str, Any], timeout: float) -> dict[str, Any]:
140
+ """GET {base}/api/v1/claw/{path}?params,返回 JSON dict。"""
141
+ qs = parse.urlencode({k: v for k, v in params.items() if v is not None and v != ""})
142
+ url = f"{claw_url(path)}?{qs}" if qs else claw_url(path)
143
+ req = request.Request(url, headers=_auth_headers(), method="GET")
144
+ try:
145
+ with request.urlopen(req, timeout=timeout) as resp:
146
+ body = resp.read().decode("utf-8")
147
+ except error.HTTPError as exc:
148
+ body = exc.read().decode("utf-8", errors="replace")
149
+ raise RuntimeError(f"HTTP {exc.code}: {body}") from exc
150
+ data = json.loads(body)
151
+ if not data.get("success"):
152
+ raise RuntimeError(json.dumps(data, ensure_ascii=False))
153
+ return data
154
+
155
+
156
+ def api_get_raw(path: str, params: dict[str, Any], timeout: float) -> bytes:
157
+ """GET 二进制流(如 agent.file.fetch),返回原始 bytes。"""
158
+ qs = parse.urlencode({k: v for k, v in params.items() if v is not None and v != ""})
159
+ url = f"{claw_url(path)}?{qs}" if qs else claw_url(path)
160
+ req = request.Request(url, headers=_auth_headers(), method="GET")
161
+ try:
162
+ with request.urlopen(req, timeout=timeout) as resp:
163
+ return resp.read()
164
+ except error.HTTPError as exc:
165
+ body = exc.read().decode("utf-8", errors="replace")
166
+ raise RuntimeError(f"HTTP {exc.code}: {body}") from exc
167
+
168
+
169
+ def api_post_json(path: str, body: dict[str, Any], timeout: float) -> dict[str, Any]:
170
+ """POST JSON 到 {base}/api/v1/claw/{path},返回 JSON dict。"""
171
+ raw = json.dumps(body, ensure_ascii=False).encode("utf-8")
172
+ headers = _auth_headers()
173
+ headers["Content-Type"] = "application/json; charset=utf-8"
174
+ req = request.Request(claw_url(path), data=raw, headers=headers, method="POST")
175
+ try:
176
+ with request.urlopen(req, timeout=timeout) as resp:
177
+ out = resp.read().decode("utf-8")
178
+ except error.HTTPError as exc:
179
+ out = exc.read().decode("utf-8", errors="replace")
180
+ raise RuntimeError(f"HTTP {exc.code}: {out}") from exc
181
+ data = json.loads(out)
182
+ if not data.get("success"):
183
+ raise RuntimeError(json.dumps(data, ensure_ascii=False))
184
+ return data
185
+
186
+
187
+ def api_post_multipart(
188
+ path: str,
189
+ fields: dict[str, str],
190
+ file_field: str,
191
+ file_path: str,
192
+ timeout: float,
193
+ file_field_name: str | None = None,
194
+ ) -> dict[str, Any]:
195
+ """POST multipart/form-data 到 claw/{path},单文件。返回 JSON dict。
196
+
197
+ file_field_name 为上传表单里的 filename,默认取 file_path 的 basename。
198
+ """
199
+ fpath = os.path.expanduser(file_path)
200
+ fname = file_field_name or os.path.basename(fpath)
201
+ mime_type, _ = mimetypes.guess_type(fpath)
202
+ if not mime_type:
203
+ mime_type = "application/octet-stream"
204
+
205
+ boundary = "----ClawUploadBoundary" + os.urandom(16).hex()
206
+ parts: list[bytes] = []
207
+ for name, value in fields.items():
208
+ parts.append(f"--{boundary}\r\n".encode())
209
+ parts.append(f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode())
210
+ parts.append(f"{value}\r\n".encode())
211
+ parts.append(f"--{boundary}\r\n".encode())
212
+ parts.append(
213
+ f'Content-Disposition: form-data; name="{file_field}"; filename="{fname}"\r\n'.encode()
214
+ )
215
+ parts.append(f"Content-Type: {mime_type}\r\n\r\n".encode())
216
+ with open(fpath, "rb") as fh:
217
+ parts.append(fh.read())
218
+ parts.append(f"\r\n--{boundary}--\r\n".encode())
219
+ body = b"".join(parts)
220
+
221
+ headers = _auth_headers()
222
+ headers["Content-Type"] = f"multipart/form-data; boundary={boundary}"
223
+ req = request.Request(claw_url(path), data=body, headers=headers, method="POST")
224
+ try:
225
+ with request.urlopen(req, timeout=timeout) as resp:
226
+ out = resp.read().decode("utf-8")
227
+ except error.HTTPError as exc:
228
+ out = exc.read().decode("utf-8", errors="replace")
229
+ raise RuntimeError(f"HTTP {exc.code}: {out}") from exc
230
+ data = json.loads(out)
231
+ if not data.get("success"):
232
+ raise RuntimeError(json.dumps(data, ensure_ascii=False))
233
+ return data
234
+
235
+
236
+ def add_cred_arg(parser: Any) -> None:
237
+ parser.add_argument(
238
+ "--cred-file",
239
+ "-c",
240
+ metavar="PATH",
241
+ help="JSON 凭证文件(含 Appia 地址、JWT、agentId 等);文件中非空字段优先于环境变量",
242
+ )
243
+
244
+
245
+ def add_timeout_arg(parser: Any, default: float = 60.0) -> None:
246
+ parser.add_argument("--timeout", type=float, default=default)
247
+
248
+
249
+ def add_json_arg(parser: Any) -> None:
250
+ parser.add_argument("--json", action="store_true", help="输出接口原始 JSON")
251
+
252
+
253
+ def emit(data: Any, as_json: bool, human: str) -> int:
254
+ if as_json:
255
+ print(json.dumps(data, ensure_ascii=False, indent=2))
256
+ else:
257
+ print(human)
258
+ return 0