wechatbridge-cli 1.4.2__tar.gz → 1.4.4__tar.gz
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.
- {wechatbridge_cli-1.4.2/wechatbridge_cli.egg-info → wechatbridge_cli-1.4.4}/PKG-INFO +3 -3
- {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.4}/README.md +2 -2
- {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.4}/tests/test_codex.py +132 -5
- {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.4}/tests/test_hardening.py +261 -1
- {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.4}/wechatbridge/__init__.py +1 -1
- {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.4}/wechatbridge/agy.py +8 -2
- {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.4}/wechatbridge/codex.py +242 -22
- {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.4}/wechatbridge/grok.py +20 -12
- {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.4}/wechatbridge/ilink.py +26 -6
- {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.4}/wechatbridge/main.py +34 -13
- {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.4}/wechatbridge/runner_common.py +24 -0
- {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.4/wechatbridge_cli.egg-info}/PKG-INFO +3 -3
- {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.4}/LICENSE +0 -0
- {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.4}/pyproject.toml +0 -0
- {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.4}/setup.cfg +0 -0
- {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.4}/wechatbridge/__main__.py +0 -0
- {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.4}/wechatbridge/config.py +0 -0
- {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.4}/wechatbridge/update_check.py +0 -0
- {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.4}/wechatbridge_cli.egg-info/SOURCES.txt +0 -0
- {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.4}/wechatbridge_cli.egg-info/dependency_links.txt +0 -0
- {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.4}/wechatbridge_cli.egg-info/entry_points.txt +0 -0
- {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.4}/wechatbridge_cli.egg-info/requires.txt +0 -0
- {wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.4}/wechatbridge_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wechatbridge-cli
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.4
|
|
4
4
|
Summary: Bridge WeChat messages to agy, Grok Build, or Codex CLIs — text/image/file/voice in, CLI replies and generated files back.
|
|
5
5
|
Author: WeChatBridge contributors
|
|
6
6
|
License: MIT
|
|
@@ -250,8 +250,8 @@ See [`deploy/wechatbridge-windows.md`](deploy/wechatbridge-windows.md).
|
|
|
250
250
|
| `/help` | list supported commands for the active backend |
|
|
251
251
|
| `/backend <agy\|grok\|codex>` | switch CLI backend for this WeChat user (on real change: clears that backend's continuation state — agy/grok flag and codex `thread_id`/resume — so the next turn starts a fresh session; history files may remain until retention cleanup) |
|
|
252
252
|
| `/clear` or `/new` | drop continue flag so the next CLI turn is a new conversation (does not instantly delete history files) |
|
|
253
|
-
| `/model <name>` | set model (
|
|
254
|
-
| `/models` | list models — agy/grok query the live CLI
|
|
253
|
+
| `/model <name>` | set model (all backends validate against a live list: agy/grok via CLI `models`; codex via `codex debug models` [then `--bundled`]; unknown name or list-fetch failure refuse and do not write prefs; see `/models`) |
|
|
254
|
+
| `/models` | list models — agy/grok/codex all query the live CLI (codex: `debug models`; falls back to a built-in reference note only if the live list cannot be fetched) |
|
|
255
255
|
| `/fast` | set low reasoning effort (**on only** — not a toggle; no “off” command) |
|
|
256
256
|
| `/planning` | set planning mode (**on only** — not a toggle) |
|
|
257
257
|
| `/add-dir <path>` | **agy:** pass `--add-dir` on later runs if path is allowed. **grok:** recorded only; not passed to the CLI yet |
|
|
@@ -219,8 +219,8 @@ See [`deploy/wechatbridge-windows.md`](deploy/wechatbridge-windows.md).
|
|
|
219
219
|
| `/help` | list supported commands for the active backend |
|
|
220
220
|
| `/backend <agy\|grok\|codex>` | switch CLI backend for this WeChat user (on real change: clears that backend's continuation state — agy/grok flag and codex `thread_id`/resume — so the next turn starts a fresh session; history files may remain until retention cleanup) |
|
|
221
221
|
| `/clear` or `/new` | drop continue flag so the next CLI turn is a new conversation (does not instantly delete history files) |
|
|
222
|
-
| `/model <name>` | set model (
|
|
223
|
-
| `/models` | list models — agy/grok query the live CLI
|
|
222
|
+
| `/model <name>` | set model (all backends validate against a live list: agy/grok via CLI `models`; codex via `codex debug models` [then `--bundled`]; unknown name or list-fetch failure refuse and do not write prefs; see `/models`) |
|
|
223
|
+
| `/models` | list models — agy/grok/codex all query the live CLI (codex: `debug models`; falls back to a built-in reference note only if the live list cannot be fetched) |
|
|
224
224
|
| `/fast` | set low reasoning effort (**on only** — not a toggle; no “off” command) |
|
|
225
225
|
| `/planning` | set planning mode (**on only** — not a toggle) |
|
|
226
226
|
| `/add-dir <path>` | **agy:** pass `--add-dir` on later runs if path is allowed. **grok:** recorded only; not passed to the CLI yet |
|
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
+
import json
|
|
6
7
|
import os
|
|
7
8
|
import sys
|
|
8
9
|
import tempfile
|
|
9
10
|
import time
|
|
10
11
|
import unittest
|
|
11
12
|
from unittest import mock
|
|
13
|
+
from unittest.mock import AsyncMock
|
|
12
14
|
|
|
13
15
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
14
16
|
_FIXTURES = os.path.join(_HERE, "fixtures")
|
|
@@ -519,23 +521,148 @@ class TestCodexSlash(unittest.IsolatedAsyncioTestCase):
|
|
|
519
521
|
self.assertEqual(_read_codex_thread_id(sd), "")
|
|
520
522
|
self.assertFalse(os.path.exists(os.path.join(sd, ".initialized.codex")))
|
|
521
523
|
|
|
522
|
-
async def
|
|
524
|
+
async def test_model_validated_stores(self):
|
|
525
|
+
"""Known model in live list → switch succeeds and prefs are written."""
|
|
523
526
|
from wechatbridge.codex import handle_codex_slash_command
|
|
524
527
|
from wechatbridge.runner_common import load_prefs
|
|
525
|
-
|
|
528
|
+
from wechatbridge import codex as codex_mod
|
|
529
|
+
|
|
530
|
+
with mock.patch.object(
|
|
531
|
+
codex_mod,
|
|
532
|
+
"_fetch_codex_model_list",
|
|
533
|
+
new=AsyncMock(return_value=["gpt-5.1-codex", "gpt-5.1", "gpt-5"]),
|
|
534
|
+
):
|
|
535
|
+
reply = await handle_codex_slash_command("/model gpt-5.1-codex", "u-model")
|
|
526
536
|
self.assertIn("模型已切换", reply)
|
|
527
|
-
self.
|
|
537
|
+
self.assertNotIn("未校验", reply)
|
|
528
538
|
self.assertEqual(load_prefs("u-model")["model"], "gpt-5.1-codex")
|
|
529
539
|
|
|
540
|
+
async def test_model_unknown_rejects(self):
|
|
541
|
+
"""Unknown model → refuse and prefs unchanged."""
|
|
542
|
+
from wechatbridge.codex import handle_codex_slash_command
|
|
543
|
+
from wechatbridge.runner_common import load_prefs, save_prefs
|
|
544
|
+
from wechatbridge import codex as codex_mod
|
|
545
|
+
|
|
546
|
+
uid = "u-model-unknown"
|
|
547
|
+
save_prefs(uid, {"model": "gpt-5.1", "effort": "", "mode": ""})
|
|
548
|
+
with mock.patch.object(
|
|
549
|
+
codex_mod,
|
|
550
|
+
"_fetch_codex_model_list",
|
|
551
|
+
new=AsyncMock(return_value=["gpt-5.1-codex", "gpt-5.1"]),
|
|
552
|
+
):
|
|
553
|
+
reply = await handle_codex_slash_command("/model not-a-real-model", uid)
|
|
554
|
+
self.assertIn("模型不存在", reply)
|
|
555
|
+
self.assertNotIn("模型已切换", reply)
|
|
556
|
+
self.assertEqual(load_prefs(uid)["model"], "gpt-5.1")
|
|
557
|
+
|
|
558
|
+
async def test_model_list_fail_rejects(self):
|
|
559
|
+
"""List fetch failure → refuse and do not write prefs (strict)."""
|
|
560
|
+
from wechatbridge.codex import handle_codex_slash_command
|
|
561
|
+
from wechatbridge.runner_common import load_prefs, save_prefs
|
|
562
|
+
from wechatbridge import codex as codex_mod
|
|
563
|
+
|
|
564
|
+
uid = "u-model-listfail"
|
|
565
|
+
save_prefs(uid, {"model": "gpt-5", "effort": "", "mode": ""})
|
|
566
|
+
with mock.patch.object(
|
|
567
|
+
codex_mod,
|
|
568
|
+
"_fetch_codex_model_list",
|
|
569
|
+
new=AsyncMock(return_value=None),
|
|
570
|
+
):
|
|
571
|
+
reply = await handle_codex_slash_command("/model gpt-5.1-codex", uid)
|
|
572
|
+
self.assertIn("无法获取模型列表", reply)
|
|
573
|
+
self.assertNotIn("模型已切换", reply)
|
|
574
|
+
self.assertEqual(load_prefs(uid)["model"], "gpt-5")
|
|
575
|
+
|
|
576
|
+
async def test_model_prefix_match(self):
|
|
577
|
+
"""Prefix match takes the first hit (same as agy)."""
|
|
578
|
+
from wechatbridge.codex import handle_codex_slash_command
|
|
579
|
+
from wechatbridge.runner_common import load_prefs
|
|
580
|
+
from wechatbridge import codex as codex_mod
|
|
581
|
+
|
|
582
|
+
with mock.patch.object(
|
|
583
|
+
codex_mod,
|
|
584
|
+
"_fetch_codex_model_list",
|
|
585
|
+
new=AsyncMock(return_value=["gpt-5.1-codex", "gpt-5.1", "gpt-5"]),
|
|
586
|
+
):
|
|
587
|
+
reply = await handle_codex_slash_command("/model gpt-5.1", "u-model-prefix")
|
|
588
|
+
# exact "gpt-5.1" is in list → exact match wins over longer prefixes
|
|
589
|
+
self.assertIn("模型已切换", reply)
|
|
590
|
+
self.assertEqual(load_prefs("u-model-prefix")["model"], "gpt-5.1")
|
|
591
|
+
|
|
592
|
+
with mock.patch.object(
|
|
593
|
+
codex_mod,
|
|
594
|
+
"_fetch_codex_model_list",
|
|
595
|
+
new=AsyncMock(return_value=["gpt-5.1-codex", "gpt-5-codex"]),
|
|
596
|
+
):
|
|
597
|
+
reply2 = await handle_codex_slash_command("/model gpt-5.1", "u-model-prefix2")
|
|
598
|
+
self.assertIn("模型已切换", reply2)
|
|
599
|
+
self.assertEqual(load_prefs("u-model-prefix2")["model"], "gpt-5.1-codex")
|
|
600
|
+
|
|
530
601
|
async def test_model_empty_shows_current(self):
|
|
531
602
|
from wechatbridge.codex import handle_codex_slash_command
|
|
603
|
+
from wechatbridge.runner_common import save_prefs
|
|
604
|
+
|
|
605
|
+
save_prefs("u-model-empty", {"model": "gpt-5.1-codex", "effort": "", "mode": ""})
|
|
532
606
|
reply = await handle_codex_slash_command("/model", "u-model-empty")
|
|
533
607
|
self.assertIn("当前模型", reply)
|
|
608
|
+
self.assertIn("gpt-5.1-codex", reply)
|
|
609
|
+
self.assertNotIn("不会校验", reply)
|
|
610
|
+
self.assertNotIn("未校验", reply)
|
|
534
611
|
|
|
535
|
-
async def
|
|
612
|
+
async def test_models_live_list(self):
|
|
613
|
+
"""/models under mock returns the live list."""
|
|
536
614
|
from wechatbridge.codex import handle_codex_slash_command
|
|
537
|
-
|
|
615
|
+
from wechatbridge import codex as codex_mod
|
|
616
|
+
|
|
617
|
+
with mock.patch.object(
|
|
618
|
+
codex_mod,
|
|
619
|
+
"_fetch_codex_model_list",
|
|
620
|
+
new=AsyncMock(return_value=["gpt-5.1-codex", "gpt-5.1"]),
|
|
621
|
+
):
|
|
622
|
+
reply = await handle_codex_slash_command("/models", "u-models")
|
|
538
623
|
self.assertIn("gpt-5.1-codex", reply)
|
|
624
|
+
self.assertIn("gpt-5.1", reply)
|
|
625
|
+
self.assertIn("debug models", reply)
|
|
626
|
+
|
|
627
|
+
async def test_models_fallback_builtin(self):
|
|
628
|
+
"""/models falls back to built-in note when live list fails."""
|
|
629
|
+
from wechatbridge.codex import handle_codex_slash_command
|
|
630
|
+
from wechatbridge import codex as codex_mod
|
|
631
|
+
|
|
632
|
+
with mock.patch.object(
|
|
633
|
+
codex_mod,
|
|
634
|
+
"_fetch_codex_model_list",
|
|
635
|
+
new=AsyncMock(return_value=None),
|
|
636
|
+
):
|
|
637
|
+
reply = await handle_codex_slash_command("/models", "u-models-fb")
|
|
638
|
+
self.assertIn("参考", reply)
|
|
639
|
+
self.assertIn("gpt-5.1-codex", reply)
|
|
640
|
+
|
|
641
|
+
async def test_parse_codex_models_json_and_lines(self):
|
|
642
|
+
from wechatbridge.codex import _parse_codex_models
|
|
643
|
+
|
|
644
|
+
j = json.dumps({
|
|
645
|
+
"models": [
|
|
646
|
+
{"slug": "gpt-5.1-codex"},
|
|
647
|
+
{"slug": "gpt-5"},
|
|
648
|
+
]
|
|
649
|
+
})
|
|
650
|
+
self.assertEqual(_parse_codex_models(j), ["gpt-5.1-codex", "gpt-5"])
|
|
651
|
+
|
|
652
|
+
nested = json.dumps({
|
|
653
|
+
"models": [{"slug": "a"}, {"id": "b", "name": "ignored-without-slug-only"}]
|
|
654
|
+
})
|
|
655
|
+
# models[] item with slug + item with id (no slug) both accepted
|
|
656
|
+
self.assertEqual(_parse_codex_models(nested), ["a", "b"])
|
|
657
|
+
|
|
658
|
+
# bare string list
|
|
659
|
+
self.assertEqual(_parse_codex_models(json.dumps(["m1", "m2"])), ["m1", "m2"])
|
|
660
|
+
|
|
661
|
+
lines = "* gpt-5.1-codex (default)\n- gpt-5\n"
|
|
662
|
+
self.assertEqual(_parse_codex_models(lines), ["gpt-5.1-codex", "gpt-5"])
|
|
663
|
+
|
|
664
|
+
self.assertEqual(_parse_codex_models(""), [])
|
|
665
|
+
self.assertEqual(_parse_codex_models("{}"), [])
|
|
539
666
|
|
|
540
667
|
async def test_fast_and_planning(self):
|
|
541
668
|
from wechatbridge.codex import handle_codex_slash_command
|
|
@@ -399,7 +399,255 @@ class TestSendArtifactsBackCodexAddDirs(unittest.IsolatedAsyncioTestCase):
|
|
|
399
399
|
# Exactly the two legitimate artifacts are uploaded; the send API is
|
|
400
400
|
# never called for any deleted/file/oob/symlink-escape target.
|
|
401
401
|
self.assertEqual(len(client.send_media.await_args_list), 2)
|
|
402
|
-
|
|
402
|
+
|
|
403
|
+
# Skipped (whitelist) artifacts get a short Chinese notice — no abs path.
|
|
404
|
+
self.assertEqual(client.send_message.await_count, 4)
|
|
405
|
+
for call in client.send_message.await_args_list:
|
|
406
|
+
text = call.kwargs["text"]
|
|
407
|
+
self.assertIn("未能发送", text)
|
|
408
|
+
self.assertNotIn(base, text)
|
|
409
|
+
self.assertNotIn(session_dir, text)
|
|
410
|
+
self.assertNotIn(oob, text)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
class TestAgyExtractArtifactsUnquote(unittest.TestCase):
|
|
414
|
+
"""agy extract_artifacts must URL-decode percent-encoded paths/names."""
|
|
415
|
+
|
|
416
|
+
def test_space_and_cjk_percent_encoded(self):
|
|
417
|
+
from wechatbridge.agy import extract_artifacts
|
|
418
|
+
|
|
419
|
+
text = (
|
|
420
|
+
"here is [my report.pdf](file:///tmp/scratch/my%20report.pdf) "
|
|
421
|
+
"and [报告.pdf](file:///tmp/scratch/%E6%8A%A5%E5%91%8A.pdf)"
|
|
422
|
+
)
|
|
423
|
+
arts = extract_artifacts(text)
|
|
424
|
+
paths = {p for _, p in arts}
|
|
425
|
+
names = {n for n, _ in arts}
|
|
426
|
+
self.assertIn("/tmp/scratch/my report.pdf", paths)
|
|
427
|
+
self.assertIn("/tmp/scratch/报告.pdf", paths)
|
|
428
|
+
self.assertIn("my report.pdf", names)
|
|
429
|
+
self.assertIn("报告.pdf", names)
|
|
430
|
+
# Encoded forms must not leak into resolved paths
|
|
431
|
+
for p in paths:
|
|
432
|
+
self.assertNotIn("%20", p)
|
|
433
|
+
self.assertNotIn("%E6", p)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
class TestSendArtifactsBackAgyAddDirs(unittest.IsolatedAsyncioTestCase):
|
|
437
|
+
"""agy must honour validated --add-dir roots at send time (like codex)."""
|
|
438
|
+
|
|
439
|
+
async def test_agy_add_dir_artifact_sent(self):
|
|
440
|
+
from wechatbridge.main import send_artifacts_back
|
|
441
|
+
from wechatbridge.config import config
|
|
442
|
+
|
|
443
|
+
with tempfile.TemporaryDirectory() as base:
|
|
444
|
+
session_dir = os.path.join(base, "session")
|
|
445
|
+
os.makedirs(session_dir)
|
|
446
|
+
scratch = os.path.join(
|
|
447
|
+
session_dir, ".gemini", "antigravity-cli", "scratch"
|
|
448
|
+
)
|
|
449
|
+
os.makedirs(scratch)
|
|
450
|
+
|
|
451
|
+
allowed_extra = os.path.join(base, "allowed_extra")
|
|
452
|
+
os.makedirs(allowed_extra)
|
|
453
|
+
good_dir = os.path.join(allowed_extra, "proj")
|
|
454
|
+
os.makedirs(good_dir)
|
|
455
|
+
good_art = os.path.join(good_dir, "doc.pdf")
|
|
456
|
+
with open(good_art, "w", encoding="utf-8") as f:
|
|
457
|
+
f.write("%PDF-1.4 ok")
|
|
458
|
+
|
|
459
|
+
scratch_art = os.path.join(scratch, "local.txt")
|
|
460
|
+
with open(scratch_art, "w", encoding="utf-8") as f:
|
|
461
|
+
f.write("scratch")
|
|
462
|
+
|
|
463
|
+
# out-of-bounds must still be blocked
|
|
464
|
+
oob = os.path.join(base, "oob")
|
|
465
|
+
os.makedirs(oob)
|
|
466
|
+
oob_art = os.path.join(oob, "secret.txt")
|
|
467
|
+
with open(oob_art, "w", encoding="utf-8") as f:
|
|
468
|
+
f.write("secret")
|
|
469
|
+
|
|
470
|
+
prefs = {"backend": "agy", "add_dirs": [good_dir]}
|
|
471
|
+
with open(
|
|
472
|
+
os.path.join(session_dir, "prefs.json"), "w", encoding="utf-8"
|
|
473
|
+
) as f:
|
|
474
|
+
json.dump(prefs, f)
|
|
475
|
+
|
|
476
|
+
client = MagicMock()
|
|
477
|
+
client.state.baseurl = "https://example.test"
|
|
478
|
+
client.state.bot_token = "tok"
|
|
479
|
+
client.send_message = AsyncMock(return_value=True)
|
|
480
|
+
client.send_media = AsyncMock(return_value=True)
|
|
481
|
+
|
|
482
|
+
artifacts = [
|
|
483
|
+
("doc.pdf", good_art),
|
|
484
|
+
("local.txt", scratch_art),
|
|
485
|
+
("secret.txt", oob_art),
|
|
486
|
+
]
|
|
487
|
+
|
|
488
|
+
with mock.patch(
|
|
489
|
+
"wechatbridge.main.get_session_dir", return_value=session_dir
|
|
490
|
+
), mock.patch(
|
|
491
|
+
"wechatbridge.runner_common.get_session_dir", return_value=session_dir
|
|
492
|
+
), mock.patch(
|
|
493
|
+
"wechatbridge.main._get_backend", return_value="agy"
|
|
494
|
+
), mock.patch.object(
|
|
495
|
+
config, "add_dir_roots", [allowed_extra]
|
|
496
|
+
):
|
|
497
|
+
await send_artifacts_back(
|
|
498
|
+
client, "user-1", "ctx-token", artifacts
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
sent = {c.kwargs["path"] for c in client.send_media.await_args_list}
|
|
502
|
+
self.assertIn(good_art, sent)
|
|
503
|
+
self.assertIn(scratch_art, sent)
|
|
504
|
+
self.assertNotIn(oob_art, sent)
|
|
505
|
+
self.assertEqual(len(client.send_media.await_args_list), 2)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
class TestMediaTypeForPath(unittest.TestCase):
|
|
509
|
+
def test_pdf_png_svg(self):
|
|
510
|
+
from wechatbridge.ilink import media_type_for_path, MEDIA_IMAGE, MEDIA_FILE
|
|
511
|
+
|
|
512
|
+
self.assertEqual(media_type_for_path("/tmp/a.pdf"), MEDIA_FILE)
|
|
513
|
+
self.assertEqual(media_type_for_path("/tmp/a.docx"), MEDIA_FILE)
|
|
514
|
+
self.assertEqual(media_type_for_path("/tmp/a.xlsx"), MEDIA_FILE)
|
|
515
|
+
self.assertEqual(media_type_for_path("/tmp/a.txt"), MEDIA_FILE)
|
|
516
|
+
self.assertEqual(media_type_for_path("/tmp/a.zip"), MEDIA_FILE)
|
|
517
|
+
self.assertEqual(media_type_for_path("/tmp/a.png"), MEDIA_IMAGE)
|
|
518
|
+
self.assertEqual(media_type_for_path("/tmp/a.jpg"), MEDIA_IMAGE)
|
|
519
|
+
self.assertEqual(media_type_for_path("/tmp/a.jpeg"), MEDIA_IMAGE)
|
|
520
|
+
self.assertEqual(media_type_for_path("/tmp/a.gif"), MEDIA_IMAGE)
|
|
521
|
+
self.assertEqual(media_type_for_path("/tmp/a.webp"), MEDIA_IMAGE)
|
|
522
|
+
self.assertEqual(media_type_for_path("/tmp/a.svg"), MEDIA_FILE)
|
|
523
|
+
self.assertEqual(media_type_for_path("/tmp/a.heic"), MEDIA_FILE)
|
|
524
|
+
self.assertEqual(media_type_for_path("/tmp/a.bin"), MEDIA_FILE)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
class TestArtifactSendFailureNotice(unittest.TestCase):
|
|
528
|
+
def test_helper_no_absolute_path(self):
|
|
529
|
+
from wechatbridge.runner_common import format_artifact_send_failure_notice
|
|
530
|
+
|
|
531
|
+
art_path = "/root/.local/share/wechatbridge/default/sessions/u1/x.pdf"
|
|
532
|
+
for reason in ("skipped", "not_found", "send_failed", "error"):
|
|
533
|
+
text = format_artifact_send_failure_notice("x.pdf", reason)
|
|
534
|
+
self.assertNotIn(art_path, text)
|
|
535
|
+
self.assertNotIn("/root/", text)
|
|
536
|
+
self.assertIn("x.pdf", text)
|
|
537
|
+
self.assertIn("未能发送", text)
|
|
538
|
+
|
|
539
|
+
def test_send_failed_notifies_user(self):
|
|
540
|
+
from wechatbridge.main import send_artifacts_back
|
|
541
|
+
|
|
542
|
+
async def _run():
|
|
543
|
+
with tempfile.TemporaryDirectory() as td:
|
|
544
|
+
scratch = os.path.join(td, ".gemini", "antigravity-cli", "scratch")
|
|
545
|
+
os.makedirs(scratch)
|
|
546
|
+
art = os.path.join(scratch, "report.pdf")
|
|
547
|
+
with open(art, "w", encoding="utf-8") as f:
|
|
548
|
+
f.write("pdf")
|
|
549
|
+
|
|
550
|
+
client = MagicMock()
|
|
551
|
+
client.state.baseurl = "https://example.test"
|
|
552
|
+
client.state.bot_token = "tok"
|
|
553
|
+
client.send_message = AsyncMock(return_value=True)
|
|
554
|
+
client.send_media = AsyncMock(return_value=False) # send fails
|
|
555
|
+
|
|
556
|
+
with mock.patch(
|
|
557
|
+
"wechatbridge.main.get_session_dir", return_value=td
|
|
558
|
+
), mock.patch(
|
|
559
|
+
"wechatbridge.main._get_backend", return_value="agy"
|
|
560
|
+
):
|
|
561
|
+
await send_artifacts_back(
|
|
562
|
+
client, "user-1", "ctx-token", [("report.pdf", art)]
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
client.send_media.assert_awaited()
|
|
566
|
+
client.send_message.assert_awaited()
|
|
567
|
+
text = client.send_message.await_args.kwargs["text"]
|
|
568
|
+
self.assertIn("report.pdf", text)
|
|
569
|
+
self.assertIn("未能发送", text)
|
|
570
|
+
self.assertNotIn(art, text)
|
|
571
|
+
self.assertNotIn(td, text)
|
|
572
|
+
|
|
573
|
+
asyncio.run(_run())
|
|
574
|
+
|
|
575
|
+
def test_not_found_notifies_user(self):
|
|
576
|
+
from wechatbridge.main import send_artifacts_back
|
|
577
|
+
|
|
578
|
+
async def _run():
|
|
579
|
+
with tempfile.TemporaryDirectory() as td:
|
|
580
|
+
scratch = os.path.join(td, ".gemini", "antigravity-cli", "scratch")
|
|
581
|
+
os.makedirs(scratch)
|
|
582
|
+
missing = os.path.join(scratch, "gone.pdf")
|
|
583
|
+
|
|
584
|
+
client = MagicMock()
|
|
585
|
+
client.state.baseurl = "https://example.test"
|
|
586
|
+
client.state.bot_token = "tok"
|
|
587
|
+
client.send_message = AsyncMock(return_value=True)
|
|
588
|
+
client.send_media = AsyncMock(return_value=True)
|
|
589
|
+
|
|
590
|
+
with mock.patch(
|
|
591
|
+
"wechatbridge.main.get_session_dir", return_value=td
|
|
592
|
+
), mock.patch(
|
|
593
|
+
"wechatbridge.main._get_backend", return_value="agy"
|
|
594
|
+
):
|
|
595
|
+
await send_artifacts_back(
|
|
596
|
+
client, "user-1", "ctx-token", [("gone.pdf", missing)]
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
client.send_media.assert_not_awaited()
|
|
600
|
+
client.send_message.assert_awaited()
|
|
601
|
+
text = client.send_message.await_args.kwargs["text"]
|
|
602
|
+
self.assertIn("gone.pdf", text)
|
|
603
|
+
self.assertIn("未能发送", text)
|
|
604
|
+
self.assertNotIn(missing, text)
|
|
605
|
+
self.assertNotIn(td, text)
|
|
606
|
+
|
|
607
|
+
asyncio.run(_run())
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
class TestGrokRelativePathArtifacts(unittest.TestCase):
|
|
611
|
+
"""grok relative file_path must join session_dir then abspath (like codex)."""
|
|
612
|
+
|
|
613
|
+
def test_relative_path_resolved(self):
|
|
614
|
+
from wechatbridge.grok import _extract_grok_artifacts
|
|
615
|
+
import urllib.parse
|
|
616
|
+
|
|
617
|
+
with tempfile.TemporaryDirectory() as session_dir:
|
|
618
|
+
# Create the relative file under session_dir
|
|
619
|
+
rel_name = "notes/out.txt"
|
|
620
|
+
abs_file = os.path.join(session_dir, "notes", "out.txt")
|
|
621
|
+
os.makedirs(os.path.dirname(abs_file))
|
|
622
|
+
with open(abs_file, "w", encoding="utf-8") as f:
|
|
623
|
+
f.write("hello")
|
|
624
|
+
|
|
625
|
+
# Fake grok chat_history.jsonl layout
|
|
626
|
+
cwd_encoded = urllib.parse.quote(session_dir, safe="")
|
|
627
|
+
session_id = "sess-rel-1"
|
|
628
|
+
hist_dir = os.path.join(
|
|
629
|
+
session_dir, ".grok", "sessions", cwd_encoded, session_id
|
|
630
|
+
)
|
|
631
|
+
os.makedirs(hist_dir)
|
|
632
|
+
hist = os.path.join(hist_dir, "chat_history.jsonl")
|
|
633
|
+
line = {
|
|
634
|
+
"type": "assistant",
|
|
635
|
+
"tool_calls": [
|
|
636
|
+
{
|
|
637
|
+
"name": "write",
|
|
638
|
+
"arguments": json.dumps({"file_path": rel_name}),
|
|
639
|
+
}
|
|
640
|
+
],
|
|
641
|
+
}
|
|
642
|
+
with open(hist, "w", encoding="utf-8") as f:
|
|
643
|
+
f.write(json.dumps(line) + "\n")
|
|
644
|
+
|
|
645
|
+
arts = _extract_grok_artifacts(session_dir, session_id, since=0.0)
|
|
646
|
+
self.assertEqual(len(arts), 1)
|
|
647
|
+
name, path = arts[0]
|
|
648
|
+
self.assertEqual(name, "out.txt")
|
|
649
|
+
self.assertEqual(path, os.path.abspath(abs_file))
|
|
650
|
+
self.assertTrue(os.path.isabs(path))
|
|
403
651
|
|
|
404
652
|
|
|
405
653
|
class TestILinkDeliveryAccepted(unittest.TestCase):
|
|
@@ -1564,6 +1812,18 @@ class TestModelCmdRejectsBellThrottle(unittest.IsolatedAsyncioTestCase):
|
|
|
1564
1812
|
self.assertIn("无法获取模型列表", out)
|
|
1565
1813
|
self.assertNotIn("模型已切换", out)
|
|
1566
1814
|
|
|
1815
|
+
async def test_codex_model_bell_throttle(self):
|
|
1816
|
+
from wechatbridge.runner_common import format_notice
|
|
1817
|
+
from wechatbridge import codex as codex_mod
|
|
1818
|
+
|
|
1819
|
+
thr = format_notice("助手通道繁忙", "上游限流")
|
|
1820
|
+
with mock.patch.object(
|
|
1821
|
+
codex_mod, "_run_codex_subcommand", new=AsyncMock(return_value=thr)
|
|
1822
|
+
):
|
|
1823
|
+
out = await codex_mod._cmd_model("gpt-5.1-codex", "u-model-codex")
|
|
1824
|
+
self.assertIn("无法获取模型列表", out)
|
|
1825
|
+
self.assertNotIn("模型已切换", out)
|
|
1826
|
+
|
|
1567
1827
|
|
|
1568
1828
|
class TestEnvIntList(unittest.TestCase):
|
|
1569
1829
|
"""#8 optional: _env_int_list boundary cases."""
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""WeChatBridge — bridge WeChat messages to agy, Grok Build, or Codex CLIs."""
|
|
2
|
-
__version__ = "1.4.
|
|
2
|
+
__version__ = "1.4.4"
|
|
@@ -11,6 +11,7 @@ import shutil
|
|
|
11
11
|
import signal
|
|
12
12
|
import sys
|
|
13
13
|
import time
|
|
14
|
+
from urllib.parse import unquote
|
|
14
15
|
|
|
15
16
|
from .config import config
|
|
16
17
|
from .runner_common import (
|
|
@@ -32,14 +33,19 @@ def extract_artifacts(text: str) -> list[tuple[str, str]]:
|
|
|
32
33
|
|
|
33
34
|
Uses regex ``\\[([^\\]]+)\\](file:///([^)]+))`` to find agy-generated
|
|
34
35
|
artifact references in stdout. Returns deduplicated, order-preserved list.
|
|
36
|
+
|
|
37
|
+
Paths and display names are URL-decoded (``urllib.parse.unquote``) so
|
|
38
|
+
percent-encoded spaces / CJK (e.g. ``my%20report.pdf``, ``%E6%8A%A5%E5%91%8A.pdf``)
|
|
39
|
+
resolve to real filesystem paths.
|
|
35
40
|
"""
|
|
36
41
|
if not text:
|
|
37
42
|
return []
|
|
38
43
|
seen = set()
|
|
39
44
|
result = []
|
|
40
45
|
for match in re.finditer(r"\[([^\]]+)\]\(file:///([^)]+)\)", text):
|
|
41
|
-
name = match.group(1).split("#")[0]
|
|
42
|
-
|
|
46
|
+
name = unquote(match.group(1).split("#")[0])
|
|
47
|
+
path_part = unquote(match.group(2).split("#")[0])
|
|
48
|
+
abs_path = path_part if path_part.startswith("/") else "/" + path_part
|
|
43
49
|
key = (name, abs_path)
|
|
44
50
|
if key not in seen:
|
|
45
51
|
seen.add(key)
|
|
@@ -21,7 +21,7 @@ from .config import config
|
|
|
21
21
|
from .runner_common import (
|
|
22
22
|
sanitize_user_id, get_session_dir, ensure_session_dir, is_first_message, mark_initialized, clear_initialized,
|
|
23
23
|
clean_output, load_prefs, save_prefs, is_dangerous, parse_model_effort,
|
|
24
|
-
sanitize_env, terminate_process, update_active_prefs,
|
|
24
|
+
sanitize_env, terminate_process, update_active_prefs, is_bridge_formatted_reply,
|
|
25
25
|
format_error, format_cli_error, format_model_label, EMPTY_REPLY, validate_add_dir,
|
|
26
26
|
)
|
|
27
27
|
|
|
@@ -776,45 +776,265 @@ async def run_codex(prompt: str, user_id: str, timeout: int = None) -> tuple:
|
|
|
776
776
|
# Slash command support
|
|
777
777
|
# ---------------------------------------------------------------------------
|
|
778
778
|
|
|
779
|
-
async def
|
|
780
|
-
"""
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
779
|
+
async def _run_codex_subcommand(subcmd_args: list, user_id: str) -> str:
|
|
780
|
+
"""Run a short codex subcommand (e.g. ``debug models``) and return cleaned output.
|
|
781
|
+
|
|
782
|
+
Timeout is fixed at 30 seconds. Session isolation matches ``run_codex``:
|
|
783
|
+
per-user ``HOME`` / ``CODEX_HOME``, optional ``CODEX_API_KEY`` refill after
|
|
784
|
+
``sanitize_env``.
|
|
785
|
+
"""
|
|
786
|
+
session_dir = ensure_user_codex(user_id)
|
|
787
|
+
cmd = [config.codex_binary_path] + list(subcmd_args)
|
|
788
|
+
process = None
|
|
789
|
+
try:
|
|
790
|
+
env = sanitize_env(session_dir)
|
|
791
|
+
env["CODEX_HOME"] = os.path.join(session_dir, ".codex")
|
|
792
|
+
env["PAGER"] = "cat"
|
|
793
|
+
env["CI"] = "true"
|
|
794
|
+
env["NONINTERACTIVE"] = "1"
|
|
795
|
+
env["PYTHONUNBUFFERED"] = "1"
|
|
796
|
+
raw_key = os.environ.get("CODEX_API_KEY")
|
|
797
|
+
if raw_key:
|
|
798
|
+
env["CODEX_API_KEY"] = raw_key
|
|
799
|
+
|
|
800
|
+
process = await asyncio.create_subprocess_exec(
|
|
801
|
+
*cmd,
|
|
802
|
+
stdout=asyncio.subprocess.PIPE,
|
|
803
|
+
stderr=asyncio.subprocess.PIPE,
|
|
804
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
805
|
+
cwd=session_dir,
|
|
806
|
+
env=env,
|
|
807
|
+
preexec_fn=os.setsid if hasattr(os, "setsid") else None,
|
|
788
808
|
)
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
"
|
|
793
|
-
|
|
809
|
+
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
810
|
+
process.communicate(), timeout=30.0
|
|
811
|
+
)
|
|
812
|
+
stdout_text = stdout_bytes.decode("utf-8", errors="replace").strip()
|
|
813
|
+
stderr_text = stderr_bytes.decode("utf-8", errors="replace").strip()
|
|
814
|
+
|
|
815
|
+
if process.returncode != 0:
|
|
816
|
+
logger.warning(
|
|
817
|
+
"codex %s exited with code %s",
|
|
818
|
+
" ".join(subcmd_args),
|
|
819
|
+
process.returncode,
|
|
820
|
+
)
|
|
821
|
+
return format_cli_error(
|
|
822
|
+
stderr_text or stdout_text or "终端指令执行失败",
|
|
823
|
+
backend="codex",
|
|
824
|
+
)
|
|
825
|
+
|
|
826
|
+
return clean_output(stdout_text) or EMPTY_REPLY
|
|
827
|
+
|
|
828
|
+
except asyncio.TimeoutError:
|
|
829
|
+
await terminate_process(process, graceful=True)
|
|
830
|
+
return format_error("查询超时", "查询超时,请稍后再试。")
|
|
831
|
+
except asyncio.CancelledError:
|
|
832
|
+
await terminate_process(process, graceful=False)
|
|
833
|
+
raise
|
|
834
|
+
except Exception as e:
|
|
835
|
+
logger.exception("codex subcommand error: %s", e)
|
|
836
|
+
await terminate_process(process, graceful=False)
|
|
837
|
+
return format_error(
|
|
838
|
+
"执行出错",
|
|
839
|
+
"这次没处理好,请稍后再试。若一直失败,请联系管理员。",
|
|
840
|
+
)
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def _parse_codex_models(output: str) -> list[str]:
|
|
844
|
+
"""Loose-parse model slug list from ``codex debug models`` stdout.
|
|
845
|
+
|
|
846
|
+
Accepts JSON (any shape with ``slug`` fields, or a ``models`` array of
|
|
847
|
+
strings/objects) and falls back to line-oriented text (one name per line,
|
|
848
|
+
optional leading bullets).
|
|
849
|
+
"""
|
|
850
|
+
if not output or not output.strip():
|
|
851
|
+
return []
|
|
852
|
+
|
|
853
|
+
text = output.strip()
|
|
854
|
+
models: list[str] = []
|
|
855
|
+
seen: set[str] = set()
|
|
856
|
+
|
|
857
|
+
def _add(name: str) -> None:
|
|
858
|
+
name = (name or "").strip()
|
|
859
|
+
if not name or name in seen:
|
|
860
|
+
return
|
|
861
|
+
# Skip obvious non-slug noise
|
|
862
|
+
if name.startswith(("[", "{", "(", "<")):
|
|
863
|
+
return
|
|
864
|
+
if name in (EMPTY_REPLY, "(empty response)"):
|
|
865
|
+
return
|
|
866
|
+
seen.add(name)
|
|
867
|
+
models.append(name)
|
|
868
|
+
|
|
869
|
+
def _walk(obj, *, under_models: bool = False) -> None:
|
|
870
|
+
if isinstance(obj, dict):
|
|
871
|
+
slug = obj.get("slug")
|
|
872
|
+
if isinstance(slug, str) and slug.strip():
|
|
873
|
+
_add(slug)
|
|
874
|
+
elif under_models:
|
|
875
|
+
# Inside a models array: accept id/name/model when slug absent
|
|
876
|
+
for key in ("id", "name", "model"):
|
|
877
|
+
val = obj.get(key)
|
|
878
|
+
if isinstance(val, str) and val.strip():
|
|
879
|
+
_add(val)
|
|
880
|
+
break
|
|
881
|
+
# Prefer the conventional "models" key, then recurse the rest
|
|
882
|
+
if "models" in obj:
|
|
883
|
+
_walk(obj["models"], under_models=True)
|
|
884
|
+
for k, v in obj.items():
|
|
885
|
+
if k == "models":
|
|
886
|
+
continue
|
|
887
|
+
_walk(v, under_models=False)
|
|
888
|
+
elif isinstance(obj, list):
|
|
889
|
+
for item in obj:
|
|
890
|
+
if isinstance(item, str) and under_models:
|
|
891
|
+
_add(item)
|
|
892
|
+
else:
|
|
893
|
+
_walk(item, under_models=under_models)
|
|
894
|
+
|
|
895
|
+
# Try full-text JSON first, then per-line JSON (JSONL-ish).
|
|
896
|
+
try:
|
|
897
|
+
data = json.loads(text)
|
|
898
|
+
# Top-level bare list of strings counts as a models array
|
|
899
|
+
if isinstance(data, list) and data and all(isinstance(x, str) for x in data):
|
|
900
|
+
_walk(data, under_models=True)
|
|
901
|
+
else:
|
|
902
|
+
_walk(data)
|
|
903
|
+
except (json.JSONDecodeError, TypeError, ValueError):
|
|
904
|
+
for line in text.splitlines():
|
|
905
|
+
line = line.strip()
|
|
906
|
+
if not line or not line.startswith(("{", "[")):
|
|
907
|
+
continue
|
|
908
|
+
try:
|
|
909
|
+
piece = json.loads(line)
|
|
910
|
+
if isinstance(piece, list) and piece and all(isinstance(x, str) for x in piece):
|
|
911
|
+
_walk(piece, under_models=True)
|
|
912
|
+
else:
|
|
913
|
+
_walk(piece)
|
|
914
|
+
except (json.JSONDecodeError, TypeError, ValueError):
|
|
915
|
+
continue
|
|
794
916
|
|
|
917
|
+
if models:
|
|
918
|
+
return models
|
|
795
919
|
|
|
796
|
-
|
|
797
|
-
|
|
920
|
+
# Line-oriented fallback (only when JSON yielded nothing useful)
|
|
921
|
+
for line in text.splitlines():
|
|
922
|
+
line = line.strip()
|
|
923
|
+
if not line:
|
|
924
|
+
continue
|
|
925
|
+
# Strip common bullets / list markers
|
|
926
|
+
for prefix in ("* ", "- ", "• ", "· "):
|
|
927
|
+
if line.startswith(prefix):
|
|
928
|
+
line = line[len(prefix):].strip()
|
|
929
|
+
break
|
|
930
|
+
# Take first token; drop trailing parenthetical notes
|
|
931
|
+
token = line.split()[0] if line.split() else ""
|
|
932
|
+
token = token.split("(")[0].strip().rstrip(",;:")
|
|
933
|
+
if token and not token.endswith(":") and not token.lower().startswith("available"):
|
|
934
|
+
_add(token)
|
|
935
|
+
|
|
936
|
+
return models
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
async def _fetch_codex_model_list(user_id: str) -> list[str] | None:
|
|
940
|
+
"""Return live model slugs, or None if the list cannot be retrieved.
|
|
941
|
+
|
|
942
|
+
Tries ``codex debug models`` first, then ``codex debug models --bundled``.
|
|
943
|
+
Strict: empty / error / bridge-formatted failure → None (caller must not write prefs).
|
|
944
|
+
"""
|
|
945
|
+
for args in (["debug", "models"], ["debug", "models", "--bundled"]):
|
|
946
|
+
output = await _run_codex_subcommand(args, user_id)
|
|
947
|
+
if not output or output.startswith("[error]") or is_bridge_formatted_reply(output):
|
|
948
|
+
continue
|
|
949
|
+
if output == EMPTY_REPLY:
|
|
950
|
+
continue
|
|
951
|
+
models = _parse_codex_models(output)
|
|
952
|
+
if models:
|
|
953
|
+
return models
|
|
954
|
+
return None
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
def _builtin_codex_models_note() -> str:
|
|
958
|
+
"""Fallback reference list when live ``debug models`` is unavailable."""
|
|
798
959
|
return (
|
|
799
960
|
"📋 **codex 可用模型(参考)** 📋\n\n"
|
|
800
|
-
"codex
|
|
961
|
+
"暂时无法从本机 `codex debug models` 拉取实时列表,以下为常见模型名,"
|
|
962
|
+
"以你的 OpenAI 账户实际可用为准:\n\n"
|
|
801
963
|
"- `gpt-5.1-codex`\n"
|
|
802
964
|
"- `gpt-5.1`\n"
|
|
803
965
|
"- `gpt-5-codex`\n"
|
|
804
966
|
"- `gpt-5`\n"
|
|
805
967
|
"- `gpt-4.1`\n\n"
|
|
806
|
-
"
|
|
968
|
+
"请稍后重试 `/models`,或直接 `/model <名称>`(会校验实时列表)。"
|
|
807
969
|
)
|
|
808
970
|
|
|
809
971
|
|
|
972
|
+
async def _cmd_model(args: str, user_id: str) -> str:
|
|
973
|
+
"""Handle /model <name>: validate against ``codex debug models``, then save.
|
|
974
|
+
|
|
975
|
+
Strict: list fetch failure or unknown name → refuse and do not write prefs.
|
|
976
|
+
Matching order (same as agy): exact match, then first prefix match.
|
|
977
|
+
"""
|
|
978
|
+
name = args.strip()
|
|
979
|
+
if not name:
|
|
980
|
+
prefs = load_prefs(user_id)
|
|
981
|
+
return (
|
|
982
|
+
"📋 **当前模型** 📋\n\n"
|
|
983
|
+
f"`{format_model_label(prefs.get('model', ''))}`\n\n"
|
|
984
|
+
"用 `/models` 查看可用模型,`/model <名称>` 切换。"
|
|
985
|
+
)
|
|
986
|
+
|
|
987
|
+
models = await _fetch_codex_model_list(user_id)
|
|
988
|
+
if models is None:
|
|
989
|
+
return "❌ **无法获取模型列表** ❌"
|
|
990
|
+
|
|
991
|
+
def _apply(model_name: str) -> str:
|
|
992
|
+
_, embedded = parse_model_effort(model_name)
|
|
993
|
+
if embedded:
|
|
994
|
+
update_active_prefs(user_id, model=model_name, effort="")
|
|
995
|
+
else:
|
|
996
|
+
update_active_prefs(user_id, model=model_name)
|
|
997
|
+
return f"✅ **模型已切换** ✅\n\n`{model_name}`"
|
|
998
|
+
|
|
999
|
+
# Exact match
|
|
1000
|
+
if name in models:
|
|
1001
|
+
return _apply(name)
|
|
1002
|
+
|
|
1003
|
+
# Prefix match (first hit, same as agy)
|
|
1004
|
+
prefix_matches = [m for m in models if m.startswith(name)]
|
|
1005
|
+
if prefix_matches:
|
|
1006
|
+
return _apply(prefix_matches[0])
|
|
1007
|
+
|
|
1008
|
+
return f"❌ **模型不存在** ❌\n\n`{name}`"
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
async def _cmd_models(user_id: str) -> str:
|
|
1012
|
+
"""List codex models via live ``debug models``; fall back to built-in note."""
|
|
1013
|
+
models = await _fetch_codex_model_list(user_id)
|
|
1014
|
+
if models is None:
|
|
1015
|
+
return _builtin_codex_models_note()
|
|
1016
|
+
|
|
1017
|
+
lines = [
|
|
1018
|
+
"📋 **codex 可用模型** 📋",
|
|
1019
|
+
"",
|
|
1020
|
+
"(via `codex debug models`)",
|
|
1021
|
+
"",
|
|
1022
|
+
]
|
|
1023
|
+
for m in models:
|
|
1024
|
+
lines.append(f"- `{m}`")
|
|
1025
|
+
lines.append("")
|
|
1026
|
+
lines.append("用 `/model <名称>` 切换(支持前缀匹配)。")
|
|
1027
|
+
return "\n".join(lines)
|
|
1028
|
+
|
|
1029
|
+
|
|
810
1030
|
def _cmd_help() -> str:
|
|
811
1031
|
"""Build /help response for codex backend."""
|
|
812
1032
|
lines = [
|
|
813
1033
|
"📋 **wechatbridge 支持指令 (codex)** 📋",
|
|
814
1034
|
"",
|
|
815
1035
|
"**模型控制**",
|
|
816
|
-
"- `/model <名称>` —
|
|
817
|
-
"- `/models` —
|
|
1036
|
+
"- `/model <名称>` — 切换模型(用 `/models` 查看可用列表)",
|
|
1037
|
+
"- `/models` — 查看可用模型列表",
|
|
818
1038
|
"- `/backend <agy|grok|codex>` — 切换助手引擎",
|
|
819
1039
|
"",
|
|
820
1040
|
"**对话控制**",
|
|
@@ -883,7 +1103,7 @@ async def handle_codex_slash_command(text: str, user_id: str) -> str | None:
|
|
|
883
1103
|
return await _cmd_model(args, user_id)
|
|
884
1104
|
|
|
885
1105
|
if cmd == "/models":
|
|
886
|
-
return _cmd_models()
|
|
1106
|
+
return await _cmd_models(user_id)
|
|
887
1107
|
|
|
888
1108
|
if cmd == "/add-dir":
|
|
889
1109
|
path = args.strip()
|
|
@@ -324,18 +324,26 @@ def _extract_grok_artifacts(session_dir: str, session_id: str, since: float = 0.
|
|
|
324
324
|
continue
|
|
325
325
|
if name in ("write", "edit", "str_replace") and isinstance(args, dict):
|
|
326
326
|
fp = args.get("file_path", "")
|
|
327
|
-
if
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
327
|
+
if not fp:
|
|
328
|
+
continue
|
|
329
|
+
# Relative paths resolve against session_dir (cwd)
|
|
330
|
+
if not os.path.isabs(fp):
|
|
331
|
+
fp = os.path.join(session_dir, fp)
|
|
332
|
+
try:
|
|
333
|
+
fp = os.path.abspath(fp)
|
|
334
|
+
except (OSError, ValueError):
|
|
335
|
+
continue
|
|
336
|
+
# 只收录本轮运行期间新写/修改的文件
|
|
337
|
+
try:
|
|
338
|
+
if since and os.path.getmtime(fp) < since - 2.0:
|
|
339
|
+
continue
|
|
340
|
+
except OSError:
|
|
341
|
+
continue # 文件已不存在,无需回传
|
|
342
|
+
art_name = os.path.basename(fp)
|
|
343
|
+
key = (art_name, fp)
|
|
344
|
+
if key not in seen:
|
|
345
|
+
seen.add(key)
|
|
346
|
+
artifacts.append(key)
|
|
339
347
|
except OSError as e:
|
|
340
348
|
logger.warning("Failed to read chat_history.jsonl: %s", e)
|
|
341
349
|
|
|
@@ -25,6 +25,30 @@ logger = logging.getLogger("ilink")
|
|
|
25
25
|
|
|
26
26
|
ILINK_BASE = config.ilink_base_url.rstrip("/")
|
|
27
27
|
|
|
28
|
+
# WeChat image_item handles these well; other image/* (svg/heic/…) go as FILE.
|
|
29
|
+
_DISPLAYABLE_IMAGE_MIMES = frozenset({
|
|
30
|
+
"image/jpeg",
|
|
31
|
+
"image/png",
|
|
32
|
+
"image/gif",
|
|
33
|
+
"image/webp",
|
|
34
|
+
"image/bmp",
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
MEDIA_IMAGE = 1
|
|
38
|
+
MEDIA_FILE = 3
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def media_type_for_path(path: str) -> int:
|
|
42
|
+
"""Return MEDIA_IMAGE or MEDIA_FILE for a local path based on guessed MIME.
|
|
43
|
+
|
|
44
|
+
Only common displayable raster images use IMAGE; everything else
|
|
45
|
+
(pdf/doc/xlsx/txt/zip, svg/heic, unknown) uses FILE so file_name is kept.
|
|
46
|
+
"""
|
|
47
|
+
mime = mimetypes.guess_type(path)[0] or "application/octet-stream"
|
|
48
|
+
if mime in _DISPLAYABLE_IMAGE_MIMES:
|
|
49
|
+
return MEDIA_IMAGE
|
|
50
|
+
return MEDIA_FILE
|
|
51
|
+
|
|
28
52
|
|
|
29
53
|
def ilink_delivery_accepted(ret, message_id) -> bool:
|
|
30
54
|
"""Whether an iLink sendmessage JSON body means the message was accepted.
|
|
@@ -530,12 +554,8 @@ class ILinkClient:
|
|
|
530
554
|
ciphertext = _encrypt_aes_ecb(plaintext, aes_key)
|
|
531
555
|
filesize = len(ciphertext)
|
|
532
556
|
|
|
533
|
-
# Determine media_type from mime
|
|
534
|
-
|
|
535
|
-
if mime.startswith("image/"):
|
|
536
|
-
media_type = 1 # MEDIA_IMAGE
|
|
537
|
-
else:
|
|
538
|
-
media_type = 3 # MEDIA_FILE
|
|
557
|
+
# Determine media_type from mime (only common rasters → IMAGE)
|
|
558
|
+
media_type = media_type_for_path(path)
|
|
539
559
|
|
|
540
560
|
# Get CDN upload URL
|
|
541
561
|
upload_param, upload_full_url = await self.get_upload_url(
|
|
@@ -23,6 +23,7 @@ from .runner_common import (
|
|
|
23
23
|
clean_session_media,
|
|
24
24
|
clear_initialized,
|
|
25
25
|
classify_upstream_failure,
|
|
26
|
+
format_artifact_send_failure_notice,
|
|
26
27
|
format_error,
|
|
27
28
|
format_model_label,
|
|
28
29
|
format_oversized_artifact_notice,
|
|
@@ -646,8 +647,9 @@ async def gate_and_run(
|
|
|
646
647
|
async def send_artifacts_back(client, from_user, context_token, artifacts) -> None:
|
|
647
648
|
"""Filter artifacts: only send back those under per-user session dir.
|
|
648
649
|
|
|
649
|
-
For agy: artifacts under .gemini/antigravity-cli/scratch
|
|
650
|
-
For
|
|
650
|
+
For agy: artifacts under .gemini/antigravity-cli/scratch (plus validated --add-dir)
|
|
651
|
+
For codex: artifacts under session_dir (plus validated --add-dir)
|
|
652
|
+
For grok: artifacts under session_dir (cwd where grok ran; no add-dir gate)
|
|
651
653
|
"""
|
|
652
654
|
session_dir = get_session_dir(from_user)
|
|
653
655
|
backend = _get_backend(from_user)
|
|
@@ -659,22 +661,34 @@ async def send_artifacts_back(client, from_user, context_token, artifacts) -> No
|
|
|
659
661
|
# codex runs with cwd=session_dir; file_change paths may also land in
|
|
660
662
|
# user-approved --add-dir roots, so allow those too.
|
|
661
663
|
allowed_root = session_dir
|
|
664
|
+
else:
|
|
665
|
+
# agy writes to .gemini/antigravity-cli/scratch under session_dir
|
|
666
|
+
allowed_root = os.path.join(session_dir, ".gemini", "antigravity-cli", "scratch")
|
|
667
|
+
|
|
668
|
+
# codex and agy both support --add-dir; re-verify stored roots at send time.
|
|
669
|
+
# Only keep roots that currently exist, are real directories, and still
|
|
670
|
+
# resolve inside the configured allowed roots (session dir + config
|
|
671
|
+
# add_dir_roots). Deleted dirs, plain files, out-of-bounds paths and
|
|
672
|
+
# symlink escapes must not become artifact allow roots. Legitimate
|
|
673
|
+
# directories are kept (and still re-checked against each artifact path
|
|
674
|
+
# below). session_dir itself is never relaxed. grok does not use add-dir.
|
|
675
|
+
if backend in ("codex", "agy"):
|
|
662
676
|
prefs = load_prefs(from_user)
|
|
663
|
-
# Second-factor verification of stored --add-dir roots at send time.
|
|
664
|
-
# Only keep roots that currently exist, are real directories, and still
|
|
665
|
-
# resolve inside the configured allowed roots (session dir + config
|
|
666
|
-
# add_dir_roots). Deleted dirs, plain files, out-of-bounds paths and
|
|
667
|
-
# symlink escapes must not become artifact allow roots. Legitimate
|
|
668
|
-
# directories are kept (and still re-checked against each artifact path
|
|
669
|
-
# below). session_dir itself is never relaxed.
|
|
670
|
-
add_dirs = []
|
|
671
677
|
for d in prefs.get("add_dirs", []) or []:
|
|
672
678
|
ok, resolved = validate_add_dir(d, from_user)
|
|
673
679
|
if ok:
|
|
674
680
|
add_dirs.append(resolved)
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
681
|
+
|
|
682
|
+
async def _notify_failure(name: str, reason: str) -> None:
|
|
683
|
+
# Never echo server absolute paths to WeChat users
|
|
684
|
+
await client.send_message(
|
|
685
|
+
to_user_id=from_user,
|
|
686
|
+
text=format_artifact_send_failure_notice(name, reason),
|
|
687
|
+
context_token=context_token,
|
|
688
|
+
baseurl=client.state.baseurl,
|
|
689
|
+
bot_token=client.state.bot_token,
|
|
690
|
+
)
|
|
691
|
+
|
|
678
692
|
for art_name, art_path in artifacts:
|
|
679
693
|
try:
|
|
680
694
|
# realpath check blocks symlink escape outside allowed root
|
|
@@ -686,9 +700,11 @@ async def send_artifacts_back(client, from_user, context_token, artifacts) -> No
|
|
|
686
700
|
break
|
|
687
701
|
if not ok_root:
|
|
688
702
|
logger.debug("skip non-scratch artifact: %s", art_path)
|
|
703
|
+
await _notify_failure(art_name, "skipped")
|
|
689
704
|
continue
|
|
690
705
|
if not os.path.isfile(os.path.realpath(art_path)):
|
|
691
706
|
logger.warning("Artifact not found: %s", art_path)
|
|
707
|
+
await _notify_failure(art_name, "not_found")
|
|
692
708
|
continue
|
|
693
709
|
art_path = os.path.realpath(art_path)
|
|
694
710
|
file_size = os.path.getsize(art_path)
|
|
@@ -719,8 +735,13 @@ async def send_artifacts_back(client, from_user, context_token, artifacts) -> No
|
|
|
719
735
|
logger.info("Artifact sent: %s -> %s", art_name, from_user)
|
|
720
736
|
else:
|
|
721
737
|
logger.warning("Failed to send artifact: %s", art_name)
|
|
738
|
+
await _notify_failure(art_name, "send_failed")
|
|
722
739
|
except Exception as e:
|
|
723
740
|
logger.exception("Error sending artifact %s: %s", art_name, e)
|
|
741
|
+
try:
|
|
742
|
+
await _notify_failure(art_name, "error")
|
|
743
|
+
except Exception:
|
|
744
|
+
logger.exception("Failed to notify user about artifact error: %s", art_name)
|
|
724
745
|
|
|
725
746
|
|
|
726
747
|
# ---------------------------------------------------------------------------
|
|
@@ -259,6 +259,30 @@ def format_oversized_artifact_notice(art_name: str, size_mb: float) -> str:
|
|
|
259
259
|
)
|
|
260
260
|
|
|
261
261
|
|
|
262
|
+
# Internal reason codes for format_artifact_send_failure_notice
|
|
263
|
+
_ARTIFACT_FAIL_REASONS = {
|
|
264
|
+
"skipped": "不在允许回传的目录内,已跳过",
|
|
265
|
+
"not_found": "文件不存在或不是普通文件",
|
|
266
|
+
"send_failed": "发送失败,请稍后重试",
|
|
267
|
+
"error": "发送出错,请稍后重试",
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def format_artifact_send_failure_notice(art_name: str, reason: str) -> str:
|
|
272
|
+
"""User-facing text when an artifact cannot be sent back to WeChat.
|
|
273
|
+
|
|
274
|
+
``reason`` is an internal code: skipped / not_found / send_failed / error.
|
|
275
|
+
Must never include server absolute paths — only the display name and reason.
|
|
276
|
+
"""
|
|
277
|
+
name = (art_name or "file").replace("`", "'").strip() or "file"
|
|
278
|
+
detail = _ARTIFACT_FAIL_REASONS.get(reason) or _ARTIFACT_FAIL_REASONS["error"]
|
|
279
|
+
return (
|
|
280
|
+
f"⚠️ **文件未能发送** ⚠️\n\n"
|
|
281
|
+
f"`{name}`\n"
|
|
282
|
+
f"{detail}"
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
262
286
|
def format_cli_error(raw_message: str, *, backend: str = "") -> str:
|
|
263
287
|
"""Map backend stderr/JSON error text into a short Chinese user reply.
|
|
264
288
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wechatbridge-cli
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.4
|
|
4
4
|
Summary: Bridge WeChat messages to agy, Grok Build, or Codex CLIs — text/image/file/voice in, CLI replies and generated files back.
|
|
5
5
|
Author: WeChatBridge contributors
|
|
6
6
|
License: MIT
|
|
@@ -250,8 +250,8 @@ See [`deploy/wechatbridge-windows.md`](deploy/wechatbridge-windows.md).
|
|
|
250
250
|
| `/help` | list supported commands for the active backend |
|
|
251
251
|
| `/backend <agy\|grok\|codex>` | switch CLI backend for this WeChat user (on real change: clears that backend's continuation state — agy/grok flag and codex `thread_id`/resume — so the next turn starts a fresh session; history files may remain until retention cleanup) |
|
|
252
252
|
| `/clear` or `/new` | drop continue flag so the next CLI turn is a new conversation (does not instantly delete history files) |
|
|
253
|
-
| `/model <name>` | set model (
|
|
254
|
-
| `/models` | list models — agy/grok query the live CLI
|
|
253
|
+
| `/model <name>` | set model (all backends validate against a live list: agy/grok via CLI `models`; codex via `codex debug models` [then `--bundled`]; unknown name or list-fetch failure refuse and do not write prefs; see `/models`) |
|
|
254
|
+
| `/models` | list models — agy/grok/codex all query the live CLI (codex: `debug models`; falls back to a built-in reference note only if the live list cannot be fetched) |
|
|
255
255
|
| `/fast` | set low reasoning effort (**on only** — not a toggle; no “off” command) |
|
|
256
256
|
| `/planning` | set planning mode (**on only** — not a toggle) |
|
|
257
257
|
| `/add-dir <path>` | **agy:** pass `--add-dir` on later runs if path is allowed. **grok:** recorded only; not passed to the CLI yet |
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.4}/wechatbridge_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{wechatbridge_cli-1.4.2 → wechatbridge_cli-1.4.4}/wechatbridge_cli.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|