api-wecom-cli 0.1.0a1__py3-none-any.whl

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.
cli/help_formatter.py ADDED
@@ -0,0 +1,217 @@
1
+ """Custom argparse help formatter for WeCom CLI — grouped, readable output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ # Domains grouped by function, ordered by usage frequency.
9
+ DOMAIN_GROUPS: dict[str, dict[str, str]] = {
10
+ "通讯录": {
11
+ "contacts": "成员、部门、标签",
12
+ "departments": "部门管理",
13
+ "users": "用户管理",
14
+ "tags": "标签管理",
15
+ "idconvert": "ID 转换",
16
+ },
17
+ "消息": {
18
+ "messages": "文本、卡片、文件推送",
19
+ },
20
+ "OA / 协同": {
21
+ "oa": "OA 数据接口",
22
+ "checkin": "打卡管理",
23
+ "meeting": "会议管理",
24
+ "living": "直播管理",
25
+ "appchat": "应用群聊管理",
26
+ "wedoc": "企业文档",
27
+ "wedrive": "企业微盘",
28
+ },
29
+ "客户 / 外部": {
30
+ "customers": "外部联系人管理",
31
+ "kf": "客服管理",
32
+ "externalpay": "外部支付",
33
+ },
34
+ "安全 / 审计": {
35
+ "security": "安全管理",
36
+ "msgaudit": "会话审计",
37
+ "chatdata": "会话内容存档",
38
+ },
39
+ "企业": {
40
+ "corp": "企业信息",
41
+ "corpgroup": "企业互联",
42
+ "batch": "异步任务",
43
+ "export": "数据导出",
44
+ },
45
+ "应用": {
46
+ "auth": "授权验证",
47
+ "miniprogram": "小程序管理",
48
+ "miniapppay": "小程序支付",
49
+ "advanced_feature": "高级功能账号管理",
50
+ },
51
+ "其他": {
52
+ "exmail": "企业邮箱",
53
+ "dial": "公费电话",
54
+ "pstncc": "企业专线电话",
55
+ "hardware": "硬件管理",
56
+ "health": "健康上报",
57
+ "hr": "人事管理",
58
+ "school": "家校沟通",
59
+ "ticket": "电子发票",
60
+ "network": "网络管理",
61
+ "unknown": "未知域",
62
+ },
63
+ }
64
+
65
+ # Flat lookup: domain -> description
66
+ DOMAIN_DESCRIPTIONS: dict[str, str] = {
67
+ domain: desc for group in DOMAIN_GROUPS.values() for domain, desc in group.items()
68
+ }
69
+
70
+ # ANSI helpers (no-op when not a tty)
71
+ _BOLD = "\033[1m" if sys.stderr.isatty() else ""
72
+ _DIM = "\033[2m" if sys.stderr.isatty() else ""
73
+ _CYAN = "\033[36m" if sys.stderr.isatty() else ""
74
+ _RESET = "\033[0m" if sys.stderr.isatty() else ""
75
+
76
+
77
+ def _pad(text: str, width: int) -> str:
78
+ return text.ljust(width)
79
+
80
+
81
+ class WeComHelpFormatter(argparse.HelpFormatter):
82
+ """Produces grouped, readable help output for wecom CLI.
83
+
84
+ Stores a back-reference to the owning parser via bind().
85
+ When format_help is called without a bound parser (e.g. during
86
+ add_subparsers prog computation), falls back to default formatting.
87
+ """
88
+
89
+ _bound_parser: argparse.ArgumentParser | None = None
90
+
91
+ def bind(self, parser: argparse.ArgumentParser) -> None:
92
+ self._bound_parser = parser
93
+
94
+ def format_help(self) -> str:
95
+ parser = self._bound_parser
96
+ if parser is None:
97
+ return super().format_help()
98
+
99
+ # Find subparsers action
100
+ sp_action = None
101
+ for action in parser._actions:
102
+ if isinstance(action, argparse._SubParsersAction):
103
+ sp_action = action
104
+ break
105
+
106
+ if sp_action is None:
107
+ return super().format_help()
108
+
109
+ # Root parser has prog="wecom", domain parser has prog="wecom <domain>"
110
+ is_root = " " not in parser.prog
111
+
112
+ if is_root:
113
+ return self._format_root_help(sp_action)
114
+
115
+ # Domain level — check if sub-choices have subparsers (real actions)
116
+ if sp_action.choices:
117
+ first_choice = next(iter(sp_action.choices.values()))
118
+ has_sub_subparsers = any(isinstance(a, argparse._SubParsersAction) for a in first_choice._actions)
119
+ if has_sub_subparsers:
120
+ # This is a domain parser whose sub-choices are action parsers with their own subparsers
121
+ # Shouldn't happen normally but handle gracefully
122
+ return self._format_domain_help(sp_action)
123
+ return self._format_domain_help(sp_action)
124
+
125
+ return super().format_help()
126
+
127
+ def _format_root_help(self, sp_action: argparse._SubParsersAction) -> str:
128
+ lines: list[str] = []
129
+
130
+ lines.append(f"{_BOLD}Usage:{_RESET} wecom {_DIM}[options]{_RESET} <domain> <action> [action-flags]")
131
+ lines.append("")
132
+ lines.append("企业微信命令行工具 — 通过命令行调用企业微信 API")
133
+ lines.append("")
134
+
135
+ lines.append(f"{_CYAN}{_BOLD}常用命令:{_RESET}")
136
+ lines.append(" wecom contacts list --department-id 1 查看部门成员")
137
+ lines.append(" wecom departments list 查看部门列表")
138
+ lines.append(" wecom messages send-text --to-user ... 发送文本消息")
139
+ lines.append("")
140
+
141
+ max_name_len = max(len(d) for d in DOMAIN_DESCRIPTIONS)
142
+ for group_name, domains in DOMAIN_GROUPS.items():
143
+ lines.append(f"{_CYAN}{_BOLD}{group_name}:{_RESET}")
144
+ for domain, desc in domains.items():
145
+ lines.append(f" {_pad(domain, max_name_len)} {desc}")
146
+ lines.append("")
147
+
148
+ lines.append(f"用 {_CYAN}wecom <domain> --help{_RESET} 查看域内命令")
149
+ lines.append("")
150
+ lines.append(f"{_CYAN}{_BOLD}Options:{_RESET}")
151
+ lines.append(" -h, --help 显示帮助")
152
+ lines.append(" --verbose 打印请求 URL 到 stderr")
153
+ lines.append(" --debug 打印完整请求/响应 JSON 到 stderr")
154
+ lines.append("")
155
+
156
+ return "\n".join(lines)
157
+
158
+ def _format_domain_help(self, sp_action: argparse._SubParsersAction) -> str:
159
+ lines: list[str] = []
160
+ prog = self._prog
161
+ domain = prog.split()[-1] if " " in prog else prog
162
+
163
+ domain_desc = DOMAIN_DESCRIPTIONS.get(domain, "")
164
+ desc_suffix = f" — {domain_desc}" if domain_desc else ""
165
+
166
+ lines.append(f"{_BOLD}Usage:{_RESET} wecom {_DIM}[options]{_RESET} {domain} <action> [action-flags]")
167
+ lines.append("")
168
+ lines.append(f"{domain}{desc_suffix}")
169
+ lines.append("")
170
+
171
+ lines.append(f"{_CYAN}{_BOLD}命令:{_RESET}")
172
+ # Get help text from _SubParsersAction._choices_actions, not from sub-parser internals
173
+ choices_help: dict[str, str] = {}
174
+ for choice_act in sp_action._choices_actions:
175
+ choices_help[choice_act.dest] = choice_act.help or ""
176
+
177
+ actions: list[tuple[str, str]] = []
178
+ for name in sp_action.choices:
179
+ actions.append((name, choices_help.get(name, "")))
180
+
181
+ if actions:
182
+ max_name_len = max(len(a[0]) for a in actions)
183
+ for name, help_text in actions:
184
+ lines.append(f" {_pad(name, max_name_len)} {help_text}")
185
+
186
+ lines.append("")
187
+ lines.append(f"用 {_CYAN}wecom {domain} <action> --help{_RESET} 查看参数详情")
188
+ lines.append("")
189
+
190
+ return "\n".join(lines)
191
+
192
+
193
+ def install_formatter(parser: argparse.ArgumentParser) -> None:
194
+ """Attach WeComHelpFormatter to parser and all sub-parsers."""
195
+ parser.formatter_class = WeComHelpFormatter # type: ignore[assignment]
196
+
197
+ def _make_fmt(p: argparse.ArgumentParser = parser) -> WeComHelpFormatter:
198
+ fmt = WeComHelpFormatter(p.prog)
199
+ fmt.bind(p)
200
+ return fmt
201
+
202
+ parser._get_formatter = _make_fmt # type: ignore[assignment]
203
+
204
+ for action in parser._actions:
205
+ if isinstance(action, argparse._SubParsersAction):
206
+ for sub_parser in action.choices.values():
207
+ sub_parser.formatter_class = WeComHelpFormatter # type: ignore[assignment]
208
+ _install_sub(sub_parser)
209
+
210
+
211
+ def _install_sub(parser: argparse.ArgumentParser) -> None:
212
+ def _make_fmt(p: argparse.ArgumentParser = parser) -> WeComHelpFormatter:
213
+ fmt = WeComHelpFormatter(p.prog)
214
+ fmt.bind(p)
215
+ return fmt
216
+
217
+ parser._get_formatter = _make_fmt # type: ignore[assignment]
cli/main.py ADDED
@@ -0,0 +1,145 @@
1
+ """Command-line interface for WeCom CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import sys
9
+
10
+ from apis.generated_client import GeneratedWeComClient
11
+ from cli.generated_commands import CommandHandler, register_generated_commands
12
+ from cli.help_formatter import DOMAIN_DESCRIPTIONS, WeComHelpFormatter, install_formatter
13
+ from core.auth import AccessTokenProvider
14
+ from core.config import WeComConfig
15
+ from core.errors import WeComCLIError
16
+ from core.requester import UnifiedRequester
17
+
18
+
19
+ def register_domain_help(
20
+ subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
21
+ ) -> None:
22
+ for name, desc in DOMAIN_DESCRIPTIONS.items():
23
+ subparsers.add_parser(name, help=desc, formatter_class=WeComHelpFormatter)
24
+
25
+
26
+ class _HelpOnlyClient:
27
+ """Stub client for --help mode. Handler closures capture this but are never called."""
28
+
29
+ def __getattr__(self, _name: str) -> object:
30
+ raise RuntimeError("Help-only client method called unexpectedly")
31
+
32
+
33
+ def bootstrap(*, verbose: bool = False, debug: bool = False) -> GeneratedWeComClient:
34
+ config = WeComConfig.load()
35
+ requester = UnifiedRequester(config)
36
+ requester.set_verbose(verbose, debug)
37
+ token_provider = AccessTokenProvider(requester, config)
38
+ requester.bind_token_provider(token_provider)
39
+ return GeneratedWeComClient(requester)
40
+
41
+
42
+ def build_parser() -> argparse.ArgumentParser:
43
+ parser = argparse.ArgumentParser(
44
+ prog="wecom",
45
+ formatter_class=WeComHelpFormatter,
46
+ add_help=False,
47
+ description="企业微信命令行工具 — 通过命令行调用企业微信API",
48
+ )
49
+ # Bind formatter immediately so add_subparsers() doesn't crash
50
+ parser._get_formatter = lambda: WeComHelpFormatter(parser.prog) # type: ignore[assignment]
51
+ parser.add_argument(
52
+ "-h",
53
+ "--help",
54
+ action="store_true",
55
+ default=False,
56
+ dest="wecom_help",
57
+ )
58
+ parser.add_argument(
59
+ "--verbose", action="store_true", default=bool(os.getenv("WECOM_VERBOSE")), help="Print request URLs to stderr"
60
+ )
61
+ parser.add_argument(
62
+ "--debug",
63
+ action="store_true",
64
+ default=bool(os.getenv("WECOM_DEBUG")),
65
+ help="Print full request/response JSON to stderr",
66
+ )
67
+ return parser
68
+
69
+
70
+ def route(args: argparse.Namespace, command_table: dict[tuple[str, str], CommandHandler]) -> dict:
71
+ action = str(getattr(args, "__action", None) or getattr(args, "action", ""))
72
+ key = (args.domain, action)
73
+ if key not in command_table:
74
+ raise WeComCLIError(f"Unknown command: {args.domain} {action}")
75
+ return command_table[key](args)
76
+
77
+
78
+ def main(argv: list[str] | None = None) -> int:
79
+ effective_argv = argv if argv is not None else sys.argv[1:]
80
+ parser = build_parser()
81
+
82
+ # No args → root help with domain list
83
+ if not effective_argv:
84
+ subparsers = parser.add_subparsers(dest="domain")
85
+ register_domain_help(subparsers)
86
+ install_formatter(parser)
87
+ parser.print_help()
88
+ return 0
89
+
90
+ has_help = "-h" in effective_argv or "--help" in effective_argv
91
+
92
+ if has_help:
93
+ positional = [a for a in effective_argv if not a.startswith("-")]
94
+ subparsers = parser.add_subparsers(dest="domain")
95
+ if not positional:
96
+ # Root help → lightweight domain list only
97
+ register_domain_help(subparsers)
98
+ else:
99
+ # Domain/action help → full registration with stub client
100
+ register_generated_commands(subparsers, _HelpOnlyClient()) # type: ignore[arg-type]
101
+ install_formatter(parser)
102
+ parser.parse_args(effective_argv)
103
+ # print_help was triggered by our custom flag or argparse
104
+ # For domain-level, the sub-parser's format_help handles it
105
+ # For root level, we need to explicitly print
106
+ if not positional:
107
+ parser.print_help()
108
+ else:
109
+ # argparse handles sub-parser help via SystemExit
110
+ try:
111
+ parser.parse_args(effective_argv)
112
+ except SystemExit:
113
+ pass
114
+ return 0
115
+
116
+ # Normal execution path
117
+ args, _ = parser.parse_known_args(effective_argv)
118
+ remaining = [a for a in effective_argv if a not in {"--verbose", "--debug"}]
119
+ if not remaining:
120
+ subparsers = parser.add_subparsers(dest="domain")
121
+ register_domain_help(subparsers)
122
+ install_formatter(parser)
123
+ parser.print_help()
124
+ return 0
125
+
126
+ try:
127
+ client = bootstrap(verbose=args.verbose, debug=args.debug)
128
+ subparsers = parser.add_subparsers(dest="domain", required=True)
129
+ command_table = register_generated_commands(subparsers, client)
130
+ install_formatter(parser)
131
+ args = parser.parse_args(effective_argv)
132
+ payload = route(args, command_table)
133
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
134
+ return 0
135
+ except WeComCLIError as exc:
136
+ print(f"[wecom-cli] {exc}", file=sys.stderr)
137
+ return 2
138
+ except Exception as exc:
139
+ print(f"[wecom-cli] Unexpected error: {exc}", file=sys.stderr)
140
+ print("[wecom-cli] Re-run with --debug for details.", file=sys.stderr)
141
+ return 1
142
+
143
+
144
+ if __name__ == "__main__":
145
+ raise SystemExit(main())
core/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Core infrastructure for WeCom CLI."""
2
+
3
+ from core.auth import AccessTokenProvider
4
+ from core.config import WeComConfig
5
+ from core.requester import UnifiedRequester
6
+
7
+ __all__ = ["AccessTokenProvider", "UnifiedRequester", "WeComConfig"]
core/auth.py ADDED
@@ -0,0 +1,45 @@
1
+ """WeCom access token acquisition and caching."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from dataclasses import dataclass
7
+
8
+ from core.config import WeComConfig
9
+ from core.errors import APIRequestError, APIResponseError, AuthError
10
+ from core.requester import UnifiedRequester
11
+
12
+
13
+ @dataclass
14
+ class TokenBundle:
15
+ access_token: str
16
+ expires_at_epoch: float
17
+
18
+
19
+ class AccessTokenProvider:
20
+ def __init__(self, requester: UnifiedRequester, config: WeComConfig) -> None:
21
+ self._requester = requester
22
+ self._config = config
23
+ self._bundle: TokenBundle | None = None
24
+
25
+ def get_token(self, force_refresh: bool = False) -> str:
26
+ if not force_refresh and self._bundle and self._bundle.expires_at_epoch > time.time() + 30:
27
+ return self._bundle.access_token
28
+
29
+ try:
30
+ payload = self._requester.request(
31
+ method="GET",
32
+ endpoint="/cgi-bin/gettoken",
33
+ query={"corpid": self._config.corp_id, "corpsecret": self._config.corp_secret},
34
+ require_auth=False,
35
+ )
36
+ except (APIRequestError, APIResponseError) as exc:
37
+ raise AuthError(str(exc)) from exc
38
+
39
+ token = str(payload.get("access_token") or "")
40
+ expires_in = int(payload.get("expires_in") or 0)
41
+ if not token or expires_in <= 0:
42
+ raise AuthError("Token response missing access_token/expires_in")
43
+
44
+ self._bundle = TokenBundle(access_token=token, expires_at_epoch=time.time() + expires_in)
45
+ return token
core/config.py ADDED
@@ -0,0 +1,53 @@
1
+ """Configuration loading for WeCom CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ from core.errors import ConfigError
11
+
12
+ DEFAULT_CONFIG_PATH = Path.home() / ".wecom-cli" / "config.json"
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class WeComConfig:
17
+ corp_id: str
18
+ corp_secret: str
19
+ base_url: str = "https://qyapi.weixin.qq.com"
20
+ timeout_seconds: float = 10.0
21
+
22
+ @classmethod
23
+ def load(cls, config_path: Path | None = None) -> WeComConfig:
24
+ path = config_path or DEFAULT_CONFIG_PATH
25
+ raw_data: dict[str, object] = {}
26
+
27
+ if path.exists():
28
+ try:
29
+ raw_data = json.loads(path.read_text(encoding="utf-8"))
30
+ except (OSError, json.JSONDecodeError) as exc:
31
+ raise ConfigError(f"Failed to read config from {path}: {exc}") from exc
32
+
33
+ corp_id = str(os.getenv("WECOM_CORP_ID") or raw_data.get("corp_id") or "")
34
+ corp_secret = str(os.getenv("WECOM_CORP_SECRET") or raw_data.get("corp_secret") or "")
35
+ base_url = str(os.getenv("WECOM_BASE_URL") or raw_data.get("base_url") or cls.base_url)
36
+ timeout_raw = os.getenv("WECOM_TIMEOUT_SECONDS") or raw_data.get("timeout_seconds")
37
+
38
+ if not corp_id or not corp_secret:
39
+ raise ConfigError(f"corp_id/corp_secret are required (env vars or config file at {path})")
40
+
41
+ timeout_seconds = cls.timeout_seconds
42
+ if timeout_raw is not None:
43
+ try:
44
+ timeout_seconds = float(str(timeout_raw))
45
+ except (TypeError, ValueError) as exc:
46
+ raise ConfigError("timeout_seconds should be a valid number") from exc
47
+
48
+ return cls(
49
+ corp_id=corp_id,
50
+ corp_secret=corp_secret,
51
+ base_url=base_url.rstrip("/"),
52
+ timeout_seconds=timeout_seconds,
53
+ )
core/errors.py ADDED
@@ -0,0 +1,29 @@
1
+ """Unified error hierarchy for WeCom CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class WeComCLIError(Exception):
7
+ """Base class for predictable CLI failures."""
8
+
9
+
10
+ class ConfigError(WeComCLIError):
11
+ """Raised when loading configuration fails."""
12
+
13
+
14
+ class AuthError(WeComCLIError):
15
+ """Raised when access token acquisition fails."""
16
+
17
+
18
+ class APIRequestError(WeComCLIError):
19
+ """Raised when a transport-level request error occurs."""
20
+
21
+
22
+ class APIResponseError(WeComCLIError):
23
+ """Raised when WeCom API returns a non-success errcode."""
24
+
25
+ def __init__(self, *, endpoint: str, errcode: int, errmsg: str) -> None:
26
+ self.endpoint = endpoint
27
+ self.errcode = errcode
28
+ self.errmsg = errmsg
29
+ super().__init__(f"{endpoint}: errcode={errcode}, errmsg={errmsg}")
core/requester.py ADDED
@@ -0,0 +1,193 @@
1
+ """Unified requester for WeCom API calls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ import urllib.error
8
+ import urllib.parse
9
+ import urllib.request
10
+ from typing import Any, Protocol
11
+
12
+ from core.config import WeComConfig
13
+ from core.errors import APIRequestError, APIResponseError
14
+
15
+ _TOKEN_EXPIRED_CODES = frozenset({40014, 42001})
16
+ _RATE_LIMIT_CODES = frozenset({45001, 45009})
17
+ _MAX_RETRIES = 2
18
+
19
+
20
+ class TokenProvider(Protocol):
21
+ def get_token(self, force_refresh: bool = False) -> str: ...
22
+
23
+
24
+ class UnifiedRequester:
25
+ def __init__(self, config: WeComConfig, token_provider: TokenProvider | None = None) -> None:
26
+ self._config = config
27
+ self._token_provider = token_provider
28
+ self._verbose = False
29
+ self._debug = False
30
+
31
+ def bind_token_provider(self, token_provider: TokenProvider) -> None:
32
+ self._token_provider = token_provider
33
+
34
+ def set_verbose(self, verbose: bool, debug: bool = False) -> None:
35
+ self._verbose = verbose or debug
36
+ self._debug = debug
37
+
38
+ def request(
39
+ self,
40
+ *,
41
+ method: str,
42
+ endpoint: str,
43
+ query: dict[str, Any] | None = None,
44
+ json_body: dict[str, Any] | None = None,
45
+ require_auth: bool = True,
46
+ _retrying: bool = False,
47
+ ) -> dict[str, Any]:
48
+ return self._request_with_retry(
49
+ method=method,
50
+ endpoint=endpoint,
51
+ query=query,
52
+ json_body=json_body,
53
+ require_auth=require_auth,
54
+ _retrying=_retrying,
55
+ _attempt=0,
56
+ )
57
+
58
+ def _request_with_retry(
59
+ self,
60
+ *,
61
+ method: str,
62
+ endpoint: str,
63
+ query: dict[str, Any] | None,
64
+ json_body: dict[str, Any] | None,
65
+ require_auth: bool,
66
+ _retrying: bool,
67
+ _attempt: int,
68
+ ) -> dict[str, Any]:
69
+ try:
70
+ return self._do_request(
71
+ method=method,
72
+ endpoint=endpoint,
73
+ query=query,
74
+ json_body=json_body,
75
+ require_auth=require_auth,
76
+ )
77
+ except APIResponseError as exc:
78
+ if _retrying or _attempt >= _MAX_RETRIES:
79
+ raise
80
+ if exc.errcode in _TOKEN_EXPIRED_CODES and require_auth and self._token_provider:
81
+ self._log_verbose(f"[retry {exc.errcode}] refreshing token")
82
+ self._token_provider.get_token(force_refresh=True)
83
+ return self._request_with_retry(
84
+ method=method,
85
+ endpoint=endpoint,
86
+ query=query,
87
+ json_body=json_body,
88
+ require_auth=require_auth,
89
+ _retrying=True,
90
+ _attempt=_attempt + 1,
91
+ )
92
+ if exc.errcode in _RATE_LIMIT_CODES:
93
+ wait = 0.5 * (2**_attempt)
94
+ self._log_verbose(f"[retry {exc.errcode}] backing off {wait:.1f}s")
95
+ time.sleep(wait)
96
+ return self._request_with_retry(
97
+ method=method,
98
+ endpoint=endpoint,
99
+ query=query,
100
+ json_body=json_body,
101
+ require_auth=require_auth,
102
+ _retrying=_retrying,
103
+ _attempt=_attempt + 1,
104
+ )
105
+ raise
106
+
107
+ def _do_request(
108
+ self,
109
+ *,
110
+ method: str,
111
+ endpoint: str,
112
+ query: dict[str, Any] | None,
113
+ json_body: dict[str, Any] | None,
114
+ require_auth: bool,
115
+ ) -> dict[str, Any]:
116
+ query_data: dict[str, Any] = dict(query or {})
117
+ if require_auth:
118
+ if self._token_provider is None:
119
+ raise APIRequestError("Token provider is not configured")
120
+ query_data["access_token"] = self._token_provider.get_token()
121
+
122
+ query_text = urllib.parse.urlencode(query_data)
123
+ url = f"{self._config.base_url}{endpoint}"
124
+ if query_text:
125
+ url = f"{url}?{query_text}"
126
+
127
+ body_data = None
128
+ headers: dict[str, str] = {"Accept": "application/json"}
129
+ if json_body is not None:
130
+ body_data = json.dumps(_strip_none(json_body)).encode("utf-8")
131
+ headers["Content-Type"] = "application/json"
132
+
133
+ if self._verbose:
134
+ import sys
135
+
136
+ display_url = url
137
+ if "access_token=" in url:
138
+ parsed = urllib.parse.urlparse(url)
139
+ qs = urllib.parse.parse_qs(parsed.query)
140
+ if "access_token" in qs:
141
+ qs["access_token"] = ["***MASKED***"]
142
+ display_url = parsed._replace(query=urllib.parse.urlencode(qs, doseq=True)).geturl()
143
+ print(f"[wecom-cli] {method} {display_url}", file=sys.stderr)
144
+ if self._debug:
145
+ import sys
146
+
147
+ if json_body:
148
+ print(f"[wecom-cli] body: {json.dumps(_strip_none(json_body), ensure_ascii=False)}", file=sys.stderr)
149
+
150
+ request = urllib.request.Request(
151
+ url=url,
152
+ data=body_data,
153
+ headers=headers,
154
+ method=method.upper(),
155
+ )
156
+ try:
157
+ with urllib.request.urlopen(request, timeout=self._config.timeout_seconds) as response:
158
+ raw = response.read().decode("utf-8")
159
+ except urllib.error.URLError as exc:
160
+ raise APIRequestError(f"Request failed for {endpoint}: {exc}") from exc
161
+
162
+ if self._debug:
163
+ import sys
164
+
165
+ print(f"[wecom-cli] response: {raw[:500]}", file=sys.stderr)
166
+
167
+ try:
168
+ payload = json.loads(raw)
169
+ except json.JSONDecodeError as exc:
170
+ raise APIRequestError(f"Invalid JSON response for {endpoint}: {raw[:200]}") from exc
171
+
172
+ errcode = int(payload.get("errcode") or 0)
173
+ if errcode != 0:
174
+ raise APIResponseError(
175
+ endpoint=endpoint,
176
+ errcode=errcode,
177
+ errmsg=str(payload.get("errmsg") or "unknown"),
178
+ )
179
+ return payload
180
+
181
+ def _log_verbose(self, msg: str) -> None:
182
+ if self._verbose:
183
+ import sys
184
+
185
+ print(f"[wecom-cli] {msg}", file=sys.stderr)
186
+
187
+
188
+ def _strip_none(value: Any) -> Any:
189
+ if isinstance(value, dict):
190
+ return {key: _strip_none(item) for key, item in value.items() if item is not None}
191
+ if isinstance(value, list):
192
+ return [_strip_none(item) for item in value if item is not None]
193
+ return value