foxclaw 0.1.2 → 0.1.4
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.
- package/index.js +1 -8
- package/package.json +1 -1
- package/python/.env.example +8 -0
- package/python/.github/workflows/ci.yml +47 -2
- package/python/.github/workflows/release.yml +5 -0
- package/python/CHANGELOG.md +100 -0
- package/python/CHANGELOG_CN.md +100 -0
- package/python/README.md +17 -2
- package/python/README_CN.md +38 -20
- package/python/bench/bm25_audit.py +1 -14
- package/python/bench/bm25_regression.py +0 -1
- package/python/bench/bm25_stress.py +0 -4
- package/python/bench/cd_audit.py +0 -23
- package/python/bench/chaos.py +6 -8
- package/python/bench/e2e_real_models.py +0 -1
- package/python/bench/llm_retry_test.py +2 -9
- package/python/bench/load_test.py +5 -8
- package/python/bench/vector_ivf_upgrade.py +1 -7
- package/python/docs/CONTENT_GUARD.md +189 -0
- package/python/docs/CONTENT_GUARD_CN.md +189 -0
- package/python/docs/RELEASE.md +78 -37
- package/python/docs/RUN_CN.md +2 -2
- package/python/evals/governance/cases.py +0 -37
- package/python/evals/governance/run_evals.py +5 -37
- package/python/examples/governance_middleware_demo.py +1 -6
- package/python/examples/langgraph_governance_demo.py +7 -11
- package/python/foxclaw/__init__.py +74 -152
- package/python/foxclaw/agents/base.py +82 -136
- package/python/foxclaw/agents/impl/_helpers.py +2 -10
- package/python/foxclaw/agents/impl/finance.py +4 -21
- package/python/foxclaw/agents/impl/office.py +7 -24
- package/python/foxclaw/agents/impl/supervisor.py +12 -79
- package/python/foxclaw/agents/mixins/_context.py +0 -23
- package/python/foxclaw/agents/mixins/augment.py +10 -16
- package/python/foxclaw/agents/mixins/llm.py +7 -6
- package/python/foxclaw/agents/mixins/memory.py +23 -3
- package/python/foxclaw/agents/mixins/preference.py +58 -8
- package/python/foxclaw/agents/mixins/prompt.py +59 -12
- package/python/foxclaw/agents/mixins/skillbox.py +6 -6
- package/python/foxclaw/agents/mixins/tool_exec.py +16 -42
- package/python/foxclaw/agents/rules/finance/RULES.md +131 -0
- package/python/foxclaw/api/__init__.py +18 -77
- package/python/foxclaw/api/_state.py +43 -0
- package/python/foxclaw/api/bootstrap.py +114 -0
- package/python/foxclaw/api/middleware.py +5 -28
- package/python/foxclaw/api/routes/ahs.py +60 -73
- package/python/foxclaw/api/routes/attachments.py +5 -6
- package/python/foxclaw/api/routes/browser.py +7 -11
- package/python/foxclaw/api/routes/domain.py +18 -26
- package/python/foxclaw/api/routes/knowledge.py +3 -21
- package/python/foxclaw/api/routes/llm_admin.py +21 -33
- package/python/foxclaw/api/routes/memory.py +50 -12
- package/python/foxclaw/api/routes/orchestration.py +3 -5
- package/python/foxclaw/api/routes/recovery.py +4 -3
- package/python/foxclaw/api/routes/review.py +2 -11
- package/python/foxclaw/api/routes/search.py +3 -7
- package/python/foxclaw/api/routes/system.py +33 -49
- package/python/foxclaw/api/routes/task.py +31 -49
- package/python/foxclaw/api/routes/ws.py +2 -6
- package/python/foxclaw/api/spine.py +1 -4
- package/python/foxclaw/behavior/__init__.py +20 -0
- package/python/foxclaw/behavior/behavior.yaml +62 -0
- package/python/foxclaw/behavior/spec.py +287 -0
- package/python/foxclaw/browser/action/action_executor.py +1 -5
- package/python/foxclaw/browser/action/action_types.py +0 -1
- package/python/foxclaw/browser/action/url_policy.py +1 -5
- package/python/foxclaw/browser/adapter/web_adapter.py +1 -1
- package/python/foxclaw/browser/driver/playwright_driver.py +20 -16
- package/python/foxclaw/browser/perception/dom_extractor.py +0 -1
- package/python/foxclaw/browser/perception/element_indexer.py +1 -2
- package/python/foxclaw/browser/service.py +7 -11
- package/python/foxclaw/capability/embedding_service.py +4 -6
- package/python/foxclaw/channels/__init__.py +9 -11
- package/python/foxclaw/channels/discord.py +4 -13
- package/python/foxclaw/channels/dispatcher.py +2 -27
- package/python/foxclaw/channels/feishu.py +10 -20
- package/python/foxclaw/channels/openclaw_bridge.py +4 -9
- package/python/foxclaw/channels/signing.py +9 -9
- package/python/foxclaw/channels/telegram.py +3 -4
- package/python/foxclaw/channels/weixin.py +7 -17
- package/python/foxclaw/cli.py +42 -13
- package/python/foxclaw/cognitive/reasoner.py +9 -10
- package/python/foxclaw/cognitive/state.py +18 -24
- package/python/foxclaw/core/audit_chain.py +1 -3
- package/python/foxclaw/core/audit_sink.py +3 -3
- package/python/foxclaw/core/compaction.py +8 -26
- package/python/foxclaw/core/config.py +2 -2
- package/python/foxclaw/core/confirm_nonce.py +6 -12
- package/python/foxclaw/core/content_guard.py +341 -0
- package/python/foxclaw/core/envfile.py +2 -2
- package/python/foxclaw/core/failure_policy.py +17 -26
- package/python/foxclaw/core/gateway.py +6 -10
- package/python/foxclaw/core/governance_audit.py +3 -7
- package/python/foxclaw/core/idempotency.py +15 -22
- package/python/foxclaw/core/injection_guard.py +10 -29
- package/python/foxclaw/core/memory_adapter.py +53 -60
- package/python/foxclaw/core/metrics.py +4 -5
- package/python/foxclaw/core/model_router.py +74 -150
- package/python/foxclaw/core/paths.py +1 -1
- package/python/foxclaw/core/recovery.py +21 -34
- package/python/foxclaw/core/scheduler.py +23 -26
- package/python/foxclaw/core/secrets.py +0 -3
- package/python/foxclaw/core/sensitive_write.py +283 -0
- package/python/foxclaw/core/sessions.py +4 -6
- package/python/foxclaw/core/settings.py +16 -32
- package/python/foxclaw/core/shared_write.py +11 -23
- package/python/foxclaw/core/steering_queue.py +12 -11
- package/python/foxclaw/core/system_bus.py +1 -1
- package/python/foxclaw/core/thought_chain.py +30 -32
- package/python/foxclaw/core/token_meter.py +9 -13
- package/python/foxclaw/core/tools.py +4 -12
- package/python/foxclaw/core/tracing.py +25 -40
- package/python/foxclaw/core/truth_pipeline.py +5 -9
- package/python/foxclaw/core/update_check.py +149 -0
- package/python/foxclaw/core/vec_recall_adapter.py +11 -37
- package/python/foxclaw/core/verify.py +6 -14
- package/python/foxclaw/core/web_search.py +4 -28
- package/python/foxclaw/finance/evidence.py +14 -18
- package/python/foxclaw/finance/fx.py +1 -3
- package/python/foxclaw/finance/graph.py +9 -18
- package/python/foxclaw/finance/models.py +20 -24
- package/python/foxclaw/finance/nlparse.py +12 -72
- package/python/foxclaw/finance/policy.py +8 -36
- package/python/foxclaw/finance/reasoner.py +3 -11
- package/python/foxclaw/finance/reasoning.py +13 -34
- package/python/foxclaw/finance/reconcile.py +5 -10
- package/python/foxclaw/finance/runtime.py +19 -46
- package/python/foxclaw/governance/middleware.py +13 -21
- package/python/foxclaw/harness/_base.py +2 -8
- package/python/foxclaw/harness/_policy.py +19 -38
- package/python/foxclaw/harness/drift.py +1 -13
- package/python/foxclaw/harness/event_bus.py +2 -5
- package/python/foxclaw/harness/harness.py +9 -97
- package/python/foxclaw/harness/loop_detection.py +11 -18
- package/python/foxclaw/itinerary/amap_client.py +2 -5
- package/python/foxclaw/itinerary/models.py +7 -7
- package/python/foxclaw/itinerary/reasoner.py +1 -6
- package/python/foxclaw/itinerary/reasoning.py +15 -21
- package/python/foxclaw/itinerary/runtime.py +8 -12
- package/python/foxclaw/kernel/capabilities.py +5 -6
- package/python/foxclaw/kernel/policy_gate.py +4 -10
- package/python/foxclaw/kernel/process_manager.py +17 -36
- package/python/foxclaw/kernel/scheduler.py +3 -9
- package/python/foxclaw/kernel/sentinel.py +13 -23
- package/python/foxclaw/kernel/sentinel_client.py +12 -15
- package/python/foxclaw/kernel/spine.py +9 -9
- package/python/foxclaw/knowledge/__init__.py +4 -20
- package/python/foxclaw/knowledge/truth_crystallizer.py +5 -8
- package/python/foxclaw/learning/knowledge_graph.py +4 -9
- package/python/foxclaw/learning/models.py +13 -13
- package/python/foxclaw/learning/reasoner.py +1 -4
- package/python/foxclaw/learning/runtime.py +19 -31
- package/python/foxclaw/learning/spaced_repetition.py +2 -7
- package/python/foxclaw/mcp_server.py +2 -3
- package/python/foxclaw/orchestration/__init__.py +16 -33
- package/python/foxclaw/pipeline/cognitive_pipeline.py +45 -97
- package/python/foxclaw/pipeline/stage_meta.py +21 -0
- package/python/foxclaw/pipeline/supervisor_stages.py +13 -42
- package/python/foxclaw/planning/llm_planner.py +3 -3
- package/python/foxclaw/planning/planner.py +6 -11
- package/python/foxclaw/search/agent_port.py +7 -11
- package/python/foxclaw/search/evidence.py +0 -4
- package/python/foxclaw/search/internal_search.py +3 -5
- package/python/foxclaw/search/models.py +11 -13
- package/python/foxclaw/search/planner.py +3 -3
- package/python/foxclaw/search/retriever.py +1 -1
- package/python/foxclaw/search/runtime.py +1 -8
- package/python/foxclaw/skills/__init__.py +46 -100
- package/python/foxclaw/skills/archive.py +111 -0
- package/python/foxclaw/skills/fetch.py +226 -0
- package/python/foxclaw/skills/hooks_spec.py +21 -25
- package/python/foxclaw/skills/install_cmd.py +216 -0
- package/python/foxclaw/skills/lifecycle.py +16 -42
- package/python/foxclaw/skills/markdown_skill.py +10 -9
- package/python/foxclaw/skills/mcp_client.py +5 -11
- package/python/foxclaw/skills/mcp_store.py +0 -3
- package/python/foxclaw/skills/permission.py +13 -48
- package/python/foxclaw/skills/pipeline.py +21 -53
- package/python/foxclaw/skills/policy.py +4 -10
- package/python/foxclaw/skills/review.py +3 -3
- package/python/frontend/deck/auth.js +0 -7
- package/python/frontend/deck/chat_model_picker.js +0 -54
- package/python/frontend/deck/domain.js +0 -20
- package/python/frontend/deck/main.js +0 -144
- package/python/frontend/deck/markdown.js +0 -4
- package/python/frontend/deck/settings.js +1 -18
- package/python/frontend/deck/state.js +0 -1
- package/python/frontend/deck/util.js +0 -2
- package/python/frontend/deck/visuals.js +0 -22
- package/python/frontend/planet/planet.js +18 -201
- package/python/frontend/v3/assets/index-JKfHlb-a.css +1 -0
- package/python/frontend/v3/assets/index-tN90dpUH.js +34 -0
- package/python/frontend/v3/index.html +3 -3
- package/python/frontend/widget_runtime.js +0 -43
- package/python/frontend/widgets.js +0 -57
- package/python/frontend_src/index.html +1 -1
- package/python/frontend_src/package-lock.json +2 -2
- package/python/frontend_src/package.json +1 -1
- package/python/frontend_src/src/App.vue +3 -7
- package/python/frontend_src/src/api.js +0 -7
- package/python/frontend_src/src/components/ChatDock.vue +27 -39
- package/python/frontend_src/src/components/DocViewer.vue +0 -9
- package/python/frontend_src/src/components/Inspector.vue +0 -6
- package/python/frontend_src/src/components/LeftRail.vue +60 -39
- package/python/frontend_src/src/components/MenuPopover.vue +9 -11
- package/python/frontend_src/src/components/MessageStream.vue +18 -30
- package/python/frontend_src/src/components/ModelPicker.vue +0 -5
- package/python/frontend_src/src/components/QuickIngest.vue +0 -6
- package/python/frontend_src/src/components/Stage.vue +71 -25
- package/python/frontend_src/src/components/ToolMenu.vue +0 -3
- package/python/frontend_src/src/components/TopBar.vue +38 -10
- package/python/frontend_src/src/components/panels/ActivityPanel.vue +0 -2
- package/python/frontend_src/src/components/panels/AgentsPanel.vue +2 -5
- package/python/frontend_src/src/components/panels/KnowledgePanel.vue +0 -16
- package/python/frontend_src/src/components/panels/MemoryPanel.vue +42 -3
- package/python/frontend_src/src/components/panels/PanelBox.vue +6 -7
- package/python/frontend_src/src/components/panels/ReviewPanel.vue +0 -16
- package/python/frontend_src/src/components/panels/SettingsPanel.vue +142 -38
- package/python/frontend_src/src/components/panels/TasksPanel.vue +0 -1
- package/python/frontend_src/src/components/panels/ToolsPanel.vue +125 -19
- package/python/frontend_src/src/lib/fmt.js +0 -4
- package/python/frontend_src/src/lib/i18n.js +53 -0
- package/python/frontend_src/src/lib/locales/en.js +173 -0
- package/python/frontend_src/src/lib/locales/zh.js +174 -0
- package/python/frontend_src/src/lib/md.js +0 -14
- package/python/frontend_src/src/main.js +2 -0
- package/python/frontend_src/src/stores/app.js +17 -32
- package/python/frontend_src/src/stores/chat.js +0 -34
- package/python/frontend_src/src/stores/model.js +0 -8
- package/python/frontend_src/src/stores/panels.js +16 -44
- package/python/frontend_src/src/stores/profile.js +31 -0
- package/python/frontend_src/src/stores/system.js +25 -0
- package/python/frontend_src/vite.config.js +0 -7
- package/python/pyproject.toml +17 -3
- package/python/requirements.txt +311 -0
- package/python/tests/conftest.py +0 -22
- package/python/tests/run_all.py +4 -66
- package/python/tests/test_agent_lifecycle.py +4 -38
- package/python/tests/test_agent_rules.py +81 -0
- package/python/tests/test_ahs_p2.py +4 -25
- package/python/tests/test_anthropic_tools.py +1 -15
- package/python/tests/test_api.py +19 -9
- package/python/tests/test_api_auth.py +1 -5
- package/python/tests/test_attachments.py +9 -25
- package/python/tests/test_audit_chain.py +2 -7
- package/python/tests/test_audit_sink.py +0 -6
- package/python/tests/test_behavior_spec.py +198 -0
- package/python/tests/test_browser.py +2 -6
- package/python/tests/test_browser_request_guard.py +0 -4
- package/python/tests/test_capabilities.py +0 -6
- package/python/tests/test_channel_signing.py +2 -9
- package/python/tests/test_channels.py +1 -11
- package/python/tests/test_config_layering.py +0 -4
- package/python/tests/test_confirm_nonce.py +1 -12
- package/python/tests/test_conflict_dismiss.py +143 -0
- package/python/tests/test_content_guard.py +177 -0
- package/python/tests/test_context_compaction_wiring.py +1 -7
- package/python/tests/test_dispatch.py +0 -6
- package/python/tests/test_domain_evals.py +11 -80
- package/python/tests/test_domain_import.py +0 -6
- package/python/tests/test_domain_reasoners.py +0 -14
- package/python/tests/test_e2e.py +0 -17
- package/python/tests/test_emb_cache.py +7 -17
- package/python/tests/test_embedding_singleton.py +1 -3
- package/python/tests/test_evals_smoke.py +0 -24
- package/python/tests/test_event_spine.py +10 -18
- package/python/tests/test_evidence_semantic.py +1 -14
- package/python/tests/test_evidence_sse.py +3 -6
- package/python/tests/test_file_ingest.py +0 -11
- package/python/tests/test_finance.py +0 -9
- package/python/tests/test_finance_ledger.py +5 -12
- package/python/tests/test_finance_persist.py +8 -12
- package/python/tests/test_gateway.py +3 -22
- package/python/tests/test_governance.py +5 -52
- package/python/tests/test_governance_audit.py +4 -39
- package/python/tests/test_governance_middleware.py +1 -7
- package/python/tests/test_hkc.py +6 -24
- package/python/tests/test_hkc_index_build_manager.py +0 -33
- package/python/tests/test_hkc_manifest_reuse.py +4 -27
- package/python/tests/test_hkc_search_event_sync.py +3 -54
- package/python/tests/test_import_path_guard.py +0 -6
- package/python/tests/test_injection_guard.py +18 -36
- package/python/tests/test_integration.py +5 -29
- package/python/tests/test_itinerary.py +0 -3
- package/python/tests/test_kee_semantic.py +0 -5
- package/python/tests/test_kernel.py +1 -11
- package/python/tests/test_learning.py +3 -12
- package/python/tests/test_llm_admin.py +4 -19
- package/python/tests/test_llm_planner.py +1 -23
- package/python/tests/test_markdown_skills.py +3 -14
- package/python/tests/test_markdown_skills_openclaw.py +4 -9
- package/python/tests/test_mcp_connectors.py +3 -15
- package/python/tests/test_mcp_guardian_discovery.py +2 -6
- package/python/tests/test_mcp_no_memory.py +3 -3
- package/python/tests/test_mcp_policy_gate.py +0 -1
- package/python/tests/test_mcp_server.py +6 -9
- package/python/tests/test_memory_recall.py +1 -4
- package/python/tests/test_metrics.py +0 -8
- package/python/tests/test_multi_chain.py +1 -13
- package/python/tests/test_multimodal.py +5 -18
- package/python/tests/test_negotiation.py +3 -13
- package/python/tests/test_orchestration.py +1 -11
- package/python/tests/test_orchestration_decoupled.py +0 -6
- package/python/tests/test_p3_p5_hardening.py +3 -17
- package/python/tests/test_parallel_dispatch.py +4 -12
- package/python/tests/test_pipeline.py +17 -58
- package/python/tests/test_plan_visible.py +4 -4
- package/python/tests/test_planner.py +0 -7
- package/python/tests/test_policy_gate.py +2 -3
- package/python/tests/test_profile.py +67 -0
- package/python/tests/test_readme_claims.py +163 -0
- package/python/tests/test_reasoner.py +2 -17
- package/python/tests/test_release_check.py +2 -13
- package/python/tests/test_resume_checkpoint.py +7 -15
- package/python/tests/test_routing.py +2 -12
- package/python/tests/test_scheduler.py +7 -10
- package/python/tests/test_search.py +1 -11
- package/python/tests/test_secrets.py +4 -10
- package/python/tests/test_semantic.py +1 -10
- package/python/tests/test_sensitive_write.py +181 -0
- package/python/tests/test_sessions.py +3 -4
- package/python/tests/test_settings.py +2 -18
- package/python/tests/test_shadow_learning.py +3 -43
- package/python/tests/test_shared_write.py +0 -7
- package/python/tests/test_skill_taint_gate.py +1 -10
- package/python/tests/test_skills.py +2 -18
- package/python/tests/test_skills_fetch.py +282 -0
- package/python/tests/test_stats_and_events.py +2 -5
- package/python/tests/test_streaming.py +2 -7
- package/python/tests/test_supervisor_pipeline.py +4 -5
- package/python/tests/test_supervisor_triage.py +4 -10
- package/python/tests/test_taint.py +2 -7
- package/python/tests/test_task_stream.py +1 -12
- package/python/tests/test_thinking_sse.py +0 -1
- package/python/tests/test_tool_center.py +1 -17
- package/python/tests/test_tool_parallel.py +2 -2
- package/python/tests/test_tool_params.py +1 -5
- package/python/tests/test_tool_use.py +0 -13
- package/python/tests/test_toplevel_concurrency.py +3 -4
- package/python/tests/test_tracing.py +7 -41
- package/python/tests/test_truth_crystallizer.py +4 -6
- package/python/tests/test_truth_flow.py +2 -7
- package/python/tests/test_update_check.py +95 -0
- package/python/tests/test_verify_evidence.py +8 -21
- package/python/tests/test_version_sync.py +35 -6
- package/python/tests/test_web_trigger.py +0 -17
- package/python/tools/build_content_lexicon.py +218 -0
- package/python/tools/bundle_web.py +98 -0
- package/python/tools/check_faiss_type.py +0 -1
- package/python/tools/check_log.ps1 +0 -8
- package/python/tools/mypy_baseline.txt +12 -4
- package/python/tools/mypy_ratchet.py +52 -6
- package/python/tools/pack.py +0 -5
- package/python/tools/recheck_conflicts.py +156 -0
- package/python/tools/release_check.py +24 -15
- package/python/tools/restart_foxclaw.ps1 +5 -8
- package/python/tools/shared_write_audit.py +1 -15
- package/python/tools/size_check.py +1 -1
- package/python/tools/strip_comments.py +228 -0
- package/python/vendor/hkc/hkc_api/routes/ability.py +31 -0
- package/python/vendor/hkc/hkc_api/schemas.py +11 -0
- package/python/vendor/hkc/hkc_kee/kee.py +124 -3
- package/python/vendor/hkc/hkc_sdk/base.py +22 -0
- package/python/vendor/hkc/hkc_sdk/direct_client.py +10 -0
- package/python/vendor/hkc/hkc_sdk/http_client.py +19 -0
- package/python/foxclaw/api/app.py +0 -123
- package/python/frontend/v3/assets/index-BD7VPbMV.js +0 -33
- package/python/frontend/v3/assets/index-CAvnYWGH.css +0 -1
|
@@ -18,20 +18,18 @@ Foxclaw Channels — Discord 适配器
|
|
|
18
18
|
"""
|
|
19
19
|
from __future__ import annotations
|
|
20
20
|
import json
|
|
21
|
-
from typing import Optional
|
|
21
|
+
from typing import Any, Optional
|
|
22
22
|
from . import ChannelAdapter, InboundMessage, OutboundMessage, SendResult
|
|
23
23
|
|
|
24
24
|
DISCORD_API_BASE = "https://discord.com/api/v10"
|
|
25
25
|
DISCORD_GATEWAY = "wss://gateway.discord.gg/?v=10&encoding=json"
|
|
26
26
|
|
|
27
|
-
# Gateway opcodes
|
|
28
27
|
OP_DISPATCH = 0
|
|
29
28
|
OP_HEARTBEAT = 1
|
|
30
29
|
OP_IDENTIFY = 2
|
|
31
30
|
OP_HELLO = 10
|
|
32
31
|
OP_HEARTBEAT_ACK = 11
|
|
33
32
|
|
|
34
|
-
# Intents(收消息内容需要 GUILD_MESSAGES + MESSAGE_CONTENT)
|
|
35
33
|
INTENT_GUILD_MESSAGES = 1 << 9
|
|
36
34
|
INTENT_DIRECT_MESSAGES = 1 << 12
|
|
37
35
|
INTENT_MESSAGE_CONTENT = 1 << 15
|
|
@@ -47,20 +45,18 @@ class DiscordAdapter(ChannelAdapter):
|
|
|
47
45
|
self.bot_token = bot_token
|
|
48
46
|
self.intents = intents
|
|
49
47
|
self.timeout = timeout
|
|
50
|
-
self._bot_user_id = ""
|
|
48
|
+
self._bot_user_id = ""
|
|
51
49
|
|
|
52
50
|
def _headers(self) -> dict:
|
|
53
51
|
return {"Authorization": f"Bot {self.bot_token}",
|
|
54
52
|
"Content-Type": "application/json",
|
|
55
53
|
"User-Agent": "Foxclaw (https://github.com, 1.0)"}
|
|
56
54
|
|
|
57
|
-
# ── 入站:标准化 Gateway MESSAGE_CREATE 事件 ───────────
|
|
58
55
|
def normalize(self, raw: dict) -> Optional[InboundMessage]:
|
|
59
56
|
"""Discord Gateway 事件 → 标准 InboundMessage。
|
|
60
57
|
|
|
61
58
|
raw 可以是完整 Gateway 帧({op,t,d})或直接是 MESSAGE_CREATE 的 data。
|
|
62
59
|
"""
|
|
63
|
-
# 取出 data 部分
|
|
64
60
|
if "op" in raw:
|
|
65
61
|
if raw.get("op") != OP_DISPATCH or raw.get("t") != "MESSAGE_CREATE":
|
|
66
62
|
return None
|
|
@@ -70,15 +66,13 @@ class DiscordAdapter(ChannelAdapter):
|
|
|
70
66
|
if not data:
|
|
71
67
|
return None
|
|
72
68
|
author = data.get("author", {})
|
|
73
|
-
# 忽略机器人消息(含自己),避免自循环
|
|
74
69
|
if author.get("bot"):
|
|
75
70
|
return None
|
|
76
71
|
if self._bot_user_id and str(author.get("id")) == self._bot_user_id:
|
|
77
72
|
return None
|
|
78
73
|
text = data.get("content", "")
|
|
79
74
|
if not text:
|
|
80
|
-
return None
|
|
81
|
-
# 有 guild_id = 服务器频道(群),无 = 私信
|
|
75
|
+
return None
|
|
82
76
|
is_group = bool(data.get("guild_id"))
|
|
83
77
|
return InboundMessage(
|
|
84
78
|
channel=self.CHANNEL,
|
|
@@ -90,7 +84,6 @@ class DiscordAdapter(ChannelAdapter):
|
|
|
90
84
|
is_group=is_group,
|
|
91
85
|
raw=data)
|
|
92
86
|
|
|
93
|
-
# ── 出站:REST 发送 ────────────────────────────────────
|
|
94
87
|
def send(self, msg: OutboundMessage) -> SendResult:
|
|
95
88
|
"""POST /channels/{channel_id}/messages 发文本。"""
|
|
96
89
|
try:
|
|
@@ -99,7 +92,7 @@ class DiscordAdapter(ChannelAdapter):
|
|
|
99
92
|
return SendResult(False, error="缺少 httpx 依赖")
|
|
100
93
|
if not self.bot_token:
|
|
101
94
|
return SendResult(False, error="未配置 Discord bot token")
|
|
102
|
-
payload = {"content": msg.text}
|
|
95
|
+
payload: dict[str, Any] = {"content": msg.text}
|
|
103
96
|
if msg.reply_to:
|
|
104
97
|
payload["message_reference"] = {"message_id": msg.reply_to}
|
|
105
98
|
url = f"{DISCORD_API_BASE}/channels/{msg.conversation_id}/messages"
|
|
@@ -156,10 +149,8 @@ class DiscordAdapter(ChannelAdapter):
|
|
|
156
149
|
raise RuntimeError("需要 websockets 库:pip install websockets")
|
|
157
150
|
import asyncio
|
|
158
151
|
async with websockets.connect(DISCORD_GATEWAY, max_size=None) as ws:
|
|
159
|
-
# 1. HELLO:拿心跳间隔
|
|
160
152
|
hello = json.loads(await ws.recv())
|
|
161
153
|
heartbeat_ms = hello["d"]["heartbeat_interval"]
|
|
162
|
-
# 2. IDENTIFY
|
|
163
154
|
await ws.send(json.dumps(self.build_identify()))
|
|
164
155
|
self._running = True
|
|
165
156
|
|
|
@@ -18,7 +18,7 @@ from . import InboundMessage, ChannelRegistry, ChannelAdapter
|
|
|
18
18
|
class ChannelDispatcher:
|
|
19
19
|
"""渠道闭环:把入站消息路由到主管,回复发回原渠道。"""
|
|
20
20
|
|
|
21
|
-
def __init__(self, supervisor, registry: ChannelRegistry = None,
|
|
21
|
+
def __init__(self, supervisor, registry: ChannelRegistry | None = None,
|
|
22
22
|
idempotency=None):
|
|
23
23
|
"""
|
|
24
24
|
supervisor: 主管 Agent(有 async run_task 方法)
|
|
@@ -27,7 +27,7 @@ class ChannelDispatcher:
|
|
|
27
27
|
"""
|
|
28
28
|
self.supervisor = supervisor
|
|
29
29
|
self.registry = registry or ChannelRegistry()
|
|
30
|
-
self._pending: set = set()
|
|
30
|
+
self._pending: set = set()
|
|
31
31
|
if idempotency is None:
|
|
32
32
|
from ..core.idempotency import IdempotencyGuard
|
|
33
33
|
idempotency = IdempotencyGuard(ttl_seconds=3600)
|
|
@@ -61,23 +61,18 @@ class ChannelDispatcher:
|
|
|
61
61
|
running = None
|
|
62
62
|
|
|
63
63
|
if running is not None:
|
|
64
|
-
# 在异步环境:发任务 + 持有引用(防止无引用 task 被 GC 静默回收)
|
|
65
64
|
task = running.create_task(self.handle(msg))
|
|
66
65
|
self._pending.add(task)
|
|
67
66
|
task.add_done_callback(self._pending.discard)
|
|
68
67
|
else:
|
|
69
|
-
# 同步环境(轮询渠道):跑到完成,调用方得到完整处理结果
|
|
70
68
|
asyncio.run(self.handle(msg))
|
|
71
69
|
|
|
72
70
|
async def handle(self, msg: InboundMessage) -> dict:
|
|
73
71
|
"""处理一条入站消息:主管思考 → 回复原渠道。返回处理结果。"""
|
|
74
|
-
# 幂等检查:同一渠道+消息ID只处理一次(防平台重发/轮询游标边界导致重复回复)
|
|
75
72
|
from ..core.idempotency import make_key
|
|
76
73
|
if msg.message_id:
|
|
77
74
|
idem_key = make_key("message", msg.channel, msg.message_id, msg.sender_id)
|
|
78
75
|
else:
|
|
79
|
-
# 无稳定 message_id 的渠道/消息:用 渠道+发送者+内容+时间窗(10秒)
|
|
80
|
-
# 算 fallback key——退化但仍能挡住短时间内的重发,不留盲区。
|
|
81
76
|
import time as _t
|
|
82
77
|
window = int(_t.time() // 10)
|
|
83
78
|
idem_key = make_key("message_fallback", msg.channel, msg.sender_id,
|
|
@@ -86,7 +81,6 @@ class ChannelDispatcher:
|
|
|
86
81
|
return {"handled": True, "replied": False, "reason": "重复消息,已跳过",
|
|
87
82
|
"idempotent_skip": True}
|
|
88
83
|
|
|
89
|
-
# 交给主管处理,带上渠道上下文(便于主管知道来源)
|
|
90
84
|
context = {
|
|
91
85
|
"channel": msg.channel,
|
|
92
86
|
"session_id": self._session_key(msg),
|
|
@@ -94,14 +88,7 @@ class ChannelDispatcher:
|
|
|
94
88
|
"sender_name": msg.sender_name,
|
|
95
89
|
"is_group": msg.is_group,
|
|
96
90
|
}
|
|
97
|
-
# 渠道入站文本来自外部(Telegram/Discord/…),默认按**不可信数据**处理:
|
|
98
|
-
# 此前 msg.text 原样当任务喂主管,等于把任意外部聊天用户当指令源(无发送者
|
|
99
|
-
# 白名单、无 <untrusted> 包裹)。可信发送者用 FOXCLAW_CHANNEL_TRUSTED_SENDERS
|
|
100
|
-
# 显式声明,逗号分隔,支持 `sender_id` 或 `channel:sender_id`,`*` 为该渠道全信任。
|
|
101
91
|
context["sender_trusted"] = self._sender_trusted(msg)
|
|
102
|
-
# 下传给权限判定:不可信来源驱动的任务里,写/发送/外发动作一律按「已接触敏感数据」
|
|
103
|
-
# 处理(见 agents/_context._untrusted_get → tool_exec._guard_tool / skillbox)。
|
|
104
|
-
# 只影响带此键的入口(渠道),本地 HTTP 对话不带 → 行为不变。
|
|
105
92
|
context["untrusted_source"] = not context["sender_trusted"]
|
|
106
93
|
text = msg.text
|
|
107
94
|
if not context["sender_trusted"]:
|
|
@@ -114,12 +101,10 @@ class ChannelDispatcher:
|
|
|
114
101
|
except Exception as e:
|
|
115
102
|
result = {"error": f"主管处理失败: {e}"}
|
|
116
103
|
|
|
117
|
-
# 提取回复文本
|
|
118
104
|
reply_text = self._extract_reply(result)
|
|
119
105
|
if not reply_text:
|
|
120
106
|
return {"handled": True, "replied": False, "reason": "无回复内容"}
|
|
121
107
|
|
|
122
|
-
# 通过原渠道回复
|
|
123
108
|
adapter = self.registry.get(msg.channel)
|
|
124
109
|
if adapter is None:
|
|
125
110
|
return {"handled": True, "replied": False, "reason": f"渠道 {msg.channel} 未注册"}
|
|
@@ -141,9 +126,6 @@ class ChannelDispatcher:
|
|
|
141
126
|
return False
|
|
142
127
|
sid = str(getattr(msg, "sender_id", "") or "")
|
|
143
128
|
chan = str(getattr(msg, "channel", "") or "")
|
|
144
|
-
# 裸 `*` 曾等于「所有渠道的所有发送者都可信」——一次手滑就把任意外部聊天用户提成
|
|
145
|
-
# 指令源,而列表里看不出它比 channel:sender_id 宽多少。现在必须二选一:
|
|
146
|
-
# 写 `channel:*`(限定渠道,推荐),或用 FOXCLAW_CHANNEL_TRUST_ALL=1 显式确认全局信任。
|
|
147
129
|
trust_all = _os.environ.get("FOXCLAW_CHANNEL_TRUST_ALL", "").strip() in ("1", "true", "True")
|
|
148
130
|
for item in (x.strip() for x in raw.split(",")):
|
|
149
131
|
if not item:
|
|
@@ -169,16 +151,12 @@ class ChannelDispatcher:
|
|
|
169
151
|
return str(result) if result else ""
|
|
170
152
|
|
|
171
153
|
inner = result.get("result")
|
|
172
|
-
# 情况1:result 直接是字符串
|
|
173
154
|
if isinstance(inner, str) and inner.strip():
|
|
174
155
|
return inner
|
|
175
|
-
# 情况2:result 是嵌套字典(主管分诊结果)
|
|
176
156
|
if isinstance(inner, dict):
|
|
177
|
-
# 单 Agent:sub_result
|
|
178
157
|
sub = inner.get("sub_result")
|
|
179
158
|
if isinstance(sub, str) and sub.strip():
|
|
180
159
|
return sub
|
|
181
|
-
# 多 Agent:拼接每步输出
|
|
182
160
|
steps = inner.get("multi_steps")
|
|
183
161
|
if isinstance(steps, list) and steps:
|
|
184
162
|
parts = []
|
|
@@ -188,17 +166,14 @@ class ChannelDispatcher:
|
|
|
188
166
|
parts.append(str(out))
|
|
189
167
|
if parts:
|
|
190
168
|
return "\n\n".join(parts)
|
|
191
|
-
# 元问题自答等:answer / summary
|
|
192
169
|
for k in ("answer", "summary", "response"):
|
|
193
170
|
v = inner.get(k)
|
|
194
171
|
if isinstance(v, str) and v.strip():
|
|
195
172
|
return v
|
|
196
|
-
# 情况3:顶层的 summary/response/output
|
|
197
173
|
for key in ("summary", "response", "output"):
|
|
198
174
|
val = result.get(key)
|
|
199
175
|
if isinstance(val, str) and val.strip():
|
|
200
176
|
return val
|
|
201
|
-
# 出错兜底
|
|
202
177
|
if result.get("error"):
|
|
203
178
|
return f"抱歉,处理时遇到问题:{result['error']}"
|
|
204
179
|
return ""
|
|
@@ -30,15 +30,14 @@ class FeishuAdapter(ChannelAdapter):
|
|
|
30
30
|
super().__init__(on_message=on_message)
|
|
31
31
|
self.app_id = app_id
|
|
32
32
|
self.app_secret = app_secret
|
|
33
|
-
self.verification_token = verification_token
|
|
34
|
-
self.encrypt_key = encrypt_key
|
|
33
|
+
self.verification_token = verification_token
|
|
34
|
+
self.encrypt_key = encrypt_key
|
|
35
35
|
self.base = FEISHU_BASE if domain == "feishu" else LARK_BASE
|
|
36
36
|
self.timeout = timeout
|
|
37
|
-
self._token = ""
|
|
38
|
-
self._token_exp = 0.0
|
|
39
|
-
self._last_error = ""
|
|
37
|
+
self._token = ""
|
|
38
|
+
self._token_exp = 0.0
|
|
39
|
+
self._last_error = ""
|
|
40
40
|
|
|
41
|
-
# ── 入站:事件订阅 ──────────────────────────────────────
|
|
42
41
|
def decrypt_event(self, encrypt: str) -> Optional[dict]:
|
|
43
42
|
"""解密飞书加密事件(AES-256-CBC,key=sha256(encrypt_key))。
|
|
44
43
|
|
|
@@ -60,7 +59,6 @@ class FeishuAdapter(ChannelAdapter):
|
|
|
60
59
|
plain = self._aes_cbc_decrypt(key, iv, ciphertext)
|
|
61
60
|
if plain is None:
|
|
62
61
|
return None
|
|
63
|
-
# PKCS7 去 padding(带校验,防损坏数据)
|
|
64
62
|
if not plain:
|
|
65
63
|
return None
|
|
66
64
|
pad = plain[-1]
|
|
@@ -76,7 +74,6 @@ class FeishuAdapter(ChannelAdapter):
|
|
|
76
74
|
|
|
77
75
|
def _aes_cbc_decrypt(self, key: bytes, iv: bytes, ciphertext: bytes) -> Optional[bytes]:
|
|
78
76
|
"""AES-256-CBC 解密。优先用 cryptography,回退 pycryptodome。"""
|
|
79
|
-
# 优先 cryptography(更常见)
|
|
80
77
|
try:
|
|
81
78
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
82
79
|
cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
|
|
@@ -85,7 +82,7 @@ class FeishuAdapter(ChannelAdapter):
|
|
|
85
82
|
except ImportError:
|
|
86
83
|
pass
|
|
87
84
|
try:
|
|
88
|
-
from Crypto.Cipher import AES
|
|
85
|
+
from Crypto.Cipher import AES
|
|
89
86
|
return AES.new(key, AES.MODE_CBC, iv).decrypt(ciphertext)
|
|
90
87
|
except ImportError:
|
|
91
88
|
self._last_error = ("缺少 AES 库,无法解密飞书加密事件。"
|
|
@@ -99,12 +96,12 @@ class FeishuAdapter(ChannelAdapter):
|
|
|
99
96
|
signature: str) -> bool:
|
|
100
97
|
"""验签:sha256(timestamp + nonce + encrypt_key + body)。"""
|
|
101
98
|
if not self.encrypt_key:
|
|
102
|
-
return True
|
|
99
|
+
return True
|
|
103
100
|
content = (timestamp + nonce + self.encrypt_key).encode() + body
|
|
104
101
|
digest = hashlib.sha256(content).hexdigest()
|
|
105
102
|
return digest == signature
|
|
106
103
|
|
|
107
|
-
def handle_event(self, payload: dict, headers: dict = None,
|
|
104
|
+
def handle_event(self, payload: dict, headers: dict | None = None,
|
|
108
105
|
raw_body: bytes = b"") -> dict:
|
|
109
106
|
"""处理飞书事件回调入口。返回应答给飞书的 dict。
|
|
110
107
|
|
|
@@ -114,23 +111,19 @@ class FeishuAdapter(ChannelAdapter):
|
|
|
114
111
|
- 验签 → normalize → on_message
|
|
115
112
|
"""
|
|
116
113
|
headers = headers or {}
|
|
117
|
-
# 1. 加密:先解密外层
|
|
118
114
|
if "encrypt" in payload:
|
|
119
115
|
dec = self.decrypt_event(payload["encrypt"])
|
|
120
116
|
if dec is None:
|
|
121
117
|
return {"error": "decrypt_failed"}
|
|
122
118
|
payload = dec
|
|
123
|
-
# 2. URL 验证(首次配置回调地址时)
|
|
124
119
|
if payload.get("type") == "url_verification":
|
|
125
120
|
return {"challenge": payload.get("challenge", "")}
|
|
126
|
-
# 3. 验签(若有签名头)
|
|
127
121
|
sig = headers.get("X-Lark-Signature") or headers.get("x-lark-signature")
|
|
128
122
|
if sig:
|
|
129
123
|
ts = headers.get("X-Lark-Request-Timestamp", "")
|
|
130
124
|
nonce = headers.get("X-Lark-Request-Nonce", "")
|
|
131
125
|
if not self.verify_signature(ts, nonce, raw_body, sig):
|
|
132
126
|
return {"error": "invalid_signature"}
|
|
133
|
-
# 4. 消息事件 → normalize → 回调
|
|
134
127
|
inbound = self.normalize(payload)
|
|
135
128
|
if inbound and self.on_message:
|
|
136
129
|
self.on_message(inbound)
|
|
@@ -145,7 +138,6 @@ class FeishuAdapter(ChannelAdapter):
|
|
|
145
138
|
message = event.get("message", {})
|
|
146
139
|
sender = event.get("sender", {})
|
|
147
140
|
sender_sid = sender.get("sender_id", {})
|
|
148
|
-
# 飞书消息内容是 JSON 字符串,文本在 {"text": "..."}
|
|
149
141
|
text = ""
|
|
150
142
|
try:
|
|
151
143
|
import json
|
|
@@ -155,19 +147,18 @@ class FeishuAdapter(ChannelAdapter):
|
|
|
155
147
|
pass
|
|
156
148
|
if not text:
|
|
157
149
|
return None
|
|
158
|
-
chat_type = message.get("chat_type", "p2p")
|
|
150
|
+
chat_type = message.get("chat_type", "p2p")
|
|
159
151
|
return InboundMessage(
|
|
160
152
|
channel=self.CHANNEL,
|
|
161
153
|
message_id=message.get("message_id", ""),
|
|
162
154
|
text=text,
|
|
163
155
|
sender_id=sender_sid.get("open_id", ""),
|
|
164
|
-
sender_name="",
|
|
156
|
+
sender_name="",
|
|
165
157
|
conversation_id=message.get("chat_id", ""),
|
|
166
158
|
is_group=(chat_type == "group"),
|
|
167
159
|
timestamp=float(message.get("create_time", 0)) / 1000 or time.time(),
|
|
168
160
|
raw=raw)
|
|
169
161
|
|
|
170
|
-
# ── 出站:tenant_access_token + 发消息 ──────────────────
|
|
171
162
|
def _get_token(self) -> str:
|
|
172
163
|
"""取 tenant_access_token,带缓存与自动刷新(过期前 5 分钟刷新)。"""
|
|
173
164
|
if self._token and time.time() < self._token_exp - 300:
|
|
@@ -197,7 +188,6 @@ class FeishuAdapter(ChannelAdapter):
|
|
|
197
188
|
token = self._get_token()
|
|
198
189
|
if not token:
|
|
199
190
|
return SendResult(False, error="获取 tenant_access_token 失败")
|
|
200
|
-
# receive_id_type 用 chat_id(群和私聊的会话 ID 都用 chat_id 发)
|
|
201
191
|
payload = {
|
|
202
192
|
"receive_id": msg.conversation_id,
|
|
203
193
|
"msg_type": "text",
|
|
@@ -47,13 +47,12 @@ class OpenClawBridgeAdapter(ChannelAdapter):
|
|
|
47
47
|
关键:保留 OpenClaw 侧的 channel(真实渠道,如 wechat)和 conversation 引用,
|
|
48
48
|
以便回复时让 OpenClaw 发回正确的原渠道。
|
|
49
49
|
"""
|
|
50
|
-
# OpenClaw 消息常见结构:{channel, message:{id,text}, sender:{id,name}, conversation:{id,isGroup}}
|
|
51
50
|
msg = raw.get("message") or raw
|
|
52
51
|
if not isinstance(msg, dict):
|
|
53
52
|
return None
|
|
54
53
|
text = msg.get("text") or msg.get("content") or ""
|
|
55
54
|
if not text:
|
|
56
|
-
return None
|
|
55
|
+
return None
|
|
57
56
|
sender = raw.get("sender") or msg.get("sender") or {}
|
|
58
57
|
conv = raw.get("conversation") or msg.get("conversation") or {}
|
|
59
58
|
return InboundMessage(
|
|
@@ -64,7 +63,7 @@ class OpenClawBridgeAdapter(ChannelAdapter):
|
|
|
64
63
|
sender_name=str(sender.get("name") or sender.get("username") or ""),
|
|
65
64
|
conversation_id=str(conv.get("id") or conv.get("conversation_id") or ""),
|
|
66
65
|
is_group=bool(conv.get("isGroup") or conv.get("is_group") or False),
|
|
67
|
-
raw=raw)
|
|
66
|
+
raw=raw)
|
|
68
67
|
|
|
69
68
|
def _upstream_channel(self, inbound_raw: dict) -> str:
|
|
70
69
|
return (inbound_raw.get("channel")
|
|
@@ -86,21 +85,17 @@ class OpenClawBridgeAdapter(ChannelAdapter):
|
|
|
86
85
|
}
|
|
87
86
|
if msg.reply_to:
|
|
88
87
|
payload["reply_to"] = msg.reply_to
|
|
89
|
-
# 若上层指明了真实渠道(从入站 raw 透传),带上,确保发回正确渠道
|
|
90
88
|
if msg.extra.get("upstream_channel"):
|
|
91
89
|
payload["channel"] = msg.extra["upstream_channel"]
|
|
92
90
|
headers = {}
|
|
93
91
|
if self.api_token:
|
|
94
92
|
headers["Authorization"] = f"Bearer {self.api_token}"
|
|
95
|
-
# 先序列化成 bytes 再发:签名必须覆盖**实际发出去的字节**,否则网关侧
|
|
96
|
-
# 重新序列化(键序/转义差异)会导致验签失败。content= 而非 json=,
|
|
97
|
-
# 就是为了保证"签的就是发的"。
|
|
98
93
|
try:
|
|
99
94
|
body = json.dumps(payload, ensure_ascii=False,
|
|
100
95
|
sort_keys=True).encode("utf-8")
|
|
101
96
|
except Exception as e:
|
|
102
97
|
return SendResult(False, error=f"出站 payload 序列化失败: {e}")
|
|
103
|
-
headers["Content-Type"] = "application/json"
|
|
98
|
+
headers["Content-Type"] = "application/json"
|
|
104
99
|
if self.signer is not None and self.signer.enabled:
|
|
105
100
|
try:
|
|
106
101
|
headers.update(self.signer.headers(body))
|
|
@@ -130,7 +125,7 @@ class OpenClawBridgeAdapter(ChannelAdapter):
|
|
|
130
125
|
|
|
131
126
|
def verify_webhook(self, headers, body: bytes,
|
|
132
127
|
tolerance: float = _signing.DEFAULT_TOLERANCE,
|
|
133
|
-
replay: "_signing.ReplayGuard" = None):
|
|
128
|
+
replay: "_signing.ReplayGuard | None" = None):
|
|
134
129
|
"""验 OpenClaw 转发来的 webhook 签名,返回 (是否通过, 原因码)。
|
|
135
130
|
|
|
136
131
|
与出站签名同一套 canonical 结构、同一个共享密钥(`signing.verify`)。
|
|
@@ -41,7 +41,7 @@ KEY_ID_HEADER = "X-Foxclaw-Key-Id"
|
|
|
41
41
|
ENV_SECRET = "FOXCLAW_CHANNEL_SIGNING_SECRET"
|
|
42
42
|
ENV_KEY_ID = "FOXCLAW_CHANNEL_SIGNING_KEY_ID"
|
|
43
43
|
|
|
44
|
-
DEFAULT_TOLERANCE = 300.0
|
|
44
|
+
DEFAULT_TOLERANCE = 300.0
|
|
45
45
|
|
|
46
46
|
|
|
47
47
|
def body_digest(body: bytes) -> str:
|
|
@@ -70,10 +70,10 @@ class ReplayGuard:
|
|
|
70
70
|
def __init__(self, ttl: float = DEFAULT_TOLERANCE, max_entries: int = 4096):
|
|
71
71
|
self.ttl = float(ttl)
|
|
72
72
|
self.max_entries = max(16, int(max_entries))
|
|
73
|
-
self._seen: dict[str, float] = {}
|
|
73
|
+
self._seen: dict[str, float] = {}
|
|
74
74
|
self._lock = threading.Lock()
|
|
75
75
|
|
|
76
|
-
def purge(self, now: float = None) -> int:
|
|
76
|
+
def purge(self, now: float | None = None) -> int:
|
|
77
77
|
"""清掉过期 nonce,返回清理条数。"""
|
|
78
78
|
cut = (now if now is not None else time.time()) - self.ttl
|
|
79
79
|
with self._lock:
|
|
@@ -82,10 +82,10 @@ class ReplayGuard:
|
|
|
82
82
|
self._seen.pop(k, None)
|
|
83
83
|
return len(dead)
|
|
84
84
|
|
|
85
|
-
def seen(self, nonce: str, now: float = None) -> bool:
|
|
85
|
+
def seen(self, nonce: str, now: float | None = None) -> bool:
|
|
86
86
|
"""见过 → True(**这是重放**);没见过则登记并返回 False。"""
|
|
87
87
|
if not nonce:
|
|
88
|
-
return True
|
|
88
|
+
return True
|
|
89
89
|
now = now if now is not None else time.time()
|
|
90
90
|
with self._lock:
|
|
91
91
|
cut = now - self.ttl
|
|
@@ -115,7 +115,7 @@ class OutboundSigner:
|
|
|
115
115
|
def enabled(self) -> bool:
|
|
116
116
|
return bool(self.secret)
|
|
117
117
|
|
|
118
|
-
def headers(self, body: bytes, nonce: str = "", now: float = None) -> dict:
|
|
118
|
+
def headers(self, body: bytes, nonce: str = "", now: float | None = None) -> dict:
|
|
119
119
|
"""生成签名头。未配置密钥直接抛错(fail-closed:调用方必须先判 `enabled`)。"""
|
|
120
120
|
if not self.enabled:
|
|
121
121
|
raise ValueError("未配置渠道签名密钥(FOXCLAW_CHANNEL_SIGNING_SECRET)")
|
|
@@ -145,8 +145,8 @@ def _header(headers: Mapping, name: str) -> str:
|
|
|
145
145
|
|
|
146
146
|
|
|
147
147
|
def verify(headers: Mapping, body: bytes, secret: str,
|
|
148
|
-
tolerance: float = DEFAULT_TOLERANCE, replay: ReplayGuard = None,
|
|
149
|
-
now: float = None) -> Tuple[bool, str]:
|
|
148
|
+
tolerance: float = DEFAULT_TOLERANCE, replay: ReplayGuard | None = None,
|
|
149
|
+
now: float | None = None) -> Tuple[bool, str]:
|
|
150
150
|
"""验签。返回 (是否通过, 原因码)。
|
|
151
151
|
|
|
152
152
|
原因码:``ok`` / ``missing_secret`` / ``missing_header`` / ``bad_version`` /
|
|
@@ -178,7 +178,7 @@ def verify(headers: Mapping, body: bytes, secret: str,
|
|
|
178
178
|
return True, "ok"
|
|
179
179
|
|
|
180
180
|
|
|
181
|
-
def from_env(env: Mapping = None) -> Optional[OutboundSigner]:
|
|
181
|
+
def from_env(env: Mapping | None = None) -> Optional[OutboundSigner]:
|
|
182
182
|
"""按环境变量构造签名器;未配置密钥 → None(不签,opt-in)。"""
|
|
183
183
|
env = env if env is not None else os.environ
|
|
184
184
|
secret = (env.get(ENV_SECRET) or "").strip()
|
|
@@ -22,7 +22,7 @@ class TelegramAdapter(ChannelAdapter):
|
|
|
22
22
|
super().__init__(on_message=on_message)
|
|
23
23
|
self.bot_token = bot_token
|
|
24
24
|
self.timeout = timeout
|
|
25
|
-
self._offset = 0
|
|
25
|
+
self._offset = 0
|
|
26
26
|
|
|
27
27
|
@property
|
|
28
28
|
def _base(self) -> str:
|
|
@@ -32,7 +32,7 @@ class TelegramAdapter(ChannelAdapter):
|
|
|
32
32
|
"""Telegram update → 标准 InboundMessage。非文本消息返回 None。"""
|
|
33
33
|
msg = raw.get("message") or raw.get("edited_message")
|
|
34
34
|
if not msg or "text" not in msg:
|
|
35
|
-
return None
|
|
35
|
+
return None
|
|
36
36
|
chat = msg.get("chat", {})
|
|
37
37
|
sender = msg.get("from", {})
|
|
38
38
|
chat_type = chat.get("type", "private")
|
|
@@ -97,11 +97,10 @@ class TelegramAdapter(ChannelAdapter):
|
|
|
97
97
|
return []
|
|
98
98
|
out = []
|
|
99
99
|
for update in data.get("result", []):
|
|
100
|
-
# 推进游标:下次从最大 update_id + 1 开始,避免重复
|
|
101
100
|
uid = update.get("update_id", 0)
|
|
102
101
|
if uid >= self._offset:
|
|
103
102
|
self._offset = uid + 1
|
|
104
|
-
inbound = self.handle_raw(update)
|
|
103
|
+
inbound = self.handle_raw(update)
|
|
105
104
|
if inbound:
|
|
106
105
|
out.append(inbound)
|
|
107
106
|
return out
|
|
@@ -22,20 +22,17 @@ import secrets
|
|
|
22
22
|
from typing import Optional
|
|
23
23
|
from . import ChannelAdapter, InboundMessage, OutboundMessage, SendResult
|
|
24
24
|
|
|
25
|
-
# 官方网关与端点(来自 Tencent/openclaw-weixin 源码 src/auth/accounts.ts)
|
|
26
25
|
WEIXIN_BASE_URL = "https://ilinkai.weixin.qq.com"
|
|
27
26
|
EP_GET_UPDATES = "ilink/bot/getupdates"
|
|
28
27
|
EP_SEND_MESSAGE = "ilink/bot/sendmessage"
|
|
29
28
|
EP_GET_CONFIG = "ilink/bot/getconfig"
|
|
30
29
|
EP_SEND_TYPING = "ilink/bot/sendtyping"
|
|
31
30
|
|
|
32
|
-
# 消息 item 类型(来自 README MessageItem 表)
|
|
33
31
|
ITEM_TEXT = 1
|
|
34
32
|
ITEM_IMAGE = 2
|
|
35
33
|
ITEM_VOICE = 3
|
|
36
34
|
ITEM_FILE = 4
|
|
37
35
|
ITEM_VIDEO = 5
|
|
38
|
-
# message_type
|
|
39
36
|
MSG_FROM_USER = 1
|
|
40
37
|
MSG_FROM_BOT = 2
|
|
41
38
|
|
|
@@ -55,8 +52,7 @@ class WeixinAdapter(ChannelAdapter):
|
|
|
55
52
|
self.ilink_app_id = ilink_app_id
|
|
56
53
|
self.long_poll_timeout_ms = long_poll_timeout_ms
|
|
57
54
|
self.timeout = timeout
|
|
58
|
-
self._cursor = ""
|
|
59
|
-
# 记录每个用户最近的 context_token(回复时必须带回)
|
|
55
|
+
self._cursor = ""
|
|
60
56
|
self._context_tokens: dict[str, str] = {}
|
|
61
57
|
|
|
62
58
|
@staticmethod
|
|
@@ -69,7 +65,6 @@ class WeixinAdapter(ChannelAdapter):
|
|
|
69
65
|
major = minor = patch = 0
|
|
70
66
|
return ((major & 0xFF) << 16) | ((minor & 0xFF) << 8) | (patch & 0xFF)
|
|
71
67
|
|
|
72
|
-
# ── 鉴权头(照官方 buildHeaders)──────────────────────
|
|
73
68
|
@staticmethod
|
|
74
69
|
def _random_wechat_uin() -> str:
|
|
75
70
|
"""X-WECHAT-UIN: 随机 uint32 → 十进制字符串 → base64。"""
|
|
@@ -91,7 +86,7 @@ class WeixinAdapter(ChannelAdapter):
|
|
|
91
86
|
def _base_info(self) -> dict:
|
|
92
87
|
return {"channel_version": self.channel_version, "bot_agent": self.bot_agent}
|
|
93
88
|
|
|
94
|
-
def _post(self, endpoint: str, body: dict, timeout: float = None):
|
|
89
|
+
def _post(self, endpoint: str, body: dict, timeout: float | None = None):
|
|
95
90
|
import httpx
|
|
96
91
|
url = f"{self.base_url}/{endpoint}"
|
|
97
92
|
payload = {**body, "base_info": self._base_info()}
|
|
@@ -100,7 +95,6 @@ class WeixinAdapter(ChannelAdapter):
|
|
|
100
95
|
timeout=timeout or self.timeout)
|
|
101
96
|
return resp
|
|
102
97
|
|
|
103
|
-
# ── 入站:getUpdates 长轮询 ────────────────────────────
|
|
104
98
|
def poll_once(self) -> list[InboundMessage]:
|
|
105
99
|
"""长轮询拉一批新消息。宿主在循环里反复调用即可持续接收。"""
|
|
106
100
|
try:
|
|
@@ -114,7 +108,7 @@ class WeixinAdapter(ChannelAdapter):
|
|
|
114
108
|
{"get_updates_buf": self._cursor},
|
|
115
109
|
timeout=self.long_poll_timeout_ms / 1000 + 5)
|
|
116
110
|
except Exception:
|
|
117
|
-
return []
|
|
111
|
+
return []
|
|
118
112
|
if resp.status_code != 200:
|
|
119
113
|
return []
|
|
120
114
|
try:
|
|
@@ -122,8 +116,7 @@ class WeixinAdapter(ChannelAdapter):
|
|
|
122
116
|
except Exception:
|
|
123
117
|
return []
|
|
124
118
|
if data.get("ret") != 0:
|
|
125
|
-
return []
|
|
126
|
-
# 推进游标
|
|
119
|
+
return []
|
|
127
120
|
new_buf = data.get("get_updates_buf")
|
|
128
121
|
if new_buf is not None:
|
|
129
122
|
self._cursor = new_buf
|
|
@@ -141,20 +134,18 @@ class WeixinAdapter(ChannelAdapter):
|
|
|
141
134
|
return self._normalize_msg(raw)
|
|
142
135
|
|
|
143
136
|
def _normalize_msg(self, msg: dict) -> Optional[InboundMessage]:
|
|
144
|
-
# 只处理用户发来的消息(message_type=1),忽略 bot 自己的回显
|
|
145
137
|
if msg.get("message_type") not in (None, MSG_FROM_USER):
|
|
146
138
|
return None
|
|
147
|
-
# 从 item_list 提取文本
|
|
148
139
|
text = ""
|
|
149
140
|
for item in msg.get("item_list", []) or []:
|
|
150
141
|
if item.get("type") == ITEM_TEXT and item.get("text_item"):
|
|
151
142
|
text += item["text_item"].get("text", "")
|
|
152
143
|
if not text:
|
|
153
|
-
return None
|
|
144
|
+
return None
|
|
154
145
|
from_user = str(msg.get("from_user_id", ""))
|
|
155
146
|
ctx = msg.get("context_token", "")
|
|
156
147
|
if from_user and ctx:
|
|
157
|
-
self._context_tokens[from_user] = ctx
|
|
148
|
+
self._context_tokens[from_user] = ctx
|
|
158
149
|
return InboundMessage(
|
|
159
150
|
channel=self.CHANNEL,
|
|
160
151
|
message_id=str(msg.get("message_id", "")),
|
|
@@ -162,11 +153,10 @@ class WeixinAdapter(ChannelAdapter):
|
|
|
162
153
|
sender_id=from_user,
|
|
163
154
|
sender_name="",
|
|
164
155
|
conversation_id=str(msg.get("session_id", "") or from_user),
|
|
165
|
-
is_group=False,
|
|
156
|
+
is_group=False,
|
|
166
157
|
timestamp=float(msg.get("create_time_ms", 0)) / 1000 or 0.0,
|
|
167
158
|
raw=msg)
|
|
168
159
|
|
|
169
|
-
# ── 出站:sendMessage ──────────────────────────────────
|
|
170
160
|
def send(self, msg: OutboundMessage) -> SendResult:
|
|
171
161
|
"""发文本消息。conversation_id 作为 to_user_id;context_token 从记录里取或由 extra 传入。"""
|
|
172
162
|
try:
|