sophhub 0.2.4 → 0.3.0

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,596 @@
1
+ #!/usr/bin/env python3
2
+ """OpenClaw note CRUD orchestrator for Feishu docs."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import re
10
+ import subprocess
11
+ import sys
12
+ from typing import Any, Dict, List, Optional, Tuple
13
+
14
+
15
+ class CLIError(Exception):
16
+ def __init__(self, message: str, code: str = "cli_error", detail: Any = None):
17
+ super().__init__(message)
18
+ self.code = code
19
+ self.detail = detail
20
+
21
+
22
+ DOCX_URL_RE = re.compile(r"/docx/([A-Za-z0-9_-]+)")
23
+
24
+
25
+ def parse_args() -> argparse.Namespace:
26
+ parser = argparse.ArgumentParser(description="Feishu note CRUD via lark-cli")
27
+ parser.add_argument(
28
+ "--op",
29
+ required=True,
30
+ choices=[
31
+ "create",
32
+ "read",
33
+ "update",
34
+ "delete",
35
+ "search",
36
+ "media-insert",
37
+ "media-download",
38
+ "comment",
39
+ "section-update",
40
+ ],
41
+ help="note operation",
42
+ )
43
+ parser.add_argument("--doc", help="doc token or doc/docx URL")
44
+ parser.add_argument("--title", help="note title")
45
+ parser.add_argument("--markdown", help="note markdown content")
46
+ parser.add_argument("--query", help="search keyword")
47
+ parser.add_argument("--mode", default="append", help="update mode")
48
+ parser.add_argument("--new-title", help="rename title while update")
49
+ parser.add_argument("--selection-by-title", help="title-based locator for update")
50
+ parser.add_argument(
51
+ "--selection-with-ellipsis", help="content locator for range update/delete"
52
+ )
53
+ parser.add_argument("--folder-token", help="target folder token when creating")
54
+ parser.add_argument("--wiki-node", help="target wiki node token when creating")
55
+ parser.add_argument("--wiki-space", help="target wiki space when creating")
56
+ parser.add_argument("--page-size", default="15", help="search page size")
57
+ parser.add_argument("--page-token", help="search page token")
58
+ parser.add_argument("--file", help="local file path for media insert")
59
+ parser.add_argument(
60
+ "--media-type",
61
+ default="image",
62
+ choices=["image", "file"],
63
+ help="media type for insert: image | file",
64
+ )
65
+ parser.add_argument(
66
+ "--token", help="media token or whiteboard token for media download"
67
+ )
68
+ parser.add_argument(
69
+ "--resource-type",
70
+ default="media",
71
+ choices=["media", "whiteboard"],
72
+ help="resource type for media download: media | whiteboard",
73
+ )
74
+ parser.add_argument("--output", help="local output path for media download")
75
+ parser.add_argument(
76
+ "--overwrite", action="store_true", help="overwrite existing download file"
77
+ )
78
+ parser.add_argument("--caption", help="image caption for media insert")
79
+ parser.add_argument(
80
+ "--align", choices=["left", "center", "right"], help="image alignment"
81
+ )
82
+ parser.add_argument("--comment", help="plain text comment body")
83
+ parser.add_argument(
84
+ "--comment-json",
85
+ help="raw reply_elements JSON array for advanced comment payloads",
86
+ )
87
+ parser.add_argument(
88
+ "--block-id", help="comment anchor block ID for local comment mode"
89
+ )
90
+ parser.add_argument(
91
+ "--full-comment",
92
+ action="store_true",
93
+ help="create full-document comment instead of local comment",
94
+ )
95
+ parser.add_argument("--section-title", help="title locator like '## Section'")
96
+ parser.add_argument(
97
+ "--section-mode",
98
+ choices=["replace", "delete", "insert_before", "insert_after"],
99
+ default="replace",
100
+ help="section update mode",
101
+ )
102
+ parser.add_argument(
103
+ "--as",
104
+ dest="identity",
105
+ default="bot",
106
+ help="bot | user (default: bot for cloud deployments)",
107
+ )
108
+ parser.add_argument("--config-dir", help="optional LARKSUITE_CLI_CONFIG_DIR")
109
+ parser.add_argument(
110
+ "--lark-cli-prefix",
111
+ action="append",
112
+ default=[],
113
+ help="repeatable argv prefix, e.g. --lark-cli-prefix npx --lark-cli-prefix -y ...",
114
+ )
115
+ parser.add_argument(
116
+ "--lark-cli-bin",
117
+ default="lark-cli",
118
+ help="(legacy) lark-cli executable path; ignored if --lark-cli-prefix is provided",
119
+ )
120
+ return parser.parse_args()
121
+
122
+
123
+ def build_env(config_dir: Optional[str]) -> Dict[str, str]:
124
+ env = os.environ.copy()
125
+ if config_dir:
126
+ env["LARKSUITE_CLI_CONFIG_DIR"] = config_dir
127
+ return env
128
+
129
+
130
+ def run_cli(
131
+ cli_prefix: List[str],
132
+ env: Dict[str, str],
133
+ args: List[str],
134
+ cwd: Optional[str] = None,
135
+ ) -> Any:
136
+ # Windows: if prefix points to a .py script, run via current Python.
137
+ if (
138
+ len(cli_prefix) == 1
139
+ and cli_prefix[0].endswith(".py")
140
+ and os.path.exists(cli_prefix[0])
141
+ ):
142
+ cli_prefix = [sys.executable, cli_prefix[0]]
143
+
144
+ proc = subprocess.run(
145
+ cli_prefix + args,
146
+ capture_output=True,
147
+ text=True,
148
+ env=env,
149
+ cwd=cwd,
150
+ )
151
+ stdout = proc.stdout.strip()
152
+ stderr = proc.stderr.strip()
153
+
154
+ parsed: Any = None
155
+ if stdout:
156
+ try:
157
+ parsed = json.loads(stdout)
158
+ except json.JSONDecodeError as exc:
159
+ raise CLIError(
160
+ f"lark-cli returned non-JSON stdout: {stdout[:400]}",
161
+ code="invalid_cli_output",
162
+ detail={"stderr": stderr, "args": args},
163
+ ) from exc
164
+
165
+ if proc.returncode != 0:
166
+ detail = parsed if isinstance(parsed, dict) else {"stderr": stderr}
167
+ raise CLIError(
168
+ f"lark-cli command failed: {' '.join(args)}",
169
+ code="lark_cli_failed",
170
+ detail=detail,
171
+ )
172
+ return parsed
173
+
174
+
175
+ def localize_cli_path(path: str) -> Tuple[str, str]:
176
+ abs_path = os.path.abspath(path)
177
+ parent = os.path.dirname(abs_path) or os.getcwd()
178
+ return parent, f"./{os.path.basename(abs_path)}"
179
+
180
+
181
+ def unwrap_payload(payload: Any) -> Any:
182
+ if isinstance(payload, dict) and "ok" in payload:
183
+ if not payload.get("ok", False):
184
+ error = payload.get("error") or {}
185
+ raise CLIError(
186
+ error.get("message", "lark-cli returned an error envelope"),
187
+ code=error.get("type", "lark_error"),
188
+ detail=payload,
189
+ )
190
+ return payload.get("data")
191
+ return payload
192
+
193
+
194
+ def parse_doc_token(doc: str) -> str:
195
+ if not doc:
196
+ return ""
197
+ if doc.startswith("http://") or doc.startswith("https://"):
198
+ match = DOCX_URL_RE.search(doc)
199
+ if match:
200
+ return match.group(1)
201
+ return ""
202
+ return doc
203
+
204
+
205
+ def create_note(
206
+ args: argparse.Namespace, env: Dict[str, str], cli_prefix: List[str]
207
+ ) -> Dict[str, Any]:
208
+ if not args.title:
209
+ raise CLIError("missing --title for create", code="validation")
210
+ if not args.markdown:
211
+ raise CLIError("missing --markdown for create", code="validation")
212
+ cmd = [
213
+ "docs",
214
+ "+create",
215
+ "--as",
216
+ args.identity,
217
+ "--title",
218
+ args.title,
219
+ "--markdown",
220
+ args.markdown,
221
+ ]
222
+ if args.folder_token:
223
+ cmd.extend(["--folder-token", args.folder_token])
224
+ if args.wiki_node:
225
+ cmd.extend(["--wiki-node", args.wiki_node])
226
+ if args.wiki_space:
227
+ cmd.extend(["--wiki-space", args.wiki_space])
228
+ data = unwrap_payload(run_cli(cli_prefix, env, cmd))
229
+ if not isinstance(data, dict):
230
+ raise CLIError("unexpected docs +create payload", code="invalid_create_payload")
231
+ return data
232
+
233
+
234
+ def read_note(
235
+ args: argparse.Namespace, env: Dict[str, str], cli_prefix: List[str]
236
+ ) -> Dict[str, Any]:
237
+ if not args.doc:
238
+ raise CLIError("missing --doc for read", code="validation")
239
+ cmd = [
240
+ "docs",
241
+ "+fetch",
242
+ "--as",
243
+ args.identity,
244
+ "--doc",
245
+ args.doc,
246
+ "--format",
247
+ "json",
248
+ ]
249
+ data = unwrap_payload(run_cli(cli_prefix, env, cmd))
250
+ if not isinstance(data, dict):
251
+ raise CLIError("unexpected docs +fetch payload", code="invalid_read_payload")
252
+ return data
253
+
254
+
255
+ def update_note(
256
+ args: argparse.Namespace, env: Dict[str, str], cli_prefix: List[str]
257
+ ) -> Dict[str, Any]:
258
+ if not args.doc:
259
+ raise CLIError("missing --doc for update", code="validation")
260
+ cmd = [
261
+ "docs",
262
+ "+update",
263
+ "--as",
264
+ args.identity,
265
+ "--doc",
266
+ args.doc,
267
+ "--mode",
268
+ args.mode,
269
+ ]
270
+ if args.markdown:
271
+ cmd.extend(["--markdown", args.markdown])
272
+ if args.new_title:
273
+ cmd.extend(["--new-title", args.new_title])
274
+ if args.selection_by_title:
275
+ cmd.extend(["--selection-by-title", args.selection_by_title])
276
+ if args.selection_with_ellipsis:
277
+ cmd.extend(["--selection-with-ellipsis", args.selection_with_ellipsis])
278
+ data = unwrap_payload(run_cli(cli_prefix, env, cmd))
279
+ if not isinstance(data, dict):
280
+ raise CLIError("unexpected docs +update payload", code="invalid_update_payload")
281
+ return data
282
+
283
+
284
+ def search_note(
285
+ args: argparse.Namespace, env: Dict[str, str], cli_prefix: List[str]
286
+ ) -> Dict[str, Any]:
287
+ cmd = [
288
+ "docs",
289
+ "+search",
290
+ "--as",
291
+ args.identity,
292
+ "--format",
293
+ "json",
294
+ "--page-size",
295
+ args.page_size,
296
+ ]
297
+ if args.query:
298
+ cmd.extend(["--query", args.query])
299
+ if args.page_token:
300
+ cmd.extend(["--page-token", args.page_token])
301
+ data = unwrap_payload(run_cli(cli_prefix, env, cmd))
302
+ if not isinstance(data, dict):
303
+ raise CLIError("unexpected docs +search payload", code="invalid_search_payload")
304
+ return data
305
+
306
+
307
+ def delete_note(
308
+ args: argparse.Namespace, env: Dict[str, str], cli_prefix: List[str]
309
+ ) -> Dict[str, Any]:
310
+ if not args.doc:
311
+ raise CLIError("missing --doc for delete", code="validation")
312
+ token = parse_doc_token(args.doc)
313
+ if not token:
314
+ raise CLIError(
315
+ "delete only supports doc token or /docx/<token> URL",
316
+ code="validation",
317
+ )
318
+
319
+ # Try direct delete first. Some tenants require explicit type param.
320
+ attempts = [
321
+ [
322
+ "api",
323
+ "DELETE",
324
+ f"/open-apis/drive/v1/files/{token}",
325
+ "--as",
326
+ args.identity,
327
+ "--format",
328
+ "json",
329
+ ],
330
+ [
331
+ "api",
332
+ "DELETE",
333
+ f"/open-apis/drive/v1/files/{token}",
334
+ "--as",
335
+ args.identity,
336
+ "--params",
337
+ '{"type":"docx"}',
338
+ "--format",
339
+ "json",
340
+ ],
341
+ ]
342
+ last_exc: Optional[CLIError] = None
343
+ for cmd in attempts:
344
+ try:
345
+ payload = run_cli(cli_prefix, env, cmd)
346
+ return {
347
+ "deleted_doc_token": token,
348
+ "raw": unwrap_payload(payload),
349
+ }
350
+ except CLIError as exc:
351
+ last_exc = exc
352
+ assert last_exc is not None
353
+ raise last_exc
354
+
355
+
356
+ def media_insert(
357
+ args: argparse.Namespace, env: Dict[str, str], cli_prefix: List[str]
358
+ ) -> Dict[str, Any]:
359
+ if not args.doc:
360
+ raise CLIError("missing --doc for media-insert", code="validation")
361
+ if not args.file:
362
+ raise CLIError("missing --file for media-insert", code="validation")
363
+
364
+ file_cwd, cli_file = localize_cli_path(args.file)
365
+ cmd = [
366
+ "docs",
367
+ "+media-insert",
368
+ "--as",
369
+ args.identity,
370
+ "--doc",
371
+ args.doc,
372
+ "--file",
373
+ cli_file,
374
+ "--type",
375
+ args.media_type,
376
+ ]
377
+ if args.align:
378
+ cmd.extend(["--align", args.align])
379
+ if args.caption:
380
+ cmd.extend(["--caption", args.caption])
381
+
382
+ data = unwrap_payload(run_cli(cli_prefix, env, cmd, cwd=file_cwd))
383
+ if not isinstance(data, dict):
384
+ raise CLIError(
385
+ "unexpected docs +media-insert payload", code="invalid_media_insert_payload"
386
+ )
387
+ return data
388
+
389
+
390
+ def media_download(
391
+ args: argparse.Namespace, env: Dict[str, str], cli_prefix: List[str]
392
+ ) -> Dict[str, Any]:
393
+ if not args.token:
394
+ raise CLIError("missing --token for media-download", code="validation")
395
+ if not args.output:
396
+ raise CLIError("missing --output for media-download", code="validation")
397
+
398
+ output_cwd, cli_output = localize_cli_path(args.output)
399
+ cmd = [
400
+ "docs",
401
+ "+media-download",
402
+ "--as",
403
+ args.identity,
404
+ "--token",
405
+ args.token,
406
+ "--type",
407
+ args.resource_type,
408
+ "--output",
409
+ cli_output,
410
+ ]
411
+ if args.overwrite:
412
+ cmd.append("--overwrite")
413
+
414
+ data = unwrap_payload(run_cli(cli_prefix, env, cmd, cwd=output_cwd))
415
+ if not isinstance(data, dict):
416
+ raise CLIError(
417
+ "unexpected docs +media-download payload",
418
+ code="invalid_media_download_payload",
419
+ )
420
+ saved_path = data.get("saved_path")
421
+ if isinstance(saved_path, str) and not os.path.isabs(saved_path):
422
+ data["saved_path"] = os.path.abspath(os.path.join(output_cwd, saved_path))
423
+ return data
424
+
425
+
426
+ def build_comment_content(args: argparse.Namespace) -> str:
427
+ if args.comment_json:
428
+ try:
429
+ parsed = json.loads(args.comment_json)
430
+ except json.JSONDecodeError as exc:
431
+ raise CLIError(
432
+ "--comment-json must be valid JSON", code="validation"
433
+ ) from exc
434
+ if not isinstance(parsed, list):
435
+ raise CLIError(
436
+ "--comment-json must be a reply_elements JSON array",
437
+ code="validation",
438
+ )
439
+ return args.comment_json
440
+
441
+ if args.comment:
442
+ return json.dumps(
443
+ [{"type": "text", "text": args.comment}],
444
+ ensure_ascii=False,
445
+ )
446
+
447
+ raise CLIError(
448
+ "missing --comment or --comment-json for comment", code="validation"
449
+ )
450
+
451
+
452
+ def add_comment(
453
+ args: argparse.Namespace, env: Dict[str, str], cli_prefix: List[str]
454
+ ) -> Dict[str, Any]:
455
+ if not args.doc:
456
+ raise CLIError("missing --doc for comment", code="validation")
457
+
458
+ cmd = [
459
+ "drive",
460
+ "+add-comment",
461
+ "--as",
462
+ args.identity,
463
+ "--doc",
464
+ args.doc,
465
+ "--content",
466
+ build_comment_content(args),
467
+ ]
468
+ if args.full_comment:
469
+ cmd.append("--full-comment")
470
+ if args.selection_with_ellipsis:
471
+ cmd.extend(["--selection-with-ellipsis", args.selection_with_ellipsis])
472
+ if args.block_id:
473
+ cmd.extend(["--block-id", args.block_id])
474
+
475
+ data = unwrap_payload(run_cli(cli_prefix, env, cmd))
476
+ if not isinstance(data, dict):
477
+ raise CLIError(
478
+ "unexpected drive +add-comment payload", code="invalid_comment_payload"
479
+ )
480
+ return data
481
+
482
+
483
+ def normalize_section_markdown(section_title: str, markdown: str) -> str:
484
+ stripped = markdown.lstrip()
485
+ if stripped.startswith("#"):
486
+ return markdown
487
+ return f"{section_title}\n\n{markdown}"
488
+
489
+
490
+ def update_section(
491
+ args: argparse.Namespace, env: Dict[str, str], cli_prefix: List[str]
492
+ ) -> Dict[str, Any]:
493
+ if not args.doc:
494
+ raise CLIError("missing --doc for section-update", code="validation")
495
+ if not args.section_title:
496
+ raise CLIError("missing --section-title for section-update", code="validation")
497
+
498
+ mode_map = {
499
+ "replace": "replace_range",
500
+ "delete": "delete_range",
501
+ "insert_before": "insert_before",
502
+ "insert_after": "insert_after",
503
+ }
504
+ cli_mode = mode_map[args.section_mode]
505
+ cmd = [
506
+ "docs",
507
+ "+update",
508
+ "--as",
509
+ args.identity,
510
+ "--doc",
511
+ args.doc,
512
+ "--mode",
513
+ cli_mode,
514
+ "--selection-by-title",
515
+ args.section_title,
516
+ ]
517
+ if args.new_title:
518
+ cmd.extend(["--new-title", args.new_title])
519
+ if args.section_mode != "delete":
520
+ if not args.markdown:
521
+ raise CLIError(
522
+ "missing --markdown for non-delete section-update",
523
+ code="validation",
524
+ )
525
+ markdown = args.markdown
526
+ if args.section_mode == "replace":
527
+ markdown = normalize_section_markdown(args.section_title, markdown)
528
+ cmd.extend(["--markdown", markdown])
529
+
530
+ data = unwrap_payload(run_cli(cli_prefix, env, cmd))
531
+ if not isinstance(data, dict):
532
+ raise CLIError(
533
+ "unexpected section update payload", code="invalid_section_update_payload"
534
+ )
535
+ enriched = dict(data)
536
+ enriched["section_title"] = args.section_title
537
+ enriched["section_mode"] = args.section_mode
538
+ return enriched
539
+
540
+
541
+ def run(
542
+ args: argparse.Namespace, env: Dict[str, str], cli_prefix: List[str]
543
+ ) -> Dict[str, Any]:
544
+ if args.op == "create":
545
+ return create_note(args, env, cli_prefix)
546
+ if args.op == "read":
547
+ return read_note(args, env, cli_prefix)
548
+ if args.op == "update":
549
+ return update_note(args, env, cli_prefix)
550
+ if args.op == "search":
551
+ return search_note(args, env, cli_prefix)
552
+ if args.op == "delete":
553
+ return delete_note(args, env, cli_prefix)
554
+ if args.op == "media-insert":
555
+ return media_insert(args, env, cli_prefix)
556
+ if args.op == "media-download":
557
+ return media_download(args, env, cli_prefix)
558
+ if args.op == "comment":
559
+ return add_comment(args, env, cli_prefix)
560
+ if args.op == "section-update":
561
+ return update_section(args, env, cli_prefix)
562
+ raise CLIError(f"unsupported op: {args.op}", code="validation")
563
+
564
+
565
+ def main() -> int:
566
+ args = parse_args()
567
+ env = build_env(args.config_dir)
568
+ from _resolve_lark_cli import as_prefix
569
+
570
+ cli_prefix = as_prefix(args.lark_cli_bin, args.lark_cli_prefix)
571
+ try:
572
+ data = run(args, env, cli_prefix)
573
+ out = {
574
+ "ok": True,
575
+ "op": args.op,
576
+ "data": data,
577
+ "error": None,
578
+ }
579
+ except CLIError as exc:
580
+ out = {
581
+ "ok": False,
582
+ "op": args.op,
583
+ "data": None,
584
+ "error": {
585
+ "code": exc.code,
586
+ "message": str(exc),
587
+ "detail": exc.detail,
588
+ },
589
+ }
590
+ json.dump(out, sys.stdout, ensure_ascii=False, indent=2)
591
+ sys.stdout.write("\n")
592
+ return 0
593
+
594
+
595
+ if __name__ == "__main__":
596
+ raise SystemExit(main())