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.
- api_wecom_cli-0.1.0a1.dist-info/METADATA +7 -0
- api_wecom_cli-0.1.0a1.dist-info/RECORD +19 -0
- api_wecom_cli-0.1.0a1.dist-info/WHEEL +5 -0
- api_wecom_cli-0.1.0a1.dist-info/entry_points.txt +2 -0
- api_wecom_cli-0.1.0a1.dist-info/licenses/LICENSE +21 -0
- api_wecom_cli-0.1.0a1.dist-info/top_level.txt +4 -0
- apis/__init__.py +3 -0
- apis/generated_client.py +4037 -0
- cli/__init__.py +1 -0
- cli/generated_commands.py +11167 -0
- cli/help_formatter.py +217 -0
- cli/main.py +145 -0
- core/__init__.py +7 -0
- core/auth.py +45 -0
- core/config.py +53 -0
- core/errors.py +29 -0
- core/requester.py +193 -0
- models/__init__.py +3 -0
- models/common.py +10 -0
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
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
|