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,364 @@
1
+ #!/usr/bin/env python3
2
+ """Wolai OpenAPI CRUD orchestrator."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import sys
10
+ import urllib.error
11
+ import urllib.parse
12
+ import urllib.request
13
+ from typing import Any, Dict, List, Optional
14
+
15
+
16
+ class WolaiError(Exception):
17
+ def __init__(self, message: str, code: str = "wolai_error", detail: Any = None):
18
+ super().__init__(message)
19
+ self.code = code
20
+ self.detail = detail
21
+
22
+
23
+ def parse_args() -> argparse.Namespace:
24
+ parser = argparse.ArgumentParser(description="Wolai note CRUD via OpenAPI")
25
+ parser.add_argument(
26
+ "--op",
27
+ required=True,
28
+ choices=["create", "read", "read-children", "update", "delete", "search"],
29
+ help="wolai operation",
30
+ )
31
+ parser.add_argument("--id", help="block/page id")
32
+ parser.add_argument("--parent-id", help="parent page or block id")
33
+ parser.add_argument("--content", help="plain text content for simple text blocks")
34
+ parser.add_argument(
35
+ "--block-json",
36
+ help='raw JSON for blocks payload, e.g. {"type":"text","content":"Hello"}',
37
+ )
38
+ parser.add_argument("--query", help="keyword for lightweight search")
39
+ parser.add_argument("--cursor", help="pagination cursor")
40
+ parser.add_argument(
41
+ "--limit",
42
+ type=int,
43
+ default=50,
44
+ help="page size for list endpoints (max 200 recommended)",
45
+ )
46
+
47
+ parser.add_argument(
48
+ "--app-token", help="Wolai app token; if missing, read WOLAI_APP_TOKEN env"
49
+ )
50
+ parser.add_argument("--app-id", help="Wolai app id; fallback WOLAI_APP_ID env")
51
+ parser.add_argument(
52
+ "--app-secret", help="Wolai app secret; fallback WOLAI_APP_SECRET env"
53
+ )
54
+ parser.add_argument(
55
+ "--base-url",
56
+ default=os.environ.get("WOLAI_OPENAPI_BASE_URL", "https://openapi.wolai.com/v1"),
57
+ help="Wolai OpenAPI base url",
58
+ )
59
+ return parser.parse_args()
60
+
61
+
62
+ def normalize_base_url(base_url: str) -> str:
63
+ return base_url.rstrip("/")
64
+
65
+
66
+ def parse_json_arg(raw: Optional[str], *, arg_name: str) -> Any:
67
+ if not raw:
68
+ return None
69
+ try:
70
+ return json.loads(raw)
71
+ except json.JSONDecodeError as exc:
72
+ raise WolaiError(
73
+ f"{arg_name} must be valid JSON",
74
+ code="validation",
75
+ detail={"arg": arg_name},
76
+ ) from exc
77
+
78
+
79
+ def build_text_block(content: str) -> Dict[str, Any]:
80
+ return {"type": "text", "content": content}
81
+
82
+
83
+ def request_json(
84
+ method: str,
85
+ url: str,
86
+ token: Optional[str] = None,
87
+ payload: Optional[Dict[str, Any]] = None,
88
+ ) -> Any:
89
+ body = None
90
+ base_headers = {
91
+ "Accept": "application/json",
92
+ }
93
+ if payload is not None:
94
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
95
+ base_headers["Content-Type"] = "application/json; charset=utf-8"
96
+
97
+ auth_variants: List[Optional[str]] = [None]
98
+ if token:
99
+ token_value = str(token).strip()
100
+ auth_variants = [token_value]
101
+ if token_value.lower().startswith("bearer "):
102
+ raw_token = token_value[7:].strip()
103
+ if raw_token:
104
+ auth_variants.append(raw_token)
105
+ else:
106
+ auth_variants.append(f"Bearer {token_value}")
107
+
108
+ last_http_error: Optional[urllib.error.HTTPError] = None
109
+ for idx, auth in enumerate(auth_variants):
110
+ headers = dict(base_headers)
111
+ if auth:
112
+ headers["Authorization"] = auth
113
+ req = urllib.request.Request(url=url, method=method, data=body, headers=headers)
114
+ try:
115
+ with urllib.request.urlopen(req) as resp:
116
+ raw = resp.read().decode("utf-8")
117
+ if not raw:
118
+ return {}
119
+ return json.loads(raw)
120
+ except urllib.error.HTTPError as exc:
121
+ last_http_error = exc
122
+ can_retry_auth = idx < len(auth_variants) - 1 and exc.code in (401, 403)
123
+ if can_retry_auth:
124
+ continue
125
+
126
+ raw = exc.read().decode("utf-8", errors="replace")
127
+ detail: Dict[str, Any] = {
128
+ "status": exc.code,
129
+ "body": raw,
130
+ "url": url,
131
+ "auth_variant": auth,
132
+ }
133
+ message = f"Wolai API request failed ({exc.code})"
134
+ try:
135
+ err_payload = json.loads(raw) if raw else {}
136
+ if isinstance(err_payload, dict):
137
+ if err_payload.get("message"):
138
+ message = str(err_payload["message"])
139
+ detail["response"] = err_payload
140
+ except json.JSONDecodeError:
141
+ pass
142
+ raise WolaiError(message, code="http_error", detail=detail) from exc
143
+ except urllib.error.URLError as exc:
144
+ raise WolaiError(
145
+ f"network error: {exc.reason}",
146
+ code="network_error",
147
+ detail={"url": url},
148
+ ) from exc
149
+
150
+ if last_http_error:
151
+ raise WolaiError(
152
+ f"Wolai API request failed ({last_http_error.code})",
153
+ code="http_error",
154
+ detail={"url": url},
155
+ )
156
+ raise WolaiError("unexpected request flow", code="internal_error", detail={"url": url})
157
+
158
+
159
+ def extract_app_token(payload: Dict[str, Any]) -> Optional[str]:
160
+ app_token = payload.get("app_token")
161
+ if isinstance(app_token, str) and app_token.strip():
162
+ return app_token.strip()
163
+
164
+ app_token_obj = payload.get("appToken")
165
+ if isinstance(app_token_obj, str) and app_token_obj.strip():
166
+ return app_token_obj.strip()
167
+ if isinstance(app_token_obj, dict):
168
+ nested = app_token_obj.get("app_token") or app_token_obj.get("appToken")
169
+ if isinstance(nested, str) and nested.strip():
170
+ return nested.strip()
171
+
172
+ data_obj = payload.get("data")
173
+ if isinstance(data_obj, dict):
174
+ nested = (
175
+ data_obj.get("app_token")
176
+ or data_obj.get("appToken")
177
+ or ((data_obj.get("appToken") or {}).get("app_token") if isinstance(data_obj.get("appToken"), dict) else None)
178
+ )
179
+ if isinstance(nested, str) and nested.strip():
180
+ return nested.strip()
181
+ return None
182
+
183
+
184
+ def resolve_token(args: argparse.Namespace, base_url: str) -> str:
185
+ explicit = args.app_token or os.environ.get("WOLAI_APP_TOKEN")
186
+ if explicit:
187
+ return explicit
188
+
189
+ app_id = args.app_id or os.environ.get("WOLAI_APP_ID")
190
+ app_secret = args.app_secret or os.environ.get("WOLAI_APP_SECRET")
191
+ if not app_id or not app_secret:
192
+ raise WolaiError(
193
+ "missing app token or app credentials (WOLAI_APP_TOKEN / WOLAI_APP_ID + WOLAI_APP_SECRET)",
194
+ code="missing_credentials",
195
+ )
196
+
197
+ payload = request_json(
198
+ "POST",
199
+ f"{base_url}/token",
200
+ payload={"appId": app_id, "appSecret": app_secret},
201
+ )
202
+ if not isinstance(payload, dict):
203
+ raise WolaiError("unexpected token response", code="invalid_token_response")
204
+ app_token = extract_app_token(payload)
205
+ if not app_token:
206
+ raise WolaiError(
207
+ "token response missing app token field",
208
+ code="invalid_token_response",
209
+ detail=payload,
210
+ )
211
+ return str(app_token)
212
+
213
+
214
+ def create_block(args: argparse.Namespace, base_url: str, token: str) -> Any:
215
+ if not args.parent_id:
216
+ raise WolaiError("missing --parent-id for create", code="validation")
217
+ blocks = parse_json_arg(args.block_json, arg_name="--block-json")
218
+ if blocks is None:
219
+ if not args.content:
220
+ raise WolaiError(
221
+ "missing --content or --block-json for create", code="validation"
222
+ )
223
+ blocks = build_text_block(args.content)
224
+ return request_json(
225
+ "POST",
226
+ f"{base_url}/blocks",
227
+ token=token,
228
+ payload={"parent_id": args.parent_id, "blocks": blocks},
229
+ )
230
+
231
+
232
+ def read_block(args: argparse.Namespace, base_url: str, token: str) -> Any:
233
+ if not args.id:
234
+ raise WolaiError("missing --id for read", code="validation")
235
+ return request_json("GET", f"{base_url}/blocks/{args.id}", token=token)
236
+
237
+
238
+ def read_children(args: argparse.Namespace, base_url: str, token: str) -> Any:
239
+ target_id = args.id or args.parent_id
240
+ if not target_id:
241
+ raise WolaiError("missing --id or --parent-id for read-children", code="validation")
242
+ query: Dict[str, Any] = {}
243
+ if args.cursor:
244
+ query["cursor"] = args.cursor
245
+ if args.limit:
246
+ query["limit"] = args.limit
247
+ url = f"{base_url}/blocks/{target_id}/children"
248
+ if query:
249
+ url = f"{url}?{urllib.parse.urlencode(query)}"
250
+ return request_json("GET", url, token=token)
251
+
252
+
253
+ def update_block(args: argparse.Namespace, base_url: str, token: str) -> Any:
254
+ if not args.id:
255
+ raise WolaiError("missing --id for update", code="validation")
256
+ blocks = parse_json_arg(args.block_json, arg_name="--block-json")
257
+ if blocks is None:
258
+ if not args.content:
259
+ raise WolaiError(
260
+ "missing --content or --block-json for update", code="validation"
261
+ )
262
+ blocks = build_text_block(args.content)
263
+ payload = {"blocks": blocks}
264
+ # Wolai docs evolve; try PATCH first, then PUT.
265
+ try:
266
+ return request_json(
267
+ "PATCH",
268
+ f"{base_url}/blocks/{args.id}",
269
+ token=token,
270
+ payload=payload,
271
+ )
272
+ except WolaiError as first_exc:
273
+ try:
274
+ return request_json(
275
+ "PUT",
276
+ f"{base_url}/blocks/{args.id}",
277
+ token=token,
278
+ payload=payload,
279
+ )
280
+ except WolaiError as second_exc:
281
+ raise WolaiError(
282
+ "update failed with both PATCH and PUT",
283
+ code="update_failed",
284
+ detail={"patch_error": first_exc.detail, "put_error": second_exc.detail},
285
+ ) from second_exc
286
+
287
+
288
+ def delete_block(args: argparse.Namespace, base_url: str, token: str) -> Any:
289
+ if not args.id:
290
+ raise WolaiError("missing --id for delete", code="validation")
291
+ return request_json("DELETE", f"{base_url}/blocks/{args.id}", token=token)
292
+
293
+
294
+ def search_blocks(args: argparse.Namespace, base_url: str, token: str) -> Any:
295
+ # OpenAPI currently has no dedicated full-text search endpoint in this skill.
296
+ # We do lightweight filtering from children list.
297
+ if not args.parent_id and not args.id:
298
+ raise WolaiError(
299
+ "search requires --parent-id (or --id) as search scope",
300
+ code="validation",
301
+ )
302
+ if not args.query:
303
+ raise WolaiError("missing --query for search", code="validation")
304
+ data = read_children(args, base_url, token)
305
+ if not isinstance(data, dict):
306
+ return {"matches": [], "raw": data}
307
+
308
+ records = data.get("data")
309
+ if not isinstance(records, list):
310
+ return {"matches": [], "raw": data}
311
+
312
+ needle = args.query.lower()
313
+ matches: List[Dict[str, Any]] = []
314
+ for item in records:
315
+ if not isinstance(item, dict):
316
+ continue
317
+ hay = json.dumps(item, ensure_ascii=False).lower()
318
+ if needle in hay:
319
+ matches.append(item)
320
+ return {
321
+ "matches": matches,
322
+ "total": len(matches),
323
+ "has_more": data.get("has_more"),
324
+ "next_cursor": data.get("next_cursor"),
325
+ }
326
+
327
+
328
+ def run(args: argparse.Namespace) -> Any:
329
+ base_url = normalize_base_url(args.base_url)
330
+ token = resolve_token(args, base_url)
331
+ if args.op == "create":
332
+ return create_block(args, base_url, token)
333
+ if args.op == "read":
334
+ return read_block(args, base_url, token)
335
+ if args.op == "read-children":
336
+ return read_children(args, base_url, token)
337
+ if args.op == "update":
338
+ return update_block(args, base_url, token)
339
+ if args.op == "delete":
340
+ return delete_block(args, base_url, token)
341
+ if args.op == "search":
342
+ return search_blocks(args, base_url, token)
343
+ raise WolaiError(f"unsupported op: {args.op}", code="validation")
344
+
345
+
346
+ def main() -> int:
347
+ args = parse_args()
348
+ try:
349
+ data = run(args)
350
+ out = {"ok": True, "op": args.op, "data": data, "error": None}
351
+ except WolaiError as exc:
352
+ out = {
353
+ "ok": False,
354
+ "op": args.op,
355
+ "data": None,
356
+ "error": {"code": exc.code, "message": str(exc), "detail": exc.detail},
357
+ }
358
+ json.dump(out, sys.stdout, ensure_ascii=False, indent=2)
359
+ sys.stdout.write("\n")
360
+ return 0
361
+
362
+
363
+ if __name__ == "__main__":
364
+ raise SystemExit(main())
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env python3
2
+ """Cross-platform entrypoint for meeting minutes flow."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import subprocess
9
+ import sys
10
+ from pathlib import Path
11
+ from urllib.parse import urlparse
12
+
13
+ from _resolve_lark_cli import resolve_lark_cli_prefix
14
+
15
+
16
+ def parse_args() -> argparse.Namespace:
17
+ parser = argparse.ArgumentParser(
18
+ description="Run meeting-minutes flow from Feishu minutes URL or token."
19
+ )
20
+ parser.add_argument("minutes_url_or_token", help="minutes URL or minute_token")
21
+ parser.add_argument(
22
+ "--output-dir",
23
+ default=str(Path.home() / ".openclaw" / "workspace" / ".meeting-minutes"),
24
+ help="artifact output dir",
25
+ )
26
+ parser.add_argument("--job-id", help="optional custom job id")
27
+ return parser.parse_args()
28
+
29
+
30
+ def extract_token(raw: str) -> str:
31
+ if raw.startswith("http://") or raw.startswith("https://"):
32
+ parsed = urlparse(raw)
33
+ token = parsed.path.rstrip("/").split("/")[-1]
34
+ return token
35
+ return raw.strip()
36
+
37
+
38
+ def main() -> int:
39
+ args = parse_args()
40
+ token = extract_token(args.minutes_url_or_token)
41
+ if not token:
42
+ print(
43
+ json.dumps(
44
+ {"status": "failed", "error_message": "missing minute_token"},
45
+ ensure_ascii=False,
46
+ )
47
+ )
48
+ return 1
49
+
50
+ script_dir = Path(__file__).resolve().parent
51
+ base_dir = script_dir.parent
52
+ cli_prefix = resolve_lark_cli_prefix(base_dir)
53
+ runner = script_dir / "openclaw_meeting_minutes.py"
54
+ job_id = args.job_id or f"meeting-minutes-{token}"
55
+
56
+ proc = subprocess.run(
57
+ [
58
+ sys.executable,
59
+ str(runner),
60
+ *sum([["--lark-cli-prefix", p] for p in cli_prefix], []),
61
+ "--minute-token",
62
+ token,
63
+ "--job-id",
64
+ job_id,
65
+ "--output-dir",
66
+ args.output_dir,
67
+ ],
68
+ capture_output=True,
69
+ text=True,
70
+ )
71
+ if proc.stdout:
72
+ sys.stdout.write(proc.stdout)
73
+ if proc.stderr:
74
+ sys.stderr.write(proc.stderr)
75
+ return proc.returncode
76
+
77
+
78
+ if __name__ == "__main__":
79
+ raise SystemExit(main())
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env python3
2
+ """Cross-platform entrypoint for note CRUD flow."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from _resolve_lark_cli import resolve_lark_cli_prefix
11
+
12
+
13
+ def main() -> int:
14
+ script_dir = Path(__file__).resolve().parent
15
+ base_dir = script_dir.parent
16
+ cli_prefix = resolve_lark_cli_prefix(base_dir)
17
+ runner = script_dir / "openclaw_notes_crud.py"
18
+
19
+ proc = subprocess.run(
20
+ [
21
+ sys.executable,
22
+ str(runner),
23
+ *sum([["--lark-cli-prefix", p] for p in cli_prefix], []),
24
+ *sys.argv[1:],
25
+ ],
26
+ capture_output=True,
27
+ text=True,
28
+ )
29
+ if proc.stdout:
30
+ sys.stdout.write(proc.stdout)
31
+ if proc.stderr:
32
+ sys.stderr.write(proc.stderr)
33
+ return proc.returncode
34
+
35
+
36
+ if __name__ == "__main__":
37
+ raise SystemExit(main())
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env python3
2
+ """Cross-platform local wrapper for vibe-notionbot."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+
10
+
11
+ def resolve_prefix() -> list[str]:
12
+ npx = shutil.which("npx")
13
+ if npx:
14
+ return [npx, "-y", "-p", "vibe-notion", "vibe-notionbot"]
15
+
16
+ local_bin = shutil.which("vibe-notionbot")
17
+ if local_bin:
18
+ return [local_bin]
19
+
20
+ raise FileNotFoundError(
21
+ "missing notion runner: npx or vibe-notionbot not found on PATH."
22
+ )
23
+
24
+
25
+ def main() -> int:
26
+ prefix = resolve_prefix()
27
+ proc = subprocess.run(prefix + sys.argv[1:], capture_output=True, text=True)
28
+ if proc.stdout:
29
+ sys.stdout.write(proc.stdout)
30
+ if proc.stderr:
31
+ sys.stderr.write(proc.stderr)
32
+ return proc.returncode
33
+
34
+
35
+ if __name__ == "__main__":
36
+ raise SystemExit(main())
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env python3
2
+ """Cross-platform entrypoint for Wolai OpenAPI note CRUD flow."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+
10
+
11
+ def main() -> int:
12
+ script_dir = Path(__file__).resolve().parent
13
+ runner = script_dir / "openclaw_wolai_notes_crud.py"
14
+ proc = subprocess.run(
15
+ [sys.executable, str(runner), *sys.argv[1:]],
16
+ capture_output=True,
17
+ text=True,
18
+ )
19
+ if proc.stdout:
20
+ sys.stdout.write(proc.stdout)
21
+ if proc.stderr:
22
+ sys.stderr.write(proc.stderr)
23
+ return proc.returncode
24
+
25
+
26
+ if __name__ == "__main__":
27
+ raise SystemExit(main())