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.
- package/package.json +1 -1
- package/skills/claw-agent-get-send/skill.json +18 -4
- package/skills/claw-agent-get-send/src/SKILL.md +103 -27
- package/skills/claw-agent-get-send/src/pyproject.toml +2 -2
- package/skills/claw-agent-get-send/src/reference-http.md +296 -0
- package/skills/claw-agent-get-send/src/references/auth.md +115 -0
- package/skills/claw-agent-get-send/src/references/creator-delegate-api.md +90 -0
- package/skills/claw-agent-get-send/src/references/overview-api.md +47 -0
- package/skills/claw-agent-get-send/src/references/query-api.md +109 -0
- package/skills/claw-agent-get-send/src/references/rooms-api.md +79 -0
- package/skills/claw-agent-get-send/src/references/session-flow.md +61 -0
- package/skills/claw-agent-get-send/src/references/write-api.md +38 -0
- package/skills/claw-agent-get-send/src/scripts/appia_claw.py +290 -2
- package/skills/claw-agent-get-send/src/scripts/appia_common.py +258 -0
- package/skills/claw-agent-get-send/src/scripts/appia_extensions.py +370 -0
|
@@ -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":
|
|
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
|