From 4c86a8f92a08c358d64b9549b6e91c83d1fa8b30 Mon Sep 17 00:00:00 2001 From: UnsongK Date: Thu, 23 Apr 2026 23:21:12 +0800 Subject: [PATCH 1/3] Add permission for WeChat Official Account content upload --- frontends/wxoaapp.py | 164 +++++++++++++++++++++++++++++++++++++++++++ mykey_template.py | 86 ++++++++--------------- 2 files changed, 194 insertions(+), 56 deletions(-) create mode 100644 frontends/wxoaapp.py diff --git a/frontends/wxoaapp.py b/frontends/wxoaapp.py new file mode 100644 index 00000000..1bba8bf9 --- /dev/null +++ b/frontends/wxoaapp.py @@ -0,0 +1,164 @@ +""" +微信公众号草稿箱工具 + + python frontends/wxoaapp.py token + python frontends/wxoaapp.py upload_thumb /path/to/cover.jpg + python frontends/wxoaapp.py add_draft --title "标题" --content "HTML" [--author ...] [--digest ...] [--thumb ...] + python frontends/wxoaapp.py add_draft --title "标题" --content @/path/to/file.html + +mykey.py: wx_oa_appid, wx_oa_appsecret +""" + +import json, os, sys, time, argparse +import requests + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from llmcore import mykeys + +APPID = str(mykeys.get("wx_oa_appid", "") or "").strip() +SECRET = str(mykeys.get("wx_oa_appsecret", "") or "").strip() +API = "https://api.weixin.qq.com/cgi-bin" +_TOKEN_CACHE = {"token": "", "expires_at": 0} + + +def _whitelist_ip_reminder(resp) -> str: + s = json.dumps(resp, ensure_ascii=False) if isinstance(resp, dict) else str(resp) + if "40164" not in s and "whitelist" not in s.lower() and "白名单" not in s and "not in whitelist" not in s.lower(): + return "" + return ( + "\n[提示] 当前出口 IP 未在公众号「IP 白名单」内。" + "请查看上方微信返回的 errmsg(一般会写出被拒绝的 IP)," + "到微信公众平台 → 设置与开发 → 基本配置 → IP白名单,将该 IP 加入后重试。" + ) + + +def get_access_token(): + if time.time() < _TOKEN_CACHE["expires_at"] - 60: + return _TOKEN_CACHE["token"] + r = requests.get( + f"{API}/token", + params={"grant_type": "client_credential", "appid": APPID, "secret": SECRET}, + timeout=10, + ).json() + if "access_token" not in r: + raise RuntimeError(f"获取 access_token 失败: {r}{_whitelist_ip_reminder(r)}") + _TOKEN_CACHE["token"] = r["access_token"] + _TOKEN_CACHE["expires_at"] = time.time() + r.get("expires_in", 7200) + return _TOKEN_CACHE["token"] + + +def upload_thumb(image_path): + token = get_access_token() + with open(image_path, "rb") as f: + r = requests.post( + f"{API}/material/add_material", + params={"access_token": token, "type": "image"}, + files={"media": (os.path.basename(image_path), f)}, + timeout=30, + ).json() + if "media_id" not in r: + raise RuntimeError(f"上传封面图失败: {r}{_whitelist_ip_reminder(r)}") + return r["media_id"] + + +def add_draft(articles): + token = get_access_token() + payload = json.dumps({"articles": articles}, ensure_ascii=False).encode("utf-8") + r = requests.post( + f"{API}/draft/add", + params={"access_token": token}, + data=payload, + headers={"Content-Type": "application/json"}, + timeout=30, + ).json() + if "media_id" not in r: + raise RuntimeError(f"上传草稿失败: {r}{_whitelist_ip_reminder(r)}") + return r["media_id"] + + +def _check_config(): + if not APPID or not SECRET: + print("ERROR: 请在 mykey.py 中配置 wx_oa_appid 和 wx_oa_appsecret") + sys.exit(1) + + +def load_content_from_arg(content: str) -> str: + """--content 若以 @ 开头则读取该文件 UTF-8 全文;否则原样作为正文。""" + if not isinstance(content, str): + content = str(content) + head = content.strip() + if not head.startswith("@"): + return content + path_part = head[1:].strip() + if not path_part: + raise ValueError("--content 以 @ 开头但路径为空") + path = os.path.expanduser(path_part) + if not os.path.isabs(path): + path = os.path.abspath(path) + if not os.path.isfile(path): + raise FileNotFoundError( + f"文件不存在或不是普通文件: {path_part!r} -> {path!r}" + ) + with open(path, encoding="utf-8", errors="replace") as f: + return f.read() + + +def main(): + parser = argparse.ArgumentParser(description="微信公众号草稿箱工具") + sub = parser.add_subparsers(dest="cmd") + + sub.add_parser("token", help="获取并打印 access_token") + + p_thumb = sub.add_parser("upload_thumb", help="上传封面图") + p_thumb.add_argument("image", help="图片路径") + + p_draft = sub.add_parser("add_draft", help="上传文章到草稿箱") + p_draft.add_argument("--title", required=True) + p_draft.add_argument( + "--content", + required=True, + help="HTML 正文;若以 @ 开头则为文件路径(UTF-8)", + ) + p_draft.add_argument("--author", default="") + p_draft.add_argument("--digest", default="") + p_draft.add_argument("--thumb", default="", help="封面图路径(可选)") + p_draft.add_argument("--thumb_media_id", default="", help="已有封面 media_id(可选)") + + args = parser.parse_args() + _check_config() + + try: + if args.cmd == "token": + print(get_access_token()) + elif args.cmd == "upload_thumb": + mid = upload_thumb(args.image) + print(f"thumb_media_id: {mid}") + elif args.cmd == "add_draft": + try: + content = load_content_from_arg(args.content) + except (OSError, ValueError) as e: + print(f"ERROR: {e}", file=sys.stderr) + sys.exit(2) + thumb_media_id = args.thumb_media_id + if args.thumb and not thumb_media_id: + thumb_media_id = upload_thumb(args.thumb) + print(f"封面已上传: {thumb_media_id}") + article = { + "title": args.title, + "content": content, + "author": args.author, + "digest": args.digest, + } + if thumb_media_id: + article["thumb_media_id"] = thumb_media_id + media_id = add_draft([article]) + print(f"草稿已上传,media_id: {media_id}") + else: + parser.print_help() + except RuntimeError as e: + print(f"ERROR: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/mykey_template.py b/mykey_template.py index dc681556..8dc83bfa 100644 --- a/mykey_template.py +++ b/mykey_template.py @@ -107,9 +107,6 @@ # 默认 False。关键字段:**所有反代/镜像 Claude Code 协议的渠道 # 都必须置 True**(CC switch、anyrouter、claude-relay-service # 等)。真 Anthropic 端点(sk-ant-)不需要开。 -# user_agent 默认 'claude-cli/2.1.113 (external, cli)'。可传入任意版本号 -# 字符串覆盖。某些第三方中转(tabcode、anyrouter 等)会按 UA -# 白名单校验,CC 升版本后被拒可在此 pin 老版本绕过。 # ══════════════════════════════════════════════════════════════════════════════ @@ -126,10 +123,8 @@ # llm_nos 里的字符串必须和被引用 session 的 'name' 字段匹配(也可以写整数索 # 引)。约束:引用的 session 必须全是 Native 系列(NativeClaudeSession 和 # NativeOAISession 可以混用)或者全不是 Native,不能 Native 与非 Native 混。 -# 请你按需 mixin_config = { - 'llm_nos': ['gpt-native'], # 按优先级排列;Claude 与 GPT 混用 - # 'llm_nos': ['cc-relay-1', 'cc-relay-2', 'gpt-native'], # 按优先级排列;Claude 与 GPT 混用,注意: 启用时需要启用'cc-relay-1', 'cc-relay-2'配置! + 'llm_nos': ['cc-relay-1', 'cc-relay-2', 'gpt-native'], # 按优先级排列;Claude 与 GPT 混用 'max_retries': 10, # int;整个 rotation 的总重试次数上限 'base_delay': 0.5, # float 秒;指数退避起始延迟(retry n 时延迟≈base_delay * 2^n) # 'spring_back': 300, # int 秒;切到备用节点后多久再尝试回到第一个节点 @@ -147,27 +142,24 @@ # ── 1a. CC switch 适配渠道(最常用)──────────────────────────────────────── # 这类渠道把 Claude Code 协议透传到上游,apikey 格式各异(sk-user-*, sk-*, cr_* # 等),统一走 Bearer 鉴权。必须设置 fake_cc_system_prompt=True。 -# native_claude_config0 = { -# 'name': 'cc-relay-1', # /llms 显示名 & mixin 引用名 -# 'apikey': 'sk-user-', # 非 sk-ant- 前缀 → Bearer 鉴权 -# 'apibase': 'https:///claude/office', # CC switch 端点 -# 'model': 'claude-opus-4-7', # 或 claude-sonnet-4-6 -# 'fake_cc_system_prompt': True, # CC 透传渠道必须置 True -# 'thinking_type': 'adaptive', # 某些渠道必须要求填写thinking_type字段 -# } +native_claude_config0 = { + 'name': 'cc-relay-1', # /llms 显示名 & mixin 引用名 + 'apikey': 'sk-user-', # 非 sk-ant- 前缀 → Bearer 鉴权 + 'apibase': 'https:///claude/office', # CC switch 端点 + 'model': 'claude-opus-4-6', # 或 claude-sonnet-4-6 + 'fake_cc_system_prompt': True, # CC 透传渠道必须置 True +} -# native_claude_config1 = { -# 'name': 'cc-relay-2', # /llms 显示名 & mixin 引用名 -# 'apikey': 'sk-', -# 'apibase': 'https://', -# 'model': 'claude-opus-4-7[1m]', # [1m] 触发 1m 上下文 beta -# 'fake_cc_system_prompt': True, -# 'thinking_type': 'adaptive', # 某些渠道必须要求填写thinking_type字段 -# 'max_retries': 3, -# 'read_timeout': 300, # 1m 上下文响应可能较慢 -# 'stream': False, # 某些渠道不支持 SSE 流式时改 False -# # 'user_agent': 'claude-cli/2.1.113 (external, cli)', -# } +native_claude_config1 = { + 'name': 'cc-relay-2', # /llms 显示名 & mixin 引用名 + 'apikey': 'sk-', + 'apibase': 'https://', + 'model': 'claude-opus-4-6[1m]', # [1m] 触发 1m 上下文 beta + 'fake_cc_system_prompt': True, + 'max_retries': 3, + 'read_timeout': 300, # 1m 上下文响应可能较慢 + # 'stream': False, # 某些渠道不支持 SSE 流式时改 False +} # ── 1b. Anthropic 官方直连 ────────────────────────────────────────────────── # 官方端点,apikey 以 sk-ant- 开头 → 自动切到 x-api-key 鉴权。 @@ -176,7 +168,7 @@ # 'name': 'anthropic-direct', # /llms 显示名 & mixin 引用名 # 'apikey': 'sk-ant-', # sk-ant- 前缀 → 自动走 x-api-key 头 # 'apibase': 'https://api.anthropic.com', # NativeClaudeSession 自动附加 ?beta=true -# 'model': 'claude-opus-4-7[1m]', # [1m] 触发 1m 上下文 beta +# 'model': 'claude-opus-4-6[1m]', # [1m] 触发 1m 上下文 beta # # ── 思考控制(thinking_type 与 reasoning_effort 独立,可同时写)── # 'thinking_type': 'adaptive', # 合法值: 'adaptive' / 'enabled' / 'disabled' # # adaptive = Claude Code 默认,模型自决预算 @@ -207,7 +199,7 @@ # 'name': 'crs-claude-max', # /llms 显示名 # 'apikey': 'cr_', # cr_ 开头 → Bearer 鉴权(64 位 hex) # 'apibase': 'https:///api',# CRS 的 Anthropic 兼容路径 -# 'model': 'claude-opus-4-7[1m]', # [1m] 触发 1m beta +# 'model': 'claude-opus-4-6[1m]', # [1m] 触发 1m beta # 'fake_cc_system_prompt': True, # bool 必填 True;CRS 也校验 CC 系统串 # 'thinking_type': 'adaptive', # 'adaptive'/'enabled'/'disabled' # # 'reasoning_effort': 'high', # 可选;写进 output_config.effort @@ -219,15 +211,15 @@ # ── 1d. CRS Gemini Ultra (Antigravity 通道) ───────────────────────────────── # CRS 把 Google Antigravity (Gemini Ultra) 包装成 Anthropic 风格接口。 # URL 路径带 /antigravity/api: -# - 'claude-opus-4-7-thinking' (CRS 原始名) -# - 'claude-opus-4-7[1m]' (触发 1m beta,CRS 会忽略多余的 beta) -# - 'claude-opus-4-7' (最简) +# - 'claude-opus-4-6-thinking' (CRS 原始名) +# - 'claude-opus-4-6[1m]' (触发 1m beta,CRS 会忽略多余的 beta) +# - 'claude-opus-4-6' (最简) # ⚠ 此通道不支持 SSE 流式,必须 stream=False。 # native_claude_config_crs_gemini = { # 'name': 'crs-gemini-ultra', # /llms 显示名 # 'apikey': 'cr_', # cr_ 前缀 → Bearer # 'apibase': 'https:///antigravity/api', -# 'model': 'claude-opus-4-7-thinking', # 或 'claude-opus-4-7[1m]' 或 'claude-opus-4-7' +# 'model': 'claude-opus-4-6-thinking', # 或 'claude-opus-4-6[1m]' 或 'claude-opus-4-6' # 'stream': False, # Antigravity 不支持 SSE 流式,stream=True 会返回伪错误 # 'max_tokens': 32768, # int # 'max_retries': 3, # int @@ -263,19 +255,6 @@ # # 'fake_cc_system_prompt': False, # MiniMax 不做 CC 指纹校验 # } -# ── 1g. Kimi for Coding (Anthropic 兼容 CC 透传端点) ────────────────────── -# Kimi 官方为 Claude Code / Codex 开放的 /coding 路径,走 Anthropic 协议。 -# 与 4b 的 Moonshot OAI 路径是两回事:model 用 'kimi-for-coding'(非 kimi-k2)。 -# 官方硬要求透传 CC system prompt → fake_cc_system_prompt=True 必填。 -# 文档: https://www.kimi.com/code/docs/third-party-tools/other-coding-agents.html -# native_claude_config_kimi = { -# 'name': 'kimi-coding', # /llms 显示名 & mixin 引用名 -# 'apikey': 'sk-kimi-', # Bearer 鉴权 -# 'apibase': 'https://api.kimi.com/coding',# Anthropic 兼容端点 -# 'model': 'kimi-for-coding', # 官方 coding 专用 model id -# 'fake_cc_system_prompt': True, # 必填;官方硬要求透传 CC 系统串 -# 'thinking_type': 'adaptive', # 'adaptive'/'enabled'/'disabled' -# } # ══════════════════════════════════════════════════════════════════════════════ # 2. NativeOAISession — OpenAI 协议 + 原生工具 @@ -330,7 +309,7 @@ # 'name': 'my-oai-proxy', # /llms 显示名 & mixin 引用名 # 'apikey': 'sk-', # Bearer 鉴权 # 'apibase': 'http://:2001', # 自动补 /v1/chat/completions -# 'model': 'gpt-5.4', # 或 claude-opus-4-7、gemini-3-flash 等 +# 'model': 'gpt-5.4', # 或 claude-opus-4-6、gemini-3-flash 等 # 'api_mode': 'chat_completions', # 'chat_completions'(默认)|'responses' # # 'reasoning_effort': 'high', # none|minimal|low|medium|high|xhigh # 'max_retries': 3, # int 默认 1 @@ -346,7 +325,7 @@ # # oai_config2 = { # # 'apikey': 'sk-...', # # 'apibase': 'http://your-proxy:2001', -# # 'model': 'claude-opus-4-7', +# # 'model': 'claude-opus-4-6', # # } @@ -380,12 +359,12 @@ # ── 4c. OpenRouter (OAI 协议多模型中继) ───────────────────────────────────── # OpenRouter 是最通用的多模型 OAI 中继,https://openrouter.ai/api/v1。 -# model 名用 provider/model 格式(如 anthropic/claude-opus-4-7)。 +# model 名用 provider/model 格式(如 anthropic/claude-opus-4-6)。 # oai_config_openrouter = { # 'name': 'openrouter-claude', # /llms 显示名 & mixin 引用名;省略则取 model # 'apikey': 'sk-or-', # OpenRouter key 形如 sk-or-xxx;Bearer 鉴权 # 'apibase': 'https://openrouter.ai/api/v1', # 补齐到 /v1/chat/completions -# 'model': 'anthropic/claude-opus-4-7', # provider/model 格式 +# 'model': 'anthropic/claude-opus-4-6', # provider/model 格式 # 'max_retries': 3, # int 默认 1 # 'connect_timeout': 10, # int 秒 默认 5(最小 1) # 'read_timeout': 120, # int 秒 默认 30(最小 5) @@ -416,10 +395,5 @@ # dingtalk_client_id = 'your_app_key' # dingtalk_client_secret = 'your_app_secret' # dingtalk_allowed_users = ['your_staff_id'] # 留空或 ['*'] 表示允许所有钉钉用户 - -# 可选:Langfuse 追踪。不设此项则不 import langfuse,零影响 -# langfuse_config = { -# 'public_key': 'pk-lf-...', -# 'secret_key': 'sk-lf-...', -# 'host': 'https://cloud.langfuse.com', # 或自托管地址 -# } +# wx_oa_appid = 'wx1234567890abcdef' # 微信公众号 AppID +# wx_oa_appsecret = 'your_appsecret_here' # 微信公众号 AppSecret From 63bd1d5e505ae0b72f778498e971d34f2c84309e Mon Sep 17 00:00:00 2001 From: UnsongK Date: Thu, 23 Apr 2026 23:28:56 +0800 Subject: [PATCH 2/3] mykey_template: document wx_oa appid/secret for wxoaapp --- mykey_template.py | 84 +++++++++++++++++++++++++++++++---------------- 1 file changed, 56 insertions(+), 28 deletions(-) diff --git a/mykey_template.py b/mykey_template.py index 8dc83bfa..fe41a7b0 100644 --- a/mykey_template.py +++ b/mykey_template.py @@ -107,6 +107,9 @@ # 默认 False。关键字段:**所有反代/镜像 Claude Code 协议的渠道 # 都必须置 True**(CC switch、anyrouter、claude-relay-service # 等)。真 Anthropic 端点(sk-ant-)不需要开。 +# user_agent 默认 'claude-cli/2.1.113 (external, cli)'。可传入任意版本号 +# 字符串覆盖。某些第三方中转(tabcode、anyrouter 等)会按 UA +# 白名单校验,CC 升版本后被拒可在此 pin 老版本绕过。 # ══════════════════════════════════════════════════════════════════════════════ @@ -123,8 +126,10 @@ # llm_nos 里的字符串必须和被引用 session 的 'name' 字段匹配(也可以写整数索 # 引)。约束:引用的 session 必须全是 Native 系列(NativeClaudeSession 和 # NativeOAISession 可以混用)或者全不是 Native,不能 Native 与非 Native 混。 +# 请你按需 mixin_config = { - 'llm_nos': ['cc-relay-1', 'cc-relay-2', 'gpt-native'], # 按优先级排列;Claude 与 GPT 混用 + 'llm_nos': ['gpt-native'], # 按优先级排列;Claude 与 GPT 混用 + # 'llm_nos': ['cc-relay-1', 'cc-relay-2', 'gpt-native'], # 按优先级排列;Claude 与 GPT 混用,注意: 启用时需要启用'cc-relay-1', 'cc-relay-2'配置! 'max_retries': 10, # int;整个 rotation 的总重试次数上限 'base_delay': 0.5, # float 秒;指数退避起始延迟(retry n 时延迟≈base_delay * 2^n) # 'spring_back': 300, # int 秒;切到备用节点后多久再尝试回到第一个节点 @@ -142,24 +147,27 @@ # ── 1a. CC switch 适配渠道(最常用)──────────────────────────────────────── # 这类渠道把 Claude Code 协议透传到上游,apikey 格式各异(sk-user-*, sk-*, cr_* # 等),统一走 Bearer 鉴权。必须设置 fake_cc_system_prompt=True。 -native_claude_config0 = { - 'name': 'cc-relay-1', # /llms 显示名 & mixin 引用名 - 'apikey': 'sk-user-', # 非 sk-ant- 前缀 → Bearer 鉴权 - 'apibase': 'https:///claude/office', # CC switch 端点 - 'model': 'claude-opus-4-6', # 或 claude-sonnet-4-6 - 'fake_cc_system_prompt': True, # CC 透传渠道必须置 True -} +# native_claude_config0 = { +# 'name': 'cc-relay-1', # /llms 显示名 & mixin 引用名 +# 'apikey': 'sk-user-', # 非 sk-ant- 前缀 → Bearer 鉴权 +# 'apibase': 'https:///claude/office', # CC switch 端点 +# 'model': 'claude-opus-4-7', # 或 claude-sonnet-4-6 +# 'fake_cc_system_prompt': True, # CC 透传渠道必须置 True +# 'thinking_type': 'adaptive', # 某些渠道必须要求填写thinking_type字段 +# } -native_claude_config1 = { - 'name': 'cc-relay-2', # /llms 显示名 & mixin 引用名 - 'apikey': 'sk-', - 'apibase': 'https://', - 'model': 'claude-opus-4-6[1m]', # [1m] 触发 1m 上下文 beta - 'fake_cc_system_prompt': True, - 'max_retries': 3, - 'read_timeout': 300, # 1m 上下文响应可能较慢 - # 'stream': False, # 某些渠道不支持 SSE 流式时改 False -} +# native_claude_config1 = { +# 'name': 'cc-relay-2', # /llms 显示名 & mixin 引用名 +# 'apikey': 'sk-', +# 'apibase': 'https://', +# 'model': 'claude-opus-4-7[1m]', # [1m] 触发 1m 上下文 beta +# 'fake_cc_system_prompt': True, +# 'thinking_type': 'adaptive', # 某些渠道必须要求填写thinking_type字段 +# 'max_retries': 3, +# 'read_timeout': 300, # 1m 上下文响应可能较慢 +# 'stream': False, # 某些渠道不支持 SSE 流式时改 False +# # 'user_agent': 'claude-cli/2.1.113 (external, cli)', +# } # ── 1b. Anthropic 官方直连 ────────────────────────────────────────────────── # 官方端点,apikey 以 sk-ant- 开头 → 自动切到 x-api-key 鉴权。 @@ -168,7 +176,7 @@ # 'name': 'anthropic-direct', # /llms 显示名 & mixin 引用名 # 'apikey': 'sk-ant-', # sk-ant- 前缀 → 自动走 x-api-key 头 # 'apibase': 'https://api.anthropic.com', # NativeClaudeSession 自动附加 ?beta=true -# 'model': 'claude-opus-4-6[1m]', # [1m] 触发 1m 上下文 beta +# 'model': 'claude-opus-4-7[1m]', # [1m] 触发 1m 上下文 beta # # ── 思考控制(thinking_type 与 reasoning_effort 独立,可同时写)── # 'thinking_type': 'adaptive', # 合法值: 'adaptive' / 'enabled' / 'disabled' # # adaptive = Claude Code 默认,模型自决预算 @@ -199,7 +207,7 @@ # 'name': 'crs-claude-max', # /llms 显示名 # 'apikey': 'cr_', # cr_ 开头 → Bearer 鉴权(64 位 hex) # 'apibase': 'https:///api',# CRS 的 Anthropic 兼容路径 -# 'model': 'claude-opus-4-6[1m]', # [1m] 触发 1m beta +# 'model': 'claude-opus-4-7[1m]', # [1m] 触发 1m beta # 'fake_cc_system_prompt': True, # bool 必填 True;CRS 也校验 CC 系统串 # 'thinking_type': 'adaptive', # 'adaptive'/'enabled'/'disabled' # # 'reasoning_effort': 'high', # 可选;写进 output_config.effort @@ -211,15 +219,15 @@ # ── 1d. CRS Gemini Ultra (Antigravity 通道) ───────────────────────────────── # CRS 把 Google Antigravity (Gemini Ultra) 包装成 Anthropic 风格接口。 # URL 路径带 /antigravity/api: -# - 'claude-opus-4-6-thinking' (CRS 原始名) -# - 'claude-opus-4-6[1m]' (触发 1m beta,CRS 会忽略多余的 beta) -# - 'claude-opus-4-6' (最简) +# - 'claude-opus-4-7-thinking' (CRS 原始名) +# - 'claude-opus-4-7[1m]' (触发 1m beta,CRS 会忽略多余的 beta) +# - 'claude-opus-4-7' (最简) # ⚠ 此通道不支持 SSE 流式,必须 stream=False。 # native_claude_config_crs_gemini = { # 'name': 'crs-gemini-ultra', # /llms 显示名 # 'apikey': 'cr_', # cr_ 前缀 → Bearer # 'apibase': 'https:///antigravity/api', -# 'model': 'claude-opus-4-6-thinking', # 或 'claude-opus-4-6[1m]' 或 'claude-opus-4-6' +# 'model': 'claude-opus-4-7-thinking', # 或 'claude-opus-4-7[1m]' 或 'claude-opus-4-7' # 'stream': False, # Antigravity 不支持 SSE 流式,stream=True 会返回伪错误 # 'max_tokens': 32768, # int # 'max_retries': 3, # int @@ -255,6 +263,19 @@ # # 'fake_cc_system_prompt': False, # MiniMax 不做 CC 指纹校验 # } +# ── 1g. Kimi for Coding (Anthropic 兼容 CC 透传端点) ────────────────────── +# Kimi 官方为 Claude Code / Codex 开放的 /coding 路径,走 Anthropic 协议。 +# 与 4b 的 Moonshot OAI 路径是两回事:model 用 'kimi-for-coding'(非 kimi-k2)。 +# 官方硬要求透传 CC system prompt → fake_cc_system_prompt=True 必填。 +# 文档: https://www.kimi.com/code/docs/third-party-tools/other-coding-agents.html +# native_claude_config_kimi = { +# 'name': 'kimi-coding', # /llms 显示名 & mixin 引用名 +# 'apikey': 'sk-kimi-', # Bearer 鉴权 +# 'apibase': 'https://api.kimi.com/coding',# Anthropic 兼容端点 +# 'model': 'kimi-for-coding', # 官方 coding 专用 model id +# 'fake_cc_system_prompt': True, # 必填;官方硬要求透传 CC 系统串 +# 'thinking_type': 'adaptive', # 'adaptive'/'enabled'/'disabled' +# } # ══════════════════════════════════════════════════════════════════════════════ # 2. NativeOAISession — OpenAI 协议 + 原生工具 @@ -309,7 +330,7 @@ # 'name': 'my-oai-proxy', # /llms 显示名 & mixin 引用名 # 'apikey': 'sk-', # Bearer 鉴权 # 'apibase': 'http://:2001', # 自动补 /v1/chat/completions -# 'model': 'gpt-5.4', # 或 claude-opus-4-6、gemini-3-flash 等 +# 'model': 'gpt-5.4', # 或 claude-opus-4-7、gemini-3-flash 等 # 'api_mode': 'chat_completions', # 'chat_completions'(默认)|'responses' # # 'reasoning_effort': 'high', # none|minimal|low|medium|high|xhigh # 'max_retries': 3, # int 默认 1 @@ -325,7 +346,7 @@ # # oai_config2 = { # # 'apikey': 'sk-...', # # 'apibase': 'http://your-proxy:2001', -# # 'model': 'claude-opus-4-6', +# # 'model': 'claude-opus-4-7', # # } @@ -359,12 +380,12 @@ # ── 4c. OpenRouter (OAI 协议多模型中继) ───────────────────────────────────── # OpenRouter 是最通用的多模型 OAI 中继,https://openrouter.ai/api/v1。 -# model 名用 provider/model 格式(如 anthropic/claude-opus-4-6)。 +# model 名用 provider/model 格式(如 anthropic/claude-opus-4-7)。 # oai_config_openrouter = { # 'name': 'openrouter-claude', # /llms 显示名 & mixin 引用名;省略则取 model # 'apikey': 'sk-or-', # OpenRouter key 形如 sk-or-xxx;Bearer 鉴权 # 'apibase': 'https://openrouter.ai/api/v1', # 补齐到 /v1/chat/completions -# 'model': 'anthropic/claude-opus-4-6', # provider/model 格式 +# 'model': 'anthropic/claude-opus-4-7', # provider/model 格式 # 'max_retries': 3, # int 默认 1 # 'connect_timeout': 10, # int 秒 默认 5(最小 1) # 'read_timeout': 120, # int 秒 默认 30(最小 5) @@ -397,3 +418,10 @@ # dingtalk_allowed_users = ['your_staff_id'] # 留空或 ['*'] 表示允许所有钉钉用户 # wx_oa_appid = 'wx1234567890abcdef' # 微信公众号 AppID # wx_oa_appsecret = 'your_appsecret_here' # 微信公众号 AppSecret + +# 可选:Langfuse 追踪。不设此项则不 import langfuse,零影响 +# langfuse_config = { +# 'public_key': 'pk-lf-...', +# 'secret_key': 'sk-lf-...', +# 'host': 'https://cloud.langfuse.com', # 或自托管地址 +# } From f2031e11b4aa7ba81ef14d31626d13b3afe1174c Mon Sep 17 00:00:00 2001 From: UnsongK Date: Fri, 24 Apr 2026 00:55:36 +0800 Subject: [PATCH 3/3] style(frontends/wxoaapp): formatting and structure cleanup --- frontends/wxoaapp.py | 197 +++++++++++++++++++++---------------------- 1 file changed, 97 insertions(+), 100 deletions(-) diff --git a/frontends/wxoaapp.py b/frontends/wxoaapp.py index 1bba8bf9..fbcd1dc8 100644 --- a/frontends/wxoaapp.py +++ b/frontends/wxoaapp.py @@ -15,15 +15,75 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from llmcore import mykeys -APPID = str(mykeys.get("wx_oa_appid", "") or "").strip() -SECRET = str(mykeys.get("wx_oa_appsecret", "") or "").strip() -API = "https://api.weixin.qq.com/cgi-bin" -_TOKEN_CACHE = {"token": "", "expires_at": 0} +API_BASE = "https://api.weixin.qq.com/cgi-bin" -def _whitelist_ip_reminder(resp) -> str: +# ── API 客户端 ──────────────────────────────── + +class WxOAClient: + """封装微信公众号 API,支持外部注入 appid/secret 以便测试和复用。""" + + def __init__(self, appid=None, secret=None): + self.appid = appid or (mykeys.get("wx_oa_appid") or "").strip() + self.secret = secret or (mykeys.get("wx_oa_appsecret") or "").strip() + self._token = "" + self._token_expires_at = 0 + + def _ensure_config(self): + if not self.appid or not self.secret: + raise RuntimeError("请在 mykey.py 中配置 wx_oa_appid 和 wx_oa_appsecret") + + def get_access_token(self): + self._ensure_config() + if time.time() < self._token_expires_at - 60: + return self._token + r = requests.get( + f"{API_BASE}/token", + params={"grant_type": "client_credential", "appid": self.appid, "secret": self.secret}, + timeout=10, + ).json() + if "access_token" not in r: + raise RuntimeError(f"获取 access_token 失败: {r}{_whitelist_hint(r)}") + self._token = r["access_token"] + self._token_expires_at = time.time() + r.get("expires_in", 7200) + return self._token + + def upload_thumb(self, image_path): + token = self.get_access_token() + with open(image_path, "rb") as f: + r = requests.post( + f"{API_BASE}/material/add_material", + params={"access_token": token, "type": "image"}, + files={"media": (os.path.basename(image_path), f)}, + timeout=30, + ).json() + if "media_id" not in r: + raise RuntimeError(f"上传封面图失败: {r}{_whitelist_hint(r)}") + return r["media_id"] + + def add_draft(self, articles): + token = self.get_access_token() + payload = json.dumps({"articles": articles}, ensure_ascii=False).encode("utf-8") + r = requests.post( + f"{API_BASE}/draft/add", + params={"access_token": token}, + data=payload, + headers={"Content-Type": "application/json"}, + timeout=30, + ).json() + if "media_id" not in r: + raise RuntimeError(f"上传草稿失败: {r}{_whitelist_hint(r)}") + return r["media_id"] + + +# ── 工具函数 ──────────────────────────────── + +_WHITELIST_KEYWORDS = ("40164", "whitelist", "白名单", "not in whitelist") + +def _whitelist_hint(resp) -> str: s = json.dumps(resp, ensure_ascii=False) if isinstance(resp, dict) else str(resp) - if "40164" not in s and "whitelist" not in s.lower() and "白名单" not in s and "not in whitelist" not in s.lower(): + low = s.lower() + if not any(kw in low for kw in _WHITELIST_KEYWORDS): return "" return ( "\n[提示] 当前出口 IP 未在公众号「IP 白名单」内。" @@ -32,130 +92,67 @@ def _whitelist_ip_reminder(resp) -> str: ) -def get_access_token(): - if time.time() < _TOKEN_CACHE["expires_at"] - 60: - return _TOKEN_CACHE["token"] - r = requests.get( - f"{API}/token", - params={"grant_type": "client_credential", "appid": APPID, "secret": SECRET}, - timeout=10, - ).json() - if "access_token" not in r: - raise RuntimeError(f"获取 access_token 失败: {r}{_whitelist_ip_reminder(r)}") - _TOKEN_CACHE["token"] = r["access_token"] - _TOKEN_CACHE["expires_at"] = time.time() + r.get("expires_in", 7200) - return _TOKEN_CACHE["token"] - - -def upload_thumb(image_path): - token = get_access_token() - with open(image_path, "rb") as f: - r = requests.post( - f"{API}/material/add_material", - params={"access_token": token, "type": "image"}, - files={"media": (os.path.basename(image_path), f)}, - timeout=30, - ).json() - if "media_id" not in r: - raise RuntimeError(f"上传封面图失败: {r}{_whitelist_ip_reminder(r)}") - return r["media_id"] - - -def add_draft(articles): - token = get_access_token() - payload = json.dumps({"articles": articles}, ensure_ascii=False).encode("utf-8") - r = requests.post( - f"{API}/draft/add", - params={"access_token": token}, - data=payload, - headers={"Content-Type": "application/json"}, - timeout=30, - ).json() - if "media_id" not in r: - raise RuntimeError(f"上传草稿失败: {r}{_whitelist_ip_reminder(r)}") - return r["media_id"] - - -def _check_config(): - if not APPID or not SECRET: - print("ERROR: 请在 mykey.py 中配置 wx_oa_appid 和 wx_oa_appsecret") - sys.exit(1) - - -def load_content_from_arg(content: str) -> str: - """--content 若以 @ 开头则读取该文件 UTF-8 全文;否则原样作为正文。""" - if not isinstance(content, str): - content = str(content) - head = content.strip() +def load_content(content: str) -> str: + """若以 @ 开头则读取该文件 UTF-8 全文,否则原样返回。""" + head = str(content).strip() if not head.startswith("@"): return content path_part = head[1:].strip() if not path_part: raise ValueError("--content 以 @ 开头但路径为空") - path = os.path.expanduser(path_part) - if not os.path.isabs(path): - path = os.path.abspath(path) + path = os.path.abspath(os.path.expanduser(path_part)) if not os.path.isfile(path): - raise FileNotFoundError( - f"文件不存在或不是普通文件: {path_part!r} -> {path!r}" - ) + raise FileNotFoundError(f"文件不存在或不是普通文件: {path_part!r} -> {path!r}") with open(path, encoding="utf-8", errors="replace") as f: return f.read() -def main(): +# ── CLI ──────────────────────────────── + +def _build_parser(): parser = argparse.ArgumentParser(description="微信公众号草稿箱工具") sub = parser.add_subparsers(dest="cmd") - sub.add_parser("token", help="获取并打印 access_token") - p_thumb = sub.add_parser("upload_thumb", help="上传封面图") p_thumb.add_argument("image", help="图片路径") - p_draft = sub.add_parser("add_draft", help="上传文章到草稿箱") p_draft.add_argument("--title", required=True) - p_draft.add_argument( - "--content", - required=True, - help="HTML 正文;若以 @ 开头则为文件路径(UTF-8)", - ) + p_draft.add_argument("--content", required=True, help="HTML 正文;若以 @ 开头则为文件路径(UTF-8)") p_draft.add_argument("--author", default="") p_draft.add_argument("--digest", default="") p_draft.add_argument("--thumb", default="", help="封面图路径(可选)") p_draft.add_argument("--thumb_media_id", default="", help="已有封面 media_id(可选)") + return parser + +def _run_add_draft(client, args): + content = load_content(args.content) + thumb_media_id = args.thumb_media_id + if args.thumb and not thumb_media_id: + thumb_media_id = client.upload_thumb(args.thumb) + print(f"封面已上传: {thumb_media_id}") + article = {"title": args.title, "content": content, "author": args.author, "digest": args.digest} + if thumb_media_id: + article["thumb_media_id"] = thumb_media_id + media_id = client.add_draft([article]) + print(f"草稿已上传,media_id: {media_id}") + + +def main(): + parser = _build_parser() args = parser.parse_args() - _check_config() + client = WxOAClient() try: if args.cmd == "token": - print(get_access_token()) + print(client.get_access_token()) elif args.cmd == "upload_thumb": - mid = upload_thumb(args.image) - print(f"thumb_media_id: {mid}") + print(f"thumb_media_id: {client.upload_thumb(args.image)}") elif args.cmd == "add_draft": - try: - content = load_content_from_arg(args.content) - except (OSError, ValueError) as e: - print(f"ERROR: {e}", file=sys.stderr) - sys.exit(2) - thumb_media_id = args.thumb_media_id - if args.thumb and not thumb_media_id: - thumb_media_id = upload_thumb(args.thumb) - print(f"封面已上传: {thumb_media_id}") - article = { - "title": args.title, - "content": content, - "author": args.author, - "digest": args.digest, - } - if thumb_media_id: - article["thumb_media_id"] = thumb_media_id - media_id = add_draft([article]) - print(f"草稿已上传,media_id: {media_id}") + _run_add_draft(client, args) else: parser.print_help() - except RuntimeError as e: + except (RuntimeError, OSError, ValueError) as e: print(f"ERROR: {e}", file=sys.stderr) sys.exit(1)