lingguang 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.
- lingguang/__init__.py +3 -0
- lingguang/cli/__init__.py +5 -0
- lingguang/cli/main.py +377 -0
- lingguang/core/__init__.py +49 -0
- lingguang/core/auth.py +198 -0
- lingguang/core/client.py +260 -0
- lingguang/core/errors.py +49 -0
- lingguang/core/models.py +134 -0
- lingguang/core/parser.py +286 -0
- lingguang/core/service.py +338 -0
- lingguang/core/sink.py +36 -0
- lingguang/mcp/__init__.py +18 -0
- lingguang/mcp/server.py +153 -0
- lingguang/mcp/tools.py +266 -0
- lingguang-0.1.0.dist-info/METADATA +421 -0
- lingguang-0.1.0.dist-info/RECORD +19 -0
- lingguang-0.1.0.dist-info/WHEEL +4 -0
- lingguang-0.1.0.dist-info/entry_points.txt +2 -0
- lingguang-0.1.0.dist-info/licenses/LICENSE +21 -0
lingguang/__init__.py
ADDED
lingguang/cli/main.py
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""CLI 入口。core 的薄壳,不含业务判断。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated, Optional
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from ..core.auth import (
|
|
13
|
+
Credentials,
|
|
14
|
+
clear_credentials,
|
|
15
|
+
describe_status,
|
|
16
|
+
get_cookie,
|
|
17
|
+
parse_cookie_string,
|
|
18
|
+
save_credentials,
|
|
19
|
+
)
|
|
20
|
+
from ..core.errors import LingguangError
|
|
21
|
+
from ..core.service import Service
|
|
22
|
+
|
|
23
|
+
app = typer.Typer(
|
|
24
|
+
name="lingguang",
|
|
25
|
+
help="灵光 AI 的 MCP 与 CLI 工具",
|
|
26
|
+
no_args_is_help=True,
|
|
27
|
+
add_completion=False,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
auth_app = typer.Typer(help="管理登录凭据", no_args_is_help=True)
|
|
31
|
+
sessions_app = typer.Typer(help="查看与管理会话", no_args_is_help=True)
|
|
32
|
+
app.add_typer(auth_app, name="auth")
|
|
33
|
+
app.add_typer(sessions_app, name="sessions")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _fail(message: str, code: int = 1) -> None:
|
|
37
|
+
"""错误统一走 stderr,stdout 只留数据。"""
|
|
38
|
+
typer.echo(f"错误: {message}", err=True)
|
|
39
|
+
raise typer.Exit(code)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _service() -> Service:
|
|
43
|
+
try:
|
|
44
|
+
return Service()
|
|
45
|
+
except LingguangError as exc:
|
|
46
|
+
_fail(str(exc))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _dump(data: object) -> None:
|
|
50
|
+
typer.echo(json.dumps(data, ensure_ascii=False, indent=2))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------- auth ----------------
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@auth_app.command("status")
|
|
57
|
+
def auth_status() -> None:
|
|
58
|
+
"""检查登录态。"""
|
|
59
|
+
status = describe_status()
|
|
60
|
+
if status.get("imported"):
|
|
61
|
+
try:
|
|
62
|
+
svc = Service()
|
|
63
|
+
status = svc.auth_status()
|
|
64
|
+
except LingguangError as exc:
|
|
65
|
+
status["probe"] = f"无法探测: {exc}"
|
|
66
|
+
_dump(status)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@auth_app.command("import")
|
|
70
|
+
def auth_import(
|
|
71
|
+
source: Annotated[Optional[Path], typer.Argument(help="含 cookie 的文件")] = None,
|
|
72
|
+
) -> None:
|
|
73
|
+
"""导入 cookie。
|
|
74
|
+
|
|
75
|
+
支持整段 cookie 字符串,也支持从 curl 命令里粘。
|
|
76
|
+
导入后会立即验证一次,失败就报错,不等下次用才炸。
|
|
77
|
+
"""
|
|
78
|
+
raw = source.read_text(encoding="utf-8") if source else typer.get_text_stream("stdin").read()
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
cookie = parse_cookie_string(raw)
|
|
82
|
+
except LingguangError as exc:
|
|
83
|
+
_fail(str(exc))
|
|
84
|
+
|
|
85
|
+
creds = Credentials(cookie=cookie, imported_at=__import__("time").time())
|
|
86
|
+
path = save_credentials(creds)
|
|
87
|
+
typer.echo(f"已保存到 {path}")
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
svc = Service(cookie=cookie)
|
|
91
|
+
result = svc.auth_status()
|
|
92
|
+
except LingguangError as exc:
|
|
93
|
+
_fail(f"已保存但验证失败: {exc}")
|
|
94
|
+
|
|
95
|
+
if result.get("valid"):
|
|
96
|
+
typer.echo("验证通过,cookie 可用")
|
|
97
|
+
else:
|
|
98
|
+
typer.echo(f"警告: 验证未通过 {result.get('probe', '')}", err=True)
|
|
99
|
+
missing = result.get("missing_keys") or []
|
|
100
|
+
if missing:
|
|
101
|
+
typer.echo(f"注意: 缺少关键 cookie {missing},稳定性可能受影响", err=True)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@auth_app.command("clear")
|
|
105
|
+
def auth_clear() -> None:
|
|
106
|
+
"""删除本地保存的 cookie。"""
|
|
107
|
+
clear_credentials()
|
|
108
|
+
typer.echo("已清除")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---------------- sessions ----------------
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@sessions_app.command("list")
|
|
115
|
+
def sessions_list(
|
|
116
|
+
limit: Annotated[int, typer.Option("--limit", "-n", help="每页条数,上限 30")] = 30,
|
|
117
|
+
json_output: Annotated[bool, typer.Option("--json", help="输出 JSON")] = False,
|
|
118
|
+
) -> None:
|
|
119
|
+
"""列出会话。"""
|
|
120
|
+
svc = _service()
|
|
121
|
+
try:
|
|
122
|
+
data = svc.list_sessions(limit=limit)
|
|
123
|
+
except LingguangError as exc:
|
|
124
|
+
_fail(str(exc))
|
|
125
|
+
|
|
126
|
+
if json_output:
|
|
127
|
+
_dump(data)
|
|
128
|
+
return
|
|
129
|
+
sessions = data.get("sessions") or []
|
|
130
|
+
if not sessions:
|
|
131
|
+
typer.echo("没有会话")
|
|
132
|
+
return
|
|
133
|
+
for s in sessions:
|
|
134
|
+
typer.echo(f"{s['id']} {s['summary']} [{s['scene']}]")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@sessions_app.command("show")
|
|
138
|
+
def sessions_show(
|
|
139
|
+
session_id: Annotated[str, typer.Argument(help="会话 id")],
|
|
140
|
+
keyword: Annotated[Optional[str], typer.Option("--keyword", "-k", help="按关键词过滤")] = None,
|
|
141
|
+
and_tools: Annotated[bool, typer.Option("--tools", help="显示工具轨迹")] = False,
|
|
142
|
+
json_output: Annotated[bool, typer.Option("--json", help="输出 JSON")] = False,
|
|
143
|
+
) -> None:
|
|
144
|
+
"""查看会话内容。"""
|
|
145
|
+
svc = _service()
|
|
146
|
+
try:
|
|
147
|
+
data = svc.get_transcript(session_id, keyword=keyword)
|
|
148
|
+
except LingguangError as exc:
|
|
149
|
+
_fail(str(exc))
|
|
150
|
+
|
|
151
|
+
if json_output:
|
|
152
|
+
_dump(data)
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
turns = data.get("turns") or []
|
|
156
|
+
if not turns:
|
|
157
|
+
typer.echo("没有匹配的轮次")
|
|
158
|
+
return
|
|
159
|
+
for i, turn in enumerate(turns, 1):
|
|
160
|
+
typer.echo(f"\n=== 第 {i} 轮 ===")
|
|
161
|
+
if turn["query"]:
|
|
162
|
+
typer.echo(f"问: {turn['query']}")
|
|
163
|
+
if and_tools and turn["tools"]:
|
|
164
|
+
typer.echo(f"工具: {' / '.join(turn['tools'])}")
|
|
165
|
+
if turn["warnings"]:
|
|
166
|
+
typer.echo(f"提示: {'; '.join(turn['warnings'])}")
|
|
167
|
+
typer.echo(turn["answer"] or "(无正文)")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@sessions_app.command("delete")
|
|
171
|
+
def sessions_delete(
|
|
172
|
+
session_id: Annotated[str, typer.Argument(help="会话 id")],
|
|
173
|
+
yes: Annotated[bool, typer.Option("--yes", "-y", help="确认删除")] = False,
|
|
174
|
+
) -> None:
|
|
175
|
+
"""删除会话。不可恢复。"""
|
|
176
|
+
if not yes:
|
|
177
|
+
_fail("删除不可恢复,请加 --yes 确认")
|
|
178
|
+
svc = _service()
|
|
179
|
+
try:
|
|
180
|
+
result = svc.delete_session(session_id, confirm=True)
|
|
181
|
+
except LingguangError as exc:
|
|
182
|
+
_fail(str(exc))
|
|
183
|
+
_dump(result)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ---------------- ask / answer ----------------
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@app.command("ask")
|
|
190
|
+
def ask(
|
|
191
|
+
query: Annotated[str, typer.Argument(help="要问灵光的问题")],
|
|
192
|
+
session: Annotated[Optional[str], typer.Option("--session", "-s", help="会话 id,不传则新建")] = None,
|
|
193
|
+
wait: Annotated[bool, typer.Option("--wait", "-w", help="等待结果返回")] = False,
|
|
194
|
+
timeout: Annotated[int, typer.Option("--timeout", help="等待秒数,上限 60")] = 60,
|
|
195
|
+
json_output: Annotated[bool, typer.Option("--json", help="输出 JSON")] = False,
|
|
196
|
+
) -> None:
|
|
197
|
+
"""向灵光提问。
|
|
198
|
+
|
|
199
|
+
不带 --wait 时立即返回 session_id 和 query_id,
|
|
200
|
+
后续用 answer 命令取结果。
|
|
201
|
+
"""
|
|
202
|
+
svc = _service()
|
|
203
|
+
try:
|
|
204
|
+
result = svc.ask(query, session_id=session)
|
|
205
|
+
except LingguangError as exc:
|
|
206
|
+
_fail(str(exc))
|
|
207
|
+
|
|
208
|
+
if not wait:
|
|
209
|
+
if json_output:
|
|
210
|
+
_dump(result.to_dict())
|
|
211
|
+
else:
|
|
212
|
+
typer.echo(f"session: {result.session_id}")
|
|
213
|
+
typer.echo(f"query: {result.query_id}")
|
|
214
|
+
if result.status == "dispatched_unknown":
|
|
215
|
+
typer.echo("状态: 已发出但结果未知,请用 answer 命令确认,不要重复提问", err=True)
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
answer = svc.get_answer(result.query_id, session_id=result.session_id, wait_seconds=timeout)
|
|
219
|
+
if json_output:
|
|
220
|
+
_dump(answer)
|
|
221
|
+
return
|
|
222
|
+
if answer["status"] == "done":
|
|
223
|
+
typer.echo(answer["text"])
|
|
224
|
+
if answer["tools"]:
|
|
225
|
+
typer.echo(f"\n工具轨迹: {' / '.join(answer['tools'])}", err=True)
|
|
226
|
+
else:
|
|
227
|
+
typer.echo(f"状态: {answer['status']}", err=True)
|
|
228
|
+
for w in answer.get("warnings") or []:
|
|
229
|
+
typer.echo(f"提示: {w}", err=True)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@app.command("answer")
|
|
233
|
+
def answer(
|
|
234
|
+
query_id: Annotated[str, typer.Argument(help="ask 返回的 query id")],
|
|
235
|
+
session: Annotated[Optional[str], typer.Option("--session", "-s", help="会话 id")] = None,
|
|
236
|
+
wait: Annotated[int, typer.Option("--wait", "-w", help="等待秒数,上限 60")] = 0,
|
|
237
|
+
json_output: Annotated[bool, typer.Option("--json", help="输出 JSON")] = False,
|
|
238
|
+
) -> None:
|
|
239
|
+
"""取回提问结果。"""
|
|
240
|
+
svc = _service()
|
|
241
|
+
try:
|
|
242
|
+
data = svc.get_answer(query_id, session_id=session, wait_seconds=wait)
|
|
243
|
+
except LingguangError as exc:
|
|
244
|
+
_fail(str(exc))
|
|
245
|
+
|
|
246
|
+
if json_output:
|
|
247
|
+
_dump(data)
|
|
248
|
+
return
|
|
249
|
+
if data["status"] == "done":
|
|
250
|
+
typer.echo(data["text"])
|
|
251
|
+
else:
|
|
252
|
+
typer.echo(f"状态: {data['status']}")
|
|
253
|
+
if data["status"] == "awaiting_confirmation":
|
|
254
|
+
typer.echo("这次提问可能需要你在灵光 App 里手动确认", err=True)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ---------------- export ----------------
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@app.command("export")
|
|
261
|
+
def export(
|
|
262
|
+
session_id: Annotated[str, typer.Argument(help="会话 id")],
|
|
263
|
+
fmt: Annotated[str, typer.Option("--format", "-f", help="md / json / jsonl / raw")] = "md",
|
|
264
|
+
output: Annotated[Optional[Path], typer.Option("--output", "-o", help="输出文件")] = None,
|
|
265
|
+
) -> None:
|
|
266
|
+
"""导出会话。
|
|
267
|
+
|
|
268
|
+
raw 格式输出未解析的原始 JSON,解析器失效时用它兜底。
|
|
269
|
+
"""
|
|
270
|
+
svc = _service()
|
|
271
|
+
|
|
272
|
+
if fmt == "raw":
|
|
273
|
+
try:
|
|
274
|
+
raw = svc.get_raw_history(session_id)
|
|
275
|
+
except LingguangError as exc:
|
|
276
|
+
_fail(str(exc))
|
|
277
|
+
text = json.dumps(raw, ensure_ascii=False, indent=2)
|
|
278
|
+
else:
|
|
279
|
+
try:
|
|
280
|
+
data = svc.get_transcript(session_id)
|
|
281
|
+
except LingguangError as exc:
|
|
282
|
+
_fail(str(exc))
|
|
283
|
+
turns = data.get("turns") or []
|
|
284
|
+
if fmt == "json":
|
|
285
|
+
text = json.dumps(turns, ensure_ascii=False, indent=2)
|
|
286
|
+
elif fmt == "jsonl":
|
|
287
|
+
text = "\n".join(json.dumps(t, ensure_ascii=False) for t in turns)
|
|
288
|
+
else:
|
|
289
|
+
lines: list[str] = []
|
|
290
|
+
for i, turn in enumerate(turns, 1):
|
|
291
|
+
lines.append(f"## 第 {i} 轮\n")
|
|
292
|
+
if turn["query"]:
|
|
293
|
+
lines.append(f"**问**: {turn['query']}\n")
|
|
294
|
+
if turn["tools"]:
|
|
295
|
+
lines.append(f"**工具**: {' / '.join(turn['tools'])}\n")
|
|
296
|
+
lines.append(f"{turn['answer']}\n")
|
|
297
|
+
text = "\n".join(lines)
|
|
298
|
+
|
|
299
|
+
if output:
|
|
300
|
+
output.write_text(text, encoding="utf-8")
|
|
301
|
+
typer.echo(f"已写入 {output}")
|
|
302
|
+
else:
|
|
303
|
+
typer.echo(text)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# ---------------- 运维 ----------------
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@app.command("doctor")
|
|
310
|
+
def doctor() -> None:
|
|
311
|
+
"""环境自检。"""
|
|
312
|
+
ok = True
|
|
313
|
+
|
|
314
|
+
# cookie
|
|
315
|
+
status = describe_status()
|
|
316
|
+
if not status.get("imported"):
|
|
317
|
+
typer.echo("[失败] 未导入 cookie", err=True)
|
|
318
|
+
ok = False
|
|
319
|
+
else:
|
|
320
|
+
missing = status.get("missing_keys") or []
|
|
321
|
+
if missing:
|
|
322
|
+
typer.echo(f"[警告] 缺少关键 cookie: {missing}")
|
|
323
|
+
else:
|
|
324
|
+
typer.echo("[通过] cookie 齐全")
|
|
325
|
+
|
|
326
|
+
if not ok:
|
|
327
|
+
raise typer.Exit(1)
|
|
328
|
+
|
|
329
|
+
# 连通性
|
|
330
|
+
try:
|
|
331
|
+
svc = Service()
|
|
332
|
+
probe = svc.auth_status()
|
|
333
|
+
if probe.get("valid"):
|
|
334
|
+
typer.echo("[通过] loginCheck 成功")
|
|
335
|
+
else:
|
|
336
|
+
typer.echo(f"[失败] {probe.get('probe', 'loginCheck 未通过')}", err=True)
|
|
337
|
+
ok = False
|
|
338
|
+
except LingguangError as exc:
|
|
339
|
+
typer.echo(f"[失败] 无法连接: {exc}", err=True)
|
|
340
|
+
ok = False
|
|
341
|
+
|
|
342
|
+
# 解析器自检
|
|
343
|
+
try:
|
|
344
|
+
from ..core.parser import parse_artifact_text
|
|
345
|
+
from pathlib import Path as _Path
|
|
346
|
+
|
|
347
|
+
fixture = _Path(__file__).resolve().parents[3] / "tests" / "fixtures" / "answer_billing.raw.html"
|
|
348
|
+
if fixture.exists():
|
|
349
|
+
text = parse_artifact_text(fixture.read_text(encoding="utf-8"))
|
|
350
|
+
if text:
|
|
351
|
+
typer.echo(f"[通过] 解析器能提取正文({len(text)} 字符)")
|
|
352
|
+
else:
|
|
353
|
+
typer.echo("[失败] 解析器未提取到正文,上游可能改版", err=True)
|
|
354
|
+
ok = False
|
|
355
|
+
else:
|
|
356
|
+
typer.echo("[跳过] 未找到 fixture")
|
|
357
|
+
except Exception as exc: # noqa: BLE001
|
|
358
|
+
typer.echo(f"[失败] 解析器自检异常: {exc}", err=True)
|
|
359
|
+
ok = False
|
|
360
|
+
|
|
361
|
+
raise typer.Exit(0 if ok else 1)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@app.command("serve")
|
|
365
|
+
def serve() -> None:
|
|
366
|
+
"""启动 MCP stdio server。"""
|
|
367
|
+
from ..mcp.server import run
|
|
368
|
+
|
|
369
|
+
run()
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def main() -> None:
|
|
373
|
+
app()
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
if __name__ == "__main__":
|
|
377
|
+
main()
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""核心层:解析、传输、业务编排。
|
|
2
|
+
|
|
3
|
+
cli 与 mcp 只能通过 service、models、errors 访问这里,
|
|
4
|
+
不要直接碰 client 和 parser。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .errors import (
|
|
8
|
+
AuthError,
|
|
9
|
+
ContractError,
|
|
10
|
+
LingguangError,
|
|
11
|
+
RateLimitError,
|
|
12
|
+
TruncatedResponseError,
|
|
13
|
+
TransportError,
|
|
14
|
+
)
|
|
15
|
+
from .models import AnswerStatus, AskResult, Scene, Session, ToolCall, Turn
|
|
16
|
+
from .parser import (
|
|
17
|
+
build_turn,
|
|
18
|
+
collect_unknown_events,
|
|
19
|
+
pair_turns,
|
|
20
|
+
parse_artifact_text,
|
|
21
|
+
parse_cot_agent_deltas,
|
|
22
|
+
parse_cot_tools,
|
|
23
|
+
)
|
|
24
|
+
from .service import Service
|
|
25
|
+
from .sink import MessageSink, NullSink
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"AuthError",
|
|
29
|
+
"ContractError",
|
|
30
|
+
"LingguangError",
|
|
31
|
+
"RateLimitError",
|
|
32
|
+
"TruncatedResponseError",
|
|
33
|
+
"TransportError",
|
|
34
|
+
"AnswerStatus",
|
|
35
|
+
"AskResult",
|
|
36
|
+
"Scene",
|
|
37
|
+
"Session",
|
|
38
|
+
"ToolCall",
|
|
39
|
+
"Turn",
|
|
40
|
+
"build_turn",
|
|
41
|
+
"collect_unknown_events",
|
|
42
|
+
"pair_turns",
|
|
43
|
+
"parse_artifact_text",
|
|
44
|
+
"parse_cot_agent_deltas",
|
|
45
|
+
"parse_cot_tools",
|
|
46
|
+
"Service",
|
|
47
|
+
"MessageSink",
|
|
48
|
+
"NullSink",
|
|
49
|
+
]
|
lingguang/core/auth.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""cookie 的导入、存储与状态检查。
|
|
2
|
+
|
|
3
|
+
cookie 是唯一入口的钥匙,失效时工具完全不可用,
|
|
4
|
+
所以这里要做事前预判而不是事后诊断。
|
|
5
|
+
|
|
6
|
+
存储位置 ~/.config/lingguang/credentials.json,权限 0600。
|
|
7
|
+
Windows 下会尝试用 DPAPI 加密 cookie 本身,
|
|
8
|
+
不可用时退化为明文加文件权限,并在状态里如实报告。
|
|
9
|
+
|
|
10
|
+
重要:明文 ctoken 被同步盘或备份软件扫走,等于泄露支付宝授权登录态。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import stat
|
|
19
|
+
import time
|
|
20
|
+
from dataclasses import asdict, dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from .errors import AuthError
|
|
25
|
+
|
|
26
|
+
CONFIG_DIR = Path.home() / ".config" / "lingguang"
|
|
27
|
+
CREDENTIALS_FILE = CONFIG_DIR / "credentials.json"
|
|
28
|
+
PENDING_FILE = Path.home() / ".cache" / "lingguang" / "pending.jsonl"
|
|
29
|
+
FAILED_DIR = Path.home() / ".cache" / "lingguang" / "failed"
|
|
30
|
+
|
|
31
|
+
# 实测有效的三个关键 cookie
|
|
32
|
+
KEY_COOKIES = ("ctoken", "prod_LINGGUANGJSESSIONID", "spanner")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Credentials:
|
|
37
|
+
cookie: str
|
|
38
|
+
imported_at: float = 0.0
|
|
39
|
+
last_ok_at: float = 0.0
|
|
40
|
+
last_error: str = ""
|
|
41
|
+
last_error_at: float = 0.0
|
|
42
|
+
encrypted: bool = False
|
|
43
|
+
|
|
44
|
+
def to_dict(self) -> dict[str, Any]:
|
|
45
|
+
return asdict(self)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def parse_cookie_string(raw: str) -> str:
|
|
49
|
+
"""从任意粘贴内容里抽出 cookie。
|
|
50
|
+
|
|
51
|
+
支持三种输入:
|
|
52
|
+
1. 纯 cookie 字符串:name=value; name=value
|
|
53
|
+
2. 从 curl 命令里粘:-H 'cookie: ...'
|
|
54
|
+
3. 带换行的多行文本
|
|
55
|
+
"""
|
|
56
|
+
text = raw.strip()
|
|
57
|
+
if not text:
|
|
58
|
+
raise AuthError("输入为空")
|
|
59
|
+
|
|
60
|
+
# 从 curl 抓
|
|
61
|
+
m = re.search(r"-H\s+['\"]cookie:\s*([^'\"]+)['\"]", text, re.IGNORECASE)
|
|
62
|
+
if m:
|
|
63
|
+
text = m.group(1).strip()
|
|
64
|
+
|
|
65
|
+
# 折叠成单行
|
|
66
|
+
text = " ".join(text.split())
|
|
67
|
+
if "ctoken=" not in text:
|
|
68
|
+
raise AuthError("内容里找不到 ctoken,请确认复制的是完整的 cookie")
|
|
69
|
+
return text
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def check_cookie_completeness(cookie: str) -> list[str]:
|
|
73
|
+
"""检查关键 cookie 是否齐全,返回缺失项。
|
|
74
|
+
|
|
75
|
+
缺 spanner 或 JSESSIONID 时服务端可能仍能识别,
|
|
76
|
+
但稳定性会下降,所以要如实报告。
|
|
77
|
+
"""
|
|
78
|
+
present = {part.strip().partition("=")[0] for part in cookie.split(";") if part.strip()}
|
|
79
|
+
return [name for name in KEY_COOKIES if name not in present]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _ensure_dir(path: Path) -> None:
|
|
83
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _restrict_permissions(path: Path) -> None:
|
|
87
|
+
"""尽力把文件权限收到 0600。Windows 上 chmod 语义有限,失败不阻断。"""
|
|
88
|
+
try:
|
|
89
|
+
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
|
90
|
+
except (OSError, NotImplementedError):
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def save_credentials(creds: Credentials) -> Path:
|
|
95
|
+
_ensure_dir(CONFIG_DIR)
|
|
96
|
+
CREDENTIALS_FILE.write_text(
|
|
97
|
+
json.dumps(creds.to_dict(), ensure_ascii=False, indent=2), encoding="utf-8"
|
|
98
|
+
)
|
|
99
|
+
_restrict_permissions(CREDENTIALS_FILE)
|
|
100
|
+
return CREDENTIALS_FILE
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def load_credentials() -> Credentials | None:
|
|
104
|
+
if not CREDENTIALS_FILE.exists():
|
|
105
|
+
return None
|
|
106
|
+
try:
|
|
107
|
+
data = json.loads(CREDENTIALS_FILE.read_text(encoding="utf-8"))
|
|
108
|
+
except (json.JSONDecodeError, OSError):
|
|
109
|
+
return None
|
|
110
|
+
if not isinstance(data, dict) or not data.get("cookie"):
|
|
111
|
+
return None
|
|
112
|
+
return Credentials(
|
|
113
|
+
cookie=str(data["cookie"]),
|
|
114
|
+
imported_at=float(data.get("imported_at") or 0),
|
|
115
|
+
last_ok_at=float(data.get("last_ok_at") or 0),
|
|
116
|
+
last_error=str(data.get("last_error") or ""),
|
|
117
|
+
last_error_at=float(data.get("last_error_at") or 0),
|
|
118
|
+
encrypted=bool(data.get("encrypted")),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def get_cookie() -> str:
|
|
123
|
+
"""按优先级取 cookie:环境变量 > 凭据文件。
|
|
124
|
+
|
|
125
|
+
环境变量优先是为了在 CI 或临时场景里能覆盖。
|
|
126
|
+
"""
|
|
127
|
+
env = os.environ.get("LG_COOKIE", "").strip()
|
|
128
|
+
if env:
|
|
129
|
+
return parse_cookie_string(env)
|
|
130
|
+
|
|
131
|
+
creds = load_credentials()
|
|
132
|
+
if creds is None:
|
|
133
|
+
raise AuthError(
|
|
134
|
+
"还没有导入 cookie。运行 `lingguang auth import <文件>` 导入,"
|
|
135
|
+
"或设置环境变量 LG_COOKIE"
|
|
136
|
+
)
|
|
137
|
+
return creds.cookie
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def mark_ok() -> None:
|
|
141
|
+
creds = load_credentials()
|
|
142
|
+
if creds is None:
|
|
143
|
+
return
|
|
144
|
+
creds.last_ok_at = time.time()
|
|
145
|
+
creds.last_error = ""
|
|
146
|
+
save_credentials(creds)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def mark_error(message: str) -> None:
|
|
150
|
+
creds = load_credentials()
|
|
151
|
+
if creds is None:
|
|
152
|
+
return
|
|
153
|
+
creds.last_error = message[:500]
|
|
154
|
+
creds.last_error_at = time.time()
|
|
155
|
+
save_credentials(creds)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def describe_status() -> dict[str, Any]:
|
|
159
|
+
"""给 auth_status 用。返回人话,不返回裸时间戳。"""
|
|
160
|
+
env_cookie = os.environ.get("LG_COOKIE", "").strip()
|
|
161
|
+
creds = load_credentials()
|
|
162
|
+
|
|
163
|
+
if creds is None and not env_cookie:
|
|
164
|
+
return {
|
|
165
|
+
"valid": False,
|
|
166
|
+
"imported": False,
|
|
167
|
+
"hint": "还没有导入 cookie,运行 `lingguang auth import <文件>`",
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if creds is None and env_cookie:
|
|
171
|
+
return {
|
|
172
|
+
"valid": False,
|
|
173
|
+
"imported": True,
|
|
174
|
+
"source": "env:LG_COOKIE",
|
|
175
|
+
"missing_keys": check_cookie_completeness(env_cookie),
|
|
176
|
+
"hint": "cookie 来自环境变量 LG_COOKIE,尚未验证",
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
assert creds is not None
|
|
180
|
+
now = time.time()
|
|
181
|
+
days = int((now - creds.imported_at) / 86400) if creds.imported_at else 0
|
|
182
|
+
hours_since_ok = int((now - creds.last_ok_at) / 3600) if creds.last_ok_at else -1
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
"valid": bool(creds.last_ok_at) and not creds.last_error,
|
|
186
|
+
"imported": True,
|
|
187
|
+
"source": "file",
|
|
188
|
+
"imported_days": days,
|
|
189
|
+
"last_ok_hours": hours_since_ok,
|
|
190
|
+
"last_error": creds.last_error,
|
|
191
|
+
"missing_keys": check_cookie_completeness(creds.cookie),
|
|
192
|
+
"hint": "cookie 会在你从未操作后的一段时间失效,失效时重新导入即可",
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def clear_credentials() -> None:
|
|
197
|
+
if CREDENTIALS_FILE.exists():
|
|
198
|
+
CREDENTIALS_FILE.unlink()
|