ChatNet 0.1.0__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.
chatnet/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """ChatArch network and campus-network CLI."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
chatnet/cli.py ADDED
@@ -0,0 +1,58 @@
1
+ """CLI entrypoint for chatnet."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+ from chatstyle import (
7
+ CommandField,
8
+ CommandSchema,
9
+ add_interactive_option,
10
+ render_success,
11
+ resolve_command_inputs,
12
+ )
13
+
14
+
15
+ HELLO_SCHEMA = CommandSchema(
16
+ name="hello",
17
+ fields=(CommandField("name", prompt="name", required=True),),
18
+ )
19
+
20
+
21
+ class ChatNetGroup(click.Group):
22
+ """Top-level group that loads heavier feature groups on demand."""
23
+
24
+ def list_commands(self, ctx: click.Context) -> list[str]:
25
+ commands = set(super().list_commands(ctx))
26
+ commands.add("ecnu")
27
+ return sorted(commands)
28
+
29
+ def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
30
+ if cmd_name == "ecnu":
31
+ from chatnet.ecnu.cli import cli as ecnu_cli
32
+
33
+ return ecnu_cli
34
+ return super().get_command(ctx, cmd_name)
35
+
36
+
37
+ @click.group(cls=ChatNetGroup)
38
+ def main() -> None:
39
+ """chatnet command line interface."""
40
+
41
+
42
+ @main.command()
43
+ @click.argument("name", required=False)
44
+ @add_interactive_option
45
+ def hello(name: str | None, interactive: bool | None) -> None:
46
+ """Print a greeting with ChatStyle-backed input resolution."""
47
+
48
+ values = resolve_command_inputs(
49
+ schema=HELLO_SCHEMA,
50
+ provided={"name": name},
51
+ interactive=interactive,
52
+ usage="Usage: chatnet hello [NAME]",
53
+ )
54
+ render_success(f"Hello, {values['name']}!")
55
+
56
+
57
+ if __name__ == "__main__":
58
+ main()
chatnet/config.py ADDED
@@ -0,0 +1,21 @@
1
+ """ChatEnv configuration schemas provided by ChatNet."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from chatenv.fields import BaseEnvConfig, EnvField
6
+
7
+
8
+ class ECNUConfig(BaseEnvConfig):
9
+ """ECNU self-service portal environment variables."""
10
+
11
+ _title = "ECNU"
12
+ _aliases = ["ecnu", "chatnet-ecnu"]
13
+ _storage_dir = "ECNU"
14
+
15
+ ECNU_USERNAME = EnvField("ECNU_USERNAME", desc="ECNU username.")
16
+ ECNU_PASSWORD = EnvField("ECNU_PASSWORD", desc="ECNU password.", is_sensitive=True)
17
+ ECNU_COOKIE = EnvField("ECNU_COOKIE", desc="Authenticated ECNU portal Cookie header.", is_sensitive=True)
18
+ ECNU_BASE_URL = EnvField("ECNU_BASE_URL", default="https://login.ecnu.edu.cn:8800", desc="ECNU portal base URL.")
19
+
20
+
21
+ __all__ = ["ECNUConfig"]
@@ -0,0 +1,5 @@
1
+ """ECNU self-service portal integration for ChatNet."""
2
+
3
+ from .portal import BASE_URL, PortalClient
4
+
5
+ __all__ = ["BASE_URL", "PortalClient"]
@@ -0,0 +1,183 @@
1
+ """Optional CAPTCHA recognition helpers for ECNU login automation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ from collections import defaultdict
7
+ from typing import Any
8
+
9
+
10
+ def _load_ocr_deps() -> tuple[Any, Any, Any]:
11
+ try:
12
+ import ddddocr
13
+ import numpy as np
14
+ from PIL import Image
15
+ except ImportError as exc:
16
+ raise RuntimeError(
17
+ 'CAPTCHA auto-login requires optional dependencies. Install them with: pip install "ChatNet[captcha]"'
18
+ ) from exc
19
+ return ddddocr, np, Image
20
+
21
+
22
+ def recognize_captcha_topk(image_bytes: bytes, topk: int = 5, expected_length: int = 4) -> list[str]:
23
+ ddddocr, _, _ = _load_ocr_deps()
24
+ ocr = ddddocr.DdddOcr(show_ad=False)
25
+ variants = build_variants(image_bytes)
26
+ variant_results = {
27
+ name: topk_for_variant(ocr, data, expected_length=expected_length)
28
+ for name, data in variants.items()
29
+ }
30
+ aggregate = aggregate_variant_candidates(variant_results, topk=topk)
31
+ return [row["text"] for row in aggregate]
32
+
33
+
34
+ def pil_to_bytes(image: Any) -> bytes:
35
+ buf = io.BytesIO()
36
+ image.save(buf, format="PNG")
37
+ return buf.getvalue()
38
+
39
+
40
+ def dilate(mask: Any, iterations: int = 1) -> Any:
41
+ _, np, _ = _load_ocr_deps()
42
+ out = mask.copy()
43
+ for _ in range(iterations):
44
+ padded = np.pad(out, 1, constant_values=False)
45
+ grown = np.zeros_like(out)
46
+ for dy in range(3):
47
+ for dx in range(3):
48
+ grown |= padded[dy : dy + out.shape[0], dx : dx + out.shape[1]]
49
+ out = grown
50
+ return out
51
+
52
+
53
+ def erode(mask: Any, iterations: int = 1) -> Any:
54
+ _, np, _ = _load_ocr_deps()
55
+ out = mask.copy()
56
+ for _ in range(iterations):
57
+ padded = np.pad(out, 1, constant_values=True)
58
+ shrunk = np.ones_like(out)
59
+ for dy in range(3):
60
+ for dx in range(3):
61
+ shrunk &= padded[dy : dy + out.shape[0], dx : dx + out.shape[1]]
62
+ out = shrunk
63
+ return out
64
+
65
+
66
+ def closing(mask: Any, iterations: int = 1) -> Any:
67
+ return erode(dilate(mask, iterations), iterations)
68
+
69
+
70
+ def mask_bbox(mask: Any) -> tuple[int, int, int, int] | None:
71
+ _, np, _ = _load_ocr_deps()
72
+ ys, xs = np.where(mask)
73
+ if len(xs) == 0:
74
+ return None
75
+ return int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max())
76
+
77
+
78
+ def build_variants(image_bytes: bytes) -> dict[str, bytes]:
79
+ _, np, Image = _load_ocr_deps()
80
+ image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
81
+ arr = np.array(image, dtype=np.uint8)
82
+ mean = arr.mean(axis=2)
83
+ spread = arr.max(axis=2) - arr.min(axis=2)
84
+ text_loose = (spread < 40) & (mean > 60)
85
+ text_tight = (spread < 30) & (mean > 95)
86
+ text_closed = closing(text_loose, iterations=1)
87
+ variants = {
88
+ "original": pil_to_bytes(image),
89
+ "text_loose": pil_to_bytes(Image.fromarray((text_loose.astype(np.uint8) * 255), mode="L")),
90
+ "text_tight": pil_to_bytes(Image.fromarray((text_tight.astype(np.uint8) * 255), mode="L")),
91
+ "text_closed": pil_to_bytes(Image.fromarray((text_closed.astype(np.uint8) * 255), mode="L")),
92
+ }
93
+ bbox = mask_bbox(text_closed)
94
+ if bbox is not None:
95
+ x1, y1, x2, y2 = bbox
96
+ crop = Image.fromarray((text_closed[y1 : y2 + 1, x1 : x2 + 1].astype(np.uint8) * 255), mode="L")
97
+ nearest = getattr(getattr(Image, "Resampling", Image), "NEAREST")
98
+ variants["text_closed_crop_x6"] = pil_to_bytes(
99
+ crop.resize((crop.width * 6, crop.height * 6), nearest)
100
+ )
101
+ return variants
102
+
103
+
104
+ def logsumexp_pair(a: float, b: float) -> float:
105
+ _, np, _ = _load_ocr_deps()
106
+ if a == -np.inf:
107
+ return b
108
+ if b == -np.inf:
109
+ return a
110
+ m = a if a > b else b
111
+ return float(m + np.log(np.exp(a - m) + np.exp(b - m)))
112
+
113
+
114
+ def ctc_prefix_beam_search(
115
+ probs: Any,
116
+ digit_indices: list[int],
117
+ index_to_digit: dict[int, str],
118
+ beam_size: int = 25,
119
+ topk: int = 5,
120
+ expected_length: int = 4,
121
+ blank_index: int = 0,
122
+ ) -> list[dict[str, Any]]:
123
+ _, np, _ = _load_ocr_deps()
124
+ beam: dict[str, tuple[float, float]] = {"": (0.0, -np.inf)}
125
+ for t in range(probs.shape[0]):
126
+ next_beam: dict[str, tuple[float, float]] = defaultdict(lambda: (-np.inf, -np.inf))
127
+ for prefix, (p_b, p_nb) in beam.items():
128
+ total = logsumexp_pair(p_b, p_nb)
129
+ nb = next_beam[prefix]
130
+ next_beam[prefix] = (
131
+ logsumexp_pair(nb[0], total + float(np.log(probs[t, blank_index] + 1e-12))),
132
+ nb[1],
133
+ )
134
+ if len(prefix) > expected_length:
135
+ continue
136
+ for idx in digit_indices:
137
+ digit = index_to_digit[idx]
138
+ p = float(np.log(probs[t, idx] + 1e-12))
139
+ last = prefix[-1] if prefix else None
140
+ if digit == last:
141
+ nb = next_beam[prefix]
142
+ next_beam[prefix] = (nb[0], logsumexp_pair(nb[1], p_nb + p))
143
+ new_prefix = prefix + digit
144
+ if len(new_prefix) <= expected_length:
145
+ nb2 = next_beam[new_prefix]
146
+ next_beam[new_prefix] = (nb2[0], logsumexp_pair(nb2[1], p_b + p))
147
+ else:
148
+ new_prefix = prefix + digit
149
+ if len(new_prefix) <= expected_length:
150
+ nb2 = next_beam[new_prefix]
151
+ next_beam[new_prefix] = (nb2[0], logsumexp_pair(nb2[1], total + p))
152
+ ranked = sorted(next_beam.items(), key=lambda kv: logsumexp_pair(kv[1][0], kv[1][1]), reverse=True)
153
+ beam = dict(ranked[:beam_size])
154
+ finals = []
155
+ for prefix, (p_b, p_nb) in beam.items():
156
+ if len(prefix) == expected_length:
157
+ finals.append({"text": prefix, "logprob": logsumexp_pair(p_b, p_nb)})
158
+ finals.sort(key=lambda row: row["logprob"], reverse=True)
159
+ return finals[:topk]
160
+
161
+
162
+ def topk_for_variant(ocr: Any, img: bytes, expected_length: int = 4) -> dict[str, Any]:
163
+ _, np, _ = _load_ocr_deps()
164
+ result = ocr.classification(img, probability=True)
165
+ probs = np.array(result["probabilities"], dtype=np.float64)
166
+ if probs.ndim == 3:
167
+ probs = probs[:, 0, :]
168
+ charset = result["charset"]
169
+ digit_indices = [i for i, ch in enumerate(charset) if ch.isdigit()]
170
+ index_to_digit = {i: charset[i] for i in digit_indices}
171
+ candidates = ctc_prefix_beam_search(probs, digit_indices, index_to_digit, expected_length=expected_length)
172
+ return {"text": result["text"], "confidence": result["confidence"], "candidates": candidates}
173
+
174
+
175
+ def aggregate_variant_candidates(variant_results: dict[str, dict[str, Any]], topk: int = 5) -> list[dict[str, Any]]:
176
+ scores: dict[str, float] = defaultdict(float)
177
+ support: dict[str, int] = defaultdict(int)
178
+ for result in variant_results.values():
179
+ for rank, row in enumerate(result["candidates"]):
180
+ scores[row["text"]] += row["logprob"]
181
+ support[row["text"]] += max(0, topk - rank)
182
+ ranked = sorted(scores, key=lambda text: (support[text], scores[text]), reverse=True)
183
+ return [{"text": text, "score": scores[text], "support": support[text]} for text in ranked[:topk]]
chatnet/ecnu/cli.py ADDED
@@ -0,0 +1,410 @@
1
+ """Click commands for ECNU self-service portal operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any, Callable
8
+
9
+ import click
10
+ from chatstyle import CommandField, CommandSchema, add_interactive_option, resolve_command_inputs
11
+ from chatenv.fields import BaseEnvConfig
12
+ from chatenv.paths import get_paths
13
+
14
+ from chatnet.config import ECNUConfig
15
+ from .portal import BASE_URL
16
+
17
+ LOGIN_SCHEMA = CommandSchema(
18
+ name="ecnu-login",
19
+ fields=(
20
+ CommandField("username", prompt="ECNU username", required=True),
21
+ CommandField("password", prompt="ECNU password", required=True, sensitive=True),
22
+ CommandField("captcha", prompt="captcha", required=True),
23
+ ),
24
+ )
25
+
26
+ LOGIN_AUTO_SCHEMA = CommandSchema(
27
+ name="ecnu-login-auto",
28
+ fields=(
29
+ CommandField("username", prompt="ECNU username", required=True),
30
+ CommandField("password", prompt="ECNU password", required=True, sensitive=True),
31
+ ),
32
+ )
33
+
34
+ VISITOR_CREATE_SCHEMA = CommandSchema(
35
+ name="ecnu-visitor-create",
36
+ fields=(CommandField("remark", prompt="visitor remark", required=True),),
37
+ )
38
+
39
+ VISITOR_UPDATE_SCHEMA = CommandSchema(
40
+ name="ecnu-visitor-update",
41
+ fields=(
42
+ CommandField("visitor_id", prompt="visitor id", required=True),
43
+ CommandField("remark", prompt="visitor remark", required=True),
44
+ CommandField("password", prompt="visitor password", required=True, sensitive=True),
45
+ ),
46
+ )
47
+
48
+ VISITOR_ID_SCHEMA = CommandSchema(
49
+ name="ecnu-visitor-id",
50
+ fields=(CommandField("visitor_id", prompt="visitor id", required=True),),
51
+ )
52
+
53
+
54
+ @click.group(name="ecnu")
55
+ @click.option("--base-url", default=None, help="ECNU portal base URL. Defaults to chatenv ECNU_BASE_URL.")
56
+ @click.option(
57
+ "--state-file",
58
+ default=None,
59
+ help="Session state JSON file. Defaults to $CHATARCH_HOME/cache/chatnet/ecnu-session.json.",
60
+ )
61
+ @click.option("--cookie", default=None, help="Existing authenticated Cookie header. Defaults to chatenv ECNU_COOKIE.")
62
+ @click.option("-e", "--env", "env_profile", default=None, help="Use a named chatenv ECNU profile.")
63
+ @click.option("--env-file", default=None, help="Explicit env file override for ECNU values.")
64
+ @click.option("--timeout", default=20, show_default=True, type=int, help="HTTP timeout in seconds.")
65
+ @click.pass_context
66
+ def cli(
67
+ ctx: click.Context,
68
+ base_url: str | None,
69
+ state_file: str | None,
70
+ cookie: str | None,
71
+ env_profile: str | None,
72
+ env_file: str | None,
73
+ timeout: int,
74
+ ) -> None:
75
+ """ECNU self-service portal helpers."""
76
+
77
+ load_chatenv(env_profile=env_profile, env_file=env_file)
78
+ ctx.obj = {
79
+ "base_url": base_url or ECNUConfig.ECNU_BASE_URL.value or BASE_URL,
80
+ "state_file": Path(state_file).expanduser() if state_file else default_state_file(),
81
+ "cookie": cookie or ECNUConfig.ECNU_COOKIE.value,
82
+ "timeout": timeout,
83
+ }
84
+
85
+
86
+ @cli.command(name="selftest")
87
+ def selftest() -> None:
88
+ """Run local parser self-test without network access."""
89
+
90
+ from .portal import run_selftest
91
+
92
+ echo_json(run_selftest())
93
+
94
+
95
+ @cli.command(name="login-init")
96
+ @click.option(
97
+ "--captcha-path",
98
+ default=None,
99
+ help="Where to save the captcha image. Defaults to $CHATARCH_HOME/cache/chatnet/ecnu-login-captcha.png.",
100
+ )
101
+ @click.pass_context
102
+ def login_init(ctx: click.Context, captcha_path: str | None) -> None:
103
+ """Fetch login page, reset session state, and download captcha."""
104
+
105
+ target_path = Path(captcha_path).expanduser() if captcha_path else default_captcha_path()
106
+ echo_json(call_client(ctx, lambda client: client.login_init(target_path)))
107
+
108
+
109
+ @cli.command(name="login")
110
+ @click.option("--username", default=None, help="ECNU username, or set ECNU_USERNAME.")
111
+ @click.option("--password", default=None, help="ECNU password, or set ECNU_PASSWORD.")
112
+ @click.option("--captcha", default=None, help="Captcha text from login-init image.")
113
+ @click.option("--sms-code", default=None, help="SMS code when required by the server.")
114
+ @add_interactive_option
115
+ @click.pass_context
116
+ def login(
117
+ ctx: click.Context,
118
+ username: str | None,
119
+ password: str | None,
120
+ captcha: str | None,
121
+ sms_code: str | None,
122
+ interactive: bool | None,
123
+ ) -> None:
124
+ """Complete login using username, password, and captcha."""
125
+
126
+ values = resolve_command_inputs(
127
+ schema=LOGIN_SCHEMA,
128
+ provided={
129
+ "username": username or ECNUConfig.ECNU_USERNAME.value,
130
+ "password": password or ECNUConfig.ECNU_PASSWORD.value,
131
+ "captcha": captcha,
132
+ },
133
+ interactive=interactive,
134
+ usage="Usage: chatnet ecnu login --username TEXT --password TEXT --captcha TEXT [-i|-I]",
135
+ )
136
+ echo_json(call_client(ctx, lambda client: client.login(values["username"], values["password"], values["captcha"], sms_code=sms_code)))
137
+
138
+
139
+ @cli.command(name="login-auto")
140
+ @click.option("--username", default=None, help="ECNU username, or set ECNU_USERNAME.")
141
+ @click.option("--password", default=None, help="ECNU password, or set ECNU_PASSWORD.")
142
+ @click.option("--sms-code", default=None, help="SMS code when required by the server.")
143
+ @click.option(
144
+ "--captcha-path",
145
+ default=None,
146
+ help="Where to save the latest captcha image. Defaults to $CHATARCH_HOME/cache/chatnet/ecnu-login-captcha.png.",
147
+ )
148
+ @click.option("--rounds", default=3, show_default=True, type=int, help="Captcha refresh rounds.")
149
+ @click.option("--topk", default=5, show_default=True, type=int, help="OCR candidates per captcha.")
150
+ @add_interactive_option
151
+ @click.pass_context
152
+ def login_auto(
153
+ ctx: click.Context,
154
+ username: str | None,
155
+ password: str | None,
156
+ sms_code: str | None,
157
+ captcha_path: str | None,
158
+ rounds: int,
159
+ topk: int,
160
+ interactive: bool | None,
161
+ ) -> None:
162
+ """Auto-solve captcha with optional OCR dependencies and try login."""
163
+
164
+ values = resolve_command_inputs(
165
+ schema=LOGIN_AUTO_SCHEMA,
166
+ provided={"username": username or ECNUConfig.ECNU_USERNAME.value, "password": password or ECNUConfig.ECNU_PASSWORD.value},
167
+ interactive=interactive,
168
+ usage="Usage: chatnet ecnu login-auto --username TEXT --password TEXT [-i|-I]",
169
+ )
170
+ target_path = Path(captcha_path).expanduser() if captcha_path else default_captcha_path()
171
+ echo_json(
172
+ call_client(
173
+ ctx,
174
+ lambda client: client.login_auto(
175
+ values["username"],
176
+ values["password"],
177
+ sms_code=sms_code,
178
+ rounds=rounds,
179
+ topk=topk,
180
+ captcha_path=target_path,
181
+ ),
182
+ )
183
+ )
184
+
185
+
186
+ @cli.command(name="session-info")
187
+ @click.pass_context
188
+ def session_info(ctx: click.Context) -> None:
189
+ """Show saved session metadata with Cookie values redacted."""
190
+
191
+ echo_json(redact_state(make_client(ctx).state))
192
+
193
+
194
+ @cli.command(name="cookie-header")
195
+ @click.pass_context
196
+ def cookie_header(ctx: click.Context) -> None:
197
+ """Print the current Cookie header from state/session."""
198
+
199
+ click.echo(make_client(ctx).cookie_header())
200
+
201
+
202
+ @cli.command(name="logout")
203
+ @click.pass_context
204
+ def logout(ctx: click.Context) -> None:
205
+ """Logout and update saved session state."""
206
+
207
+ echo_json(call_client(ctx, lambda client: client.logout()))
208
+
209
+
210
+ @cli.command(name="home")
211
+ @click.pass_context
212
+ def home(ctx: click.Context) -> None:
213
+ """Fetch home summary."""
214
+
215
+ echo_json(call_client(ctx, lambda client: client.home_summary()))
216
+
217
+
218
+ @cli.command(name="user-info")
219
+ @click.pass_context
220
+ def user_info(ctx: click.Context) -> None:
221
+ """Fetch user information."""
222
+
223
+ echo_json(call_client(ctx, lambda client: client.user_info()))
224
+
225
+
226
+ @cli.command(name="auth-log")
227
+ @click.option("--start", default=None, help="Start time.")
228
+ @click.option("--end", default=None, help="End time.")
229
+ @click.option("--limit", default=None, type=int, help="Maximum rows to print.")
230
+ @click.pass_context
231
+ def auth_log(ctx: click.Context, start: str | None, end: str | None, limit: int | None) -> None:
232
+ """Query authentication logs."""
233
+
234
+ echo_json(call_client(ctx, lambda client: client.auth_logs(start, end, limit)))
235
+
236
+
237
+ @cli.command(name="detail-log")
238
+ @click.option("--start", default=None, help="Start time.")
239
+ @click.option("--end", default=None, help="End time.")
240
+ @click.option("--limit", default=None, type=int, help="Maximum rows to print.")
241
+ @click.pass_context
242
+ def detail_log(ctx: click.Context, start: str | None, end: str | None, limit: int | None) -> None:
243
+ """Query network detail logs."""
244
+
245
+ echo_json(call_client(ctx, lambda client: client.detail_logs(start, end, limit)))
246
+
247
+
248
+ @cli.group(name="visitor")
249
+ def visitor_group() -> None:
250
+ """Visitor account management."""
251
+
252
+
253
+ @visitor_group.command(name="list")
254
+ @click.pass_context
255
+ def visitor_list(ctx: click.Context) -> None:
256
+ """List visitor accounts."""
257
+
258
+ echo_json(call_client(ctx, lambda client: client.list_visitors()))
259
+
260
+
261
+ @visitor_group.command(name="get")
262
+ @click.option("--id", "visitor_id", default=None, help="Visitor record id.")
263
+ @click.option("--account", default=None, help="Visitor account name.")
264
+ @click.pass_context
265
+ def visitor_get(ctx: click.Context, visitor_id: str | None, account: str | None) -> None:
266
+ """Get one visitor by id or account."""
267
+
268
+ if not visitor_id and not account:
269
+ raise click.UsageError("Provide either --id or --account.")
270
+ echo_json(call_client(ctx, lambda client: client.get_visitor(visitor_id=visitor_id, account=account)))
271
+
272
+
273
+ @visitor_group.command(name="create")
274
+ @click.option("--remark", default=None, help="Visitor remark, 2-14 Chinese or English letters.")
275
+ @click.option("--dry-run", is_flag=True, help="Print request spec without submitting.")
276
+ @add_interactive_option
277
+ @click.pass_context
278
+ def visitor_create(ctx: click.Context, remark: str | None, dry_run: bool, interactive: bool | None) -> None:
279
+ """Create a visitor account."""
280
+
281
+ values = resolve_command_inputs(
282
+ schema=VISITOR_CREATE_SCHEMA,
283
+ provided={"remark": remark},
284
+ interactive=interactive,
285
+ usage="Usage: chatnet ecnu visitor create --remark TEXT [-i|-I]",
286
+ )
287
+ echo_json(call_client(ctx, lambda client: client.create_visitor(values["remark"], dry_run=dry_run)))
288
+
289
+
290
+ @visitor_group.command(name="update")
291
+ @click.option("--id", "visitor_id", default=None, help="Visitor record id.")
292
+ @click.option("--remark", default=None, help="Visitor remark, 2-14 Chinese or English letters.")
293
+ @click.option("--password", default=None, help="New visitor password.")
294
+ @click.option("--dry-run", is_flag=True, help="Print request spec without submitting.")
295
+ @add_interactive_option
296
+ @click.pass_context
297
+ def visitor_update(
298
+ ctx: click.Context,
299
+ visitor_id: str | None,
300
+ remark: str | None,
301
+ password: str | None,
302
+ dry_run: bool,
303
+ interactive: bool | None,
304
+ ) -> None:
305
+ """Update visitor remark and password."""
306
+
307
+ values = resolve_command_inputs(
308
+ schema=VISITOR_UPDATE_SCHEMA,
309
+ provided={"visitor_id": visitor_id, "remark": remark, "password": password},
310
+ interactive=interactive,
311
+ usage="Usage: chatnet ecnu visitor update --id ID --remark TEXT --password TEXT [-i|-I]",
312
+ )
313
+ echo_json(
314
+ call_client(
315
+ ctx,
316
+ lambda client: client.update_visitor(
317
+ values["visitor_id"],
318
+ values["remark"],
319
+ values["password"],
320
+ dry_run=dry_run,
321
+ ),
322
+ )
323
+ )
324
+
325
+
326
+ @visitor_group.command(name="delete")
327
+ @click.option("--id", "visitor_id", default=None, help="Visitor record id.")
328
+ @click.option("--dry-run", is_flag=True, help="Print request spec without submitting.")
329
+ @add_interactive_option
330
+ @click.pass_context
331
+ def visitor_delete(ctx: click.Context, visitor_id: str | None, dry_run: bool, interactive: bool | None) -> None:
332
+ """Delete a visitor account."""
333
+
334
+ values = resolve_command_inputs(
335
+ schema=VISITOR_ID_SCHEMA,
336
+ provided={"visitor_id": visitor_id},
337
+ interactive=interactive,
338
+ usage="Usage: chatnet ecnu visitor delete --id ID [-i|-I]",
339
+ )
340
+ echo_json(call_client(ctx, lambda client: client.delete_visitor(values["visitor_id"], dry_run=dry_run)))
341
+
342
+
343
+ @visitor_group.command(name="lock")
344
+ @click.option("--id", "visitor_id", default=None, help="Visitor record id.")
345
+ @click.option("--dry-run", is_flag=True, help="Print request spec without submitting.")
346
+ @add_interactive_option
347
+ @click.pass_context
348
+ def visitor_lock(ctx: click.Context, visitor_id: str | None, dry_run: bool, interactive: bool | None) -> None:
349
+ """Lock a visitor account."""
350
+
351
+ values = resolve_command_inputs(
352
+ schema=VISITOR_ID_SCHEMA,
353
+ provided={"visitor_id": visitor_id},
354
+ interactive=interactive,
355
+ usage="Usage: chatnet ecnu visitor lock --id ID [-i|-I]",
356
+ )
357
+ echo_json(call_client(ctx, lambda client: client.lock_visitor(values["visitor_id"], dry_run=dry_run)))
358
+
359
+
360
+ def make_client(ctx: click.Context) -> Any:
361
+ from .portal import PortalClient
362
+
363
+ config = ctx.find_root().obj or ctx.obj or {}
364
+ return PortalClient(
365
+ base_url=config["base_url"],
366
+ state_file=config["state_file"],
367
+ cookie_header=config.get("cookie"),
368
+ timeout=config["timeout"],
369
+ )
370
+
371
+
372
+ def call_client(ctx: click.Context, func: Callable[[Any], Any]) -> Any:
373
+ try:
374
+ return func(make_client(ctx))
375
+ except Exception as exc:
376
+ raise click.ClickException(str(exc)) from exc
377
+
378
+
379
+ def echo_json(data: Any) -> None:
380
+ click.echo(json.dumps(data, ensure_ascii=False, indent=2))
381
+
382
+
383
+ def load_chatenv(env_profile: str | None = None, env_file: str | None = None) -> None:
384
+ envs_dir = get_paths().envs_dir
385
+ if env_profile:
386
+ BaseEnvConfig.load_all_with_override(
387
+ envs_dir,
388
+ ECNUConfig.get_profile_env_file(envs_dir, env_profile),
389
+ )
390
+ return
391
+ if env_file:
392
+ BaseEnvConfig.load_all_with_override(envs_dir, Path(env_file).expanduser())
393
+ return
394
+ BaseEnvConfig.load_all(envs_dir)
395
+
396
+
397
+ def default_state_file() -> Path:
398
+ return get_paths().home_dir / "cache" / "chatnet" / "ecnu-session.json"
399
+
400
+
401
+ def default_captcha_path() -> Path:
402
+ return get_paths().home_dir / "cache" / "chatnet" / "ecnu-login-captcha.png"
403
+
404
+
405
+ def redact_state(state: dict[str, Any]) -> dict[str, Any]:
406
+ redacted = dict(state)
407
+ cookies = redacted.get("cookies")
408
+ if isinstance(cookies, dict):
409
+ redacted["cookies"] = {key: "***" for key in cookies}
410
+ return redacted